Merge pull request #11 from vespo92/feature/complete-integration
Some checks failed
CI / Type Check (push) Failing after 40s
CI / Lint & Format (push) Failing after 6m39s
CI / Security Scan (push) Has been skipped
CI / Unit & Integration Tests (push) Failing after 6m57s
CI / Build (push) Has been skipped
CI / E2E Tests (push) Has been skipped
CI / Docker Build (push) Failing after 1m24s

Complete Integration: Merge All 15 Agent Branches
This commit is contained in:
Vinnie Esposito 2025-11-23 12:09:45 -06:00 committed by GitHub
commit baca320262
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
253 changed files with 47524 additions and 93 deletions

77
.dockerignore Normal file
View file

@ -0,0 +1,77 @@
# LocalGreenChain Docker Ignore
# Prevents copying unnecessary files to Docker context
# Dependencies
node_modules
.pnp
.pnp.js
# Testing
coverage
.nyc_output
cypress/videos
cypress/screenshots
__tests__
# Build outputs (we rebuild inside container)
.next
out
build
dist
# Development files
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Environment files (should be passed at runtime)
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE and editor files
.idea
.vscode
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Docker
Dockerfile*
docker-compose*
.docker
# Documentation (not needed in production)
*.md
docs
CHANGELOG.md
README.md
LICENSE
# Misc
.eslintcache
.turbo
*.tsbuildinfo
# Data files (should be mounted as volumes)
data
*.json.bak
# Tor configuration (handled separately)
tor
# Infrastructure files
infra
.github

View file

@ -1,25 +1,124 @@
# =============================================================================
# LocalGreenChain Environment Variables
# Agent 4: Production Deployment
# Copy this file to .env.local and fill in the values
# =============================================================================
# -----------------------------------------------------------------------------
# Application Settings
# -----------------------------------------------------------------------------
NODE_ENV=development
PORT=3001
NEXT_PUBLIC_API_URL=http://localhost:3001
NEXT_PUBLIC_APP_NAME=LocalGreenChain
# -----------------------------------------------------------------------------
# Database (PostgreSQL)
# -----------------------------------------------------------------------------
DATABASE_URL=postgresql://lgc:lgc_password@localhost:5432/localgreenchain
DB_USER=lgc
DB_PASSWORD=lgc_password
DB_NAME=localgreenchain
DB_HOST=localhost
DB_PORT=5432
# -----------------------------------------------------------------------------
# Redis Cache
# -----------------------------------------------------------------------------
REDIS_URL=redis://localhost:6379
REDIS_HOST=localhost
REDIS_PORT=6379
# -----------------------------------------------------------------------------
# Authentication (NextAuth.js)
# Generate secret: openssl rand -base64 32
# -----------------------------------------------------------------------------
NEXTAUTH_URL=http://localhost:3001
NEXTAUTH_SECRET=your-secret-key-change-in-production
# OAuth Providers (optional)
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# -----------------------------------------------------------------------------
# Error Tracking (Sentry)
# -----------------------------------------------------------------------------
SENTRY_DSN=
NEXT_PUBLIC_SENTRY_DSN=
SENTRY_ORG=
SENTRY_PROJECT=
SENTRY_AUTH_TOKEN=
# -----------------------------------------------------------------------------
# Logging
# Levels: error, warn, info, debug, trace
# -----------------------------------------------------------------------------
LOG_LEVEL=info
LOG_FORMAT=json
# -----------------------------------------------------------------------------
# Monitoring
# -----------------------------------------------------------------------------
PROMETHEUS_ENABLED=false
METRICS_PORT=9091
# -----------------------------------------------------------------------------
# Plants.net API (optional)
# -----------------------------------------------------------------------------
PLANTS_NET_API_KEY=your_api_key_here
# Tor Configuration
# -----------------------------------------------------------------------------
# Tor Configuration (optional)
# -----------------------------------------------------------------------------
TOR_ENABLED=false
TOR_SOCKS_HOST=127.0.0.1
TOR_SOCKS_PORT=9050
TOR_CONTROL_PORT=9051
TOR_HIDDEN_SERVICE_DIR=/var/lib/tor/localgreenchain
# -----------------------------------------------------------------------------
# Privacy Settings
# -----------------------------------------------------------------------------
DEFAULT_PRIVACY_MODE=standard
ALLOW_ANONYMOUS_REGISTRATION=true
LOCATION_OBFUSCATION_DEFAULT=fuzzy
# Application Settings
NODE_ENV=development
PORT=3001
# -----------------------------------------------------------------------------
# File Storage (S3/R2/MinIO)
# -----------------------------------------------------------------------------
STORAGE_PROVIDER=local
S3_BUCKET=
S3_REGION=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_ENDPOINT=
# -----------------------------------------------------------------------------
# Email (SMTP)
# -----------------------------------------------------------------------------
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=noreply@localgreenchain.local
# -----------------------------------------------------------------------------
# Rate Limiting
# -----------------------------------------------------------------------------
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX_REQUESTS=100
# -----------------------------------------------------------------------------
# Security
# -----------------------------------------------------------------------------
CORS_ORIGINS=http://localhost:3001
CSP_REPORT_URI=
# -----------------------------------------------------------------------------
# Legacy Drupal Settings (for backward compatibility)
# -----------------------------------------------------------------------------
NEXT_PUBLIC_DRUPAL_BASE_URL=http://localhost:8080
NEXT_IMAGE_DOMAIN=localhost
DRUPAL_CLIENT_ID=52ce1a10-bf5c-4c81-8edf-eea3af95da84

236
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,236 @@
# LocalGreenChain CI Pipeline
# Combined: Agent 4 (Production Deployment) + Agent 5 (Testing)
#
# Runs on every push and pull request:
# - Linting, formatting, and type checking
# - Unit and integration tests
# - E2E tests with Cypress
# - Build verification
# - Docker build (main branch only)
# - Security scanning
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
NODE_ENV: test
NODE_VERSION: '18'
jobs:
# ==========================================================================
# Lint and Type Check
# ==========================================================================
lint:
name: Lint & Format
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run ESLint
run: bun run lint
- name: Check formatting
run: bun run format:check
type-check:
name: Type Check
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run TypeScript type checking
run: bun run type-check
# ==========================================================================
# Unit Tests
# ==========================================================================
test:
name: Unit & Integration Tests
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run tests with coverage
run: bun run test:ci
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: |
coverage/
junit.xml
retention-days: 30
# ==========================================================================
# Build
# ==========================================================================
build:
name: Build
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [lint, type-check, test]
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build application
run: bun run build
env:
NEXT_TELEMETRY_DISABLED: 1
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build-output
path: .next/
retention-days: 7
# ==========================================================================
# E2E Tests
# ==========================================================================
e2e:
name: E2E Tests
runs-on: ubuntu-latest
timeout-minutes: 20
needs: build
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: build-output
path: .next/
- name: Run Cypress tests
uses: cypress-io/github-action@v6
with:
start: bun run start
wait-on: 'http://localhost:3001'
wait-on-timeout: 120
browser: chrome
record: false
- name: Upload Cypress screenshots
uses: actions/upload-artifact@v4
if: failure()
with:
name: cypress-screenshots
path: cypress/screenshots
retention-days: 7
- name: Upload Cypress videos
uses: actions/upload-artifact@v4
if: failure()
with:
name: cypress-videos
path: cypress/videos
retention-days: 7
# ==========================================================================
# Docker Build (only on main branch)
# ==========================================================================
docker:
name: Docker Build
runs-on: ubuntu-latest
timeout-minutes: 20
needs: [build]
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
uses: docker/build-push-action@v5
with:
context: .
push: false
tags: localgreenchain:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ==========================================================================
# Security Scan
# ==========================================================================
security:
name: Security Scan
runs-on: ubuntu-latest
timeout-minutes: 10
needs: [lint]
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run security audit
run: bun pm audit || true
continue-on-error: true
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
severity: 'CRITICAL,HIGH'
exit-code: '0'

169
.github/workflows/deploy.yml vendored Normal file
View file

@ -0,0 +1,169 @@
# LocalGreenChain Production Deployment
# Agent 4: Production Deployment
#
# Deploys to production when a release is published
# or manually triggered
name: Deploy Production
on:
release:
types: [published]
workflow_dispatch:
inputs:
environment:
description: 'Deployment environment'
required: true
default: 'production'
type: choice
options:
- production
- staging
concurrency:
group: deploy-${{ github.event.inputs.environment || 'production' }}
cancel-in-progress: false
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# ==========================================================================
# Build and Push Docker Image
# ==========================================================================
build:
name: Build & Push Image
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
packages: write
outputs:
image_tag: ${{ steps.meta.outputs.tags }}
image_digest: ${{ steps.build.outputs.digest }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Build and push
id: build
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
NEXT_PUBLIC_API_URL=${{ vars.API_URL }}
NEXT_PUBLIC_SENTRY_DSN=${{ vars.SENTRY_DSN }}
# ==========================================================================
# Deploy to Production
# ==========================================================================
deploy:
name: Deploy
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [build]
environment:
name: ${{ github.event.inputs.environment || 'production' }}
url: ${{ vars.APP_URL }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Deploy notification (start)
run: |
echo "🚀 Starting deployment to ${{ github.event.inputs.environment || 'production' }}"
echo "Image: ${{ needs.build.outputs.image_tag }}"
# Add your deployment steps here
# Examples:
# - SSH and docker-compose pull/up
# - Kubernetes deployment
# - Cloud provider specific deployment
- name: Deploy notification (complete)
run: |
echo "✅ Deployment completed successfully"
# ==========================================================================
# Post-Deployment Verification
# ==========================================================================
verify:
name: Verify Deployment
runs-on: ubuntu-latest
timeout-minutes: 10
needs: [deploy]
steps:
- name: Wait for deployment to stabilize
run: sleep 30
- name: Health check
run: |
for i in {1..5}; do
status=$(curl -s -o /dev/null -w "%{http_code}" ${{ vars.APP_URL }}/api/health || echo "000")
if [ "$status" = "200" ]; then
echo "✅ Health check passed"
exit 0
fi
echo "Attempt $i: Status $status, retrying..."
sleep 10
done
echo "❌ Health check failed after 5 attempts"
exit 1
- name: Smoke tests
run: |
# Verify critical endpoints
curl -f ${{ vars.APP_URL }}/api/health/live || exit 1
curl -f ${{ vars.APP_URL }}/api/health/ready || exit 1
echo "✅ Smoke tests passed"
# ==========================================================================
# Rollback on Failure
# ==========================================================================
rollback:
name: Rollback
runs-on: ubuntu-latest
needs: [verify]
if: failure()
steps:
- name: Rollback notification
run: |
echo "⚠️ Deployment verification failed, initiating rollback..."
# Add rollback logic here
- name: Alert team
run: |
echo "🔔 Deployment failed - team has been notified"

139
.github/workflows/preview.yml vendored Normal file
View file

@ -0,0 +1,139 @@
# LocalGreenChain Preview Deployments
# Agent 4: Production Deployment
#
# Creates preview deployments for pull requests
name: Preview Deployment
on:
pull_request:
types: [opened, synchronize, reopened]
concurrency:
group: preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
# ==========================================================================
# Build Preview
# ==========================================================================
build:
name: Build Preview
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build application
run: bun run build
env:
NEXT_TELEMETRY_DISABLED: 1
NEXT_PUBLIC_API_URL: https://preview-${{ github.event.pull_request.number }}.localgreenchain.dev
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: preview-build
path: |
.next/
public/
package.json
next.config.js
retention-days: 7
# ==========================================================================
# Deploy Preview
# ==========================================================================
deploy:
name: Deploy Preview
runs-on: ubuntu-latest
timeout-minutes: 10
needs: [build]
permissions:
pull-requests: write
steps:
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: preview-build
- name: Deploy preview
id: deploy
run: |
# Add your preview deployment logic here
# Examples: Vercel, Netlify, or custom solution
PREVIEW_URL="https://preview-${{ github.event.pull_request.number }}.localgreenchain.dev"
echo "preview_url=${PREVIEW_URL}" >> $GITHUB_OUTPUT
echo "Deployed to: ${PREVIEW_URL}"
- name: Comment on PR
uses: actions/github-script@v7
with:
script: |
const previewUrl = '${{ steps.deploy.outputs.preview_url }}';
// Find existing comment
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.data.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('Preview Deployment')
);
const body = `## 🚀 Preview Deployment
| Status | URL |
|--------|-----|
| ✅ Ready | [${previewUrl}](${previewUrl}) |
**Commit:** \`${context.sha.substring(0, 7)}\`
**Updated:** ${new Date().toISOString()}
---
<sub>This preview will be automatically deleted when the PR is closed.</sub>`;
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: body
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: body
});
}
# ==========================================================================
# Cleanup on PR Close
# ==========================================================================
cleanup:
name: Cleanup Preview
runs-on: ubuntu-latest
if: github.event.action == 'closed'
steps:
- name: Delete preview deployment
run: |
echo "Cleaning up preview deployment for PR #${{ github.event.pull_request.number }}"
# Add your cleanup logic here

36
.husky/_/husky.sh Executable file
View file

@ -0,0 +1,36 @@
#!/usr/bin/env sh
if [ -z "$husky_skip_init" ]; then
debug () {
if [ "$HUSKY_DEBUG" = "1" ]; then
echo "husky (debug) - $1"
fi
}
readonly hook_name="$(basename -- "$0")"
debug "starting $hook_name..."
if [ "$HUSKY" = "0" ]; then
debug "HUSKY env variable is set to 0, skipping hook"
exit 0
fi
if [ -f ~/.huskyrc ]; then
debug "sourcing ~/.huskyrc"
. ~/.huskyrc
fi
readonly husky_skip_init=1
export husky_skip_init
sh -e "$0" "$@"
exitCode="$?"
if [ $exitCode != 0 ]; then
echo "husky - $hook_name hook exited with code $exitCode (error)"
fi
if [ $exitCode = 127 ]; then
echo "husky - command not found in PATH=$PATH"
fi
exit $exitCode
fi

4
.husky/commit-msg Executable file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx --no -- commitlint --edit ${1}

4
.husky/pre-commit Executable file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged

10
.prettierignore Normal file
View file

@ -0,0 +1,10 @@
node_modules/
.next/
out/
coverage/
.git/
*.min.js
*.min.css
bun.lockb
package-lock.json
yarn.lock

11
.prettierrc Normal file
View file

@ -0,0 +1,11 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"tabWidth": 2,
"useTabs": false,
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}

