diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1347ac0 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example index 666788e..2f31785 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c7a0342 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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' diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..f48bee5 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -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" diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 0000000..8f254e3 --- /dev/null +++ b/.github/workflows/preview.yml @@ -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()} + + --- + This preview will be automatically deleted when the PR is closed.`; + + 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 diff --git a/.husky/_/husky.sh b/.husky/_/husky.sh new file mode 100755 index 0000000..cec959a --- /dev/null +++ b/.husky/_/husky.sh @@ -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 diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 0000000..c160a77 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx --no -- commitlint --edit ${1} diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..d24fdfc --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx lint-staged diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..08c0169 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,10 @@ +node_modules/ +.next/ +out/ +coverage/ +.git/ +*.min.js +*.min.css +bun.lockb +package-lock.json +yarn.lock diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..01d128a --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "es5", + "tabWidth": 2, + "useTabs": false, + "printWidth": 100, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/Dockerfile b/Dockerfile index fd8bdb5..ea103b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/__tests__/api/agents/lineage.test.ts b/__tests__/api/agents/lineage.test.ts new file mode 100644 index 0000000..e3564b0 --- /dev/null +++ b/__tests__/api/agents/lineage.test.ts @@ -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); + }); +}); diff --git a/__tests__/api/plants.test.ts b/__tests__/api/plants.test.ts new file mode 100644 index 0000000..5cb31a6 --- /dev/null +++ b/__tests__/api/plants.test.ts @@ -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 = {}; + + 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); + }); + }); +}); diff --git a/__tests__/lib/agents/GrowerAdvisoryAgent.test.ts b/__tests__/lib/agents/GrowerAdvisoryAgent.test.ts new file mode 100644 index 0000000..ebc8088 --- /dev/null +++ b/__tests__/lib/agents/GrowerAdvisoryAgent.test.ts @@ -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 }, + ], + }; +} diff --git a/__tests__/setup.ts b/__tests__/setup.ts new file mode 100644 index 0000000..7b2eebe --- /dev/null +++ b/__tests__/setup.ts @@ -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 { + 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)}`; +} diff --git a/__tests__/unit/agents/AgentOrchestrator.test.ts b/__tests__/unit/agents/AgentOrchestrator.test.ts new file mode 100644 index 0000000..a0d149a --- /dev/null +++ b/__tests__/unit/agents/AgentOrchestrator.test.ts @@ -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); + }); + }); +}); diff --git a/__tests__/unit/agents/BaseAgent.test.ts b/__tests__/unit/agents/BaseAgent.test.ts new file mode 100644 index 0000000..1b0e7c1 --- /dev/null +++ b/__tests__/unit/agents/BaseAgent.test.ts @@ -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) { + 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 { + 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) { + 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'); + }); + }); +}); diff --git a/__tests__/unit/agents/PlantLineageAgent.test.ts b/__tests__/unit/agents/PlantLineageAgent.test.ts new file mode 100644 index 0000000..64ceb61 --- /dev/null +++ b/__tests__/unit/agents/PlantLineageAgent.test.ts @@ -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); + }); + }); +}); diff --git a/__tests__/unit/blockchain/PlantChain.test.ts b/__tests__/unit/blockchain/PlantChain.test.ts new file mode 100644 index 0000000..b6073fc --- /dev/null +++ b/__tests__/unit/blockchain/PlantChain.test.ts @@ -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 => ({ + 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); + }); + }); +}); diff --git a/bun.lock b/bun.lock index 8bee58b..75a02f1 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000..3364109 --- /dev/null +++ b/commitlint.config.js @@ -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], + }, +}; diff --git a/components/EnvironmentalForm.tsx b/components/EnvironmentalForm.tsx index 3e8e269..a76fd16 100644 --- a/components/EnvironmentalForm.tsx +++ b/components/EnvironmentalForm.tsx @@ -28,10 +28,11 @@ export default function EnvironmentalForm({ section: K, updates: Partial ) => { + const currentSection = value[section] || {}; onChange({ ...value, [section]: { - ...value[section], + ...(currentSection as object || {}), ...updates, }, }); diff --git a/components/analytics/DataTable.tsx b/components/analytics/DataTable.tsx new file mode 100644 index 0000000..f82b0ed --- /dev/null +++ b/components/analytics/DataTable.tsx @@ -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(null); + const [sortDir, setSortDir] = useState(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 ( + + + + ); + } + if (sortDir === 'asc') { + return ( + + + + ); + } + return ( + + + + ); + }; + + const alignClasses = { + left: 'text-left', + center: 'text-center', + right: 'text-right', + }; + + return ( +
+ {/* Header */} +
+
+ {title &&

{title}

} + {showSearch && ( +
+ { + 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" + /> + + + +
+ )} +
+
+ + {/* Table */} +
+ + + + {columns.map((col) => ( + + ))} + + + + {paginatedData.length === 0 ? ( + + + + ) : ( + paginatedData.map((row, rowIndex) => ( + + {columns.map((col) => ( + + ))} + + )) + )} + +
col.sortable !== false && handleSort(col.key)} + > +
+ {col.header} + {col.sortable !== false && getSortIcon(col.key)} +
+
+ No data available +
+ {col.render ? col.render(row[col.key], row) : row[col.key]} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + Showing {page * pageSize + 1} to {Math.min((page + 1) * pageSize, sortedData.length)} of{' '} + {sortedData.length} results + +
+ + +
+
+ )} +
+ ); +} diff --git a/components/analytics/DateRangePicker.tsx b/components/analytics/DateRangePicker.tsx new file mode 100644 index 0000000..a3e8cf0 --- /dev/null +++ b/components/analytics/DateRangePicker.tsx @@ -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 ( +
+ Time range: +
+ {timeRangeOptions.map((option) => ( + + ))} +
+
+ ); +} diff --git a/components/analytics/FilterPanel.tsx b/components/analytics/FilterPanel.tsx new file mode 100644 index 0000000..fed04bd --- /dev/null +++ b/components/analytics/FilterPanel.tsx @@ -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; + onChange: (values: Record) => 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 ( +
+ {/* Header */} + + + {/* Filter content */} + {isExpanded && ( +
+
+ {filters.map((filter) => ( +
+ + {filter.type === 'select' && filter.options && ( + + )} + {filter.type === 'multiselect' && filter.options && ( +
+ {filter.options.map((opt) => ( + + ))} +
+ )} + {filter.type === 'search' && ( + 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" + /> + )} +
+ ))} +
+ + {/* Actions */} +
+ {onReset && ( + + )} + +
+
+ )} +
+ ); +} diff --git a/components/analytics/KPICard.tsx b/components/analytics/KPICard.tsx new file mode 100644 index 0000000..b8d5e1f --- /dev/null +++ b/components/analytics/KPICard.tsx @@ -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 ( + + + + ); + } + if (trend === 'down') { + return ( + + + + ); + } + return ( + + + + ); + }; + + const getTrendColor = () => { + if (trend === 'up') return 'text-green-600'; + if (trend === 'down') return 'text-red-600'; + return 'text-gray-500'; + }; + + if (loading) { + return ( +
+
+
+
+
+ ); + } + + return ( +
+
+

{title}

+ {icon && {icon}} +
+
+

{value}

+ {unit && {unit}} +
+ {(change !== undefined || changePercent !== undefined) && ( +
+ {getTrendIcon()} + + {changePercent !== undefined + ? `${changePercent > 0 ? '+' : ''}${changePercent.toFixed(1)}%` + : change !== undefined + ? `${change > 0 ? '+' : ''}${change}` + : ''} + + vs prev period +
+ )} +
+ ); +} diff --git a/components/analytics/TrendIndicator.tsx b/components/analytics/TrendIndicator.tsx new file mode 100644 index 0000000..c32977d --- /dev/null +++ b/components/analytics/TrendIndicator.tsx @@ -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 ( + + + + ); + case 'down': + return ( + + + + ); + default: + return ( + + + + ); + } + }; + + const getLabel = () => { + switch (direction) { + case 'up': + return 'Increasing'; + case 'down': + return 'Decreasing'; + default: + return 'Stable'; + } + }; + + return ( +
+ {getIcon()} + {value !== undefined && ( + + {value > 0 ? '+' : ''}{value.toFixed(1)}% + + )} + {showLabel && ( + {getLabel()} + )} +
+ ); +} diff --git a/components/analytics/charts/AreaChart.tsx b/components/analytics/charts/AreaChart.tsx new file mode 100644 index 0000000..4f20b78 --- /dev/null +++ b/components/analytics/charts/AreaChart.tsx @@ -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 ( +
+ {title &&

{title}

} + + + + {yKeys.map((key, index) => ( + + + + + ))} + + {showGrid && } + + + [formatter(value), '']} + /> + {showLegend && } + {yKeys.map((key, index) => ( + + ))} + + +
+ ); +} diff --git a/components/analytics/charts/BarChart.tsx b/components/analytics/charts/BarChart.tsx new file mode 100644 index 0000000..60848fc --- /dev/null +++ b/components/analytics/charts/BarChart.tsx @@ -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 ( +
+ {title &&

{title}

} + + + {showGrid && } + {horizontal ? ( + <> + + + + ) : ( + <> + + + + )} + [formatter(value), '']} + /> + {showLegend && yKeys.length > 1 && } + {yKeys.map((key, index) => ( + + {yKeys.length === 1 && + data.map((entry, i) => ( + + ))} + + ))} + + +
+ ); +} diff --git a/components/analytics/charts/Gauge.tsx b/components/analytics/charts/Gauge.tsx new file mode 100644 index 0000000..98162ff --- /dev/null +++ b/components/analytics/charts/Gauge.tsx @@ -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 ( +
+ {title &&

{title}

} +
+ + + + {gaugeData.map((entry, index) => ( + + ))} + + + +
+ + {value.toFixed(1)} + + {unit} +
+
+
+ 0 + {max / 2} + {max} +
+
+ ); +} diff --git a/components/analytics/charts/Heatmap.tsx b/components/analytics/charts/Heatmap.tsx new file mode 100644 index 0000000..f1d5077 --- /dev/null +++ b/components/analytics/charts/Heatmap.tsx @@ -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 ( +
+ {title &&

{title}

} +
+ {/* X Labels */} +
+ {xLabels.map((label) => ( +
+ {label} +
+ ))} +
+ + {/* Grid */} + {yLabels.map((yLabel) => ( +
+
+ {yLabel} +
+ {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 ( +
+ {showValues && value !== undefined && ( + + {value.toFixed(0)} + + )} +
+ ); + })} +
+ ))} + + {/* Legend */} +
+ Low +
+ High +
+
+
+ ); +} diff --git a/components/analytics/charts/LineChart.tsx b/components/analytics/charts/LineChart.tsx new file mode 100644 index 0000000..492ab56 --- /dev/null +++ b/components/analytics/charts/LineChart.tsx @@ -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 ( +
+ {title &&

{title}

} + + + {showGrid && } + + + [formatter(value), '']} + /> + {showLegend && } + {yKeys.map((key, index) => ( + + ))} + + +
+ ); +} diff --git a/components/analytics/charts/PieChart.tsx b/components/analytics/charts/PieChart.tsx new file mode 100644 index 0000000..4c14fb6 --- /dev/null +++ b/components/analytics/charts/PieChart.tsx @@ -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 ( + cx ? 'start' : 'end'} + dominantBaseline="central" + fontSize="12" + fontWeight="bold" + > + {`${(percent * 100).toFixed(0)}%`} + + ); + }; + + return ( +
+ {title &&

{title}

} + + + + {data.map((entry, index) => ( + + ))} + + [formatter(value), '']} + /> + {showLegend && ( + + )} + + +
+ ); +} diff --git a/components/analytics/charts/index.ts b/components/analytics/charts/index.ts new file mode 100644 index 0000000..00af66d --- /dev/null +++ b/components/analytics/charts/index.ts @@ -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'; diff --git a/components/analytics/index.ts b/components/analytics/index.ts index 6c36352..d02950e 100644 --- a/components/analytics/index.ts +++ b/components/analytics/index.ts @@ -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'; diff --git a/components/auth/AuthGuard.tsx b/components/auth/AuthGuard.tsx new file mode 100644 index 0000000..3b55465 --- /dev/null +++ b/components/auth/AuthGuard.tsx @@ -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 || ( +
+
+
+

Loading...

+
+
+ ) + ) + } + + // Not authenticated + if (!isAuthenticated) { + return ( + fallback || ( +
+
+

