Compare commits

..

No commits in common. "baca3202627cd77eca0332aa26af43fa4b35e685" and "b0dc9fca4d421476e23c3ccb1c74493612610066" have entirely different histories.

146 changed files with 375 additions and 30604 deletions

View file

@ -1,259 +0,0 @@
/**
* PlantLineageAgent API Tests
* Tests for lineage agent API endpoints
*/
import { PlantLineageAgent, getPlantLineageAgent } from '../../../lib/agents';
import { getBlockchain, initializeBlockchain } from '../../../lib/blockchain/manager';
describe('PlantLineageAgent API', () => {
let agent: PlantLineageAgent;
beforeEach(() => {
// Get fresh agent instance
agent = getPlantLineageAgent();
});
afterEach(async () => {
// Ensure agent is stopped after each test
if (agent.status === 'running') {
await agent.stop();
}
});
describe('GET /api/agents/lineage', () => {
it('should return agent status and configuration', () => {
expect(agent.config.id).toBe('plant-lineage-agent');
expect(agent.config.name).toBe('Plant Lineage Agent');
expect(agent.config.description).toBeDefined();
expect(agent.config.priority).toBe('high');
expect(agent.config.intervalMs).toBe(60000);
});
it('should return current metrics', () => {
const metrics = agent.getMetrics();
expect(metrics.agentId).toBe('plant-lineage-agent');
expect(metrics.tasksCompleted).toBeGreaterThanOrEqual(0);
expect(metrics.tasksFailed).toBeGreaterThanOrEqual(0);
expect(metrics.averageExecutionMs).toBeGreaterThanOrEqual(0);
});
it('should return network statistics', () => {
const networkStats = agent.getNetworkStats();
expect(networkStats.totalPlants).toBeGreaterThanOrEqual(0);
expect(networkStats.totalLineages).toBeGreaterThanOrEqual(0);
expect(networkStats.avgGenerationDepth).toBeGreaterThanOrEqual(0);
expect(networkStats.avgLineageSize).toBeGreaterThanOrEqual(0);
expect(networkStats.geographicSpread).toBeGreaterThanOrEqual(0);
});
it('should return anomaly summary', () => {
const anomalies = agent.getAnomalies();
expect(Array.isArray(anomalies)).toBe(true);
});
});
describe('POST /api/agents/lineage', () => {
it('should start agent successfully', async () => {
expect(agent.status).toBe('idle');
await agent.start();
expect(agent.status).toBe('running');
});
it('should stop agent successfully', async () => {
await agent.start();
expect(agent.status).toBe('running');
await agent.stop();
expect(agent.status).toBe('idle');
});
it('should pause and resume agent', async () => {
await agent.start();
expect(agent.status).toBe('running');
agent.pause();
expect(agent.status).toBe('paused');
agent.resume();
expect(agent.status).toBe('running');
});
it('should handle start when already running', async () => {
await agent.start();
const firstStatus = agent.status;
await agent.start(); // Should not throw
expect(agent.status).toBe(firstStatus);
});
});
describe('GET /api/agents/lineage/[plantId]', () => {
it('should return null for non-existent plant analysis', () => {
const analysis = agent.getLineageAnalysis('non-existent-plant-id');
expect(analysis).toBeNull();
});
it('should return analysis structure when available', () => {
// Analysis would be populated after agent runs
// For now, test the structure expectations
const analysis = agent.getLineageAnalysis('test-plant-id');
// Should return null for non-cached plant
expect(analysis).toBeNull();
});
});
describe('GET /api/agents/lineage/anomalies', () => {
it('should return empty array when no anomalies', () => {
const anomalies = agent.getAnomalies();
expect(Array.isArray(anomalies)).toBe(true);
});
it('should support filtering by severity', () => {
const allAnomalies = agent.getAnomalies();
const highSeverity = allAnomalies.filter(a => a.severity === 'high');
const mediumSeverity = allAnomalies.filter(a => a.severity === 'medium');
const lowSeverity = allAnomalies.filter(a => a.severity === 'low');
expect(highSeverity.length + mediumSeverity.length + lowSeverity.length).toBe(allAnomalies.length);
});
it('should support filtering by type', () => {
const allAnomalies = agent.getAnomalies();
const validTypes = ['orphan', 'circular', 'invalid_generation', 'missing_parent', 'suspicious_location'];
for (const anomaly of allAnomalies) {
expect(validTypes).toContain(anomaly.type);
}
});
});
describe('Agent Lifecycle', () => {
it('should track uptime correctly', async () => {
const initialMetrics = agent.getMetrics();
const initialUptime = initialMetrics.uptime;
await agent.start();
// Wait a bit
await new Promise(resolve => setTimeout(resolve, 100));
const runningMetrics = agent.getMetrics();
expect(runningMetrics.uptime).toBeGreaterThan(initialUptime);
});
it('should maintain metrics across start/stop cycles', async () => {
await agent.start();
await agent.stop();
const metrics = agent.getMetrics();
expect(metrics.uptime).toBeGreaterThan(0);
});
});
describe('Alert Management', () => {
it('should return alerts array', () => {
const alerts = agent.getAlerts();
expect(Array.isArray(alerts)).toBe(true);
});
it('should have proper alert structure', () => {
const alerts = agent.getAlerts();
for (const alert of alerts) {
expect(alert.id).toBeDefined();
expect(alert.agentId).toBe('plant-lineage-agent');
expect(alert.severity).toBeDefined();
expect(alert.title).toBeDefined();
expect(alert.message).toBeDefined();
expect(alert.timestamp).toBeDefined();
expect(typeof alert.acknowledged).toBe('boolean');
}
});
});
describe('Error Handling', () => {
it('should handle agent operations gracefully', async () => {
// Test that agent doesn't throw on basic operations
expect(() => agent.getMetrics()).not.toThrow();
expect(() => agent.getAnomalies()).not.toThrow();
expect(() => agent.getNetworkStats()).not.toThrow();
expect(() => agent.getAlerts()).not.toThrow();
});
it('should handle pause on non-running agent', () => {
expect(agent.status).toBe('idle');
agent.pause(); // Should not throw
// Status should remain idle (only pauses if running)
expect(agent.status).toBe('idle');
});
it('should handle resume on non-paused agent', () => {
expect(agent.status).toBe('idle');
agent.resume(); // Should not throw
// Status should remain idle (only resumes if paused)
expect(agent.status).toBe('idle');
});
});
describe('Response Format', () => {
it('should return consistent metrics format', () => {
const metrics = agent.getMetrics();
expect(typeof metrics.agentId).toBe('string');
expect(typeof metrics.tasksCompleted).toBe('number');
expect(typeof metrics.tasksFailed).toBe('number');
expect(typeof metrics.averageExecutionMs).toBe('number');
expect(typeof metrics.uptime).toBe('number');
expect(Array.isArray(metrics.errors)).toBe(true);
});
it('should return consistent network stats format', () => {
const stats = agent.getNetworkStats();
expect(typeof stats.totalPlants).toBe('number');
expect(typeof stats.totalLineages).toBe('number');
expect(typeof stats.avgGenerationDepth).toBe('number');
expect(typeof stats.avgLineageSize).toBe('number');
expect(typeof stats.geographicSpread).toBe('number');
});
});
});
describe('PlantLineageAgent Integration', () => {
it('should be accessible via singleton', () => {
const agent1 = getPlantLineageAgent();
const agent2 = getPlantLineageAgent();
expect(agent1).toBe(agent2);
});
it('should have correct priority', () => {
const agent = getPlantLineageAgent();
expect(agent.config.priority).toBe('high');
});
it('should have correct interval', () => {
const agent = getPlantLineageAgent();
// Should run every minute
expect(agent.config.intervalMs).toBe(60000);
});
});

View file

@ -1,215 +0,0 @@
/**
* GrowerAdvisoryAgent Tests
* Tests for the grower advisory and recommendation system
*/
import {
GrowerAdvisoryAgent,
getGrowerAdvisoryAgent,
} from '../../../lib/agents/GrowerAdvisoryAgent';
describe('GrowerAdvisoryAgent', () => {
let agent: GrowerAdvisoryAgent;
beforeEach(() => {
agent = new GrowerAdvisoryAgent();
});
describe('Initialization', () => {
it('should create agent with correct configuration', () => {
expect(agent.config.id).toBe('grower-advisory-agent');
expect(agent.config.name).toBe('Grower Advisory Agent');
expect(agent.config.enabled).toBe(true);
expect(agent.config.priority).toBe('high');
});
it('should have correct interval (5 minutes)', () => {
expect(agent.config.intervalMs).toBe(300000);
});
it('should start in idle status', () => {
expect(agent.status).toBe('idle');
});
it('should have empty metrics initially', () => {
const metrics = agent.getMetrics();
expect(metrics.tasksCompleted).toBe(0);
expect(metrics.tasksFailed).toBe(0);
expect(metrics.errors).toEqual([]);
});
});
describe('Grower Profile Management', () => {
it('should register a grower profile', () => {
const profile = createGrowerProfile('grower-1');
agent.registerGrowerProfile(profile);
const retrieved = agent.getGrowerProfile('grower-1');
expect(retrieved).not.toBeNull();
expect(retrieved?.growerId).toBe('grower-1');
});
it('should return null for unknown grower', () => {
const retrieved = agent.getGrowerProfile('unknown-grower');
expect(retrieved).toBeNull();
});
it('should update existing profile', () => {
const profile1 = createGrowerProfile('grower-1');
profile1.experienceLevel = 'beginner';
agent.registerGrowerProfile(profile1);
const profile2 = createGrowerProfile('grower-1');
profile2.experienceLevel = 'expert';
agent.registerGrowerProfile(profile2);
const retrieved = agent.getGrowerProfile('grower-1');
expect(retrieved?.experienceLevel).toBe('expert');
});
});
describe('Recommendations', () => {
it('should return empty recommendations for unknown grower', () => {
const recs = agent.getRecommendations('unknown-grower');
expect(recs).toEqual([]);
});
it('should get recommendations after profile registration', () => {
const profile = createGrowerProfile('grower-1');
agent.registerGrowerProfile(profile);
// Recommendations are generated during runOnce
const recs = agent.getRecommendations('grower-1');
expect(Array.isArray(recs)).toBe(true);
});
});
describe('Rotation Advice', () => {
it('should return null for unknown grower', () => {
const advice = agent.getRotationAdvice('unknown-grower');
expect(advice).toBeNull();
});
});
describe('Market Opportunities', () => {
it('should return array of opportunities', () => {
const opps = agent.getOpportunities();
expect(Array.isArray(opps)).toBe(true);
});
});
describe('Grower Performance', () => {
it('should return null for unknown grower', () => {
const perf = agent.getPerformance('unknown-grower');
expect(perf).toBeNull();
});
});
describe('Seasonal Alerts', () => {
it('should return array of seasonal alerts', () => {
const alerts = agent.getSeasonalAlerts();
expect(Array.isArray(alerts)).toBe(true);
});
});
describe('Agent Lifecycle', () => {
it('should start and change status to running', async () => {
await agent.start();
expect(agent.status).toBe('running');
await agent.stop();
});
it('should stop and change status to idle', async () => {
await agent.start();
await agent.stop();
expect(agent.status).toBe('idle');
});
it('should pause when running', async () => {
await agent.start();
agent.pause();
expect(agent.status).toBe('paused');
await agent.stop();
});
it('should resume after pause', async () => {
await agent.start();
agent.pause();
agent.resume();
expect(agent.status).toBe('running');
await agent.stop();
});
});
describe('Singleton', () => {
it('should return same instance from getGrowerAdvisoryAgent', () => {
const agent1 = getGrowerAdvisoryAgent();
const agent2 = getGrowerAdvisoryAgent();
expect(agent1).toBe(agent2);
});
});
describe('Alerts', () => {
it('should return alerts array', () => {
const alerts = agent.getAlerts();
expect(Array.isArray(alerts)).toBe(true);
});
});
describe('Task Execution', () => {
it('should execute runOnce successfully', async () => {
const profile = createGrowerProfile('grower-1');
agent.registerGrowerProfile(profile);
const result = await agent.runOnce();
expect(result).not.toBeNull();
expect(result?.status).toBe('completed');
expect(result?.type).toBe('grower_advisory');
});
it('should report metrics in task result', async () => {
const profile = createGrowerProfile('grower-1');
agent.registerGrowerProfile(profile);
const result = await agent.runOnce();
expect(result?.result).toHaveProperty('growersAdvised');
expect(result?.result).toHaveProperty('recommendationsGenerated');
expect(result?.result).toHaveProperty('opportunitiesIdentified');
expect(result?.result).toHaveProperty('alertsGenerated');
});
it('should count registered growers', async () => {
agent.registerGrowerProfile(createGrowerProfile('grower-1'));
agent.registerGrowerProfile(createGrowerProfile('grower-2'));
agent.registerGrowerProfile(createGrowerProfile('grower-3'));
const result = await agent.runOnce();
expect(result?.result?.growersAdvised).toBe(3);
});
});
});
// Helper function to create test grower profiles
function createGrowerProfile(
growerId: string,
lat: number = 40.7128,
lon: number = -74.006
) {
return {
growerId,
growerName: `Test Grower ${growerId}`,
location: { latitude: lat, longitude: lon },
availableSpaceSqm: 100,
specializations: ['lettuce', 'tomato'],
certifications: ['organic'],
experienceLevel: 'intermediate' as const,
preferredCrops: ['lettuce', 'tomato', 'basil'],
growingHistory: [
{ cropType: 'lettuce', successRate: 85, avgYield: 4.5 },
{ cropType: 'tomato', successRate: 75, avgYield: 8.0 },
],
};
}

494
bun.lock
View file

@ -5,29 +5,31 @@
"": {
"name": "localgreenchain",
"dependencies": {
"@aws-sdk/client-s3": "^3.937.0",
"@aws-sdk/s3-request-presigner": "^3.937.0",
"@tailwindcss/forms": "^0.4.0",
"@tailwindcss/typography": "^0.5.1",
"@tanstack/react-query": "^4.0.10",
"classnames": "^2.3.1",
"d3": "^7.9.0",
"date-fns": "^4.1.0",
"drupal-jsonapi-params": "^1.2.2",
"html-react-parser": "^1.2.7",
"multer": "^2.0.2",
"next": "^12.2.3",
"next-drupal": "^1.6.0",
"nprogress": "^0.2.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-hook-form": "^7.8.6",
"recharts": "^3.4.1",
"sharp": "^0.34.5",
"socks-proxy-agent": "^8.0.2",
},
"devDependencies": {
"@babel/core": "^7.12.9",
"@types/d3": "^7.4.3",
"@types/jest": "^29.5.0",
"@types/multer": "^2.0.0",
"@types/node": "^17.0.21",
"@types/react": "^17.0.0",
"@types/sharp": "^0.32.0",
"autoprefixer": "^10.4.2",
"eslint-config-next": "^12.0.10",
"jest": "^29.5.0",
@ -41,6 +43,90 @@
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
"@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="],
"@aws-crypto/sha1-browser": ["@aws-crypto/sha1-browser@5.2.0", "", { "dependencies": { "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg=="],
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
"@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
"@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="],
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.937.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.936.0", "@aws-sdk/credential-provider-node": "3.936.0", "@aws-sdk/middleware-bucket-endpoint": "3.936.0", "@aws-sdk/middleware-expect-continue": "3.936.0", "@aws-sdk/middleware-flexible-checksums": "3.936.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-location-constraint": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-sdk-s3": "3.936.0", "@aws-sdk/middleware-ssec": "3.936.0", "@aws-sdk/middleware-user-agent": "3.936.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/signature-v4-multi-region": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.936.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.5", "@smithy/eventstream-serde-browser": "^4.2.5", "@smithy/eventstream-serde-config-resolver": "^4.3.5", "@smithy/eventstream-serde-node": "^4.2.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-blob-browser": "^4.2.6", "@smithy/hash-node": "^4.2.5", "@smithy/hash-stream-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/md5-js": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-retry": "^4.4.12", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.11", "@smithy/util-defaults-mode-node": "^4.2.14", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "@smithy/util-waiter": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-ioeNe6HSc7PxjsUQY7foSHmgesxM5KwAeUtPhIHgKx99nrM+7xYCfW4FMvHypUzz7ZOvqlCdH7CEAZ8ParBvVg=="],
"@aws-sdk/client-sso": ["@aws-sdk/client-sso@3.936.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.936.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-user-agent": "3.936.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.936.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-retry": "^4.4.12", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.11", "@smithy/util-defaults-mode-node": "^4.2.14", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-0G73S2cDqYwJVvqL08eakj79MZG2QRaB56Ul8/Ps9oQxllr7DMI1IQ/N3j3xjxgpq/U36pkoFZ8aK1n7Sbr3IQ=="],
"@aws-sdk/core": ["@aws-sdk/core@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@aws-sdk/xml-builder": "3.930.0", "@smithy/core": "^3.18.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/property-provider": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/signature-v4": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-eGJ2ySUMvgtOziHhDRDLCrj473RJoL4J1vPjVM3NrKC/fF3/LoHjkut8AAnKmrW6a2uTzNKubigw8dEnpmpERw=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.936.0", "", { "dependencies": { "@aws-sdk/core": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-dKajFuaugEA5i9gCKzOaVy9uTeZcApE+7Z5wdcZ6j40523fY1a56khDAUYkCfwqa7sHci4ccmxBkAo+fW1RChA=="],
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.936.0", "", { "dependencies": { "@aws-sdk/core": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/node-http-handler": "^4.4.5", "@smithy/property-provider": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/util-stream": "^4.5.6", "tslib": "^2.6.2" } }, "sha512-5FguODLXG1tWx/x8fBxH+GVrk7Hey2LbXV5h9SFzYCx/2h50URBm0+9hndg0Rd23+xzYe14F6SI9HA9c1sPnjg=="],
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.936.0", "", { "dependencies": { "@aws-sdk/core": "3.936.0", "@aws-sdk/credential-provider-env": "3.936.0", "@aws-sdk/credential-provider-http": "3.936.0", "@aws-sdk/credential-provider-login": "3.936.0", "@aws-sdk/credential-provider-process": "3.936.0", "@aws-sdk/credential-provider-sso": "3.936.0", "@aws-sdk/credential-provider-web-identity": "3.936.0", "@aws-sdk/nested-clients": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/credential-provider-imds": "^4.2.5", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-TbUv56ERQQujoHcLMcfL0Q6bVZfYF83gu/TjHkVkdSlHPOIKaG/mhE2XZSQzXv1cud6LlgeBbfzVAxJ+HPpffg=="],
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.936.0", "", { "dependencies": { "@aws-sdk/core": "3.936.0", "@aws-sdk/nested-clients": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-8DVrdRqPyUU66gfV7VZNToh56ZuO5D6agWrkLQE/xbLJOm2RbeRgh6buz7CqV8ipRd6m+zCl9mM4F3osQLZn8Q=="],
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.936.0", "", { "dependencies": { "@aws-sdk/credential-provider-env": "3.936.0", "@aws-sdk/credential-provider-http": "3.936.0", "@aws-sdk/credential-provider-ini": "3.936.0", "@aws-sdk/credential-provider-process": "3.936.0", "@aws-sdk/credential-provider-sso": "3.936.0", "@aws-sdk/credential-provider-web-identity": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/credential-provider-imds": "^4.2.5", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-rk/2PCtxX9xDsQW8p5Yjoca3StqmQcSfkmD7nQ61AqAHL1YgpSQWqHE+HjfGGiHDYKG7PvE33Ku2GyA7lEIJAw=="],
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.936.0", "", { "dependencies": { "@aws-sdk/core": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-GpA4AcHb96KQK2PSPUyvChvrsEKiLhQ5NWjeef2IZ3Jc8JoosiedYqp6yhZR+S8cTysuvx56WyJIJc8y8OTrLA=="],
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.936.0", "", { "dependencies": { "@aws-sdk/client-sso": "3.936.0", "@aws-sdk/core": "3.936.0", "@aws-sdk/token-providers": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-wHlEAJJvtnSyxTfNhN98JcU4taA1ED2JvuI2eePgawqBwS/Tzi0mhED1lvNIaWOkjfLd+nHALwszGrtJwEq4yQ=="],
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.936.0", "", { "dependencies": { "@aws-sdk/core": "3.936.0", "@aws-sdk/nested-clients": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-v3qHAuoODkoRXsAF4RG+ZVO6q2P9yYBT4GMpMEfU9wXVNn7AIfwZgTwzSUfnjNiGva5BKleWVpRpJ9DeuLFbUg=="],
"@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@aws-sdk/util-arn-parser": "3.893.0", "@smithy/node-config-provider": "^4.3.5", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "@smithy/util-config-provider": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-XLSVVfAorUxZh6dzF+HTOp4R1B5EQcdpGcPliWr0KUj2jukgjZEcqbBmjyMF/p9bmyQsONX80iURF1HLAlW0qg=="],
"@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-Eb4ELAC23bEQLJmUMYnPWcjD3FZIsmz2svDiXEcxRkQU9r7NRID7pM7C5NPH94wOfiCk0b2Y8rVyFXW0lGQwbA=="],
"@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.936.0", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/is-array-buffer": "^4.2.0", "@smithy/node-config-provider": "^4.3.5", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-l3GG6CrSQtMCM6fWY7foV3JQv0WJWT+3G6PSP3Ceb/KEE/5Lz5PrYFXTBf+bVoYL1b0bGjGajcgAXpstBmtHtQ=="],
"@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-tAaObaAnsP1XnLGndfkGWFuzrJYuk9W0b/nLvol66t8FZExIAf/WdkT2NNAWOYxljVs++oHnyHBCxIlaHrzSiw=="],
"@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-SCMPenDtQMd9o5da9JzkHz838w3327iqXk3cbNnXWqnNRx6unyW8FL0DZ84gIY12kAyVHz5WEqlWuekc15ehfw=="],
"@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-aPSJ12d3a3Ea5nyEnLbijCaaYJT2QjQ9iW+zGh5QcZYXmOGWbKVyPSxmVOboZQG+c1M8t6d2O7tqrwzIq8L8qw=="],
"@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@aws/lambda-invoke-store": "^0.2.0", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-l4aGbHpXM45YNgXggIux1HgsCVAvvBoqHPkqLnqMl9QVapfuSTjJHfDYDsx1Xxct6/m7qSMUzanBALhiaGO2fA=="],
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.936.0", "", { "dependencies": { "@aws-sdk/core": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-arn-parser": "3.893.0", "@smithy/core": "^3.18.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/protocol-http": "^5.3.5", "@smithy/signature-v4": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-UQs/pVq4cOygsnKON0pOdSKIWkfgY0dzq4h+fR+xHi/Ng3XzxPJhWeAE6tDsKrcyQc1X8UdSbS70XkfGYr5hng=="],
"@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-/GLC9lZdVp05ozRik5KsuODR/N7j+W+2TbfdFL3iS+7un+gnP6hC8RDOZd6WhpZp7drXQ9guKiTAxkZQwzS8DA=="],
"@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.936.0", "", { "dependencies": { "@aws-sdk/core": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@smithy/core": "^3.18.5", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-YB40IPa7K3iaYX0lSnV9easDOLPLh+fJyUDF3BH8doX4i1AOSsYn86L4lVldmOaSX+DwiaqKHpvk4wPBdcIPWw=="],
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.936.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.936.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-user-agent": "3.936.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.936.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-retry": "^4.4.12", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.11", "@smithy/util-defaults-mode-node": "^4.2.14", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-eyj2tz1XmDSLSZQ5xnB7cLTVKkSJnYAEoNDSUNhzWPxrBDYeJzIbatecOKceKCU8NBf8gWWZCK/CSY0mDxMO0A=="],
"@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/config-resolver": "^4.4.3", "@smithy/node-config-provider": "^4.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-wOKhzzWsshXGduxO4pqSiNyL9oUtk4BEvjWm9aaq6Hmfdoydq6v6t0rAGHWPjFwy9z2haovGRi3C8IxdMB4muw=="],
"@aws-sdk/s3-request-presigner": ["@aws-sdk/s3-request-presigner@3.937.0", "", { "dependencies": { "@aws-sdk/signature-v4-multi-region": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-format-url": "3.936.0", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-AvsCt6FnnKTpkmzDA1pFzmXPyxbGBdtllOIY0mL1iNSVZ3d7SoJKZH4NaqlcgUtbYG9zVh6QfLWememj1yEAmw=="],
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.936.0", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/protocol-http": "^5.3.5", "@smithy/signature-v4": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-8qS0GFUqkmwO7JZ0P8tdluBmt1UTfYUah8qJXGzNh9n1Pcb0AIeT117cCSiCUtwk+gDbJvd4hhRIhJCNr5wgjg=="],
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.936.0", "", { "dependencies": { "@aws-sdk/core": "3.936.0", "@aws-sdk/nested-clients": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-vvw8+VXk0I+IsoxZw0mX9TMJawUJvEsg3EF7zcCSetwhNPAU8Xmlhv7E/sN/FgSmm7b7DsqKoW6rVtQiCs1PWQ=="],
"@aws-sdk/types": ["@aws-sdk/types@3.936.0", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-uz0/VlMd2pP5MepdrHizd+T+OKfyK4r3OA9JI+L/lPKg0YFQosdJNCKisr6o70E3dh8iMpFYxF1UN/4uZsyARg=="],
"@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.893.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-u8H4f2Zsi19DGnwj5FSZzDMhytYF/bCh37vAtBsn3cNDL3YG578X5oc+wSX54pM3tOxS+NY7tvOAo52SW7koUA=="],
"@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-endpoints": "^3.2.5", "tslib": "^2.6.2" } }, "sha512-0Zx3Ntdpu+z9Wlm7JKUBOzS9EunwKAb4KdGUQQxDqh5Lc3ta5uBoub+FgmVuzwnmBu9U1Os8UuwVTH0Lgu+P5w=="],
"@aws-sdk/util-format-url": ["@aws-sdk/util-format-url@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/querystring-builder": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-MS5eSEtDUFIAMHrJaMERiHAvDPdfxc/T869ZjDNFAIiZhyc037REw0aoTNeimNXDNy2txRNZJaAUn/kE4RwN+g=="],
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.893.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-T89pFfgat6c8nMmpI8eKjBcDcgJq36+m9oiXbcUzeU55MP9ZuGgBomGjGnHaEyF36jenW9gmg3NfZDm0AO2XPg=="],
"@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/types": "^4.9.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-eZ/XF6NxMtu+iCma58GRNRxSq4lHo6zHQLOZRIeL/ghqYJirqHdenMOwrzPettj60KWlv827RVebP9oNVrwZbw=="],
"@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.936.0", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/node-config-provider": "^4.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-XOEc7PF9Op00pWV2AYCGDSu5iHgYjIO53Py2VUQTIvP7SRCaCsXmA33mjBvC2Ms6FhSyWNa4aK4naUGIz0hQcw=="],
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.930.0", "", { "dependencies": { "@smithy/types": "^4.9.0", "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" } }, "sha512-YIfkD17GocxdmlUVc3ia52QhcWuRIUJonbF8A2CYfcWNV3HzvAqpcPeC0bYUhkK+8e8YO1ARnLKZQE0TlwzorA=="],
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.1", "", {}, "sha512-sIyFcoPZkTtNu9xFeEoynMef3bPJIAbOfUh+ueYcfhVl6xm2VRtMcMclSxmZCMnHHd4hlYKJeq/aggmBEWynww=="],
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="],
@ -111,6 +197,8 @@
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@0.2.3", "", {}, "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw=="],
"@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
@ -125,6 +213,56 @@
"@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@2.0.3", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="],
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@istanbuljs/load-nyc-config": ["@istanbuljs/load-nyc-config@1.1.0", "", { "dependencies": { "camelcase": "^5.3.1", "find-up": "^4.1.0", "get-package-type": "^0.1.0", "js-yaml": "^3.13.1", "resolve-from": "^5.0.0" } }, "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ=="],
"@istanbuljs/schema": ["@istanbuljs/schema@0.1.3", "", {}, "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA=="],
@ -203,8 +341,6 @@
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.10.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^10.2.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-/U17EXQ9Do9Yx4DlNGU6eVNfZvFJfYpUtRRdLf19PbPjdWBxNlxGZXywQZ1p1Nz8nMkWplTI7iD/23m07nolDA=="],
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
"@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.15.0", "", {}, "sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw=="],
@ -215,9 +351,107 @@
"@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="],
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
"@smithy/abort-controller": ["@smithy/abort-controller@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-j7HwVkBw68YW8UmFRcjZOmssE77Rvk0GWAIN1oFBhsaovQmZWYCIcGa9/pwRB0ExI8Sk9MWNALTjftjHZea7VA=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA=="],
"@smithy/chunked-blob-reader-native": ["@smithy/chunked-blob-reader-native@4.2.1", "", { "dependencies": { "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ=="],
"@smithy/config-resolver": ["@smithy/config-resolver@4.4.3", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.5", "@smithy/types": "^4.9.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-ezHLe1tKLUxDJo2LHtDuEDyWXolw8WGOR92qb4bQdWq/zKenO5BvctZGrVJBK08zjezSk7bmbKFOXIVyChvDLw=="],
"@smithy/core": ["@smithy/core@3.18.5", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.6", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-6gnIz3h+PEPQGDj8MnRSjDvKBah042jEoPgjFGJ4iJLBE78L4lY/n98x14XyPF4u3lN179Ub/ZKFY5za9GeLQw=="],
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.5", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.5", "@smithy/property-provider": "^4.2.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-BZwotjoZWn9+36nimwm/OLIcVe+KYRwzMjfhd4QT7QxPm9WY0HiOV8t/Wlh+HVUif0SBVV7ksq8//hPaBC/okQ=="],
"@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.5", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.9.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-Ogt4Zi9hEbIP17oQMd68qYOHUzmH47UkK7q7Gl55iIm9oKt27MUGrC5JfpMroeHjdkOliOA4Qt3NQ1xMq/nrlA=="],
"@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.5", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-HohfmCQZjppVnKX2PnXlf47CW3j92Ki6T/vkAT2DhBR47e89pen3s4fIa7otGTtrVxmj7q+IhH0RnC5kpR8wtw=="],
"@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-ibjQjM7wEXtECiT6my1xfiMH9IcEczMOS6xiCQXoUIYSj5b1CpBbJ3VYbdwDy8Vcg5JHN7eFpOCGk8nyZAltNQ=="],
"@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.5", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-+elOuaYx6F2H6x1/5BQP5ugv12nfJl66GhxON8+dWVUEDJ9jah/A0tayVdkLRP0AeSac0inYkDz5qBFKfVp2Gg=="],
"@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.5", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-G9WSqbST45bmIFaeNuP/EnC19Rhp54CcVdX9PDL1zyEB514WsDVXhlyihKlGXnRycmHNmVv88Bvvt4EYxWef/Q=="],
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.6", "", { "dependencies": { "@smithy/protocol-http": "^5.3.5", "@smithy/querystring-builder": "^4.2.5", "@smithy/types": "^4.9.0", "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-3+RG3EA6BBJ/ofZUeTFJA7mHfSYrZtQIrDP9dI8Lf7X6Jbos2jptuLrAAteDiFVrmbEmLSuRG/bUKzfAXk7dhg=="],
"@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.6", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.0", "@smithy/chunked-blob-reader-native": "^4.2.1", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-8P//tA8DVPk+3XURk2rwcKgYwFvwGwmJH/wJqQiSKwXZtf/LiZK+hbUZmPj/9KzM+OVSwe4o85KTp5x9DUZTjw=="],
"@smithy/hash-node": ["@smithy/hash-node@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DpYX914YOfA3UDT9CN1BM787PcHfWRBB43fFGCYrZFUH0Jv+5t8yYl+Pd5PW4+QzoGEDvn5d5QIO4j2HyYZQSA=="],
"@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-6+do24VnEyvWcGdHXomlpd0m8bfZePpUKBy7m311n+JuRwug8J4dCanJdTymx//8mi0nlkflZBvJe+dEO/O12Q=="],
"@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-2L2erASEro1WC5nV+plwIMxrTXpvpfzl4e+Nre6vBVRR2HKeGGcvpJyyL3/PpiSg+cJG2KpTmZmq934Olb6e5A=="],
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ=="],
"@smithy/md5-js": ["@smithy/md5-js@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-Bt6jpSTMWfjCtC0s79gZ/WZ1w90grfmopVOWqkI2ovhjpD5Q2XRXuecIPB9689L2+cCySMbaXDhBPU56FKNDNg=="],
"@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.5", "", { "dependencies": { "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-Y/RabVa5vbl5FuHYV2vUCwvh/dqzrEY/K2yWPSqvhFUwIY0atLqO4TienjBXakoy4zrKAMCZwg+YEqmH7jaN7A=="],
"@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.3.12", "", { "dependencies": { "@smithy/core": "^3.18.5", "@smithy/middleware-serde": "^4.2.6", "@smithy/node-config-provider": "^4.3.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-middleware": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-9pAX/H+VQPzNbouhDhkW723igBMLgrI8OtX+++M7iKJgg/zY/Ig3i1e6seCcx22FWhE6Q/S61BRdi2wXBORT+A=="],
"@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.12", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.5", "@smithy/protocol-http": "^5.3.5", "@smithy/service-error-classification": "^4.2.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-S4kWNKFowYd0lID7/DBqWHOQxmxlsf0jBaos9chQZUWTVOjSW1Ogyh8/ib5tM+agFDJ/TCxuCTvrnlc+9cIBcQ=="],
"@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.6", "", { "dependencies": { "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-VkLoE/z7e2g8pirwisLz8XJWedUSY8my/qrp81VmAdyrhi94T+riBfwP+AOEEFR9rFTSonC/5D2eWNmFabHyGQ=="],
"@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-bYrutc+neOyWxtZdbB2USbQttZN0mXaOyYLIsaTbJhFsfpXyGWUxJpEuO1rJ8IIJm2qH4+xJT0mxUSsEDTYwdQ=="],
"@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.5", "", { "dependencies": { "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-UTurh1C4qkVCtqggI36DGbLB2Kv8UlcFdMXDcWMbqVY2uRg0XmT9Pb4Vj6oSQ34eizO1fvR0RnFV4Axw4IrrAg=="],
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.5", "", { "dependencies": { "@smithy/abort-controller": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/querystring-builder": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-CMnzM9R2WqlqXQGtIlsHMEZfXKJVTIrqCNoSd/QpAyp+Dw0a1Vps13l6ma1fH8g7zSPNsA59B/kWgeylFuA/lw=="],
"@smithy/property-provider": ["@smithy/property-provider@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-8iLN1XSE1rl4MuxvQ+5OSk/Zb5El7NJZ1td6Tn+8dQQHIjp59Lwl6bd0+nzw6SKm2wSSriH2v/I9LPzUic7EOg=="],
"@smithy/protocol-http": ["@smithy/protocol-http@5.3.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-RlaL+sA0LNMp03bf7XPbFmT5gN+w3besXSWMkA8rcmxLSVfiEXElQi4O2IWwPfxzcHkxqrwBFMbngB8yx/RvaQ=="],
"@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "@smithy/util-uri-escape": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-y98otMI1saoajeik2kLfGyRp11e5U/iJYH/wLCh3aTV/XutbGT9nziKGkgCaMD1ghK7p6htHMm6b6scl9JRUWg=="],
"@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-031WCTdPYgiQRYNPXznHXof2YM0GwL6SeaSyTH/P72M1Vz73TvCNH2Nq8Iu2IEPq9QP2yx0/nrw5YmSeAi/AjQ=="],
"@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0" } }, "sha512-8fEvK+WPE3wUAcDvqDQG1Vk3ANLR8Px979te96m84CbKAjBVf25rPYSzb4xU4hlTyho7VhOGnh5i62D/JVF0JQ=="],
"@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.0", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-5WmZ5+kJgJDjwXXIzr1vDTG+RhF9wzSODQBfkrQ2VVkYALKGvZX1lgVSxEkgicSAFnFhPj5rudJV0zoinqS0bA=="],
"@smithy/signature-v4": ["@smithy/signature-v4@5.3.5", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-uri-escape": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-xSUfMu1FT7ccfSXkoLl/QRQBi2rOvi3tiBZU2Tdy3I6cgvZ6SEi9QNey+lqps/sJRnogIS+lq+B1gxxbra2a/w=="],
"@smithy/smithy-client": ["@smithy/smithy-client@4.9.8", "", { "dependencies": { "@smithy/core": "^3.18.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-stack": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "@smithy/util-stream": "^4.5.6", "tslib": "^2.6.2" } }, "sha512-8xgq3LgKDEFoIrLWBho/oYKyWByw9/corz7vuh1upv7ZBm0ZMjGYBhbn6v643WoIqA9UTcx5A5htEp/YatUwMA=="],
"@smithy/types": ["@smithy/types@4.9.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-MvUbdnXDTwykR8cB1WZvNNwqoWVaTRA0RLlLmf/cIFNMM2cKWz01X4Ly6SMC4Kks30r8tT3Cty0jmeWfiuyHTA=="],
"@smithy/url-parser": ["@smithy/url-parser@4.2.5", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-VaxMGsilqFnK1CeBX+LXnSuaMx4sTL/6znSZh2829txWieazdVxr54HmiyTsIbpOTLcf5nYpq9lpzmwRdxj6rQ=="],
"@smithy/util-base64": ["@smithy/util-base64@4.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ=="],
"@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg=="],
"@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.2.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA=="],
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew=="],
"@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q=="],
"@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.11", "", { "dependencies": { "@smithy/property-provider": "^4.2.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-yHv+r6wSQXEXTPVCIQTNmXVWs7ekBTpMVErjqZoWkYN75HIFN5y9+/+sYOejfAuvxWGvgzgxbTHa/oz61YTbKw=="],
"@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.14", "", { "dependencies": { "@smithy/config-resolver": "^4.4.3", "@smithy/credential-provider-imds": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/property-provider": "^4.2.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-ljZN3iRvaJUgulfvobIuG97q1iUuCMrvXAlkZ4msY+ZuVHQHDIqn7FKZCEj+bx8omz6kF5yQXms/xhzjIO5XiA=="],
"@smithy/util-endpoints": ["@smithy/util-endpoints@3.2.5", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-3O63AAWu2cSNQZp+ayl9I3NapW1p1rR5mlVHcF6hAB1dPZUQFfRPYtplWX/3xrzWthPGj5FqB12taJJCfH6s8A=="],
"@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw=="],
"@smithy/util-middleware": ["@smithy/util-middleware@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-6Y3+rvBF7+PZOc40ybeZMcGln6xJGVeY60E7jy9Mv5iKpMJpHgRE6dKy9ScsVxvfAYuEX4Q9a65DQX90KaQ3bA=="],
"@smithy/util-retry": ["@smithy/util-retry@4.2.5", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-GBj3+EZBbN4NAqJ/7pAhsXdfzdlznOh8PydUijy6FpNIMnHPSMO2/rP4HKu+UFeikJxShERk528oy7GT79YiJg=="],
"@smithy/util-stream": ["@smithy/util-stream@4.5.6", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.6", "@smithy/node-http-handler": "^4.4.5", "@smithy/types": "^4.9.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-qWw/UM59TiaFrPevefOZ8CNBKbYEP6wBAIlLqxn3VAIo9rgnTNc4ASbVrqDmhuwI87usnjhdQrxodzAGFFzbRQ=="],
"@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA=="],
"@smithy/util-utf8": ["@smithy/util-utf8@4.2.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw=="],
"@smithy/util-waiter": ["@smithy/util-waiter@4.2.5", "", { "dependencies": { "@smithy/abort-controller": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-Dbun99A3InifQdIrsXZ+QLcC0PGBPAdrl4cj1mTgJvyc9N2zf7QSxg8TBkzsCmGJdE3TLbO9ycwpY0EkWahQ/g=="],
"@smithy/uuid": ["@smithy/uuid@1.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="],
"@swc/helpers": ["@swc/helpers@0.4.11", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-rEUrBSGIoSFuYxwBYtlUFMlE2CwGhmW+w9355/5oduSw8e5h2+Tj4UrAGNNgP9915++wj5vkQo0UuOBqOAq4nw=="],
@ -237,72 +471,18 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="],
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
"@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="],
"@types/express": ["@types/express@5.0.5", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^1" } }, "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ=="],
"@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="],
"@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="],
"@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="],
"@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="],
"@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="],
"@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="],
"@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="],
"@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="],
"@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="],
"@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="],
"@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="],
"@types/d3-random": ["@types/d3-random@3.0.3", "", {}, "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="],
"@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="],
"@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="],
"@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="],
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
"@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.0", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA=="],
"@types/graceful-fs": ["@types/graceful-fs@4.1.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ=="],
"@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
"@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="],
"@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="],
@ -313,17 +493,29 @@
"@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
"@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="],
"@types/multer": ["@types/multer@2.0.0", "", { "dependencies": { "@types/express": "*" } }, "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw=="],
"@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="],
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
"@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="],
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
"@types/react": ["@types/react@17.0.90", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "^0.16", "csstype": "^3.2.2" } }, "sha512-P9beVR/x06U9rCJzSxtENnOr4BrbJ6VrsrDTc+73TtHv9XHhryXKbjGRB+6oooB2r0G/pQkD/S4dHo/7jUfwFw=="],
"@types/scheduler": ["@types/scheduler@0.16.8", "", {}, "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A=="],
"@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="],
"@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
"@types/sharp": ["@types/sharp@0.32.0", "", { "dependencies": { "sharp": "*" } }, "sha512-OOi3kL+FZDnPhVzsfD37J88FNeZh6gQsGcLc95NbeURRGvmSjeXiDcyWzF2o3yh/gQAUn2uhh/e+CPCa5nwAxw=="],
"@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="],
"@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="],
@ -359,6 +551,8 @@
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"append-field": ["append-field@1.0.0", "", {}, "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="],
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
@ -411,6 +605,8 @@
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"bowser": ["bowser@2.12.1", "", {}, "sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
@ -423,6 +619,8 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="],
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
@ -453,8 +651,6 @@
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"co": ["co@4.6.0", "", {}, "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ=="],
"collect-v8-coverage": ["collect-v8-coverage@1.0.3", "", {}, "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw=="],
@ -467,6 +663,8 @@
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"create-jest": ["create-jest@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "chalk": "^4.0.0", "exit": "^0.1.2", "graceful-fs": "^4.2.9", "jest-config": "^29.7.0", "jest-util": "^29.7.0", "prompts": "^2.0.1" }, "bin": { "create-jest": "bin/create-jest.js" } }, "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q=="],
@ -477,68 +675,6 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="],
"d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="],
"d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="],
"d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="],
"d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
"d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="],
"d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="],
"d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
"d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="],
"d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="],
"d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="],
"d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],
"d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="],
"d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="],
"d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="],
"damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
@ -547,12 +683,8 @@
"data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"dedent": ["dedent@1.7.0", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
@ -563,7 +695,7 @@
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
"delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-newline": ["detect-newline@3.1.0", "", {}, "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA=="],
@ -615,8 +747,6 @@
"es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
"es-toolkit": ["es-toolkit@1.42.0", "", {}, "sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
@ -655,8 +785,6 @@
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"exit": ["exit@0.1.2", "", {}, "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ=="],
@ -671,6 +799,8 @@
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="],
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
"fb-watchman": ["fb-watchman@2.0.2", "", { "dependencies": { "bser": "2.1.1" } }, "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA=="],
@ -759,12 +889,8 @@
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"import-local": ["import-local@3.2.0", "", { "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" }, "bin": { "import-local-fixture": "fixtures/cli.js" } }, "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA=="],
@ -779,8 +905,6 @@
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
@ -969,12 +1093,18 @@
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"mini-svg-data-uri": ["mini-svg-data-uri@1.4.4", "", { "bin": { "mini-svg-data-uri": "cli.js" } }, "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg=="],
@ -983,8 +1113,12 @@
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"multer": ["multer@2.0.2", "", { "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "concat-stream": "^2.0.0", "mkdirp": "^0.5.6", "object-assign": "^4.1.1", "type-is": "^1.6.18", "xtend": "^4.0.2" } }, "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
@ -1109,26 +1243,18 @@
"react-property": ["react-property@2.0.0", "", {}, "sha512-kzmNjIgU32mO4mmH5+iUyrqlpFQhF8K2k7eZ4fdLSOPFrD1XgEuSBv9LDEgxRXTMBqMd8ppT0x6TIzqE5pdGdw=="],
"react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"recharts": ["recharts@3.4.1", "", { "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-35kYg6JoOgwq8sE4rhYkVWwa6aAIgOtT+Ob0gitnShjwUwZmhrmy7Jco/5kJNF4PnLXgt9Hwq+geEMS+WrjU1g=="],
"redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"resolve-cwd": ["resolve-cwd@3.0.0", "", { "dependencies": { "resolve-from": "^5.0.0" } }, "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg=="],
@ -1141,20 +1267,16 @@
"rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
"robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"scheduler": ["scheduler@0.20.2", "", { "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" } }, "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@ -1165,6 +1287,8 @@
"set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
@ -1201,6 +1325,8 @@
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
"streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="],
"string-length": ["string-length@4.0.2", "", { "dependencies": { "char-regex": "^1.0.2", "strip-ansi": "^6.0.0" } }, "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@ -1217,6 +1343,8 @@
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
@ -1225,6 +1353,8 @@
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="],
"style-to-js": ["style-to-js@1.1.1", "", { "dependencies": { "style-to-object": "0.3.0" } }, "sha512-RJ18Z9t2B02sYhZtfWKQq5uplVctgvjTfLWT7+Eb1zjUjIrWzX5SdlkwLGQozrqarTmEzJJ/YmdNJCUNI47elg=="],
"style-to-object": ["style-to-object@0.3.0", "", { "dependencies": { "inline-style-parser": "0.1.1" } }, "sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA=="],
@ -1247,8 +1377,6 @@
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tmpl": ["tmpl@1.0.5", "", {}, "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="],
@ -1271,6 +1399,8 @@
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
"typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="],
@ -1279,6 +1409,8 @@
"typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="],
"typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="],
"typescript": ["typescript@4.9.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g=="],
"uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="],
@ -1295,8 +1427,6 @@
"v8-to-istanbul": ["v8-to-istanbul@9.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^2.0.0" } }, "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA=="],
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
"walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@ -1319,6 +1449,8 @@
"write-file-atomic": ["write-file-atomic@4.0.2", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" } }, "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
@ -1329,6 +1461,12 @@
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
"@istanbuljs/load-nyc-config/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
@ -1339,6 +1477,8 @@
"@tailwindcss/typography/postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
"@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
@ -1349,8 +1489,6 @@
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
"dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],
"eslint/doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="],
@ -1395,6 +1533,8 @@
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"sharp/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
"string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@ -1411,12 +1551,24 @@
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@istanbuljs/load-nyc-config/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@istanbuljs/load-nyc-config/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],

View file

@ -28,11 +28,10 @@ export default function EnvironmentalForm({
section: K,
updates: Partial<GrowingEnvironment[K]>
) => {
const currentSection = value[section] || {};
onChange({
...value,
[section]: {
...(currentSection as object || {}),
...value[section],
...updates,
},
});

View file

@ -1,229 +0,0 @@
/**
* Data Table Component
* Sortable and filterable data table for analytics
*/
import { useState, useMemo } from 'react';
interface Column {
key: string;
header: string;
sortable?: boolean;
render?: (value: any, row: any) => React.ReactNode;
width?: string;
align?: 'left' | 'center' | 'right';
}
interface DataTableProps {
data: any[];
columns: Column[];
title?: string;
pageSize?: number;
showSearch?: boolean;
searchPlaceholder?: string;
}
type SortDirection = 'asc' | 'desc' | null;
export default function DataTable({
data,
columns,
title,
pageSize = 10,
showSearch = true,
searchPlaceholder = 'Search...',
}: DataTableProps) {
const [sortKey, setSortKey] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<SortDirection>(null);
const [search, setSearch] = useState('');
const [page, setPage] = useState(0);
const filteredData = useMemo(() => {
if (!search) return data;
const searchLower = search.toLowerCase();
return data.filter((row) =>
columns.some((col) => {
const value = row[col.key];
return String(value).toLowerCase().includes(searchLower);
})
);
}, [data, columns, search]);
const sortedData = useMemo(() => {
if (!sortKey || !sortDir) return filteredData;
return [...filteredData].sort((a, b) => {
const aVal = a[sortKey];
const bVal = b[sortKey];
if (aVal === bVal) return 0;
if (aVal === null || aVal === undefined) return 1;
if (bVal === null || bVal === undefined) return -1;
const comparison = aVal < bVal ? -1 : 1;
return sortDir === 'asc' ? comparison : -comparison;
});
}, [filteredData, sortKey, sortDir]);
const paginatedData = useMemo(() => {
const start = page * pageSize;
return sortedData.slice(start, start + pageSize);
}, [sortedData, page, pageSize]);
const totalPages = Math.ceil(sortedData.length / pageSize);
const handleSort = (key: string) => {
if (sortKey === key) {
if (sortDir === 'asc') setSortDir('desc');
else if (sortDir === 'desc') {
setSortKey(null);
setSortDir(null);
}
} else {
setSortKey(key);
setSortDir('asc');
}
};
const getSortIcon = (key: string) => {
if (sortKey !== key) {
return (
<svg className="w-4 h-4 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
);
}
if (sortDir === 'asc') {
return (
<svg className="w-4 h-4 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
);
}
return (
<svg className="w-4 h-4 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
);
};
const alignClasses = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
};
return (
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center justify-between">
{title && <h3 className="text-lg font-bold text-gray-900">{title}</h3>}
{showSearch && (
<div className="relative">
<input
type="text"
placeholder={searchPlaceholder}
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(0);
}}
className="pl-10 pr-4 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
/>
<svg
className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
)}
</div>
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
{columns.map((col) => (
<th
key={col.key}
className={`px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider ${
alignClasses[col.align || 'left']
} ${col.sortable !== false ? 'cursor-pointer hover:bg-gray-100' : ''}`}
style={{ width: col.width }}
onClick={() => col.sortable !== false && handleSort(col.key)}
>
<div className="flex items-center space-x-1">
<span>{col.header}</span>
{col.sortable !== false && getSortIcon(col.key)}
</div>
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{paginatedData.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-6 py-8 text-center text-gray-500">
No data available
</td>
</tr>
) : (
paginatedData.map((row, rowIndex) => (
<tr key={rowIndex} className="hover:bg-gray-50">
{columns.map((col) => (
<td
key={col.key}
className={`px-6 py-4 whitespace-nowrap text-sm text-gray-900 ${
alignClasses[col.align || 'left']
}`}
>
{col.render ? col.render(row[col.key], row) : row[col.key]}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
<span className="text-sm text-gray-500">
Showing {page * pageSize + 1} to {Math.min((page + 1) * pageSize, sortedData.length)} of{' '}
{sortedData.length} results
</span>
<div className="flex space-x-2">
<button
onClick={() => setPage(page - 1)}
disabled={page === 0}
className="px-3 py-1 border border-gray-200 rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
Previous
</button>
<button
onClick={() => setPage(page + 1)}
disabled={page >= totalPages - 1}
className="px-3 py-1 border border-gray-200 rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
Next
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -1,47 +0,0 @@
/**
* Date Range Picker Component
* Allows selection of time range for analytics
*/
import { TimeRange } from '../../lib/analytics/types';
interface DateRangePickerProps {
value: TimeRange;
onChange: (range: TimeRange) => void;
showCustom?: boolean;
}
const timeRangeOptions: { value: TimeRange; label: string }[] = [
{ value: '7d', label: 'Last 7 days' },
{ value: '30d', label: 'Last 30 days' },
{ value: '90d', label: 'Last 90 days' },
{ value: '365d', label: 'Last year' },
{ value: 'all', label: 'All time' },
];
export default function DateRangePicker({
value,
onChange,
showCustom = false,
}: DateRangePickerProps) {
return (
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-500">Time range:</span>
<div className="inline-flex rounded-lg border border-gray-200 bg-white">
{timeRangeOptions.map((option) => (
<button
key={option.value}
onClick={() => onChange(option.value)}
className={`px-4 py-2 text-sm font-medium transition-colors first:rounded-l-lg last:rounded-r-lg ${
value === option.value
? 'bg-green-500 text-white'
: 'text-gray-600 hover:bg-gray-50'
}`}
>
{option.label}
</button>
))}
</div>
</div>
);
}

View file

@ -1,165 +0,0 @@
/**
* Filter Panel Component
* Provides filtering options for analytics data
*/
import { useState } from 'react';
interface FilterOption {
value: string;
label: string;
}
interface FilterConfig {
key: string;
label: string;
type: 'select' | 'multiselect' | 'search';
options?: FilterOption[];
}
interface FilterPanelProps {
filters: FilterConfig[];
values: Record<string, any>;
onChange: (values: Record<string, any>) => void;
onReset?: () => void;
}
export default function FilterPanel({
filters,
values,
onChange,
onReset,
}: FilterPanelProps) {
const [isExpanded, setIsExpanded] = useState(false);
const handleChange = (key: string, value: any) => {
onChange({ ...values, [key]: value });
};
const handleMultiSelect = (key: string, value: string) => {
const current = values[key] || [];
const updated = current.includes(value)
? current.filter((v: string) => v !== value)
: [...current, value];
handleChange(key, updated);
};
const activeFilterCount = Object.values(values).filter(
(v) => v && (Array.isArray(v) ? v.length > 0 : true)
).length;
return (
<div className="bg-white rounded-lg shadow border border-gray-200">
{/* Header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50"
>
<div className="flex items-center space-x-2">
<svg
className="w-5 h-5 text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
/>
</svg>
<span className="font-medium text-gray-700">Filters</span>
{activeFilterCount > 0 && (
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded-full">
{activeFilterCount} active
</span>
)}
</div>
<svg
className={`w-5 h-5 text-gray-400 transform transition-transform ${
isExpanded ? 'rotate-180' : ''
}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Filter content */}
{isExpanded && (
<div className="px-4 py-4 border-t border-gray-200 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filters.map((filter) => (
<div key={filter.key}>
<label className="block text-sm font-medium text-gray-700 mb-1">
{filter.label}
</label>
{filter.type === 'select' && filter.options && (
<select
value={values[filter.key] || ''}
onChange={(e) => handleChange(filter.key, e.target.value || null)}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value="">All</option>
{filter.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
)}
{filter.type === 'multiselect' && filter.options && (
<div className="flex flex-wrap gap-2">
{filter.options.map((opt) => (
<button
key={opt.value}
onClick={() => handleMultiSelect(filter.key, opt.value)}
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
(values[filter.key] || []).includes(opt.value)
? 'bg-green-500 text-white border-green-500'
: 'bg-white text-gray-600 border-gray-200 hover:border-green-300'
}`}
>
{opt.label}
</button>
))}
</div>
)}
{filter.type === 'search' && (
<input
type="text"
value={values[filter.key] || ''}
onChange={(e) => handleChange(filter.key, e.target.value || null)}
placeholder={`Search ${filter.label.toLowerCase()}...`}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
/>
)}
</div>
))}
</div>
{/* Actions */}
<div className="flex justify-end space-x-2 pt-2 border-t border-gray-100">
{onReset && (
<button
onClick={onReset}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800"
>
Reset filters
</button>
)}
<button
onClick={() => setIsExpanded(false)}
className="px-4 py-2 text-sm bg-green-500 text-white rounded-lg hover:bg-green-600"
>
Apply
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -1,129 +0,0 @@
/**
* KPI Card Component
* Displays key performance indicators with trend indicators
*/
import { TrendDirection } from '../../lib/analytics/types';
interface KPICardProps {
title: string;
value: number | string;
unit?: string;
change?: number;
changePercent?: number;
trend?: TrendDirection;
color?: 'green' | 'blue' | 'purple' | 'orange' | 'red' | 'teal';
icon?: React.ReactNode;
loading?: boolean;
}
const colorClasses = {
green: {
bg: 'bg-green-50',
text: 'text-green-600',
icon: 'text-green-500',
},
blue: {
bg: 'bg-blue-50',
text: 'text-blue-600',
icon: 'text-blue-500',
},
purple: {
bg: 'bg-purple-50',
text: 'text-purple-600',
icon: 'text-purple-500',
},
orange: {
bg: 'bg-orange-50',
text: 'text-orange-600',
icon: 'text-orange-500',
},
red: {
bg: 'bg-red-50',
text: 'text-red-600',
icon: 'text-red-500',
},
teal: {
bg: 'bg-teal-50',
text: 'text-teal-600',
icon: 'text-teal-500',
},
};
export default function KPICard({
title,
value,
unit,
change,
changePercent,
trend = 'stable',
color = 'green',
icon,
loading = false,
}: KPICardProps) {
const classes = colorClasses[color];
const getTrendIcon = () => {
if (trend === 'up') {
return (
<svg className="w-4 h-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
);
}
if (trend === 'down') {
return (
<svg className="w-4 h-4 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
);
}
return (
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14" />
</svg>
);
};
const getTrendColor = () => {
if (trend === 'up') return 'text-green-600';
if (trend === 'down') return 'text-red-600';
return 'text-gray-500';
};
if (loading) {
return (
<div className={`${classes.bg} rounded-lg p-6 animate-pulse`}>
<div className="h-4 bg-gray-200 rounded w-1/2 mb-4"></div>
<div className="h-8 bg-gray-200 rounded w-3/4 mb-2"></div>
<div className="h-3 bg-gray-200 rounded w-1/3"></div>
</div>
);
}
return (
<div className={`${classes.bg} rounded-lg p-6 transition-all hover:shadow-md`}>
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium text-gray-600">{title}</p>
{icon && <span className={classes.icon}>{icon}</span>}
</div>
<div className="flex items-baseline space-x-2">
<p className={`text-3xl font-bold ${classes.text}`}>{value}</p>
{unit && <span className="text-sm text-gray-500">{unit}</span>}
</div>
{(change !== undefined || changePercent !== undefined) && (
<div className={`flex items-center mt-2 space-x-1 ${getTrendColor()}`}>
{getTrendIcon()}
<span className="text-sm font-medium">
{changePercent !== undefined
? `${changePercent > 0 ? '+' : ''}${changePercent.toFixed(1)}%`
: change !== undefined
? `${change > 0 ? '+' : ''}${change}`
: ''}
</span>
<span className="text-xs text-gray-500">vs prev period</span>
</div>
)}
</div>
);
}

View file

@ -1,105 +0,0 @@
/**
* Trend Indicator Component
* Shows trend direction with visual indicators
*/
import { TrendDirection } from '../../lib/analytics/types';
interface TrendIndicatorProps {
direction: TrendDirection;
value?: number;
showLabel?: boolean;
size?: 'sm' | 'md' | 'lg';
}
const sizeClasses = {
sm: 'w-3 h-3',
md: 'w-4 h-4',
lg: 'w-5 h-5',
};
const textSizeClasses = {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base',
};
export default function TrendIndicator({
direction,
value,
showLabel = false,
size = 'md',
}: TrendIndicatorProps) {
const iconSize = sizeClasses[size];
const textSize = textSizeClasses[size];
const getColor = () => {
switch (direction) {
case 'up':
return 'text-green-500';
case 'down':
return 'text-red-500';
default:
return 'text-gray-400';
}
};
const getBgColor = () => {
switch (direction) {
case 'up':
return 'bg-green-100';
case 'down':
return 'bg-red-100';
default:
return 'bg-gray-100';
}
};
const getIcon = () => {
switch (direction) {
case 'up':
return (
<svg className={iconSize} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 11l5-5m0 0l5 5m-5-5v12" />
</svg>
);
case 'down':
return (
<svg className={iconSize} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 13l-5 5m0 0l-5-5m5 5V6" />
</svg>
);
default:
return (
<svg className={iconSize} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14" />
</svg>
);
}
};
const getLabel = () => {
switch (direction) {
case 'up':
return 'Increasing';
case 'down':
return 'Decreasing';
default:
return 'Stable';
}
};
return (
<div className={`inline-flex items-center space-x-1.5 px-2 py-1 rounded-full ${getBgColor()}`}>
<span className={getColor()}>{getIcon()}</span>
{value !== undefined && (
<span className={`font-medium ${getColor()} ${textSize}`}>
{value > 0 ? '+' : ''}{value.toFixed(1)}%
</span>
)}
{showLabel && (
<span className={`${getColor()} ${textSize}`}>{getLabel()}</span>
)}
</div>
);
}

View file

@ -1,98 +0,0 @@
/**
* Area Chart Component
* Displays time series data as a filled area chart
*/
import {
AreaChart as RechartsAreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
interface AreaChartProps {
data: any[];
xKey: string;
yKey: string | string[];
title?: string;
colors?: string[];
height?: number;
showGrid?: boolean;
showLegend?: boolean;
stacked?: boolean;
gradient?: boolean;
formatter?: (value: number) => string;
}
const DEFAULT_COLORS = ['#10b981', '#3b82f6', '#8b5cf6', '#f59e0b', '#ef4444'];
export default function AreaChart({
data,
xKey,
yKey,
title,
colors = DEFAULT_COLORS,
height = 300,
showGrid = true,
showLegend = true,
stacked = false,
gradient = true,
formatter = (value) => value.toLocaleString(),
}: AreaChartProps) {
const yKeys = Array.isArray(yKey) ? yKey : [yKey];
return (
<div className="bg-white rounded-lg shadow-lg p-6">
{title && <h3 className="text-lg font-bold text-gray-900 mb-4">{title}</h3>}
<ResponsiveContainer width="100%" height={height}>
<RechartsAreaChart data={data} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
<defs>
{yKeys.map((key, index) => (
<linearGradient key={key} id={`color${key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={colors[index % colors.length]} stopOpacity={0.8} />
<stop offset="95%" stopColor={colors[index % colors.length]} stopOpacity={0.1} />
</linearGradient>
))}
</defs>
{showGrid && <CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />}
<XAxis
dataKey={xKey}
tick={{ fill: '#6b7280', fontSize: 12 }}
tickLine={{ stroke: '#e5e7eb' }}
/>
<YAxis
tick={{ fill: '#6b7280', fontSize: 12 }}
tickLine={{ stroke: '#e5e7eb' }}
tickFormatter={formatter}
/>
<Tooltip
contentStyle={{
backgroundColor: '#fff',
border: '1px solid #e5e7eb',
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
}}
formatter={(value: number) => [formatter(value), '']}
/>
{showLegend && <Legend />}
{yKeys.map((key, index) => (
<Area
key={key}
type="monotone"
dataKey={key}
stackId={stacked ? 'stack' : undefined}
stroke={colors[index % colors.length]}
strokeWidth={2}
fill={gradient ? `url(#color${key})` : colors[index % colors.length]}
fillOpacity={gradient ? 1 : 0.6}
/>
))}
</RechartsAreaChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -1,99 +0,0 @@
/**
* Bar Chart Component
* Displays categorical data as bars
*/
import {
BarChart as RechartsBarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
Cell,
} from 'recharts';
interface BarChartProps {
data: any[];
xKey: string;
yKey: string | string[];
title?: string;
colors?: string[];
height?: number;
showGrid?: boolean;
showLegend?: boolean;
stacked?: boolean;
horizontal?: boolean;
formatter?: (value: number) => string;
}
const DEFAULT_COLORS = ['#10b981', '#3b82f6', '#8b5cf6', '#f59e0b', '#ef4444', '#06b6d4'];
export default function BarChart({
data,
xKey,
yKey,
title,
colors = DEFAULT_COLORS,
height = 300,
showGrid = true,
showLegend = true,
stacked = false,
horizontal = false,
formatter = (value) => value.toLocaleString(),
}: BarChartProps) {
const yKeys = Array.isArray(yKey) ? yKey : [yKey];
const layout = horizontal ? 'vertical' : 'horizontal';
return (
<div className="bg-white rounded-lg shadow-lg p-6">
{title && <h3 className="text-lg font-bold text-gray-900 mb-4">{title}</h3>}
<ResponsiveContainer width="100%" height={height}>
<RechartsBarChart
data={data}
layout={layout}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
{showGrid && <CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />}
{horizontal ? (
<>
<XAxis type="number" tick={{ fill: '#6b7280', fontSize: 12 }} tickFormatter={formatter} />
<YAxis dataKey={xKey} type="category" tick={{ fill: '#6b7280', fontSize: 12 }} width={100} />
</>
) : (
<>
<XAxis dataKey={xKey} tick={{ fill: '#6b7280', fontSize: 12 }} />
<YAxis tick={{ fill: '#6b7280', fontSize: 12 }} tickFormatter={formatter} />
</>
)}
<Tooltip
contentStyle={{
backgroundColor: '#fff',
border: '1px solid #e5e7eb',
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
}}
formatter={(value: number) => [formatter(value), '']}
/>
{showLegend && yKeys.length > 1 && <Legend />}
{yKeys.map((key, index) => (
<Bar
key={key}
dataKey={key}
fill={colors[index % colors.length]}
stackId={stacked ? 'stack' : undefined}
radius={[4, 4, 0, 0]}
>
{yKeys.length === 1 &&
data.map((entry, i) => (
<Cell key={`cell-${i}`} fill={colors[i % colors.length]} />
))}
</Bar>
))}
</RechartsBarChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -1,91 +0,0 @@
/**
* Gauge Chart Component
* Displays a single value as a gauge/meter
*/
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts';
interface GaugeProps {
value: number;
max?: number;
title?: string;
unit?: string;
size?: number;
colors?: { low: string; medium: string; high: string };
thresholds?: { low: number; high: number };
}
const DEFAULT_COLORS = {
low: '#ef4444',
medium: '#f59e0b',
high: '#10b981',
};
export default function Gauge({
value,
max = 100,
title,
unit = '%',
size = 200,
colors = DEFAULT_COLORS,
thresholds = { low: 33, high: 66 },
}: GaugeProps) {
const percentage = Math.min((value / max) * 100, 100);
// Determine color based on thresholds
let color: string;
if (percentage < thresholds.low) {
color = colors.low;
} else if (percentage < thresholds.high) {
color = colors.medium;
} else {
color = colors.high;
}
// Data for semi-circle gauge
const gaugeData = [
{ value: percentage, color },
{ value: 100 - percentage, color: '#e5e7eb' },
];
return (
<div className="bg-white rounded-lg shadow-lg p-6 flex flex-col items-center">
{title && <h3 className="text-lg font-bold text-gray-900 mb-2">{title}</h3>}
<div className="relative" style={{ width: size, height: size / 2 + 20 }}>
<ResponsiveContainer width="100%" height={size}>
<PieChart>
<Pie
data={gaugeData}
cx="50%"
cy="100%"
startAngle={180}
endAngle={0}
innerRadius={size * 0.3}
outerRadius={size * 0.4}
paddingAngle={0}
dataKey="value"
>
{gaugeData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} stroke="none" />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
<div
className="absolute inset-0 flex flex-col items-center justify-end pb-2"
style={{ top: size * 0.2 }}
>
<span className="text-3xl font-bold" style={{ color }}>
{value.toFixed(1)}
</span>
<span className="text-sm text-gray-500">{unit}</span>
</div>
</div>
<div className="flex justify-between w-full mt-2 px-4 text-xs text-gray-500">
<span>0</span>
<span>{max / 2}</span>
<span>{max}</span>
</div>
</div>
);
}

View file

@ -1,134 +0,0 @@
/**
* Heatmap Component
* Displays data intensity across a grid
*/
interface HeatmapCell {
x: string;
y: string;
value: number;
}
interface HeatmapProps {
data: HeatmapCell[];
title?: string;
xLabels: string[];
yLabels: string[];
colorRange?: { min: string; max: string };
height?: number;
showValues?: boolean;
}
function interpolateColor(color1: string, color2: string, factor: number): string {
const hex = (c: string) => parseInt(c, 16);
const r1 = hex(color1.slice(1, 3));
const g1 = hex(color1.slice(3, 5));
const b1 = hex(color1.slice(5, 7));
const r2 = hex(color2.slice(1, 3));
const g2 = hex(color2.slice(3, 5));
const b2 = hex(color2.slice(5, 7));
const r = Math.round(r1 + (r2 - r1) * factor);
const g = Math.round(g1 + (g2 - g1) * factor);
const b = Math.round(b1 + (b2 - b1) * factor);
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}
export default function Heatmap({
data,
title,
xLabels,
yLabels,
colorRange = { min: '#fee2e2', max: '#10b981' },
height = 300,
showValues = true,
}: HeatmapProps) {
const maxValue = Math.max(...data.map((d) => d.value));
const minValue = Math.min(...data.map((d) => d.value));
const range = maxValue - minValue || 1;
const getColor = (value: number): string => {
const factor = (value - minValue) / range;
return interpolateColor(colorRange.min, colorRange.max, factor);
};
const getValue = (x: string, y: string): number | undefined => {
const cell = data.find((d) => d.x === x && d.y === y);
return cell?.value;
};
const cellWidth = `${100 / xLabels.length}%`;
const cellHeight = (height - 40) / yLabels.length;
return (
<div className="bg-white rounded-lg shadow-lg p-6">
{title && <h3 className="text-lg font-bold text-gray-900 mb-4">{title}</h3>}
<div style={{ height }}>
{/* X Labels */}
<div className="flex mb-1" style={{ paddingLeft: '80px' }}>
{xLabels.map((label) => (
<div
key={label}
className="text-xs text-gray-500 text-center truncate"
style={{ width: cellWidth }}
>
{label}
</div>
))}
</div>
{/* Grid */}
{yLabels.map((yLabel) => (
<div key={yLabel} className="flex">
<div
className="flex items-center justify-end pr-2 text-xs text-gray-500"
style={{ width: '80px' }}
>
{yLabel}
</div>
{xLabels.map((xLabel) => {
const value = getValue(xLabel, yLabel);
const bgColor = value !== undefined ? getColor(value) : '#f3f4f6';
const textColor =
value !== undefined && (value - minValue) / range > 0.5
? '#fff'
: '#374151';
return (
<div
key={`${xLabel}-${yLabel}`}
className="flex items-center justify-center border border-white rounded-sm transition-all hover:ring-2 hover:ring-gray-400"
style={{
width: cellWidth,
height: cellHeight,
backgroundColor: bgColor,
}}
title={`${xLabel}, ${yLabel}: ${value ?? 'N/A'}`}
>
{showValues && value !== undefined && (
<span className="text-xs font-medium" style={{ color: textColor }}>
{value.toFixed(0)}
</span>
)}
</div>
);
})}
</div>
))}
{/* Legend */}
<div className="flex items-center justify-center mt-4 space-x-4">
<span className="text-xs text-gray-500">Low</span>
<div
className="w-24 h-3 rounded"
style={{
background: `linear-gradient(to right, ${colorRange.min}, ${colorRange.max})`,
}}
/>
<span className="text-xs text-gray-500">High</span>
</div>
</div>
</div>
);
}

View file

@ -1,85 +0,0 @@
/**
* Line Chart Component
* Displays time series data as a line chart
*/
import {
LineChart as RechartsLineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
interface LineChartProps {
data: any[];
xKey: string;
yKey: string | string[];
title?: string;
colors?: string[];
height?: number;
showGrid?: boolean;
showLegend?: boolean;
formatter?: (value: number) => string;
}
const DEFAULT_COLORS = ['#10b981', '#3b82f6', '#8b5cf6', '#f59e0b', '#ef4444'];
export default function LineChart({
data,
xKey,
yKey,
title,
colors = DEFAULT_COLORS,
height = 300,
showGrid = true,
showLegend = true,
formatter = (value) => value.toLocaleString(),
}: LineChartProps) {
const yKeys = Array.isArray(yKey) ? yKey : [yKey];
return (
<div className="bg-white rounded-lg shadow-lg p-6">
{title && <h3 className="text-lg font-bold text-gray-900 mb-4">{title}</h3>}
<ResponsiveContainer width="100%" height={height}>
<RechartsLineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
{showGrid && <CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />}
<XAxis
dataKey={xKey}
tick={{ fill: '#6b7280', fontSize: 12 }}
tickLine={{ stroke: '#e5e7eb' }}
/>
<YAxis
tick={{ fill: '#6b7280', fontSize: 12 }}
tickLine={{ stroke: '#e5e7eb' }}
tickFormatter={formatter}
/>
<Tooltip
contentStyle={{
backgroundColor: '#fff',
border: '1px solid #e5e7eb',
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
}}
formatter={(value: number) => [formatter(value), '']}
/>
{showLegend && <Legend />}
{yKeys.map((key, index) => (
<Line
key={key}
type="monotone"
dataKey={key}
stroke={colors[index % colors.length]}
strokeWidth={2}
dot={{ fill: colors[index % colors.length], r: 4 }}
activeDot={{ r: 6 }}
/>
))}
</RechartsLineChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -1,123 +0,0 @@
/**
* Pie Chart Component
* Displays distribution data as a pie chart
*/
import {
PieChart as RechartsPieChart,
Pie,
Cell,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
interface PieChartProps {
data: any[];
dataKey: string;
nameKey: string;
title?: string;
colors?: string[];
height?: number;
showLegend?: boolean;
innerRadius?: number;
outerRadius?: number;
formatter?: (value: number) => string;
}
const DEFAULT_COLORS = [
'#10b981', '#3b82f6', '#8b5cf6', '#f59e0b', '#ef4444',
'#06b6d4', '#ec4899', '#84cc16', '#f97316', '#6366f1',
];
export default function PieChart({
data,
dataKey,
nameKey,
title,
colors = DEFAULT_COLORS,
height = 300,
showLegend = true,
innerRadius = 0,
outerRadius = 80,
formatter = (value) => value.toLocaleString(),
}: PieChartProps) {
const RADIAN = Math.PI / 180;
const renderCustomizedLabel = ({
cx,
cy,
midAngle,
innerRadius,
outerRadius,
percent,
}: any) => {
if (percent < 0.05) return null;
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
const x = cx + radius * Math.cos(-midAngle * RADIAN);
const y = cy + radius * Math.sin(-midAngle * RADIAN);
return (
<text
x={x}
y={y}
fill="white"
textAnchor={x > cx ? 'start' : 'end'}
dominantBaseline="central"
fontSize="12"
fontWeight="bold"
>
{`${(percent * 100).toFixed(0)}%`}
</text>
);
};
return (
<div className="bg-white rounded-lg shadow-lg p-6">
{title && <h3 className="text-lg font-bold text-gray-900 mb-4">{title}</h3>}
<ResponsiveContainer width="100%" height={height}>
<RechartsPieChart>
<Pie
data={data}
cx="50%"
cy="50%"
labelLine={false}
label={renderCustomizedLabel}
innerRadius={innerRadius}
outerRadius={outerRadius}
paddingAngle={2}
dataKey={dataKey}
nameKey={nameKey}
>
{data.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={colors[index % colors.length]}
stroke="#fff"
strokeWidth={2}
/>
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: '#fff',
border: '1px solid #e5e7eb',
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
}}
formatter={(value: number) => [formatter(value), '']}
/>
{showLegend && (
<Legend
layout="horizontal"
verticalAlign="bottom"
align="center"
wrapperStyle={{ paddingTop: '20px' }}
/>
)}
</RechartsPieChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -1,11 +0,0 @@
/**
* Chart Components Index
* Export all chart components
*/
export { default as LineChart } from './LineChart';
export { default as BarChart } from './BarChart';
export { default as PieChart } from './PieChart';
export { default as AreaChart } from './AreaChart';
export { default as Gauge } from './Gauge';
export { default as Heatmap } from './Heatmap';

View file

@ -1,19 +1,3 @@
/**
* Analytics Components Index
* Export all analytics components
*/
// Charts
export * from './charts';
// Widgets
export { default as KPICard } from './KPICard';
export { default as TrendIndicator } from './TrendIndicator';
export { default as DataTable } from './DataTable';
export { default as DateRangePicker } from './DateRangePicker';
export { default as FilterPanel } from './FilterPanel';
// Existing components
export { default as EnvironmentalImpact } from './EnvironmentalImpact';
export { default as FoodMilesTracker } from './FoodMilesTracker';
export { default as SavingsCalculator } from './SavingsCalculator';

View file

@ -1,149 +0,0 @@
import Link from 'next/link';
interface Listing {
id: string;
title: string;
description: string;
price: number;
currency: string;
quantity: number;
category: string;
sellerName?: string;
location?: { city?: string; region?: string };
tags: string[];
viewCount: number;
}
const categoryLabels: Record<string, string> = {
seeds: 'Seeds',
seedlings: 'Seedlings',
mature_plants: 'Mature Plants',
cuttings: 'Cuttings',
produce: 'Produce',
supplies: 'Supplies',
};
const categoryIcons: Record<string, string> = {
seeds: '🌰',
seedlings: '🌱',
mature_plants: '🪴',
cuttings: '✂️',
produce: '🥬',
supplies: '🧰',
};
interface ListingCardProps {
listing: Listing;
variant?: 'default' | 'compact' | 'featured';
}
export function ListingCard({ listing, variant = 'default' }: ListingCardProps) {
if (variant === 'compact') {
return (
<Link href={`/marketplace/listings/${listing.id}`}>
<a className="flex items-center gap-4 p-4 bg-white rounded-lg shadow hover:shadow-md transition border border-gray-200">
<div className="w-16 h-16 bg-gradient-to-br from-green-100 to-emerald-100 rounded-lg flex items-center justify-center flex-shrink-0">
<span className="text-2xl">{categoryIcons[listing.category] || '🌿'}</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{listing.title}</h3>
<p className="text-sm text-gray-500">{categoryLabels[listing.category]}</p>
</div>
<div className="text-right">
<div className="font-bold text-green-600">${listing.price.toFixed(2)}</div>
<div className="text-xs text-gray-500">{listing.quantity} avail.</div>
</div>
</a>
</Link>
);
}
if (variant === 'featured') {
return (
<Link href={`/marketplace/listings/${listing.id}`}>
<a className="block bg-white rounded-xl shadow-lg hover:shadow-xl transition overflow-hidden border-2 border-green-200">
<div className="relative">
<div className="h-56 bg-gradient-to-br from-green-100 to-emerald-100 flex items-center justify-center">
<span className="text-7xl">{categoryIcons[listing.category] || '🌿'}</span>
</div>
<div className="absolute top-4 left-4">
<span className="px-3 py-1 bg-green-600 text-white text-sm font-medium rounded-full">
Featured
</span>
</div>
</div>
<div className="p-6">
<div className="flex justify-between items-start mb-3">
<h3 className="text-xl font-bold text-gray-900 line-clamp-1">
{listing.title}
</h3>
<span className="text-2xl font-bold text-green-600">
${listing.price.toFixed(2)}
</span>
</div>
<p className="text-gray-600 line-clamp-2 mb-4">
{listing.description}
</p>
<div className="flex justify-between items-center">
<div className="flex items-center gap-2 text-sm text-gray-500">
<span>{categoryLabels[listing.category]}</span>
<span></span>
<span>{listing.quantity} available</span>
</div>
<span className="text-sm text-gray-400">
{listing.viewCount} views
</span>
</div>
</div>
</a>
</Link>
);
}
// Default variant
return (
<Link href={`/marketplace/listings/${listing.id}`}>
<a className="block bg-white rounded-lg shadow hover:shadow-lg transition overflow-hidden border border-gray-200">
<div className="h-48 bg-gradient-to-br from-green-100 to-emerald-100 flex items-center justify-center">
<span className="text-6xl">{categoryIcons[listing.category] || '🌿'}</span>
</div>
<div className="p-4">
<div className="flex justify-between items-start mb-2">
<h3 className="text-lg font-semibold text-gray-900 line-clamp-1">
{listing.title}
</h3>
<span className="text-lg font-bold text-green-600">
${listing.price.toFixed(2)}
</span>
</div>
<p className="text-gray-600 text-sm line-clamp-2 mb-3">
{listing.description}
</p>
<div className="flex justify-between items-center text-sm text-gray-500">
<span>{categoryLabels[listing.category]}</span>
<span>{listing.quantity} available</span>
</div>
{listing.sellerName && (
<div className="mt-2 text-sm text-gray-500">
by {listing.sellerName}
</div>
)}
{listing.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{listing.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full"
>
{tag}
</span>
))}
</div>
)}
</div>
</a>
</Link>
);
}
export default ListingCard;

View file

@ -1,290 +0,0 @@
import { useState } from 'react';
interface ListingFormData {
title: string;
description: string;
price: string;
quantity: string;
category: string;
tags: string;
city: string;
region: string;
}
interface ListingFormProps {
initialData?: Partial<ListingFormData>;
onSubmit: (data: ListingFormData) => Promise<void>;
submitLabel?: string;
isLoading?: boolean;
}
const categories = [
{ value: 'seeds', label: 'Seeds', icon: '🌰', description: 'Plant seeds for growing' },
{ value: 'seedlings', label: 'Seedlings', icon: '🌱', description: 'Young plants ready for transplanting' },
{ value: 'mature_plants', label: 'Mature Plants', icon: '🪴', description: 'Fully grown plants' },
{ value: 'cuttings', label: 'Cuttings', icon: '✂️', description: 'Plant cuttings for propagation' },
{ value: 'produce', label: 'Produce', icon: '🥬', description: 'Fresh fruits and vegetables' },
{ value: 'supplies', label: 'Supplies', icon: '🧰', description: 'Gardening tools and supplies' },
];
export function ListingForm({
initialData = {},
onSubmit,
submitLabel = 'Create Listing',
isLoading = false,
}: ListingFormProps) {
const [formData, setFormData] = useState<ListingFormData>({
title: initialData.title || '',
description: initialData.description || '',
price: initialData.price || '',
quantity: initialData.quantity || '1',
category: initialData.category || '',
tags: initialData.tags || '',
city: initialData.city || '',
region: initialData.region || '',
});
const [errors, setErrors] = useState<Partial<Record<keyof ListingFormData, string>>>({});
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
// Clear error when field is edited
if (errors[name as keyof ListingFormData]) {
setErrors((prev) => ({ ...prev, [name]: undefined }));
}
};
const validate = (): boolean => {
const newErrors: Partial<Record<keyof ListingFormData, string>> = {};
if (!formData.title.trim()) {
newErrors.title = 'Title is required';
} else if (formData.title.length < 10) {
newErrors.title = 'Title must be at least 10 characters';
}
if (!formData.description.trim()) {
newErrors.description = 'Description is required';
} else if (formData.description.length < 20) {
newErrors.description = 'Description must be at least 20 characters';
}
if (!formData.price) {
newErrors.price = 'Price is required';
} else if (parseFloat(formData.price) <= 0) {
newErrors.price = 'Price must be greater than 0';
}
if (!formData.quantity) {
newErrors.quantity = 'Quantity is required';
} else if (parseInt(formData.quantity, 10) < 1) {
newErrors.quantity = 'Quantity must be at least 1';
}
if (!formData.category) {
newErrors.category = 'Category is required';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) {
return;
}
await onSubmit(formData);
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Title */}
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
Title *
</label>
<input
type="text"
id="title"
name="title"
value={formData.title}
onChange={handleChange}
placeholder="e.g., Organic Tomato Seedlings - Cherokee Purple"
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent ${
errors.title ? 'border-red-300' : 'border-gray-300'
}`}
/>
{errors.title && <p className="mt-1 text-sm text-red-600">{errors.title}</p>}
</div>
{/* Description */}
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
Description *
</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
rows={5}
placeholder="Describe your item in detail. Include information about variety, growing conditions, care instructions, etc."
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent ${
errors.description ? 'border-red-300' : 'border-gray-300'
}`}
/>
{errors.description && <p className="mt-1 text-sm text-red-600">{errors.description}</p>}
<p className="mt-1 text-sm text-gray-500">
{formData.description.length}/500 characters
</p>
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Category *
</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{categories.map((cat) => (
<button
key={cat.value}
type="button"
onClick={() => setFormData((prev) => ({ ...prev, category: cat.value }))}
className={`p-4 rounded-lg border-2 text-left transition ${
formData.category === cat.value
? 'border-green-500 bg-green-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="text-2xl mb-1">{cat.icon}</div>
<div className="font-medium text-gray-900">{cat.label}</div>
<div className="text-xs text-gray-500">{cat.description}</div>
</button>
))}
</div>
{errors.category && <p className="mt-2 text-sm text-red-600">{errors.category}</p>}
</div>
{/* Price and Quantity */}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="price" className="block text-sm font-medium text-gray-700 mb-1">
Price (USD) *
</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
id="price"
name="price"
value={formData.price}
onChange={handleChange}
min="0.01"
step="0.01"
placeholder="0.00"
className={`w-full pl-8 pr-4 py-3 border rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent ${
errors.price ? 'border-red-300' : 'border-gray-300'
}`}
/>
</div>
{errors.price && <p className="mt-1 text-sm text-red-600">{errors.price}</p>}
</div>
<div>
<label htmlFor="quantity" className="block text-sm font-medium text-gray-700 mb-1">
Quantity *
</label>
<input
type="number"
id="quantity"
name="quantity"
value={formData.quantity}
onChange={handleChange}
min="1"
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent ${
errors.quantity ? 'border-red-300' : 'border-gray-300'
}`}
/>
{errors.quantity && <p className="mt-1 text-sm text-red-600">{errors.quantity}</p>}
</div>
</div>
{/* Location */}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="city" className="block text-sm font-medium text-gray-700 mb-1">
City (optional)
</label>
<input
type="text"
id="city"
name="city"
value={formData.city}
onChange={handleChange}
placeholder="e.g., Portland"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
<div>
<label htmlFor="region" className="block text-sm font-medium text-gray-700 mb-1">
State/Region (optional)
</label>
<input
type="text"
id="region"
name="region"
value={formData.region}
onChange={handleChange}
placeholder="e.g., OR"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
</div>
{/* Tags */}
<div>
<label htmlFor="tags" className="block text-sm font-medium text-gray-700 mb-1">
Tags (optional)
</label>
<input
type="text"
id="tags"
name="tags"
value={formData.tags}
onChange={handleChange}
placeholder="organic, heirloom, non-gmo (comma separated)"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
<p className="mt-1 text-sm text-gray-500">
Add tags to help buyers find your listing
</p>
</div>
{/* Submit Button */}
<div className="pt-4">
<button
type="submit"
disabled={isLoading}
className="w-full py-4 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<span className="flex items-center justify-center gap-2">
<span className="animate-spin"></span>
Processing...
</span>
) : (
submitLabel
)}
</button>
</div>
</form>
);
}
export default ListingForm;

View file

@ -1,64 +0,0 @@
import { ListingCard } from './ListingCard';
interface Listing {
id: string;
title: string;
description: string;
price: number;
currency: string;
quantity: number;
category: string;
sellerName?: string;
location?: { city?: string; region?: string };
tags: string[];
viewCount: number;
}
interface ListingGridProps {
listings: Listing[];
columns?: 2 | 3 | 4;
variant?: 'default' | 'compact' | 'featured';
emptyMessage?: string;
}
export function ListingGrid({
listings,
columns = 3,
variant = 'default',
emptyMessage = 'No listings found',
}: ListingGridProps) {
if (listings.length === 0) {
return (
<div className="text-center py-12 bg-white rounded-lg shadow">
<div className="text-4xl mb-4">🌿</div>
<p className="text-gray-500">{emptyMessage}</p>
</div>
);
}
const gridCols = {
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
};
if (variant === 'compact') {
return (
<div className="space-y-3">
{listings.map((listing) => (
<ListingCard key={listing.id} listing={listing} variant="compact" />
))}
</div>
);
}
return (
<div className={`grid ${gridCols[columns]} gap-6`}>
{listings.map((listing) => (
<ListingCard key={listing.id} listing={listing} variant={variant} />
))}
</div>
);
}
export default ListingGrid;

View file

@ -1,144 +0,0 @@
import { useState } from 'react';
interface OfferFormProps {
listingId: string;
askingPrice: number;
currency?: string;
onSubmit: (amount: number, message: string) => Promise<void>;
onCancel?: () => void;
}
export function OfferForm({
listingId,
askingPrice,
currency = 'USD',
onSubmit,
onCancel,
}: OfferFormProps) {
const [amount, setAmount] = useState(askingPrice.toString());
const [message, setMessage] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
const offerAmount = parseFloat(amount);
if (isNaN(offerAmount) || offerAmount <= 0) {
setError('Please enter a valid offer amount');
return;
}
setSubmitting(true);
try {
await onSubmit(offerAmount, message);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to submit offer');
} finally {
setSubmitting(false);
}
};
const suggestedOffers = [
{ label: 'Full Price', value: askingPrice },
{ label: '10% Off', value: Math.round(askingPrice * 0.9 * 100) / 100 },
{ label: '15% Off', value: Math.round(askingPrice * 0.85 * 100) / 100 },
];
return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}
{/* Suggested Offers */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Quick Select
</label>
<div className="flex gap-2">
{suggestedOffers.map((suggestion) => (
<button
key={suggestion.label}
type="button"
onClick={() => setAmount(suggestion.value.toString())}
className={`px-3 py-2 rounded-lg text-sm transition ${
parseFloat(amount) === suggestion.value
? 'bg-green-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{suggestion.label}
<br />
<span className="font-semibold">${suggestion.value.toFixed(2)}</span>
</button>
))}
</div>
</div>
{/* Custom Amount */}
<div>
<label htmlFor="offerAmount" className="block text-sm font-medium text-gray-700 mb-1">
Your Offer ({currency})
</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
id="offerAmount"
value={amount}
onChange={(e) => setAmount(e.target.value)}
required
min="0.01"
step="0.01"
className="w-full pl-8 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
<p className="mt-1 text-sm text-gray-500">
Asking price: ${askingPrice.toFixed(2)}
</p>
</div>
{/* Message */}
<div>
<label htmlFor="offerMessage" className="block text-sm font-medium text-gray-700 mb-1">
Message to Seller (optional)
</label>
<textarea
id="offerMessage"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={3}
placeholder="Introduce yourself or ask a question..."
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
{onCancel && (
<button
type="button"
onClick={onCancel}
className="flex-1 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
>
Cancel
</button>
)}
<button
type="submit"
disabled={submitting}
className="flex-1 py-3 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 transition disabled:opacity-50"
>
{submitting ? 'Submitting...' : 'Submit Offer'}
</button>
</div>
</form>
);
}
export default OfferForm;

View file

@ -1,163 +0,0 @@
interface Offer {
id: string;
buyerId: string;
buyerName?: string;
amount: number;
message?: string;
status: string;
createdAt: string;
}
interface OfferListProps {
offers: Offer[];
isSellerView?: boolean;
onAccept?: (offerId: string) => void;
onReject?: (offerId: string) => void;
onWithdraw?: (offerId: string) => void;
}
const statusColors: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800',
accepted: 'bg-green-100 text-green-800',
rejected: 'bg-red-100 text-red-800',
withdrawn: 'bg-gray-100 text-gray-800',
expired: 'bg-gray-100 text-gray-800',
};
const statusLabels: Record<string, string> = {
pending: 'Pending',
accepted: 'Accepted',
rejected: 'Rejected',
withdrawn: 'Withdrawn',
expired: 'Expired',
};
export function OfferList({
offers,
isSellerView = false,
onAccept,
onReject,
onWithdraw,
}: OfferListProps) {
if (offers.length === 0) {
return (
<div className="text-center py-8 bg-gray-50 rounded-lg">
<div className="text-3xl mb-2">📭</div>
<p className="text-gray-500">No offers yet</p>
</div>
);
}
return (
<div className="space-y-4">
{offers.map((offer) => (
<OfferItem
key={offer.id}
offer={offer}
isSellerView={isSellerView}
onAccept={onAccept}
onReject={onReject}
onWithdraw={onWithdraw}
/>
))}
</div>
);
}
interface OfferItemProps {
offer: Offer;
isSellerView: boolean;
onAccept?: (offerId: string) => void;
onReject?: (offerId: string) => void;
onWithdraw?: (offerId: string) => void;
}
function OfferItem({
offer,
isSellerView,
onAccept,
onReject,
onWithdraw,
}: OfferItemProps) {
const isPending = offer.status === 'pending';
return (
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="flex justify-between items-start">
<div>
{isSellerView && (
<div className="flex items-center gap-2 mb-2">
<div className="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
<span>👤</span>
</div>
<span className="font-medium text-gray-900">
{offer.buyerName || 'Anonymous'}
</span>
</div>
)}
<div className="text-2xl font-bold text-green-600">
${offer.amount.toFixed(2)}
</div>
<div className="text-sm text-gray-500 mt-1">
{new Date(offer.createdAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
})}
</div>
</div>
<span
className={`px-3 py-1 rounded-full text-sm font-medium ${
statusColors[offer.status]
}`}
>
{statusLabels[offer.status]}
</span>
</div>
{offer.message && (
<div className="mt-3 p-3 bg-gray-50 rounded-lg text-gray-700 text-sm">
"{offer.message}"
</div>
)}
{isPending && (
<div className="flex gap-2 mt-4 pt-4 border-t">
{isSellerView ? (
<>
{onAccept && (
<button
onClick={() => onAccept(offer.id)}
className="flex-1 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition font-medium"
>
Accept
</button>
)}
{onReject && (
<button
onClick={() => onReject(offer.id)}
className="flex-1 py-2 border border-red-300 text-red-600 rounded-lg hover:bg-red-50 transition font-medium"
>
Reject
</button>
)}
</>
) : (
onWithdraw && (
<button
onClick={() => onWithdraw(offer.id)}
className="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition"
>
Withdraw Offer
</button>
)
)}
</div>
)}
</div>
);
}
export default OfferList;

View file

@ -1,96 +0,0 @@
interface PriceDisplayProps {
price: number;
currency?: string;
originalPrice?: number;
size?: 'sm' | 'md' | 'lg' | 'xl';
showCurrency?: boolean;
}
const sizeClasses = {
sm: 'text-sm',
md: 'text-lg',
lg: 'text-2xl',
xl: 'text-4xl',
};
export function PriceDisplay({
price,
currency = 'USD',
originalPrice,
size = 'md',
showCurrency = false,
}: PriceDisplayProps) {
const hasDiscount = originalPrice && originalPrice > price;
const discountPercentage = hasDiscount
? Math.round((1 - price / originalPrice) * 100)
: 0;
const formatPrice = (value: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
};
return (
<div className="flex items-baseline gap-2">
<span className={`font-bold text-green-600 ${sizeClasses[size]}`}>
{formatPrice(price)}
</span>
{showCurrency && (
<span className="text-gray-500 text-sm">{currency}</span>
)}
{hasDiscount && (
<>
<span className="text-gray-400 line-through text-sm">
{formatPrice(originalPrice)}
</span>
<span className="px-2 py-0.5 bg-red-100 text-red-700 text-xs rounded-full font-medium">
{discountPercentage}% OFF
</span>
</>
)}
</div>
);
}
interface PriceRangeDisplayProps {
minPrice: number;
maxPrice: number;
currency?: string;
}
export function PriceRangeDisplay({
minPrice,
maxPrice,
currency = 'USD',
}: PriceRangeDisplayProps) {
const formatPrice = (value: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
if (minPrice === maxPrice) {
return (
<span className="font-semibold text-green-600">
{formatPrice(minPrice)}
</span>
);
}
return (
<span className="font-semibold text-green-600">
{formatPrice(minPrice)} - {formatPrice(maxPrice)}
</span>
);
}
export default PriceDisplay;

View file

@ -1,219 +0,0 @@
import { useState } from 'react';
interface SearchFiltersProps {
initialValues?: {
query?: string;
category?: string;
minPrice?: string;
maxPrice?: string;
sortBy?: string;
};
onApply: (filters: {
query?: string;
category?: string;
minPrice?: string;
maxPrice?: string;
sortBy?: string;
}) => void;
onClear: () => void;
}
const categoryLabels: Record<string, string> = {
seeds: 'Seeds',
seedlings: 'Seedlings',
mature_plants: 'Mature Plants',
cuttings: 'Cuttings',
produce: 'Produce',
supplies: 'Supplies',
};
const categoryIcons: Record<string, string> = {
seeds: '🌰',
seedlings: '🌱',
mature_plants: '🪴',
cuttings: '✂️',
produce: '🥬',
supplies: '🧰',
};
const sortOptions = [
{ value: 'date_desc', label: 'Newest First' },
{ value: 'date_asc', label: 'Oldest First' },
{ value: 'price_asc', label: 'Price: Low to High' },
{ value: 'price_desc', label: 'Price: High to Low' },
{ value: 'relevance', label: 'Most Popular' },
];
export function SearchFilters({
initialValues = {},
onApply,
onClear,
}: SearchFiltersProps) {
const [query, setQuery] = useState(initialValues.query || '');
const [category, setCategory] = useState(initialValues.category || '');
const [minPrice, setMinPrice] = useState(initialValues.minPrice || '');
const [maxPrice, setMaxPrice] = useState(initialValues.maxPrice || '');
const [sortBy, setSortBy] = useState(initialValues.sortBy || 'date_desc');
const [isExpanded, setIsExpanded] = useState(false);
const handleApply = () => {
onApply({
query: query || undefined,
category: category || undefined,
minPrice: minPrice || undefined,
maxPrice: maxPrice || undefined,
sortBy: sortBy || undefined,
});
};
const handleClear = () => {
setQuery('');
setCategory('');
setMinPrice('');
setMaxPrice('');
setSortBy('date_desc');
onClear();
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
handleApply();
};
return (
<div className="bg-white rounded-lg shadow p-4">
{/* Search Bar */}
<form onSubmit={handleSearch} className="mb-4">
<div className="flex gap-2">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search listings..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
<button
type="submit"
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
>
Search
</button>
</div>
</form>
{/* Toggle Filters */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 text-gray-600 hover:text-gray-800 mb-2"
>
<span>{isExpanded ? '▼' : '▶'}</span>
<span>Advanced Filters</span>
</button>
{/* Expanded Filters */}
{isExpanded && (
<div className="space-y-4 pt-4 border-t">
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Category
</label>
<div className="flex flex-wrap gap-2">
<button
onClick={() => setCategory('')}
className={`px-3 py-1 rounded-full text-sm transition ${
!category
? 'bg-green-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
All
</button>
{Object.entries(categoryLabels).map(([value, label]) => (
<button
key={value}
onClick={() => setCategory(value)}
className={`px-3 py-1 rounded-full text-sm transition ${
category === value
? 'bg-green-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{categoryIcons[value]} {label}
</button>
))}
</div>
</div>
{/* Price Range */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Price Range
</label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">$</span>
<input
type="number"
value={minPrice}
onChange={(e) => setMinPrice(e.target.value)}
placeholder="Min"
min="0"
className="w-full pl-7 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
<span className="text-gray-400">-</span>
<div className="relative flex-1">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">$</span>
<input
type="number"
value={maxPrice}
onChange={(e) => setMaxPrice(e.target.value)}
placeholder="Max"
min="0"
className="w-full pl-7 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
</div>
</div>
{/* Sort */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Sort By
</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
{sortOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* Action Buttons */}
<div className="flex gap-2 pt-2">
<button
onClick={handleClear}
className="flex-1 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
>
Clear All
</button>
<button
onClick={handleApply}
className="flex-1 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
>
Apply Filters
</button>
</div>
</div>
)}
</div>
);
}
export default SearchFilters;

View file

@ -1,8 +0,0 @@
// Marketplace Components Index
export { ListingCard } from './ListingCard';
export { ListingGrid } from './ListingGrid';
export { ListingForm } from './ListingForm';
export { OfferForm } from './OfferForm';
export { OfferList } from './OfferList';
export { SearchFilters } from './SearchFilters';
export { PriceDisplay, PriceRangeDisplay } from './PriceDisplay';

View file

@ -1,92 +0,0 @@
import * as React from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import classNames from 'classnames';
interface NavItem {
href: string;
icon: React.ReactNode;
label: string;
}
const navItems: NavItem[] = [
{
href: '/m',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
label: 'Home',
},
{
href: '/m/scan',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
),
label: 'Scan',
},
{
href: '/m/quick-add',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
),
label: 'Add',
},
{
href: '/plants/explore',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
),
label: 'Explore',
},
{
href: '/m/profile',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
),
label: 'Profile',
},
];
export function BottomNav() {
const router = useRouter();
const pathname = router.pathname;
return (
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-200 pb-safe md:hidden">
<div className="flex items-center justify-around h-16">
{navItems.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
return (
<Link key={item.href} href={item.href}>
<a
className={classNames(
'flex flex-col items-center justify-center w-full h-full space-y-1 transition-colors',
{
'text-green-600': isActive,
'text-gray-500 hover:text-gray-700': !isActive,
}
)}
>
{item.icon}
<span className="text-xs font-medium">{item.label}</span>
</a>
</Link>
);
})}
</div>
</nav>
);
}
export default BottomNav;

View file

@ -1,183 +0,0 @@
import * as React from 'react';
interface BeforeInstallPromptEvent extends Event {
readonly platforms: string[];
readonly userChoice: Promise<{
outcome: 'accepted' | 'dismissed';
platform: string;
}>;
prompt(): Promise<void>;
}
export function InstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = React.useState<BeforeInstallPromptEvent | null>(null);
const [showPrompt, setShowPrompt] = React.useState(false);
const [isIOS, setIsIOS] = React.useState(false);
const [isInstalled, setIsInstalled] = React.useState(false);
React.useEffect(() => {
// Check if already installed
if (window.matchMedia('(display-mode: standalone)').matches) {
setIsInstalled(true);
return;
}
// Check if iOS
const ua = window.navigator.userAgent;
const isIOSDevice = /iPad|iPhone|iPod/.test(ua) && !(window as any).MSStream;
setIsIOS(isIOSDevice);
// Check if user has dismissed the prompt recently
const dismissedAt = localStorage.getItem('pwa-prompt-dismissed');
if (dismissedAt) {
const dismissedTime = new Date(dismissedAt).getTime();
const now = Date.now();
const dayInMs = 24 * 60 * 60 * 1000;
if (now - dismissedTime < 7 * dayInMs) {
return;
}
}
// For non-iOS devices, wait for the beforeinstallprompt event
const handleBeforeInstallPrompt = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
setShowPrompt(true);
};
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
// For iOS, show prompt after a delay if not installed
if (isIOSDevice && !navigator.standalone) {
const timer = setTimeout(() => {
setShowPrompt(true);
}, 3000);
return () => clearTimeout(timer);
}
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
};
}, []);
const handleInstall = async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
setShowPrompt(false);
setIsInstalled(true);
}
setDeferredPrompt(null);
};
const handleDismiss = () => {
setShowPrompt(false);
localStorage.setItem('pwa-prompt-dismissed', new Date().toISOString());
};
if (!showPrompt || isInstalled) {
return null;
}
return (
<div className="fixed bottom-20 left-4 right-4 z-50 md:left-auto md:right-4 md:max-w-sm animate-slide-up">
<div className="bg-white rounded-2xl shadow-lg border border-gray-200 overflow-hidden">
<div className="p-4">
<div className="flex items-start space-x-3">
{/* App Icon */}
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-green-600 rounded-xl flex items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-7 w-7 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-gray-900">Install LocalGreenChain</h3>
<p className="mt-1 text-sm text-gray-500">
{isIOS
? 'Tap the share button and select "Add to Home Screen"'
: 'Install our app for a better experience with offline support'}
</p>
</div>
{/* Close button */}
<button
onClick={handleDismiss}
className="flex-shrink-0 p-1 rounded-full hover:bg-gray-100"
aria-label="Dismiss"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* iOS Instructions */}
{isIOS && (
<div className="mt-3 flex items-center space-x-2 text-sm text-gray-600 bg-gray-50 rounded-lg p-3">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 text-blue-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
/>
</svg>
<span>Then tap "Add to Home Screen"</span>
</div>
)}
{/* Install Button (non-iOS) */}
{!isIOS && deferredPrompt && (
<div className="mt-3 flex space-x-3">
<button
onClick={handleDismiss}
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
>
Not now
</button>
<button
onClick={handleInstall}
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700"
>
Install
</button>
</div>
)}
</div>
</div>
</div>
);
}
export default InstallPrompt;

View file

@ -1,101 +0,0 @@
import * as React from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';
interface MobileHeaderProps {
title?: string;
showBack?: boolean;
rightAction?: React.ReactNode;
}
export function MobileHeader({ title, showBack = false, rightAction }: MobileHeaderProps) {
const router = useRouter();
const handleBack = () => {
if (window.history.length > 1) {
router.back();
} else {
router.push('/m');
}
};
return (
<header className="fixed top-0 left-0 right-0 z-50 bg-white border-b border-gray-200 pt-safe md:hidden">
<div className="flex items-center justify-between h-14 px-4">
{/* Left side */}
<div className="flex items-center w-20">
{showBack ? (
<button
onClick={handleBack}
className="flex items-center justify-center w-10 h-10 -ml-2 rounded-full hover:bg-gray-100 active:bg-gray-200"
aria-label="Go back"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6 text-gray-700"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
) : (
<Link href="/m">
<a className="flex items-center space-x-2">
<div className="w-8 h-8 bg-green-600 rounded-lg flex items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
</a>
</Link>
)}
</div>
{/* Center - Title */}
<div className="flex-1 text-center">
<h1 className="text-lg font-semibold text-gray-900 truncate">{title || 'LocalGreenChain'}</h1>
</div>
{/* Right side */}
<div className="flex items-center justify-end w-20">
{rightAction || (
<button
className="flex items-center justify-center w-10 h-10 -mr-2 rounded-full hover:bg-gray-100 active:bg-gray-200"
aria-label="Notifications"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6 text-gray-700"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
</button>
)}
</div>
</div>
</header>
);
}
export default MobileHeader;

View file

@ -1,138 +0,0 @@
import * as React from 'react';
import classNames from 'classnames';
interface PullToRefreshProps {
onRefresh: () => Promise<void>;
children: React.ReactNode;
className?: string;
}
export function PullToRefresh({ onRefresh, children, className }: PullToRefreshProps) {
const containerRef = React.useRef<HTMLDivElement>(null);
const [startY, setStartY] = React.useState(0);
const [pullDistance, setPullDistance] = React.useState(0);
const [isRefreshing, setIsRefreshing] = React.useState(false);
const [isPulling, setIsPulling] = React.useState(false);
const threshold = 80;
const maxPull = 120;
const resistance = 2.5;
const handleTouchStart = (e: React.TouchEvent) => {
// Only start if scrolled to top
if (containerRef.current && containerRef.current.scrollTop === 0) {
setStartY(e.touches[0].clientY);
setIsPulling(true);
}
};
const handleTouchMove = (e: React.TouchEvent) => {
if (!isPulling || isRefreshing) return;
const currentY = e.touches[0].clientY;
const diff = (currentY - startY) / resistance;
if (diff > 0) {
const distance = Math.min(maxPull, diff);
setPullDistance(distance);
// Prevent default scroll when pulling
if (containerRef.current && containerRef.current.scrollTop === 0) {
e.preventDefault();
}
}
};
const handleTouchEnd = async () => {
if (!isPulling || isRefreshing) return;
setIsPulling(false);
if (pullDistance >= threshold) {
setIsRefreshing(true);
setPullDistance(60); // Keep indicator visible during refresh
try {
await onRefresh();
} finally {
setIsRefreshing(false);
setPullDistance(0);
}
} else {
setPullDistance(0);
}
};
const progress = Math.min(1, pullDistance / threshold);
const rotation = pullDistance * 3;
return (
<div
ref={containerRef}
className={classNames('relative overflow-auto', className)}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{/* Pull indicator */}
<div
className="absolute left-1/2 transform -translate-x-1/2 z-10 transition-opacity"
style={{
top: pullDistance - 40,
opacity: pullDistance > 10 ? 1 : 0,
}}
>
<div
className={classNames(
'w-10 h-10 rounded-full bg-white shadow-lg flex items-center justify-center',
{ 'animate-spin': isRefreshing }
)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className={classNames('h-6 w-6 text-green-600 transition-transform', {
'animate-spin': isRefreshing,
})}
style={{ transform: isRefreshing ? undefined : `rotate(${rotation}deg)` }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</div>
</div>
{/* Pull text */}
{pullDistance > 10 && !isRefreshing && (
<div
className="absolute left-1/2 transform -translate-x-1/2 text-sm text-gray-500 transition-opacity z-10"
style={{
top: pullDistance + 5,
opacity: progress,
}}
>
{pullDistance >= threshold ? 'Release to refresh' : 'Pull to refresh'}
</div>
)}
{/* Content */}
<div
className="transition-transform"
style={{
transform: `translateY(${pullDistance}px)`,
transitionDuration: isPulling ? '0ms' : '200ms',
}}
>
{children}
</div>
</div>
);
}
export default PullToRefresh;

View file

@ -1,196 +0,0 @@
import * as React from 'react';
import classNames from 'classnames';
interface QRScannerProps {
onScan: (result: string) => void;
onError?: (error: Error) => void;
onClose?: () => void;
className?: string;
}
export function QRScanner({ onScan, onError, onClose, className }: QRScannerProps) {
const videoRef = React.useRef<HTMLVideoElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const [isScanning, setIsScanning] = React.useState(false);
const [hasCamera, setHasCamera] = React.useState(true);
const [cameraError, setCameraError] = React.useState<string | null>(null);
const streamRef = React.useRef<MediaStream | null>(null);
const startCamera = React.useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1280 },
height: { ideal: 720 },
},
});
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
setIsScanning(true);
}
} catch (err) {
const error = err as Error;
setHasCamera(false);
setCameraError(error.message);
onError?.(error);
}
}, [onError]);
const stopCamera = React.useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
setIsScanning(false);
}, []);
React.useEffect(() => {
startCamera();
return () => stopCamera();
}, [startCamera, stopCamera]);
// Simple QR detection simulation (in production, use a library like jsQR)
React.useEffect(() => {
if (!isScanning) return;
const scanInterval = setInterval(() => {
if (videoRef.current && canvasRef.current) {
const canvas = canvasRef.current;
const video = videoRef.current;
const ctx = canvas.getContext('2d');
if (ctx && video.readyState === video.HAVE_ENOUGH_DATA) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// In production, use jsQR library here:
// const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// const code = jsQR(imageData.data, imageData.width, imageData.height);
// if (code) {
// stopCamera();
// onScan(code.data);
// }
}
}
}, 100);
return () => clearInterval(scanInterval);
}, [isScanning, onScan, stopCamera]);
// Demo function to simulate a scan
const simulateScan = () => {
stopCamera();
onScan('plant:abc123-tomato-heirloom');
};
if (!hasCamera) {
return (
<div className={classNames('flex flex-col items-center justify-center p-8 bg-gray-100 rounded-lg', className)}>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-16 w-16 text-gray-400 mb-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<p className="text-gray-600 text-center mb-2">Camera access denied</p>
<p className="text-sm text-gray-500 text-center">{cameraError}</p>
<button
onClick={startCamera}
className="mt-4 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
>
Try again
</button>
</div>
);
}
return (
<div className={classNames('relative overflow-hidden rounded-lg bg-black', className)}>
{/* Video feed */}
<video
ref={videoRef}
className="w-full h-full object-cover"
playsInline
muted
/>
{/* Hidden canvas for image processing */}
<canvas ref={canvasRef} className="hidden" />
{/* Scanning overlay */}
<div className="absolute inset-0 flex items-center justify-center">
{/* Darkened corners */}
<div className="absolute inset-0 bg-black/50" />
{/* Transparent scanning area */}
<div className="relative w-64 h-64">
{/* Cut out the scanning area */}
<div className="absolute inset-0 border-2 border-white rounded-lg" />
{/* Corner markers */}
<div className="absolute top-0 left-0 w-8 h-8 border-t-4 border-l-4 border-green-500 rounded-tl-lg" />
<div className="absolute top-0 right-0 w-8 h-8 border-t-4 border-r-4 border-green-500 rounded-tr-lg" />
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-4 border-l-4 border-green-500 rounded-bl-lg" />
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-4 border-r-4 border-green-500 rounded-br-lg" />
{/* Scanning line animation */}
<div className="absolute top-0 left-0 right-0 h-0.5 bg-green-500 animate-scan-line" />
</div>
</div>
{/* Instructions */}
<div className="absolute bottom-20 left-0 right-0 text-center">
<p className="text-white text-sm bg-black/50 inline-block px-4 py-2 rounded-full">
Point camera at QR code
</p>
</div>
{/* Demo scan button (remove in production) */}
<button
onClick={simulateScan}
className="absolute bottom-4 left-1/2 transform -translate-x-1/2 px-6 py-2 bg-green-600 text-white rounded-full text-sm font-medium shadow-lg"
>
Demo: Simulate Scan
</button>
{/* Close button */}
{onClose && (
<button
onClick={() => {
stopCamera();
onClose();
}}
className="absolute top-4 right-4 w-10 h-10 bg-black/50 rounded-full flex items-center justify-center"
aria-label="Close scanner"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
);
}
export default QRScanner;

View file

@ -1,131 +0,0 @@
import * as React from 'react';
import classNames from 'classnames';
interface SwipeableCardProps {
children: React.ReactNode;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
leftAction?: React.ReactNode;
rightAction?: React.ReactNode;
className?: string;
}
export function SwipeableCard({
children,
onSwipeLeft,
onSwipeRight,
leftAction,
rightAction,
className,
}: SwipeableCardProps) {
const cardRef = React.useRef<HTMLDivElement>(null);
const [startX, setStartX] = React.useState(0);
const [currentX, setCurrentX] = React.useState(0);
const [isSwiping, setIsSwiping] = React.useState(false);
const threshold = 100;
const maxSwipe = 150;
const handleTouchStart = (e: React.TouchEvent) => {
setStartX(e.touches[0].clientX);
setIsSwiping(true);
};
const handleTouchMove = (e: React.TouchEvent) => {
if (!isSwiping) return;
const diff = e.touches[0].clientX - startX;
const clampedDiff = Math.max(-maxSwipe, Math.min(maxSwipe, diff));
// Only allow swiping in directions that have actions
if (diff > 0 && !onSwipeRight) return;
if (diff < 0 && !onSwipeLeft) return;
setCurrentX(clampedDiff);
};
const handleTouchEnd = () => {
setIsSwiping(false);
if (currentX > threshold && onSwipeRight) {
onSwipeRight();
} else if (currentX < -threshold && onSwipeLeft) {
onSwipeLeft();
}
setCurrentX(0);
};
const swipeProgress = Math.abs(currentX) / threshold;
const direction = currentX > 0 ? 'right' : 'left';
return (
<div className={classNames('relative overflow-hidden rounded-lg', className)}>
{/* Left action background */}
{rightAction && (
<div
className={classNames(
'absolute inset-y-0 left-0 flex items-center justify-start pl-4 bg-green-500 transition-opacity',
{
'opacity-100': currentX > 0,
'opacity-0': currentX <= 0,
}
)}
style={{ width: Math.abs(currentX) }}
>
{rightAction}
</div>
)}
{/* Right action background */}
{leftAction && (
<div
className={classNames(
'absolute inset-y-0 right-0 flex items-center justify-end pr-4 bg-red-500 transition-opacity',
{
'opacity-100': currentX < 0,
'opacity-0': currentX >= 0,
}
)}
style={{ width: Math.abs(currentX) }}
>
{leftAction}
</div>
)}
{/* Main card content */}
<div
ref={cardRef}
className="relative bg-white transition-transform touch-pan-y"
style={{
transform: `translateX(${currentX}px)`,
transitionDuration: isSwiping ? '0ms' : '200ms',
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{children}
</div>
{/* Swipe indicator */}
{isSwiping && Math.abs(currentX) > 20 && (
<div
className={classNames(
'absolute top-1/2 transform -translate-y-1/2 text-white text-sm font-medium',
{
'left-4': direction === 'right',
'right-4': direction === 'left',
}
)}
style={{ opacity: swipeProgress }}
>
{direction === 'right' && onSwipeRight && 'Release to confirm'}
{direction === 'left' && onSwipeLeft && 'Release to delete'}
</div>
)}
</div>
);
}
export default SwipeableCard;

View file

@ -1,6 +0,0 @@
export { BottomNav } from './BottomNav';
export { MobileHeader } from './MobileHeader';
export { InstallPrompt } from './InstallPrompt';
export { SwipeableCard } from './SwipeableCard';
export { PullToRefresh } from './PullToRefresh';
export { QRScanner } from './QRScanner';

View file

@ -1,127 +0,0 @@
/**
* NotificationBell Component
* Header bell icon with unread badge and dropdown
*/
import React, { useState, useEffect, useRef } from 'react';
import { NotificationList } from './NotificationList';
interface NotificationBellProps {
userId?: string;
}
export function NotificationBell({ userId = 'demo-user' }: NotificationBellProps) {
const [isOpen, setIsOpen] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
fetchUnreadCount();
// Poll for new notifications every 30 seconds
const interval = setInterval(fetchUnreadCount, 30000);
return () => clearInterval(interval);
}, [userId]);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
async function fetchUnreadCount() {
try {
const response = await fetch(`/api/notifications?userId=${userId}&unreadOnly=true&limit=1`);
const data = await response.json();
if (data.success) {
setUnreadCount(data.data.unreadCount);
}
} catch (error) {
console.error('Failed to fetch unread count:', error);
} finally {
setIsLoading(false);
}
}
function handleNotificationRead() {
setUnreadCount(prev => Math.max(0, prev - 1));
}
function handleAllRead() {
setUnreadCount(0);
}
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="relative p-2 text-gray-600 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-green-500 rounded-full transition-colors"
aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ''}`}
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
{!isLoading && unreadCount > 0 && (
<span className="absolute top-0 right-0 inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-white transform translate-x-1/2 -translate-y-1/2 bg-red-500 rounded-full">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-96 max-h-[80vh] bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden">
<div className="p-4 border-b border-gray-100">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Notifications</h3>
{unreadCount > 0 && (
<button
onClick={handleAllRead}
className="text-sm text-green-600 hover:text-green-700"
>
Mark all as read
</button>
)}
</div>
</div>
<div className="overflow-y-auto max-h-96">
<NotificationList
userId={userId}
onNotificationRead={handleNotificationRead}
onAllRead={handleAllRead}
compact
/>
</div>
<div className="p-3 border-t border-gray-100 bg-gray-50">
<a
href="/notifications"
className="block text-center text-sm text-green-600 hover:text-green-700"
>
View all notifications
</a>
</div>
</div>
)}
</div>
);
}

View file

@ -1,156 +0,0 @@
/**
* NotificationItem Component
* Single notification display with actions
*/
import React from 'react';
interface Notification {
id: string;
type: string;
title: string;
message: string;
actionUrl?: string;
read: boolean;
createdAt: string;
}
interface NotificationItemProps {
notification: Notification;
onMarkAsRead: (id: string) => void;
onDelete: (id: string) => void;
compact?: boolean;
}
const typeIcons: Record<string, { icon: string; bgColor: string }> = {
welcome: { icon: '👋', bgColor: 'bg-blue-100' },
plant_registered: { icon: '🌱', bgColor: 'bg-green-100' },
plant_reminder: { icon: '🌿', bgColor: 'bg-green-100' },
transport_alert: { icon: '🚚', bgColor: 'bg-yellow-100' },
farm_alert: { icon: '🏭', bgColor: 'bg-orange-100' },
harvest_ready: { icon: '🎉', bgColor: 'bg-green-100' },
demand_match: { icon: '🤝', bgColor: 'bg-purple-100' },
weekly_digest: { icon: '📊', bgColor: 'bg-blue-100' },
system_alert: { icon: '⚙️', bgColor: 'bg-gray-100' }
};
export function NotificationItem({
notification,
onMarkAsRead,
onDelete,
compact = false
}: NotificationItemProps) {
const { icon, bgColor } = typeIcons[notification.type] || typeIcons.system_alert;
function formatTimeAgo(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (seconds < 60) return 'Just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`;
return date.toLocaleDateString();
}
function handleClick() {
if (!notification.read) {
onMarkAsRead(notification.id);
}
if (notification.actionUrl) {
window.location.href = notification.actionUrl;
}
}
return (
<div
className={`relative group ${compact ? 'p-3' : 'p-4'} hover:bg-gray-50 transition-colors ${
!notification.read ? 'bg-green-50/30' : ''
}`}
>
<div
className="flex items-start cursor-pointer"
onClick={handleClick}
role="button"
tabIndex={0}
onKeyPress={e => e.key === 'Enter' && handleClick()}
>
{/* Icon */}
<div
className={`flex-shrink-0 ${compact ? 'w-8 h-8' : 'w-10 h-10'} ${bgColor} rounded-full flex items-center justify-center`}
>
<span className={compact ? 'text-sm' : 'text-lg'}>{icon}</span>
</div>
{/* Content */}
<div className={`flex-1 ${compact ? 'ml-3' : 'ml-4'}`}>
<div className="flex items-start justify-between">
<div className="flex-1">
<p
className={`font-medium text-gray-900 ${compact ? 'text-sm' : ''} ${
!notification.read ? 'font-semibold' : ''
}`}
>
{notification.title}
</p>
<p className={`text-gray-600 mt-0.5 ${compact ? 'text-xs line-clamp-2' : 'text-sm'}`}>
{notification.message}
</p>
</div>
{/* Unread indicator */}
{!notification.read && (
<div className="flex-shrink-0 ml-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
</div>
)}
</div>
<div className="flex items-center mt-2">
<span className={`text-gray-400 ${compact ? 'text-xs' : 'text-xs'}`}>
{formatTimeAgo(notification.createdAt)}
</span>
{notification.actionUrl && (
<span className={`ml-2 text-green-600 ${compact ? 'text-xs' : 'text-xs'}`}>
View details
</span>
)}
</div>
</div>
</div>
{/* Actions (visible on hover) */}
<div className="absolute right-4 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity flex space-x-1">
{!notification.read && (
<button
onClick={e => {
e.stopPropagation();
onMarkAsRead(notification.id);
}}
className="p-1.5 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded"
title="Mark as read"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</button>
)}
<button
onClick={e => {
e.stopPropagation();
onDelete(notification.id);
}}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded"
title="Delete"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
);
}

View file

@ -1,229 +0,0 @@
/**
* NotificationList Component
* Displays a list of notifications with infinite scroll
*/
import React, { useState, useEffect } from 'react';
import { NotificationItem } from './NotificationItem';
interface Notification {
id: string;
type: string;
title: string;
message: string;
actionUrl?: string;
read: boolean;
createdAt: string;
}
interface NotificationListProps {
userId?: string;
onNotificationRead?: () => void;
onAllRead?: () => void;
compact?: boolean;
showFilters?: boolean;
}
export function NotificationList({
userId = 'demo-user',
onNotificationRead,
onAllRead,
compact = false,
showFilters = false
}: NotificationListProps) {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState<'all' | 'unread'>('all');
const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0);
const limit = compact ? 5 : 20;
useEffect(() => {
fetchNotifications(true);
}, [userId, filter]);
async function fetchNotifications(reset = false) {
try {
setIsLoading(true);
const currentOffset = reset ? 0 : offset;
const unreadOnly = filter === 'unread';
const response = await fetch(
`/api/notifications?userId=${userId}&limit=${limit}&offset=${currentOffset}&unreadOnly=${unreadOnly}`
);
const data = await response.json();
if (data.success) {
if (reset) {
setNotifications(data.data.notifications);
} else {
setNotifications(prev => [...prev, ...data.data.notifications]);
}
setHasMore(data.data.pagination.hasMore);
setOffset(currentOffset + limit);
} else {
setError(data.error);
}
} catch (err: any) {
setError(err.message);
} finally {
setIsLoading(false);
}
}
async function handleMarkAsRead(notificationId: string) {
try {
const response = await fetch(`/api/notifications/${notificationId}?userId=${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ read: true, userId })
});
if (response.ok) {
setNotifications(prev =>
prev.map(n => (n.id === notificationId ? { ...n, read: true } : n))
);
onNotificationRead?.();
}
} catch (error) {
console.error('Failed to mark as read:', error);
}
}
async function handleMarkAllAsRead() {
try {
const response = await fetch('/api/notifications/read-all', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId })
});
if (response.ok) {
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
onAllRead?.();
}
} catch (error) {
console.error('Failed to mark all as read:', error);
}
}
async function handleDelete(notificationId: string) {
try {
const response = await fetch(`/api/notifications/${notificationId}?userId=${userId}`, {
method: 'DELETE'
});
if (response.ok) {
setNotifications(prev => prev.filter(n => n.id !== notificationId));
}
} catch (error) {
console.error('Failed to delete notification:', error);
}
}
if (error) {
return (
<div className="p-4 text-center text-red-600">
<p>Failed to load notifications</p>
<button
onClick={() => fetchNotifications(true)}
className="mt-2 text-sm text-green-600 hover:text-green-700"
>
Try again
</button>
</div>
);
}
return (
<div className={compact ? '' : 'max-w-2xl mx-auto'}>
{showFilters && (
<div className="flex items-center justify-between mb-4 p-4 bg-white rounded-lg border">
<div className="flex space-x-2">
<button
onClick={() => setFilter('all')}
className={`px-3 py-1 rounded-full text-sm ${
filter === 'all'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
All
</button>
<button
onClick={() => setFilter('unread')}
className={`px-3 py-1 rounded-full text-sm ${
filter === 'unread'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
Unread
</button>
</div>
<button
onClick={handleMarkAllAsRead}
className="text-sm text-green-600 hover:text-green-700"
>
Mark all as read
</button>
</div>
)}
{isLoading && notifications.length === 0 ? (
<div className="p-8 text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-gray-200 border-t-green-500"></div>
<p className="mt-2 text-gray-500">Loading notifications...</p>
</div>
) : notifications.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<svg
className="w-12 h-12 mx-auto mb-4 text-gray-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
<p>No notifications yet</p>
</div>
) : (
<div className="divide-y divide-gray-100">
{notifications.map(notification => (
<NotificationItem
key={notification.id}
notification={notification}
onMarkAsRead={handleMarkAsRead}
onDelete={handleDelete}
compact={compact}
/>
))}
</div>
)}
{hasMore && !isLoading && (
<div className="p-4 text-center">
<button
onClick={() => fetchNotifications(false)}
className="text-sm text-green-600 hover:text-green-700"
>
Load more
</button>
</div>
)}
{isLoading && notifications.length > 0 && (
<div className="p-4 text-center">
<div className="inline-block animate-spin rounded-full h-4 w-4 border-2 border-gray-200 border-t-green-500"></div>
</div>
)}
</div>
);
}

View file

@ -1,285 +0,0 @@
/**
* PreferencesForm Component
* User notification preferences management
*/
import React, { useState, useEffect } from 'react';
interface NotificationPreferences {
email: boolean;
push: boolean;
inApp: boolean;
plantReminders: boolean;
transportAlerts: boolean;
farmAlerts: boolean;
harvestAlerts: boolean;
demandMatches: boolean;
weeklyDigest: boolean;
quietHoursStart?: string;
quietHoursEnd?: string;
timezone?: string;
}
interface PreferencesFormProps {
userId?: string;
onSave?: (preferences: NotificationPreferences) => void;
}
export function PreferencesForm({ userId = 'demo-user', onSave }: PreferencesFormProps) {
const [preferences, setPreferences] = useState<NotificationPreferences>({
email: true,
push: true,
inApp: true,
plantReminders: true,
transportAlerts: true,
farmAlerts: true,
harvestAlerts: true,
demandMatches: true,
weeklyDigest: true
});
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
useEffect(() => {
fetchPreferences();
}, [userId]);
async function fetchPreferences() {
try {
const response = await fetch(`/api/notifications/preferences?userId=${userId}`);
const data = await response.json();
if (data.success) {
setPreferences(data.data);
}
} catch (error) {
console.error('Failed to fetch preferences:', error);
} finally {
setIsLoading(false);
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setIsSaving(true);
setMessage(null);
try {
const response = await fetch('/api/notifications/preferences', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...preferences, userId })
});
const data = await response.json();
if (data.success) {
setMessage({ type: 'success', text: 'Preferences saved successfully!' });
onSave?.(data.data);
} else {
setMessage({ type: 'error', text: data.error || 'Failed to save preferences' });
}
} catch (error) {
setMessage({ type: 'error', text: 'Failed to save preferences' });
} finally {
setIsSaving(false);
}
}
function handleToggle(key: keyof NotificationPreferences) {
setPreferences(prev => ({
...prev,
[key]: !prev[key]
}));
}
if (isLoading) {
return (
<div className="p-8 text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-gray-200 border-t-green-500"></div>
<p className="mt-2 text-gray-500">Loading preferences...</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{message && (
<div
className={`p-4 rounded-lg ${
message.type === 'success' ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'
}`}
>
{message.text}
</div>
)}
{/* Notification Channels */}
<div className="bg-white p-6 rounded-lg border">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Notification Channels</h3>
<p className="text-sm text-gray-600 mb-4">Choose how you want to receive notifications</p>
<div className="space-y-4">
<ToggleRow
label="Email notifications"
description="Receive notifications via email"
enabled={preferences.email}
onChange={() => handleToggle('email')}
/>
<ToggleRow
label="Push notifications"
description="Receive browser push notifications"
enabled={preferences.push}
onChange={() => handleToggle('push')}
/>
<ToggleRow
label="In-app notifications"
description="See notifications in the app"
enabled={preferences.inApp}
onChange={() => handleToggle('inApp')}
/>
</div>
</div>
{/* Notification Types */}
<div className="bg-white p-6 rounded-lg border">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Notification Types</h3>
<p className="text-sm text-gray-600 mb-4">Choose which types of notifications you want to receive</p>
<div className="space-y-4">
<ToggleRow
label="Plant reminders"
description="Reminders for watering, fertilizing, and plant care"
enabled={preferences.plantReminders}
onChange={() => handleToggle('plantReminders')}
/>
<ToggleRow
label="Transport alerts"
description="Updates about plant transport and logistics"
enabled={preferences.transportAlerts}
onChange={() => handleToggle('transportAlerts')}
/>
<ToggleRow
label="Farm alerts"
description="Alerts about vertical farm conditions and issues"
enabled={preferences.farmAlerts}
onChange={() => handleToggle('farmAlerts')}
/>
<ToggleRow
label="Harvest alerts"
description="Notifications when crops are ready for harvest"
enabled={preferences.harvestAlerts}
onChange={() => handleToggle('harvestAlerts')}
/>
<ToggleRow
label="Demand matches"
description="Alerts when your supply matches consumer demand"
enabled={preferences.demandMatches}
onChange={() => handleToggle('demandMatches')}
/>
<ToggleRow
label="Weekly digest"
description="Weekly summary of your activity and insights"
enabled={preferences.weeklyDigest}
onChange={() => handleToggle('weeklyDigest')}
/>
</div>
</div>
{/* Quiet Hours */}
<div className="bg-white p-6 rounded-lg border">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Quiet Hours</h3>
<p className="text-sm text-gray-600 mb-4">Set times when you don't want to receive notifications</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Start time</label>
<input
type="time"
value={preferences.quietHoursStart || ''}
onChange={e =>
setPreferences(prev => ({ ...prev, quietHoursStart: e.target.value }))
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">End time</label>
<input
type="time"
value={preferences.quietHoursEnd || ''}
onChange={e =>
setPreferences(prev => ({ ...prev, quietHoursEnd: e.target.value }))
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500"
/>
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Timezone</label>
<select
value={preferences.timezone || ''}
onChange={e => setPreferences(prev => ({ ...prev, timezone: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500"
>
<option value="">Select timezone</option>
<option value="America/New_York">Eastern Time</option>
<option value="America/Chicago">Central Time</option>
<option value="America/Denver">Mountain Time</option>
<option value="America/Los_Angeles">Pacific Time</option>
<option value="Europe/London">London</option>
<option value="Europe/Paris">Paris</option>
<option value="Asia/Tokyo">Tokyo</option>
<option value="UTC">UTC</option>
</select>
</div>
</div>
{/* Submit */}
<div className="flex justify-end">
<button
type="submit"
disabled={isSaving}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isSaving ? 'Saving...' : 'Save Preferences'}
</button>
</div>
</form>
);
}
interface ToggleRowProps {
label: string;
description: string;
enabled: boolean;
onChange: () => void;
}
function ToggleRow({ label, description, enabled, onChange }: ToggleRowProps) {
return (
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">{label}</p>
<p className="text-sm text-gray-500">{description}</p>
</div>
<button
type="button"
role="switch"
aria-checked={enabled}
onClick={onChange}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
enabled ? 'bg-green-600' : 'bg-gray-200'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
enabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
);
}

View file

@ -1,8 +0,0 @@
/**
* Notification Components Index
*/
export { NotificationBell } from './NotificationBell';
export { NotificationList } from './NotificationList';
export { NotificationItem } from './NotificationItem';
export { PreferencesForm } from './PreferencesForm';

View file

@ -1,167 +0,0 @@
/**
* Connection Status Indicator Component
*
* Shows the current WebSocket connection status with visual feedback.
*/
import React from 'react';
import classNames from 'classnames';
import { useConnectionStatus } from '../../lib/realtime/useSocket';
import type { ConnectionStatus as ConnectionStatusType } from '../../lib/realtime/types';
interface ConnectionStatusProps {
showLabel?: boolean;
showLatency?: boolean;
size?: 'sm' | 'md' | 'lg';
className?: string;
}
/**
* Get status color classes
*/
function getStatusColor(status: ConnectionStatusType): string {
switch (status) {
case 'connected':
return 'bg-green-500';
case 'connecting':
case 'reconnecting':
return 'bg-yellow-500 animate-pulse';
case 'disconnected':
return 'bg-gray-400';
case 'error':
return 'bg-red-500';
default:
return 'bg-gray-400';
}
}
/**
* Get status label
*/
function getStatusLabel(status: ConnectionStatusType): string {
switch (status) {
case 'connected':
return 'Connected';
case 'connecting':
return 'Connecting...';
case 'reconnecting':
return 'Reconnecting...';
case 'disconnected':
return 'Disconnected';
case 'error':
return 'Connection Error';
default:
return 'Unknown';
}
}
/**
* Get size classes
*/
function getSizeClasses(size: 'sm' | 'md' | 'lg'): { dot: string; text: string } {
switch (size) {
case 'sm':
return { dot: 'w-2 h-2', text: 'text-xs' };
case 'md':
return { dot: 'w-3 h-3', text: 'text-sm' };
case 'lg':
return { dot: 'w-4 h-4', text: 'text-base' };
default:
return { dot: 'w-3 h-3', text: 'text-sm' };
}
}
/**
* Connection Status component
*/
export function ConnectionStatus({
showLabel = true,
showLatency = false,
size = 'md',
className,
}: ConnectionStatusProps) {
const { status, latency } = useConnectionStatus();
const sizeClasses = getSizeClasses(size);
return (
<div
className={classNames(
'inline-flex items-center gap-2',
className
)}
title={getStatusLabel(status)}
>
{/* Status dot */}
<span
className={classNames(
'rounded-full',
sizeClasses.dot,
getStatusColor(status)
)}
/>
{/* Label */}
{showLabel && (
<span className={classNames('text-gray-600', sizeClasses.text)}>
{getStatusLabel(status)}
</span>
)}
{/* Latency */}
{showLatency && status === 'connected' && latency !== undefined && (
<span className={classNames('text-gray-400', sizeClasses.text)}>
({latency}ms)
</span>
)}
</div>
);
}
/**
* Compact connection indicator (dot only)
*/
export function ConnectionDot({ className }: { className?: string }) {
const { status } = useConnectionStatus();
return (
<span
className={classNames(
'inline-block w-2 h-2 rounded-full',
getStatusColor(status),
className
)}
title={getStatusLabel(status)}
/>
);
}
/**
* Connection banner for showing reconnection status
*/
export function ConnectionBanner() {
const { status } = useConnectionStatus();
if (status === 'connected') {
return null;
}
const bannerClasses = classNames(
'fixed top-0 left-0 right-0 py-2 px-4 text-center text-sm font-medium z-50',
{
'bg-yellow-100 text-yellow-800': status === 'connecting' || status === 'reconnecting',
'bg-red-100 text-red-800': status === 'error',
'bg-gray-100 text-gray-800': status === 'disconnected',
}
);
return (
<div className={bannerClasses}>
{status === 'connecting' && 'Connecting to real-time updates...'}
{status === 'reconnecting' && 'Connection lost. Reconnecting...'}
{status === 'error' && 'Connection error. Please check your network.'}
{status === 'disconnected' && 'Disconnected from real-time updates.'}
</div>
);
}
export default ConnectionStatus;

View file

@ -1,256 +0,0 @@
/**
* Live Chart Component
*
* Displays real-time data as a simple line chart.
*/
import React, { useMemo } from 'react';
import classNames from 'classnames';
import { useSocket } from '../../lib/realtime/useSocket';
import type { TransparencyEventType } from '../../lib/realtime/types';
interface LiveChartProps {
eventTypes?: TransparencyEventType[];
dataKey?: string;
title?: string;
color?: string;
height?: number;
maxDataPoints?: number;
showGrid?: boolean;
className?: string;
}
/**
* Simple SVG line chart for real-time data
*/
export function LiveChart({
eventTypes = ['system.metric'],
dataKey = 'value',
title = 'Live Data',
color = '#3B82F6',
height = 120,
maxDataPoints = 30,
showGrid = true,
className,
}: LiveChartProps) {
const { events } = useSocket({
eventTypes,
maxEvents: maxDataPoints,
});
// Extract data points
const dataPoints = useMemo(() => {
return events
.filter((e) => e.data && typeof e.data[dataKey] === 'number')
.map((e) => ({
value: e.data[dataKey] as number,
timestamp: new Date(e.timestamp).getTime(),
}))
.reverse()
.slice(-maxDataPoints);
}, [events, dataKey, maxDataPoints]);
// Calculate chart dimensions
const chartWidth = 400;
const chartHeight = height - 40;
const padding = { top: 10, right: 10, bottom: 20, left: 40 };
const innerWidth = chartWidth - padding.left - padding.right;
const innerHeight = chartHeight - padding.top - padding.bottom;
// Calculate scales
const { minValue, maxValue, points, pathD } = useMemo(() => {
if (dataPoints.length === 0) {
return { minValue: 0, maxValue: 100, points: [], pathD: '' };
}
const values = dataPoints.map((d) => d.value);
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const pts = dataPoints.map((d, i) => ({
x: padding.left + (i / Math.max(1, dataPoints.length - 1)) * innerWidth,
y: padding.top + innerHeight - ((d.value - min) / range) * innerHeight,
}));
const d = pts.length > 0
? `M ${pts.map((p) => `${p.x},${p.y}`).join(' L ')}`
: '';
return { minValue: min, maxValue: max, points: pts, pathD: d };
}, [dataPoints, innerWidth, innerHeight, padding]);
// Latest value
const latestValue = dataPoints.length > 0 ? dataPoints[dataPoints.length - 1].value : null;
return (
<div className={classNames('bg-white rounded-lg p-4 border border-gray-200', className)}>
{/* Header */}
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium text-gray-700">{title}</h4>
{latestValue !== null && (
<span className="text-lg font-bold" style={{ color }}>
{latestValue.toFixed(1)}
</span>
)}
</div>
{/* Chart */}
<svg
width="100%"
height={chartHeight}
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
preserveAspectRatio="xMidYMid meet"
>
{/* Grid */}
{showGrid && (
<g className="text-gray-200">
{/* Horizontal grid lines */}
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => (
<line
key={`h-${ratio}`}
x1={padding.left}
y1={padding.top + innerHeight * ratio}
x2={padding.left + innerWidth}
y2={padding.top + innerHeight * ratio}
stroke="currentColor"
strokeDasharray="2,2"
/>
))}
{/* Vertical grid lines */}
{[0, 0.5, 1].map((ratio) => (
<line
key={`v-${ratio}`}
x1={padding.left + innerWidth * ratio}
y1={padding.top}
x2={padding.left + innerWidth * ratio}
y2={padding.top + innerHeight}
stroke="currentColor"
strokeDasharray="2,2"
/>
))}
</g>
)}
{/* Y-axis labels */}
<g className="text-gray-500 text-xs">
<text x={padding.left - 5} y={padding.top + 4} textAnchor="end">
{maxValue.toFixed(0)}
</text>
<text x={padding.left - 5} y={padding.top + innerHeight} textAnchor="end">
{minValue.toFixed(0)}
</text>
</g>
{/* Line path */}
{pathD && (
<>
{/* Gradient area */}
<defs>
<linearGradient id="areaGradient" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.2} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<path
d={`${pathD} L ${points[points.length - 1]?.x},${padding.top + innerHeight} L ${points[0]?.x},${padding.top + innerHeight} Z`}
fill="url(#areaGradient)"
/>
<path
d={pathD}
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</>
)}
{/* Data points */}
{points.map((p, i) => (
<circle
key={i}
cx={p.x}
cy={p.y}
r={i === points.length - 1 ? 4 : 2}
fill={color}
/>
))}
{/* No data message */}
{dataPoints.length === 0 && (
<text
x={chartWidth / 2}
y={chartHeight / 2}
textAnchor="middle"
className="text-gray-400 text-sm"
>
Waiting for data...
</text>
)}
</svg>
</div>
);
}
/**
* Event count chart - shows event frequency over time
*/
export function EventCountChart({
className,
}: {
className?: string;
}) {
const { events } = useSocket({ maxEvents: 100 });
// Group events by minute
const countsByMinute = useMemo(() => {
const counts: Record<string, number> = {};
const now = Date.now();
// Initialize last 10 minutes
for (let i = 0; i < 10; i++) {
const minute = Math.floor((now - i * 60000) / 60000);
counts[minute] = 0;
}
// Count events
events.forEach((e) => {
const minute = Math.floor(new Date(e.timestamp).getTime() / 60000);
if (counts[minute] !== undefined) {
counts[minute]++;
}
});
return Object.entries(counts)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([, count]) => count);
}, [events]);
const maxCount = Math.max(...countsByMinute, 1);
return (
<div className={classNames('bg-white rounded-lg p-4 border border-gray-200', className)}>
<h4 className="text-sm font-medium text-gray-700 mb-2">Events per Minute</h4>
<div className="flex items-end gap-1 h-16">
{countsByMinute.map((count, i) => (
<div
key={i}
className="flex-1 bg-blue-500 rounded-t transition-all duration-300"
style={{ height: `${(count / maxCount) * 100}%` }}
title={`${count} events`}
/>
))}
</div>
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>10m ago</span>
<span>Now</span>
</div>
</div>
);
}
export default LiveChart;

View file

@ -1,255 +0,0 @@
/**
* Live Feed Component
*
* Displays a real-time feed of events from the LocalGreenChain system.
*/
import React, { useMemo } from 'react';
import classNames from 'classnames';
import { useLiveFeed } from '../../lib/realtime/useSocket';
import type { LiveFeedItem, RoomType, TransparencyEventType } from '../../lib/realtime/types';
import { EventCategory, getEventCategory } from '../../lib/realtime/events';
import { ConnectionStatus } from './ConnectionStatus';
interface LiveFeedProps {
rooms?: RoomType[];
eventTypes?: TransparencyEventType[];
maxItems?: number;
showConnectionStatus?: boolean;
showTimestamps?: boolean;
showClearButton?: boolean;
filterCategory?: EventCategory;
className?: string;
emptyMessage?: string;
}
/**
* Format timestamp for display
*/
function formatTimestamp(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - timestamp;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
if (diffSec < 60) {
return 'Just now';
} else if (diffMin < 60) {
return `${diffMin}m ago`;
} else if (diffHour < 24) {
return `${diffHour}h ago`;
} else {
return date.toLocaleDateString();
}
}
/**
* Get color classes for event type
*/
function getColorClasses(color: string): { bg: string; border: string; text: string } {
switch (color) {
case 'green':
return {
bg: 'bg-green-50',
border: 'border-green-200',
text: 'text-green-800',
};
case 'blue':
return {
bg: 'bg-blue-50',
border: 'border-blue-200',
text: 'text-blue-800',
};
case 'yellow':
return {
bg: 'bg-yellow-50',
border: 'border-yellow-200',
text: 'text-yellow-800',
};
case 'red':
return {
bg: 'bg-red-50',
border: 'border-red-200',
text: 'text-red-800',
};
case 'purple':
return {
bg: 'bg-purple-50',
border: 'border-purple-200',
text: 'text-purple-800',
};
case 'gray':
default:
return {
bg: 'bg-gray-50',
border: 'border-gray-200',
text: 'text-gray-800',
};
}
}
/**
* Single feed item component
*/
function FeedItem({
item,
showTimestamp,
}: {
item: LiveFeedItem;
showTimestamp: boolean;
}) {
const colors = getColorClasses(item.formatted.color);
return (
<div
className={classNames(
'p-3 rounded-lg border transition-all duration-300 animate-fadeIn',
colors.bg,
colors.border
)}
>
<div className="flex items-start gap-3">
{/* Icon */}
<span className="text-xl flex-shrink-0" role="img" aria-label={item.formatted.title}>
{item.formatted.icon}
</span>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className={classNames('font-medium text-sm', colors.text)}>
{item.formatted.title}
</span>
{showTimestamp && (
<span className="text-xs text-gray-400 flex-shrink-0">
{formatTimestamp(item.timestamp)}
</span>
)}
</div>
<p className="text-sm text-gray-600 mt-1 truncate">
{item.formatted.description}
</p>
</div>
</div>
</div>
);
}
/**
* Live Feed component
*/
export function LiveFeed({
rooms,
eventTypes,
maxItems = 20,
showConnectionStatus = true,
showTimestamps = true,
showClearButton = true,
filterCategory,
className,
emptyMessage = 'No events yet. Real-time updates will appear here.',
}: LiveFeedProps) {
const { items, isConnected, status, clearFeed } = useLiveFeed({
rooms,
eventTypes,
maxEvents: maxItems,
});
// Filter items by category if specified
const filteredItems = useMemo(() => {
if (!filterCategory) return items;
return items.filter((item) => {
const category = getEventCategory(item.event.type);
return category === filterCategory;
});
}, [items, filterCategory]);
return (
<div className={classNames('flex flex-col h-full', className)}>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<h3 className="text-lg font-semibold text-gray-900">Live Feed</h3>
{showConnectionStatus && <ConnectionStatus size="sm" showLabel={false} />}
</div>
<div className="flex items-center gap-2">
{filteredItems.length > 0 && (
<span className="text-sm text-gray-500">
{filteredItems.length} event{filteredItems.length !== 1 ? 's' : ''}
</span>
)}
{showClearButton && filteredItems.length > 0 && (
<button
onClick={clearFeed}
className="text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
Clear
</button>
)}
</div>
</div>
{/* Feed content */}
<div className="flex-1 overflow-y-auto space-y-2">
{filteredItems.length === 0 ? (
<div className="text-center py-8">
<div className="text-4xl mb-2">📡</div>
<p className="text-gray-500 text-sm">{emptyMessage}</p>
{!isConnected && (
<p className="text-yellow-600 text-xs mt-2">
Status: {status}
</p>
)}
</div>
) : (
filteredItems.map((item) => (
<FeedItem
key={item.id}
item={item}
showTimestamp={showTimestamps}
/>
))
)}
</div>
</div>
);
}
/**
* Compact live feed for sidebars
*/
export function CompactLiveFeed({
maxItems = 5,
className,
}: {
maxItems?: number;
className?: string;
}) {
const { items } = useLiveFeed({ maxEvents: maxItems });
if (items.length === 0) {
return null;
}
return (
<div className={classNames('space-y-1', className)}>
{items.slice(0, maxItems).map((item) => (
<div
key={item.id}
className="flex items-center gap-2 py-1 text-sm"
>
<span>{item.formatted.icon}</span>
<span className="truncate text-gray-600">
{item.formatted.description}
</span>
</div>
))}
</div>
);
}
export default LiveFeed;

View file

@ -1,325 +0,0 @@
/**
* Notification Toast Component
*
* Displays real-time notifications as toast messages.
*/
import React, { useEffect, useState, useCallback } from 'react';
import classNames from 'classnames';
import { useSocketContext } from '../../lib/realtime/SocketContext';
import type { RealtimeNotification } from '../../lib/realtime/types';
interface NotificationToastProps {
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
maxVisible?: number;
autoHideDuration?: number;
className?: string;
}
/**
* Get position classes
*/
function getPositionClasses(position: NotificationToastProps['position']): string {
switch (position) {
case 'top-left':
return 'top-4 left-4';
case 'bottom-right':
return 'bottom-4 right-4';
case 'bottom-left':
return 'bottom-4 left-4';
case 'top-right':
default:
return 'top-4 right-4';
}
}
/**
* Get notification type styles
*/
function getTypeStyles(type: RealtimeNotification['type']): {
bg: string;
border: string;
icon: string;
iconColor: string;
} {
switch (type) {
case 'success':
return {
bg: 'bg-green-50',
border: 'border-green-200',
icon: '✓',
iconColor: 'text-green-600',
};
case 'warning':
return {
bg: 'bg-yellow-50',
border: 'border-yellow-200',
icon: '⚠',
iconColor: 'text-yellow-600',
};
case 'error':
return {
bg: 'bg-red-50',
border: 'border-red-200',
icon: '✕',
iconColor: 'text-red-600',
};
case 'info':
default:
return {
bg: 'bg-blue-50',
border: 'border-blue-200',
icon: '',
iconColor: 'text-blue-600',
};
}
}
/**
* Single toast notification
*/
function Toast({
notification,
onDismiss,
autoHideDuration,
}: {
notification: RealtimeNotification;
onDismiss: (id: string) => void;
autoHideDuration: number;
}) {
const [isVisible, setIsVisible] = useState(false);
const [isLeaving, setIsLeaving] = useState(false);
const styles = getTypeStyles(notification.type);
// Animate in
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), 10);
return () => clearTimeout(timer);
}, []);
// Auto hide
useEffect(() => {
if (autoHideDuration <= 0) return;
const timer = setTimeout(() => {
handleDismiss();
}, autoHideDuration);
return () => clearTimeout(timer);
}, [autoHideDuration]);
const handleDismiss = useCallback(() => {
setIsLeaving(true);
setTimeout(() => {
onDismiss(notification.id);
}, 300);
}, [notification.id, onDismiss]);
return (
<div
className={classNames(
'max-w-sm w-full p-4 rounded-lg border shadow-lg transition-all duration-300',
styles.bg,
styles.border,
{
'opacity-0 translate-x-4': !isVisible || isLeaving,
'opacity-100 translate-x-0': isVisible && !isLeaving,
}
)}
role="alert"
>
<div className="flex items-start gap-3">
{/* Icon */}
<span className={classNames('text-xl font-bold flex-shrink-0', styles.iconColor)}>
{styles.icon}
</span>
{/* Content */}
<div className="flex-1 min-w-0">
<h4 className="font-medium text-gray-900 text-sm">
{notification.title}
</h4>
<p className="text-sm text-gray-600 mt-1">
{notification.message}
</p>
</div>
{/* Close button */}
<button
onClick={handleDismiss}
className="flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Dismiss notification"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
</div>
);
}
/**
* Notification Toast container
*/
export function NotificationToast({
position = 'top-right',
maxVisible = 5,
autoHideDuration = 5000,
className,
}: NotificationToastProps) {
const { notifications, dismissNotification } = useSocketContext();
// Only show non-read, non-dismissed notifications
const visibleNotifications = notifications
.filter((n) => !n.read && !n.dismissed)
.slice(0, maxVisible);
if (visibleNotifications.length === 0) {
return null;
}
return (
<div
className={classNames(
'fixed z-50 flex flex-col gap-2',
getPositionClasses(position),
className
)}
>
{visibleNotifications.map((notification) => (
<Toast
key={notification.id}
notification={notification}
onDismiss={dismissNotification}
autoHideDuration={autoHideDuration}
/>
))}
</div>
);
}
/**
* Notification bell with badge
*/
export function NotificationBell({
onClick,
className,
}: {
onClick?: () => void;
className?: string;
}) {
const { unreadCount } = useSocketContext();
return (
<button
onClick={onClick}
className={classNames(
'relative p-2 text-gray-600 hover:text-gray-900 transition-colors',
className
)}
aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ''}`}
>
{/* Bell icon */}
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
{/* Badge */}
{unreadCount > 0 && (
<span className="absolute top-0 right-0 inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-bold leading-none text-white transform translate-x-1/2 -translate-y-1/2 bg-red-500 rounded-full">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
);
}
/**
* Notification list dropdown
*/
export function NotificationList({
className,
onClose,
}: {
className?: string;
onClose?: () => void;
}) {
const { notifications, markNotificationRead, markAllRead } = useSocketContext();
return (
<div
className={classNames(
'w-80 max-h-96 bg-white rounded-lg shadow-lg border border-gray-200 overflow-hidden',
className
)}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50">
<h3 className="font-semibold text-gray-900">Notifications</h3>
{notifications.length > 0 && (
<button
onClick={markAllRead}
className="text-sm text-blue-600 hover:text-blue-800"
>
Mark all read
</button>
)}
</div>
{/* List */}
<div className="overflow-y-auto max-h-72">
{notifications.length === 0 ? (
<div className="py-8 text-center text-gray-500">
<div className="text-3xl mb-2">🔔</div>
<p className="text-sm">No notifications</p>
</div>
) : (
notifications.map((notification) => (
<div
key={notification.id}
className={classNames(
'px-4 py-3 border-b border-gray-100 hover:bg-gray-50 cursor-pointer transition-colors',
{ 'bg-blue-50': !notification.read }
)}
onClick={() => markNotificationRead(notification.id)}
>
<div className="flex items-start gap-2">
<span className="text-lg">
{getTypeStyles(notification.type).icon}
</span>
<div className="flex-1 min-w-0">
<p className={classNames('text-sm', { 'font-medium': !notification.read })}>
{notification.title}
</p>
<p className="text-xs text-gray-500 truncate">
{notification.message}
</p>
<p className="text-xs text-gray-400 mt-1">
{new Date(notification.timestamp).toLocaleTimeString()}
</p>
</div>
</div>
</div>
))
)}
</div>
</div>
);
}
export default NotificationToast;

View file

@ -1,27 +0,0 @@
/**
* Real-Time Components for LocalGreenChain
*
* Export all real-time UI components.
*/
export {
ConnectionStatus,
ConnectionDot,
ConnectionBanner,
} from './ConnectionStatus';
export {
LiveFeed,
CompactLiveFeed,
} from './LiveFeed';
export {
NotificationToast,
NotificationBell,
NotificationList,
} from './NotificationToast';
export {
LiveChart,
EventCountChart,
} from './LiveChart';

View file

@ -1,263 +0,0 @@
#!/usr/bin/env bun
/**
* NetworkDiscoveryAgent Deployment Script
* Agent 8 - Geographic Network Discovery and Analysis
*
* This script provides standalone deployment for the NetworkDiscoveryAgent,
* which maps and analyzes the geographic distribution of the plant network.
*
* Responsibilities:
* - Map plant distribution across regions
* - Identify network hotspots and clusters
* - Suggest grower/consumer connections
* - Track network growth patterns
* - Detect coverage gaps
*
* Usage:
* bun run deploy/NetworkDiscoveryAgent.ts
* bun run deploy:network-discovery
*
* Environment Variables:
* AGENT_INTERVAL_MS - Execution interval (default: 600000 = 10 min)
* AGENT_LOG_LEVEL - Log level: debug, info, warn, error (default: info)
* AGENT_AUTO_RESTART - Auto-restart on failure (default: true)
* AGENT_MAX_RETRIES - Max retry attempts (default: 3)
*/
import { getNetworkDiscoveryAgent, NetworkDiscoveryAgent } from '../lib/agents/NetworkDiscoveryAgent';
// Configuration from environment
const config = {
intervalMs: parseInt(process.env.AGENT_INTERVAL_MS || '600000'),
logLevel: process.env.AGENT_LOG_LEVEL || 'info',
autoRestart: process.env.AGENT_AUTO_RESTART !== 'false',
maxRetries: parseInt(process.env.AGENT_MAX_RETRIES || '3'),
};
// Logger utility
const log = {
debug: (...args: any[]) => config.logLevel === 'debug' && console.log('[DEBUG]', ...args),
info: (...args: any[]) => ['debug', 'info'].includes(config.logLevel) && console.log('[INFO]', ...args),
warn: (...args: any[]) => ['debug', 'info', 'warn'].includes(config.logLevel) && console.warn('[WARN]', ...args),
error: (...args: any[]) => console.error('[ERROR]', ...args),
};
/**
* Format uptime as human-readable string
*/
function formatUptime(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ${hours % 24}h ${minutes % 60}m`;
if (hours > 0) return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
return `${seconds}s`;
}
/**
* Display agent status
*/
function displayStatus(agent: NetworkDiscoveryAgent): void {
const metrics = agent.getMetrics();
const analysis = agent.getNetworkAnalysis();
const clusters = agent.getClusters();
const gaps = agent.getCoverageGaps();
const suggestions = agent.getConnectionSuggestions();
const growth = agent.getGrowthHistory();
const regions = agent.getRegionalStats();
console.log('\n' + '='.repeat(60));
console.log(' NETWORK DISCOVERY AGENT - STATUS REPORT');
console.log('='.repeat(60));
// Agent Metrics
console.log('\n AGENT METRICS');
console.log(' ' + '-'.repeat(40));
console.log(` Status: ${agent.status}`);
console.log(` Uptime: ${formatUptime(metrics.uptime)}`);
console.log(` Tasks Completed: ${metrics.tasksCompleted}`);
console.log(` Tasks Failed: ${metrics.tasksFailed}`);
console.log(` Avg Execution: ${Math.round(metrics.averageExecutionMs)}ms`);
console.log(` Last Run: ${metrics.lastRunAt || 'Never'}`);
// Network Analysis
console.log('\n NETWORK ANALYSIS');
console.log(' ' + '-'.repeat(40));
console.log(` Total Nodes: ${analysis.totalNodes}`);
console.log(` Connections: ${analysis.totalConnections}`);
console.log(` Clusters: ${clusters.length}`);
console.log(` Hotspots: ${analysis.hotspots.length}`);
console.log(` Coverage Gaps: ${gaps.length}`);
console.log(` Suggestions: ${suggestions.length}`);
// Cluster Details
if (clusters.length > 0) {
console.log('\n TOP CLUSTERS');
console.log(' ' + '-'.repeat(40));
const topClusters = clusters.slice(0, 5);
for (const cluster of topClusters) {
console.log(` - ${cluster.activityLevel.toUpperCase()} activity cluster`);
console.log(` Nodes: ${cluster.nodes.length}, Radius: ${cluster.radius}km`);
if (cluster.dominantSpecies.length > 0) {
console.log(` Species: ${cluster.dominantSpecies.slice(0, 3).join(', ')}`);
}
}
}
// Coverage Gaps
if (gaps.length > 0) {
console.log('\n COVERAGE GAPS');
console.log(' ' + '-'.repeat(40));
for (const gap of gaps.slice(0, 3)) {
console.log(` - ${gap.populationDensity.toUpperCase()} area`);
console.log(` Distance to nearest: ${gap.distanceToNearest}km`);
console.log(` Potential demand: ${gap.potentialDemand}`);
}
}
// Top Suggestions
if (suggestions.length > 0) {
console.log('\n TOP CONNECTION SUGGESTIONS');
console.log(' ' + '-'.repeat(40));
for (const suggestion of suggestions.slice(0, 3)) {
console.log(` - Strength: ${suggestion.strength}%`);
console.log(` Distance: ${suggestion.distance}km`);
console.log(` Reason: ${suggestion.reason}`);
}
}
// Regional Stats
if (regions.length > 0) {
console.log('\n REGIONAL STATISTICS');
console.log(' ' + '-'.repeat(40));
for (const region of regions) {
if (region.nodeCount > 0) {
console.log(` ${region.region}:`);
console.log(` Nodes: ${region.nodeCount}, Plants: ${region.plantCount}`);
console.log(` Species: ${region.uniqueSpecies}, Activity: ${region.avgActivityScore}`);
}
}
}
// Growth Trend
if (growth.length > 0) {
const latest = growth[growth.length - 1];
console.log('\n NETWORK GROWTH');
console.log(' ' + '-'.repeat(40));
console.log(` Total Nodes: ${latest.totalNodes}`);
console.log(` Total Connections: ${latest.totalConnections}`);
console.log(` New Nodes/Week: ${latest.newNodesWeek}`);
console.log(` Geographic Span: ${latest.geographicExpansion}km`);
}
// Alerts
const alerts = agent.getAlerts();
const unacknowledged = alerts.filter(a => !a.acknowledged);
if (unacknowledged.length > 0) {
console.log('\n ACTIVE ALERTS');
console.log(' ' + '-'.repeat(40));
for (const alert of unacknowledged.slice(0, 5)) {
console.log(` [${alert.severity.toUpperCase()}] ${alert.title}`);
console.log(` ${alert.message}`);
}
}
console.log('\n' + '='.repeat(60));
}
/**
* Main deployment function
*/
async function deploy(): Promise<void> {
console.log('\n' + '='.repeat(60));
console.log(' DEPLOYING NETWORK DISCOVERY AGENT (Agent 8)');
console.log('='.repeat(60));
console.log(`\n Configuration:`);
console.log(` - Interval: ${config.intervalMs}ms (${config.intervalMs / 60000} min)`);
console.log(` - Log Level: ${config.logLevel}`);
console.log(` - Auto Restart: ${config.autoRestart}`);
console.log(` - Max Retries: ${config.maxRetries}`);
console.log('');
// Get agent instance
const agent = getNetworkDiscoveryAgent();
// Register event handlers
agent.on('task_completed', (data) => {
log.info(`Task completed: ${JSON.stringify(data.result)}`);
});
agent.on('task_failed', (data) => {
log.error(`Task failed: ${data.error}`);
});
agent.on('agent_started', () => {
log.info('Network Discovery Agent started');
});
agent.on('agent_stopped', () => {
log.info('Network Discovery Agent stopped');
});
// Start the agent
log.info('Starting Network Discovery Agent...');
try {
await agent.start();
log.info('Agent started successfully');
// Run initial discovery
log.info('Running initial network discovery...');
await agent.runOnce();
log.info('Initial discovery complete');
// Display initial status
displayStatus(agent);
// Set up periodic status display
const statusInterval = setInterval(() => {
displayStatus(agent);
}, config.intervalMs);
// Handle shutdown signals
const shutdown = async (signal: string) => {
log.info(`Received ${signal}, shutting down...`);
clearInterval(statusInterval);
try {
await agent.stop();
log.info('Agent stopped gracefully');
process.exit(0);
} catch (error) {
log.error('Error during shutdown:', error);
process.exit(1);
}
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
// Keep the process running
log.info(`Agent running. Press Ctrl+C to stop.`);
log.info(`Next discovery in ${config.intervalMs / 60000} minutes...`);
} catch (error) {
log.error('Failed to start agent:', error);
if (config.autoRestart) {
log.info('Auto-restart enabled, retrying in 10 seconds...');
setTimeout(() => deploy(), 10000);
} else {
process.exit(1);
}
}
}
// Run deployment
deploy().catch((error) => {
console.error('Deployment failed:', error);
process.exit(1);
});

View file

@ -201,7 +201,7 @@ export class AgentOrchestrator {
}
// Stop all agents
for (const agent of Array.from(this.agents.values())) {
for (const agent of this.agents.values()) {
try {
await agent.stop();
console.log(`[Orchestrator] Stopped: ${agent.config.name}`);
@ -275,7 +275,7 @@ export class AgentOrchestrator {
* Perform health check on all agents
*/
private performHealthCheck(): void {
for (const [agentId, agent] of Array.from(this.agents.entries())) {
for (const [agentId, agent] of this.agents) {
const health = this.getAgentHealth(agentId);
if (!health.isHealthy) {
@ -296,7 +296,7 @@ export class AgentOrchestrator {
private aggregateAlerts(): void {
this.aggregatedAlerts = [];
for (const agent of Array.from(this.agents.values())) {
for (const agent of this.agents.values()) {
const alerts = agent.getAlerts()
.filter(a => !a.acknowledged)
.slice(-this.config.maxAlertsPerAgent);

View file

@ -168,7 +168,7 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
*/
async runOnce(): Promise<AgentTask | null> {
const blockchain = getBlockchain();
const chain = blockchain.chain;
const chain = blockchain.getChain();
const plants = chain.slice(1); // Skip genesis
let profilesUpdated = 0;
@ -265,9 +265,9 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
for (const block of healthyPlants) {
const env = block.plant.environment;
if (env?.soil?.pH) pHValues.push(env.soil.pH);
if (env?.climate?.temperatureDay) tempValues.push(env.climate.temperatureDay);
if (env?.climate?.humidityAverage) humidityValues.push(env.climate.humidityAverage);
if (env?.lighting?.naturalLight?.hoursPerDay) lightValues.push(env.lighting.naturalLight.hoursPerDay);
if (env?.climate?.avgTemperature) tempValues.push(env.climate.avgTemperature);
if (env?.climate?.avgHumidity) humidityValues.push(env.climate.avgHumidity);
if (env?.lighting?.hoursPerDay) lightValues.push(env.lighting.hoursPerDay);
}
const profile: EnvironmentProfile = existing || {
@ -357,16 +357,15 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
// Lighting analysis
if (env.lighting) {
const lightHours = env.lighting.naturalLight?.hoursPerDay || env.lighting.artificialLight?.hoursPerDay;
const lightDiff = lightHours
? Math.abs(lightHours - profile.optimalConditions.lightHours.optimal)
const lightDiff = env.lighting.hoursPerDay
? Math.abs(env.lighting.hoursPerDay - profile.optimalConditions.lightHours.optimal)
: 2;
lightingScore = Math.max(0, 100 - lightDiff * 15);
if (lightDiff > 2) {
improvements.push({
category: 'lighting',
currentState: `${lightHours || 'unknown'} hours/day`,
currentState: `${env.lighting.hoursPerDay || 'unknown'} hours/day`,
recommendedState: `${profile.optimalConditions.lightHours.optimal} hours/day`,
priority: lightDiff > 4 ? 'high' : 'medium',
expectedImpact: 'Better photosynthesis and growth',
@ -377,11 +376,11 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
// Climate analysis
if (env.climate) {
const tempDiff = env.climate.temperatureDay
? Math.abs(env.climate.temperatureDay - profile.optimalConditions.temperature.optimal)
const tempDiff = env.climate.avgTemperature
? Math.abs(env.climate.avgTemperature - profile.optimalConditions.temperature.optimal)
: 5;
const humDiff = env.climate.humidityAverage
? Math.abs(env.climate.humidityAverage - profile.optimalConditions.humidity.optimal)
const humDiff = env.climate.avgHumidity
? Math.abs(env.climate.avgHumidity - profile.optimalConditions.humidity.optimal)
: 10;
climateScore = Math.max(0, 100 - tempDiff * 5 - humDiff * 1);
@ -389,7 +388,7 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
if (tempDiff > 3) {
improvements.push({
category: 'climate',
currentState: `${env.climate.temperatureDay?.toFixed(1) || 'unknown'}°C`,
currentState: `${env.climate.avgTemperature?.toFixed(1) || 'unknown'}°C`,
recommendedState: `${profile.optimalConditions.temperature.optimal}°C`,
priority: tempDiff > 6 ? 'high' : 'medium',
expectedImpact: 'Reduced stress and improved growth',
@ -409,8 +408,7 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
// Nutrients analysis
if (env.nutrients) {
nutrientsScore = 75; // Base score if nutrient data exists
// Bonus for complete NPK profile
if (env.nutrients.nitrogen && env.nutrients.phosphorus && env.nutrients.potassium) {
if (env.nutrients.fertilizer?.schedule === 'regular') {
nutrientsScore = 90;
}
}
@ -464,7 +462,7 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
// Find common soil types
const soilTypes = plantsWithEnv
.map(p => p.plant.environment?.soil?.type)
.map(p => p.plant.environment?.soil?.soilType)
.filter(Boolean);
const commonSoilType = this.findMostCommon(soilTypes as string[]);
@ -473,7 +471,7 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
patterns.push({
patternId: `pattern-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`,
species,
conditions: { soil: { type: commonSoilType } } as any,
conditions: { soil: { soilType: commonSoilType } } as any,
successMetric: 'health',
successValue: 85,
sampleSize: plantsWithEnv.length,
@ -529,7 +527,7 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
if (cached) return cached;
const blockchain = getBlockchain();
const chain = blockchain.chain;
const chain = blockchain.getChain();
const block1 = chain.find(b => b.plant.id === plant1Id);
const block2 = chain.find(b => b.plant.id === plant2Id);
@ -547,14 +545,14 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
// Compare soil
if (env1?.soil && env2?.soil) {
totalFactors++;
if (env1.soil.type === env2.soil.type) {
if (env1.soil.soilType === env2.soil.soilType) {
matchingFactors.push('Soil type');
matchScore++;
} else {
differingFactors.push({
factor: 'Soil type',
plant1Value: env1.soil.type,
plant2Value: env2.soil.type
plant1Value: env1.soil.soilType,
plant2Value: env2.soil.soilType
});
}
@ -590,7 +588,7 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
if (env1?.climate && env2?.climate) {
totalFactors++;
const tempDiff = Math.abs(
(env1.climate.temperatureDay || 0) - (env2.climate.temperatureDay || 0)
(env1.climate.avgTemperature || 0) - (env2.climate.avgTemperature || 0)
);
if (tempDiff < 3) {
matchingFactors.push('Temperature');
@ -598,8 +596,8 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
} else {
differingFactors.push({
factor: 'Temperature',
plant1Value: env1.climate.temperatureDay,
plant2Value: env2.climate.temperatureDay
plant1Value: env1.climate.avgTemperature,
plant2Value: env2.climate.avgTemperature
});
}
}

View file

@ -178,7 +178,7 @@ export class GrowerAdvisoryAgent extends BaseAgent {
*/
private updateGrowerProfiles(): void {
const blockchain = getBlockchain();
const chain = blockchain.chain.slice(1);
const chain = blockchain.getChain().slice(1);
const ownerPlants = new Map<string, typeof chain>();
@ -219,11 +219,7 @@ export class GrowerAdvisoryAgent extends BaseAgent {
if (['growing', 'mature', 'flowering', 'fruiting'].includes(plant.plant.status)) {
existing.healthy++;
}
// Estimate yield based on health score, or use default of 2kg
const healthMultiplier = plant.plant.growthMetrics?.healthScore
? plant.plant.growthMetrics.healthScore / 50
: 1;
existing.yield += 2 * healthMultiplier;
existing.yield += plant.plant.growthMetrics?.estimatedYieldKg || 2;
historyMap.set(crop, existing);
}

View file

@ -102,7 +102,7 @@ export class NetworkDiscoveryAgent extends BaseAgent {
*/
async runOnce(): Promise<AgentTask | null> {
const blockchain = getBlockchain();
const chain = blockchain.chain;
const chain = blockchain.getChain();
const plants = chain.slice(1);
// Build network from plant data

View file

@ -58,7 +58,7 @@ export class PlantLineageAgent extends BaseAgent {
*/
async runOnce(): Promise<AgentTask | null> {
const blockchain = getBlockchain();
const chain = blockchain.chain;
const chain = blockchain.getChain();
// Skip genesis block
const plantBlocks = chain.slice(1);
@ -133,7 +133,7 @@ export class PlantLineageAgent extends BaseAgent {
totalLineageSize: ancestors.length + descendants.length + 1,
propagationChain,
geographicSpread,
oldestAncestorDate: oldestAncestor?.timestamp || plant.registeredAt,
oldestAncestorDate: oldestAncestor?.timestamp || plant.dateAcquired,
healthScore: this.calculateHealthScore(plant, chain)
};
}

View file

@ -232,7 +232,7 @@ export class SustainabilityAgent extends BaseAgent {
*/
private calculateWaterMetrics(): WaterMetrics {
const blockchain = getBlockchain();
const plantCount = blockchain.chain.length - 1;
const plantCount = blockchain.getChain().length - 1;
// Simulate water usage based on plant count
// Vertical farms use ~10% of traditional water
@ -265,7 +265,7 @@ export class SustainabilityAgent extends BaseAgent {
*/
private calculateWasteMetrics(): WasteMetrics {
const blockchain = getBlockchain();
const plants = blockchain.chain.slice(1);
const plants = blockchain.getChain().slice(1);
const deceasedPlants = plants.filter(p => p.plant.status === 'deceased').length;
const totalPlants = plants.length;
@ -311,7 +311,7 @@ export class SustainabilityAgent extends BaseAgent {
// Biodiversity: based on plant variety
const blockchain = getBlockchain();
const plants = blockchain.chain.slice(1);
const plants = blockchain.getChain().slice(1);
const uniqueSpecies = new Set(plants.map(p => p.plant.commonName)).size;
const biodiversity = Math.min(100, 30 + uniqueSpecies * 5);

View file

@ -1,406 +0,0 @@
/**
* Data Aggregator for Analytics
* Aggregates data from various sources for analytics dashboards
*/
import {
AnalyticsOverview,
PlantAnalytics,
TransportAnalytics,
FarmAnalytics,
SustainabilityAnalytics,
TimeRange,
DateRange,
TimeSeriesDataPoint,
AnalyticsFilters,
AggregationConfig,
GroupByPeriod,
} from './types';
import { subDays, subMonths, startOfDay, endOfDay, format, eachDayOfInterval, parseISO } from 'date-fns';
// Mock data generators for demonstration - in production these would query actual databases
/**
* Get date range from TimeRange enum
*/
export function getDateRangeFromTimeRange(timeRange: TimeRange): DateRange {
const end = endOfDay(new Date());
let start: Date;
switch (timeRange) {
case '7d':
start = startOfDay(subDays(new Date(), 7));
break;
case '30d':
start = startOfDay(subDays(new Date(), 30));
break;
case '90d':
start = startOfDay(subDays(new Date(), 90));
break;
case '365d':
start = startOfDay(subDays(new Date(), 365));
break;
case 'all':
default:
start = startOfDay(subMonths(new Date(), 24)); // Default to 2 years
}
return { start, end };
}
/**
* Generate time series data points for a date range
*/
export function generateTimeSeriesPoints(
dateRange: DateRange,
valueGenerator: (date: Date, index: number) => number
): TimeSeriesDataPoint[] {
const days = eachDayOfInterval({ start: dateRange.start, end: dateRange.end });
return days.map((day, index) => ({
timestamp: format(day, 'yyyy-MM-dd'),
value: valueGenerator(day, index),
label: format(day, 'MMM d'),
}));
}
/**
* Aggregate data by time period
*/
export function aggregateByPeriod<T>(
data: T[],
dateField: keyof T,
valueField: keyof T,
period: GroupByPeriod
): Record<string, number> {
const aggregated: Record<string, number> = {};
data.forEach((item) => {
const date = parseISO(item[dateField] as string);
let key: string;
switch (period) {
case 'hour':
key = format(date, 'yyyy-MM-dd HH:00');
break;
case 'day':
key = format(date, 'yyyy-MM-dd');
break;
case 'week':
key = format(date, "yyyy-'W'ww");
break;
case 'month':
key = format(date, 'yyyy-MM');
break;
case 'year':
key = format(date, 'yyyy');
break;
}
aggregated[key] = (aggregated[key] || 0) + (item[valueField] as number);
});
return aggregated;
}
/**
* Calculate percentage change between two values
*/
export function calculateChange(current: number, previous: number): { change: number; percent: number } {
const change = current - previous;
const percent = previous !== 0 ? (change / previous) * 100 : current > 0 ? 100 : 0;
return { change, percent };
}
/**
* Get analytics overview with aggregated metrics
*/
export async function getAnalyticsOverview(
filters: AnalyticsFilters = { timeRange: '30d' }
): Promise<AnalyticsOverview> {
const dateRange = filters.dateRange || getDateRangeFromTimeRange(filters.timeRange);
// In production, these would be actual database queries
// For now, generate realistic mock data
const baseValue = 1000 + Math.random() * 500;
return {
totalPlants: Math.floor(baseValue * 1.5),
plantsRegisteredToday: Math.floor(Math.random() * 15 + 5),
plantsRegisteredThisWeek: Math.floor(Math.random() * 80 + 40),
plantsRegisteredThisMonth: Math.floor(Math.random() * 250 + 150),
totalTransportEvents: Math.floor(baseValue * 2.3),
totalCarbonKg: Math.round((Math.random() * 500 + 200) * 100) / 100,
totalFoodMiles: Math.round((Math.random() * 10000 + 5000) * 10) / 10,
activeUsers: Math.floor(Math.random() * 200 + 100),
growthRate: Math.round((Math.random() * 20 + 5) * 10) / 10,
trendsData: [
{
metric: 'Plants',
currentValue: Math.floor(baseValue * 1.5),
previousValue: Math.floor(baseValue * 1.35),
change: Math.floor(baseValue * 0.15),
changePercent: 11.1,
direction: 'up',
period: filters.timeRange,
},
{
metric: 'Carbon Saved',
currentValue: Math.round((Math.random() * 200 + 100) * 10) / 10,
previousValue: Math.round((Math.random() * 180 + 90) * 10) / 10,
change: Math.round((Math.random() * 20 + 10) * 10) / 10,
changePercent: 12.5,
direction: 'up',
period: filters.timeRange,
},
{
metric: 'Active Users',
currentValue: Math.floor(Math.random() * 200 + 100),
previousValue: Math.floor(Math.random() * 180 + 90),
change: Math.floor(Math.random() * 30 + 10),
changePercent: 8.3,
direction: 'up',
period: filters.timeRange,
},
{
metric: 'Food Miles',
currentValue: Math.round((Math.random() * 5000 + 2500) * 10) / 10,
previousValue: Math.round((Math.random() * 5500 + 2800) * 10) / 10,
change: -Math.round((Math.random() * 500 + 200) * 10) / 10,
changePercent: -8.7,
direction: 'down',
period: filters.timeRange,
},
],
};
}
/**
* Get plant-specific analytics
*/
export async function getPlantAnalytics(
filters: AnalyticsFilters = { timeRange: '30d' }
): Promise<PlantAnalytics> {
const dateRange = filters.dateRange || getDateRangeFromTimeRange(filters.timeRange);
const speciesData = [
{ species: 'Tomato', count: 245, percentage: 28.5, trend: 'up' as const },
{ species: 'Lettuce', count: 198, percentage: 23.0, trend: 'up' as const },
{ species: 'Pepper', count: 156, percentage: 18.1, trend: 'stable' as const },
{ species: 'Basil', count: 134, percentage: 15.6, trend: 'up' as const },
{ species: 'Cucumber', count: 87, percentage: 10.1, trend: 'down' as const },
{ species: 'Other', count: 41, percentage: 4.7, trend: 'stable' as const },
];
return {
totalPlants: speciesData.reduce((sum, s) => sum + s.count, 0),
plantsBySpecies: speciesData,
plantsByGeneration: [
{ generation: 1, count: 340, percentage: 39.5 },
{ generation: 2, count: 280, percentage: 32.5 },
{ generation: 3, count: 156, percentage: 18.1 },
{ generation: 4, count: 68, percentage: 7.9 },
{ generation: 5, count: 17, percentage: 2.0 },
],
registrationsTrend: generateTimeSeriesPoints(dateRange, (_, i) =>
Math.floor(Math.random() * 15 + 5 + Math.sin(i / 7) * 5)
),
averageLineageDepth: 2.3,
topGrowers: [
{ userId: 'user-1', name: 'Green Gardens Co', totalPlants: 145, totalSpecies: 12, averageGeneration: 2.1 },
{ userId: 'user-2', name: 'Urban Farm LLC', totalPlants: 98, totalSpecies: 8, averageGeneration: 1.8 },
{ userId: 'user-3', name: 'Local Seeds Inc', totalPlants: 76, totalSpecies: 15, averageGeneration: 3.2 },
],
recentRegistrations: [
{ id: 'plant-1', name: 'Cherry Tomato #245', species: 'Tomato', registeredAt: new Date().toISOString(), generation: 3 },
{ id: 'plant-2', name: 'Butterhead Lettuce', species: 'Lettuce', registeredAt: new Date().toISOString(), generation: 2 },
{ id: 'plant-3', name: 'Sweet Basil', species: 'Basil', registeredAt: new Date().toISOString(), generation: 1 },
],
};
}
/**
* Get transport analytics
*/
export async function getTransportAnalytics(
filters: AnalyticsFilters = { timeRange: '30d' }
): Promise<TransportAnalytics> {
const dateRange = filters.dateRange || getDateRangeFromTimeRange(filters.timeRange);
return {
totalEvents: 2847,
totalDistanceKm: 15234.5,
totalCarbonKg: 487.3,
carbonSavedKg: 1256.8,
eventsByType: [
{ eventType: 'seed_acquisition', count: 423, percentage: 14.9, carbonKg: 52.3 },
{ eventType: 'growing_transport', count: 687, percentage: 24.1, carbonKg: 112.4 },
{ eventType: 'harvest', count: 534, percentage: 18.8, carbonKg: 45.2 },
{ eventType: 'distribution', count: 756, percentage: 26.6, carbonKg: 178.9 },
{ eventType: 'consumer_delivery', count: 447, percentage: 15.7, carbonKg: 98.5 },
],
eventsByMethod: [
{ method: 'walking', count: 312, percentage: 11.0, distanceKm: 156, carbonKg: 0, efficiency: 100 },
{ method: 'bicycle', count: 534, percentage: 18.8, distanceKm: 1602, carbonKg: 0, efficiency: 100 },
{ method: 'electric_vehicle', count: 687, percentage: 24.1, distanceKm: 4806, carbonKg: 72.1, efficiency: 85 },
{ method: 'gasoline_vehicle', count: 756, percentage: 26.6, distanceKm: 5292, carbonKg: 264.6, efficiency: 45 },
{ method: 'local_delivery', count: 558, percentage: 19.6, distanceKm: 3378, carbonKg: 150.6, efficiency: 60 },
],
dailyStats: generateTimeSeriesPoints(dateRange, (_, i) => ({
date: format(dateRange.start, 'yyyy-MM-dd'),
eventCount: Math.floor(Math.random() * 80 + 40),
distanceKm: Math.round((Math.random() * 500 + 200) * 10) / 10,
carbonKg: Math.round((Math.random() * 20 + 5) * 100) / 100,
})).map(p => ({
date: p.timestamp,
eventCount: p.value,
distanceKm: Math.round((Math.random() * 500 + 200) * 10) / 10,
carbonKg: Math.round((Math.random() * 20 + 5) * 100) / 100,
})),
averageDistancePerEvent: 5.35,
mostEfficientRoutes: [
{ from: 'Local Farm A', to: 'Community Center', method: 'bicycle', distanceKm: 2.3, carbonKg: 0, frequency: 45 },
{ from: 'Urban Garden', to: 'Farmers Market', method: 'walking', distanceKm: 0.8, carbonKg: 0, frequency: 38 },
{ from: 'Rooftop Farm', to: 'Restaurant Row', method: 'electric_vehicle', distanceKm: 4.5, carbonKg: 0.07, frequency: 32 },
],
carbonTrend: generateTimeSeriesPoints(dateRange, (_, i) =>
Math.round((Math.random() * 15 + 10 - i * 0.1) * 100) / 100
),
};
}
/**
* Get farm analytics
*/
export async function getFarmAnalytics(
filters: AnalyticsFilters = { timeRange: '30d' }
): Promise<FarmAnalytics> {
const dateRange = filters.dateRange || getDateRangeFromTimeRange(filters.timeRange);
return {
totalFarms: 24,
totalZones: 156,
activeBatches: 89,
completedBatches: 234,
averageYieldKg: 45.6,
resourceUsage: {
waterLiters: 125000,
energyKwh: 8500,
nutrientsKg: 450,
waterEfficiency: 87.5,
energyEfficiency: 92.3,
},
performanceByZone: [
{ zoneId: 'zone-1', zoneName: 'Zone A - Leafy Greens', currentCrop: 'Lettuce', healthScore: 94, yieldKg: 52.3, efficiency: 91 },
{ zoneId: 'zone-2', zoneName: 'Zone B - Herbs', currentCrop: 'Basil', healthScore: 88, yieldKg: 38.7, efficiency: 85 },
{ zoneId: 'zone-3', zoneName: 'Zone C - Tomatoes', currentCrop: 'Cherry Tomato', healthScore: 92, yieldKg: 67.4, efficiency: 89 },
{ zoneId: 'zone-4', zoneName: 'Zone D - Microgreens', currentCrop: 'Mixed Micro', healthScore: 96, yieldKg: 24.1, efficiency: 94 },
],
batchCompletionTrend: generateTimeSeriesPoints(dateRange, (_, i) =>
Math.floor(Math.random() * 5 + 2)
),
yieldPredictions: [
{ cropType: 'Lettuce', predictedYieldKg: 156.5, confidence: 0.92, harvestDate: format(subDays(new Date(), -7), 'yyyy-MM-dd') },
{ cropType: 'Tomato', predictedYieldKg: 234.8, confidence: 0.87, harvestDate: format(subDays(new Date(), -14), 'yyyy-MM-dd') },
{ cropType: 'Basil', predictedYieldKg: 45.2, confidence: 0.94, harvestDate: format(subDays(new Date(), -5), 'yyyy-MM-dd') },
],
topPerformingCrops: [
{ cropType: 'Lettuce', averageYieldKg: 48.3, growthDays: 28, successRate: 94.5, batches: 45 },
{ cropType: 'Basil', averageYieldKg: 12.4, growthDays: 21, successRate: 91.2, batches: 38 },
{ cropType: 'Cherry Tomato', averageYieldKg: 67.8, growthDays: 65, successRate: 88.7, batches: 22 },
{ cropType: 'Microgreens', averageYieldKg: 5.6, growthDays: 14, successRate: 96.8, batches: 67 },
],
};
}
/**
* Get sustainability analytics
*/
export async function getSustainabilityAnalytics(
filters: AnalyticsFilters = { timeRange: '30d' }
): Promise<SustainabilityAnalytics> {
const dateRange = filters.dateRange || getDateRangeFromTimeRange(filters.timeRange);
return {
overallScore: 82.5,
carbonFootprint: {
totalEmittedKg: 487.3,
totalSavedKg: 1256.8,
netImpactKg: -769.5,
reductionPercentage: 72.1,
equivalentTrees: 38.4,
monthlyTrend: generateTimeSeriesPoints(dateRange, (_, i) =>
Math.round((50 - i * 0.5 + Math.random() * 10) * 10) / 10
),
},
foodMiles: {
totalMiles: 15234.5,
averageMilesPerPlant: 17.7,
savedMiles: 48672.3,
localPercentage: 76.2,
monthlyTrend: generateTimeSeriesPoints(dateRange, (_, i) =>
Math.round((600 - i * 5 + Math.random() * 100) * 10) / 10
),
},
waterUsage: {
totalUsedLiters: 125000,
savedLiters: 87500,
efficiencyScore: 87.5,
perKgProduce: 2.8,
},
localProduction: {
localCount: 654,
totalCount: 861,
percentage: 76.0,
trend: 'up',
},
goals: [
{ id: 'goal-1', name: 'Carbon Neutral by 2025', target: 0, current: 487.3, unit: 'kg CO2', progress: 72, deadline: '2025-12-31', status: 'on_track' },
{ id: 'goal-2', name: '80% Local Production', target: 80, current: 76, unit: '%', progress: 95, deadline: '2024-12-31', status: 'on_track' },
{ id: 'goal-3', name: 'Reduce Food Miles 50%', target: 50, current: 38, unit: '%', progress: 76, deadline: '2024-06-30', status: 'at_risk' },
{ id: 'goal-4', name: 'Water Efficiency 90%', target: 90, current: 87.5, unit: '%', progress: 97, deadline: '2024-12-31', status: 'on_track' },
],
trends: [
{
metric: 'Carbon Reduction',
values: generateTimeSeriesPoints(dateRange, (_, i) =>
Math.round((65 + i * 0.3 + Math.random() * 5) * 10) / 10
),
},
{
metric: 'Local Production',
values: generateTimeSeriesPoints(dateRange, (_, i) =>
Math.round((70 + i * 0.2 + Math.random() * 3) * 10) / 10
),
},
],
};
}
/**
* Cache management for analytics data
*/
const analyticsCache = new Map<string, { data: any; timestamp: number }>();
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
export function getCachedData<T>(key: string): T | null {
const cached = analyticsCache.get(key);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
return cached.data as T;
}
return null;
}
export function setCachedData<T>(key: string, data: T): void {
analyticsCache.set(key, { data, timestamp: Date.now() });
}
export function clearCache(): void {
analyticsCache.clear();
}
/**
* Generate cache key from filters
*/
export function generateCacheKey(prefix: string, filters: AnalyticsFilters): string {
return `${prefix}-${JSON.stringify(filters)}`;
}

View file

@ -1,189 +0,0 @@
/**
* Cache Management for Analytics
* Provides caching for expensive analytics calculations
*/
interface CacheEntry<T> {
data: T;
timestamp: number;
expiresAt: number;
}
class AnalyticsCache {
private cache: Map<string, CacheEntry<any>> = new Map();
private defaultTTL: number = 5 * 60 * 1000; // 5 minutes
/**
* Get cached data
*/
get<T>(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return null;
}
return entry.data as T;
}
/**
* Set cached data
*/
set<T>(key: string, data: T, ttlMs: number = this.defaultTTL): void {
const now = Date.now();
this.cache.set(key, {
data,
timestamp: now,
expiresAt: now + ttlMs,
});
}
/**
* Check if key exists and is valid
*/
has(key: string): boolean {
const entry = this.cache.get(key);
if (!entry) return false;
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return false;
}
return true;
}
/**
* Delete a specific key
*/
delete(key: string): boolean {
return this.cache.delete(key);
}
/**
* Clear all cached data
*/
clear(): void {
this.cache.clear();
}
/**
* Clear expired entries
*/
cleanup(): number {
const now = Date.now();
let cleaned = 0;
for (const [key, entry] of this.cache.entries()) {
if (now > entry.expiresAt) {
this.cache.delete(key);
cleaned++;
}
}
return cleaned;
}
/**
* Get cache statistics
*/
getStats(): {
size: number;
validEntries: number;
expiredEntries: number;
} {
const now = Date.now();
let valid = 0;
let expired = 0;
for (const entry of this.cache.values()) {
if (now > entry.expiresAt) {
expired++;
} else {
valid++;
}
}
return {
size: this.cache.size,
validEntries: valid,
expiredEntries: expired,
};
}
/**
* Generate cache key from object
*/
static generateKey(prefix: string, params: Record<string, any>): string {
const sortedParams = Object.keys(params)
.sort()
.map((key) => `${key}:${JSON.stringify(params[key])}`)
.join('|');
return `${prefix}:${sortedParams}`;
}
}
// Singleton instance
export const analyticsCache = new AnalyticsCache();
/**
* Cache decorator for async functions
*/
export function cached<T>(
keyGenerator: (...args: any[]) => string,
ttlMs: number = 5 * 60 * 1000
) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
const cacheKey = keyGenerator(...args);
const cached = analyticsCache.get<T>(cacheKey);
if (cached !== null) {
return cached;
}
const result = await originalMethod.apply(this, args);
analyticsCache.set(cacheKey, result, ttlMs);
return result;
};
return descriptor;
};
}
/**
* Higher-order function for caching async functions
*/
export function withCache<T, A extends any[]>(
fn: (...args: A) => Promise<T>,
keyGenerator: (...args: A) => string,
ttlMs: number = 5 * 60 * 1000
): (...args: A) => Promise<T> {
return async (...args: A): Promise<T> => {
const cacheKey = keyGenerator(...args);
const cached = analyticsCache.get<T>(cacheKey);
if (cached !== null) {
return cached;
}
const result = await fn(...args);
analyticsCache.set(cacheKey, result, ttlMs);
return result;
};
}
// Schedule periodic cleanup
if (typeof setInterval !== 'undefined') {
setInterval(() => {
analyticsCache.cleanup();
}, 60 * 1000); // Run cleanup every minute
}

View file

@ -1,70 +0,0 @@
/**
* Analytics Module Index
* Exports all analytics functionality
*/
// Types
export * from './types';
// Data aggregation
export {
getDateRangeFromTimeRange,
generateTimeSeriesPoints,
aggregateByPeriod,
calculateChange,
getAnalyticsOverview,
getPlantAnalytics,
getTransportAnalytics,
getFarmAnalytics,
getSustainabilityAnalytics,
getCachedData,
setCachedData,
clearCache,
generateCacheKey,
} from './aggregator';
// Metrics calculations
export {
mean,
median,
standardDeviation,
percentile,
minMax,
getTrendDirection,
percentageChange,
movingAverage,
rateOfChange,
normalize,
cagr,
efficiencyScore,
carbonIntensity,
foodMilesScore,
sustainabilityScore,
generateKPICards,
calculateGrowthMetrics,
detectAnomalies,
correlationCoefficient,
formatNumber,
formatPercentage,
} from './metrics';
// Trend analysis
export {
analyzeTrend,
linearRegression,
forecast,
detectSeasonality,
findPeaksAndValleys,
calculateMomentum,
exponentialSmoothing,
generateTrendSummary,
compareTimeSeries,
getTrendConfidence,
yearOverYearComparison,
} from './trends';
// Cache management
export {
analyticsCache,
withCache,
} from './cache';

View file

@ -1,326 +0,0 @@
/**
* Metrics Calculations for Analytics
* Provides metric calculations and statistical functions
*/
import { TrendDirection, TimeSeriesDataPoint, KPICardData } from './types';
/**
* Calculate mean of an array of numbers
*/
export function mean(values: number[]): number {
if (values.length === 0) return 0;
return values.reduce((sum, v) => sum + v, 0) / values.length;
}
/**
* Calculate median of an array of numbers
*/
export function median(values: number[]): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
/**
* Calculate standard deviation
*/
export function standardDeviation(values: number[]): number {
if (values.length === 0) return 0;
const avg = mean(values);
const squareDiffs = values.map(v => Math.pow(v - avg, 2));
return Math.sqrt(mean(squareDiffs));
}
/**
* Calculate percentile
*/
export function percentile(values: number[], p: number): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const index = (p / 100) * (sorted.length - 1);
const lower = Math.floor(index);
const upper = Math.ceil(index);
const weight = index - lower;
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
}
/**
* Calculate min and max
*/
export function minMax(values: number[]): { min: number; max: number } {
if (values.length === 0) return { min: 0, max: 0 };
return {
min: Math.min(...values),
max: Math.max(...values),
};
}
/**
* Determine trend direction from two values
*/
export function getTrendDirection(current: number, previous: number, threshold: number = 0.5): TrendDirection {
const change = ((current - previous) / Math.abs(previous || 1)) * 100;
if (Math.abs(change) < threshold) return 'stable';
return change > 0 ? 'up' : 'down';
}
/**
* Calculate percentage change
*/
export function percentageChange(current: number, previous: number): number {
if (previous === 0) return current > 0 ? 100 : 0;
return ((current - previous) / Math.abs(previous)) * 100;
}
/**
* Calculate moving average
*/
export function movingAverage(data: TimeSeriesDataPoint[], windowSize: number): TimeSeriesDataPoint[] {
return data.map((point, index) => {
const start = Math.max(0, index - windowSize + 1);
const window = data.slice(start, index + 1);
const avg = mean(window.map(p => p.value));
return {
...point,
value: Math.round(avg * 100) / 100,
};
});
}
/**
* Calculate rate of change (derivative)
*/
export function rateOfChange(data: TimeSeriesDataPoint[]): TimeSeriesDataPoint[] {
return data.slice(1).map((point, index) => ({
...point,
value: point.value - data[index].value,
}));
}
/**
* Normalize values to 0-100 range
*/
export function normalize(values: number[]): number[] {
const { min, max } = minMax(values);
const range = max - min;
if (range === 0) return values.map(() => 50);
return values.map(v => ((v - min) / range) * 100);
}
/**
* Calculate compound annual growth rate (CAGR)
*/
export function cagr(startValue: number, endValue: number, years: number): number {
if (startValue <= 0 || years <= 0) return 0;
return (Math.pow(endValue / startValue, 1 / years) - 1) * 100;
}
/**
* Calculate efficiency score
*/
export function efficiencyScore(actual: number, optimal: number): number {
if (optimal === 0) return actual === 0 ? 100 : 0;
return Math.min(100, (optimal / actual) * 100);
}
/**
* Calculate carbon intensity (kg CO2 per km)
*/
export function carbonIntensity(carbonKg: number, distanceKm: number): number {
if (distanceKm === 0) return 0;
return carbonKg / distanceKm;
}
/**
* Calculate food miles score (0-100, lower is better)
*/
export function foodMilesScore(miles: number, maxMiles: number = 5000): number {
if (miles >= maxMiles) return 0;
return Math.round((1 - miles / maxMiles) * 100);
}
/**
* Calculate sustainability composite score
*/
export function sustainabilityScore(
carbonReduction: number,
localPercentage: number,
waterEfficiency: number,
wasteReduction: number
): number {
const weights = {
carbon: 0.35,
local: 0.25,
water: 0.25,
waste: 0.15,
};
return Math.round(
carbonReduction * weights.carbon +
localPercentage * weights.local +
waterEfficiency * weights.water +
wasteReduction * weights.waste
);
}
/**
* Generate KPI card data from metrics
*/
export function generateKPICards(metrics: {
plants: { current: number; previous: number };
carbon: { current: number; previous: number };
foodMiles: { current: number; previous: number };
users: { current: number; previous: number };
sustainability: { current: number; previous: number };
}): KPICardData[] {
return [
{
id: 'total-plants',
title: 'Total Plants',
value: metrics.plants.current,
change: metrics.plants.current - metrics.plants.previous,
changePercent: percentageChange(metrics.plants.current, metrics.plants.previous),
trend: getTrendDirection(metrics.plants.current, metrics.plants.previous),
color: 'green',
},
{
id: 'carbon-saved',
title: 'Carbon Saved',
value: metrics.carbon.current.toFixed(1),
unit: 'kg CO2',
change: metrics.carbon.current - metrics.carbon.previous,
changePercent: percentageChange(metrics.carbon.current, metrics.carbon.previous),
trend: getTrendDirection(metrics.carbon.current, metrics.carbon.previous),
color: 'teal',
},
{
id: 'food-miles',
title: 'Food Miles',
value: metrics.foodMiles.current.toFixed(0),
unit: 'km',
change: metrics.foodMiles.current - metrics.foodMiles.previous,
changePercent: percentageChange(metrics.foodMiles.current, metrics.foodMiles.previous),
trend: getTrendDirection(metrics.foodMiles.previous, metrics.foodMiles.current), // Inverted: lower is better
color: 'blue',
},
{
id: 'active-users',
title: 'Active Users',
value: metrics.users.current,
change: metrics.users.current - metrics.users.previous,
changePercent: percentageChange(metrics.users.current, metrics.users.previous),
trend: getTrendDirection(metrics.users.current, metrics.users.previous),
color: 'purple',
},
{
id: 'sustainability',
title: 'Sustainability Score',
value: metrics.sustainability.current.toFixed(0),
unit: '%',
change: metrics.sustainability.current - metrics.sustainability.previous,
changePercent: percentageChange(metrics.sustainability.current, metrics.sustainability.previous),
trend: getTrendDirection(metrics.sustainability.current, metrics.sustainability.previous),
color: 'green',
},
];
}
/**
* Calculate growth metrics
*/
export function calculateGrowthMetrics(data: TimeSeriesDataPoint[]): {
totalGrowth: number;
averageDaily: number;
peakValue: number;
peakDate: string;
trend: TrendDirection;
} {
if (data.length === 0) {
return { totalGrowth: 0, averageDaily: 0, peakValue: 0, peakDate: '', trend: 'stable' };
}
const values = data.map(d => d.value);
const total = values.reduce((sum, v) => sum + v, 0);
const avgDaily = total / data.length;
const maxIndex = values.indexOf(Math.max(...values));
const firstHalf = mean(values.slice(0, Math.floor(values.length / 2)));
const secondHalf = mean(values.slice(Math.floor(values.length / 2)));
return {
totalGrowth: total,
averageDaily: Math.round(avgDaily * 100) / 100,
peakValue: values[maxIndex],
peakDate: data[maxIndex].timestamp,
trend: getTrendDirection(secondHalf, firstHalf),
};
}
/**
* Detect anomalies using z-score
*/
export function detectAnomalies(
data: TimeSeriesDataPoint[],
threshold: number = 2
): TimeSeriesDataPoint[] {
const values = data.map(d => d.value);
const avg = mean(values);
const std = standardDeviation(values);
if (std === 0) return [];
return data.filter(point => {
const zScore = Math.abs((point.value - avg) / std);
return zScore > threshold;
});
}
/**
* Calculate correlation coefficient between two datasets
*/
export function correlationCoefficient(x: number[], y: number[]): number {
if (x.length !== y.length || x.length === 0) return 0;
const n = x.length;
const meanX = mean(x);
const meanY = mean(y);
let numerator = 0;
let denomX = 0;
let denomY = 0;
for (let i = 0; i < n; i++) {
const dx = x[i] - meanX;
const dy = y[i] - meanY;
numerator += dx * dy;
denomX += dx * dx;
denomY += dy * dy;
}
const denominator = Math.sqrt(denomX * denomY);
return denominator === 0 ? 0 : numerator / denominator;
}
/**
* Format large numbers for display
*/
export function formatNumber(value: number, decimals: number = 1): string {
if (Math.abs(value) >= 1000000) {
return (value / 1000000).toFixed(decimals) + 'M';
}
if (Math.abs(value) >= 1000) {
return (value / 1000).toFixed(decimals) + 'K';
}
return value.toFixed(decimals);
}
/**
* Format percentage for display
*/
export function formatPercentage(value: number, showSign: boolean = false): string {
const formatted = value.toFixed(1);
if (showSign && value > 0) return '+' + formatted + '%';
return formatted + '%';
}

View file

@ -1,411 +0,0 @@
/**
* Trend Analysis for Analytics
* Provides trend detection, forecasting, and pattern analysis
*/
import { TimeSeriesDataPoint, TrendDirection, TrendData, TimeRange } from './types';
import { mean, standardDeviation, percentageChange, movingAverage } from './metrics';
import { format, parseISO, differenceInDays } from 'date-fns';
/**
* Analyze trend from time series data
*/
export function analyzeTrend(data: TimeSeriesDataPoint[]): TrendData {
if (data.length < 2) {
return {
metric: 'Unknown',
currentValue: data[0]?.value || 0,
previousValue: 0,
change: 0,
changePercent: 0,
direction: 'stable',
period: 'N/A',
};
}
const midpoint = Math.floor(data.length / 2);
const firstHalf = data.slice(0, midpoint);
const secondHalf = data.slice(midpoint);
const firstAvg = mean(firstHalf.map(d => d.value));
const secondAvg = mean(secondHalf.map(d => d.value));
const change = secondAvg - firstAvg;
const changePercent = percentageChange(secondAvg, firstAvg);
let direction: TrendDirection = 'stable';
if (changePercent > 5) direction = 'up';
else if (changePercent < -5) direction = 'down';
return {
metric: '',
currentValue: secondAvg,
previousValue: firstAvg,
change,
changePercent: Math.round(changePercent * 10) / 10,
direction,
period: `${data[0].timestamp} - ${data[data.length - 1].timestamp}`,
};
}
/**
* Calculate linear regression for forecasting
*/
export function linearRegression(data: TimeSeriesDataPoint[]): {
slope: number;
intercept: number;
rSquared: number;
} {
const n = data.length;
if (n < 2) return { slope: 0, intercept: 0, rSquared: 0 };
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0, sumY2 = 0;
data.forEach((point, i) => {
const x = i;
const y = point.value;
sumX += x;
sumY += y;
sumXY += x * y;
sumX2 += x * x;
sumY2 += y * y;
});
const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
const intercept = (sumY - slope * sumX) / n;
// Calculate R-squared
const yMean = sumY / n;
let ssRes = 0, ssTot = 0;
data.forEach((point, i) => {
const predicted = slope * i + intercept;
ssRes += Math.pow(point.value - predicted, 2);
ssTot += Math.pow(point.value - yMean, 2);
});
const rSquared = ssTot === 0 ? 1 : 1 - ssRes / ssTot;
return {
slope: Math.round(slope * 1000) / 1000,
intercept: Math.round(intercept * 1000) / 1000,
rSquared: Math.round(rSquared * 1000) / 1000,
};
}
/**
* Forecast future values using linear regression
*/
export function forecast(
data: TimeSeriesDataPoint[],
periodsAhead: number
): TimeSeriesDataPoint[] {
const regression = linearRegression(data);
const predictions: TimeSeriesDataPoint[] = [];
const lastIndex = data.length - 1;
for (let i = 1; i <= periodsAhead; i++) {
const predictedValue = regression.slope * (lastIndex + i) + regression.intercept;
predictions.push({
timestamp: `+${i}`,
value: Math.max(0, Math.round(predictedValue * 100) / 100),
label: `Forecast ${i}`,
});
}
return predictions;
}
/**
* Detect seasonality in data
*/
export function detectSeasonality(data: TimeSeriesDataPoint[]): {
hasSeasonality: boolean;
period: number;
strength: number;
} {
if (data.length < 14) {
return { hasSeasonality: false, period: 0, strength: 0 };
}
// Try common periods: 7 days (weekly), 30 days (monthly)
const periods = [7, 14, 30];
let bestPeriod = 0;
let bestCorrelation = 0;
for (const period of periods) {
if (data.length < period * 2) continue;
let correlation = 0;
let count = 0;
for (let i = period; i < data.length; i++) {
correlation += Math.abs(data[i].value - data[i - period].value);
count++;
}
const avgCorr = count > 0 ? 1 - correlation / (count * mean(data.map(d => d.value))) : 0;
if (avgCorr > bestCorrelation) {
bestCorrelation = avgCorr;
bestPeriod = period;
}
}
return {
hasSeasonality: bestCorrelation > 0.5,
period: bestPeriod,
strength: Math.round(bestCorrelation * 100) / 100,
};
}
/**
* Identify peaks and valleys in data
*/
export function findPeaksAndValleys(
data: TimeSeriesDataPoint[],
sensitivity: number = 1.5
): {
peaks: TimeSeriesDataPoint[];
valleys: TimeSeriesDataPoint[];
} {
const peaks: TimeSeriesDataPoint[] = [];
const valleys: TimeSeriesDataPoint[] = [];
if (data.length < 3) return { peaks, valleys };
const avg = mean(data.map(d => d.value));
const std = standardDeviation(data.map(d => d.value));
const threshold = std * sensitivity;
for (let i = 1; i < data.length - 1; i++) {
const prev = data[i - 1].value;
const curr = data[i].value;
const next = data[i + 1].value;
// Peak detection
if (curr > prev && curr > next && curr > avg + threshold) {
peaks.push(data[i]);
}
// Valley detection
if (curr < prev && curr < next && curr < avg - threshold) {
valleys.push(data[i]);
}
}
return { peaks, valleys };
}
/**
* Calculate trend momentum
*/
export function calculateMomentum(data: TimeSeriesDataPoint[], lookback: number = 5): number {
if (data.length < lookback) return 0;
const recent = data.slice(-lookback);
const older = data.slice(-lookback * 2, -lookback);
if (older.length === 0) return 0;
const recentAvg = mean(recent.map(d => d.value));
const olderAvg = mean(older.map(d => d.value));
return Math.round(((recentAvg - olderAvg) / olderAvg) * 100 * 10) / 10;
}
/**
* Smooth data using exponential smoothing
*/
export function exponentialSmoothing(
data: TimeSeriesDataPoint[],
alpha: number = 0.3
): TimeSeriesDataPoint[] {
if (data.length === 0) return [];
const smoothed: TimeSeriesDataPoint[] = [data[0]];
for (let i = 1; i < data.length; i++) {
const smoothedValue = alpha * data[i].value + (1 - alpha) * smoothed[i - 1].value;
smoothed.push({
...data[i],
value: Math.round(smoothedValue * 100) / 100,
});
}
return smoothed;
}
/**
* Generate trend summary text
*/
export function generateTrendSummary(data: TimeSeriesDataPoint[], metricName: string): string {
if (data.length < 2) return `Insufficient data for ${metricName} analysis.`;
const trend = analyzeTrend(data);
const regression = linearRegression(data);
const { peaks, valleys } = findPeaksAndValleys(data);
const momentum = calculateMomentum(data);
let summary = '';
// Overall direction
if (trend.direction === 'up') {
summary += `${metricName} is trending upward with a ${Math.abs(trend.changePercent).toFixed(1)}% increase. `;
} else if (trend.direction === 'down') {
summary += `${metricName} is trending downward with a ${Math.abs(trend.changePercent).toFixed(1)}% decrease. `;
} else {
summary += `${metricName} remains relatively stable. `;
}
// Trend strength
if (regression.rSquared > 0.8) {
summary += 'The trend is strong and consistent. ';
} else if (regression.rSquared > 0.5) {
summary += 'The trend is moderate with some variability. ';
} else {
summary += 'Data shows high variability. ';
}
// Momentum
if (momentum > 10) {
summary += 'Recent acceleration detected. ';
} else if (momentum < -10) {
summary += 'Recent deceleration detected. ';
}
// Notable events
if (peaks.length > 0) {
summary += `${peaks.length} notable peak(s) observed. `;
}
if (valleys.length > 0) {
summary += `${valleys.length} notable dip(s) observed. `;
}
return summary.trim();
}
/**
* Compare two time series for similarity
*/
export function compareTimeSeries(
series1: TimeSeriesDataPoint[],
series2: TimeSeriesDataPoint[]
): {
correlation: number;
leadLag: number;
divergence: number;
} {
// Ensure same length
const minLength = Math.min(series1.length, series2.length);
const s1 = series1.slice(0, minLength).map(d => d.value);
const s2 = series2.slice(0, minLength).map(d => d.value);
// Calculate correlation
const mean1 = mean(s1);
const mean2 = mean(s2);
let numerator = 0, denom1 = 0, denom2 = 0;
for (let i = 0; i < minLength; i++) {
const d1 = s1[i] - mean1;
const d2 = s2[i] - mean2;
numerator += d1 * d2;
denom1 += d1 * d1;
denom2 += d2 * d2;
}
const correlation = denom1 * denom2 === 0 ? 0 : numerator / Math.sqrt(denom1 * denom2);
// Calculate divergence (normalized difference)
const divergence = mean(s1.map((v, i) => Math.abs(v - s2[i]))) / ((mean1 + mean2) / 2);
return {
correlation: Math.round(correlation * 1000) / 1000,
leadLag: 0, // Simplified - full cross-correlation would require more computation
divergence: Math.round(divergence * 1000) / 1000,
};
}
/**
* Get trend confidence level
*/
export function getTrendConfidence(data: TimeSeriesDataPoint[]): {
level: 'high' | 'medium' | 'low';
score: number;
factors: string[];
} {
const factors: string[] = [];
let score = 50;
// Data quantity factor
if (data.length >= 30) {
score += 15;
factors.push('Sufficient data points');
} else if (data.length >= 14) {
score += 10;
factors.push('Moderate data points');
} else {
score -= 10;
factors.push('Limited data points');
}
// Consistency factor
const std = standardDeviation(data.map(d => d.value));
const avg = mean(data.map(d => d.value));
const cv = avg !== 0 ? std / avg : 0;
if (cv < 0.2) {
score += 15;
factors.push('Low variability');
} else if (cv < 0.5) {
score += 5;
factors.push('Moderate variability');
} else {
score -= 10;
factors.push('High variability');
}
// Trend strength
const regression = linearRegression(data);
if (regression.rSquared > 0.7) {
score += 20;
factors.push('Strong trend fit');
} else if (regression.rSquared > 0.4) {
score += 10;
factors.push('Moderate trend fit');
} else {
score -= 5;
factors.push('Weak trend fit');
}
const level = score >= 70 ? 'high' : score >= 50 ? 'medium' : 'low';
return {
level,
score: Math.min(100, Math.max(0, score)),
factors,
};
}
/**
* Calculate year-over-year comparison
*/
export function yearOverYearComparison(
currentPeriod: TimeSeriesDataPoint[],
previousPeriod: TimeSeriesDataPoint[]
): {
currentTotal: number;
previousTotal: number;
change: number;
changePercent: number;
trend: TrendDirection;
} {
const currentTotal = currentPeriod.reduce((sum, d) => sum + d.value, 0);
const previousTotal = previousPeriod.reduce((sum, d) => sum + d.value, 0);
const change = currentTotal - previousTotal;
const changePercent = percentageChange(currentTotal, previousTotal);
return {
currentTotal: Math.round(currentTotal * 100) / 100,
previousTotal: Math.round(previousTotal * 100) / 100,
change: Math.round(change * 100) / 100,
changePercent: Math.round(changePercent * 10) / 10,
trend: changePercent > 5 ? 'up' : changePercent < -5 ? 'down' : 'stable',
};
}

View file

@ -1,306 +0,0 @@
/**
* Analytics Types for LocalGreenChain
* Defines all types for the Advanced Analytics Dashboard
*/
// Time range options for analytics queries
export type TimeRange = '7d' | '30d' | '90d' | '365d' | 'all';
export interface DateRange {
start: Date;
end: Date;
}
// Generic data point for time series
export interface TimeSeriesDataPoint {
timestamp: string;
value: number;
label?: string;
}
// Analytics overview metrics
export interface AnalyticsOverview {
totalPlants: number;
plantsRegisteredToday: number;
plantsRegisteredThisWeek: number;
plantsRegisteredThisMonth: number;
totalTransportEvents: number;
totalCarbonKg: number;
totalFoodMiles: number;
activeUsers: number;
growthRate: number;
trendsData: TrendData[];
}
// Trend direction indicator
export type TrendDirection = 'up' | 'down' | 'stable';
export interface TrendData {
metric: string;
currentValue: number;
previousValue: number;
change: number;
changePercent: number;
direction: TrendDirection;
period: string;
}
// Plant analytics
export interface PlantAnalytics {
totalPlants: number;
plantsBySpecies: SpeciesDistribution[];
plantsByGeneration: GenerationDistribution[];
registrationsTrend: TimeSeriesDataPoint[];
averageLineageDepth: number;
topGrowers: GrowerStats[];
recentRegistrations: PlantRegistration[];
}
export interface SpeciesDistribution {
species: string;
count: number;
percentage: number;
trend: TrendDirection;
}
export interface GenerationDistribution {
generation: number;
count: number;
percentage: number;
}
export interface GrowerStats {
userId: string;
name: string;
totalPlants: number;
totalSpecies: number;
averageGeneration: number;
}
export interface PlantRegistration {
id: string;
name: string;
species: string;
registeredAt: string;
generation: number;
}
// Transport analytics
export interface TransportAnalytics {
totalEvents: number;
totalDistanceKm: number;
totalCarbonKg: number;
carbonSavedKg: number;
eventsByType: TransportEventDistribution[];
eventsByMethod: TransportMethodDistribution[];
dailyStats: DailyTransportStats[];
averageDistancePerEvent: number;
mostEfficientRoutes: EfficientRoute[];
carbonTrend: TimeSeriesDataPoint[];
}
export interface TransportEventDistribution {
eventType: string;
count: number;
percentage: number;
carbonKg: number;
}
export interface TransportMethodDistribution {
method: string;
count: number;
percentage: number;
distanceKm: number;
carbonKg: number;
efficiency: number;
}
export interface DailyTransportStats {
date: string;
eventCount: number;
distanceKm: number;
carbonKg: number;
}
export interface EfficientRoute {
from: string;
to: string;
method: string;
distanceKm: number;
carbonKg: number;
frequency: number;
}
// Vertical farm analytics
export interface FarmAnalytics {
totalFarms: number;
totalZones: number;
activeBatches: number;
completedBatches: number;
averageYieldKg: number;
resourceUsage: ResourceUsageStats;
performanceByZone: ZonePerformance[];
batchCompletionTrend: TimeSeriesDataPoint[];
yieldPredictions: YieldPrediction[];
topPerformingCrops: CropPerformance[];
}
export interface ResourceUsageStats {
waterLiters: number;
energyKwh: number;
nutrientsKg: number;
waterEfficiency: number;
energyEfficiency: number;
}
export interface ZonePerformance {
zoneId: string;
zoneName: string;
currentCrop: string;
healthScore: number;
yieldKg: number;
efficiency: number;
}
export interface YieldPrediction {
cropType: string;
predictedYieldKg: number;
confidence: number;
harvestDate: string;
}
export interface CropPerformance {
cropType: string;
averageYieldKg: number;
growthDays: number;
successRate: number;
batches: number;
}
// Sustainability analytics
export interface SustainabilityAnalytics {
overallScore: number;
carbonFootprint: CarbonMetrics;
foodMiles: FoodMilesMetrics;
waterUsage: WaterMetrics;
localProduction: LocalProductionMetrics;
goals: SustainabilityGoal[];
trends: SustainabilityTrend[];
}
export interface CarbonMetrics {
totalEmittedKg: number;
totalSavedKg: number;
netImpactKg: number;
reductionPercentage: number;
equivalentTrees: number;
monthlyTrend: TimeSeriesDataPoint[];
}
export interface FoodMilesMetrics {
totalMiles: number;
averageMilesPerPlant: number;
savedMiles: number;
localPercentage: number;
monthlyTrend: TimeSeriesDataPoint[];
}
export interface WaterMetrics {
totalUsedLiters: number;
savedLiters: number;
efficiencyScore: number;
perKgProduce: number;
}
export interface LocalProductionMetrics {
localCount: number;
totalCount: number;
percentage: number;
trend: TrendDirection;
}
export interface SustainabilityGoal {
id: string;
name: string;
target: number;
current: number;
unit: string;
progress: number;
deadline: string;
status: 'on_track' | 'at_risk' | 'behind';
}
export interface SustainabilityTrend {
metric: string;
values: TimeSeriesDataPoint[];
}
// Export options
export interface ExportOptions {
format: 'csv' | 'pdf' | 'json';
dateRange: DateRange;
sections: string[];
includeCharts: boolean;
}
export interface ExportResult {
filename: string;
mimeType: string;
data: string;
generatedAt: string;
}
// Dashboard configuration
export interface DashboardConfig {
refreshInterval: number;
defaultTimeRange: TimeRange;
visibleWidgets: string[];
layout: 'grid' | 'list';
}
// KPI Card configuration
export interface KPICardData {
id: string;
title: string;
value: number | string;
unit?: string;
change?: number;
changePercent?: number;
trend: TrendDirection;
color: 'green' | 'blue' | 'purple' | 'orange' | 'red' | 'teal';
icon?: string;
}
// Chart configuration
export interface ChartConfig {
title: string;
type: 'line' | 'bar' | 'pie' | 'area' | 'heatmap' | 'gauge';
data: any[];
xKey?: string;
yKey?: string;
colors?: string[];
showLegend?: boolean;
showGrid?: boolean;
height?: number;
}
// Filter state for analytics
export interface AnalyticsFilters {
timeRange: TimeRange;
dateRange?: DateRange;
species?: string[];
regions?: string[];
transportMethods?: string[];
farmIds?: string[];
}
// Aggregation types
export type AggregationType = 'sum' | 'avg' | 'min' | 'max' | 'count';
export type GroupByPeriod = 'hour' | 'day' | 'week' | 'month' | 'year';
export interface AggregationConfig {
metric: string;
aggregation: AggregationType;
groupBy?: GroupByPeriod;
filters?: Record<string, any>;
}

View file

@ -11,9 +11,9 @@ export class PlantChain {
private plantIndex: Map<string, PlantBlock>; // Quick lookup by plant ID
constructor(difficulty: number = 4) {
this.chain = [this.createGenesisBlock()];
this.difficulty = difficulty;
this.plantIndex = new Map();
this.chain = [this.createGenesisBlock()];
}
/**

View file

@ -2,15 +2,6 @@
import { GrowingEnvironment, GrowthMetrics } from '../environment/types';
// Re-export types from environment
export type { GrowingEnvironment, GrowthMetrics };
// Re-export PlantBlock class
export { PlantBlock } from './PlantBlock';
// Propagation type alias
export type PropagationType = 'seed' | 'clone' | 'cutting' | 'division' | 'grafting' | 'original';
export interface PlantLocation {
latitude: number;
longitude: number;

View file

@ -154,7 +154,7 @@ export interface PlantingRecommendation {
// Quantities
recommendedQuantity: number;
quantityUnit: 'plants' | 'seeds' | 'kg_expected_yield' | 'sqm';
quantityUnit: 'plants' | 'seeds' | 'kg_expected_yield';
expectedYieldKg: number;
yieldConfidence: number; // 0-100

View file

@ -1,15 +0,0 @@
// Marketplace Module Index
// Re-exports all marketplace services and types
export * from './types';
export { listingService } from './listingService';
export { offerService } from './offerService';
export { searchService } from './searchService';
export { matchingService } from './matchingService';
export {
listingStore,
offerStore,
sellerProfileStore,
wishlistStore,
generateId,
} from './store';

View file

@ -1,233 +0,0 @@
// Listing Service for Marketplace
// Handles CRUD operations for marketplace listings
import {
Listing,
ListingStatus,
CreateListingInput,
UpdateListingInput,
} from './types';
import { listingStore, generateId } from './store';
export class ListingService {
/**
* Create a new listing
*/
async createListing(
sellerId: string,
sellerName: string,
input: CreateListingInput
): Promise<Listing> {
const listing: Listing = {
id: generateId(),
sellerId,
sellerName,
plantId: input.plantId,
title: input.title,
description: input.description,
price: input.price,
currency: input.currency || 'USD',
quantity: input.quantity,
category: input.category,
status: ListingStatus.DRAFT,
location: input.location,
tags: input.tags || [],
images: [],
viewCount: 0,
createdAt: new Date(),
updatedAt: new Date(),
expiresAt: input.expiresAt,
};
return listingStore.create(listing);
}
/**
* Get a listing by ID
*/
async getListingById(id: string): Promise<Listing | null> {
const listing = listingStore.getById(id);
return listing || null;
}
/**
* Get a listing and increment view count
*/
async viewListing(id: string): Promise<Listing | null> {
listingStore.incrementViewCount(id);
return this.getListingById(id);
}
/**
* Get all listings by seller
*/
async getListingsBySeller(sellerId: string): Promise<Listing[]> {
return listingStore.getBySellerId(sellerId);
}
/**
* Get all active listings
*/
async getActiveListings(): Promise<Listing[]> {
return listingStore.getAll().filter(l => l.status === ListingStatus.ACTIVE);
}
/**
* Update a listing
*/
async updateListing(
id: string,
sellerId: string,
updates: UpdateListingInput
): Promise<Listing | null> {
const listing = listingStore.getById(id);
if (!listing) {
return null;
}
// Verify ownership
if (listing.sellerId !== sellerId) {
throw new Error('Unauthorized: You do not own this listing');
}
const updated = listingStore.update(id, updates);
return updated || null;
}
/**
* Publish a draft listing (make it active)
*/
async publishListing(id: string, sellerId: string): Promise<Listing | null> {
const listing = listingStore.getById(id);
if (!listing) {
return null;
}
if (listing.sellerId !== sellerId) {
throw new Error('Unauthorized: You do not own this listing');
}
if (listing.status !== ListingStatus.DRAFT) {
throw new Error('Only draft listings can be published');
}
return listingStore.update(id, { status: ListingStatus.ACTIVE }) || null;
}
/**
* Cancel a listing
*/
async cancelListing(id: string, sellerId: string): Promise<Listing | null> {
const listing = listingStore.getById(id);
if (!listing) {
return null;
}
if (listing.sellerId !== sellerId) {
throw new Error('Unauthorized: You do not own this listing');
}
if (listing.status === ListingStatus.SOLD) {
throw new Error('Cannot cancel a sold listing');
}
return listingStore.update(id, { status: ListingStatus.CANCELLED }) || null;
}
/**
* Mark listing as sold
*/
async markAsSold(id: string, sellerId: string): Promise<Listing | null> {
const listing = listingStore.getById(id);
if (!listing) {
return null;
}
if (listing.sellerId !== sellerId) {
throw new Error('Unauthorized: You do not own this listing');
}
return listingStore.update(id, {
status: ListingStatus.SOLD,
quantity: 0
}) || null;
}
/**
* Delete a listing (only drafts or cancelled)
*/
async deleteListing(id: string, sellerId: string): Promise<boolean> {
const listing = listingStore.getById(id);
if (!listing) {
return false;
}
if (listing.sellerId !== sellerId) {
throw new Error('Unauthorized: You do not own this listing');
}
if (listing.status === ListingStatus.ACTIVE || listing.status === ListingStatus.SOLD) {
throw new Error('Cannot delete active or sold listings. Cancel first.');
}
return listingStore.delete(id);
}
/**
* Get listing statistics for a seller
*/
async getSellerStats(sellerId: string): Promise<{
totalListings: number;
activeListings: number;
soldListings: number;
totalViews: number;
averagePrice: number;
}> {
const listings = listingStore.getBySellerId(sellerId);
const activeListings = listings.filter(l => l.status === ListingStatus.ACTIVE);
const soldListings = listings.filter(l => l.status === ListingStatus.SOLD);
const totalViews = listings.reduce((sum, l) => sum + l.viewCount, 0);
const averagePrice = listings.length > 0
? listings.reduce((sum, l) => sum + l.price, 0) / listings.length
: 0;
return {
totalListings: listings.length,
activeListings: activeListings.length,
soldListings: soldListings.length,
totalViews,
averagePrice: Math.round(averagePrice * 100) / 100,
};
}
/**
* Check and expire old listings
*/
async expireOldListings(): Promise<number> {
const now = new Date();
let expiredCount = 0;
const activeListings = listingStore.getAll().filter(
l => l.status === ListingStatus.ACTIVE && l.expiresAt
);
for (const listing of activeListings) {
if (listing.expiresAt && listing.expiresAt < now) {
listingStore.update(listing.id, { status: ListingStatus.EXPIRED });
expiredCount++;
}
}
return expiredCount;
}
}
// Export singleton instance
export const listingService = new ListingService();

View file

@ -1,230 +0,0 @@
// Matching Service for Marketplace
// Matches buyers with sellers based on preferences and listings
import { Listing, ListingCategory, ListingStatus } from './types';
import { listingStore, sellerProfileStore } from './store';
export interface BuyerPreferences {
categories: ListingCategory[];
maxPrice?: number;
preferredLocation?: {
lat: number;
lng: number;
maxDistanceKm: number;
};
preferredTags?: string[];
preferVerifiedSellers?: boolean;
}
export interface MatchResult {
listing: Listing;
score: number;
matchReasons: string[];
}
export class MatchingService {
/**
* Find listings that match buyer preferences
*/
async findMatchesForBuyer(
buyerId: string,
preferences: BuyerPreferences,
limit: number = 10
): Promise<MatchResult[]> {
const activeListings = listingStore
.getAll()
.filter(l => l.status === ListingStatus.ACTIVE && l.sellerId !== buyerId);
const matches: MatchResult[] = [];
for (const listing of activeListings) {
const { score, reasons } = this.calculateMatchScore(listing, preferences);
if (score > 0) {
matches.push({
listing,
score,
matchReasons: reasons,
});
}
}
// Sort by score descending
matches.sort((a, b) => b.score - a.score);
return matches.slice(0, limit);
}
/**
* Find buyers who might be interested in a listing
* (In a real system, this would query user preferences)
*/
async findPotentialBuyers(listingId: string): Promise<string[]> {
// Placeholder - would match against stored buyer preferences
// For now, return empty array as we don't have buyer preference storage
return [];
}
/**
* Get recommended listings for a user based on their history
*/
async getRecommendedListings(
userId: string,
limit: number = 8
): Promise<Listing[]> {
// Get user's purchase history and viewed listings
// For now, return featured listings as recommendations
const activeListings = listingStore
.getAll()
.filter(l => l.status === ListingStatus.ACTIVE && l.sellerId !== userId);
// Sort by a combination of view count and recency
return activeListings
.sort((a, b) => {
const aScore = a.viewCount * 0.7 + this.recencyScore(a.createdAt) * 0.3;
const bScore = b.viewCount * 0.7 + this.recencyScore(b.createdAt) * 0.3;
return bScore - aScore;
})
.slice(0, limit);
}
/**
* Find sellers with good ratings in a category
*/
async findTopSellersInCategory(
category: ListingCategory,
limit: number = 5
): Promise<{ userId: string; displayName: string; rating: number; listingCount: number }[]> {
// Get all active listings in category
const categoryListings = listingStore
.getAll()
.filter(l => l.status === ListingStatus.ACTIVE && l.category === category);
// Group by seller
const sellerStats: Record<string, { count: number; sellerId: string }> = {};
for (const listing of categoryListings) {
if (!sellerStats[listing.sellerId]) {
sellerStats[listing.sellerId] = { count: 0, sellerId: listing.sellerId };
}
sellerStats[listing.sellerId].count++;
}
// Get seller profiles and sort by rating
const topSellers = Object.values(sellerStats)
.map(stat => {
const profile = sellerProfileStore.getByUserId(stat.sellerId);
return {
userId: stat.sellerId,
displayName: profile?.displayName || 'Unknown Seller',
rating: profile?.rating || 0,
listingCount: stat.count,
};
})
.sort((a, b) => b.rating - a.rating)
.slice(0, limit);
return topSellers;
}
/**
* Calculate match score between a listing and buyer preferences
*/
private calculateMatchScore(
listing: Listing,
preferences: BuyerPreferences
): { score: number; reasons: string[] } {
let score = 0;
const reasons: string[] = [];
// Category match (highest weight)
if (preferences.categories.includes(listing.category)) {
score += 40;
reasons.push(`Matches preferred category: ${listing.category}`);
}
// Price within budget
if (preferences.maxPrice && listing.price <= preferences.maxPrice) {
score += 20;
reasons.push('Within budget');
}
// Location match
if (preferences.preferredLocation && listing.location) {
const distance = this.calculateDistance(
preferences.preferredLocation.lat,
preferences.preferredLocation.lng,
listing.location.lat,
listing.location.lng
);
if (distance <= preferences.preferredLocation.maxDistanceKm) {
score += 25;
reasons.push(`Nearby (${Math.round(distance)}km away)`);
}
}
// Tag matches
if (preferences.preferredTags && preferences.preferredTags.length > 0) {
const matchingTags = listing.tags.filter(tag =>
preferences.preferredTags!.includes(tag.toLowerCase())
);
if (matchingTags.length > 0) {
score += matchingTags.length * 5;
reasons.push(`Matching tags: ${matchingTags.join(', ')}`);
}
}
// Verified seller preference
if (preferences.preferVerifiedSellers) {
const profile = sellerProfileStore.getByUserId(listing.sellerId);
if (profile?.verified) {
score += 10;
reasons.push('Verified seller');
}
}
return { score, reasons };
}
/**
* Calculate recency score (0-100, higher for more recent)
*/
private recencyScore(date: Date): number {
const now = Date.now();
const created = date.getTime();
const daysOld = (now - created) / (1000 * 60 * 60 * 24);
// Exponential decay: 100 for today, ~37 for 7 days, ~14 for 14 days
return 100 * Math.exp(-daysOld / 10);
}
/**
* Calculate distance between two points (Haversine formula)
*/
private calculateDistance(
lat1: number,
lng1: number,
lat2: number,
lng2: number
): number {
const R = 6371; // Earth's radius in km
const dLat = this.toRad(lat2 - lat1);
const dLng = this.toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.toRad(lat1)) *
Math.cos(this.toRad(lat2)) *
Math.sin(dLng / 2) *
Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
private toRad(deg: number): number {
return deg * (Math.PI / 180);
}
}
// Export singleton instance
export const matchingService = new MatchingService();

View file

@ -1,291 +0,0 @@
// Offer Service for Marketplace
// Handles offer management for marketplace listings
import {
Offer,
OfferStatus,
Listing,
ListingStatus,
CreateOfferInput,
} from './types';
import { offerStore, listingStore, generateId } from './store';
export class OfferService {
/**
* Create a new offer on a listing
*/
async createOffer(
buyerId: string,
buyerName: string,
input: CreateOfferInput
): Promise<Offer> {
const listing = listingStore.getById(input.listingId);
if (!listing) {
throw new Error('Listing not found');
}
if (listing.status !== ListingStatus.ACTIVE) {
throw new Error('Cannot make offers on inactive listings');
}
if (listing.sellerId === buyerId) {
throw new Error('Cannot make an offer on your own listing');
}
// Check for existing pending offer from this buyer
const existingOffer = offerStore.getByListingId(input.listingId)
.find(o => o.buyerId === buyerId && o.status === OfferStatus.PENDING);
if (existingOffer) {
throw new Error('You already have a pending offer on this listing');
}
const offer: Offer = {
id: generateId(),
listingId: input.listingId,
buyerId,
buyerName,
amount: input.amount,
message: input.message,
status: OfferStatus.PENDING,
createdAt: new Date(),
updatedAt: new Date(),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
};
return offerStore.create(offer);
}
/**
* Get an offer by ID
*/
async getOfferById(id: string): Promise<Offer | null> {
return offerStore.getById(id) || null;
}
/**
* Get all offers for a listing
*/
async getOffersForListing(listingId: string): Promise<Offer[]> {
return offerStore.getByListingId(listingId);
}
/**
* Get all offers made by a buyer
*/
async getOffersByBuyer(buyerId: string): Promise<Offer[]> {
return offerStore.getByBuyerId(buyerId);
}
/**
* Get all offers for a seller's listings
*/
async getOffersForSeller(sellerId: string): Promise<(Offer & { listing?: Listing })[]> {
const sellerListings = listingStore.getBySellerId(sellerId);
const listingIds = new Set(sellerListings.map(l => l.id));
const offers = offerStore.getAll().filter(o => listingIds.has(o.listingId));
return offers.map(offer => ({
...offer,
listing: listingStore.getById(offer.listingId),
}));
}
/**
* Accept an offer
*/
async acceptOffer(offerId: string, sellerId: string): Promise<Offer> {
const offer = offerStore.getById(offerId);
if (!offer) {
throw new Error('Offer not found');
}
const listing = listingStore.getById(offer.listingId);
if (!listing) {
throw new Error('Listing not found');
}
if (listing.sellerId !== sellerId) {
throw new Error('Unauthorized: You do not own this listing');
}
if (offer.status !== OfferStatus.PENDING) {
throw new Error('Can only accept pending offers');
}
// Accept this offer
const acceptedOffer = offerStore.update(offerId, {
status: OfferStatus.ACCEPTED
});
// Reject all other pending offers for this listing
const otherOffers = offerStore.getByListingId(offer.listingId)
.filter(o => o.id !== offerId && o.status === OfferStatus.PENDING);
for (const otherOffer of otherOffers) {
offerStore.update(otherOffer.id, { status: OfferStatus.REJECTED });
}
// Mark listing as sold
listingStore.update(offer.listingId, {
status: ListingStatus.SOLD,
quantity: 0
});
if (!acceptedOffer) {
throw new Error('Failed to accept offer');
}
return acceptedOffer;
}
/**
* Reject an offer
*/
async rejectOffer(offerId: string, sellerId: string): Promise<Offer> {
const offer = offerStore.getById(offerId);
if (!offer) {
throw new Error('Offer not found');
}
const listing = listingStore.getById(offer.listingId);
if (!listing) {
throw new Error('Listing not found');
}
if (listing.sellerId !== sellerId) {
throw new Error('Unauthorized: You do not own this listing');
}
if (offer.status !== OfferStatus.PENDING) {
throw new Error('Can only reject pending offers');
}
const rejectedOffer = offerStore.update(offerId, {
status: OfferStatus.REJECTED
});
if (!rejectedOffer) {
throw new Error('Failed to reject offer');
}
return rejectedOffer;
}
/**
* Withdraw an offer (buyer action)
*/
async withdrawOffer(offerId: string, buyerId: string): Promise<Offer> {
const offer = offerStore.getById(offerId);
if (!offer) {
throw new Error('Offer not found');
}
if (offer.buyerId !== buyerId) {
throw new Error('Unauthorized: This is not your offer');
}
if (offer.status !== OfferStatus.PENDING) {
throw new Error('Can only withdraw pending offers');
}
const withdrawnOffer = offerStore.update(offerId, {
status: OfferStatus.WITHDRAWN
});
if (!withdrawnOffer) {
throw new Error('Failed to withdraw offer');
}
return withdrawnOffer;
}
/**
* Counter offer (update amount)
*/
async updateOfferAmount(
offerId: string,
buyerId: string,
newAmount: number
): Promise<Offer> {
const offer = offerStore.getById(offerId);
if (!offer) {
throw new Error('Offer not found');
}
if (offer.buyerId !== buyerId) {
throw new Error('Unauthorized: This is not your offer');
}
if (offer.status !== OfferStatus.PENDING) {
throw new Error('Can only update pending offers');
}
const updatedOffer = offerStore.update(offerId, { amount: newAmount });
if (!updatedOffer) {
throw new Error('Failed to update offer');
}
return updatedOffer;
}
/**
* Get offer statistics
*/
async getOfferStats(userId: string, role: 'buyer' | 'seller'): Promise<{
totalOffers: number;
pendingOffers: number;
acceptedOffers: number;
rejectedOffers: number;
}> {
let offers: Offer[];
if (role === 'buyer') {
offers = offerStore.getByBuyerId(userId);
} else {
const sellerListings = listingStore.getBySellerId(userId);
const listingIds = new Set(sellerListings.map(l => l.id));
offers = offerStore.getAll().filter(o => listingIds.has(o.listingId));
}
return {
totalOffers: offers.length,
pendingOffers: offers.filter(o => o.status === OfferStatus.PENDING).length,
acceptedOffers: offers.filter(o => o.status === OfferStatus.ACCEPTED).length,
rejectedOffers: offers.filter(o => o.status === OfferStatus.REJECTED).length,
};
}
/**
* Expire old pending offers
*/
async expireOldOffers(): Promise<number> {
const now = new Date();
let expiredCount = 0;
const pendingOffers = offerStore.getAll().filter(
o => o.status === OfferStatus.PENDING && o.expiresAt
);
for (const offer of pendingOffers) {
if (offer.expiresAt && offer.expiresAt < now) {
offerStore.update(offer.id, { status: OfferStatus.EXPIRED });
expiredCount++;
}
}
return expiredCount;
}
}
// Export singleton instance
export const offerService = new OfferService();

View file

@ -1,285 +0,0 @@
// Search Service for Marketplace
// Handles listing search and filtering
import {
Listing,
ListingStatus,
SearchFilters,
SearchResult,
ListingCategory,
MarketplaceStats,
} from './types';
import { listingStore } from './store';
export class SearchService {
/**
* Search listings with filters
*/
async searchListings(filters: SearchFilters): Promise<SearchResult> {
let listings = listingStore.getAll();
// Only show active listings unless specific status requested
if (filters.status) {
listings = listings.filter(l => l.status === filters.status);
} else {
listings = listings.filter(l => l.status === ListingStatus.ACTIVE);
}
// Text search in title and description
if (filters.query) {
const query = filters.query.toLowerCase();
listings = listings.filter(l =>
l.title.toLowerCase().includes(query) ||
l.description.toLowerCase().includes(query) ||
l.tags.some(tag => tag.toLowerCase().includes(query))
);
}
// Category filter
if (filters.category) {
listings = listings.filter(l => l.category === filters.category);
}
// Price range filter
if (filters.minPrice !== undefined) {
listings = listings.filter(l => l.price >= filters.minPrice!);
}
if (filters.maxPrice !== undefined) {
listings = listings.filter(l => l.price <= filters.maxPrice!);
}
// Seller filter
if (filters.sellerId) {
listings = listings.filter(l => l.sellerId === filters.sellerId);
}
// Tags filter
if (filters.tags && filters.tags.length > 0) {
const filterTags = filters.tags.map(t => t.toLowerCase());
listings = listings.filter(l =>
filterTags.some(tag => l.tags.map(t => t.toLowerCase()).includes(tag))
);
}
// Location filter (approximate distance)
if (filters.location) {
listings = listings.filter(l => {
if (!l.location) return false;
const distance = this.calculateDistance(
filters.location!.lat,
filters.location!.lng,
l.location.lat,
l.location.lng
);
return distance <= filters.location!.radiusKm;
});
}
// Sort listings
listings = this.sortListings(listings, filters.sortBy || 'date_desc', filters.query);
// Pagination
const page = filters.page || 1;
const limit = filters.limit || 20;
const total = listings.length;
const startIndex = (page - 1) * limit;
const paginatedListings = listings.slice(startIndex, startIndex + limit);
return {
listings: paginatedListings,
total,
page,
limit,
hasMore: startIndex + limit < total,
};
}
/**
* Get featured listings (most viewed active listings)
*/
async getFeaturedListings(limit: number = 6): Promise<Listing[]> {
return listingStore
.getAll()
.filter(l => l.status === ListingStatus.ACTIVE)
.sort((a, b) => b.viewCount - a.viewCount)
.slice(0, limit);
}
/**
* Get recent listings
*/
async getRecentListings(limit: number = 10): Promise<Listing[]> {
return listingStore
.getAll()
.filter(l => l.status === ListingStatus.ACTIVE)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.slice(0, limit);
}
/**
* Get listings by category
*/
async getListingsByCategory(
category: ListingCategory,
limit: number = 20
): Promise<Listing[]> {
return listingStore
.getAll()
.filter(l => l.status === ListingStatus.ACTIVE && l.category === category)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.slice(0, limit);
}
/**
* Get similar listings based on category and tags
*/
async getSimilarListings(listingId: string, limit: number = 4): Promise<Listing[]> {
const listing = listingStore.getById(listingId);
if (!listing) return [];
return listingStore
.getAll()
.filter(l =>
l.id !== listingId &&
l.status === ListingStatus.ACTIVE &&
(l.category === listing.category ||
l.tags.some(tag => listing.tags.includes(tag)))
)
.sort((a, b) => {
// Score based on matching tags
const aScore = a.tags.filter(tag => listing.tags.includes(tag)).length;
const bScore = b.tags.filter(tag => listing.tags.includes(tag)).length;
return bScore - aScore;
})
.slice(0, limit);
}
/**
* Get marketplace statistics
*/
async getMarketplaceStats(): Promise<MarketplaceStats> {
const allListings = listingStore.getAll();
const activeListings = allListings.filter(l => l.status === ListingStatus.ACTIVE);
const soldListings = allListings.filter(l => l.status === ListingStatus.SOLD);
// Category counts
const categoryCounts: Record<ListingCategory, number> = {
[ListingCategory.SEEDS]: 0,
[ListingCategory.SEEDLINGS]: 0,
[ListingCategory.MATURE_PLANTS]: 0,
[ListingCategory.CUTTINGS]: 0,
[ListingCategory.PRODUCE]: 0,
[ListingCategory.SUPPLIES]: 0,
};
activeListings.forEach(l => {
categoryCounts[l.category]++;
});
// Top categories
const topCategories = Object.entries(categoryCounts)
.map(([category, count]) => ({
category: category as ListingCategory,
count
}))
.sort((a, b) => b.count - a.count)
.slice(0, 5);
// Average price
const averagePrice = activeListings.length > 0
? activeListings.reduce((sum, l) => sum + l.price, 0) / activeListings.length
: 0;
return {
totalListings: allListings.length,
activeListings: activeListings.length,
totalSales: soldListings.length,
totalOffers: 0, // Would be calculated from offer store
categoryCounts,
averagePrice: Math.round(averagePrice * 100) / 100,
topCategories,
};
}
/**
* Get popular tags
*/
async getPopularTags(limit: number = 20): Promise<{ tag: string; count: number }[]> {
const tagCounts: Record<string, number> = {};
listingStore
.getAll()
.filter(l => l.status === ListingStatus.ACTIVE)
.forEach(l => {
l.tags.forEach(tag => {
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
});
});
return Object.entries(tagCounts)
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => b.count - a.count)
.slice(0, limit);
}
/**
* Sort listings based on sort option
*/
private sortListings(
listings: Listing[],
sortBy: string,
query?: string
): Listing[] {
switch (sortBy) {
case 'price_asc':
return listings.sort((a, b) => a.price - b.price);
case 'price_desc':
return listings.sort((a, b) => b.price - a.price);
case 'date_asc':
return listings.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
case 'date_desc':
return listings.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
case 'relevance':
if (!query) {
return listings.sort((a, b) => b.viewCount - a.viewCount);
}
// Simple relevance scoring based on title match
return listings.sort((a, b) => {
const aTitle = a.title.toLowerCase().includes(query.toLowerCase()) ? 1 : 0;
const bTitle = b.title.toLowerCase().includes(query.toLowerCase()) ? 1 : 0;
return bTitle - aTitle;
});
default:
return listings;
}
}
/**
* Calculate distance between two points (Haversine formula)
*/
private calculateDistance(
lat1: number,
lng1: number,
lat2: number,
lng2: number
): number {
const R = 6371; // Earth's radius in km
const dLat = this.toRad(lat2 - lat1);
const dLng = this.toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.toRad(lat1)) *
Math.cos(this.toRad(lat2)) *
Math.sin(dLng / 2) *
Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
private toRad(deg: number): number {
return deg * (Math.PI / 180);
}
}
// Export singleton instance
export const searchService = new SearchService();

View file

@ -1,273 +0,0 @@
// In-Memory Store for Marketplace
// This will be replaced with Prisma database calls once Agent 2 completes database setup
import {
Listing,
Offer,
SellerProfile,
WishlistItem,
ListingCategory,
ListingStatus,
OfferStatus,
} from './types';
// In-memory storage
const listings: Map<string, Listing> = new Map();
const offers: Map<string, Offer> = new Map();
const sellerProfiles: Map<string, SellerProfile> = new Map();
const wishlistItems: Map<string, WishlistItem> = new Map();
// Helper to generate IDs
export const generateId = (): string => {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
};
// Seed with sample data for development
const seedSampleData = () => {
const sampleListings: Listing[] = [
{
id: 'listing-1',
sellerId: 'user-1',
sellerName: 'Green Thumb Gardens',
title: 'Heirloom Tomato Seedlings - Cherokee Purple',
description: 'Beautiful heirloom Cherokee Purple tomato seedlings, organically grown. These produce large, deep purple-red fruits with rich, complex flavor. Perfect for home gardens.',
price: 4.99,
currency: 'USD',
quantity: 24,
category: ListingCategory.SEEDLINGS,
status: ListingStatus.ACTIVE,
location: { lat: 40.7128, lng: -74.006, city: 'New York', region: 'NY' },
tags: ['organic', 'heirloom', 'tomato', 'vegetable'],
images: [
{ id: 'img-1', listingId: 'listing-1', url: '/images/tomato-seedling.jpg', alt: 'Cherokee Purple Tomato Seedling', isPrimary: true, createdAt: new Date() }
],
viewCount: 142,
createdAt: new Date('2024-03-01'),
updatedAt: new Date('2024-03-15'),
},
{
id: 'listing-2',
sellerId: 'user-2',
sellerName: 'Urban Herb Farm',
title: 'Fresh Basil Plants - Genovese',
description: 'Ready-to-harvest Genovese basil plants grown in our vertical farm. Perfect for pesto, salads, and Italian cuisine. Each plant is 6-8 inches tall.',
price: 6.50,
currency: 'USD',
quantity: 50,
category: ListingCategory.MATURE_PLANTS,
status: ListingStatus.ACTIVE,
location: { lat: 34.0522, lng: -118.2437, city: 'Los Angeles', region: 'CA' },
tags: ['herbs', 'basil', 'culinary', 'fresh'],
images: [],
viewCount: 89,
createdAt: new Date('2024-03-10'),
updatedAt: new Date('2024-03-10'),
},
{
id: 'listing-3',
sellerId: 'user-1',
sellerName: 'Green Thumb Gardens',
title: 'Organic Lettuce Mix Seeds',
description: 'Premium mix of organic lettuce seeds including romaine, butterhead, and red leaf varieties. Perfect for succession planting.',
price: 3.99,
currency: 'USD',
quantity: 100,
category: ListingCategory.SEEDS,
status: ListingStatus.ACTIVE,
location: { lat: 40.7128, lng: -74.006, city: 'New York', region: 'NY' },
tags: ['organic', 'seeds', 'lettuce', 'salad'],
images: [],
viewCount: 256,
createdAt: new Date('2024-02-15'),
updatedAt: new Date('2024-03-01'),
},
{
id: 'listing-4',
sellerId: 'user-3',
sellerName: 'Succulent Paradise',
title: 'Assorted Succulent Cuttings - 10 Pack',
description: 'Beautiful assortment of succulent cuttings ready for propagation. Includes echeveria, sedum, and crassula varieties.',
price: 15.00,
currency: 'USD',
quantity: 30,
category: ListingCategory.CUTTINGS,
status: ListingStatus.ACTIVE,
location: { lat: 33.4484, lng: -112.074, city: 'Phoenix', region: 'AZ' },
tags: ['succulents', 'cuttings', 'propagation', 'drought-tolerant'],
images: [],
viewCount: 178,
createdAt: new Date('2024-03-05'),
updatedAt: new Date('2024-03-12'),
},
{
id: 'listing-5',
sellerId: 'user-2',
sellerName: 'Urban Herb Farm',
title: 'Fresh Microgreens - Chef\'s Mix',
description: 'Freshly harvested microgreens mix including sunflower, radish, and pea shoots. Harvested same day as shipping for maximum freshness.',
price: 8.99,
currency: 'USD',
quantity: 40,
category: ListingCategory.PRODUCE,
status: ListingStatus.ACTIVE,
location: { lat: 34.0522, lng: -118.2437, city: 'Los Angeles', region: 'CA' },
tags: ['microgreens', 'fresh', 'produce', 'chef'],
images: [],
viewCount: 312,
createdAt: new Date('2024-03-18'),
updatedAt: new Date('2024-03-18'),
},
];
const sampleProfiles: SellerProfile[] = [
{
userId: 'user-1',
displayName: 'Green Thumb Gardens',
bio: 'Family-owned nursery specializing in heirloom vegetables and native plants.',
location: { city: 'New York', region: 'NY' },
rating: 4.8,
reviewCount: 127,
totalSales: 523,
memberSince: new Date('2023-01-15'),
verified: true,
},
{
userId: 'user-2',
displayName: 'Urban Herb Farm',
bio: 'Vertical farm growing fresh herbs and microgreens in the heart of LA.',
location: { city: 'Los Angeles', region: 'CA' },
rating: 4.9,
reviewCount: 89,
totalSales: 412,
memberSince: new Date('2023-03-20'),
verified: true,
},
{
userId: 'user-3',
displayName: 'Succulent Paradise',
bio: 'Desert plant enthusiast sharing the beauty of succulents.',
location: { city: 'Phoenix', region: 'AZ' },
rating: 4.7,
reviewCount: 56,
totalSales: 198,
memberSince: new Date('2023-06-01'),
verified: false,
},
];
// Seed listings
sampleListings.forEach(listing => {
listings.set(listing.id, listing);
});
// Seed profiles
sampleProfiles.forEach(profile => {
sellerProfiles.set(profile.userId, profile);
});
};
// Initialize with sample data
seedSampleData();
// Export store operations
export const listingStore = {
getAll: (): Listing[] => Array.from(listings.values()),
getById: (id: string): Listing | undefined => listings.get(id),
getBySellerId: (sellerId: string): Listing[] =>
Array.from(listings.values()).filter(l => l.sellerId === sellerId),
create: (listing: Listing): Listing => {
listings.set(listing.id, listing);
return listing;
},
update: (id: string, updates: Partial<Listing>): Listing | undefined => {
const existing = listings.get(id);
if (!existing) return undefined;
const updated = { ...existing, ...updates, updatedAt: new Date() };
listings.set(id, updated);
return updated;
},
delete: (id: string): boolean => listings.delete(id),
incrementViewCount: (id: string): void => {
const listing = listings.get(id);
if (listing) {
listing.viewCount++;
listings.set(id, listing);
}
},
};
export const offerStore = {
getAll: (): Offer[] => Array.from(offers.values()),
getById: (id: string): Offer | undefined => offers.get(id),
getByListingId: (listingId: string): Offer[] =>
Array.from(offers.values()).filter(o => o.listingId === listingId),
getByBuyerId: (buyerId: string): Offer[] =>
Array.from(offers.values()).filter(o => o.buyerId === buyerId),
create: (offer: Offer): Offer => {
offers.set(offer.id, offer);
return offer;
},
update: (id: string, updates: Partial<Offer>): Offer | undefined => {
const existing = offers.get(id);
if (!existing) return undefined;
const updated = { ...existing, ...updates, updatedAt: new Date() };
offers.set(id, updated);
return updated;
},
delete: (id: string): boolean => offers.delete(id),
};
export const sellerProfileStore = {
getByUserId: (userId: string): SellerProfile | undefined =>
sellerProfiles.get(userId),
create: (profile: SellerProfile): SellerProfile => {
sellerProfiles.set(profile.userId, profile);
return profile;
},
update: (userId: string, updates: Partial<SellerProfile>): SellerProfile | undefined => {
const existing = sellerProfiles.get(userId);
if (!existing) return undefined;
const updated = { ...existing, ...updates };
sellerProfiles.set(userId, updated);
return updated;
},
};
export const wishlistStore = {
getByUserId: (userId: string): WishlistItem[] =>
Array.from(wishlistItems.values()).filter(w => w.userId === userId),
add: (item: WishlistItem): WishlistItem => {
wishlistItems.set(item.id, item);
return item;
},
remove: (userId: string, listingId: string): boolean => {
const item = Array.from(wishlistItems.values()).find(
w => w.userId === userId && w.listingId === listingId
);
if (item) {
return wishlistItems.delete(item.id);
}
return false;
},
exists: (userId: string, listingId: string): boolean =>
Array.from(wishlistItems.values()).some(
w => w.userId === userId && w.listingId === listingId
),
};

View file

@ -1,167 +0,0 @@
// Marketplace Types for LocalGreenChain
// These types define the marketplace foundation for plant trading
export enum ListingCategory {
SEEDS = 'seeds',
SEEDLINGS = 'seedlings',
MATURE_PLANTS = 'mature_plants',
CUTTINGS = 'cuttings',
PRODUCE = 'produce',
SUPPLIES = 'supplies',
}
export enum ListingStatus {
DRAFT = 'draft',
ACTIVE = 'active',
SOLD = 'sold',
EXPIRED = 'expired',
CANCELLED = 'cancelled',
}
export enum OfferStatus {
PENDING = 'pending',
ACCEPTED = 'accepted',
REJECTED = 'rejected',
WITHDRAWN = 'withdrawn',
EXPIRED = 'expired',
}
export interface ListingImage {
id: string;
listingId: string;
url: string;
alt: string;
isPrimary: boolean;
createdAt: Date;
}
export interface Listing {
id: string;
sellerId: string;
sellerName?: string;
plantId?: string;
title: string;
description: string;
price: number;
currency: string;
quantity: number;
category: ListingCategory;
status: ListingStatus;
location?: {
lat: number;
lng: number;
city?: string;
region?: string;
};
tags: string[];
images: ListingImage[];
viewCount: number;
createdAt: Date;
updatedAt: Date;
expiresAt?: Date;
}
export interface Offer {
id: string;
listingId: string;
buyerId: string;
buyerName?: string;
amount: number;
message?: string;
status: OfferStatus;
createdAt: Date;
updatedAt: Date;
expiresAt?: Date;
}
export interface SellerProfile {
userId: string;
displayName: string;
bio?: string;
location?: {
city?: string;
region?: string;
};
rating: number;
reviewCount: number;
totalSales: number;
memberSince: Date;
verified: boolean;
}
export interface WishlistItem {
id: string;
userId: string;
listingId: string;
addedAt: Date;
}
export interface SearchFilters {
query?: string;
category?: ListingCategory;
minPrice?: number;
maxPrice?: number;
location?: {
lat: number;
lng: number;
radiusKm: number;
};
sellerId?: string;
status?: ListingStatus;
tags?: string[];
sortBy?: 'price_asc' | 'price_desc' | 'date_desc' | 'date_asc' | 'relevance';
page?: number;
limit?: number;
}
export interface SearchResult {
listings: Listing[];
total: number;
page: number;
limit: number;
hasMore: boolean;
}
export interface CreateListingInput {
title: string;
description: string;
price: number;
currency?: string;
quantity: number;
category: ListingCategory;
plantId?: string;
location?: {
lat: number;
lng: number;
city?: string;
region?: string;
};
tags?: string[];
expiresAt?: Date;
}
export interface UpdateListingInput {
title?: string;
description?: string;
price?: number;
quantity?: number;
status?: ListingStatus;
tags?: string[];
expiresAt?: Date;
}
export interface CreateOfferInput {
listingId: string;
amount: number;
message?: string;
}
export interface MarketplaceStats {
totalListings: number;
activeListings: number;
totalSales: number;
totalOffers: number;
categoryCounts: Record<ListingCategory, number>;
averagePrice: number;
topCategories: { category: ListingCategory; count: number }[];
}

View file

@ -1,254 +0,0 @@
/**
* Mobile Camera Utilities
* Provides camera access, photo capture, and image processing for mobile devices
*/
export interface CameraConfig {
facingMode?: 'user' | 'environment';
width?: number;
height?: number;
aspectRatio?: number;
}
export interface CapturedImage {
blob: Blob;
dataUrl: string;
width: number;
height: number;
}
export class CameraService {
private stream: MediaStream | null = null;
private videoElement: HTMLVideoElement | null = null;
async checkCameraAvailability(): Promise<boolean> {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.some((device) => device.kind === 'videoinput');
} catch {
return false;
}
}
async requestPermission(): Promise<PermissionState> {
try {
const result = await navigator.permissions.query({ name: 'camera' as PermissionName });
return result.state;
} catch {
// Fallback: try to access camera to trigger permission prompt
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
stream.getTracks().forEach((track) => track.stop());
return 'granted';
} catch {
return 'denied';
}
}
}
async startCamera(videoElement: HTMLVideoElement, config: CameraConfig = {}): Promise<void> {
const {
facingMode = 'environment',
width = 1280,
height = 720,
aspectRatio,
} = config;
const constraints: MediaStreamConstraints = {
video: {
facingMode,
width: { ideal: width },
height: { ideal: height },
...(aspectRatio && { aspectRatio }),
},
};
try {
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
videoElement.srcObject = this.stream;
this.videoElement = videoElement;
await videoElement.play();
} catch (error) {
throw new Error(`Failed to start camera: ${(error as Error).message}`);
}
}
async capturePhoto(quality = 0.9): Promise<CapturedImage> {
if (!this.videoElement || !this.stream) {
throw new Error('Camera not started');
}
const canvas = document.createElement('canvas');
const video = this.videoElement;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get canvas context');
}
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('Failed to capture photo'));
return;
}
const dataUrl = canvas.toDataURL('image/jpeg', quality);
resolve({
blob,
dataUrl,
width: canvas.width,
height: canvas.height,
});
},
'image/jpeg',
quality
);
});
}
async switchCamera(): Promise<void> {
if (!this.videoElement) {
throw new Error('Camera not started');
}
const currentTrack = this.stream?.getVideoTracks()[0];
const currentFacingMode = currentTrack?.getSettings().facingMode;
const newFacingMode = currentFacingMode === 'user' ? 'environment' : 'user';
this.stopCamera();
await this.startCamera(this.videoElement, { facingMode: newFacingMode });
}
stopCamera(): void {
if (this.stream) {
this.stream.getTracks().forEach((track) => track.stop());
this.stream = null;
}
if (this.videoElement) {
this.videoElement.srcObject = null;
this.videoElement = null;
}
}
isActive(): boolean {
return this.stream !== null && this.stream.active;
}
}
// Image processing utilities
export async function cropImage(
image: CapturedImage,
x: number,
y: number,
width: number,
height: number
): Promise<CapturedImage> {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get canvas context');
}
const img = await loadImage(image.dataUrl);
ctx.drawImage(img, x, y, width, height, 0, 0, width, height);
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('Failed to crop image'));
return;
}
resolve({
blob,
dataUrl: canvas.toDataURL('image/jpeg', 0.9),
width,
height,
});
},
'image/jpeg',
0.9
);
});
}
export async function resizeImage(
image: CapturedImage,
maxWidth: number,
maxHeight: number
): Promise<CapturedImage> {
const img = await loadImage(image.dataUrl);
let { width, height } = img;
const ratio = Math.min(maxWidth / width, maxHeight / height);
if (ratio < 1) {
width = Math.round(width * ratio);
height = Math.round(height * ratio);
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get canvas context');
}
ctx.drawImage(img, 0, 0, width, height);
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('Failed to resize image'));
return;
}
resolve({
blob,
dataUrl: canvas.toDataURL('image/jpeg', 0.9),
width,
height,
});
},
'image/jpeg',
0.9
);
});
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}
// Singleton instance
let cameraInstance: CameraService | null = null;
export function getCamera(): CameraService {
if (!cameraInstance) {
cameraInstance = new CameraService();
}
return cameraInstance;
}
export default CameraService;

View file

@ -1,257 +0,0 @@
/**
* Mobile Gesture Utilities
* Provides touch gesture detection and handling
*/
export interface GestureEvent {
type: 'swipe' | 'pinch' | 'rotate' | 'tap' | 'longpress' | 'doubletap';
direction?: 'up' | 'down' | 'left' | 'right';
scale?: number;
rotation?: number;
center?: { x: number; y: number };
velocity?: { x: number; y: number };
}
export interface GestureHandlers {
onSwipe?: (direction: 'up' | 'down' | 'left' | 'right', velocity: number) => void;
onPinch?: (scale: number) => void;
onRotate?: (angle: number) => void;
onTap?: (x: number, y: number) => void;
onLongPress?: (x: number, y: number) => void;
onDoubleTap?: (x: number, y: number) => void;
}
export interface GestureConfig {
swipeThreshold?: number;
swipeVelocityThreshold?: number;
longPressDelay?: number;
doubleTapDelay?: number;
pinchThreshold?: number;
}
const defaultConfig: Required<GestureConfig> = {
swipeThreshold: 50,
swipeVelocityThreshold: 0.3,
longPressDelay: 500,
doubleTapDelay: 300,
pinchThreshold: 0.1,
};
export function createGestureHandler(
element: HTMLElement,
handlers: GestureHandlers,
config: GestureConfig = {}
): () => void {
const cfg = { ...defaultConfig, ...config };
let startX = 0;
let startY = 0;
let startTime = 0;
let longPressTimer: NodeJS.Timeout | null = null;
let lastTapTime = 0;
let initialDistance = 0;
let initialAngle = 0;
const getDistance = (touches: TouchList): number => {
if (touches.length < 2) return 0;
const dx = touches[1].clientX - touches[0].clientX;
const dy = touches[1].clientY - touches[0].clientY;
return Math.sqrt(dx * dx + dy * dy);
};
const getAngle = (touches: TouchList): number => {
if (touches.length < 2) return 0;
const dx = touches[1].clientX - touches[0].clientX;
const dy = touches[1].clientY - touches[0].clientY;
return (Math.atan2(dy, dx) * 180) / Math.PI;
};
const handleTouchStart = (e: TouchEvent) => {
const touch = e.touches[0];
startX = touch.clientX;
startY = touch.clientY;
startTime = Date.now();
// Long press detection
if (handlers.onLongPress) {
longPressTimer = setTimeout(() => {
handlers.onLongPress!(startX, startY);
// Vibrate on long press if available
if ('vibrate' in navigator) {
navigator.vibrate(50);
}
}, cfg.longPressDelay);
}
// Pinch/rotate initialization
if (e.touches.length === 2) {
initialDistance = getDistance(e.touches);
initialAngle = getAngle(e.touches);
}
};
const handleTouchMove = (e: TouchEvent) => {
// Cancel long press on move
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
// Pinch detection
if (e.touches.length === 2 && (handlers.onPinch || handlers.onRotate)) {
const currentDistance = getDistance(e.touches);
const currentAngle = getAngle(e.touches);
if (handlers.onPinch && initialDistance > 0) {
const scale = currentDistance / initialDistance;
if (Math.abs(scale - 1) > cfg.pinchThreshold) {
handlers.onPinch(scale);
}
}
if (handlers.onRotate) {
const rotation = currentAngle - initialAngle;
handlers.onRotate(rotation);
}
}
};
const handleTouchEnd = (e: TouchEvent) => {
// Clear long press timer
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
const touch = e.changedTouches[0];
const endX = touch.clientX;
const endY = touch.clientY;
const endTime = Date.now();
const deltaX = endX - startX;
const deltaY = endY - startY;
const deltaTime = endTime - startTime;
// Swipe detection
if (handlers.onSwipe) {
const velocity = Math.sqrt(deltaX * deltaX + deltaY * deltaY) / deltaTime;
if (velocity > cfg.swipeVelocityThreshold) {
if (Math.abs(deltaX) > Math.abs(deltaY)) {
if (Math.abs(deltaX) > cfg.swipeThreshold) {
handlers.onSwipe(deltaX > 0 ? 'right' : 'left', velocity);
}
} else {
if (Math.abs(deltaY) > cfg.swipeThreshold) {
handlers.onSwipe(deltaY > 0 ? 'down' : 'up', velocity);
}
}
}
}
// Tap / Double tap detection
if (Math.abs(deltaX) < 10 && Math.abs(deltaY) < 10 && deltaTime < 300) {
const now = Date.now();
if (handlers.onDoubleTap && now - lastTapTime < cfg.doubleTapDelay) {
handlers.onDoubleTap(endX, endY);
lastTapTime = 0;
} else if (handlers.onTap) {
lastTapTime = now;
// Delay tap to wait for potential double tap
if (handlers.onDoubleTap) {
setTimeout(() => {
if (lastTapTime === now) {
handlers.onTap!(endX, endY);
}
}, cfg.doubleTapDelay);
} else {
handlers.onTap(endX, endY);
}
}
}
};
const handleTouchCancel = () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
};
// Add event listeners
element.addEventListener('touchstart', handleTouchStart, { passive: true });
element.addEventListener('touchmove', handleTouchMove, { passive: true });
element.addEventListener('touchend', handleTouchEnd, { passive: true });
element.addEventListener('touchcancel', handleTouchCancel, { passive: true });
// Return cleanup function
return () => {
element.removeEventListener('touchstart', handleTouchStart);
element.removeEventListener('touchmove', handleTouchMove);
element.removeEventListener('touchend', handleTouchEnd);
element.removeEventListener('touchcancel', handleTouchCancel);
if (longPressTimer) {
clearTimeout(longPressTimer);
}
};
}
// React hook for gestures
export function useGestures(
ref: React.RefObject<HTMLElement>,
handlers: GestureHandlers,
config?: GestureConfig
): void {
if (typeof window === 'undefined') return;
const handlersRef = { current: handlers };
handlersRef.current = handlers;
const element = ref.current;
if (!element) return;
// Note: In actual React usage, this should be inside a useEffect
createGestureHandler(element, handlersRef.current, config);
}
// Haptic feedback utility
export function triggerHaptic(type: 'light' | 'medium' | 'heavy' = 'light'): void {
if (!('vibrate' in navigator)) return;
const patterns: Record<typeof type, number | number[]> = {
light: 10,
medium: 25,
heavy: [50, 50, 50],
};
navigator.vibrate(patterns[type]);
}
// Prevent pull-to-refresh on specific elements
export function preventPullToRefresh(element: HTMLElement): () => void {
let startY = 0;
const handleTouchStart = (e: TouchEvent) => {
startY = e.touches[0].clientY;
};
const handleTouchMove = (e: TouchEvent) => {
const currentY = e.touches[0].clientY;
const scrollTop = element.scrollTop;
// Prevent if at top and pulling down
if (scrollTop === 0 && currentY > startY) {
e.preventDefault();
}
};
element.addEventListener('touchstart', handleTouchStart, { passive: true });
element.addEventListener('touchmove', handleTouchMove, { passive: false });
return () => {
element.removeEventListener('touchstart', handleTouchStart);
element.removeEventListener('touchmove', handleTouchMove);
};
}

View file

@ -1,4 +0,0 @@
export * from './camera';
export * from './offline';
export * from './gestures';
export * from './pwa';

View file

@ -1,275 +0,0 @@
/**
* Offline Support Utilities
* Provides IndexedDB storage and background sync for offline functionality
*/
import { openDB, DBSchema, IDBPDatabase } from 'idb';
// Database schema
interface LocalGreenChainDB extends DBSchema {
'pending-plants': {
key: string;
value: {
id: string;
data: any;
createdAt: string;
attempts: number;
};
indexes: { 'by-created': string };
};
'pending-transport': {
key: string;
value: {
id: string;
data: any;
createdAt: string;
attempts: number;
};
indexes: { 'by-created': string };
};
'cached-plants': {
key: string;
value: {
id: string;
data: any;
cachedAt: string;
};
indexes: { 'by-cached': string };
};
'user-preferences': {
key: string;
value: any;
};
}
const DB_NAME = 'localgreenchain-offline';
const DB_VERSION = 1;
let dbPromise: Promise<IDBPDatabase<LocalGreenChainDB>> | null = null;
async function getDB(): Promise<IDBPDatabase<LocalGreenChainDB>> {
if (!dbPromise) {
dbPromise = openDB<LocalGreenChainDB>(DB_NAME, DB_VERSION, {
upgrade(db) {
// Pending plants store
if (!db.objectStoreNames.contains('pending-plants')) {
const plantStore = db.createObjectStore('pending-plants', { keyPath: 'id' });
plantStore.createIndex('by-created', 'createdAt');
}
// Pending transport store
if (!db.objectStoreNames.contains('pending-transport')) {
const transportStore = db.createObjectStore('pending-transport', { keyPath: 'id' });
transportStore.createIndex('by-created', 'createdAt');
}
// Cached plants store
if (!db.objectStoreNames.contains('cached-plants')) {
const cacheStore = db.createObjectStore('cached-plants', { keyPath: 'id' });
cacheStore.createIndex('by-cached', 'cachedAt');
}
// User preferences store
if (!db.objectStoreNames.contains('user-preferences')) {
db.createObjectStore('user-preferences');
}
},
});
}
return dbPromise;
}
// Network status
export function isOnline(): boolean {
return typeof navigator !== 'undefined' ? navigator.onLine : true;
}
export function onNetworkChange(callback: (online: boolean) => void): () => void {
if (typeof window === 'undefined') return () => {};
const handleOnline = () => callback(true);
const handleOffline = () => callback(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}
// Pending operations
export async function queuePlantRegistration(plantData: any): Promise<string> {
const db = await getDB();
const id = generateId();
await db.put('pending-plants', {
id,
data: plantData,
createdAt: new Date().toISOString(),
attempts: 0,
});
// Register for background sync if available
if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) {
const registration = await navigator.serviceWorker.ready;
await (registration as any).sync.register('sync-plants');
}
return id;
}
export async function queueTransportEvent(eventData: any): Promise<string> {
const db = await getDB();
const id = generateId();
await db.put('pending-transport', {
id,
data: eventData,
createdAt: new Date().toISOString(),
attempts: 0,
});
// Register for background sync if available
if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) {
const registration = await navigator.serviceWorker.ready;
await (registration as any).sync.register('sync-transport');
}
return id;
}
export async function getPendingPlants(): Promise<any[]> {
const db = await getDB();
return db.getAll('pending-plants');
}
export async function getPendingTransport(): Promise<any[]> {
const db = await getDB();
return db.getAll('pending-transport');
}
export async function removePendingPlant(id: string): Promise<void> {
const db = await getDB();
await db.delete('pending-plants', id);
}
export async function removePendingTransport(id: string): Promise<void> {
const db = await getDB();
await db.delete('pending-transport', id);
}
// Plant caching
export async function cachePlant(plant: any): Promise<void> {
const db = await getDB();
await db.put('cached-plants', {
id: plant.id,
data: plant,
cachedAt: new Date().toISOString(),
});
}
export async function getCachedPlant(id: string): Promise<any | null> {
const db = await getDB();
const cached = await db.get('cached-plants', id);
return cached?.data || null;
}
export async function getCachedPlants(): Promise<any[]> {
const db = await getDB();
const all = await db.getAll('cached-plants');
return all.map((item) => item.data);
}
export async function clearCachedPlants(): Promise<void> {
const db = await getDB();
await db.clear('cached-plants');
}
// User preferences
export async function setPreference(key: string, value: any): Promise<void> {
const db = await getDB();
await db.put('user-preferences', value, key);
}
export async function getPreference<T>(key: string): Promise<T | null> {
const db = await getDB();
return db.get('user-preferences', key) as Promise<T | null>;
}
// Sync operations
export async function syncPendingPlants(): Promise<{ success: number; failed: number }> {
const pending = await getPendingPlants();
let success = 0;
let failed = 0;
for (const item of pending) {
try {
const response = await fetch('/api/plants/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item.data),
});
if (response.ok) {
await removePendingPlant(item.id);
success++;
} else {
failed++;
}
} catch {
failed++;
}
}
return { success, failed };
}
export async function syncPendingTransport(): Promise<{ success: number; failed: number }> {
const pending = await getPendingTransport();
let success = 0;
let failed = 0;
for (const item of pending) {
try {
const response = await fetch('/api/transport/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item.data),
});
if (response.ok) {
await removePendingTransport(item.id);
success++;
} else {
failed++;
}
} catch {
failed++;
}
}
return { success, failed };
}
export async function syncAll(): Promise<void> {
if (!isOnline()) return;
await Promise.all([
syncPendingPlants(),
syncPendingTransport(),
]);
}
// Utility
function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
// Auto-sync when coming online
if (typeof window !== 'undefined') {
window.addEventListener('online', () => {
syncAll().catch(console.error);
});
}

View file

@ -1,268 +0,0 @@
/**
* PWA Utilities
* Service worker registration, update handling, and install prompt management
*/
export interface PWAStatus {
isInstalled: boolean;
isStandalone: boolean;
canInstall: boolean;
isOnline: boolean;
updateAvailable: boolean;
}
interface BeforeInstallPromptEvent extends Event {
readonly platforms: string[];
readonly userChoice: Promise<{
outcome: 'accepted' | 'dismissed';
platform: string;
}>;
prompt(): Promise<void>;
}
let deferredPrompt: BeforeInstallPromptEvent | null = null;
let swRegistration: ServiceWorkerRegistration | null = null;
// Check if running as installed PWA
export function isInstalled(): boolean {
if (typeof window === 'undefined') return false;
return (
window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone === true ||
document.referrer.includes('android-app://')
);
}
// Check if running in standalone mode
export function isStandalone(): boolean {
if (typeof window === 'undefined') return false;
return window.matchMedia('(display-mode: standalone)').matches;
}
// Check if app can be installed
export function canInstall(): boolean {
return deferredPrompt !== null;
}
// Get current PWA status
export function getPWAStatus(): PWAStatus {
return {
isInstalled: isInstalled(),
isStandalone: isStandalone(),
canInstall: canInstall(),
isOnline: typeof navigator !== 'undefined' ? navigator.onLine : true,
updateAvailable: false, // Updated by service worker
};
}
// Prompt user to install PWA
export async function promptInstall(): Promise<boolean> {
if (!deferredPrompt) {
return false;
}
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
deferredPrompt = null;
return outcome === 'accepted';
}
// Register service worker
export async function registerServiceWorker(): Promise<ServiceWorkerRegistration | null> {
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
return null;
}
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
});
swRegistration = registration;
// Check for updates periodically
setInterval(() => {
registration.update();
}, 60 * 60 * 1000); // Check every hour
// Handle updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (!newWorker) return;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New update available
dispatchPWAEvent('updateavailable');
}
});
});
return registration;
} catch (error) {
console.error('Service worker registration failed:', error);
return null;
}
}
// Apply pending update
export async function applyUpdate(): Promise<void> {
if (!swRegistration?.waiting) return;
// Tell service worker to skip waiting
swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' });
// Reload page after new service worker takes over
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
});
}
// Unregister service worker
export async function unregisterServiceWorker(): Promise<boolean> {
if (!swRegistration) return false;
const success = await swRegistration.unregister();
if (success) {
swRegistration = null;
}
return success;
}
// Clear all caches
export async function clearCaches(): Promise<void> {
if (typeof caches === 'undefined') return;
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map((name) => caches.delete(name)));
}
// Subscribe to push notifications
export async function subscribeToPush(vapidPublicKey: string): Promise<PushSubscription | null> {
if (!swRegistration) {
console.error('Service worker not registered');
return null;
}
try {
const subscription = await swRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
return subscription;
} catch (error) {
console.error('Push subscription failed:', error);
return null;
}
}
// Unsubscribe from push notifications
export async function unsubscribeFromPush(): Promise<boolean> {
if (!swRegistration) return false;
try {
const subscription = await swRegistration.pushManager.getSubscription();
if (subscription) {
return await subscription.unsubscribe();
}
return true;
} catch {
return false;
}
}
// Check push notification permission
export function getPushPermission(): NotificationPermission {
if (typeof Notification === 'undefined') return 'denied';
return Notification.permission;
}
// Request push notification permission
export async function requestPushPermission(): Promise<NotificationPermission> {
if (typeof Notification === 'undefined') return 'denied';
return await Notification.requestPermission();
}
// Initialize PWA
export function initPWA(): () => void {
if (typeof window === 'undefined') return () => {};
// Listen for install prompt
const handleBeforeInstallPrompt = (e: Event) => {
e.preventDefault();
deferredPrompt = e as BeforeInstallPromptEvent;
dispatchPWAEvent('caninstall');
};
// Listen for app installed
const handleAppInstalled = () => {
deferredPrompt = null;
dispatchPWAEvent('installed');
};
// Listen for online/offline
const handleOnline = () => dispatchPWAEvent('online');
const handleOffline = () => dispatchPWAEvent('offline');
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.addEventListener('appinstalled', handleAppInstalled);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
// Register service worker
registerServiceWorker();
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.removeEventListener('appinstalled', handleAppInstalled);
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}
// PWA event dispatcher
function dispatchPWAEvent(type: string, detail?: any): void {
window.dispatchEvent(new CustomEvent(`pwa:${type}`, { detail }));
}
// Utility to convert VAPID key
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// Share API wrapper
export async function share(data: ShareData): Promise<boolean> {
if (!navigator.share) {
return false;
}
try {
await navigator.share(data);
return true;
} catch (error) {
if ((error as Error).name !== 'AbortError') {
console.error('Share failed:', error);
}
return false;
}
}
// Check if Web Share API is supported
export function canShare(data?: ShareData): boolean {
if (!navigator.share) return false;
if (!data) return true;
return navigator.canShare?.(data) ?? true;
}

View file

@ -1,358 +0,0 @@
/**
* Email Notification Channel
* Handles sending email notifications via SMTP or SendGrid
*/
import { EmailNotificationData, NotificationPayload, NotificationType } from '../types';
interface EmailConfig {
provider: 'sendgrid' | 'nodemailer' | 'smtp';
apiKey?: string;
from: string;
replyTo?: string;
smtp?: {
host: string;
port: number;
secure: boolean;
user: string;
pass: string;
};
}
export class EmailChannel {
private config: EmailConfig;
constructor(config: EmailConfig) {
this.config = config;
}
/**
* Send an email notification
*/
async send(data: EmailNotificationData): Promise<void> {
const emailData = {
...data,
from: data.from || this.config.from,
replyTo: data.replyTo || this.config.replyTo
};
switch (this.config.provider) {
case 'sendgrid':
await this.sendViaSendGrid(emailData);
break;
case 'smtp':
case 'nodemailer':
await this.sendViaSMTP(emailData);
break;
default:
// Development mode - log email
console.log('[EmailChannel] Development mode - Email would be sent:', {
to: emailData.to,
subject: emailData.subject,
preview: emailData.text?.substring(0, 100)
});
}
}
/**
* Send via SendGrid API
*/
private async sendViaSendGrid(data: EmailNotificationData): Promise<void> {
if (!this.config.apiKey) {
throw new Error('SendGrid API key not configured');
}
const response = await fetch('https://api.sendgrid.com/v3/mail/send', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.config.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
personalizations: [{ to: [{ email: data.to }] }],
from: { email: data.from },
reply_to: data.replyTo ? { email: data.replyTo } : undefined,
subject: data.subject,
content: [
{ type: 'text/plain', value: data.text || data.html.replace(/<[^>]*>/g, '') },
{ type: 'text/html', value: data.html }
]
})
});
if (!response.ok) {
const error = await response.text();
throw new Error(`SendGrid error: ${error}`);
}
}
/**
* Send via SMTP (using nodemailer-like approach)
*/
private async sendViaSMTP(data: EmailNotificationData): Promise<void> {
// In production, this would use nodemailer
// For now, we'll simulate the SMTP send
if (!this.config.smtp?.host) {
console.log('[EmailChannel] SMTP not configured - simulating send');
return;
}
// Simulate SMTP connection and send
console.log(`[EmailChannel] Sending email via SMTP to ${data.to}`);
// In production implementation:
// const nodemailer = require('nodemailer');
// const transporter = nodemailer.createTransport({
// host: this.config.smtp.host,
// port: this.config.smtp.port,
// secure: this.config.smtp.secure,
// auth: {
// user: this.config.smtp.user,
// pass: this.config.smtp.pass
// }
// });
// await transporter.sendMail(data);
}
/**
* Render email template based on notification type
*/
async renderTemplate(payload: NotificationPayload): Promise<string> {
const templates = {
welcome: this.getWelcomeTemplate,
plant_registered: this.getPlantRegisteredTemplate,
plant_reminder: this.getPlantReminderTemplate,
transport_alert: this.getTransportAlertTemplate,
farm_alert: this.getFarmAlertTemplate,
harvest_ready: this.getHarvestReadyTemplate,
demand_match: this.getDemandMatchTemplate,
weekly_digest: this.getWeeklyDigestTemplate,
system_alert: this.getSystemAlertTemplate
};
const templateFn = templates[payload.type] || this.getDefaultTemplate;
return templateFn.call(this, payload);
}
/**
* Base email layout
*/
private getBaseLayout(content: string, payload: NotificationPayload): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${payload.title}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background-color: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%); color: white; padding: 30px; text-align: center; border-radius: 8px 8px 0 0; }
.header h1 { margin: 0; font-size: 24px; }
.logo { font-size: 32px; margin-bottom: 10px; }
.content { background: white; padding: 30px; border-radius: 0 0 8px 8px; }
.button { display: inline-block; background: #22c55e; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin: 20px 0; }
.button:hover { background: #16a34a; }
.footer { text-align: center; padding: 20px; color: #666; font-size: 12px; }
.alert-info { background: #dbeafe; border-left: 4px solid #3b82f6; padding: 15px; margin: 15px 0; }
.alert-warning { background: #fef3c7; border-left: 4px solid #f59e0b; padding: 15px; margin: 15px 0; }
.alert-success { background: #d1fae5; border-left: 4px solid #22c55e; padding: 15px; margin: 15px 0; }
.stats { display: flex; justify-content: space-around; margin: 20px 0; }
.stat-item { text-align: center; padding: 15px; }
.stat-value { font-size: 24px; font-weight: bold; color: #22c55e; }
.stat-label { font-size: 12px; color: #666; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">🌱</div>
<h1>LocalGreenChain</h1>
</div>
<div class="content">
${content}
</div>
<div class="footer">
<p>LocalGreenChain - Transparent Seed-to-Seed Tracking</p>
<p>
<a href="{{unsubscribe_url}}">Manage notification preferences</a>
</p>
</div>
</div>
</body>
</html>`;
}
private getWelcomeTemplate(payload: NotificationPayload): string {
const content = `
<h2>Welcome to LocalGreenChain! 🌿</h2>
<p>Thank you for joining our community of sustainable growers and conscious consumers.</p>
<p>With LocalGreenChain, you can:</p>
<ul>
<li>Track your plants from seed to seed</li>
<li>Monitor transport and carbon footprint</li>
<li>Connect with local growers and consumers</li>
<li>Manage vertical farms with precision</li>
</ul>
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">Get Started</a>` : ''}
`;
return this.getBaseLayout(content, payload);
}
private getPlantRegisteredTemplate(payload: NotificationPayload): string {
const data = payload.data || {};
const content = `
<h2>Plant Registered Successfully 🌱</h2>
<p>Your plant has been registered on the blockchain.</p>
<div class="alert-success">
<strong>Plant ID:</strong> ${data.plantId || 'N/A'}<br>
<strong>Species:</strong> ${data.species || 'N/A'}<br>
<strong>Variety:</strong> ${data.variety || 'N/A'}
</div>
<p>You can now track this plant throughout its entire lifecycle.</p>
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">View Plant Details</a>` : ''}
`;
return this.getBaseLayout(content, payload);
}
private getPlantReminderTemplate(payload: NotificationPayload): string {
const data = payload.data || {};
const content = `
<h2>Plant Care Reminder 🌿</h2>
<div class="alert-info">
<strong>${payload.title}</strong><br>
${payload.message}
</div>
${data.plantName ? `<p><strong>Plant:</strong> ${data.plantName}</p>` : ''}
${data.action ? `<p><strong>Recommended Action:</strong> ${data.action}</p>` : ''}
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">View Plant</a>` : ''}
`;
return this.getBaseLayout(content, payload);
}
private getTransportAlertTemplate(payload: NotificationPayload): string {
const data = payload.data || {};
const content = `
<h2>Transport Update 🚚</h2>
<div class="alert-info">
${payload.message}
</div>
${data.distance ? `
<div class="stats">
<div class="stat-item">
<div class="stat-value">${data.distance} km</div>
<div class="stat-label">Distance</div>
</div>
<div class="stat-item">
<div class="stat-value">${data.carbonKg || '0'} kg</div>
<div class="stat-label">Carbon Footprint</div>
</div>
</div>
` : ''}
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">View Journey</a>` : ''}
`;
return this.getBaseLayout(content, payload);
}
private getFarmAlertTemplate(payload: NotificationPayload): string {
const data = payload.data || {};
const alertClass = data.severity === 'warning' ? 'alert-warning' : 'alert-info';
const content = `
<h2>Farm Alert ${data.severity === 'warning' ? '⚠️' : ''}</h2>
<div class="${alertClass}">
<strong>${payload.title}</strong><br>
${payload.message}
</div>
${data.zone ? `<p><strong>Zone:</strong> ${data.zone}</p>` : ''}
${data.recommendation ? `<p><strong>Recommendation:</strong> ${data.recommendation}</p>` : ''}
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">View Farm Dashboard</a>` : ''}
`;
return this.getBaseLayout(content, payload);
}
private getHarvestReadyTemplate(payload: NotificationPayload): string {
const data = payload.data || {};
const content = `
<h2>Harvest Ready! 🎉</h2>
<div class="alert-success">
<strong>Great news!</strong> Your crop is ready for harvest.
</div>
${data.batchId ? `<p><strong>Batch:</strong> ${data.batchId}</p>` : ''}
${data.cropType ? `<p><strong>Crop:</strong> ${data.cropType}</p>` : ''}
${data.estimatedYield ? `<p><strong>Estimated Yield:</strong> ${data.estimatedYield}</p>` : ''}
<p>Log the harvest to update your blockchain records.</p>
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">Log Harvest</a>` : ''}
`;
return this.getBaseLayout(content, payload);
}
private getDemandMatchTemplate(payload: NotificationPayload): string {
const data = payload.data || {};
const content = `
<h2>Demand Match Found! 🤝</h2>
<p>We've found a match between supply and demand.</p>
<div class="alert-success">
${payload.message}
</div>
${data.matchDetails ? `
<p><strong>Crop:</strong> ${data.matchDetails.crop}</p>
<p><strong>Quantity:</strong> ${data.matchDetails.quantity}</p>
<p><strong>Region:</strong> ${data.matchDetails.region}</p>
` : ''}
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">View Match Details</a>` : ''}
`;
return this.getBaseLayout(content, payload);
}
private getWeeklyDigestTemplate(payload: NotificationPayload): string {
const data = payload.data || {};
const content = `
<h2>Your Weekly Summary 📊</h2>
<p>Here's what happened this week on LocalGreenChain:</p>
<div class="stats">
<div class="stat-item">
<div class="stat-value">${data.plantsRegistered || 0}</div>
<div class="stat-label">Plants Registered</div>
</div>
<div class="stat-item">
<div class="stat-value">${data.carbonSaved || 0} kg</div>
<div class="stat-label">Carbon Saved</div>
</div>
<div class="stat-item">
<div class="stat-value">${data.localMiles || 0}</div>
<div class="stat-label">Local Food Miles</div>
</div>
</div>
${data.highlights ? `
<h3>Highlights</h3>
<ul>
${data.highlights.map((h: string) => `<li>${h}</li>`).join('')}
</ul>
` : ''}
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">View Full Report</a>` : ''}
`;
return this.getBaseLayout(content, payload);
}
private getSystemAlertTemplate(payload: NotificationPayload): string {
const content = `
<h2>System Notification </h2>
<div class="alert-info">
<strong>${payload.title}</strong><br>
${payload.message}
</div>
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">Learn More</a>` : ''}
`;
return this.getBaseLayout(content, payload);
}
private getDefaultTemplate(payload: NotificationPayload): string {
const content = `
<h2>${payload.title}</h2>
<p>${payload.message}</p>
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">View Details</a>` : ''}
`;
return this.getBaseLayout(content, payload);
}
}

View file

@ -1,219 +0,0 @@
/**
* In-App Notification Channel
* Handles in-application notifications with persistence
*/
import { InAppNotification, NotificationType } from '../types';
export class InAppChannel {
private notifications: Map<string, InAppNotification[]> = new Map();
private maxNotificationsPerUser = 100;
/**
* Send an in-app notification
*/
async send(notification: InAppNotification): Promise<void> {
const userNotifications = this.notifications.get(notification.userId) || [];
// Add new notification at the beginning
userNotifications.unshift(notification);
// Trim to max
if (userNotifications.length > this.maxNotificationsPerUser) {
userNotifications.splice(this.maxNotificationsPerUser);
}
this.notifications.set(notification.userId, userNotifications);
console.log(`[InAppChannel] Notification added for user ${notification.userId}`);
}
/**
* Get notifications for a user
*/
getNotifications(userId: string, options?: {
unreadOnly?: boolean;
type?: NotificationType;
limit?: number;
offset?: number;
}): InAppNotification[] {
let userNotifications = this.notifications.get(userId) || [];
// Filter by unread
if (options?.unreadOnly) {
userNotifications = userNotifications.filter(n => !n.read);
}
// Filter by type
if (options?.type) {
userNotifications = userNotifications.filter(n => n.type === options.type);
}
// Filter expired
const now = new Date().toISOString();
userNotifications = userNotifications.filter(n =>
!n.expiresAt || n.expiresAt > now
);
// Apply pagination
const offset = options?.offset || 0;
const limit = options?.limit || 50;
return userNotifications.slice(offset, offset + limit);
}
/**
* Get notification by ID
*/
getNotification(notificationId: string): InAppNotification | undefined {
for (const userNotifications of this.notifications.values()) {
const notification = userNotifications.find(n => n.id === notificationId);
if (notification) return notification;
}
return undefined;
}
/**
* Mark notification as read
*/
markAsRead(notificationId: string): boolean {
for (const [userId, userNotifications] of this.notifications.entries()) {
const notification = userNotifications.find(n => n.id === notificationId);
if (notification) {
notification.read = true;
return true;
}
}
return false;
}
/**
* Mark all notifications as read for a user
*/
markAllAsRead(userId: string): number {
const userNotifications = this.notifications.get(userId);
if (!userNotifications) return 0;
let count = 0;
userNotifications.forEach(n => {
if (!n.read) {
n.read = true;
count++;
}
});
return count;
}
/**
* Get unread count for a user
*/
getUnreadCount(userId: string): number {
const userNotifications = this.notifications.get(userId) || [];
return userNotifications.filter(n => !n.read).length;
}
/**
* Delete a notification
*/
delete(notificationId: string): boolean {
for (const [userId, userNotifications] of this.notifications.entries()) {
const index = userNotifications.findIndex(n => n.id === notificationId);
if (index !== -1) {
userNotifications.splice(index, 1);
return true;
}
}
return false;
}
/**
* Delete all notifications for a user
*/
deleteAll(userId: string): number {
const userNotifications = this.notifications.get(userId);
if (!userNotifications) return 0;
const count = userNotifications.length;
this.notifications.delete(userId);
return count;
}
/**
* Clean up expired notifications
*/
cleanupExpired(): number {
const now = new Date().toISOString();
let removed = 0;
for (const [userId, userNotifications] of this.notifications.entries()) {
const originalLength = userNotifications.length;
const filtered = userNotifications.filter(n =>
!n.expiresAt || n.expiresAt > now
);
removed += originalLength - filtered.length;
this.notifications.set(userId, filtered);
}
return removed;
}
/**
* Get stats for a user
*/
getStats(userId: string): {
total: number;
unread: number;
byType: Record<NotificationType, number>;
} {
const userNotifications = this.notifications.get(userId) || [];
const byType: Record<string, number> = {};
userNotifications.forEach(n => {
byType[n.type] = (byType[n.type] || 0) + 1;
});
return {
total: userNotifications.length,
unread: userNotifications.filter(n => !n.read).length,
byType: byType as Record<NotificationType, number>
};
}
/**
* Group notifications by date
*/
getGroupedByDate(userId: string): {
today: InAppNotification[];
yesterday: InAppNotification[];
thisWeek: InAppNotification[];
older: InAppNotification[];
} {
const userNotifications = this.notifications.get(userId) || [];
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
const result = {
today: [] as InAppNotification[],
yesterday: [] as InAppNotification[],
thisWeek: [] as InAppNotification[],
older: [] as InAppNotification[]
};
userNotifications.forEach(n => {
const createdAt = new Date(n.createdAt);
if (createdAt >= today) {
result.today.push(n);
} else if (createdAt >= yesterday) {
result.yesterday.push(n);
} else if (createdAt >= weekAgo) {
result.thisWeek.push(n);
} else {
result.older.push(n);
}
});
return result;
}
}

View file

@ -1,163 +0,0 @@
/**
* Push Notification Channel
* Handles Web Push notifications using VAPID
*/
import { PushNotificationData, PushSubscription } from '../types';
interface PushConfig {
vapidPublicKey: string;
vapidPrivateKey: string;
vapidSubject: string;
}
export class PushChannel {
private config: PushConfig;
private subscriptions: Map<string, PushSubscription[]> = new Map();
constructor(config: PushConfig) {
this.config = config;
}
/**
* Send a push notification
*/
async send(data: PushNotificationData): Promise<void> {
if (!this.config.vapidPublicKey || !this.config.vapidPrivateKey) {
console.log('[PushChannel] VAPID keys not configured - simulating push');
return;
}
const payload = JSON.stringify({
title: data.title,
body: data.body,
icon: data.icon || '/icons/icon-192x192.png',
badge: data.badge || '/icons/badge-72x72.png',
data: data.data,
actions: data.actions
});
// In production, this would use web-push library:
// const webpush = require('web-push');
// webpush.setVapidDetails(
// this.config.vapidSubject,
// this.config.vapidPublicKey,
// this.config.vapidPrivateKey
// );
// await webpush.sendNotification(subscription, payload);
console.log(`[PushChannel] Push notification sent: ${data.title}`);
}
/**
* Subscribe a user to push notifications
*/
subscribe(userId: string, subscription: Omit<PushSubscription, 'userId' | 'createdAt'>): PushSubscription {
const fullSubscription: PushSubscription = {
...subscription,
userId,
createdAt: new Date().toISOString()
};
const userSubs = this.subscriptions.get(userId) || [];
// Check if subscription already exists
const existing = userSubs.find(s => s.endpoint === subscription.endpoint);
if (existing) {
existing.lastUsedAt = new Date().toISOString();
return existing;
}
userSubs.push(fullSubscription);
this.subscriptions.set(userId, userSubs);
console.log(`[PushChannel] User ${userId} subscribed to push notifications`);
return fullSubscription;
}
/**
* Unsubscribe from push notifications
*/
unsubscribe(userId: string, endpoint?: string): boolean {
if (!endpoint) {
// Remove all subscriptions for user
this.subscriptions.delete(userId);
return true;
}
const userSubs = this.subscriptions.get(userId);
if (!userSubs) return false;
const index = userSubs.findIndex(s => s.endpoint === endpoint);
if (index === -1) return false;
userSubs.splice(index, 1);
if (userSubs.length === 0) {
this.subscriptions.delete(userId);
} else {
this.subscriptions.set(userId, userSubs);
}
return true;
}
/**
* Get subscriptions for a user
*/
getSubscriptions(userId: string): PushSubscription[] {
return this.subscriptions.get(userId) || [];
}
/**
* Check if user has push subscriptions
*/
hasSubscription(userId: string): boolean {
const subs = this.subscriptions.get(userId);
return subs !== undefined && subs.length > 0;
}
/**
* Send to all user's subscriptions
*/
async sendToUser(userId: string, data: Omit<PushNotificationData, 'token'>): Promise<void> {
const subscriptions = this.getSubscriptions(userId);
for (const sub of subscriptions) {
try {
await this.send({
...data,
token: sub.endpoint
});
sub.lastUsedAt = new Date().toISOString();
} catch (error: any) {
console.error(`[PushChannel] Failed to send to ${sub.endpoint}:`, error.message);
// Remove invalid subscriptions
if (error.statusCode === 410 || error.statusCode === 404) {
this.unsubscribe(userId, sub.endpoint);
}
}
}
}
/**
* Get VAPID public key for client
*/
getPublicKey(): string {
return this.config.vapidPublicKey;
}
/**
* Generate VAPID keys (utility method)
*/
static generateVapidKeys(): { publicKey: string; privateKey: string } {
// In production, use web-push library:
// const webpush = require('web-push');
// return webpush.generateVAPIDKeys();
// For development, return placeholder keys
return {
publicKey: 'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U',
privateKey: 'UUxI4O8-FbRouADVXc-hK3ltRAc8_DIoISjp22LG0S0'
};
}
}

View file

@ -1,161 +0,0 @@
/**
* LocalGreenChain Notification System
* Multi-channel notifications with email, push, and in-app support
*/
export * from './types';
export { NotificationService, getNotificationService } from './service';
export { NotificationScheduler, getNotificationScheduler } from './scheduler';
export { EmailChannel } from './channels/email';
export { PushChannel } from './channels/push';
export { InAppChannel } from './channels/inApp';
// Convenience functions
import { getNotificationService } from './service';
import { getNotificationScheduler } from './scheduler';
import { NotificationPayload, NotificationChannel, NotificationPriority, NotificationRecipient } from './types';
/**
* Send a notification (convenience function)
*/
export async function sendNotification(
recipient: NotificationRecipient,
payload: NotificationPayload,
options?: {
channels?: NotificationChannel[];
priority?: NotificationPriority;
}
) {
return getNotificationService().send(recipient, payload, options);
}
/**
* Send a welcome notification
*/
export async function sendWelcomeNotification(userId: string, email: string, name?: string) {
return sendNotification(
{ userId, email },
{
type: 'welcome',
title: `Welcome to LocalGreenChain${name ? `, ${name}` : ''}!`,
message: 'Thank you for joining our community. Start tracking your plants today!',
actionUrl: '/dashboard'
},
{ channels: ['email', 'inApp'] }
);
}
/**
* Send a plant registered notification
*/
export async function sendPlantRegisteredNotification(
userId: string,
email: string,
plantId: string,
species: string,
variety?: string
) {
return sendNotification(
{ userId, email },
{
type: 'plant_registered',
title: 'Plant Registered Successfully',
message: `Your ${species}${variety ? ` (${variety})` : ''} has been registered on the blockchain.`,
data: { plantId, species, variety },
actionUrl: `/plants/${plantId}`
},
{ channels: ['inApp', 'email'] }
);
}
/**
* Send a transport alert
*/
export async function sendTransportAlert(
userId: string,
email: string,
plantId: string,
eventType: string,
distance?: number,
carbonKg?: number
) {
return sendNotification(
{ userId, email },
{
type: 'transport_alert',
title: 'Transport Event Recorded',
message: `A ${eventType} event has been logged for your plant.`,
data: { plantId, eventType, distance, carbonKg },
actionUrl: `/transport/journey/${plantId}`
},
{ channels: ['inApp'] }
);
}
/**
* Send a farm alert
*/
export async function sendFarmAlert(
userId: string,
email: string,
farmId: string,
zone: string,
severity: 'info' | 'warning' | 'critical',
message: string,
recommendation?: string
) {
return sendNotification(
{ userId, email },
{
type: 'farm_alert',
title: `Farm Alert: ${zone}`,
message,
data: { farmId, zone, severity, recommendation },
actionUrl: `/vertical-farm/${farmId}`
},
{
channels: severity === 'critical' ? ['inApp', 'email', 'push'] : ['inApp'],
priority: severity === 'critical' ? 'urgent' : 'medium'
}
);
}
/**
* Send a demand match notification
*/
export async function sendDemandMatchNotification(
userId: string,
email: string,
matchDetails: {
crop: string;
quantity: number;
region: string;
matchId: string;
}
) {
return sendNotification(
{ userId, email },
{
type: 'demand_match',
title: 'New Demand Match Found!',
message: `A consumer is looking for ${matchDetails.quantity} units of ${matchDetails.crop} in ${matchDetails.region}.`,
data: { matchDetails },
actionUrl: `/marketplace/match/${matchDetails.matchId}`
},
{ channels: ['inApp', 'email'], priority: 'high' }
);
}
/**
* Start the notification scheduler
*/
export function startNotificationScheduler(intervalMs?: number) {
getNotificationScheduler().start(intervalMs);
}
/**
* Stop the notification scheduler
*/
export function stopNotificationScheduler() {
getNotificationScheduler().stop();
}

View file

@ -1,344 +0,0 @@
/**
* Notification Scheduler
* Handles scheduled and recurring notifications
*/
import { getNotificationService } from './service';
import {
ScheduledNotification,
NotificationRecipient,
NotificationPayload,
NotificationChannel,
NotificationPriority
} from './types';
export class NotificationScheduler {
private static instance: NotificationScheduler;
private scheduledNotifications: Map<string, ScheduledNotification> = new Map();
private checkInterval: NodeJS.Timeout | null = null;
private isRunning = false;
private constructor() {}
static getInstance(): NotificationScheduler {
if (!NotificationScheduler.instance) {
NotificationScheduler.instance = new NotificationScheduler();
}
return NotificationScheduler.instance;
}
/**
* Start the scheduler
*/
start(intervalMs: number = 60000): void {
if (this.isRunning) return;
this.isRunning = true;
console.log('[NotificationScheduler] Started');
// Check immediately
this.processScheduledNotifications();
// Set up interval
this.checkInterval = setInterval(() => {
this.processScheduledNotifications();
}, intervalMs);
}
/**
* Stop the scheduler
*/
stop(): void {
if (this.checkInterval) {
clearInterval(this.checkInterval);
this.checkInterval = null;
}
this.isRunning = false;
console.log('[NotificationScheduler] Stopped');
}
/**
* Schedule a notification
*/
schedule(
recipient: NotificationRecipient,
payload: NotificationPayload,
scheduledFor: Date | string,
options?: {
channels?: NotificationChannel[];
priority?: NotificationPriority;
recurring?: {
pattern: 'daily' | 'weekly' | 'monthly';
endDate?: string;
};
}
): ScheduledNotification {
const id = `sched-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const scheduled: ScheduledNotification = {
id,
notification: {
recipientId: recipient.userId,
payload,
channels: options?.channels || ['inApp', 'email'],
priority: options?.priority || 'medium',
retryCount: 0
},
scheduledFor: typeof scheduledFor === 'string' ? scheduledFor : scheduledFor.toISOString(),
recurring: options?.recurring,
status: 'scheduled'
};
this.scheduledNotifications.set(id, scheduled);
console.log(`[NotificationScheduler] Scheduled notification ${id} for ${scheduled.scheduledFor}`);
return scheduled;
}
/**
* Cancel a scheduled notification
*/
cancel(id: string): boolean {
const scheduled = this.scheduledNotifications.get(id);
if (scheduled && scheduled.status === 'scheduled') {
scheduled.status = 'cancelled';
return true;
}
return false;
}
/**
* Get scheduled notification
*/
getScheduled(id: string): ScheduledNotification | undefined {
return this.scheduledNotifications.get(id);
}
/**
* Get all scheduled notifications for a user
*/
getScheduledForUser(userId: string): ScheduledNotification[] {
const result: ScheduledNotification[] = [];
this.scheduledNotifications.forEach(scheduled => {
if (scheduled.notification.recipientId === userId && scheduled.status === 'scheduled') {
result.push(scheduled);
}
});
return result.sort((a, b) =>
new Date(a.scheduledFor).getTime() - new Date(b.scheduledFor).getTime()
);
}
/**
* Schedule a plant care reminder
*/
schedulePlantReminder(
userId: string,
email: string,
plantId: string,
plantName: string,
reminderType: 'water' | 'fertilize' | 'prune' | 'harvest',
scheduledFor: Date
): ScheduledNotification {
const reminderMessages = {
water: `Time to water your ${plantName}!`,
fertilize: `Your ${plantName} needs fertilizing.`,
prune: `Consider pruning your ${plantName} for better growth.`,
harvest: `Your ${plantName} may be ready for harvest!`
};
return this.schedule(
{ userId, email },
{
type: 'plant_reminder',
title: `Plant Care Reminder: ${plantName}`,
message: reminderMessages[reminderType],
data: { plantId, plantName, reminderType },
actionUrl: `/plants/${plantId}`
},
scheduledFor,
{ channels: ['inApp', 'email', 'push'] }
);
}
/**
* Schedule weekly digest
*/
scheduleWeeklyDigest(userId: string, email: string): ScheduledNotification {
// Schedule for next Monday at 9 AM
const now = new Date();
const nextMonday = new Date(now);
nextMonday.setDate(now.getDate() + ((7 - now.getDay() + 1) % 7 || 7));
nextMonday.setHours(9, 0, 0, 0);
return this.schedule(
{ userId, email },
{
type: 'weekly_digest',
title: 'Your Weekly LocalGreenChain Summary',
message: 'Check out what happened this week!',
actionUrl: '/dashboard'
},
nextMonday,
{
channels: ['email'],
recurring: { pattern: 'weekly' }
}
);
}
/**
* Schedule harvest alert
*/
scheduleHarvestAlert(
userId: string,
email: string,
batchId: string,
cropType: string,
estimatedHarvestDate: Date
): ScheduledNotification {
// Schedule alert 1 day before harvest
const alertDate = new Date(estimatedHarvestDate);
alertDate.setDate(alertDate.getDate() - 1);
return this.schedule(
{ userId, email },
{
type: 'harvest_ready',
title: `Harvest Coming Soon: ${cropType}`,
message: `Your ${cropType} batch will be ready for harvest tomorrow!`,
data: { batchId, cropType, estimatedHarvestDate: estimatedHarvestDate.toISOString() },
actionUrl: `/vertical-farm/batch/${batchId}`
},
alertDate,
{ channels: ['inApp', 'email', 'push'], priority: 'high' }
);
}
/**
* Process due notifications
*/
private async processScheduledNotifications(): Promise<void> {
const now = new Date();
const notificationService = getNotificationService();
for (const [id, scheduled] of this.scheduledNotifications.entries()) {
if (scheduled.status !== 'scheduled') continue;
const scheduledTime = new Date(scheduled.scheduledFor);
if (scheduledTime <= now) {
try {
// Send the notification
await notificationService.send(
{ userId: scheduled.notification.recipientId },
scheduled.notification.payload,
{
channels: scheduled.notification.channels,
priority: scheduled.notification.priority
}
);
// Handle recurring
if (scheduled.recurring) {
const endDate = scheduled.recurring.endDate
? new Date(scheduled.recurring.endDate)
: null;
if (!endDate || scheduledTime < endDate) {
// Schedule next occurrence
const nextDate = this.getNextOccurrence(scheduledTime, scheduled.recurring.pattern);
scheduled.scheduledFor = nextDate.toISOString();
console.log(`[NotificationScheduler] Rescheduled ${id} for ${scheduled.scheduledFor}`);
} else {
scheduled.status = 'sent';
}
} else {
scheduled.status = 'sent';
}
console.log(`[NotificationScheduler] Sent scheduled notification ${id}`);
} catch (error: any) {
console.error(`[NotificationScheduler] Failed to send ${id}:`, error.message);
}
}
}
}
/**
* Calculate next occurrence date
*/
private getNextOccurrence(current: Date, pattern: 'daily' | 'weekly' | 'monthly'): Date {
const next = new Date(current);
switch (pattern) {
case 'daily':
next.setDate(next.getDate() + 1);
break;
case 'weekly':
next.setDate(next.getDate() + 7);
break;
case 'monthly':
next.setMonth(next.getMonth() + 1);
break;
}
return next;
}
/**
* Get scheduler stats
*/
getStats(): {
isRunning: boolean;
total: number;
scheduled: number;
sent: number;
cancelled: number;
} {
let scheduled = 0;
let sent = 0;
let cancelled = 0;
this.scheduledNotifications.forEach(n => {
switch (n.status) {
case 'scheduled': scheduled++; break;
case 'sent': sent++; break;
case 'cancelled': cancelled++; break;
}
});
return {
isRunning: this.isRunning,
total: this.scheduledNotifications.size,
scheduled,
sent,
cancelled
};
}
/**
* Clean up old notifications
*/
cleanup(olderThanDays: number = 30): number {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - olderThanDays);
let removed = 0;
for (const [id, scheduled] of this.scheduledNotifications.entries()) {
if (scheduled.status !== 'scheduled') {
const scheduledDate = new Date(scheduled.scheduledFor);
if (scheduledDate < cutoff) {
this.scheduledNotifications.delete(id);
removed++;
}
}
}
return removed;
}
}
// Export singleton getter
export function getNotificationScheduler(): NotificationScheduler {
return NotificationScheduler.getInstance();
}

View file

@ -1,503 +0,0 @@
/**
* NotificationService - Core notification management service
* Handles multi-channel notification dispatch and management
*/
import {
Notification,
NotificationPayload,
NotificationChannel,
NotificationPriority,
NotificationRecipient,
NotificationStatus,
NotificationType,
UserNotificationPreferences,
InAppNotification,
NotificationStats,
NotificationConfig
} from './types';
import { EmailChannel } from './channels/email';
import { PushChannel } from './channels/push';
import { InAppChannel } from './channels/inApp';
export class NotificationService {
private static instance: NotificationService;
private notifications: Map<string, Notification> = new Map();
private userPreferences: Map<string, UserNotificationPreferences> = new Map();
private stats: NotificationStats;
private config: NotificationConfig;
private emailChannel: EmailChannel;
private pushChannel: PushChannel;
private inAppChannel: InAppChannel;
private constructor() {
this.config = this.loadConfig();
this.stats = this.initializeStats();
this.emailChannel = new EmailChannel(this.config.email);
this.pushChannel = new PushChannel(this.config.push);
this.inAppChannel = new InAppChannel();
}
static getInstance(): NotificationService {
if (!NotificationService.instance) {
NotificationService.instance = new NotificationService();
}
return NotificationService.instance;
}
/**
* Send a notification to a recipient
*/
async send(
recipient: NotificationRecipient,
payload: NotificationPayload,
options?: {
channels?: NotificationChannel[];
priority?: NotificationPriority;
}
): Promise<Notification> {
const notification = this.createNotification(recipient, payload, options);
// Store notification
this.notifications.set(notification.id, notification);
// Check user preferences and quiet hours
const preferences = this.getUserPreferences(recipient.userId);
const channels = this.filterChannelsByPreferences(
notification.channels,
preferences,
payload.type
);
if (channels.length === 0) {
notification.status = 'delivered';
notification.deliveredAt = new Date().toISOString();
return notification;
}
// Check quiet hours
if (this.isInQuietHours(preferences)) {
// Queue for later if not urgent
if (notification.priority !== 'urgent') {
console.log(`[NotificationService] Notification ${notification.id} queued for after quiet hours`);
return notification;
}
}
// Send through each channel
await this.dispatchNotification(notification, recipient, channels);
return notification;
}
/**
* Send notification to multiple recipients
*/
async sendBulk(
recipients: NotificationRecipient[],
payload: NotificationPayload,
options?: {
channels?: NotificationChannel[];
priority?: NotificationPriority;
}
): Promise<Notification[]> {
const notifications: Notification[] = [];
// Process in batches
const batchSize = this.config.defaults.batchSize;
for (let i = 0; i < recipients.length; i += batchSize) {
const batch = recipients.slice(i, i + batchSize);
const batchPromises = batch.map(recipient =>
this.send(recipient, payload, options)
);
const batchResults = await Promise.allSettled(batchPromises);
batchResults.forEach((result, index) => {
if (result.status === 'fulfilled') {
notifications.push(result.value);
} else {
console.error(`[NotificationService] Failed to send to ${batch[index].userId}:`, result.reason);
}
});
}
return notifications;
}
/**
* Get notifications for a user
*/
getUserNotifications(userId: string, options?: {
unreadOnly?: boolean;
type?: NotificationType;
limit?: number;
offset?: number;
}): InAppNotification[] {
return this.inAppChannel.getNotifications(userId, options);
}
/**
* Mark notification as read
*/
markAsRead(notificationId: string, userId: string): boolean {
const notification = this.notifications.get(notificationId);
if (notification && notification.recipientId === userId) {
notification.readAt = new Date().toISOString();
notification.status = 'read';
this.inAppChannel.markAsRead(notificationId);
return true;
}
return false;
}
/**
* Mark all notifications as read for a user
*/
markAllAsRead(userId: string): number {
return this.inAppChannel.markAllAsRead(userId);
}
/**
* Get unread count for a user
*/
getUnreadCount(userId: string): number {
return this.inAppChannel.getUnreadCount(userId);
}
/**
* Update user notification preferences
*/
updatePreferences(userId: string, preferences: Partial<UserNotificationPreferences>): UserNotificationPreferences {
const current = this.getUserPreferences(userId);
const updated: UserNotificationPreferences = {
...current,
...preferences,
userId
};
this.userPreferences.set(userId, updated);
return updated;
}
/**
* Get user notification preferences
*/
getUserPreferences(userId: string): UserNotificationPreferences {
return this.userPreferences.get(userId) || this.getDefaultPreferences(userId);
}
/**
* Get notification statistics
*/
getStats(): NotificationStats {
return { ...this.stats };
}
/**
* Get a specific notification
*/
getNotification(id: string): Notification | undefined {
return this.notifications.get(id);
}
/**
* Retry failed notification
*/
async retry(notificationId: string): Promise<Notification | null> {
const notification = this.notifications.get(notificationId);
if (!notification || notification.status !== 'failed') {
return null;
}
if (notification.retryCount >= this.config.defaults.retryAttempts) {
console.log(`[NotificationService] Max retries reached for ${notificationId}`);
return notification;
}
notification.retryCount++;
notification.status = 'pending';
notification.error = undefined;
// Re-dispatch
const recipient: NotificationRecipient = {
userId: notification.recipientId
};
await this.dispatchNotification(notification, recipient, notification.channels);
return notification;
}
/**
* Delete notification
*/
delete(notificationId: string, userId: string): boolean {
const notification = this.notifications.get(notificationId);
if (notification && notification.recipientId === userId) {
this.notifications.delete(notificationId);
this.inAppChannel.delete(notificationId);
return true;
}
return false;
}
/**
* Create notification object
*/
private createNotification(
recipient: NotificationRecipient,
payload: NotificationPayload,
options?: {
channels?: NotificationChannel[];
priority?: NotificationPriority;
}
): Notification {
const id = `notif-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
return {
id,
recipientId: recipient.userId,
payload,
channels: options?.channels || ['inApp', 'email'],
priority: options?.priority || 'medium',
status: 'pending',
createdAt: new Date().toISOString(),
retryCount: 0
};
}
/**
* Dispatch notification through channels
*/
private async dispatchNotification(
notification: Notification,
recipient: NotificationRecipient,
channels: NotificationChannel[]
): Promise<void> {
const results: { channel: NotificationChannel; success: boolean; error?: string }[] = [];
for (const channel of channels) {
try {
switch (channel) {
case 'email':
if (recipient.email) {
await this.emailChannel.send({
to: recipient.email,
subject: notification.payload.title,
html: await this.emailChannel.renderTemplate(notification.payload),
text: notification.payload.message
});
results.push({ channel, success: true });
this.stats.byChannel.email.sent++;
this.stats.byChannel.email.delivered++;
}
break;
case 'push':
if (recipient.pushToken) {
await this.pushChannel.send({
token: recipient.pushToken,
title: notification.payload.title,
body: notification.payload.message,
data: notification.payload.data
});
results.push({ channel, success: true });
this.stats.byChannel.push.sent++;
this.stats.byChannel.push.delivered++;
}
break;
case 'inApp':
await this.inAppChannel.send({
id: notification.id,
userId: recipient.userId,
type: notification.payload.type,
title: notification.payload.title,
message: notification.payload.message,
actionUrl: notification.payload.actionUrl,
imageUrl: notification.payload.imageUrl,
read: false,
createdAt: notification.createdAt
});
results.push({ channel, success: true });
this.stats.byChannel.inApp.sent++;
this.stats.byChannel.inApp.delivered++;
break;
}
} catch (error: any) {
console.error(`[NotificationService] Failed to send via ${channel}:`, error.message);
results.push({ channel, success: false, error: error.message });
this.stats.byChannel[channel].failed++;
}
}
// Update notification status
const allSuccessful = results.every(r => r.success);
const anySuccessful = results.some(r => r.success);
if (allSuccessful) {
notification.status = 'delivered';
notification.deliveredAt = new Date().toISOString();
this.stats.totalDelivered++;
} else if (anySuccessful) {
notification.status = 'delivered';
notification.deliveredAt = new Date().toISOString();
notification.error = results.filter(r => !r.success).map(r => `${r.channel}: ${r.error}`).join('; ');
this.stats.totalDelivered++;
} else {
notification.status = 'failed';
notification.error = results.map(r => `${r.channel}: ${r.error}`).join('; ');
this.stats.totalFailed++;
}
notification.sentAt = new Date().toISOString();
this.stats.totalSent++;
this.stats.byType[notification.payload.type] = (this.stats.byType[notification.payload.type] || 0) + 1;
}
/**
* Filter channels by user preferences
*/
private filterChannelsByPreferences(
channels: NotificationChannel[],
preferences: UserNotificationPreferences,
type: NotificationType
): NotificationChannel[] {
return channels.filter(channel => {
// Check channel preference
if (channel === 'email' && !preferences.email) return false;
if (channel === 'push' && !preferences.push) return false;
if (channel === 'inApp' && !preferences.inApp) return false;
// Check type-specific preference
switch (type) {
case 'plant_reminder':
return preferences.plantReminders;
case 'transport_alert':
return preferences.transportAlerts;
case 'farm_alert':
return preferences.farmAlerts;
case 'harvest_ready':
return preferences.harvestAlerts;
case 'demand_match':
return preferences.demandMatches;
case 'weekly_digest':
return preferences.weeklyDigest;
default:
return true;
}
});
}
/**
* Check if current time is within quiet hours
*/
private isInQuietHours(preferences: UserNotificationPreferences): boolean {
if (!preferences.quietHoursStart || !preferences.quietHoursEnd) {
return false;
}
const now = new Date();
const timezone = preferences.timezone || 'UTC';
try {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
hour: '2-digit',
minute: '2-digit',
hour12: false
});
const currentTime = formatter.format(now);
const [startHour, startMin] = preferences.quietHoursStart.split(':').map(Number);
const [endHour, endMin] = preferences.quietHoursEnd.split(':').map(Number);
const [currentHour, currentMin] = currentTime.split(':').map(Number);
const currentMinutes = currentHour * 60 + currentMin;
const startMinutes = startHour * 60 + startMin;
const endMinutes = endHour * 60 + endMin;
if (startMinutes <= endMinutes) {
return currentMinutes >= startMinutes && currentMinutes < endMinutes;
} else {
// Quiet hours span midnight
return currentMinutes >= startMinutes || currentMinutes < endMinutes;
}
} catch {
return false;
}
}
/**
* Get default preferences
*/
private getDefaultPreferences(userId: string): UserNotificationPreferences {
return {
userId,
email: true,
push: true,
inApp: true,
plantReminders: true,
transportAlerts: true,
farmAlerts: true,
harvestAlerts: true,
demandMatches: true,
weeklyDigest: true
};
}
/**
* Initialize stats
*/
private initializeStats(): NotificationStats {
return {
totalSent: 0,
totalDelivered: 0,
totalFailed: 0,
byChannel: {
email: { sent: 0, delivered: 0, failed: 0 },
push: { sent: 0, delivered: 0, failed: 0 },
inApp: { sent: 0, delivered: 0, failed: 0 }
},
byType: {} as Record<NotificationType, number>
};
}
/**
* Load configuration
*/
private loadConfig(): NotificationConfig {
return {
email: {
provider: (process.env.EMAIL_PROVIDER as 'sendgrid' | 'nodemailer' | 'smtp') || 'nodemailer',
apiKey: process.env.SENDGRID_API_KEY,
from: process.env.EMAIL_FROM || 'noreply@localgreenchain.local',
replyTo: process.env.EMAIL_REPLY_TO,
smtp: {
host: process.env.SMTP_HOST || 'localhost',
port: parseInt(process.env.SMTP_PORT || '587'),
secure: process.env.SMTP_SECURE === 'true',
user: process.env.SMTP_USER || '',
pass: process.env.SMTP_PASS || ''
}
},
push: {
vapidPublicKey: process.env.VAPID_PUBLIC_KEY || '',
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY || '',
vapidSubject: process.env.VAPID_SUBJECT || 'mailto:admin@localgreenchain.local'
},
defaults: {
retryAttempts: 3,
retryDelayMs: 5000,
batchSize: 50
}
};
}
}
// Export singleton getter
export function getNotificationService(): NotificationService {
return NotificationService.getInstance();
}

View file

@ -1,170 +0,0 @@
/**
* Notification System Types
* Multi-channel notification system for LocalGreenChain
*/
export type NotificationChannel = 'email' | 'push' | 'inApp';
export type NotificationPriority = 'low' | 'medium' | 'high' | 'urgent';
export type NotificationStatus = 'pending' | 'sent' | 'delivered' | 'failed' | 'read';
export type NotificationType =
| 'welcome'
| 'plant_registered'
| 'plant_reminder'
| 'transport_alert'
| 'farm_alert'
| 'harvest_ready'
| 'demand_match'
| 'weekly_digest'
| 'system_alert';
export interface NotificationRecipient {
userId: string;
email?: string;
pushToken?: string;
preferences?: UserNotificationPreferences;
}
export interface NotificationPayload {
type: NotificationType;
title: string;
message: string;
data?: Record<string, any>;
actionUrl?: string;
imageUrl?: string;
}
export interface Notification {
id: string;
recipientId: string;
payload: NotificationPayload;
channels: NotificationChannel[];
priority: NotificationPriority;
status: NotificationStatus;
createdAt: string;
sentAt?: string;
deliveredAt?: string;
readAt?: string;
error?: string;
retryCount: number;
metadata?: Record<string, any>;
}
export interface UserNotificationPreferences {
userId: string;
email: boolean;
push: boolean;
inApp: boolean;
plantReminders: boolean;
transportAlerts: boolean;
farmAlerts: boolean;
harvestAlerts: boolean;
demandMatches: boolean;
weeklyDigest: boolean;
quietHoursStart?: string; // HH:mm format
quietHoursEnd?: string; // HH:mm format
timezone?: string;
}
export interface EmailNotificationData {
to: string;
subject: string;
html: string;
text?: string;
from?: string;
replyTo?: string;
}
export interface PushNotificationData {
token: string;
title: string;
body: string;
icon?: string;
badge?: string;
data?: Record<string, any>;
actions?: PushAction[];
}
export interface PushAction {
action: string;
title: string;
icon?: string;
}
export interface PushSubscription {
userId: string;
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
createdAt: string;
lastUsedAt?: string;
}
export interface InAppNotification {
id: string;
userId: string;
type: NotificationType;
title: string;
message: string;
actionUrl?: string;
imageUrl?: string;
read: boolean;
createdAt: string;
expiresAt?: string;
}
export interface NotificationQueue {
notifications: Notification[];
processing: boolean;
lastProcessedAt?: string;
}
export interface ScheduledNotification {
id: string;
notification: Omit<Notification, 'id' | 'createdAt' | 'status'>;
scheduledFor: string;
recurring?: {
pattern: 'daily' | 'weekly' | 'monthly';
endDate?: string;
};
status: 'scheduled' | 'sent' | 'cancelled';
}
export interface NotificationStats {
totalSent: number;
totalDelivered: number;
totalFailed: number;
byChannel: {
email: { sent: number; delivered: number; failed: number };
push: { sent: number; delivered: number; failed: number };
inApp: { sent: number; delivered: number; failed: number };
};
byType: Record<NotificationType, number>;
}
export interface NotificationConfig {
email: {
provider: 'sendgrid' | 'nodemailer' | 'smtp';
apiKey?: string;
from: string;
replyTo?: string;
smtp?: {
host: string;
port: number;
secure: boolean;
user: string;
pass: string;
};
};
push: {
vapidPublicKey: string;
vapidPrivateKey: string;
vapidSubject: string;
};
defaults: {
retryAttempts: number;
retryDelayMs: number;
batchSize: number;
};
}

View file

@ -24,7 +24,7 @@ export interface FuzzyLocation {
*/
export function generateAnonymousId(): string {
const randomBytes = crypto.randomBytes(32);
return 'anon_' + crypto.createHash('sha256').update(new Uint8Array(randomBytes)).digest('hex').substring(0, 16);
return 'anon_' + crypto.createHash('sha256').update(randomBytes).digest('hex').substring(0, 16);
}
/**
@ -111,14 +111,14 @@ export function generateAnonymousPlantName(plantType: string, generation: number
*/
export function encryptData(data: string, key: string): string {
const algorithm = 'aes-256-cbc';
const keyHash = new Uint8Array(crypto.createHash('sha256').update(key).digest());
const iv = new Uint8Array(crypto.randomBytes(16));
const keyHash = crypto.createHash('sha256').update(key).digest();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, keyHash as any, iv as any);
const cipher = crypto.createCipheriv(algorithm, keyHash, iv);
let encrypted = cipher.update(data, 'utf8', 'hex');
encrypted += cipher.final('hex');
return Buffer.from(iv).toString('hex') + ':' + encrypted;
return iv.toString('hex') + ':' + encrypted;
}
/**
@ -126,13 +126,13 @@ export function encryptData(data: string, key: string): string {
*/
export function decryptData(encryptedData: string, key: string): string {
const algorithm = 'aes-256-cbc';
const keyHash = new Uint8Array(crypto.createHash('sha256').update(key).digest());
const keyHash = crypto.createHash('sha256').update(key).digest();
const parts = encryptedData.split(':');
const iv = new Uint8Array(Buffer.from(parts[0], 'hex'));
const iv = Buffer.from(parts[0], 'hex');
const encrypted = parts[1];
const decipher = crypto.createDecipheriv(algorithm, keyHash as any, iv as any);
const decipher = crypto.createDecipheriv(algorithm, keyHash, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');

View file

@ -1,235 +0,0 @@
/**
* Socket.io Context Provider for LocalGreenChain
*
* Provides socket connection state to the entire application.
*/
import React, { createContext, useContext, useEffect, useState, useCallback, useRef, ReactNode } from 'react';
import { getSocketClient, RealtimeSocketClient } from './socketClient';
import type {
ConnectionStatus,
TransparencyEvent,
RoomType,
TransparencyEventType,
ConnectionMetrics,
RealtimeNotification,
} from './types';
import { toFeedItem } from './events';
/**
* Socket context value type
*/
interface SocketContextValue {
// Connection state
status: ConnectionStatus;
isConnected: boolean;
metrics: ConnectionMetrics;
// Events
events: TransparencyEvent[];
latestEvent: TransparencyEvent | null;
// Notifications
notifications: RealtimeNotification[];
unreadCount: number;
// Actions
connect: () => void;
disconnect: () => void;
joinRoom: (room: RoomType) => Promise<boolean>;
leaveRoom: (room: RoomType) => Promise<boolean>;
subscribeToTypes: (types: TransparencyEventType[]) => Promise<boolean>;
clearEvents: () => void;
markNotificationRead: (id: string) => void;
dismissNotification: (id: string) => void;
markAllRead: () => void;
}
const SocketContext = createContext<SocketContextValue | null>(null);
/**
* Provider props
*/
interface SocketProviderProps {
children: ReactNode;
userId?: string;
autoConnect?: boolean;
maxEvents?: number;
maxNotifications?: number;
}
/**
* Convert event to notification
*/
function eventToNotification(event: TransparencyEvent): RealtimeNotification {
const feedItem = toFeedItem(event);
let notificationType: RealtimeNotification['type'] = 'info';
if (event.priority === 'CRITICAL') notificationType = 'error';
else if (event.priority === 'HIGH') notificationType = 'warning';
else if (event.type.includes('error')) notificationType = 'error';
else if (event.type.includes('completed') || event.type.includes('verified')) notificationType = 'success';
return {
id: event.id,
type: notificationType,
title: feedItem.formatted.title,
message: feedItem.formatted.description,
timestamp: feedItem.timestamp,
eventType: event.type,
data: event.data,
read: false,
dismissed: false,
};
}
/**
* Socket Provider component
*/
export function SocketProvider({
children,
userId,
autoConnect = true,
maxEvents = 100,
maxNotifications = 50,
}: SocketProviderProps) {
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
const [events, setEvents] = useState<TransparencyEvent[]>([]);
const [latestEvent, setLatestEvent] = useState<TransparencyEvent | null>(null);
const [notifications, setNotifications] = useState<RealtimeNotification[]>([]);
const [metrics, setMetrics] = useState<ConnectionMetrics>({
status: 'disconnected',
eventsReceived: 0,
reconnectAttempts: 0,
rooms: [],
});
const clientRef = useRef<RealtimeSocketClient | null>(null);
// Initialize client
useEffect(() => {
if (typeof window === 'undefined') return;
const client = getSocketClient({ auth: { userId } });
clientRef.current = client;
// Set up listeners
const unsubStatus = client.onStatusChange((newStatus) => {
setStatus(newStatus);
setMetrics(client.getMetrics());
});
const unsubEvent = client.onEvent((event) => {
setLatestEvent(event);
setEvents((prev) => [event, ...prev].slice(0, maxEvents));
setMetrics(client.getMetrics());
// Create notification for important events
if (event.priority === 'HIGH' || event.priority === 'CRITICAL') {
const notification = eventToNotification(event);
setNotifications((prev) => [notification, ...prev].slice(0, maxNotifications));
}
});
// Auto connect
if (autoConnect) {
client.connect();
}
// Initial metrics
setMetrics(client.getMetrics());
return () => {
unsubStatus();
unsubEvent();
};
}, [autoConnect, userId, maxEvents, maxNotifications]);
const connect = useCallback(() => {
clientRef.current?.connect();
}, []);
const disconnect = useCallback(() => {
clientRef.current?.disconnect();
}, []);
const joinRoom = useCallback(async (room: RoomType) => {
return clientRef.current?.joinRoom(room) ?? false;
}, []);
const leaveRoom = useCallback(async (room: RoomType) => {
return clientRef.current?.leaveRoom(room) ?? false;
}, []);
const subscribeToTypes = useCallback(async (types: TransparencyEventType[]) => {
return clientRef.current?.subscribeToTypes(types) ?? false;
}, []);
const clearEvents = useCallback(() => {
setEvents([]);
setLatestEvent(null);
}, []);
const markNotificationRead = useCallback((id: string) => {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, read: true } : n))
);
}, []);
const dismissNotification = useCallback((id: string) => {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, dismissed: true } : n))
);
}, []);
const markAllRead = useCallback(() => {
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
}, []);
const unreadCount = notifications.filter((n) => !n.read && !n.dismissed).length;
const value: SocketContextValue = {
status,
isConnected: status === 'connected',
metrics,
events,
latestEvent,
notifications: notifications.filter((n) => !n.dismissed),
unreadCount,
connect,
disconnect,
joinRoom,
leaveRoom,
subscribeToTypes,
clearEvents,
markNotificationRead,
dismissNotification,
markAllRead,
};
return (
<SocketContext.Provider value={value}>
{children}
</SocketContext.Provider>
);
}
/**
* Hook to use socket context
*/
export function useSocketContext(): SocketContextValue {
const context = useContext(SocketContext);
if (!context) {
throw new Error('useSocketContext must be used within a SocketProvider');
}
return context;
}
/**
* Hook to optionally use socket context (returns null if not in provider)
*/
export function useOptionalSocketContext(): SocketContextValue | null {
return useContext(SocketContext);
}
export default SocketContext;

View file

@ -1,273 +0,0 @@
/**
* Real-Time Event Definitions for LocalGreenChain
*
* Defines all real-time event types and utilities for formatting events.
*/
import type { TransparencyEventType, TransparencyEvent, LiveFeedItem } from './types';
/**
* Event categories for grouping and filtering
*/
export enum EventCategory {
PLANT = 'plant',
TRANSPORT = 'transport',
DEMAND = 'demand',
FARM = 'farm',
AGENT = 'agent',
BLOCKCHAIN = 'blockchain',
SYSTEM = 'system',
AUDIT = 'audit',
}
/**
* Real-time event types enum for client use
*/
export enum RealtimeEvent {
// Plant events
PLANT_REGISTERED = 'plant.registered',
PLANT_CLONED = 'plant.cloned',
PLANT_TRANSFERRED = 'plant.transferred',
PLANT_UPDATED = 'plant.updated',
// Transport events
TRANSPORT_STARTED = 'transport.started',
TRANSPORT_COMPLETED = 'transport.completed',
TRANSPORT_VERIFIED = 'transport.verified',
// Demand events
DEMAND_CREATED = 'demand.created',
DEMAND_MATCHED = 'demand.matched',
SUPPLY_COMMITTED = 'supply.committed',
// Farm events
FARM_REGISTERED = 'farm.registered',
FARM_UPDATED = 'farm.updated',
BATCH_STARTED = 'batch.started',
BATCH_HARVESTED = 'batch.harvested',
// Agent events
AGENT_ALERT = 'agent.alert',
AGENT_TASK_COMPLETED = 'agent.task_completed',
AGENT_ERROR = 'agent.error',
// Blockchain events
BLOCKCHAIN_BLOCK_ADDED = 'blockchain.block_added',
BLOCKCHAIN_VERIFIED = 'blockchain.verified',
BLOCKCHAIN_ERROR = 'blockchain.error',
// System events
SYSTEM_HEALTH = 'system.health',
SYSTEM_ALERT = 'system.alert',
SYSTEM_METRIC = 'system.metric',
// Audit events
AUDIT_LOGGED = 'audit.logged',
AUDIT_ANOMALY = 'audit.anomaly',
}
/**
* Map event types to their categories
*/
export const EVENT_CATEGORIES: Record<TransparencyEventType, EventCategory> = {
'plant.registered': EventCategory.PLANT,
'plant.cloned': EventCategory.PLANT,
'plant.transferred': EventCategory.PLANT,
'plant.updated': EventCategory.PLANT,
'transport.started': EventCategory.TRANSPORT,
'transport.completed': EventCategory.TRANSPORT,
'transport.verified': EventCategory.TRANSPORT,
'demand.created': EventCategory.DEMAND,
'demand.matched': EventCategory.DEMAND,
'supply.committed': EventCategory.DEMAND,
'farm.registered': EventCategory.FARM,
'farm.updated': EventCategory.FARM,
'batch.started': EventCategory.FARM,
'batch.harvested': EventCategory.FARM,
'agent.alert': EventCategory.AGENT,
'agent.task_completed': EventCategory.AGENT,
'agent.error': EventCategory.AGENT,
'blockchain.block_added': EventCategory.BLOCKCHAIN,
'blockchain.verified': EventCategory.BLOCKCHAIN,
'blockchain.error': EventCategory.BLOCKCHAIN,
'system.health': EventCategory.SYSTEM,
'system.alert': EventCategory.SYSTEM,
'system.metric': EventCategory.SYSTEM,
'audit.logged': EventCategory.AUDIT,
'audit.anomaly': EventCategory.AUDIT,
};
/**
* Event display configuration
*/
interface EventDisplay {
title: string;
icon: string;
color: string;
}
/**
* Map event types to display properties
*/
export const EVENT_DISPLAY: Record<TransparencyEventType, EventDisplay> = {
'plant.registered': { title: 'Plant Registered', icon: '🌱', color: 'green' },
'plant.cloned': { title: 'Plant Cloned', icon: '🧬', color: 'green' },
'plant.transferred': { title: 'Plant Transferred', icon: '🔄', color: 'blue' },
'plant.updated': { title: 'Plant Updated', icon: '📝', color: 'gray' },
'transport.started': { title: 'Transport Started', icon: '🚚', color: 'yellow' },
'transport.completed': { title: 'Transport Completed', icon: '✅', color: 'green' },
'transport.verified': { title: 'Transport Verified', icon: '🔍', color: 'blue' },
'demand.created': { title: 'Demand Created', icon: '📊', color: 'purple' },
'demand.matched': { title: 'Demand Matched', icon: '🎯', color: 'green' },
'supply.committed': { title: 'Supply Committed', icon: '📦', color: 'blue' },
'farm.registered': { title: 'Farm Registered', icon: '🏭', color: 'green' },
'farm.updated': { title: 'Farm Updated', icon: '🔧', color: 'gray' },
'batch.started': { title: 'Batch Started', icon: '🌿', color: 'green' },
'batch.harvested': { title: 'Batch Harvested', icon: '🥬', color: 'green' },
'agent.alert': { title: 'Agent Alert', icon: '⚠️', color: 'yellow' },
'agent.task_completed': { title: 'Task Completed', icon: '✔️', color: 'green' },
'agent.error': { title: 'Agent Error', icon: '❌', color: 'red' },
'blockchain.block_added': { title: 'Block Added', icon: '🔗', color: 'blue' },
'blockchain.verified': { title: 'Blockchain Verified', icon: '✓', color: 'green' },
'blockchain.error': { title: 'Blockchain Error', icon: '⛓️‍💥', color: 'red' },
'system.health': { title: 'Health Check', icon: '💓', color: 'green' },
'system.alert': { title: 'System Alert', icon: '🔔', color: 'yellow' },
'system.metric': { title: 'Metric Update', icon: '📈', color: 'blue' },
'audit.logged': { title: 'Audit Logged', icon: '📋', color: 'gray' },
'audit.anomaly': { title: 'Anomaly Detected', icon: '🚨', color: 'red' },
};
/**
* Get the category for an event type
*/
export function getEventCategory(type: TransparencyEventType): EventCategory {
return EVENT_CATEGORIES[type];
}
/**
* Get display properties for an event type
*/
export function getEventDisplay(type: TransparencyEventType): EventDisplay {
return EVENT_DISPLAY[type] || { title: type, icon: '📌', color: 'gray' };
}
/**
* Format a description for an event
*/
export function formatEventDescription(event: TransparencyEvent): string {
const { type, data, source } = event;
switch (type) {
case 'plant.registered':
return `${data.name || 'A plant'} was registered by ${source}`;
case 'plant.cloned':
return `${data.name || 'A plant'} was cloned from ${data.parentName || 'parent'}`;
case 'plant.transferred':
return `${data.name || 'A plant'} was transferred to ${data.newOwner || 'new owner'}`;
case 'plant.updated':
return `${data.name || 'A plant'} information was updated`;
case 'transport.started':
return `Transport started from ${data.from || 'origin'} to ${data.to || 'destination'}`;
case 'transport.completed':
return `Transport completed: ${data.distance || '?'} km traveled`;
case 'transport.verified':
return `Transport verified on blockchain`;
case 'demand.created':
return `Demand signal created for ${data.product || 'product'}`;
case 'demand.matched':
return `Demand matched with ${data.supplier || 'a supplier'}`;
case 'supply.committed':
return `Supply committed: ${data.quantity || '?'} units`;
case 'farm.registered':
return `Vertical farm "${data.name || 'Farm'}" registered`;
case 'farm.updated':
return `Farm settings updated`;
case 'batch.started':
return `Growing batch started: ${data.cropType || 'crops'}`;
case 'batch.harvested':
return `Batch harvested: ${data.yield || '?'} kg`;
case 'agent.alert':
return data.message || 'Agent alert triggered';
case 'agent.task_completed':
return `Task completed: ${data.taskName || 'task'}`;
case 'agent.error':
return `Agent error: ${data.error || 'unknown error'}`;
case 'blockchain.block_added':
return `New block added to chain`;
case 'blockchain.verified':
return `Blockchain integrity verified`;
case 'blockchain.error':
return `Blockchain error: ${data.error || 'unknown error'}`;
case 'system.health':
return `System health: ${data.status || 'OK'}`;
case 'system.alert':
return data.message || 'System alert';
case 'system.metric':
return `Metric: ${data.metricName || 'metric'} = ${data.value || '?'}`;
case 'audit.logged':
return `Audit: ${data.action || 'action'} by ${data.actor || 'unknown'}`;
case 'audit.anomaly':
return `Anomaly: ${data.description || 'unusual activity detected'}`;
default:
return `Event from ${source}`;
}
}
/**
* Convert a TransparencyEvent to a LiveFeedItem
*/
export function toFeedItem(event: TransparencyEvent): LiveFeedItem {
const display = getEventDisplay(event.type);
return {
id: event.id,
event,
timestamp: new Date(event.timestamp).getTime(),
formatted: {
title: display.title,
description: formatEventDescription(event),
icon: display.icon,
color: display.color,
},
};
}
/**
* Get events by category
*/
export function getEventsByCategory(category: EventCategory): TransparencyEventType[] {
return Object.entries(EVENT_CATEGORIES)
.filter(([, cat]) => cat === category)
.map(([type]) => type as TransparencyEventType);
}
/**
* Check if an event type belongs to a category
*/
export function isEventInCategory(type: TransparencyEventType, category: EventCategory): boolean {
return EVENT_CATEGORIES[type] === category;
}
/**
* Priority to numeric value for sorting
*/
export function priorityToNumber(priority: string): number {
switch (priority) {
case 'CRITICAL': return 4;
case 'HIGH': return 3;
case 'NORMAL': return 2;
case 'LOW': return 1;
default: return 0;
}
}
/**
* Sort events by priority and time
*/
export function sortEvents(events: TransparencyEvent[]): TransparencyEvent[] {
return [...events].sort((a, b) => {
const priorityDiff = priorityToNumber(b.priority) - priorityToNumber(a.priority);
if (priorityDiff !== 0) return priorityDiff;
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
});
}

View file

@ -1,92 +0,0 @@
/**
* Real-Time Module for LocalGreenChain
*
* Provides WebSocket-based real-time updates using Socket.io
* with automatic fallback to SSE.
*/
// Types
export type {
RoomType,
ConnectionStatus,
SocketAuthPayload,
SocketHandshake,
ClientToServerEvents,
ServerToClientEvents,
InterServerEvents,
SocketData,
RealtimeNotification,
ConnectionMetrics,
LiveFeedItem,
TransparencyEventType,
EventPriority,
TransparencyEvent,
} from './types';
// Events
export {
EventCategory,
RealtimeEvent,
EVENT_CATEGORIES,
EVENT_DISPLAY,
getEventCategory,
getEventDisplay,
formatEventDescription,
toFeedItem,
getEventsByCategory,
isEventInCategory,
priorityToNumber,
sortEvents,
} from './events';
// Rooms
export {
parseRoom,
createRoom,
getDefaultRooms,
getCategoryRoom,
getEventRooms,
isValidRoom,
canJoinRoom,
ROOM_LIMITS,
} from './rooms';
// Server
export {
RealtimeSocketServer,
getSocketServer,
} from './socketServer';
// Client
export {
RealtimeSocketClient,
getSocketClient,
createSocketClient,
} from './socketClient';
export type {
SocketClientConfig,
EventListener,
StatusListener,
ErrorListener,
} from './socketClient';
// React Hooks
export {
useSocket,
useLiveFeed,
usePlantUpdates,
useFarmUpdates,
useConnectionStatus,
useEventCount,
} from './useSocket';
export type {
UseSocketOptions,
UseSocketReturn,
} from './useSocket';
// React Context
export {
SocketProvider,
useSocketContext,
useOptionalSocketContext,
} from './SocketContext';

View file

@ -1,137 +0,0 @@
/**
* Room Management for Socket.io
*
* Manages room subscriptions for targeted event delivery.
*/
import type { RoomType, TransparencyEventType } from './types';
import { EventCategory, getEventCategory } from './events';
/**
* Parse a room type to extract its components
*/
export function parseRoom(room: RoomType): { type: string; id?: string } {
if (room.includes(':')) {
const [type, id] = room.split(':');
return { type, id };
}
return { type: room };
}
/**
* Create a room name for a specific entity
*/
export function createRoom(type: 'plant' | 'farm' | 'user', id: string): RoomType {
return `${type}:${id}` as RoomType;
}
/**
* Get the default rooms for a user based on their role
*/
export function getDefaultRooms(userId?: string): RoomType[] {
const rooms: RoomType[] = ['global'];
if (userId) {
rooms.push(`user:${userId}` as RoomType);
}
return rooms;
}
/**
* Get category-based room for an event type
*/
export function getCategoryRoom(type: TransparencyEventType): RoomType {
const category = getEventCategory(type);
switch (category) {
case EventCategory.PLANT:
return 'plants';
case EventCategory.TRANSPORT:
return 'transport';
case EventCategory.FARM:
return 'farms';
case EventCategory.DEMAND:
return 'demand';
case EventCategory.SYSTEM:
case EventCategory.AGENT:
case EventCategory.BLOCKCHAIN:
case EventCategory.AUDIT:
return 'system';
default:
return 'global';
}
}
/**
* Determine which rooms should receive an event
*/
export function getEventRooms(
type: TransparencyEventType,
data: Record<string, unknown>
): RoomType[] {
const rooms: RoomType[] = ['global'];
// Add category room
const categoryRoom = getCategoryRoom(type);
if (categoryRoom !== 'global') {
rooms.push(categoryRoom);
}
// Add entity-specific rooms
if (data.plantId && typeof data.plantId === 'string') {
rooms.push(`plant:${data.plantId}` as RoomType);
}
if (data.farmId && typeof data.farmId === 'string') {
rooms.push(`farm:${data.farmId}` as RoomType);
}
if (data.userId && typeof data.userId === 'string') {
rooms.push(`user:${data.userId}` as RoomType);
}
return rooms;
}
/**
* Check if a room is valid
*/
export function isValidRoom(room: string): room is RoomType {
const validPrefixes = ['global', 'plants', 'transport', 'farms', 'demand', 'system'];
const validEntityPrefixes = ['plant:', 'farm:', 'user:'];
if (validPrefixes.includes(room)) {
return true;
}
return validEntityPrefixes.some((prefix) => room.startsWith(prefix));
}
/**
* Room subscription limits per connection
*/
export const ROOM_LIMITS = {
maxRooms: 50,
maxEntityRooms: 20,
maxGlobalRooms: 10,
};
/**
* Check if a connection can join another room
*/
export function canJoinRoom(currentRooms: RoomType[], newRoom: RoomType): boolean {
if (currentRooms.length >= ROOM_LIMITS.maxRooms) {
return false;
}
const parsed = parseRoom(newRoom);
const entityRooms = currentRooms.filter((r) => r.includes(':')).length;
const globalRooms = currentRooms.filter((r) => !r.includes(':')).length;
if (parsed.id && entityRooms >= ROOM_LIMITS.maxEntityRooms) {
return false;
}
if (!parsed.id && globalRooms >= ROOM_LIMITS.maxGlobalRooms) {
return false;
}
return true;
}

View file

@ -1,379 +0,0 @@
/**
* Socket.io Client for LocalGreenChain
*
* Provides a client-side wrapper for Socket.io connections.
*/
import { io, Socket } from 'socket.io-client';
import type {
ClientToServerEvents,
ServerToClientEvents,
ConnectionStatus,
RoomType,
TransparencyEventType,
TransparencyEvent,
ConnectionMetrics,
} from './types';
type TypedSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
/**
* Client configuration options
*/
export interface SocketClientConfig {
url?: string;
path?: string;
autoConnect?: boolean;
auth?: {
userId?: string;
token?: string;
sessionId?: string;
};
reconnection?: boolean;
reconnectionAttempts?: number;
reconnectionDelay?: number;
}
/**
* Event listener types
*/
export type EventListener = (event: TransparencyEvent) => void;
export type StatusListener = (status: ConnectionStatus) => void;
export type ErrorListener = (error: { code: string; message: string }) => void;
/**
* Socket.io client wrapper
*/
class RealtimeSocketClient {
private socket: TypedSocket | null = null;
private config: SocketClientConfig;
private status: ConnectionStatus = 'disconnected';
private eventListeners: Set<EventListener> = new Set();
private statusListeners: Set<StatusListener> = new Set();
private errorListeners: Set<ErrorListener> = new Set();
private metrics: ConnectionMetrics;
private pingInterval: NodeJS.Timeout | null = null;
constructor(config: SocketClientConfig = {}) {
this.config = {
url: typeof window !== 'undefined' ? window.location.origin : '',
path: '/api/socket',
autoConnect: true,
reconnection: true,
reconnectionAttempts: 10,
reconnectionDelay: 1000,
...config,
};
this.metrics = {
status: 'disconnected',
eventsReceived: 0,
reconnectAttempts: 0,
rooms: [],
};
}
/**
* Connect to the server
*/
connect(): void {
if (this.socket?.connected) {
return;
}
this.updateStatus('connecting');
this.socket = io(this.config.url!, {
path: this.config.path,
autoConnect: this.config.autoConnect,
auth: this.config.auth,
reconnection: this.config.reconnection,
reconnectionAttempts: this.config.reconnectionAttempts,
reconnectionDelay: this.config.reconnectionDelay,
transports: ['websocket', 'polling'],
});
this.setupEventHandlers();
}
/**
* Set up socket event handlers
*/
private setupEventHandlers(): void {
if (!this.socket) return;
// Connection events
this.socket.on('connect', () => {
this.updateStatus('connected');
this.metrics.connectedAt = Date.now();
this.metrics.reconnectAttempts = 0;
this.startPingInterval();
});
this.socket.on('disconnect', () => {
this.updateStatus('disconnected');
this.stopPingInterval();
});
this.socket.on('connect_error', () => {
this.updateStatus('error');
this.metrics.reconnectAttempts++;
});
// Server events
this.socket.on('connection:established', (data) => {
console.log('[SocketClient] Connected:', data.socketId);
});
this.socket.on('connection:error', (error) => {
this.errorListeners.forEach((listener) => listener(error));
});
// Real-time events
this.socket.on('event', (event) => {
this.metrics.eventsReceived++;
this.metrics.lastEventAt = Date.now();
this.eventListeners.forEach((listener) => listener(event));
});
this.socket.on('event:batch', (events) => {
this.metrics.eventsReceived += events.length;
this.metrics.lastEventAt = Date.now();
events.forEach((event) => {
this.eventListeners.forEach((listener) => listener(event));
});
});
// Room events
this.socket.on('room:joined', (room) => {
if (!this.metrics.rooms.includes(room)) {
this.metrics.rooms.push(room);
}
});
this.socket.on('room:left', (room) => {
this.metrics.rooms = this.metrics.rooms.filter((r) => r !== room);
});
// System events
this.socket.on('system:message', (message) => {
console.log(`[SocketClient] System ${message.type}: ${message.text}`);
});
this.socket.on('system:heartbeat', () => {
// Heartbeat received - connection is alive
});
// Reconnection events
this.socket.io.on('reconnect_attempt', () => {
this.updateStatus('reconnecting');
this.metrics.reconnectAttempts++;
});
this.socket.io.on('reconnect', () => {
this.updateStatus('connected');
});
}
/**
* Start ping interval for latency measurement
*/
private startPingInterval(): void {
this.stopPingInterval();
this.pingInterval = setInterval(() => {
if (this.socket?.connected) {
const start = Date.now();
this.socket.emit('ping', (serverTime) => {
this.metrics.latency = Date.now() - start;
});
}
}, 10000); // Every 10 seconds
}
/**
* Stop ping interval
*/
private stopPingInterval(): void {
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
}
/**
* Update connection status and notify listeners
*/
private updateStatus(status: ConnectionStatus): void {
this.status = status;
this.metrics.status = status;
this.statusListeners.forEach((listener) => listener(status));
}
/**
* Disconnect from the server
*/
disconnect(): void {
this.stopPingInterval();
if (this.socket) {
this.socket.disconnect();
this.socket = null;
}
this.updateStatus('disconnected');
}
/**
* Join a room
*/
joinRoom(room: RoomType): Promise<boolean> {
return new Promise((resolve) => {
if (!this.socket?.connected) {
resolve(false);
return;
}
this.socket.emit('room:join', room, (success) => {
resolve(success);
});
});
}
/**
* Leave a room
*/
leaveRoom(room: RoomType): Promise<boolean> {
return new Promise((resolve) => {
if (!this.socket?.connected) {
resolve(false);
return;
}
this.socket.emit('room:leave', room, (success) => {
resolve(success);
});
});
}
/**
* Subscribe to specific event types
*/
subscribeToTypes(types: TransparencyEventType[]): Promise<boolean> {
return new Promise((resolve) => {
if (!this.socket?.connected) {
resolve(false);
return;
}
this.socket.emit('subscribe:types', types, (success) => {
resolve(success);
});
});
}
/**
* Unsubscribe from specific event types
*/
unsubscribeFromTypes(types: TransparencyEventType[]): Promise<boolean> {
return new Promise((resolve) => {
if (!this.socket?.connected) {
resolve(false);
return;
}
this.socket.emit('unsubscribe:types', types, (success) => {
resolve(success);
});
});
}
/**
* Get recent events
*/
getRecentEvents(limit: number = 50): Promise<TransparencyEvent[]> {
return new Promise((resolve) => {
if (!this.socket?.connected) {
resolve([]);
return;
}
this.socket.emit('events:recent', limit, (events) => {
resolve(events);
});
});
}
/**
* Add an event listener
*/
onEvent(listener: EventListener): () => void {
this.eventListeners.add(listener);
return () => this.eventListeners.delete(listener);
}
/**
* Add a status listener
*/
onStatusChange(listener: StatusListener): () => void {
this.statusListeners.add(listener);
return () => this.statusListeners.delete(listener);
}
/**
* Add an error listener
*/
onError(listener: ErrorListener): () => void {
this.errorListeners.add(listener);
return () => this.errorListeners.delete(listener);
}
/**
* Get current connection status
*/
getStatus(): ConnectionStatus {
return this.status;
}
/**
* Get connection metrics
*/
getMetrics(): ConnectionMetrics {
return { ...this.metrics };
}
/**
* Check if connected
*/
isConnected(): boolean {
return this.socket?.connected ?? false;
}
/**
* Get socket ID
*/
getSocketId(): string | undefined {
return this.socket?.id;
}
}
// Singleton instance for client-side use
let clientInstance: RealtimeSocketClient | null = null;
/**
* Get the singleton socket client instance
*/
export function getSocketClient(config?: SocketClientConfig): RealtimeSocketClient {
if (!clientInstance) {
clientInstance = new RealtimeSocketClient(config);
}
return clientInstance;
}
/**
* Create a new socket client instance
*/
export function createSocketClient(config?: SocketClientConfig): RealtimeSocketClient {
return new RealtimeSocketClient(config);
}
export { RealtimeSocketClient };
export default RealtimeSocketClient;

View file

@ -1,343 +0,0 @@
/**
* Socket.io Server for LocalGreenChain
*
* Provides real-time WebSocket communication with automatic
* fallback to SSE for environments that don't support WebSockets.
*/
import { Server as SocketIOServer, Socket } from 'socket.io';
import type { Server as HTTPServer } from 'http';
import type {
ClientToServerEvents,
ServerToClientEvents,
InterServerEvents,
SocketData,
RoomType,
TransparencyEventType,
TransparencyEvent,
} from './types';
import { getEventStream } from '../transparency/EventStream';
import { getEventRooms, isValidRoom, canJoinRoom, getDefaultRooms } from './rooms';
import * as crypto from 'crypto';
type TypedSocket = Socket<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
/**
* Socket.io server configuration
*/
interface SocketServerConfig {
cors?: {
origin: string | string[];
credentials?: boolean;
};
pingTimeout?: number;
pingInterval?: number;
}
/**
* Socket.io server wrapper for LocalGreenChain
*/
class RealtimeSocketServer {
private io: SocketIOServer<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData> | null = null;
private eventStreamSubscriptionId: string | null = null;
private connectedClients: Map<string, TypedSocket> = new Map();
private heartbeatInterval: NodeJS.Timeout | null = null;
/**
* Initialize the Socket.io server
*/
initialize(httpServer: HTTPServer, config: SocketServerConfig = {}): void {
if (this.io) {
console.log('[RealtimeSocketServer] Already initialized');
return;
}
this.io = new SocketIOServer(httpServer, {
path: '/api/socket',
cors: config.cors || {
origin: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3001',
credentials: true,
},
pingTimeout: config.pingTimeout || 60000,
pingInterval: config.pingInterval || 25000,
transports: ['websocket', 'polling'],
});
this.setupMiddleware();
this.setupEventHandlers();
this.subscribeToEventStream();
this.startHeartbeat();
console.log('[RealtimeSocketServer] Initialized');
}
/**
* Set up authentication and rate limiting middleware
*/
private setupMiddleware(): void {
if (!this.io) return;
// Authentication middleware
this.io.use((socket, next) => {
const auth = socket.handshake.auth;
// Generate session ID if not provided
const sessionId = auth.sessionId || `sess_${crypto.randomBytes(8).toString('hex')}`;
// Attach data to socket
socket.data = {
userId: auth.userId,
sessionId,
connectedAt: Date.now(),
rooms: new Set<RoomType>(),
subscribedTypes: new Set<TransparencyEventType>(),
};
next();
});
}
/**
* Set up socket event handlers
*/
private setupEventHandlers(): void {
if (!this.io) return;
this.io.on('connection', (socket: TypedSocket) => {
console.log(`[RealtimeSocketServer] Client connected: ${socket.id}`);
// Store connected client
this.connectedClients.set(socket.id, socket);
// Join default rooms
const defaultRooms = getDefaultRooms(socket.data.userId);
defaultRooms.forEach((room) => {
socket.join(room);
socket.data.rooms.add(room);
});
// Send connection established event
socket.emit('connection:established', {
socketId: socket.id,
serverTime: Date.now(),
});
// Handle room join
socket.on('room:join', (room, callback) => {
if (!isValidRoom(room)) {
callback?.(false);
return;
}
if (!canJoinRoom(Array.from(socket.data.rooms), room)) {
callback?.(false);
return;
}
socket.join(room);
socket.data.rooms.add(room);
socket.emit('room:joined', room);
callback?.(true);
});
// Handle room leave
socket.on('room:leave', (room, callback) => {
socket.leave(room);
socket.data.rooms.delete(room);
socket.emit('room:left', room);
callback?.(true);
});
// Handle type subscriptions
socket.on('subscribe:types', (types, callback) => {
types.forEach((type) => socket.data.subscribedTypes.add(type));
callback?.(true);
});
// Handle type unsubscriptions
socket.on('unsubscribe:types', (types, callback) => {
types.forEach((type) => socket.data.subscribedTypes.delete(type));
callback?.(true);
});
// Handle ping for latency measurement
socket.on('ping', (callback) => {
callback(Date.now());
});
// Handle recent events request
socket.on('events:recent', (limit, callback) => {
const eventStream = getEventStream();
const events = eventStream.getRecent(Math.min(limit, 100));
callback(events);
});
// Handle disconnect
socket.on('disconnect', (reason) => {
console.log(`[RealtimeSocketServer] Client disconnected: ${socket.id} (${reason})`);
this.connectedClients.delete(socket.id);
});
});
}
/**
* Subscribe to the EventStream for broadcasting events
*/
private subscribeToEventStream(): void {
const eventStream = getEventStream();
// Subscribe to all event types
this.eventStreamSubscriptionId = eventStream.subscribe(
eventStream.getAvailableEventTypes(),
(event) => {
this.broadcastEvent(event);
}
);
console.log('[RealtimeSocketServer] Subscribed to EventStream');
}
/**
* Broadcast an event to appropriate rooms
*/
private broadcastEvent(event: TransparencyEvent): void {
if (!this.io) return;
// Get rooms that should receive this event
const rooms = getEventRooms(event.type, event.data);
// Emit to each room
rooms.forEach((room) => {
this.io!.to(room).emit('event', event);
});
}
/**
* Start heartbeat to keep connections alive
*/
private startHeartbeat(): void {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
}
this.heartbeatInterval = setInterval(() => {
if (this.io) {
this.io.emit('system:heartbeat', Date.now());
}
}, 30000); // Every 30 seconds
}
/**
* Emit an event to specific rooms
*/
emitToRooms(rooms: RoomType[], eventName: keyof ServerToClientEvents, data: unknown): void {
if (!this.io) return;
rooms.forEach((room) => {
(this.io!.to(room) as any).emit(eventName, data);
});
}
/**
* Emit an event to a specific user
*/
emitToUser(userId: string, eventName: keyof ServerToClientEvents, data: unknown): void {
this.emitToRooms([`user:${userId}` as RoomType], eventName, data);
}
/**
* Emit a system message to all connected clients
*/
emitSystemMessage(type: 'info' | 'warning' | 'error', text: string): void {
if (!this.io) return;
this.io.emit('system:message', { type, text });
}
/**
* Get connected client count
*/
getConnectedCount(): number {
return this.connectedClients.size;
}
/**
* Get all connected socket IDs
*/
getConnectedSockets(): string[] {
return Array.from(this.connectedClients.keys());
}
/**
* Get server stats
*/
getStats(): {
connectedClients: number;
rooms: string[];
uptime: number;
} {
return {
connectedClients: this.connectedClients.size,
rooms: this.io ? Array.from(this.io.sockets.adapter.rooms.keys()) : [],
uptime: process.uptime(),
};
}
/**
* Shutdown the server gracefully
*/
async shutdown(): Promise<void> {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
}
if (this.eventStreamSubscriptionId) {
const eventStream = getEventStream();
eventStream.unsubscribe(this.eventStreamSubscriptionId);
}
if (this.io) {
// Notify all clients
this.io.emit('system:message', {
type: 'warning',
text: 'Server is shutting down',
});
// Disconnect all clients
this.io.disconnectSockets(true);
// Close server
await new Promise<void>((resolve) => {
this.io!.close(() => {
console.log('[RealtimeSocketServer] Shutdown complete');
resolve();
});
});
this.io = null;
}
}
/**
* Get the Socket.io server instance
*/
getIO(): SocketIOServer | null {
return this.io;
}
}
// Singleton instance
let socketServerInstance: RealtimeSocketServer | null = null;
/**
* Get the singleton socket server instance
*/
export function getSocketServer(): RealtimeSocketServer {
if (!socketServerInstance) {
socketServerInstance = new RealtimeSocketServer();
}
return socketServerInstance;
}
export { RealtimeSocketServer };
export default RealtimeSocketServer;

View file

@ -1,152 +0,0 @@
/**
* Real-Time System Types for LocalGreenChain
*
* Defines all types used in the WebSocket/SSE real-time communication system.
*/
import type { TransparencyEventType, EventPriority, TransparencyEvent } from '../transparency/EventStream';
// Re-export for convenience
export type { TransparencyEventType, EventPriority, TransparencyEvent };
/**
* Room types for Socket.io subscriptions
*/
export type RoomType =
| 'global' // All events
| 'plants' // All plant events
| 'transport' // All transport events
| 'farms' // All farm events
| 'demand' // All demand events
| 'system' // System events
| `plant:${string}` // Specific plant
| `farm:${string}` // Specific farm
| `user:${string}`; // User-specific events
/**
* Connection status states
*/
export type ConnectionStatus =
| 'connecting'
| 'connected'
| 'disconnected'
| 'reconnecting'
| 'error';
/**
* Socket authentication payload
*/
export interface SocketAuthPayload {
userId?: string;
token?: string;
sessionId?: string;
}
/**
* Socket handshake data
*/
export interface SocketHandshake {
auth: SocketAuthPayload;
rooms?: RoomType[];
}
/**
* Client-to-server events
*/
export interface ClientToServerEvents {
// Room management
'room:join': (room: RoomType, callback?: (success: boolean) => void) => void;
'room:leave': (room: RoomType, callback?: (success: boolean) => void) => void;
// Event subscriptions
'subscribe:types': (types: TransparencyEventType[], callback?: (success: boolean) => void) => void;
'unsubscribe:types': (types: TransparencyEventType[], callback?: (success: boolean) => void) => void;
// Ping for connection health
'ping': (callback: (timestamp: number) => void) => void;
// Request recent events
'events:recent': (limit: number, callback: (events: TransparencyEvent[]) => void) => void;
}
/**
* Server-to-client events
*/
export interface ServerToClientEvents {
// Real-time events
'event': (event: TransparencyEvent) => void;
'event:batch': (events: TransparencyEvent[]) => void;
// Connection status
'connection:established': (data: { socketId: string; serverTime: number }) => void;
'connection:error': (error: { code: string; message: string }) => void;
// Room notifications
'room:joined': (room: RoomType) => void;
'room:left': (room: RoomType) => void;
// System messages
'system:message': (message: { type: 'info' | 'warning' | 'error'; text: string }) => void;
'system:heartbeat': (timestamp: number) => void;
}
/**
* Inter-server events (for scaling)
*/
export interface InterServerEvents {
'event:broadcast': (event: TransparencyEvent, rooms: RoomType[]) => void;
}
/**
* Socket data attached to each connection
*/
export interface SocketData {
userId?: string;
sessionId: string;
connectedAt: number;
rooms: Set<RoomType>;
subscribedTypes: Set<TransparencyEventType>;
}
/**
* Real-time notification for UI display
*/
export interface RealtimeNotification {
id: string;
type: 'success' | 'info' | 'warning' | 'error';
title: string;
message: string;
timestamp: number;
eventType?: TransparencyEventType;
data?: Record<string, unknown>;
read: boolean;
dismissed: boolean;
}
/**
* Connection metrics for monitoring
*/
export interface ConnectionMetrics {
status: ConnectionStatus;
connectedAt?: number;
lastEventAt?: number;
eventsReceived: number;
reconnectAttempts: number;
latency?: number;
rooms: RoomType[];
}
/**
* Live feed item for display
*/
export interface LiveFeedItem {
id: string;
event: TransparencyEvent;
timestamp: number;
formatted: {
title: string;
description: string;
icon: string;
color: string;
};
}

View file

@ -1,258 +0,0 @@
/**
* React Hook for Socket.io Real-Time Updates
*
* Provides easy-to-use hooks for real-time data in React components.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { getSocketClient, RealtimeSocketClient } from './socketClient';
import type {
ConnectionStatus,
TransparencyEvent,
RoomType,
TransparencyEventType,
ConnectionMetrics,
LiveFeedItem,
} from './types';
import { toFeedItem } from './events';
/**
* Hook configuration options
*/
export interface UseSocketOptions {
autoConnect?: boolean;
rooms?: RoomType[];
eventTypes?: TransparencyEventType[];
userId?: string;
maxEvents?: number;
}
/**
* Hook return type
*/
export interface UseSocketReturn {
status: ConnectionStatus;
isConnected: boolean;
events: TransparencyEvent[];
latestEvent: TransparencyEvent | null;
metrics: ConnectionMetrics;
connect: () => void;
disconnect: () => void;
joinRoom: (room: RoomType) => Promise<boolean>;
leaveRoom: (room: RoomType) => Promise<boolean>;
clearEvents: () => void;
}
/**
* Main socket hook for real-time updates
*/
export function useSocket(options: UseSocketOptions = {}): UseSocketReturn {
const {
autoConnect = true,
rooms = [],
eventTypes = [],
userId,
maxEvents = 100,
} = options;
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
const [events, setEvents] = useState<TransparencyEvent[]>([]);
const [latestEvent, setLatestEvent] = useState<TransparencyEvent | null>(null);
const [metrics, setMetrics] = useState<ConnectionMetrics>({
status: 'disconnected',
eventsReceived: 0,
reconnectAttempts: 0,
rooms: [],
});
const clientRef = useRef<RealtimeSocketClient | null>(null);
const cleanupRef = useRef<(() => void)[]>([]);
// Initialize client
useEffect(() => {
if (typeof window === 'undefined') return;
const client = getSocketClient({ auth: { userId } });
clientRef.current = client;
// Set up listeners
const unsubStatus = client.onStatusChange((newStatus) => {
setStatus(newStatus);
setMetrics(client.getMetrics());
});
const unsubEvent = client.onEvent((event) => {
setLatestEvent(event);
setEvents((prev) => {
const updated = [event, ...prev];
return updated.slice(0, maxEvents);
});
setMetrics(client.getMetrics());
});
cleanupRef.current = [unsubStatus, unsubEvent];
// Auto connect
if (autoConnect) {
client.connect();
}
// Initial metrics
setMetrics(client.getMetrics());
return () => {
cleanupRef.current.forEach((cleanup) => cleanup());
};
}, [autoConnect, userId, maxEvents]);
// Join initial rooms
useEffect(() => {
if (!clientRef.current || status !== 'connected') return;
rooms.forEach((room) => {
clientRef.current?.joinRoom(room);
});
}, [status, rooms]);
// Subscribe to event types
useEffect(() => {
if (!clientRef.current || status !== 'connected' || eventTypes.length === 0) return;
clientRef.current.subscribeToTypes(eventTypes);
}, [status, eventTypes]);
const connect = useCallback(() => {
clientRef.current?.connect();
}, []);
const disconnect = useCallback(() => {
clientRef.current?.disconnect();
}, []);
const joinRoom = useCallback(async (room: RoomType) => {
return clientRef.current?.joinRoom(room) ?? false;
}, []);
const leaveRoom = useCallback(async (room: RoomType) => {
return clientRef.current?.leaveRoom(room) ?? false;
}, []);
const clearEvents = useCallback(() => {
setEvents([]);
setLatestEvent(null);
}, []);
return {
status,
isConnected: status === 'connected',
events,
latestEvent,
metrics,
connect,
disconnect,
joinRoom,
leaveRoom,
clearEvents,
};
}
/**
* Hook for live feed display
*/
export function useLiveFeed(options: UseSocketOptions = {}): {
items: LiveFeedItem[];
isConnected: boolean;
status: ConnectionStatus;
clearFeed: () => void;
} {
const { events, isConnected, status, clearEvents } = useSocket(options);
const items = events.map((event) => toFeedItem(event));
return {
items,
isConnected,
status,
clearFeed: clearEvents,
};
}
/**
* Hook for tracking a specific plant's real-time updates
*/
export function usePlantUpdates(plantId: string): {
events: TransparencyEvent[];
isConnected: boolean;
} {
return useSocket({
rooms: [`plant:${plantId}` as RoomType],
eventTypes: [
'plant.registered',
'plant.cloned',
'plant.transferred',
'plant.updated',
'transport.started',
'transport.completed',
],
}) as { events: TransparencyEvent[]; isConnected: boolean };
}
/**
* Hook for tracking a specific farm's real-time updates
*/
export function useFarmUpdates(farmId: string): {
events: TransparencyEvent[];
isConnected: boolean;
} {
return useSocket({
rooms: [`farm:${farmId}` as RoomType],
eventTypes: [
'farm.registered',
'farm.updated',
'batch.started',
'batch.harvested',
'agent.alert',
],
}) as { events: TransparencyEvent[]; isConnected: boolean };
}
/**
* Hook for connection status only (lightweight)
*/
export function useConnectionStatus(): {
status: ConnectionStatus;
isConnected: boolean;
latency: number | undefined;
} {
const { status, isConnected, metrics } = useSocket({ autoConnect: true });
return {
status,
isConnected,
latency: metrics.latency,
};
}
/**
* Hook for event counts (useful for notification badges)
*/
export function useEventCount(options: UseSocketOptions = {}): {
count: number;
unreadCount: number;
markAllRead: () => void;
} {
const { events } = useSocket(options);
const [readCount, setReadCount] = useState(0);
const markAllRead = useCallback(() => {
setReadCount(events.length);
}, [events.length]);
return {
count: events.length,
unreadCount: Math.max(0, events.length - readCount),
markAllRead,
};
}
export default useSocket;

View file

@ -1,157 +1,11 @@
const withPWA = require('next-pwa')({
dest: 'public',
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === 'development',
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-webfonts',
expiration: {
maxEntries: 4,
maxAgeSeconds: 365 * 24 * 60 * 60, // 1 year
},
},
},
{
urlPattern: /^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'google-fonts-stylesheets',
expiration: {
maxEntries: 4,
maxAgeSeconds: 7 * 24 * 60 * 60, // 1 week
},
},
},
{
urlPattern: /\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'static-font-assets',
expiration: {
maxEntries: 4,
maxAgeSeconds: 7 * 24 * 60 * 60, // 1 week
},
},
},
{
urlPattern: /\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'static-image-assets',
expiration: {
maxEntries: 64,
maxAgeSeconds: 24 * 60 * 60, // 1 day
},
},
},
{
urlPattern: /\/_next\/image\?url=.+$/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'next-image',
expiration: {
maxEntries: 64,
maxAgeSeconds: 24 * 60 * 60, // 1 day
},
},
},
{
urlPattern: /\.(?:mp3|wav|ogg)$/i,
handler: 'CacheFirst',
options: {
rangeRequests: true,
cacheName: 'static-audio-assets',
expiration: {
maxEntries: 32,
maxAgeSeconds: 24 * 60 * 60, // 1 day
},
},
},
{
urlPattern: /\.(?:mp4)$/i,
handler: 'CacheFirst',
options: {
rangeRequests: true,
cacheName: 'static-video-assets',
expiration: {
maxEntries: 32,
maxAgeSeconds: 24 * 60 * 60, // 1 day
},
},
},
{
urlPattern: /\.(?:js)$/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'static-js-assets',
expiration: {
maxEntries: 32,
maxAgeSeconds: 24 * 60 * 60, // 1 day
},
},
},
{
urlPattern: /\.(?:css|less)$/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'static-style-assets',
expiration: {
maxEntries: 32,
maxAgeSeconds: 24 * 60 * 60, // 1 day
},
},
},
{
urlPattern: /\/_next\/data\/.+\/.+\.json$/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'next-data',
expiration: {
maxEntries: 32,
maxAgeSeconds: 24 * 60 * 60, // 1 day
},
},
},
{
urlPattern: /\/api\/.*$/i,
handler: 'NetworkFirst',
method: 'GET',
options: {
cacheName: 'apis',
expiration: {
maxEntries: 16,
maxAgeSeconds: 24 * 60 * 60, // 1 day
},
networkTimeoutSeconds: 10,
},
},
{
urlPattern: /.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'others',
expiration: {
maxEntries: 32,
maxAgeSeconds: 24 * 60 * 60, // 1 day
},
networkTimeoutSeconds: 10,
},
},
],
});
module.exports = withPWA({
module.exports = {
swcMinify: true,
i18n: {
locales: ["en", "es"],
defaultLocale: "en",
},
images: {
domains: [process.env.NEXT_IMAGE_DOMAIN].filter(Boolean),
domains: [process.env.NEXT_IMAGE_DOMAIN],
},
async rewrites() {
return [
@ -171,4 +25,4 @@ module.exports = withPWA({
},
]
},
});
}

8658
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -28,9 +28,7 @@
"db:seed": "bun run prisma/seed.ts",
"db:studio": "prisma studio",
"prepare": "husky install",
"validate": "bun run type-check && bun run lint && bun run test",
"deploy:network-discovery": "bun run deploy/NetworkDiscoveryAgent.ts",
"agent:network-discovery": "bun run deploy/NetworkDiscoveryAgent.ts"
"validate": "bun run type-check && bun run lint && bun run test"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.937.0",
@ -42,24 +40,17 @@
"@tanstack/react-query": "^4.0.10",
"bcryptjs": "^3.0.3",
"classnames": "^2.3.1",
"d3": "^7.9.0",
"date-fns": "^4.1.0",
"drupal-jsonapi-params": "^1.2.2",
"html-react-parser": "^1.2.7",
"idb": "^7.1.1",
"multer": "^2.0.2",
"next": "^12.2.3",
"next-auth": "^4.24.13",
"next-drupal": "^1.6.0",
"next-pwa": "^5.6.0",
"nprogress": "^0.2.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-hook-form": "^7.8.6",
"recharts": "^3.4.1",
"sharp": "^0.34.5",
"socket.io": "^4.7.2",
"socket.io-client": "^4.7.2",
"socks-proxy-agent": "^8.0.2"
},
"devDependencies": {
@ -67,7 +58,6 @@
"@commitlint/cli": "^18.4.3",
"@commitlint/config-conventional": "^18.4.3",
"@types/bcryptjs": "^3.0.0",
"@types/d3": "^7.4.3",
"@types/jest": "^29.5.0",
"@types/multer": "^2.0.0",
"@types/node": "^17.0.21",
@ -85,8 +75,8 @@
"prisma": "^5.7.0",
"start-server-and-test": "^2.0.3",
"tailwindcss": "^3.0.15",
"ts-jest": "^29.4.5",
"typescript": "^5.9.3"
"ts-jest": "^29.1.0",
"typescript": "^4.5.5"
},
"lint-staged": {
"*.{ts,tsx}": [

View file

@ -7,9 +7,6 @@ import { syncDrupalPreviewRoutes } from "next-drupal"
import "nprogress/nprogress.css"
import "styles/globals.css"
import "styles/mobile.css"
import { initPWA } from "lib/mobile/pwa"
NProgress.configure({ showSpinner: false })
@ -25,13 +22,6 @@ export default function App({ Component, pageProps: { session, ...pageProps } })
if (!queryClientRef.current) {
queryClientRef.current = new QueryClient()
}
// Initialize PWA
React.useEffect(() => {
const cleanup = initPWA()
return cleanup
}, [])
return (
<SessionProvider session={session}>
<QueryClientProvider client={queryClientRef.current}>

View file

@ -1,253 +0,0 @@
/**
* Farm Analytics Page
* Vertical farm performance and resource analytics
*/
import { useState, useEffect } from 'react';
import Link from 'next/link';
import {
KPICard,
DateRangePicker,
LineChart,
BarChart,
Gauge,
DataTable,
} from '../../components/analytics';
import { TimeRange, FarmAnalytics } from '../../lib/analytics/types';
export default function FarmAnalyticsPage() {
const [timeRange, setTimeRange] = useState<TimeRange>('30d');
const [loading, setLoading] = useState(true);
const [data, setData] = useState<FarmAnalytics | null>(null);
useEffect(() => {
fetchData();
}, [timeRange]);
const fetchData = async () => {
setLoading(true);
try {
const response = await fetch(`/api/analytics/farms?timeRange=${timeRange}`);
const result = await response.json();
setData(result.data);
} catch (error) {
console.error('Failed to fetch farm analytics:', error);
} finally {
setLoading(false);
}
};
const zoneColumns = [
{ key: 'zoneName', header: 'Zone' },
{ key: 'currentCrop', header: 'Crop' },
{
key: 'healthScore',
header: 'Health',
align: 'right' as const,
render: (v: number) => (
<span className={`font-medium ${v >= 90 ? 'text-green-600' : v >= 70 ? 'text-yellow-600' : 'text-red-600'}`}>
{v}%
</span>
),
},
{ key: 'yieldKg', header: 'Yield (kg)', align: 'right' as const, render: (v: number) => v.toFixed(1) },
{ key: 'efficiency', header: 'Efficiency', align: 'right' as const, render: (v: number) => `${v}%` },
];
const cropColumns = [
{ key: 'cropType', header: 'Crop' },
{ key: 'batches', header: 'Batches', align: 'right' as const },
{ key: 'averageYieldKg', header: 'Avg Yield (kg)', align: 'right' as const, render: (v: number) => v.toFixed(1) },
{ key: 'growthDays', header: 'Growth Days', align: 'right' as const },
{
key: 'successRate',
header: 'Success Rate',
align: 'right' as const,
render: (v: number) => (
<span className={`font-medium ${v >= 90 ? 'text-green-600' : v >= 75 ? 'text-yellow-600' : 'text-red-600'}`}>
{v.toFixed(1)}%
</span>
),
},
];
const predictionColumns = [
{ key: 'cropType', header: 'Crop' },
{ key: 'predictedYieldKg', header: 'Predicted Yield', align: 'right' as const, render: (v: number) => `${v.toFixed(1)} kg` },
{ key: 'confidence', header: 'Confidence', align: 'right' as const, render: (v: number) => `${(v * 100).toFixed(0)}%` },
{ key: 'harvestDate', header: 'Harvest Date' },
];
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
{/* Header */}
<div className="bg-gradient-to-r from-emerald-600 to-green-600 text-white">
<div className="max-w-7xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold">Farm Analytics</h1>
<p className="text-emerald-200 mt-1">Vertical farm performance and resource optimization</p>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 py-8">
{/* Time Range Selector */}
<div className="mb-8">
<DateRangePicker value={timeRange} onChange={setTimeRange} />
</div>
{/* Navigation Tabs */}
<div className="flex space-x-4 mb-8">
<Link href="/analytics">
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Overview</a>
</Link>
<Link href="/analytics/plants">
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Plants</a>
</Link>
<Link href="/analytics/transport">
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Transport</a>
</Link>
<Link href="/analytics/farms">
<a className="px-4 py-2 bg-emerald-500 text-white rounded-lg font-medium">Farms</a>
</Link>
<Link href="/analytics/sustainability">
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Sustainability</a>
</Link>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<KPICard
title="Total Farms"
value={data?.totalFarms || 0}
trend="up"
color="green"
loading={loading}
/>
<KPICard
title="Active Zones"
value={data?.totalZones || 0}
trend="stable"
color="blue"
loading={loading}
/>
<KPICard
title="Active Batches"
value={data?.activeBatches || 0}
trend="up"
color="purple"
loading={loading}
/>
<KPICard
title="Avg Yield"
value={data?.averageYieldKg?.toFixed(1) || '0'}
unit="kg/batch"
trend="up"
color="teal"
loading={loading}
/>
</div>
{/* Resource Gauges */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-8">
<Gauge
value={data?.resourceUsage?.waterEfficiency || 0}
title="Water Efficiency"
unit="%"
/>
<Gauge
value={data?.resourceUsage?.energyEfficiency || 0}
title="Energy Efficiency"
unit="%"
/>
<Gauge
value={(data?.completedBatches || 0) / ((data?.activeBatches || 1) + (data?.completedBatches || 0)) * 100}
title="Completion Rate"
unit="%"
/>
<Gauge
value={90}
title="Overall Health"
unit="%"
/>
</div>
{/* Resource Usage Stats */}
<div className="bg-white rounded-lg shadow-lg p-6 mb-8">
<h3 className="text-lg font-bold text-gray-900 mb-4">Resource Usage Summary</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="text-center p-4 bg-blue-50 rounded-lg">
<p className="text-3xl font-bold text-blue-600">
{data?.resourceUsage?.waterLiters?.toLocaleString() || 0}
</p>
<p className="text-sm text-gray-500">Water Used (L)</p>
</div>
<div className="text-center p-4 bg-yellow-50 rounded-lg">
<p className="text-3xl font-bold text-yellow-600">
{data?.resourceUsage?.energyKwh?.toLocaleString() || 0}
</p>
<p className="text-sm text-gray-500">Energy Used (kWh)</p>
</div>
<div className="text-center p-4 bg-green-50 rounded-lg">
<p className="text-3xl font-bold text-green-600">
{data?.resourceUsage?.nutrientsKg?.toLocaleString() || 0}
</p>
<p className="text-sm text-gray-500">Nutrients Used (kg)</p>
</div>
</div>
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{data?.batchCompletionTrend && (
<LineChart
data={data.batchCompletionTrend}
xKey="label"
yKey="value"
title="Batch Completions Over Time"
height={300}
/>
)}
{data?.topPerformingCrops && (
<BarChart
data={data.topPerformingCrops}
xKey="cropType"
yKey="averageYieldKg"
title="Average Yield by Crop"
height={300}
/>
)}
</div>
{/* Tables */}
<div className="space-y-6">
{data?.performanceByZone && (
<DataTable
data={data.performanceByZone}
columns={zoneColumns}
title="Zone Performance"
pageSize={6}
/>
)}
{data?.topPerformingCrops && (
<DataTable
data={data.topPerformingCrops}
columns={cropColumns}
title="Crop Performance"
pageSize={6}
/>
)}
{data?.yieldPredictions && (
<DataTable
data={data.yieldPredictions}
columns={predictionColumns}
title="Upcoming Harvest Predictions"
pageSize={5}
/>
)}
</div>
</div>
</div>
);
}

View file

@ -1,225 +0,0 @@
/**
* Analytics Dashboard - Main Page
* Comprehensive overview of all analytics data
*/
import { useState, useEffect } from 'react';
import Link from 'next/link';
import {
KPICard,
DateRangePicker,
LineChart,
BarChart,
PieChart,
} from '../../components/analytics';
import { TimeRange, AnalyticsOverview, PlantAnalytics, TransportAnalytics } from '../../lib/analytics/types';
export default function AnalyticsDashboard() {
const [timeRange, setTimeRange] = useState<TimeRange>('30d');
const [loading, setLoading] = useState(true);
const [overview, setOverview] = useState<AnalyticsOverview | null>(null);
const [plantData, setPlantData] = useState<PlantAnalytics | null>(null);
const [transportData, setTransportData] = useState<TransportAnalytics | null>(null);
useEffect(() => {
fetchData();
}, [timeRange]);
const fetchData = async () => {
setLoading(true);
try {
const [overviewRes, plantRes, transportRes] = await Promise.all([
fetch(`/api/analytics/overview?timeRange=${timeRange}`),
fetch(`/api/analytics/plants?timeRange=${timeRange}`),
fetch(`/api/analytics/transport?timeRange=${timeRange}`),
]);
const overviewData = await overviewRes.json();
const plantDataRes = await plantRes.json();
const transportDataRes = await transportRes.json();
setOverview(overviewData.data);
setPlantData(plantDataRes.data);
setTransportData(transportDataRes.data);
} catch (error) {
console.error('Failed to fetch analytics data:', error);
} finally {
setLoading(false);
}
};
const handleExport = (format: 'csv' | 'json') => {
window.open(`/api/analytics/export?format=${format}&timeRange=${timeRange}`, '_blank');
};
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
{/* Header */}
<div className="bg-gradient-to-r from-green-600 to-emerald-600 text-white">
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Analytics Dashboard</h1>
<p className="text-green-200 mt-1">
Comprehensive insights into your LocalGreenChain network
</p>
</div>
<div className="flex items-center space-x-4">
<button
onClick={() => handleExport('csv')}
className="px-4 py-2 bg-white/20 hover:bg-white/30 rounded-lg text-sm font-medium transition-colors"
>
Export CSV
</button>
<button
onClick={() => handleExport('json')}
className="px-4 py-2 bg-white/20 hover:bg-white/30 rounded-lg text-sm font-medium transition-colors"
>
Export JSON
</button>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 py-8">
{/* Time Range Selector */}
<div className="mb-8">
<DateRangePicker value={timeRange} onChange={setTimeRange} />
</div>
{/* Navigation Tabs */}
<div className="flex space-x-4 mb-8">
<Link href="/analytics">
<a className="px-4 py-2 bg-green-500 text-white rounded-lg font-medium">Overview</a>
</Link>
<Link href="/analytics/plants">
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Plants</a>
</Link>
<Link href="/analytics/transport">
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Transport</a>
</Link>
<Link href="/analytics/farms">
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Farms</a>
</Link>
<Link href="/analytics/sustainability">
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Sustainability</a>
</Link>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<KPICard
title="Total Plants"
value={overview?.totalPlants || 0}
trend={overview?.trendsData?.[0]?.direction || 'stable'}
changePercent={overview?.trendsData?.[0]?.changePercent}
color="green"
loading={loading}
/>
<KPICard
title="Carbon Saved"
value={transportData?.carbonSavedKg?.toFixed(1) || '0'}
unit="kg CO2"
trend="up"
changePercent={12.5}
color="teal"
loading={loading}
/>
<KPICard
title="Food Miles"
value={transportData?.totalDistanceKm?.toFixed(0) || '0'}
unit="km"
trend="down"
changePercent={-8.7}
color="blue"
loading={loading}
/>
<KPICard
title="Active Users"
value={overview?.activeUsers || 0}
trend="up"
changePercent={8.3}
color="purple"
loading={loading}
/>
</div>
{/* Charts Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{/* Plant Registrations Trend */}
{plantData?.registrationsTrend && (
<LineChart
data={plantData.registrationsTrend}
xKey="label"
yKey="value"
title="Plant Registrations Over Time"
height={300}
/>
)}
{/* Species Distribution */}
{plantData?.plantsBySpecies && (
<PieChart
data={plantData.plantsBySpecies}
dataKey="count"
nameKey="species"
title="Plants by Species"
height={300}
/>
)}
</div>
{/* Transport Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{/* Transport Methods */}
{transportData?.eventsByMethod && (
<BarChart
data={transportData.eventsByMethod}
xKey="method"
yKey="count"
title="Transport Events by Method"
height={300}
horizontal
/>
)}
{/* Carbon Trend */}
{transportData?.carbonTrend && (
<LineChart
data={transportData.carbonTrend}
xKey="label"
yKey="value"
title="Carbon Emissions Trend"
colors={['#ef4444']}
height={300}
/>
)}
</div>
{/* Summary Stats */}
<div className="bg-white rounded-lg shadow-lg p-6">
<h3 className="text-lg font-bold text-gray-900 mb-4">Network Summary</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div className="text-center p-4 bg-gray-50 rounded-lg">
<p className="text-2xl font-bold text-gray-900">{overview?.plantsRegisteredToday || 0}</p>
<p className="text-sm text-gray-500">Registered Today</p>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<p className="text-2xl font-bold text-gray-900">{overview?.plantsRegisteredThisWeek || 0}</p>
<p className="text-sm text-gray-500">This Week</p>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<p className="text-2xl font-bold text-gray-900">{overview?.plantsRegisteredThisMonth || 0}</p>
<p className="text-sm text-gray-500">This Month</p>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<p className="text-2xl font-bold text-green-600">{overview?.growthRate?.toFixed(1) || 0}%</p>
<p className="text-sm text-gray-500">Growth Rate</p>
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,182 +0,0 @@
/**
* Plant Analytics Page
* Detailed analytics for plant registrations and lineage
*/
import { useState, useEffect } from 'react';
import Link from 'next/link';
import {
KPICard,
DateRangePicker,
LineChart,
BarChart,
PieChart,
DataTable,
TrendIndicator,
} from '../../components/analytics';
import { TimeRange, PlantAnalytics } from '../../lib/analytics/types';
export default function PlantAnalyticsPage() {
const [timeRange, setTimeRange] = useState<TimeRange>('30d');
const [loading, setLoading] = useState(true);
const [data, setData] = useState<PlantAnalytics | null>(null);
useEffect(() => {
fetchData();
}, [timeRange]);
const fetchData = async () => {
setLoading(true);
try {
const response = await fetch(`/api/analytics/plants?timeRange=${timeRange}`);
const result = await response.json();
setData(result.data);
} catch (error) {
console.error('Failed to fetch plant analytics:', error);
} finally {
setLoading(false);
}
};
const speciesColumns = [
{ key: 'species', header: 'Species' },
{ key: 'count', header: 'Count', align: 'right' as const },
{ key: 'percentage', header: '% Share', align: 'right' as const, render: (v: number) => `${v.toFixed(1)}%` },
{ key: 'trend', header: 'Trend', render: (v: string) => <TrendIndicator direction={v as any} /> },
];
const growerColumns = [
{ key: 'name', header: 'Grower' },
{ key: 'totalPlants', header: 'Plants', align: 'right' as const },
{ key: 'totalSpecies', header: 'Species', align: 'right' as const },
{ key: 'averageGeneration', header: 'Avg Gen', align: 'right' as const, render: (v: number) => v.toFixed(1) },
];
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
{/* Header */}
<div className="bg-gradient-to-r from-green-600 to-emerald-600 text-white">
<div className="max-w-7xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold">Plant Analytics</h1>
<p className="text-green-200 mt-1">Detailed insights into plant registrations and lineage</p>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 py-8">
{/* Time Range Selector */}
<div className="mb-8">
<DateRangePicker value={timeRange} onChange={setTimeRange} />
</div>
{/* Navigation Tabs */}
<div className="flex space-x-4 mb-8">
<Link href="/analytics">
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Overview</a>
</Link>
<Link href="/analytics/plants">
<a className="px-4 py-2 bg-green-500 text-white rounded-lg font-medium">Plants</a>
</Link>
<Link href="/analytics/transport">
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Transport</a>
</Link>
<Link href="/analytics/farms">
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Farms</a>
</Link>
<Link href="/analytics/sustainability">
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Sustainability</a>
</Link>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<KPICard
title="Total Plants"
value={data?.totalPlants || 0}
trend="up"
color="green"
loading={loading}
/>
<KPICard
title="Unique Species"
value={data?.plantsBySpecies?.length || 0}
trend="stable"
color="blue"
loading={loading}
/>
<KPICard
title="Avg Lineage Depth"
value={data?.averageLineageDepth?.toFixed(1) || '0'}
unit="generations"
trend="up"
color="purple"
loading={loading}
/>
<KPICard
title="Top Growers"
value={data?.topGrowers?.length || 0}
trend="stable"
color="teal"
loading={loading}
/>
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{data?.registrationsTrend && (
<LineChart
data={data.registrationsTrend}
xKey="label"
yKey="value"
title="Plant Registrations Over Time"
height={300}
/>
)}
{data?.plantsBySpecies && (
<PieChart
data={data.plantsBySpecies}
dataKey="count"
nameKey="species"
title="Distribution by Species"
height={300}
/>
)}
</div>
{/* Generation Distribution */}
{data?.plantsByGeneration && (
<div className="mb-8">
<BarChart
data={data.plantsByGeneration}
xKey="generation"
yKey="count"
title="Plants by Generation"
height={250}
/>
</div>
)}
{/* Tables */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{data?.plantsBySpecies && (
<DataTable
data={data.plantsBySpecies}
columns={speciesColumns}
title="Species Breakdown"
pageSize={6}
/>
)}
{data?.topGrowers && (
<DataTable
data={data.topGrowers}
columns={growerColumns}
title="Top Growers"
pageSize={6}
/>
)}
</div>
</div>
</div>
);
}

View file

@ -1,315 +0,0 @@
/**
* Sustainability Analytics Page
* Environmental impact and sustainability metrics
*/
import { useState, useEffect } from 'react';
import Link from 'next/link';
import {
KPICard,
DateRangePicker,
LineChart,
AreaChart,
Gauge,
DataTable,
TrendIndicator,
} from '../../components/analytics';
import { TimeRange, SustainabilityAnalytics } from '../../lib/analytics/types';
export default function SustainabilityAnalyticsPage() {
const [timeRange, setTimeRange] = useState<TimeRange>('30d');
const [loading, setLoading] = useState(true);
const [data, setData] = useState<SustainabilityAnalytics | null>(null);
useEffect(() => {
fetchData();
}, [timeRange]);
const fetchData = async () => {
setLoading(true);
try {
const response = await fetch(`/api/analytics/sustainability?timeRange=${timeRange}`);
const result = await response.json();
setData(result.data);
} catch (error) {
console.error('Failed to fetch sustainability analytics:', error);
} finally {
setLoading(false);
}
};
const goalColumns = [
{ key: 'name', header: 'Goal' },
{ key: 'current', header: 'Current', align: 'right' as const, render: (v: number, row: any) => `${v} ${row.unit}` },
{ key: 'target', header: 'Target', align: 'right' as const, render: (v: number, row: any) => `${v} ${row.unit}` },
{
key: 'progress',
header: 'Progress',
align: 'right' as const,
render: (v: number) => (
<div className="flex items-center space-x-2">
<div className="w-20 bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${v >= 90 ? 'bg-green-500' : v >= 70 ? 'bg-yellow-500' : 'bg-red-500'}`}
style={{ width: `${Math.min(v, 100)}%` }}
/>
</div>
<span className="text-sm">{v}%</span>
</div>
),
},
{
key: 'status',
header: 'Status',
render: (v: string) => (
<span
className={`px-2 py-1 text-xs rounded-full ${
v === 'on_track'
? 'bg-green-100 text-green-700'
: v === 'at_risk'
? 'bg-yellow-100 text-yellow-700'
: 'bg-red-100 text-red-700'
}`}
>
{v.replace('_', ' ')}
</span>
),
},
];
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
{/* Header */}
<div className="bg-gradient-to-r from-green-700 to-emerald-700 text-white">
<div className="max-w-7xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold">Sustainability Analytics</h1>
<p className="text-green-200 mt-1">Environmental impact and sustainability metrics</p>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 py-8">
{/* Time Range Selector */}
<div className="mb-8">
<DateRangePicker value={timeRange} onChange={setTimeRange} />
</div>
{/* Navigation Tabs */}
<div className="flex space-x-4 mb-8">
<Link href="/analytics">
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Overview</a>
</Link>
<Link href="/analytics/plants">
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Plants</a>
</Link>
<Link href="/analytics/transport">
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Transport</a>
</Link>
<Link href="/analytics/farms">
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Farms</a>
</Link>
<Link href="/analytics/sustainability">
<a className="px-4 py-2 bg-green-700 text-white rounded-lg font-medium">Sustainability</a>
</Link>
</div>
{/* Overall Score */}
<div className="bg-gradient-to-r from-green-500 to-emerald-500 rounded-lg p-8 mb-8 text-white">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold">Overall Sustainability Score</h2>
<p className="text-green-100 mt-1">Based on carbon, local production, water, and waste metrics</p>
</div>
<div className="text-center">
<div className="text-6xl font-bold">{data?.overallScore?.toFixed(0) || 0}</div>
<div className="text-green-100">out of 100</div>
</div>
</div>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<KPICard
title="Carbon Saved"
value={data?.carbonFootprint?.totalSavedKg?.toFixed(0) || '0'}
unit="kg CO2"
trend="up"
color="green"
loading={loading}
/>
<KPICard
title="Reduction Rate"
value={data?.carbonFootprint?.reductionPercentage?.toFixed(0) || '0'}
unit="%"
trend="up"
color="teal"
loading={loading}
/>
<KPICard
title="Local Production"
value={data?.localProduction?.percentage?.toFixed(0) || '0'}
unit="%"
trend="up"
color="blue"
loading={loading}
/>
<KPICard
title="Water Efficiency"
value={data?.waterUsage?.efficiencyScore?.toFixed(0) || '0'}
unit="%"
trend="up"
color="purple"
loading={loading}
/>
</div>
{/* Gauges */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-8">
<Gauge
value={data?.carbonFootprint?.reductionPercentage || 0}
title="Carbon Reduction"
unit="%"
/>
<Gauge
value={data?.foodMiles?.localPercentage || 0}
title="Local Sourcing"
unit="%"
/>
<Gauge
value={data?.waterUsage?.efficiencyScore || 0}
title="Water Efficiency"
unit="%"
/>
<Gauge
value={data?.overallScore || 0}
title="Sustainability Score"
unit="pts"
/>
</div>
{/* Impact Metrics */}
<div className="bg-white rounded-lg shadow-lg p-6 mb-8">
<h3 className="text-lg font-bold text-gray-900 mb-4">Environmental Impact</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="text-center p-4 bg-green-50 rounded-lg">
<p className="text-3xl font-bold text-green-600">
{data?.carbonFootprint?.equivalentTrees?.toFixed(1) || 0}
</p>
<p className="text-sm text-gray-500">Trees Equivalent</p>
<p className="text-xs text-gray-400 mt-1">CO2 absorption per year</p>
</div>
<div className="text-center p-4 bg-blue-50 rounded-lg">
<p className="text-3xl font-bold text-blue-600">
{data?.foodMiles?.savedMiles?.toLocaleString() || 0}
</p>
<p className="text-sm text-gray-500">Miles Saved</p>
<p className="text-xs text-gray-400 mt-1">vs conventional transport</p>
</div>
<div className="text-center p-4 bg-cyan-50 rounded-lg">
<p className="text-3xl font-bold text-cyan-600">
{data?.waterUsage?.savedLiters?.toLocaleString() || 0}
</p>
<p className="text-sm text-gray-500">Liters Saved</p>
<p className="text-xs text-gray-400 mt-1">Water conservation</p>
</div>
<div className="text-center p-4 bg-purple-50 rounded-lg">
<p className="text-3xl font-bold text-purple-600">
{data?.localProduction?.localCount || 0}
</p>
<p className="text-sm text-gray-500">Local Plants</p>
<p className="text-xs text-gray-400 mt-1">Within 50km radius</p>
</div>
</div>
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{data?.carbonFootprint?.monthlyTrend && (
<AreaChart
data={data.carbonFootprint.monthlyTrend}
xKey="label"
yKey="value"
title="Carbon Emissions Trend"
colors={['#10b981']}
height={300}
/>
)}
{data?.foodMiles?.monthlyTrend && (
<AreaChart
data={data.foodMiles.monthlyTrend}
xKey="label"
yKey="value"
title="Food Miles Trend"
colors={['#3b82f6']}
height={300}
/>
)}
</div>
{/* Sustainability Trends */}
{data?.trends && data.trends.length > 0 && (
<div className="mb-8">
<LineChart
data={data.trends[0].values}
xKey="label"
yKey="value"
title="Sustainability Trend Over Time"
colors={['#10b981', '#3b82f6']}
height={250}
/>
</div>
)}
{/* Goals Table */}
{data?.goals && (
<DataTable
data={data.goals}
columns={goalColumns}
title="Sustainability Goals Progress"
pageSize={10}
showSearch={false}
/>
)}
{/* Tips Section */}
<div className="mt-8 bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg border border-green-200 p-6">
<h3 className="text-lg font-bold text-gray-900 mb-4">Sustainability Recommendations</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-start space-x-3">
<span className="text-green-500 mt-1">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</span>
<p className="text-sm text-gray-600">Increase local sourcing to reduce transport emissions</p>
</div>
<div className="flex items-start space-x-3">
<span className="text-green-500 mt-1">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</span>
<p className="text-sm text-gray-600">Use electric or bicycle delivery for short distances</p>
</div>
<div className="flex items-start space-x-3">
<span className="text-green-500 mt-1">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</span>
<p className="text-sm text-gray-600">Optimize water usage in vertical farming operations</p>
</div>
<div className="flex items-start space-x-3">
<span className="text-green-500 mt-1">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</span>
<p className="text-sm text-gray-600">Consolidate deliveries to reduce carbon footprint</p>
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,232 +0,0 @@
/**
* Transport Analytics Page
* Carbon footprint and food miles analysis
*/
import { useState, useEffect } from 'react';
import Link from 'next/link';
import {
KPICard,
DateRangePicker,
LineChart,
BarChart,
PieChart,
AreaChart,
Gauge,
DataTable,
} from '../../components/analytics';
import { TimeRange, TransportAnalytics } from '../../lib/analytics/types';
export default function TransportAnalyticsPage() {
const [timeRange, setTimeRange] = useState<TimeRange>('30d');
const [loading, setLoading] = useState(true);
const [data, setData] = useState<TransportAnalytics | null>(null);
useEffect(() => {
fetchData();
}, [timeRange]);
const fetchData = async () => {
setLoading(true);
try {
const response = await fetch(`/api/analytics/transport?timeRange=${timeRange}`);
const result = await response.json();
setData(result.data);
} catch (error) {
console.error('Failed to fetch transport analytics:', error);
} finally {
setLoading(false);
}
};
const methodColumns = [
{ key: 'method', header: 'Method' },
{ key: 'count', header: 'Events', align: 'right' as const },
{ key: 'distanceKm', header: 'Distance (km)', align: 'right' as const, render: (v: number) => v.toLocaleString() },
{ key: 'carbonKg', header: 'Carbon (kg)', align: 'right' as const, render: (v: number) => v.toFixed(2) },
{
key: 'efficiency',
header: 'Efficiency',
align: 'right' as const,
render: (v: number) => (
<span className={`font-medium ${v >= 80 ? 'text-green-600' : v >= 50 ? 'text-yellow-600' : 'text-red-600'}`}>
{v}%
</span>
),
},
];
const routeColumns = [
{ key: 'from', header: 'From' },
{ key: 'to', header: 'To' },
{ key: 'method', header: 'Method' },
{ key: 'distanceKm', header: 'Distance', align: 'right' as const, render: (v: number) => `${v} km` },
{ key: 'frequency', header: 'Frequency', align: 'right' as const },
];
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
{/* Header */}
<div className="bg-gradient-to-r from-teal-600 to-cyan-600 text-white">
<div className="max-w-7xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold">Transport Analytics</h1>
<p className="text-teal-200 mt-1">Carbon footprint and food miles analysis</p>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 py-8">
{/* Time Range Selector */}
<div className="mb-8">
<DateRangePicker value={timeRange} onChange={setTimeRange} />
</div>
{/* Navigation Tabs */}
<div className="flex space-x-4 mb-8">
<Link href="/analytics">
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Overview</a>
</Link>
<Link href="/analytics/plants">
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Plants</a>
</Link>
<Link href="/analytics/transport">
<a className="px-4 py-2 bg-teal-500 text-white rounded-lg font-medium">Transport</a>
</Link>
<Link href="/analytics/farms">
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Farms</a>
</Link>
<Link href="/analytics/sustainability">
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Sustainability</a>
</Link>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<KPICard
title="Total Events"
value={data?.totalEvents || 0}
trend="up"
color="blue"
loading={loading}
/>
<KPICard
title="Total Distance"
value={data?.totalDistanceKm?.toLocaleString() || '0'}
unit="km"
trend="stable"
color="purple"
loading={loading}
/>
<KPICard
title="Carbon Emitted"
value={data?.totalCarbonKg?.toFixed(1) || '0'}
unit="kg CO2"
trend="down"
color="orange"
loading={loading}
/>
<KPICard
title="Carbon Saved"
value={data?.carbonSavedKg?.toFixed(1) || '0'}
unit="kg CO2"
trend="up"
color="green"
loading={loading}
/>
</div>
{/* Gauges */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-8">
<Gauge
value={data?.totalCarbonKg ? (data.carbonSavedKg / (data.totalCarbonKg + data.carbonSavedKg)) * 100 : 0}
title="Carbon Reduction"
unit="%"
/>
<Gauge
value={100 - ((data?.averageDistancePerEvent || 0) / 50) * 100}
title="Distance Efficiency"
unit="%"
/>
<Gauge
value={data?.eventsByMethod?.filter(m => m.efficiency >= 80).length
? (data.eventsByMethod.filter(m => m.efficiency >= 80).reduce((s, m) => s + m.count, 0) / data.totalEvents) * 100
: 0}
title="Green Transport"
unit="%"
/>
<Gauge
value={75}
title="Local Sourcing"
unit="%"
/>
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{data?.carbonTrend && (
<AreaChart
data={data.carbonTrend}
xKey="label"
yKey="value"
title="Carbon Emissions Trend"
colors={['#f59e0b']}
height={300}
/>
)}
{data?.eventsByMethod && (
<BarChart
data={data.eventsByMethod}
xKey="method"
yKey="carbonKg"
title="Carbon by Transport Method"
height={300}
/>
)}
</div>
{/* Event Types Pie Chart */}
{data?.eventsByType && (
<div className="mb-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<PieChart
data={data.eventsByType}
dataKey="count"
nameKey="eventType"
title="Events by Type"
height={300}
/>
<PieChart
data={data.eventsByMethod}
dataKey="distanceKm"
nameKey="method"
title="Distance by Method"
height={300}
/>
</div>
</div>
)}
{/* Tables */}
<div className="grid grid-cols-1 gap-6">
{data?.eventsByMethod && (
<DataTable
data={data.eventsByMethod}
columns={methodColumns}
title="Transport Method Breakdown"
pageSize={8}
/>
)}
{data?.mostEfficientRoutes && (
<DataTable
data={data.mostEfficientRoutes}
columns={routeColumns}
title="Most Efficient Routes"
pageSize={5}
/>
)}
</div>
</div>
</div>
);
}

View file

@ -1,85 +0,0 @@
/**
* API Route: Get lineage analysis for a specific plant
* GET /api/agents/lineage/[plantId]
*/
import type { NextApiRequest, NextApiResponse } from 'next';
import { getPlantLineageAgent } from '../../../../lib/agents';
import { getBlockchain } from '../../../../lib/blockchain/manager';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'GET') {
return res.status(405).json({ success: false, error: 'Method not allowed' });
}
try {
const { plantId } = req.query;
if (!plantId || typeof plantId !== 'string') {
return res.status(400).json({ success: false, error: 'Invalid plant ID' });
}
// Check if plant exists in blockchain
const blockchain = getBlockchain();
const plant = blockchain.findPlant(plantId);
if (!plant) {
return res.status(404).json({ success: false, error: 'Plant not found' });
}
const agent = getPlantLineageAgent();
// Get cached lineage analysis from agent
let analysis = agent.getLineageAnalysis(plantId);
// If not cached, the agent hasn't scanned this plant yet
// Return basic info with a note that full analysis is pending
if (!analysis) {
return res.status(200).json({
success: true,
plantId,
cached: false,
message: 'Lineage analysis pending. Agent will analyze on next scan cycle.',
plant: {
id: plant.id,
species: plant.species,
generation: plant.generation,
status: plant.status,
parentPlantId: plant.parentPlantId,
childPlants: plant.childPlants || [],
},
});
}
res.status(200).json({
success: true,
plantId,
cached: true,
analysis: {
generation: analysis.generation,
ancestors: analysis.ancestors,
descendants: analysis.descendants,
totalLineageSize: analysis.totalLineageSize,
propagationChain: analysis.propagationChain,
geographicSpread: analysis.geographicSpread,
oldestAncestorDate: analysis.oldestAncestorDate,
healthScore: analysis.healthScore,
},
plant: {
id: plant.id,
species: plant.species,
generation: plant.generation,
status: plant.status,
propagationType: plant.propagationType,
location: plant.location,
dateAcquired: plant.dateAcquired,
},
});
} catch (error: any) {
console.error('Error getting lineage analysis:', error);
res.status(500).json({ success: false, error: error.message || 'Internal server error' });
}
}

View file

@ -1,79 +0,0 @@
/**
* API Route: Get lineage anomalies detected by PlantLineageAgent
* GET /api/agents/lineage/anomalies
*/
import type { NextApiRequest, NextApiResponse } from 'next';
import { getPlantLineageAgent } from '../../../../lib/agents';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'GET') {
return res.status(405).json({ success: false, error: 'Method not allowed' });
}
try {
const { severity, type, limit } = req.query;
const agent = getPlantLineageAgent();
let anomalies = agent.getAnomalies();
// Filter by severity if specified
if (severity && typeof severity === 'string') {
const validSeverities = ['low', 'medium', 'high'];
if (!validSeverities.includes(severity)) {
return res.status(400).json({
success: false,
error: `Invalid severity. Must be one of: ${validSeverities.join(', ')}`
});
}
anomalies = anomalies.filter(a => a.severity === severity);
}
// Filter by type if specified
if (type && typeof type === 'string') {
const validTypes = ['orphan', 'circular', 'invalid_generation', 'missing_parent', 'suspicious_location'];
if (!validTypes.includes(type)) {
return res.status(400).json({
success: false,
error: `Invalid type. Must be one of: ${validTypes.join(', ')}`
});
}
anomalies = anomalies.filter(a => a.type === type);
}
// Apply limit if specified
const maxResults = limit ? Math.min(parseInt(limit as string, 10), 100) : 50;
anomalies = anomalies.slice(0, maxResults);
// Group by type for summary
const byType: Record<string, number> = {};
const bySeverity: Record<string, number> = {};
const allAnomalies = agent.getAnomalies();
for (const anomaly of allAnomalies) {
byType[anomaly.type] = (byType[anomaly.type] || 0) + 1;
bySeverity[anomaly.severity] = (bySeverity[anomaly.severity] || 0) + 1;
}
res.status(200).json({
success: true,
summary: {
total: allAnomalies.length,
byType,
bySeverity,
},
filters: {
severity: severity || null,
type: type || null,
limit: maxResults,
},
anomalies,
});
} catch (error: any) {
console.error('Error getting lineage anomalies:', error);
res.status(500).json({ success: false, error: error.message || 'Internal server error' });
}
}

View file

@ -1,92 +0,0 @@
/**
* API Route: PlantLineageAgent Status and Network Stats
* GET /api/agents/lineage - Get agent status and network statistics
* POST /api/agents/lineage - Start/stop/restart the agent
*/
import type { NextApiRequest, NextApiResponse } from 'next';
import { getPlantLineageAgent } from '../../../../lib/agents';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const agent = getPlantLineageAgent();
if (req.method === 'GET') {
try {
const metrics = agent.getMetrics();
const networkStats = agent.getNetworkStats();
const anomalies = agent.getAnomalies();
res.status(200).json({
success: true,
agent: {
id: agent.config.id,
name: agent.config.name,
description: agent.config.description,
status: agent.status,
priority: agent.config.priority,
intervalMs: agent.config.intervalMs,
},
metrics: {
tasksCompleted: metrics.tasksCompleted,
tasksFailed: metrics.tasksFailed,
averageExecutionMs: Math.round(metrics.averageExecutionMs),
lastRunAt: metrics.lastRunAt,
lastSuccessAt: metrics.lastSuccessAt,
uptime: metrics.uptime,
},
networkStats,
anomalySummary: {
total: anomalies.length,
bySeverity: {
high: anomalies.filter(a => a.severity === 'high').length,
medium: anomalies.filter(a => a.severity === 'medium').length,
low: anomalies.filter(a => a.severity === 'low').length,
},
},
});
} catch (error: any) {
console.error('Error getting PlantLineageAgent status:', error);
res.status(500).json({ success: false, error: error.message || 'Internal server error' });
}
} else if (req.method === 'POST') {
try {
const { action } = req.body;
if (!action || !['start', 'stop', 'pause', 'resume'].includes(action)) {
return res.status(400).json({
success: false,
error: 'Invalid action. Must be one of: start, stop, pause, resume'
});
}
switch (action) {
case 'start':
await agent.start();
break;
case 'stop':
await agent.stop();
break;
case 'pause':
agent.pause();
break;
case 'resume':
agent.resume();
break;
}
res.status(200).json({
success: true,
action,
newStatus: agent.status,
});
} catch (error: any) {
console.error('Error controlling PlantLineageAgent:', error);
res.status(500).json({ success: false, error: error.message || 'Internal server error' });
}
} else {
res.status(405).json({ success: false, error: 'Method not allowed' });
}
}

View file

@ -1,80 +0,0 @@
/**
* API Route: TransportTrackerAgent Analytics
* GET /api/agents/transport-tracker/analytics - Get network stats and user analytics
* GET /api/agents/transport-tracker/analytics?userId=xxx - Get specific user analytics
*/
import type { NextApiRequest, NextApiResponse } from 'next';
import { getTransportTrackerAgent } from '../../../../lib/agents/TransportTrackerAgent';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'GET') {
return res.status(405).json({ success: false, error: 'Method not allowed' });
}
try {
const agent = getTransportTrackerAgent();
const { userId } = req.query;
// If userId provided, return user-specific analytics
if (userId && typeof userId === 'string') {
const userAnalysis = agent.getUserAnalysis(userId);
if (!userAnalysis) {
return res.status(404).json({
success: false,
error: `No analytics found for user: ${userId}`
});
}
return res.status(200).json({
success: true,
data: {
user: userAnalysis,
recommendations: userAnalysis.recommendations,
efficiency: {
rating: userAnalysis.efficiency,
carbonPerKm: userAnalysis.carbonPerKm,
totalCarbonKg: userAnalysis.totalCarbonKg
}
}
});
}
// Otherwise, return network-wide analytics
const networkStats = agent.getNetworkStats();
if (!networkStats) {
return res.status(200).json({
success: true,
data: {
message: 'No network statistics available yet. Run the agent to collect data.',
networkStats: null
}
});
}
res.status(200).json({
success: true,
data: {
networkStats,
insights: {
avgCarbonPerEvent: networkStats.avgCarbonPerEvent,
avgDistancePerEvent: networkStats.avgDistancePerEvent,
greenTransportPercentage: networkStats.greenTransportPercentage,
topMethods: Object.entries(networkStats.methodDistribution)
.sort(([, a], [, b]) => (b as number) - (a as number))
.slice(0, 5)
.map(([method, count]) => ({ method, count }))
},
trends: networkStats.dailyTrends
}
});
} catch (error: any) {
console.error('Error fetching transport tracker analytics:', error);
res.status(500).json({ success: false, error: error.message || 'Internal server error' });
}
}

Some files were not shown because too many files have changed in this diff Show more