View file

@ -1,40 +1,82 @@
# Dockerfile for LocalGreenChain
# Uses Bun for fast builds and runtime
# Multi-stage production build with Bun runtime
# Agent 4: Production Deployment
FROM oven/bun:1 as base
# =============================================================================
# Stage 1: Dependencies
# =============================================================================
FROM oven/bun:1 AS deps
WORKDIR /app
# Install dependencies
# Install dependencies only (better caching)
COPY package.json bun.lockb* ./
RUN bun install --frozen-lockfile
RUN bun install --frozen-lockfile --production=false
# Copy application code
# =============================================================================
# Stage 2: Builder
# =============================================================================
FROM oven/bun:1 AS builder
WORKDIR /app
# Copy dependencies from deps stage
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build arguments for build-time configuration
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_SENTRY_DSN
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN
# Disable Next.js telemetry during build
ENV NEXT_TELEMETRY_DISABLED=1
# Build Next.js application
RUN bun run build
# Production stage
FROM oven/bun:1-slim as production
# Remove development dependencies
RUN bun install --frozen-lockfile --production
# =============================================================================
# Stage 3: Production Runner
# =============================================================================
FROM oven/bun:1-slim AS production
WORKDIR /app
# Copy dependencies and build output
COPY --from=base /app/node_modules ./node_modules
COPY --from=base /app/.next ./.next
COPY --from=base /app/public ./public
COPY --from=base /app/package.json ./package.json
COPY --from=base /app/next.config.js ./next.config.js
# Create non-root user for security
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Create data directory
RUN mkdir -p /app/data
# Copy necessary files from builder
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/next.config.js ./next.config.js
# Copy Next.js build output with proper ownership
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Create data directory with proper permissions
RUN mkdir -p /app/data && chown -R nextjs:nodejs /app/data
# Set production environment
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3001
ENV HOSTNAME="0.0.0.0"
# Expose port
EXPOSE 3001
# Set environment to production
ENV NODE_ENV=production
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3001/api/health || exit 1
# Switch to non-root user
USER nextjs
# Run the application
CMD ["bun", "run", "start"]

View file

@ -0,0 +1,259 @@
/**
* 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

@ -0,0 +1,180 @@
/**
* Plants API Tests
* Integration tests for plant-related API endpoints
*/
// Mock the blockchain manager
jest.mock('../../lib/blockchain/manager', () => ({
getBlockchain: jest.fn(() => ({
getChain: jest.fn(() => [
// Genesis block
{
index: 0,
timestamp: '2024-01-01T00:00:00Z',
plant: { id: 'genesis' },
previousHash: '0',
hash: 'genesis-hash',
nonce: 0,
},
// Test plants
{
index: 1,
timestamp: '2024-01-02T00:00:00Z',
plant: {
id: 'plant-1',
name: 'Cherry Tomato',
species: 'Tomato',
variety: 'Cherry',
generation: 1,
propagationType: 'original',
status: 'healthy',
location: { latitude: 40.7128, longitude: -74.006 },
},
previousHash: 'genesis-hash',
hash: 'hash-1',
nonce: 1,
},
{
index: 2,
timestamp: '2024-01-03T00:00:00Z',
plant: {
id: 'plant-2',
name: 'Sweet Basil',
species: 'Basil',
variety: 'Genovese',
generation: 1,
propagationType: 'seed',
parentPlantId: 'plant-1',
status: 'thriving',
location: { latitude: 40.7228, longitude: -74.016 },
},
previousHash: 'hash-1',
hash: 'hash-2',
nonce: 2,
},
]),
addPlant: jest.fn((plant) => ({
index: 3,
timestamp: new Date().toISOString(),
plant,
previousHash: 'hash-2',
hash: 'hash-3',
nonce: 3,
})),
findPlant: jest.fn((id) => {
if (id === 'plant-1') {
return {
index: 1,
plant: {
id: 'plant-1',
name: 'Cherry Tomato',
species: 'Tomato',
variety: 'Cherry',
generation: 1,
status: 'healthy',
},
};
}
return undefined;
}),
isValid: jest.fn(() => true),
})),
}));
describe('Plants API', () => {
describe('GET /api/plants', () => {
it('should return plant list', async () => {
const { getBlockchain } = require('../../lib/blockchain/manager');
const blockchain = getBlockchain();
const chain = blockchain.getChain();
expect(chain.length).toBeGreaterThan(1);
expect(chain[1].plant.name).toBe('Cherry Tomato');
});
});
describe('GET /api/plants/[id]', () => {
it('should return plant by ID', async () => {
const { getBlockchain } = require('../../lib/blockchain/manager');
const blockchain = getBlockchain();
const plant = blockchain.findPlant('plant-1');
expect(plant).toBeDefined();
expect(plant.plant.name).toBe('Cherry Tomato');
});
it('should return undefined for non-existent plant', async () => {
const { getBlockchain } = require('../../lib/blockchain/manager');
const blockchain = getBlockchain();
const plant = blockchain.findPlant('non-existent');
expect(plant).toBeUndefined();
});
});
describe('POST /api/plants/register', () => {
it('should register new plant', async () => {
const { getBlockchain } = require('../../lib/blockchain/manager');
const blockchain = getBlockchain();
const newPlant = {
id: 'plant-3',
name: 'New Plant',
species: 'Test',
variety: 'Test',
generation: 1,
propagationType: 'seed',
status: 'healthy',
location: { latitude: 40.7, longitude: -74.0 },
};
const block = blockchain.addPlant(newPlant);
expect(block).toBeDefined();
expect(block.plant.name).toBe('New Plant');
expect(blockchain.addPlant).toHaveBeenCalledWith(newPlant);
});
});
describe('GET /api/plants/network', () => {
it('should return network statistics', async () => {
const { getBlockchain } = require('../../lib/blockchain/manager');
const blockchain = getBlockchain();
const chain = blockchain.getChain();
// Calculate network stats
const plants = chain.slice(1); // Skip genesis
const totalPlants = plants.length;
const speciesCounts: Record<string, number> = {};
plants.forEach((block: any) => {
const species = block.plant.species;
speciesCounts[species] = (speciesCounts[species] || 0) + 1;
});
expect(totalPlants).toBe(2);
expect(speciesCounts['Tomato']).toBe(1);
expect(speciesCounts['Basil']).toBe(1);
});
});
describe('GET /api/plants/lineage/[id]', () => {
it('should return lineage for plant with parent', async () => {
const { getBlockchain } = require('../../lib/blockchain/manager');
const blockchain = getBlockchain();
const chain = blockchain.getChain();
const plant2 = chain[2].plant;
expect(plant2.parentPlantId).toBe('plant-1');
});
});
describe('Blockchain Validation', () => {
it('should validate chain integrity', async () => {
const { getBlockchain } = require('../../lib/blockchain/manager');
const blockchain = getBlockchain();
expect(blockchain.isValid()).toBe(true);
});
});
});

View file

@ -0,0 +1,215 @@
/**
* 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 },
],
};
}

48
__tests__/setup.ts Normal file
View file

@ -0,0 +1,48 @@
/**
* Jest Test Setup
* Global configuration and utilities for all tests
*/
// Extend Jest matchers if needed
// import '@testing-library/jest-dom';
// Mock console methods to reduce noise in tests
const originalConsole = { ...console };
beforeAll(() => {
// Suppress console.log during tests unless DEBUG is set
if (!process.env.DEBUG) {
console.log = jest.fn();
console.info = jest.fn();
}
});
afterAll(() => {
// Restore console
console.log = originalConsole.log;
console.info = originalConsole.info;
});
// Global timeout for async operations
jest.setTimeout(10000);
// Clean up after each test
afterEach(() => {
jest.clearAllMocks();
});
// Utility function for creating mock dates
export function mockDate(date: Date | string): void {
const mockDateValue = new Date(date);
jest.spyOn(global, 'Date').mockImplementation(() => mockDateValue as any);
}
// Utility function for waiting in tests
export function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Utility to create test IDs
export function createTestId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}

View file

@ -0,0 +1,317 @@
/**
* AgentOrchestrator Tests
* Tests for the agent orchestration system
*/
import { AgentOrchestrator, getOrchestrator } from '../../../lib/agents/AgentOrchestrator';
// Mock all agents
jest.mock('../../../lib/agents/PlantLineageAgent', () => ({
PlantLineageAgent: jest.fn().mockImplementation(() => ({
config: { id: 'plant-lineage-agent', name: 'Plant Lineage Agent', priority: 'high' },
status: 'idle',
start: jest.fn().mockResolvedValue(undefined),
stop: jest.fn().mockResolvedValue(undefined),
getMetrics: jest.fn().mockReturnValue({
agentId: 'plant-lineage-agent',
tasksCompleted: 5,
tasksFailed: 0,
averageExecutionMs: 100,
errors: [],
}),
getAlerts: jest.fn().mockReturnValue([]),
})),
getPlantLineageAgent: jest.fn(),
}));
jest.mock('../../../lib/agents/TransportTrackerAgent', () => ({
TransportTrackerAgent: jest.fn().mockImplementation(() => ({
config: { id: 'transport-tracker-agent', name: 'Transport Tracker Agent', priority: 'high' },
status: 'idle',
start: jest.fn().mockResolvedValue(undefined),
stop: jest.fn().mockResolvedValue(undefined),
getMetrics: jest.fn().mockReturnValue({
agentId: 'transport-tracker-agent',
tasksCompleted: 3,
tasksFailed: 1,
averageExecutionMs: 150,
errors: [],
}),
getAlerts: jest.fn().mockReturnValue([]),
})),
getTransportTrackerAgent: jest.fn(),
}));
jest.mock('../../../lib/agents/DemandForecastAgent', () => ({
DemandForecastAgent: jest.fn().mockImplementation(() => ({
config: { id: 'demand-forecast-agent', name: 'Demand Forecast Agent', priority: 'high' },
status: 'idle',
start: jest.fn().mockResolvedValue(undefined),
stop: jest.fn().mockResolvedValue(undefined),
getMetrics: jest.fn().mockReturnValue({
agentId: 'demand-forecast-agent',
tasksCompleted: 2,
tasksFailed: 0,
averageExecutionMs: 200,
errors: [],
}),
getAlerts: jest.fn().mockReturnValue([]),
})),
getDemandForecastAgent: jest.fn(),
}));
jest.mock('../../../lib/agents/VerticalFarmAgent', () => ({
VerticalFarmAgent: jest.fn().mockImplementation(() => ({
config: { id: 'vertical-farm-agent', name: 'Vertical Farm Agent', priority: 'critical' },
status: 'idle',
start: jest.fn().mockResolvedValue(undefined),
stop: jest.fn().mockResolvedValue(undefined),
getMetrics: jest.fn().mockReturnValue({
agentId: 'vertical-farm-agent',
tasksCompleted: 10,
tasksFailed: 0,
averageExecutionMs: 50,
errors: [],
}),
getAlerts: jest.fn().mockReturnValue([]),
})),
getVerticalFarmAgent: jest.fn(),
}));
jest.mock('../../../lib/agents/EnvironmentAnalysisAgent', () => ({
EnvironmentAnalysisAgent: jest.fn().mockImplementation(() => ({
config: { id: 'environment-analysis-agent', name: 'Environment Analysis Agent', priority: 'medium' },
status: 'idle',
start: jest.fn().mockResolvedValue(undefined),
stop: jest.fn().mockResolvedValue(undefined),
getMetrics: jest.fn().mockReturnValue({
agentId: 'environment-analysis-agent',
tasksCompleted: 1,
tasksFailed: 0,
averageExecutionMs: 300,
errors: [],
}),
getAlerts: jest.fn().mockReturnValue([]),
})),
getEnvironmentAnalysisAgent: jest.fn(),
}));
jest.mock('../../../lib/agents/MarketMatchingAgent', () => ({
MarketMatchingAgent: jest.fn().mockImplementation(() => ({
config: { id: 'market-matching-agent', name: 'Market Matching Agent', priority: 'high' },
status: 'idle',
start: jest.fn().mockResolvedValue(undefined),
stop: jest.fn().mockResolvedValue(undefined),
getMetrics: jest.fn().mockReturnValue({
agentId: 'market-matching-agent',
tasksCompleted: 4,
tasksFailed: 0,
averageExecutionMs: 120,
errors: [],
}),
getAlerts: jest.fn().mockReturnValue([]),
})),
getMarketMatchingAgent: jest.fn(),
}));
jest.mock('../../../lib/agents/SustainabilityAgent', () => ({
SustainabilityAgent: jest.fn().mockImplementation(() => ({
config: { id: 'sustainability-agent', name: 'Sustainability Agent', priority: 'medium' },
status: 'idle',
start: jest.fn().mockResolvedValue(undefined),
stop: jest.fn().mockResolvedValue(undefined),
getMetrics: jest.fn().mockReturnValue({
agentId: 'sustainability-agent',
tasksCompleted: 1,
tasksFailed: 0,
averageExecutionMs: 400,
errors: [],
}),
getAlerts: jest.fn().mockReturnValue([]),
})),
getSustainabilityAgent: jest.fn(),
}));
jest.mock('../../../lib/agents/NetworkDiscoveryAgent', () => ({
NetworkDiscoveryAgent: jest.fn().mockImplementation(() => ({
config: { id: 'network-discovery-agent', name: 'Network Discovery Agent', priority: 'medium' },
status: 'idle',
start: jest.fn().mockResolvedValue(undefined),
stop: jest.fn().mockResolvedValue(undefined),
getMetrics: jest.fn().mockReturnValue({
agentId: 'network-discovery-agent',
tasksCompleted: 1,
tasksFailed: 0,
averageExecutionMs: 500,
errors: [],
}),
getAlerts: jest.fn().mockReturnValue([]),
})),
getNetworkDiscoveryAgent: jest.fn(),
}));
jest.mock('../../../lib/agents/QualityAssuranceAgent', () => ({
QualityAssuranceAgent: jest.fn().mockImplementation(() => ({
config: { id: 'quality-assurance-agent', name: 'Quality Assurance Agent', priority: 'critical' },
status: 'idle',
start: jest.fn().mockResolvedValue(undefined),
stop: jest.fn().mockResolvedValue(undefined),
getMetrics: jest.fn().mockReturnValue({
agentId: 'quality-assurance-agent',
tasksCompleted: 8,
tasksFailed: 0,
averageExecutionMs: 80,
errors: [],
}),
getAlerts: jest.fn().mockReturnValue([]),
})),
getQualityAssuranceAgent: jest.fn(),
}));
jest.mock('../../../lib/agents/GrowerAdvisoryAgent', () => ({
GrowerAdvisoryAgent: jest.fn().mockImplementation(() => ({
config: { id: 'grower-advisory-agent', name: 'Grower Advisory Agent', priority: 'high' },
status: 'idle',
start: jest.fn().mockResolvedValue(undefined),
stop: jest.fn().mockResolvedValue(undefined),
getMetrics: jest.fn().mockReturnValue({
agentId: 'grower-advisory-agent',
tasksCompleted: 6,
tasksFailed: 1,
averageExecutionMs: 180,
errors: [],
}),
getAlerts: jest.fn().mockReturnValue([]),
})),
getGrowerAdvisoryAgent: jest.fn(),
}));
describe('AgentOrchestrator', () => {
let orchestrator: AgentOrchestrator;
beforeEach(() => {
// Reset singleton
(globalThis as any).__orchestratorInstance = undefined;
orchestrator = new AgentOrchestrator();
});
afterEach(async () => {
const status = orchestrator.getStatus();
if (status.isRunning) {
await orchestrator.stopAll();
}
});
describe('Initialization', () => {
it('should initialize with all 10 agents', () => {
const status = orchestrator.getStatus();
expect(status.totalAgents).toBe(10);
});
it('should not be running initially', () => {
const status = orchestrator.getStatus();
expect(status.isRunning).toBe(false);
});
it('should have no running agents initially', () => {
const status = orchestrator.getStatus();
expect(status.runningAgents).toBe(0);
});
});
describe('Starting Agents', () => {
it('should start all agents', async () => {
await orchestrator.startAll();
const status = orchestrator.getStatus();
expect(status.isRunning).toBe(true);
});
it('should start individual agent', async () => {
await orchestrator.startAgent('plant-lineage-agent');
const health = orchestrator.getAgentHealth('plant-lineage-agent');
expect(health).toBeDefined();
});
});
describe('Stopping Agents', () => {
it('should stop all agents', async () => {
await orchestrator.startAll();
await orchestrator.stopAll();
const status = orchestrator.getStatus();
expect(status.isRunning).toBe(false);
});
it('should stop individual agent', async () => {
await orchestrator.startAgent('plant-lineage-agent');
await orchestrator.stopAgent('plant-lineage-agent');
// Agent should be stopped
});
});
describe('Agent Health', () => {
it('should return health for existing agent', async () => {
await orchestrator.startAll();
const health = orchestrator.getAgentHealth('plant-lineage-agent');
expect(health).toHaveProperty('agentId');
expect(health?.agentId).toBe('plant-lineage-agent');
});
it('should return undefined for non-existent agent', () => {
const health = orchestrator.getAgentHealth('non-existent-agent');
expect(health).toBeUndefined();
});
});
describe('Status', () => {
it('should return correct status structure', () => {
const status = orchestrator.getStatus();
expect(status).toHaveProperty('isRunning');
expect(status).toHaveProperty('totalAgents');
expect(status).toHaveProperty('runningAgents');
expect(status).toHaveProperty('healthyAgents');
});
it('should track uptime when running', async () => {
await orchestrator.startAll();
await new Promise((resolve) => setTimeout(resolve, 50));
const status = orchestrator.getStatus();
expect(status.uptime).toBeGreaterThan(0);
});
});
describe('Dashboard', () => {
it('should return dashboard data', async () => {
await orchestrator.startAll();
const dashboard = orchestrator.getDashboard();
expect(dashboard).toHaveProperty('status');
expect(dashboard).toHaveProperty('agents');
expect(dashboard).toHaveProperty('recentAlerts');
});
it('should include all agents in dashboard', async () => {
await orchestrator.startAll();
const dashboard = orchestrator.getDashboard();
expect(dashboard.agents.length).toBe(10);
});
});
describe('Alerts', () => {
it('should return empty alerts initially', () => {
const alerts = orchestrator.getAlerts();
expect(alerts).toEqual([]);
});
it('should filter alerts by severity', () => {
const criticalAlerts = orchestrator.getAlerts('critical');
expect(Array.isArray(criticalAlerts)).toBe(true);
});
});
describe('Singleton', () => {
it('should return same instance from getOrchestrator', () => {
const orch1 = getOrchestrator();
const orch2 = getOrchestrator();
expect(orch1).toBe(orch2);
});
});
});