Redirecting to sign in...

+
+
+ ) + ) + } + + // Check role requirement + if (requiredRole && user) { + if (!hasRole(user.role, requiredRole)) { + return ( +
+
+
403
+

Access Denied

+

+ You don't have permission to access this page. This page requires{' '} + {requiredRole} role or higher. +

+ +
+
+ ) + } + } + + // Check permission requirement + if (requiredPermission && user) { + if (!hasPermission(user.role, requiredPermission)) { + return ( +
+
+
403
+

Access Denied

+

+ You don't have the required permission to access this page. +

+ +
+
+ ) + } + } + + return <>{children} +} + +// Higher-order component version +export function withAuthGuard

( + Component: React.ComponentType

, + options?: { + requiredRole?: UserRole + requiredPermission?: string + fallback?: React.ReactNode + redirectTo?: string + } +) { + return function AuthGuardedComponent(props: P) { + return ( + + + + ) + } +} + +export default AuthGuard diff --git a/components/auth/LoginForm.tsx b/components/auth/LoginForm.tsx new file mode 100644 index 0000000..958e46a --- /dev/null +++ b/components/auth/LoginForm.tsx @@ -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(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 ( +

+ {error && ( +
+ {error} +
+ )} + +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
+
+ + +
+ + +
+ + +
+ ) +} + +function getErrorMessage(error: string): string { + const errorMessages: Record = { + CredentialsSignin: 'Invalid email or password', + default: 'An error occurred during sign in', + } + return errorMessages[error] ?? errorMessages.default +} + +export default LoginForm diff --git a/components/auth/PasswordResetForm.tsx b/components/auth/PasswordResetForm.tsx new file mode 100644 index 0000000..0f05828 --- /dev/null +++ b/components/auth/PasswordResetForm.tsx @@ -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(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 ( +
+

Password Reset Successful!

+

Your password has been reset successfully.

+
+ ) + } + + return ( +
+ {error && ( +
+ {error} +
+ )} + +
+
+ + 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" + /> +

+ Must contain uppercase, lowercase, and numbers +

+
+ +
+ + 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" + /> +
+
+ + +
+ ) +} + +export default PasswordResetForm diff --git a/components/auth/RegisterForm.tsx b/components/auth/RegisterForm.tsx new file mode 100644 index 0000000..4d80042 --- /dev/null +++ b/components/auth/RegisterForm.tsx @@ -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(null) + + const handleChange = (e: React.ChangeEvent) => { + 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 ( +
+ {error && ( +
+ {error} +
+ )} + +
+
+ + +
+ +
+ + +
+ +
+ + +

+ Must contain uppercase, lowercase, and numbers +

+
+ +
+ + +
+
+ + +
+ ) +} + +export default RegisterForm diff --git a/components/auth/SocialLoginButtons.tsx b/components/auth/SocialLoginButtons.tsx new file mode 100644 index 0000000..52a1706 --- /dev/null +++ b/components/auth/SocialLoginButtons.tsx @@ -0,0 +1,95 @@ +import { signIn } from 'next-auth/react' + +interface SocialLoginButtonsProps { + callbackUrl?: string + providers?: string[] +} + +const providerConfig: Record = { + github: { + name: 'GitHub', + icon: ( + + + + ), + bgColor: 'bg-gray-900 hover:bg-gray-800', + }, + google: { + name: 'Google', + icon: ( + + + + + + + ), + 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 ( +
+ {availableProviders.map((providerId) => { + const config = providerConfig[providerId] + const isGoogle = providerId === 'google' + + return ( + + ) + })} +
+ ) +} + +export function SocialDivider() { + return ( +
+
+
+
+
+ Or continue with +
+
+ ) +} + +export default SocialLoginButtons diff --git a/components/auth/index.ts b/components/auth/index.ts new file mode 100644 index 0000000..3b99559 --- /dev/null +++ b/components/auth/index.ts @@ -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' diff --git a/components/marketplace/ListingCard.tsx b/components/marketplace/ListingCard.tsx new file mode 100644 index 0000000..48bad87 --- /dev/null +++ b/components/marketplace/ListingCard.tsx @@ -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 = { + seeds: 'Seeds', + seedlings: 'Seedlings', + mature_plants: 'Mature Plants', + cuttings: 'Cuttings', + produce: 'Produce', + supplies: 'Supplies', +}; + +const categoryIcons: Record = { + 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 ( + + +
+ {categoryIcons[listing.category] || '🌿'} +
+
+

{listing.title}

+

{categoryLabels[listing.category]}

+
+
+
${listing.price.toFixed(2)}
+
{listing.quantity} avail.
+
+
+ + ); + } + + if (variant === 'featured') { + return ( + + +
+
+ {categoryIcons[listing.category] || '🌿'} +
+
+ + Featured + +
+
+
+
+

+ {listing.title} +

+ + ${listing.price.toFixed(2)} + +
+

+ {listing.description} +

+
+
+ {categoryLabels[listing.category]} + + {listing.quantity} available +
+ + {listing.viewCount} views + +
+
+
+ + ); + } + + // Default variant + return ( + + +
+ {categoryIcons[listing.category] || '🌿'} +
+
+
+

+ {listing.title} +

+ + ${listing.price.toFixed(2)} + +
+

+ {listing.description} +

+
+ {categoryLabels[listing.category]} + {listing.quantity} available +
+ {listing.sellerName && ( +
+ by {listing.sellerName} +
+ )} + {listing.tags.length > 0 && ( +
+ {listing.tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} +
+ )} +
+
+ + ); +} + +export default ListingCard; diff --git a/components/marketplace/ListingForm.tsx b/components/marketplace/ListingForm.tsx new file mode 100644 index 0000000..64c026e --- /dev/null +++ b/components/marketplace/ListingForm.tsx @@ -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; + onSubmit: (data: ListingFormData) => Promise; + 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({ + 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>>({}); + + const handleChange = ( + e: React.ChangeEvent + ) => { + 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> = {}; + + 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 ( +
+ {/* Title */} +
+ + + {errors.title &&

{errors.title}

} +
+ + {/* Description */} +
+ +