View file

@ -0,0 +1,268 @@
/**
* BaseAgent Tests
* Tests for the abstract base agent class
*/
import { BaseAgent } from '../../../lib/agents/BaseAgent';
import { AgentConfig, AgentTask, AgentStatus } from '../../../lib/agents/types';
// Concrete implementation for testing
class TestAgent extends BaseAgent {
public runOnceResult: AgentTask | null = null;
public runOnceError: Error | null = null;
public runOnceCallCount = 0;
constructor(config?: Partial<AgentConfig>) {
super({
id: 'test-agent',
name: 'Test Agent',
description: 'Agent for testing',
enabled: true,
intervalMs: 100,
priority: 'medium',
maxRetries: 3,
timeoutMs: 5000,
...config,
});
}
async runOnce(): Promise<AgentTask | null> {
this.runOnceCallCount++;
if (this.runOnceError) {
throw this.runOnceError;
}
return this.runOnceResult;
}
// Expose protected methods for testing
public testCreateAlert(
severity: 'info' | 'warning' | 'error' | 'critical',
title: string,
message: string
) {
return this.createAlert(severity, title, message);
}
public testHandleError(error: Error) {
return this.handleError(error);
}
public testAddTask(type: string, payload: Record<string, any>) {
return this.addTask(type, payload);
}
public testCreateTaskResult(
type: string,
status: 'completed' | 'failed',
result?: any
) {
return this.createTaskResult(type, status, result);
}
}
describe('BaseAgent', () => {
let agent: TestAgent;
beforeEach(() => {
agent = new TestAgent();
jest.clearAllMocks();
});
afterEach(async () => {
if (agent.status === 'running') {
await agent.stop();
}
});
describe('Initialization', () => {
it('should initialize with correct config', () => {
expect(agent.config.id).toBe('test-agent');
expect(agent.config.name).toBe('Test Agent');
expect(agent.config.enabled).toBe(true);
});
it('should initialize with idle status', () => {
expect(agent.status).toBe('idle');
});
it('should initialize with empty metrics', () => {
const metrics = agent.getMetrics();
expect(metrics.tasksCompleted).toBe(0);
expect(metrics.tasksFailed).toBe(0);
expect(metrics.averageExecutionMs).toBe(0);
expect(metrics.errors).toEqual([]);
});
it('should initialize with empty alerts', () => {
expect(agent.getAlerts()).toEqual([]);
});
});
describe('Lifecycle', () => {
it('should start and update status to running', async () => {
agent.runOnceResult = agent.testCreateTaskResult('test', 'completed');
await agent.start();
expect(agent.status).toBe('running');
});
it('should not start twice', async () => {
agent.runOnceResult = agent.testCreateTaskResult('test', 'completed');
await agent.start();
const firstCallCount = agent.runOnceCallCount;
await agent.start(); // Second call
expect(agent.runOnceCallCount).toBe(firstCallCount);
});
it('should stop and update status to idle', async () => {
agent.runOnceResult = agent.testCreateTaskResult('test', 'completed');
await agent.start();
await agent.stop();
expect(agent.status).toBe('idle');
});
it('should pause and resume', async () => {
agent.runOnceResult = agent.testCreateTaskResult('test', 'completed');
await agent.start();
agent.pause();
expect(agent.status).toBe('paused');
agent.resume();
expect(agent.status).toBe('running');
});
it('should not pause if not running', () => {
agent.pause();
expect(agent.status).toBe('idle');
});
it('should not resume if not paused', () => {
agent.resume();
expect(agent.status).toBe('idle');
});
});
describe('Task Execution', () => {
it('should run task on start', async () => {
agent.runOnceResult = agent.testCreateTaskResult('test', 'completed');
await agent.start();
expect(agent.runOnceCallCount).toBe(1);
await agent.stop();
});
it('should increment tasksCompleted on success', async () => {
agent.runOnceResult = agent.testCreateTaskResult('test', 'completed', { data: 'test' });
await agent.start();
await agent.stop();
expect(agent.getMetrics().tasksCompleted).toBe(1);
});
it('should increment tasksFailed on failure', async () => {
agent.runOnceResult = agent.testCreateTaskResult('test', 'failed', null);
await agent.start();
await agent.stop();
expect(agent.getMetrics().tasksFailed).toBe(1);
});
it('should update lastRunAt after execution', async () => {
agent.runOnceResult = agent.testCreateTaskResult('test', 'completed');
await agent.start();
await agent.stop();
expect(agent.getMetrics().lastRunAt).not.toBeNull();
});
});
describe('Error Handling', () => {
it('should handle thrown errors', async () => {
agent.runOnceError = new Error('Test error');
await agent.start();
await agent.stop();
const metrics = agent.getMetrics();
expect(metrics.errors.length).toBe(1);
expect(metrics.errors[0].message).toBe('Test error');
});
it('should record error timestamp', async () => {
agent.testHandleError(new Error('Test error'));
const metrics = agent.getMetrics();
expect(metrics.lastErrorAt).not.toBeNull();
});
it('should limit errors to 50', () => {
for (let i = 0; i < 60; i++) {
agent.testHandleError(new Error(`Error ${i}`));
}
expect(agent.getMetrics().errors.length).toBe(50);
});
});
describe('Alerts', () => {
it('should create alerts with correct structure', () => {
const alert = agent.testCreateAlert('warning', 'Test Alert', 'Test message');
expect(alert.id).toBeDefined();
expect(alert.severity).toBe('warning');
expect(alert.title).toBe('Test Alert');
expect(alert.message).toBe('Test message');
expect(alert.acknowledged).toBe(false);
});
it('should add alerts to the list', () => {
agent.testCreateAlert('info', 'Alert 1', 'Message 1');
agent.testCreateAlert('warning', 'Alert 2', 'Message 2');
expect(agent.getAlerts().length).toBe(2);
});
it('should limit alerts to 100', () => {
for (let i = 0; i < 110; i++) {
agent.testCreateAlert('info', `Alert ${i}`, 'Message');
}
expect(agent.getAlerts().length).toBe(100);
});
});
describe('Metrics', () => {
it('should calculate uptime when running', async () => {
agent.runOnceResult = agent.testCreateTaskResult('test', 'completed');
await agent.start();
await new Promise((resolve) => setTimeout(resolve, 50));
const metrics = agent.getMetrics();
expect(metrics.uptime).toBeGreaterThan(0);
await agent.stop();
});
it('should calculate average execution time', async () => {
agent.runOnceResult = agent.testCreateTaskResult('test', 'completed');
await agent.start();
await agent.stop();
const metrics = agent.getMetrics();
expect(metrics.averageExecutionMs).toBeGreaterThanOrEqual(0);
});
});
describe('Task Queue', () => {
it('should add tasks with unique IDs', () => {
const task1 = agent.testAddTask('type1', { key: 'value1' });
const task2 = agent.testAddTask('type2', { key: 'value2' });
expect(task1.id).not.toBe(task2.id);
});
it('should set correct task properties', () => {
const task = agent.testAddTask('test-type', { data: 'value' });
expect(task.type).toBe('test-type');
expect(task.payload).toEqual({ data: 'value' });
expect(task.status).toBe('pending');
expect(task.agentId).toBe('test-agent');
});
});
describe('Task Results', () => {
it('should create completed task result', () => {
const result = agent.testCreateTaskResult('scan', 'completed', { count: 10 });
expect(result.status).toBe('completed');
expect(result.result).toEqual({ count: 10 });
});
it('should create failed task result', () => {
const result = agent.testCreateTaskResult('scan', 'failed', null);
expect(result.status).toBe('failed');
});
});
});

View file

@ -0,0 +1,241 @@
/**
* PlantLineageAgent Tests
* Tests for the plant lineage tracking agent
*/
import { PlantLineageAgent, getPlantLineageAgent } from '../../../lib/agents/PlantLineageAgent';
// Mock the blockchain manager
jest.mock('../../../lib/blockchain/manager', () => ({
getBlockchain: jest.fn(() => ({
getChain: jest.fn(() => [
// Genesis block
{
index: 0,
timestamp: '2024-01-01T00:00:00Z',
plant: { id: 'genesis' },
previousHash: '0',
hash: 'genesis-hash',
nonce: 0,
},
// First generation plant
{
index: 1,
timestamp: '2024-01-02T00:00:00Z',
plant: {
id: 'plant-1',
name: 'Original Tomato',
species: 'Tomato',
variety: 'Cherry',
generation: 1,
propagationType: 'original',
parentPlantId: undefined,
childPlants: ['plant-2'],
status: 'thriving',
dateAcquired: '2024-01-02',
location: { latitude: 40.7128, longitude: -74.006 },
environment: { light: 'full_sun' },
growthMetrics: { height: 50 },
},
previousHash: 'genesis-hash',
hash: 'hash-1',
nonce: 1,
},
// Second generation plant
{
index: 2,
timestamp: '2024-01-15T00:00:00Z',
plant: {
id: 'plant-2',
name: 'Cloned Tomato',
species: 'Tomato',
variety: 'Cherry',
generation: 2,
propagationType: 'cutting',
parentPlantId: 'plant-1',
childPlants: ['plant-3'],
status: 'healthy',
dateAcquired: '2024-01-15',
location: { latitude: 40.73, longitude: -73.99 },
environment: { light: 'partial_sun' },
growthMetrics: { height: 30 },
},
previousHash: 'hash-1',
hash: 'hash-2',
nonce: 2,
},
// Third generation plant
{
index: 3,
timestamp: '2024-02-01T00:00:00Z',
plant: {
id: 'plant-3',
name: 'Third Gen Tomato',
species: 'Tomato',
variety: 'Cherry',
generation: 3,
propagationType: 'seed',
parentPlantId: 'plant-2',
childPlants: [],
status: 'healthy',
dateAcquired: '2024-02-01',
location: { latitude: 40.75, longitude: -73.98 },
environment: { light: 'full_sun' },
growthMetrics: { height: 20 },
},
previousHash: 'hash-2',
hash: 'hash-3',
nonce: 3,
},
]),
})),
}));
describe('PlantLineageAgent', () => {
let agent: PlantLineageAgent;
beforeEach(() => {
agent = new PlantLineageAgent();
});
afterEach(async () => {
if (agent.status === 'running') {
await agent.stop();
}
});
describe('Initialization', () => {
it('should initialize with correct config', () => {
expect(agent.config.id).toBe('plant-lineage-agent');
expect(agent.config.name).toBe('Plant Lineage Agent');
expect(agent.config.priority).toBe('high');
expect(agent.config.intervalMs).toBe(60000);
});
it('should start in idle status', () => {
expect(agent.status).toBe('idle');
});
});
describe('runOnce', () => {
it('should complete a scan cycle', async () => {
const result = await agent.runOnce();
expect(result).not.toBeNull();
expect(result?.status).toBe('completed');
expect(result?.type).toBe('lineage_scan');
});
it('should scan plants and update cache', async () => {
await agent.runOnce();
expect(agent.getLineageAnalysis('plant-1')).not.toBeNull();
expect(agent.getLineageAnalysis('plant-2')).not.toBeNull();
expect(agent.getLineageAnalysis('plant-3')).not.toBeNull();
});
it('should return scan statistics', async () => {
const result = await agent.runOnce();
expect(result?.result).toHaveProperty('plantsScanned');
expect(result?.result).toHaveProperty('anomaliesFound');
expect(result?.result).toHaveProperty('cacheSize');
expect(result?.result.plantsScanned).toBe(3);
});
});
describe('Lineage Analysis', () => {
beforeEach(async () => {
await agent.runOnce();
});
it('should find ancestors correctly', () => {
const analysis = agent.getLineageAnalysis('plant-3');
expect(analysis?.ancestors).toContain('plant-2');
expect(analysis?.ancestors).toContain('plant-1');
expect(analysis?.ancestors.length).toBe(2);
});
it('should find descendants correctly', () => {
const analysis = agent.getLineageAnalysis('plant-1');
expect(analysis?.descendants).toContain('plant-2');
expect(analysis?.descendants).toContain('plant-3');
});
it('should track generation depth', () => {
const analysis1 = agent.getLineageAnalysis('plant-1');
const analysis3 = agent.getLineageAnalysis('plant-3');
expect(analysis1?.generation).toBe(1);
expect(analysis3?.generation).toBe(3);
});
it('should calculate lineage size', () => {
const analysis = agent.getLineageAnalysis('plant-2');
// plant-2 has 1 ancestor (plant-1) and 1 descendant (plant-3) + itself = 3
expect(analysis?.totalLineageSize).toBe(3);
});
it('should build propagation chain', () => {
const analysis = agent.getLineageAnalysis('plant-3');
expect(analysis?.propagationChain).toEqual(['original', 'cutting', 'seed']);
});
it('should calculate health score', () => {
const analysis = agent.getLineageAnalysis('plant-1');
expect(analysis?.healthScore).toBeGreaterThan(0);
expect(analysis?.healthScore).toBeLessThanOrEqual(100);
});
it('should return null for non-existent plant', () => {
const analysis = agent.getLineageAnalysis('non-existent');
expect(analysis).toBeNull();
});
});
describe('Network Statistics', () => {
beforeEach(async () => {
await agent.runOnce();
});
it('should calculate total plants', () => {
const stats = agent.getNetworkStats();
expect(stats.totalPlants).toBe(3);
});
it('should calculate total lineages (root plants)', () => {
const stats = agent.getNetworkStats();
expect(stats.totalLineages).toBe(1); // Only plant-1 has no ancestors
});
it('should calculate average generation depth', () => {
const stats = agent.getNetworkStats();
// (1 + 2 + 3) / 3 = 2
expect(stats.avgGenerationDepth).toBe(2);
});
it('should return empty stats when no data', () => {
const emptyAgent = new PlantLineageAgent();
const stats = emptyAgent.getNetworkStats();
expect(stats.totalPlants).toBe(0);
expect(stats.totalLineages).toBe(0);
});
});
describe('Anomaly Detection', () => {
it('should return empty anomalies initially', () => {
expect(agent.getAnomalies()).toEqual([]);
});
it('should detect anomalies during scan', async () => {
await agent.runOnce();
const anomalies = agent.getAnomalies();
// The mock data is valid, so no anomalies should be detected
expect(Array.isArray(anomalies)).toBe(true);
});
});
describe('Singleton', () => {
it('should return same instance from getPlantLineageAgent', () => {
const agent1 = getPlantLineageAgent();
const agent2 = getPlantLineageAgent();
expect(agent1).toBe(agent2);
});
});
});

View file

@ -0,0 +1,169 @@
/**
* PlantChain Tests
* Tests for the blockchain implementation
*/
import { PlantChain } from '../../../lib/blockchain/PlantChain';
import { PlantData } from '../../../lib/blockchain/types';
describe('PlantChain', () => {
let chain: PlantChain;
beforeEach(() => {
chain = new PlantChain();
});
describe('Initialization', () => {
it('should create chain with genesis block', () => {
expect(chain.getChain().length).toBe(1);
});
it('should have valid genesis block', () => {
const genesisBlock = chain.getChain()[0];
expect(genesisBlock.index).toBe(0);
expect(genesisBlock.previousHash).toBe('0');
});
});
describe('Adding Plants', () => {
const createTestPlant = (overrides?: Partial<PlantData>): PlantData => ({
id: `plant-${Date.now()}`,
name: 'Test Plant',
species: 'Test Species',
variety: 'Test Variety',
generation: 1,
propagationType: 'original',
dateAcquired: new Date().toISOString(),
location: {
latitude: 40.7128,
longitude: -74.006,
city: 'New York',
},
status: 'healthy',
...overrides,
});
it('should add new plant to chain', () => {
const plant = createTestPlant();
chain.addPlant(plant);
expect(chain.getChain().length).toBe(2);
});
it('should generate valid block hash', () => {
const plant = createTestPlant();
chain.addPlant(plant);
const newBlock = chain.getChain()[1];
expect(newBlock.hash).toBeDefined();
expect(newBlock.hash.length).toBeGreaterThan(0);
});
it('should link blocks correctly', () => {
const plant = createTestPlant();
chain.addPlant(plant);
const genesisBlock = chain.getChain()[0];
const newBlock = chain.getChain()[1];
expect(newBlock.previousHash).toBe(genesisBlock.hash);
});
it('should store plant data correctly', () => {
const plant = createTestPlant({ name: 'My Tomato' });
chain.addPlant(plant);
const newBlock = chain.getChain()[1];
expect(newBlock.plant.name).toBe('My Tomato');
});
it('should add multiple plants', () => {
chain.addPlant(createTestPlant({ id: 'plant-1' }));
chain.addPlant(createTestPlant({ id: 'plant-2' }));
chain.addPlant(createTestPlant({ id: 'plant-3' }));
expect(chain.getChain().length).toBe(4);
});
});
describe('Finding Plants', () => {
beforeEach(() => {
chain.addPlant({
id: 'tomato-1',
name: 'Cherry Tomato',
species: 'Tomato',
variety: 'Cherry',
generation: 1,
propagationType: 'original',
dateAcquired: new Date().toISOString(),
location: { latitude: 40.7, longitude: -74.0 },
status: 'healthy',
});
chain.addPlant({
id: 'basil-1',
name: 'Sweet Basil',
species: 'Basil',
variety: 'Genovese',
generation: 1,
propagationType: 'seed',
dateAcquired: new Date().toISOString(),
location: { latitude: 40.8, longitude: -73.9 },
status: 'thriving',
});
});
it('should find plant by ID', () => {
const block = chain.findPlant('tomato-1');
expect(block).toBeDefined();
expect(block?.plant.name).toBe('Cherry Tomato');
});
it('should return undefined for non-existent plant', () => {
const block = chain.findPlant('non-existent');
expect(block).toBeUndefined();
});
});
describe('Chain Validation', () => {
it('should validate empty chain (genesis only)', () => {
expect(chain.isValid()).toBe(true);
});
it('should validate chain with plants', () => {
chain.addPlant({
id: 'plant-1',
name: 'Test Plant',
species: 'Test',
variety: 'Test',
generation: 1,
propagationType: 'original',
dateAcquired: new Date().toISOString(),
location: { latitude: 40.7, longitude: -74.0 },
status: 'healthy',
});
expect(chain.isValid()).toBe(true);
});
});
describe('Serialization', () => {
beforeEach(() => {
chain.addPlant({
id: 'plant-1',
name: 'Test Plant',
species: 'Test',
variety: 'Test',
generation: 1,
propagationType: 'original',
dateAcquired: new Date().toISOString(),
location: { latitude: 40.7, longitude: -74.0 },
status: 'healthy',
});
});
it('should export to JSON', () => {
const json = chain.toJSON();
expect(json).toBeDefined();
expect(Array.isArray(json)).toBe(true);
});
it('should import from JSON', () => {
const json = chain.toJSON();
const restored = PlantChain.fromJSON(json);
expect(restored.getChain().length).toBe(chain.getChain().length);
});
});
});

178
bun.lock
View file

@ -9,6 +9,8 @@
"@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",
"next": "^12.2.3",
@ -17,10 +19,12 @@
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-hook-form": "^7.8.6",
"recharts": "^3.4.1",
"socks-proxy-agent": "^8.0.2",
},
"devDependencies": {
"@babel/core": "^7.12.9",
"@types/d3": "^7.4.3",
"@types/jest": "^29.5.0",
"@types/node": "^17.0.21",
"@types/react": "^17.0.0",
@ -199,6 +203,8 @@
"@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=="],
@ -209,6 +215,10 @@
"@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=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@swc/helpers": ["@swc/helpers@0.4.11", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-rEUrBSGIoSFuYxwBYtlUFMlE2CwGhmW+w9355/5oduSw8e5h2+Tj4UrAGNNgP9915++wj5vkQo0UuOBqOAq4nw=="],
"@tailwindcss/forms": ["@tailwindcss/forms@0.4.1", "", { "dependencies": { "mini-svg-data-uri": "^1.2.3" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1" } }, "sha512-gS9xjCmJjUBz/eP12QlENPLnf0tCx68oYE3mri0GMP5jdtVwLbGUNSRpjsp6NzLAZzZy3ueOwrcqB78Ax6Z84A=="],
@ -227,6 +237,70 @@
"@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/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="],
"@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/graceful-fs": ["@types/graceful-fs@4.1.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ=="],
"@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="],
@ -249,6 +323,8 @@
"@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="],
"@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="],
@ -377,6 +453,8 @@
"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=="],
@ -399,6 +477,68 @@
"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=="],
@ -407,8 +547,12 @@
"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=="],
@ -419,6 +563,8 @@
"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-newline": ["detect-newline@3.1.0", "", {}, "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
@ -469,6 +615,8 @@
"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=="],
@ -507,6 +655,8 @@
"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=="],
@ -609,8 +759,12 @@
"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=="],
@ -625,6 +779,8 @@
"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=="],
@ -953,16 +1109,26 @@
"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=="],
"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=="],
@ -975,14 +1141,20 @@
"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-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=="],
@ -1075,6 +1247,8 @@
"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=="],
@ -1121,6 +1295,8 @@
"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=="],
@ -1173,6 +1349,8 @@
"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=="],

25
commitlint.config.js Normal file
View file

@ -0,0 +1,25 @@
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [
2,
'always',
[
'feat', // New feature
'fix', // Bug fix
'docs', // Documentation
'style', // Code style (formatting, semicolons, etc.)
'refactor', // Code refactoring
'perf', // Performance improvement
'test', // Adding or updating tests
'build', // Build system or dependencies
'ci', // CI configuration
'chore', // Maintenance tasks
'revert', // Revert a previous commit
],
],
'subject-case': [2, 'always', 'lower-case'],
'subject-max-length': [2, 'always', 72],
'body-max-line-length': [2, 'always', 100],
},
};

View file

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

View file

@ -0,0 +1,229 @@
/**
* 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

@ -0,0 +1,47 @@
/**
* 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

@ -0,0 +1,165 @@
/**
* 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

@ -0,0 +1,129 @@
/**
* 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

@ -0,0 +1,105 @@
/**
* 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

@ -0,0 +1,98 @@
/**
* 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

@ -0,0 +1,99 @@
/**
* 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

@ -0,0 +1,91 @@
/**
* 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

@ -0,0 +1,134 @@
/**
* 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

@ -0,0 +1,85 @@
/**
* 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

@ -0,0 +1,123 @@
/**
* 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

@ -0,0 +1,11 @@
/**
* 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,3 +1,19 @@
/**
* 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

@ -0,0 +1,128 @@
import { useEffect } from 'react'
import { useRouter } from 'next/router'
import { useAuth } from '@/lib/auth/useAuth'
import { UserRole } from '@/lib/auth/types'
import { hasRole, hasPermission } from '@/lib/auth/permissions'
interface AuthGuardProps {
children: React.ReactNode
requiredRole?: UserRole
requiredPermission?: string
fallback?: React.ReactNode
redirectTo?: string
}
export function AuthGuard({
children,
requiredRole,
requiredPermission,
fallback,
redirectTo = '/auth/signin',
}: AuthGuardProps) {
const router = useRouter()
const { user, isAuthenticated, isLoading } = useAuth()
useEffect(() => {
if (!isLoading && !isAuthenticated) {
const returnUrl = encodeURIComponent(router.asPath)
router.push(`${redirectTo}?callbackUrl=${returnUrl}`)
}
}, [isLoading, isAuthenticated, router, redirectTo])
// Show loading state
if (isLoading) {
return (
fallback || (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading...</p>
</div>
</div>
)
)
}
// Not authenticated
if (!isAuthenticated) {
return (
fallback || (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<p className="text-gray-600">Redirecting to sign in...</p>
</div>
</div>
)
)
}
// Check role requirement
if (requiredRole && user) {
if (!hasRole(user.role, requiredRole)) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center max-w-md mx-auto p-8">
<div className="text-red-500 text-6xl mb-4">403</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Access Denied</h1>
<p className="text-gray-600 mb-4">
You don't have permission to access this page. This page requires{' '}
<span className="font-medium">{requiredRole}</span> role or higher.
</p>
<button
onClick={() => router.back()}
className="text-green-600 hover:text-green-500 font-medium"
>
Go back
</button>
</div>
</div>
)
}
}
// Check permission requirement
if (requiredPermission && user) {
if (!hasPermission(user.role, requiredPermission)) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center max-w-md mx-auto p-8">
<div className="text-red-500 text-6xl mb-4">403</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Access Denied</h1>
<p className="text-gray-600 mb-4">
You don't have the required permission to access this page.
</p>
<button
onClick={() => router.back()}
className="text-green-600 hover:text-green-500 font-medium"
>
Go back
</button>
</div>
</div>
)
}
}
return <>{children}</>
}
// Higher-order component version
export function withAuthGuard<P extends object>(
Component: React.ComponentType<P>,
options?: {
requiredRole?: UserRole
requiredPermission?: string
fallback?: React.ReactNode
redirectTo?: string
}
) {
return function AuthGuardedComponent(props: P) {
return (
<AuthGuard {...options}>
<Component {...props} />
</AuthGuard>
)
}
}
export default AuthGuard

View file

@ -0,0 +1,132 @@
import { useState } from 'react'
import { signIn } from 'next-auth/react'
import Link from 'next/link'
interface LoginFormProps {
callbackUrl?: string
onSuccess?: () => void
onError?: (error: string) => void
}
export function LoginForm({ callbackUrl = '/', onSuccess, onError }: LoginFormProps) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError(null)
try {
const result = await signIn('credentials', {
email,
password,
redirect: false,
})
if (result?.error) {
const errorMessage = getErrorMessage(result.error)
setError(errorMessage)
onError?.(errorMessage)
} else if (result?.ok) {
onSuccess?.()
if (callbackUrl) {
window.location.href = callbackUrl
}
}
} catch (err) {
const errorMessage = 'An unexpected error occurred'
setError(errorMessage)
onError?.(errorMessage)
} finally {
setIsLoading(false)
}
}
return (
<form className="space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded relative text-sm">
{error}
</div>
)}
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="login-email" className="sr-only">
Email address
</label>
<input
id="login-email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-green-500 focus:border-green-500 focus:z-10 sm:text-sm"
placeholder="Email address"
/>
</div>
<div>
<label htmlFor="login-password" className="sr-only">
Password
</label>
<input
id="login-password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-green-500 focus:border-green-500 focus:z-10 sm:text-sm"
placeholder="Password"
/>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
id="remember-me"
name="remember-me"
type="checkbox"
className="h-4 w-4 text-green-600 focus:ring-green-500 border-gray-300 rounded"
/>
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900">
Remember me
</label>
</div>
<div className="text-sm">
<Link href="/auth/forgot-password">
<a className="font-medium text-green-600 hover:text-green-500">
Forgot password?
</a>
</Link>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Signing in...' : 'Sign in'}
</button>
</form>
)
}
function getErrorMessage(error: string): string {
const errorMessages: Record<string, string> = {
CredentialsSignin: 'Invalid email or password',
default: 'An error occurred during sign in',
}
return errorMessages[error] ?? errorMessages.default
}
export default LoginForm

View file

@ -0,0 +1,142 @@
import { useState } from 'react'
interface PasswordResetFormProps {
token: string
onSuccess?: () => void
onError?: (error: string) => void
}
export function PasswordResetForm({ token, onSuccess, onError }: PasswordResetFormProps) {
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const validatePassword = (): string | null => {
if (password.length < 8) {
return 'Password must be at least 8 characters long'
}
const hasUpperCase = /[A-Z]/.test(password)
const hasLowerCase = /[a-z]/.test(password)
const hasNumbers = /\d/.test(password)
if (!hasUpperCase || !hasLowerCase || !hasNumbers) {
return 'Password must contain uppercase, lowercase, and numbers'
}
if (password !== confirmPassword) {
return 'Passwords do not match'
}
return null
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError(null)
const validationError = validatePassword()
if (validationError) {
setError(validationError)
setIsLoading(false)
onError?.(validationError)
return
}
try {
const response = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password }),
})
const data = await response.json()
if (!response.ok) {
setError(data.message || 'An error occurred')
onError?.(data.message || 'An error occurred')
return
}
setSuccess(true)
onSuccess?.()
} catch (err) {
const errorMessage = 'An unexpected error occurred'
setError(errorMessage)
onError?.(errorMessage)
} finally {
setIsLoading(false)
}
}
if (success) {
return (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-8 rounded-lg text-center">
<h3 className="text-lg font-medium mb-2">Password Reset Successful!</h3>
<p>Your password has been reset successfully.</p>
</div>
)
}
return (
<form className="space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded relative text-sm">
{error}
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="new-password" className="block text-sm font-medium text-gray-700">
New Password
</label>
<input
id="new-password"
name="password"
type="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
placeholder="At least 8 characters"
/>
<p className="mt-1 text-xs text-gray-500">
Must contain uppercase, lowercase, and numbers
</p>
</div>
<div>
<label htmlFor="confirm-new-password" className="block text-sm font-medium text-gray-700">
Confirm New Password
</label>
<input
id="confirm-new-password"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
placeholder="Confirm your password"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Resetting...' : 'Reset password'}
</button>
</form>
)
}
export default PasswordResetForm

View file

@ -0,0 +1,195 @@
import { useState } from 'react'
import { signIn } from 'next-auth/react'
interface RegisterFormProps {
callbackUrl?: string
onSuccess?: () => void
onError?: (error: string) => void
}
export function RegisterForm({ callbackUrl = '/', onSuccess, onError }: RegisterFormProps) {
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
confirmPassword: '',
})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData((prev) => ({
...prev,
[e.target.name]: e.target.value,
}))
}
const validateForm = (): string | null => {
if (!formData.email || !formData.password) {
return 'Email and password are required'
}
if (formData.password.length < 8) {
return 'Password must be at least 8 characters long'
}
const hasUpperCase = /[A-Z]/.test(formData.password)
const hasLowerCase = /[a-z]/.test(formData.password)
const hasNumbers = /\d/.test(formData.password)
if (!hasUpperCase || !hasLowerCase || !hasNumbers) {
return 'Password must contain uppercase, lowercase, and numbers'
}
if (formData.password !== formData.confirmPassword) {
return 'Passwords do not match'
}
return null
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError(null)
const validationError = validateForm()
if (validationError) {
setError(validationError)
setIsLoading(false)
onError?.(validationError)
return
}
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.name,
email: formData.email,
password: formData.password,
}),
})
const data = await response.json()
if (!response.ok) {
setError(data.message || 'Registration failed')
onError?.(data.message || 'Registration failed')
return
}
onSuccess?.()
// Auto sign in after successful registration
const result = await signIn('credentials', {
email: formData.email,
password: formData.password,
redirect: false,
})
if (result?.ok && callbackUrl) {
window.location.href = callbackUrl
}
} catch (err) {
const errorMessage = 'An unexpected error occurred'
setError(errorMessage)
onError?.(errorMessage)
} finally {
setIsLoading(false)
}
}
return (
<form className="space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded relative text-sm">
{error}
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="register-name" className="block text-sm font-medium text-gray-700">
Full Name (optional)
</label>
<input
id="register-name"
name="name"
type="text"
autoComplete="name"
value={formData.name}
onChange={handleChange}
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
placeholder="John Doe"
/>
</div>
<div>
<label htmlFor="register-email" className="block text-sm font-medium text-gray-700">
Email address
</label>
<input
id="register-email"
name="email"
type="email"
autoComplete="email"
required
value={formData.email}
onChange={handleChange}
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
placeholder="you@example.com"
/>
</div>
<div>
<label htmlFor="register-password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="register-password"
name="password"
type="password"
autoComplete="new-password"
required
value={formData.password}
onChange={handleChange}
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
placeholder="At least 8 characters"
/>
<p className="mt-1 text-xs text-gray-500">
Must contain uppercase, lowercase, and numbers
</p>
</div>
<div>
<label htmlFor="register-confirmPassword" className="block text-sm font-medium text-gray-700">
Confirm Password
</label>
<input
id="register-confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
value={formData.confirmPassword}
onChange={handleChange}
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
placeholder="Confirm your password"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Creating account...' : 'Create account'}
</button>
</form>
)
}
export default RegisterForm

View file

@ -0,0 +1,95 @@
import { signIn } from 'next-auth/react'
interface SocialLoginButtonsProps {
callbackUrl?: string
providers?: string[]
}
const providerConfig: Record<string, { name: string; icon: JSX.Element; bgColor: string }> = {
github: {
name: 'GitHub',
icon: (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z"
clipRule="evenodd"
/>
</svg>
),
bgColor: 'bg-gray-900 hover:bg-gray-800',
},
google: {
name: 'Google',
icon: (
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
),
bgColor: 'bg-white hover:bg-gray-50 border border-gray-300',
},
}
export function SocialLoginButtons({ callbackUrl = '/', providers = ['github', 'google'] }: SocialLoginButtonsProps) {
const handleSignIn = (providerId: string) => {
signIn(providerId, { callbackUrl })
}
const availableProviders = providers.filter((p) => p in providerConfig)
if (availableProviders.length === 0) {
return null
}
return (
<div className="space-y-3">
{availableProviders.map((providerId) => {
const config = providerConfig[providerId]
const isGoogle = providerId === 'google'
return (
<button
key={providerId}
onClick={() => handleSignIn(providerId)}
className={`w-full inline-flex justify-center items-center py-2 px-4 rounded-md shadow-sm text-sm font-medium ${
config.bgColor
} ${isGoogle ? 'text-gray-700' : 'text-white'} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500`}
>
<span className="mr-2">{config.icon}</span>
Continue with {config.name}
</button>
)
})}
</div>
)
}
export function SocialDivider() {
return (
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">Or continue with</span>
</div>
</div>
)
}
export default SocialLoginButtons

5
components/auth/index.ts Normal file
View file

@ -0,0 +1,5 @@
export { LoginForm } from './LoginForm'
export { RegisterForm } from './RegisterForm'
export { PasswordResetForm } from './PasswordResetForm'
export { SocialLoginButtons, SocialDivider } from './SocialLoginButtons'
export { AuthGuard, withAuthGuard } from './AuthGuard'

View file

@ -0,0 +1,149 @@
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

@ -0,0 +1,290 @@
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

@ -0,0 +1,64 @@
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

@ -0,0 +1,144 @@
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

@ -0,0 +1,163 @@
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

@ -0,0 +1,96 @@
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

@ -0,0 +1,219 @@
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

@ -0,0 +1,8 @@
// 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

@ -0,0 +1,92 @@
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

@ -0,0 +1,183 @@
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

@ -0,0 +1,101 @@
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

@ -0,0 +1,138 @@
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

@ -0,0 +1,196 @@
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

@ -0,0 +1,131 @@
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

@ -0,0 +1,6 @@
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

@ -0,0 +1,127 @@
/**
* 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

@ -0,0 +1,156 @@
/**
* 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

@ -0,0 +1,229 @@
/**
* 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

@ -0,0 +1,285 @@
/**
* 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

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

View file

@ -0,0 +1,167 @@
/**
* 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

@ -0,0 +1,256 @@
/**
* 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

@ -0,0 +1,255 @@
/**
* 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

@ -0,0 +1,325 @@
/**
* 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

@ -0,0 +1,27 @@
/**
* 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

@ -0,0 +1,236 @@
/**
* Document Uploader Component
* Agent 3: File Upload & Storage System
*
* Upload interface for documents (PDF, DOC, etc.)
*/
import React, { useState, useCallback, useRef } from 'react';
import type { FileCategory } from '../../lib/storage/types';
import ProgressBar from './ProgressBar';
interface UploadedDocument {
id: string;
url: string;
size: number;
mimeType: string;
originalName: string;
}
interface DocumentUploaderProps {
category?: FileCategory;
plantId?: string;
farmId?: string;
userId?: string;
onUpload?: (file: UploadedDocument) => void;
onError?: (error: string) => void;
accept?: string;
className?: string;
}
export function DocumentUploader({
category = 'document',
plantId,
farmId,
userId,
onUpload,
onError,
accept = '.pdf,.doc,.docx',
className = '',
}: DocumentUploaderProps) {
const [isUploading, setIsUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string>();
const [uploadedFile, setUploadedFile] = useState<UploadedDocument | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const uploadFile = async (file: File) => {
setIsUploading(true);
setProgress(10);
setError(undefined);
const formData = new FormData();
formData.append('file', file);
formData.append('category', category);
if (plantId) formData.append('plantId', plantId);
if (farmId) formData.append('farmId', farmId);
if (userId) formData.append('userId', userId);
try {
setProgress(30);
const response = await fetch('/api/upload/document', {
method: 'POST',
body: formData,
});
setProgress(80);
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Upload failed');
}
setProgress(100);
setUploadedFile(data.file);
onUpload?.(data.file);
} catch (error) {
const message = error instanceof Error ? error.message : 'Upload failed';
setError(message);
onError?.(message);
} finally {
setIsUploading(false);
}
};
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
await uploadFile(file);
}
},
[]
);
const handleClick = () => {
fileInputRef.current?.click();
};
const handleRemove = () => {
setUploadedFile(null);
setProgress(0);
setError(undefined);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const getFileIcon = (mimeType: string) => {
if (mimeType === 'application/pdf') {
return (
<svg className="w-8 h-8 text-red-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4zm-3 9.5c0 .83-.67 1.5-1.5 1.5s-1.5-.67-1.5-1.5.67-1.5 1.5-1.5 1.5.67 1.5 1.5zm3 3c0 .83-.67 1.5-1.5 1.5s-1.5-.67-1.5-1.5.67-1.5 1.5-1.5 1.5.67 1.5 1.5z" />
</svg>
);
}
return (
<svg className="w-8 h-8 text-blue-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm4 18H6V4h7v5h5v11z" />
</svg>
);
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<div className={className}>
<input
ref={fileInputRef}
type="file"
accept={accept}
onChange={handleFileChange}
className="hidden"
/>
{uploadedFile ? (
<div className="flex items-center p-4 border rounded-lg bg-gray-50">
{getFileIcon(uploadedFile.mimeType)}
<div className="ml-3 flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{uploadedFile.originalName}
</p>
<p className="text-xs text-gray-500">
{formatFileSize(uploadedFile.size)}
</p>
</div>
<div className="flex items-center space-x-2">
<a
href={uploadedFile.url}
target="_blank"
rel="noopener noreferrer"
className="text-green-600 hover:text-green-700"
title="Download"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</a>
<button
onClick={handleRemove}
className="text-red-500 hover:text-red-600"
title="Remove"
>
<svg className="w-5 h-5" 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>
) : (
<button
onClick={handleClick}
disabled={isUploading}
className={`
w-full border-2 border-dashed rounded-lg p-6 text-center
transition-colors duration-200
${isUploading
? 'border-gray-200 bg-gray-50 cursor-not-allowed'
: 'border-gray-300 hover:border-green-400 hover:bg-gray-50 cursor-pointer'
}
`}
>
<svg
className="mx-auto h-10 w-10 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<p className="mt-2 text-sm text-gray-600">
<span className="text-green-600 font-medium">Click to upload</span>
{' '}a document
</p>
<p className="mt-1 text-xs text-gray-500">
PDF, DOC, DOCX up to 10MB
</p>
</button>
)}
{isUploading && (
<div className="mt-3">
<ProgressBar progress={progress} />
<p className="text-xs text-gray-500 mt-1 text-center">Uploading...</p>
</div>
)}
{error && (
<p className="mt-2 text-sm text-red-500 text-center">{error}</p>
)}
</div>
);
}
export default DocumentUploader;

View file

@ -0,0 +1,266 @@
/**
* Image Uploader Component
* Agent 3: File Upload & Storage System
*
* Drag & drop image upload with preview and progress
*/
import React, { useState, useCallback, useRef } from 'react';
import type { FileCategory } from '../../lib/storage/types';
interface UploadedFile {
id: string;
url: string;
thumbnailUrl?: string;
width?: number;
height?: number;
size: number;
}
interface ImageUploaderProps {
category?: FileCategory;
plantId?: string;
farmId?: string;
userId?: string;
onUpload?: (file: UploadedFile) => void;
onError?: (error: string) => void;
maxFiles?: number;
accept?: string;
className?: string;
}
interface UploadState {
isUploading: boolean;
progress: number;
error?: string;
preview?: string;
}
export function ImageUploader({
category = 'plant-photo',
plantId,
farmId,
userId,
onUpload,
onError,
maxFiles = 1,
accept = 'image/*',
className = '',
}: ImageUploaderProps) {
const [uploadState, setUploadState] = useState<UploadState>({
isUploading: false,
progress: 0,
});
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const uploadFile = async (file: File) => {
// Create preview
const reader = new FileReader();
reader.onload = (e) => {
setUploadState((prev) => ({
...prev,
preview: e.target?.result as string,
}));
};
reader.readAsDataURL(file);
// Start upload
setUploadState((prev) => ({
...prev,
isUploading: true,
progress: 0,
error: undefined,
}));
const formData = new FormData();
formData.append('file', file);
formData.append('category', category);
if (plantId) formData.append('plantId', plantId);
if (farmId) formData.append('farmId', farmId);
if (userId) formData.append('userId', userId);
try {
const response = await fetch('/api/upload/image', {
method: 'POST',
body: formData,
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Upload failed');
}
setUploadState({
isUploading: false,
progress: 100,
preview: data.file.thumbnailUrl || data.file.url,
});
onUpload?.(data.file);
} catch (error) {
const message = error instanceof Error ? error.message : 'Upload failed';
setUploadState((prev) => ({
...prev,
isUploading: false,
error: message,
}));
onError?.(message);
}
};
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files).slice(0, maxFiles);
if (files.length > 0) {
await uploadFile(files[0]);
}
},
[maxFiles]
);
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []).slice(0, maxFiles);
if (files.length > 0) {
await uploadFile(files[0]);
}
},
[maxFiles]
);
const handleClick = () => {
fileInputRef.current?.click();
};
const handleRemove = () => {
setUploadState({
isUploading: false,
progress: 0,
preview: undefined,
error: undefined,
});
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<div className={`relative ${className}`}>
<input
ref={fileInputRef}
type="file"
accept={accept}
onChange={handleFileChange}
className="hidden"
/>
{uploadState.preview ? (
<div className="relative rounded-lg overflow-hidden border-2 border-green-200">
<img
src={uploadState.preview}
alt="Uploaded preview"
className="w-full h-48 object-cover"
/>
<button
onClick={handleRemove}
className="absolute top-2 right-2 bg-red-500 text-white rounded-full p-1 hover:bg-red-600 transition-colors"
title="Remove image"
>
<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
onClick={handleClick}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`
border-2 border-dashed rounded-lg p-8 text-center cursor-pointer
transition-colors duration-200
${isDragging
? 'border-green-500 bg-green-50'
: 'border-gray-300 hover:border-green-400 hover:bg-gray-50'
}
${uploadState.isUploading ? 'pointer-events-none opacity-50' : ''}
`}
>
<svg
className="mx-auto h-12 w-12 text-gray-400"
stroke="currentColor"
fill="none"
viewBox="0 0 48 48"
>
<path
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<p className="mt-2 text-sm text-gray-600">
<span className="text-green-600 font-medium">Click to upload</span>
{' '}or drag and drop
</p>
<p className="mt-1 text-xs text-gray-500">
PNG, JPG, GIF, WEBP up to 5MB
</p>
</div>
)}
{uploadState.isUploading && (
<div className="mt-2">
<div className="bg-gray-200 rounded-full h-2">
<div
className="bg-green-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadState.progress}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-1 text-center">Uploading...</p>
</div>
)}
{uploadState.error && (
<p className="mt-2 text-sm text-red-500 text-center">{uploadState.error}</p>
)}
</div>
);
}
export default ImageUploader;

View file

@ -0,0 +1,213 @@
/**
* Photo Gallery Component
* Agent 3: File Upload & Storage System
*
* Displays a grid of plant photos with lightbox view
*/
import React, { useState } from 'react';
interface Photo {
id: string;
url: string;
thumbnailUrl?: string;
width?: number;
height?: number;
caption?: string;
uploadedAt?: string;
}
interface PhotoGalleryProps {
photos: Photo[];
onDelete?: (photoId: string) => void;
editable?: boolean;
columns?: 2 | 3 | 4;
className?: string;
}
export function PhotoGallery({
photos,
onDelete,
editable = false,
columns = 3,
className = '',
}: PhotoGalleryProps) {
const [selectedPhoto, setSelectedPhoto] = useState<Photo | null>(null);
const [isDeleting, setIsDeleting] = useState<string | null>(null);
const handleDelete = async (photoId: string) => {
if (!onDelete) return;
setIsDeleting(photoId);
try {
await onDelete(photoId);
} finally {
setIsDeleting(null);
}
};
const gridCols = {
2: 'grid-cols-2',
3: 'grid-cols-2 sm:grid-cols-3',
4: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4',
};
if (photos.length === 0) {
return (
<div className={`text-center py-8 ${className}`}>
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
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>
<p className="mt-2 text-sm text-gray-500">No photos yet</p>
</div>
);
}
return (
<>
<div className={`grid ${gridCols[columns]} gap-4 ${className}`}>
{photos.map((photo) => (
<div
key={photo.id}
className="relative group aspect-square rounded-lg overflow-hidden bg-gray-100"
>
<img
src={photo.thumbnailUrl || photo.url}
alt={photo.caption || 'Plant photo'}
className="w-full h-full object-cover cursor-pointer transition-transform duration-200 group-hover:scale-105"
onClick={() => setSelectedPhoto(photo)}
loading="lazy"
/>
{/* Overlay on hover */}
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all duration-200 flex items-center justify-center">
<button
onClick={() => setSelectedPhoto(photo)}
className="opacity-0 group-hover:opacity-100 bg-white rounded-full p-2 mx-1 transition-opacity duration-200 hover:bg-gray-100"
title="View full size"
>
<svg
className="w-5 h-5 text-gray-700"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7"
/>
</svg>
</button>
{editable && onDelete && (
<button
onClick={() => handleDelete(photo.id)}
disabled={isDeleting === photo.id}
className="opacity-0 group-hover:opacity-100 bg-red-500 text-white rounded-full p-2 mx-1 transition-opacity duration-200 hover:bg-red-600 disabled:opacity-50"
title="Delete photo"
>
{isDeleting === photo.id ? (
<svg
className="w-5 h-5 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
)}
</button>
)}
</div>
{/* Caption */}
{photo.caption && (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2">
<p className="text-white text-xs truncate">{photo.caption}</p>
</div>
)}
</div>
))}
</div>
{/* Lightbox */}
{selectedPhoto && (
<div
className="fixed inset-0 z-50 bg-black bg-opacity-90 flex items-center justify-center p-4"
onClick={() => setSelectedPhoto(null)}
>
<button
className="absolute top-4 right-4 text-white hover:text-gray-300 transition-colors"
onClick={() => setSelectedPhoto(null)}
>
<svg
className="w-8 h-8"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<img
src={selectedPhoto.url}
alt={selectedPhoto.caption || 'Plant photo'}
className="max-w-full max-h-full object-contain"
onClick={(e) => e.stopPropagation()}
/>
{selectedPhoto.caption && (
<div className="absolute bottom-4 left-4 right-4 text-center">
<p className="text-white text-lg">{selectedPhoto.caption}</p>
</div>
)}
</div>
)}
</>
);
}
export default PhotoGallery;

View file

@ -0,0 +1,63 @@
/**
* Progress Bar Component
* Agent 3: File Upload & Storage System
*
* Animated progress bar for uploads
*/
import React from 'react';
interface ProgressBarProps {
progress: number;
showPercentage?: boolean;
color?: 'green' | 'blue' | 'purple' | 'orange';
size?: 'sm' | 'md' | 'lg';
animated?: boolean;
className?: string;
}
const colorClasses = {
green: 'bg-green-500',
blue: 'bg-blue-500',
purple: 'bg-purple-500',
orange: 'bg-orange-500',
};
const sizeClasses = {
sm: 'h-1',
md: 'h-2',
lg: 'h-3',
};
export function ProgressBar({
progress,
showPercentage = false,
color = 'green',
size = 'md',
animated = true,
className = '',
}: ProgressBarProps) {
const clampedProgress = Math.min(100, Math.max(0, progress));
return (
<div className={className}>
<div className={`bg-gray-200 rounded-full overflow-hidden ${sizeClasses[size]}`}>
<div
className={`
${colorClasses[color]} ${sizeClasses[size]} rounded-full
transition-all duration-300 ease-out
${animated && clampedProgress < 100 ? 'animate-pulse' : ''}
`}
style={{ width: `${clampedProgress}%` }}
/>
</div>
{showPercentage && (
<p className="text-xs text-gray-500 mt-1 text-right">
{Math.round(clampedProgress)}%
</p>
)}
</div>
);
}
export default ProgressBar;

View file

@ -0,0 +1,11 @@
/**
* Upload Components Index
* Agent 3: File Upload & Storage System
*
* Export all upload-related components
*/
export { ImageUploader } from './ImageUploader';
export { PhotoGallery } from './PhotoGallery';
export { DocumentUploader } from './DocumentUploader';
export { ProgressBar } from './ProgressBar';

35
cypress.config.ts Normal file
View file

@ -0,0 +1,35 @@
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3001',
supportFile: 'cypress/support/e2e.ts',
specPattern: 'cypress/e2e/**/*.cy.{ts,tsx}',
viewportWidth: 1280,
viewportHeight: 720,
video: true,
screenshotOnRunFailure: true,
defaultCommandTimeout: 10000,
requestTimeout: 10000,
responseTimeout: 30000,
retries: {
runMode: 2,
openMode: 0,
},
setupNodeEvents(on, config) {
// implement node event listeners here
on('task', {
log(message) {
console.log(message);
return null;
},
});
},
},
component: {
devServer: {
framework: 'next',
bundler: 'webpack',
},
},
});

32
cypress/e2e/home.cy.ts Normal file
View file

@ -0,0 +1,32 @@
/**
* Home Page E2E Tests
*/
describe('Home Page', () => {
beforeEach(() => {
cy.visit('/');
cy.waitForPageLoad();
});
it('should load the home page', () => {
cy.url().should('eq', `${Cypress.config('baseUrl')}/`);
});
it('should display the main navigation', () => {
cy.get('nav').should('be.visible');
});
it('should have proper page title', () => {
cy.title().should('not.be.empty');
});
it('should be responsive on mobile viewport', () => {
cy.viewport('iphone-x');
cy.get('nav').should('be.visible');
});
it('should be responsive on tablet viewport', () => {
cy.viewport('ipad-2');
cy.get('nav').should('be.visible');
});
});

View file

@ -0,0 +1,51 @@
/**
* Plant Registration E2E Tests
*/
describe('Plant Registration', () => {
beforeEach(() => {
cy.visit('/plants/register');
cy.waitForPageLoad();
});
it('should load the registration page', () => {
cy.url().should('include', '/plants/register');
});
it('should display registration form', () => {
cy.get('form').should('be.visible');
});
it('should have required form fields', () => {
// Check for common form fields
cy.get('input, select, textarea').should('have.length.at.least', 1);
});
it('should show validation errors for empty form submission', () => {
// Try to submit empty form
cy.get('form').within(() => {
cy.get('button[type="submit"]').click();
});
// Form should not navigate away without valid data
cy.url().should('include', '/plants/register');
});
describe('Form Validation', () => {
it('should require plant name', () => {
cy.get('input[name="name"]').should('exist');
});
it('should require plant species', () => {
cy.get('input[name="species"], select[name="species"]').should('exist');
});
});
describe('Anonymous Registration', () => {
it('should allow anonymous registration', () => {
cy.visit('/plants/register-anonymous');
cy.waitForPageLoad();
cy.url().should('include', '/plants/register-anonymous');
cy.get('form').should('be.visible');
});
});
});

View file

@ -0,0 +1,49 @@
/**
* Transparency Dashboard E2E Tests
*/
describe('Transparency Dashboard', () => {
beforeEach(() => {
cy.visit('/transparency');
cy.waitForPageLoad();
});
it('should load the transparency page', () => {
cy.url().should('include', '/transparency');
});
it('should display dashboard content', () => {
cy.get('main').should('be.visible');
});
it('should show transparency metrics', () => {
// Check for dashboard sections
cy.get('[data-testid="dashboard"], .dashboard, main').should('be.visible');
});
describe('Data Display', () => {
it('should display charts or data visualizations', () => {
// Look for chart containers or data elements
cy.get('canvas, svg, [class*="chart"], [class*="graph"]').should(
'have.length.at.least',
0
);
});
it('should display audit information', () => {
// Check for audit-related content
cy.contains(/audit|log|record|history/i).should('exist');
});
});
describe('Accessibility', () => {
it('should have proper heading structure', () => {
cy.get('h1, h2, h3').should('have.length.at.least', 1);
});
it('should be keyboard navigable', () => {
cy.get('body').tab();
cy.focused().should('exist');
});
});
});

View file

@ -0,0 +1,59 @@
/**
* Vertical Farm E2E Tests
*/
describe('Vertical Farm', () => {
describe('Farm List Page', () => {
beforeEach(() => {
cy.visit('/vertical-farm');
cy.waitForPageLoad();
});
it('should load the vertical farm page', () => {
cy.url().should('include', '/vertical-farm');
});
it('should display farm management content', () => {
cy.get('main').should('be.visible');
});
it('should have navigation to register new farm', () => {
cy.contains(/register|new|add|create/i).should('exist');
});
});
describe('Farm Registration', () => {
beforeEach(() => {
cy.visit('/vertical-farm/register');
cy.waitForPageLoad();
});
it('should load the registration page', () => {
cy.url().should('include', '/vertical-farm/register');
});
it('should display registration form', () => {
cy.get('form').should('be.visible');
});
it('should have required form fields', () => {
cy.get('input, select, textarea').should('have.length.at.least', 1);
});
});
describe('Responsiveness', () => {
it('should display correctly on mobile', () => {
cy.viewport('iphone-x');
cy.visit('/vertical-farm');
cy.waitForPageLoad();
cy.get('main').should('be.visible');
});
it('should display correctly on tablet', () => {
cy.viewport('ipad-2');
cy.visit('/vertical-farm');
cy.waitForPageLoad();
cy.get('main').should('be.visible');
});
});
});

View file

@ -1,5 +1,25 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
"plants": [
{
"id": "plant-1",
"name": "Cherry Tomato",
"species": "Tomato",
"variety": "Cherry",
"generation": 1,
"status": "healthy"
},
{
"id": "plant-2",
"name": "Sweet Basil",
"species": "Basil",
"variety": "Genovese",
"generation": 1,
"status": "thriving"
}
],
"user": {
"id": "user-1",
"email": "test@example.com",
"name": "Test User"
}
}

View file

@ -0,0 +1,27 @@
/**
* Cypress Custom Commands
*/
// Wait for page to fully load
Cypress.Commands.add('waitForPageLoad', () => {
cy.document().its('readyState').should('eq', 'complete');
});
// Login command (placeholder for auth implementation)
Cypress.Commands.add('login', (email: string, password: string) => {
// This will be implemented when auth is added
cy.log(`Login with ${email}`);
cy.session([email, password], () => {
// Placeholder for auth session
cy.visit('/');
});
});
// Navigate to a plant page
Cypress.Commands.add('visitPlant', (plantId: string) => {
cy.visit(`/plants/${plantId}`);
cy.waitForPageLoad();
});
// Export empty object for module
export {};

45
cypress/support/e2e.ts Normal file
View file

@ -0,0 +1,45 @@
/**
* Cypress E2E Support File
* This file is processed and loaded automatically before test files.
*/
// Import commands
import './commands';
// Global hooks
beforeEach(() => {
// Clear local storage between tests
cy.clearLocalStorage();
});
// Handle uncaught exceptions
Cypress.on('uncaught:exception', (err, runnable) => {
// Returning false prevents Cypress from failing the test
// This is useful for third-party scripts that may throw errors
if (err.message.includes('ResizeObserver loop')) {
return false;
}
return true;
});
// Add custom assertions if needed
declare global {
namespace Cypress {
interface Chainable {
/**
* Custom command to wait for page load
*/
waitForPageLoad(): Chainable<void>;
/**
* Custom command to login (placeholder for auth tests)
*/
login(email: string, password: string): Chainable<void>;
/**
* Custom command to navigate to a plant page
*/
visitPlant(plantId: string): Chainable<void>;
}
}
}

View file

@ -0,0 +1,263 @@
#!/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);
});

155
docker-compose.dev.yml Normal file
View file

@ -0,0 +1,155 @@
# LocalGreenChain Development Docker Compose
# Agent 4: Production Deployment
# Development environment with hot reloading and debug tools
version: '3.8'
services:
# ==========================================================================
# Application (Development Mode)
# ==========================================================================
app:
build:
context: .
dockerfile: Dockerfile
target: deps # Use deps stage for development
container_name: lgc-app-dev
restart: unless-stopped
ports:
- "${PORT:-3001}:3001"
environment:
- NODE_ENV=development
- DATABASE_URL=postgresql://lgc:lgc_dev_password@postgres:5432/localgreenchain_dev
- REDIS_URL=redis://redis:6379
- LOG_LEVEL=debug
- PLANTS_NET_API_KEY=${PLANTS_NET_API_KEY:-}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
# Mount source code for hot reloading
- .:/app
- /app/node_modules # Exclude node_modules
- /app/.next # Exclude build output
networks:
- lgc-dev-network
command: bun run dev
# ==========================================================================
# Database (Development)
# ==========================================================================
postgres:
image: postgres:15-alpine
container_name: lgc-postgres-dev
restart: unless-stopped
environment:
- POSTGRES_USER=lgc
- POSTGRES_PASSWORD=lgc_dev_password
- POSTGRES_DB=localgreenchain_dev
volumes:
- postgres-dev-data:/var/lib/postgresql/data
ports:
- "5433:5432" # Different port to avoid conflicts
networks:
- lgc-dev-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U lgc -d localgreenchain_dev"]
interval: 5s
timeout: 5s
retries: 5
# ==========================================================================
# Cache (Development)
# ==========================================================================
redis:
image: redis:7-alpine
container_name: lgc-redis-dev
restart: unless-stopped
command: redis-server --appendonly yes
volumes:
- redis-dev-data:/data
ports:
- "6380:6379" # Different port to avoid conflicts
networks:
- lgc-dev-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
# ==========================================================================
# Database Admin (pgAdmin)
# ==========================================================================
pgadmin:
image: dpage/pgadmin4:latest
container_name: lgc-pgadmin-dev
restart: unless-stopped
environment:
- PGADMIN_DEFAULT_EMAIL=admin@localgreenchain.local
- PGADMIN_DEFAULT_PASSWORD=admin
- PGADMIN_CONFIG_SERVER_MODE=False
volumes:
- pgadmin-dev-data:/var/lib/pgadmin
ports:
- "5050:80"
networks:
- lgc-dev-network
depends_on:
- postgres
profiles:
- tools
# ==========================================================================
# Redis Commander (Redis UI)
# ==========================================================================
redis-commander:
image: rediscommander/redis-commander:latest
container_name: lgc-redis-commander-dev
restart: unless-stopped
environment:
- REDIS_HOSTS=local:redis:6379
ports:
- "8081:8081"
networks:
- lgc-dev-network
depends_on:
- redis
profiles:
- tools
# ==========================================================================
# MailHog (Email Testing)
# ==========================================================================
mailhog:
image: mailhog/mailhog:latest
container_name: lgc-mailhog-dev
restart: unless-stopped
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI
networks:
- lgc-dev-network
profiles:
- tools
# =============================================================================
# Networks
# =============================================================================
networks:
lgc-dev-network:
driver: bridge
name: lgc-dev-network
# =============================================================================
# Volumes
# =============================================================================
volumes:
postgres-dev-data:
name: lgc-postgres-dev-data
redis-dev-data:
name: lgc-redis-dev-data
pgadmin-dev-data:
name: lgc-pgadmin-dev-data

164
docker-compose.yml Normal file
View file

@ -0,0 +1,164 @@
# LocalGreenChain Production Docker Compose
# Agent 4: Production Deployment
# Full stack with PostgreSQL, Redis, and monitoring
version: '3.8'
services:
# ==========================================================================
# Application
# ==========================================================================
app:
build:
context: .
dockerfile: Dockerfile
args:
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:3001}
- NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN:-}
container_name: lgc-app
restart: unless-stopped
ports:
- "${PORT:-3001}:3001"
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://${DB_USER:-lgc}:${DB_PASSWORD:-lgc_password}@postgres:5432/${DB_NAME:-localgreenchain}
- REDIS_URL=redis://redis:6379
- SENTRY_DSN=${SENTRY_DSN:-}
- LOG_LEVEL=${LOG_LEVEL:-info}
- PLANTS_NET_API_KEY=${PLANTS_NET_API_KEY:-}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- app-data:/app/data
networks:
- lgc-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3001/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
labels:
- "prometheus.scrape=true"
- "prometheus.port=3001"
- "prometheus.path=/api/metrics"
# ==========================================================================
# Database
# ==========================================================================
postgres:
image: postgres:15-alpine
container_name: lgc-postgres
restart: unless-stopped
environment:
- POSTGRES_USER=${DB_USER:-lgc}
- POSTGRES_PASSWORD=${DB_PASSWORD:-lgc_password}
- POSTGRES_DB=${DB_NAME:-localgreenchain}
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- postgres-data:/var/lib/postgresql/data
ports:
- "${DB_PORT:-5432}:5432"
networks:
- lgc-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-lgc} -d ${DB_NAME:-localgreenchain}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
# ==========================================================================
# Cache
# ==========================================================================
redis:
image: redis:7-alpine
container_name: lgc-redis
restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis-data:/data
ports:
- "${REDIS_PORT:-6379}:6379"
networks:
- lgc-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# ==========================================================================
# Monitoring - Prometheus
# ==========================================================================
prometheus:
image: prom/prometheus:v2.47.0
container_name: lgc-prometheus
restart: unless-stopped
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--storage.tsdb.retention.time=15d'
- '--web.enable-lifecycle'
volumes:
- ./infra/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus-data:/prometheus
ports:
- "${PROMETHEUS_PORT:-9090}:9090"
networks:
- lgc-network
depends_on:
- app
profiles:
- monitoring
# ==========================================================================
# Monitoring - Grafana
# ==========================================================================
grafana:
image: grafana/grafana:10.1.0
container_name: lgc-grafana
restart: unless-stopped
environment:
- GF_SECURITY_ADMIN_USER=${GRAFANA_USER:-admin}
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-admin}
- GF_USERS_ALLOW_SIGN_UP=false
- GF_SERVER_ROOT_URL=${GRAFANA_ROOT_URL:-http://localhost:3000}
volumes:
- grafana-data:/var/lib/grafana
- ./infra/grafana/provisioning:/etc/grafana/provisioning:ro
- ./infra/grafana/dashboards:/var/lib/grafana/dashboards:ro
ports:
- "${GRAFANA_PORT:-3000}:3000"
networks:
- lgc-network
depends_on:
- prometheus
profiles:
- monitoring
# =============================================================================
# Networks
# =============================================================================
networks:
lgc-network:
driver: bridge
name: lgc-network
# =============================================================================
# Volumes
# =============================================================================
volumes:
app-data:
name: lgc-app-data
postgres-data:
name: lgc-postgres-data
redis-data:
name: lgc-redis-data
prometheus-data:
name: lgc-prometheus-data
grafana-data:
name: lgc-grafana-data

363
docs/DATABASE.md Normal file
View file

@ -0,0 +1,363 @@
# LocalGreenChain Database Integration
This document describes the PostgreSQL database integration for LocalGreenChain, implemented as part of Agent 2's deployment.
## Overview
The database layer provides persistent storage for all LocalGreenChain entities using PostgreSQL and Prisma ORM. This replaces the previous file-based JSON storage with a robust, scalable database solution.
## Quick Start
### 1. Install Dependencies
```bash
bun install
```
### 2. Configure Database
Copy the environment template and configure your database connection:
```bash
cp .env.example .env
```
Edit `.env` and set your PostgreSQL connection string:
```env
DATABASE_URL="postgresql://user:password@localhost:5432/localgreenchain?schema=public"
```
### 3. Generate Prisma Client
```bash
bun run db:generate
```
### 4. Run Migrations
For development:
```bash
bun run db:migrate
```
For production:
```bash
bun run db:migrate:prod
```
### 5. Seed Database (Optional)
```bash
bun run db:seed
```
## Database Schema
The schema includes the following main entities:
### Core Entities
| Entity | Description |
|--------|-------------|
| `User` | User accounts with authentication and profiles |
| `Plant` | Plant records with lineage tracking |
| `TransportEvent` | Supply chain transport events |
| `SeedBatch` | Seed batch tracking |
| `HarvestBatch` | Harvest batch records |
### Vertical Farming
| Entity | Description |
|--------|-------------|
| `VerticalFarm` | Vertical farm facilities |
| `GrowingZone` | Individual growing zones |
| `CropBatch` | Active crop batches |
| `GrowingRecipe` | Growing recipes/protocols |
| `ResourceUsage` | Energy and resource tracking |
| `FarmAnalytics` | Farm performance analytics |
### Demand & Market
| Entity | Description |
|--------|-------------|
| `ConsumerPreference` | Consumer food preferences |
| `DemandSignal` | Aggregated demand signals |
| `SupplyCommitment` | Grower supply commitments |
| `MarketMatch` | Matched supply and demand |
| `SeasonalPlan` | Grower seasonal plans |
| `DemandForecast` | Demand predictions |
| `PlantingRecommendation` | Planting recommendations |
### Audit & Blockchain
| Entity | Description |
|--------|-------------|
| `AuditLog` | System audit trail |
| `BlockchainBlock` | Blockchain block storage |
## Usage
### Importing the Database Layer
```typescript
import * as db from '@/lib/db';
// or import specific functions
import { createPlant, getPlantById, getPlantLineage } from '@/lib/db';
```
### Common Operations
#### Users
```typescript
// Create a user
const user = await db.createUser({
email: 'grower@example.com',
name: 'John Farmer',
userType: 'GROWER',
city: 'San Francisco',
country: 'USA',
});
// Get user by email
const user = await db.getUserByEmail('grower@example.com');
// Get user with their plants
const userWithPlants = await db.getUserWithPlants(userId);
```
#### Plants
```typescript
// Create a plant
const plant = await db.createPlant({
commonName: 'Cherry Tomato',
scientificName: 'Solanum lycopersicum',
plantedDate: new Date(),
latitude: 37.7749,
longitude: -122.4194,
ownerId: userId,
});
// Clone a plant
const clone = await db.clonePlant(parentId, newOwnerId, 'CLONE');
// Get plant lineage
const lineage = await db.getPlantLineage(plantId);
// Returns: { plant, ancestors, descendants, siblings }
// Find nearby plants
const nearby = await db.getNearbyPlants({
latitude: 37.7749,
longitude: -122.4194,
radiusKm: 10,
});
```
#### Transport Events
```typescript
// Create a transport event
const event = await db.createTransportEvent({
eventType: 'DISTRIBUTION',
fromLatitude: 37.8044,
fromLongitude: -122.2712,
fromLocationType: 'VERTICAL_FARM',
toLatitude: 37.7849,
toLongitude: -122.4094,
toLocationType: 'MARKET',
durationMinutes: 25,
transportMethod: 'ELECTRIC_TRUCK',
senderId: farmerId,
receiverId: distributorId,
});
// Get plant journey
const journey = await db.getPlantJourney(plantId);
// Get environmental impact
const impact = await db.getEnvironmentalImpact({ userId });
```
#### Vertical Farms
```typescript
// Create a farm
const farm = await db.createVerticalFarm({
name: 'Urban Greens',
ownerId: userId,
latitude: 37.8044,
longitude: -122.2712,
address: '123 Farm St',
city: 'Oakland',
country: 'USA',
specs: { totalAreaSqm: 500, numberOfLevels: 5 },
});
// Create a growing zone
const zone = await db.createGrowingZone({
name: 'Zone A',
farmId: farm.id,
level: 1,
areaSqm: 80,
growingMethod: 'NFT',
plantPositions: 400,
});
// Create a crop batch
const batch = await db.createCropBatch({
farmId: farm.id,
zoneId: zone.id,
cropType: 'Lettuce',
plantCount: 400,
plantingDate: new Date(),
expectedHarvestDate: new Date(Date.now() + 28 * 24 * 60 * 60 * 1000),
expectedYieldKg: 60,
});
```
#### Demand & Market
```typescript
// Set consumer preferences
await db.upsertConsumerPreference({
consumerId: userId,
latitude: 37.7849,
longitude: -122.4094,
preferredCategories: ['leafy_greens', 'herbs'],
certificationPreferences: ['organic', 'local'],
});
// Create supply commitment
const commitment = await db.createSupplyCommitment({
growerId: farmerId,
produceType: 'Butterhead Lettuce',
committedQuantityKg: 60,
availableFrom: new Date(),
availableUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
pricePerKg: 8.5,
deliveryRadiusKm: 25,
deliveryMethods: ['grower_delivery', 'customer_pickup'],
});
// Find matching supply
const matches = await db.findMatchingSupply(
'Lettuce',
{ latitude: 37.7849, longitude: -122.4094, radiusKm: 20 },
new Date()
);
```
### Using the Database-Backed Blockchain
The database layer includes a `PlantChainDB` class that provides blockchain functionality with PostgreSQL persistence:
```typescript
import { getBlockchainDB } from '@/lib/blockchain/manager';
// Get the database-backed blockchain
const chain = await getBlockchainDB();
// Register a plant (creates both DB record and blockchain block)
const { plant, block } = await chain.registerPlant({
commonName: 'Tomato',
latitude: 37.7749,
longitude: -122.4194,
ownerId: userId,
});
// Clone a plant
const { plant: clone, block: cloneBlock } = await chain.clonePlant(
parentId,
newOwnerId,
'CLONE'
);
// Verify blockchain integrity
const isValid = await chain.isChainValid();
```
## NPM Scripts
| Script | Description |
|--------|-------------|
| `bun run db:generate` | Generate Prisma client |
| `bun run db:push` | Push schema to database (dev) |
| `bun run db:migrate` | Create and run migration (dev) |
| `bun run db:migrate:prod` | Run migrations (production) |
| `bun run db:seed` | Seed database with test data |
| `bun run db:studio` | Open Prisma Studio GUI |
## Architecture
```
lib/db/
├── prisma.ts # Prisma client singleton
├── types.ts # Type definitions and utilities
├── users.ts # User CRUD operations
├── plants.ts # Plant operations with lineage
├── transport.ts # Transport event operations
├── farms.ts # Vertical farm operations
├── demand.ts # Demand and market operations
├── audit.ts # Audit logging and blockchain
└── index.ts # Central exports
prisma/
├── schema.prisma # Database schema
└── seed.ts # Seed script
```
## Migration from File Storage
If you have existing data in JSON files, you can migrate to the database:
1. Ensure database is configured and migrations are run
2. Load existing JSON data
3. Use the database service layer to insert records
4. Verify data integrity
5. Remove old JSON files
## Performance Considerations
- The schema includes strategic indexes on frequently queried fields
- Pagination is supported for large result sets
- Location-based queries use in-memory filtering (consider PostGIS for large scale)
- Blockchain integrity verification scans all blocks (cache results for performance)
## Troubleshooting
### Connection Issues
```bash
# Test database connection
bunx prisma db pull
```
### Migration Issues
```bash
# Reset database (WARNING: deletes all data)
bunx prisma migrate reset
# Generate new migration
bunx prisma migrate dev --name your_migration_name
```
### Type Issues
```bash
# Regenerate Prisma client
bun run db:generate
```
## Security Notes
- Never commit `.env` files with real credentials
- Use environment variables for all sensitive configuration
- Database user should have minimal required permissions
- Enable SSL for production database connections
---
*Implemented by Agent 2 - Database Integration*

View file

@ -0,0 +1,682 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"description": "LocalGreenChain Application Dashboard",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"id": 1,
"panels": [],
"title": "Overview",
"type": "row"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 0,
"y": 1
},
"id": 2,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"textMode": "auto"
},
"pluginVersion": "10.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "sum(lgc_http_requests_total)",
"refId": "A"
}
],
"title": "Total Requests",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 6,
"y": 1
},
"id": 3,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"textMode": "auto"
},
"pluginVersion": "10.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "lgc_plants_registered_total",
"refId": "A"
}
],
"title": "Plants Registered",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 12,
"y": 1
},
"id": 4,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"textMode": "auto"
},
"pluginVersion": "10.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "lgc_active_agents",
"refId": "A"
}
],
"title": "Active Agents",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 18,
"y": 1
},
"id": 5,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"textMode": "auto"
},
"pluginVersion": "10.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "lgc_blockchain_blocks",
"refId": "A"
}
],
"title": "Blockchain Blocks",
"type": "stat"
},
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 5
},
"id": 6,
"panels": [],
"title": "HTTP Metrics",
"type": "row"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 6
},
"id": 7,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "rate(lgc_http_requests_total[5m])",
"legendFormat": "{{method}} {{path}}",
"refId": "A"
}
],
"title": "Request Rate",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 6
},
"id": 8,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "histogram_quantile(0.95, rate(lgc_http_request_duration_seconds_bucket[5m]))",
"legendFormat": "p95 {{method}} {{path}}",
"refId": "A"
}
],
"title": "Request Duration (p95)",
"type": "timeseries"
},
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 14
},
"id": 9,
"panels": [],
"title": "Agent Metrics",
"type": "row"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 15
},
"id": 10,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "rate(lgc_agent_cycles_total[5m])",
"legendFormat": "{{agent}}",
"refId": "A"
}
],
"title": "Agent Cycle Rate",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 15
},
"id": 11,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "histogram_quantile(0.95, rate(lgc_agent_cycle_duration_seconds_bucket[5m]))",
"legendFormat": "{{agent}}",
"refId": "A"
}
],
"title": "Agent Cycle Duration (p95)",
"type": "timeseries"
}
],
"refresh": "30s",
"schemaVersion": 38,
"style": "dark",
"tags": ["localgreenchain", "application"],
"templating": {
"list": [
{
"current": {
"selected": false,
"text": "Prometheus",
"value": "Prometheus"
},
"hide": 0,
"includeAll": false,
"label": "Datasource",
"multi": false,
"name": "DS_PROMETHEUS",
"options": [],
"query": "prometheus",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"type": "datasource"
}
]
},
"time": {
"from": "now-1h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "LocalGreenChain Dashboard",
"uid": "localgreenchain-main",
"version": 1,
"weekStart": ""
}

View file

@ -0,0 +1,16 @@
# LocalGreenChain Grafana Dashboard Provisioning
# Agent 4: Production Deployment
apiVersion: 1
providers:
- name: 'LocalGreenChain'
orgId: 1
folder: 'LocalGreenChain'
folderUid: 'lgc'
type: file
disableDeletion: false
updateIntervalSeconds: 30
allowUiUpdates: true
options:
path: /var/lib/grafana/dashboards

View file

@ -0,0 +1,30 @@
# LocalGreenChain Grafana Datasources
# Agent 4: Production Deployment
apiVersion: 1
datasources:
# Prometheus
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false
jsonData:
timeInterval: "15s"
httpMethod: POST
# PostgreSQL (optional)
# - name: PostgreSQL
# type: postgres
# url: postgres:5432
# database: localgreenchain
# user: lgc
# secureJsonData:
# password: ${DB_PASSWORD}
# jsonData:
# sslmode: disable
# maxOpenConns: 5
# maxIdleConns: 2
# connMaxLifetime: 14400

View file

@ -0,0 +1,65 @@
# LocalGreenChain Prometheus Configuration
# Agent 4: Production Deployment
global:
scrape_interval: 15s
evaluation_interval: 15s
external_labels:
monitor: 'localgreenchain'
# Alerting configuration (optional)
alerting:
alertmanagers:
- static_configs:
- targets: []
# - alertmanager:9093
# Rule files (optional)
rule_files: []
# - "first_rules.yml"
# - "second_rules.yml"
# Scrape configurations
scrape_configs:
# Prometheus self-monitoring
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
metrics_path: /metrics
# LocalGreenChain Application
- job_name: 'localgreenchain'
static_configs:
- targets: ['app:3001']
metrics_path: /api/metrics
scrape_interval: 30s
scrape_timeout: 10s
# PostgreSQL (if using postgres_exporter)
- job_name: 'postgresql'
static_configs:
- targets: []
# - postgres-exporter:9187
scrape_interval: 60s
# Redis (if using redis_exporter)
- job_name: 'redis'
static_configs:
- targets: []
# - redis-exporter:9121
scrape_interval: 30s
# Node Exporter (if running)
- job_name: 'node'
static_configs:
- targets: []
# - node-exporter:9100
scrape_interval: 30s
# Remote write configuration (optional)
# For long-term storage or external Prometheus
# remote_write:
# - url: "https://remote-prometheus.example.com/api/v1/write"
# basic_auth:
# username: user
# password: pass

View file

@ -8,14 +8,20 @@ const config = {
'^@/(.*)$': '<rootDir>/$1',
},
transform: {
'^.+\\.tsx?$': ['ts-jest', {
tsconfig: 'tsconfig.json',
}],
'^.+\\.tsx?$': [
'ts-jest',
{
tsconfig: 'tsconfig.json',
},
],
},
collectCoverageFrom: [
'lib/**/*.ts',
'!lib/**/*.d.ts',
'!lib/**/types.ts',
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html', 'json-summary'],
coverageThreshold: {
global: {
branches: 80,
@ -24,8 +30,11 @@ const config = {
statements: 80,
},
},
setupFilesAfterEnv: [],
setupFilesAfterEnv: ['<rootDir>/__tests__/setup.ts'],
testPathIgnorePatterns: ['/node_modules/', '/.next/', '/cypress/'],
verbose: true,
// Increase timeout for async tests
testTimeout: 10000,
};
module.exports = config;

View file

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

View file

@ -178,7 +178,7 @@ export class GrowerAdvisoryAgent extends BaseAgent {
*/
private updateGrowerProfiles(): void {
const blockchain = getBlockchain();
const chain = blockchain.getChain().slice(1);
const chain = blockchain.chain.slice(1);
const ownerPlants = new Map<string, typeof chain>();
@ -219,7 +219,11 @@ export class GrowerAdvisoryAgent extends BaseAgent {
if (['growing', 'mature', 'flowering', 'fruiting'].includes(plant.plant.status)) {
existing.healthy++;
}
existing.yield += plant.plant.growthMetrics?.estimatedYieldKg || 2;
// 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;
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.getChain();
const chain = blockchain.chain;
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.getChain();
const chain = blockchain.chain;
// 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.dateAcquired,
oldestAncestorDate: oldestAncestor?.timestamp || plant.registeredAt,
healthScore: this.calculateHealthScore(plant, chain)
};
}

View file

@ -14,8 +14,8 @@ import { BaseAgent } from './BaseAgent';
import { AgentConfig, AgentTask, QualityReport } from './types';
import { getBlockchain } from '../blockchain/manager';
import { getTransportChain } from '../transport/tracker';
import { PlantBlock } from '../blockchain/types';
import crypto from 'crypto';
import { PlantBlock } from '../blockchain/PlantBlock';
import * as crypto from 'crypto';
interface IntegrityCheck {
chainId: string;
@ -131,7 +131,7 @@ export class QualityAssuranceAgent extends BaseAgent {
*/
private async verifyPlantChain(): Promise<IntegrityCheck> {
const blockchain = getBlockchain();
const chain = blockchain.getChain();
const chain = blockchain.chain;
let hashMismatches = 0;
let linkBroken = 0;
@ -205,7 +205,7 @@ export class QualityAssuranceAgent extends BaseAgent {
const issues: DataQualityIssue[] = [];
const blockchain = getBlockchain();
const chain = blockchain.getChain().slice(1);
const chain = blockchain.chain.slice(1);
const seenIds = new Set<string>();
@ -390,7 +390,7 @@ export class QualityAssuranceAgent extends BaseAgent {
*/
private calculateStatistics(): DataStatistics {
const blockchain = getBlockchain();
const chain = blockchain.getChain().slice(1);
const chain = blockchain.chain.slice(1);
let completeRecords = 0;
let partialRecords = 0;

View file

@ -232,7 +232,7 @@ export class SustainabilityAgent extends BaseAgent {
*/
private calculateWaterMetrics(): WaterMetrics {
const blockchain = getBlockchain();
const plantCount = blockchain.getChain().length - 1;
const plantCount = blockchain.chain.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.getChain().slice(1);
const plants = blockchain.chain.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.getChain().slice(1);
const plants = blockchain.chain.slice(1);
const uniqueSpecies = new Set(plants.map(p => p.plant.commonName)).size;
const biodiversity = Math.min(100, 30 + uniqueSpecies * 5);

View file

@ -160,7 +160,7 @@ export interface QualityReport {
blockIndex: number;
issueType: string;
description: string;
severity: 'low' | 'medium' | 'high';
severity: 'low' | 'medium' | 'high' | 'critical';
}[];
lastVerifiedAt: string;
}

406
lib/analytics/aggregator.ts Normal file
View file

@ -0,0 +1,406 @@
/**
* 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)}`;
}

189
lib/analytics/cache.ts Normal file
View file

@ -0,0 +1,189 @@
/**
* 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
}

70
lib/analytics/index.ts Normal file
View file

@ -0,0 +1,70 @@
/**
* 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';

326
lib/analytics/metrics.ts Normal file
View file

@ -0,0 +1,326 @@
/**
* 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 + '%';
}

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