Files
zeavis-edu/docs/superpowers/plans/2026-05-22-fullstack-education-catalog.md
T
Asep Haryana Saputra 84c1f46841 Implement fullstack education catalog with disease detail pages, API routes, and shared types
- Add disease detail page component with data fetching and error handling
- Create shared types for diseases and classifications
- Implement API routes for diseases, classifications, and dashboard summary
- Develop reusable components for risk badge and disease card
- Build catalog page with search and filter functionality
- Update dashboard page with data-backed summary and manual classification form
- Register new routes in the web application
- Ensure type safety and consistency across shared modules
2026-05-22 14:26:13 +00:00

30 KiB

Fullstack Education Catalog Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a PostgreSQL-backed ZeaVis Edu education catalog with dashboard, disease detail pages, manual classification submission, history, and statistics.

Architecture: Shared TypeScript types define the disease/classification contract. The Elysia API exposes /api/v1 catalog, classification, and dashboard endpoints backed by Drizzle/PostgreSQL. The React app uses TanStack Query and React Router to render landing, dashboard, catalog, detail, and manual classification flows.

Tech Stack: Bun workspaces, Moon tasks, TypeScript, Elysia, Drizzle ORM, PostgreSQL, React, Vite, React Router, TanStack Query, Tailwind CSS.


File structure

  • Modify packages/shared/src/app-status.ts: keep existing app status helper.
  • Create packages/shared/src/diseases.ts: labels, slugs, risk levels, domain types, seed records, mapping helpers.
  • Create packages/shared/src/classifications.ts: manual classification request/response and dashboard summary types.
  • Modify packages/shared/src/index.ts: export new shared modules.
  • Modify apps/api/src/db/schema.ts: add diseaseCatalog and manualClassifications Drizzle tables.
  • Create apps/api/src/db/seed-data.ts: map shared seed catalog to insertable database rows.
  • Create apps/api/src/lib/http-errors.ts: small JSON error helpers.
  • Create apps/api/src/routes/diseases.ts: catalog and detail endpoints.
  • Create apps/api/src/routes/classifications.ts: manual submission and history endpoints.
  • Create apps/api/src/routes/dashboard.ts: summary endpoint.
  • Modify apps/api/src/index.ts: register new routes.
  • Create apps/web/src/lib/api-client.ts: typed fetch helpers.
  • Create apps/web/src/components/risk-badge.tsx: reusable risk badge.
  • Create apps/web/src/components/disease-card.tsx: reusable disease card.
  • Create apps/web/src/components/manual-classification-form.tsx: submission form.
  • Create apps/web/src/pages/catalog-page.tsx: searchable/filterable catalog.
  • Create apps/web/src/pages/disease-detail-page.tsx: disease detail page.
  • Modify apps/web/src/pages/dashboard-page.tsx: data-backed summary/history/manual form.
  • Modify apps/web/src/pages/landing-page.tsx: product-ready landing content.
  • Modify apps/web/src/app.tsx: add routes.

Task 1: Shared disease domain

Files:

  • Create: packages/shared/src/diseases.ts

  • Create: packages/shared/src/classifications.ts

  • Modify: packages/shared/src/index.ts

  • Step 1: Create disease domain types and seed records

Write packages/shared/src/diseases.ts:

export const diseaseLabels = ['Bercak Daun', 'Hawar Daun', 'Karat Daun', 'Daun Sehat'] as const;
export type DiseaseLabel = (typeof diseaseLabels)[number];

export const diseaseSlugs = ['bercak-daun', 'hawar-daun', 'karat-daun', 'daun-sehat'] as const;
export type DiseaseSlug = (typeof diseaseSlugs)[number];

export const riskLevels = ['low', 'medium', 'high'] as const;
export type RiskLevel = (typeof riskLevels)[number];

export type DiseaseCatalogItem = {
  slug: DiseaseSlug;
  label: DiseaseLabel;
  commonName: string;
  summary: string;
  description: string;
  symptoms: string[];
  recommendations: string[];
  riskLevel: RiskLevel;
  accentColor: string;
  displayOrder: number;
};

export const diseaseCatalogSeed: DiseaseCatalogItem[] = [
  {
    slug: 'bercak-daun',
    label: 'Bercak Daun',
    commonName: 'Gray Leaf Spot',
    summary: 'Penyakit jamur yang membentuk bercak abu-abu kecokelatan memanjang pada daun jagung.',
    description:
      'Bercak Daun biasanya berkembang pada kondisi lembap dan dapat mengurangi area fotosintesis jika tidak dipantau sejak awal.',
    symptoms: ['Bercak memanjang berwarna abu-abu kecokelatan', 'Lesi dibatasi tulang daun', 'Daun tampak mengering pada infeksi berat'],
    recommendations: ['Pantau kelembapan lahan', 'Gunakan varietas tahan bila tersedia', 'Bersihkan sisa tanaman terinfeksi'],
    riskLevel: 'medium',
    accentColor: 'slate',
    displayOrder: 1,
  },
  {
    slug: 'hawar-daun',
    label: 'Hawar Daun',
    commonName: 'Leaf Blight',
    summary: 'Penyakit hawar yang menimbulkan area nekrotik luas dan dapat menyebar cepat pada daun.',
    description:
      'Hawar Daun perlu ditangani serius karena gejalanya dapat meluas dan menurunkan kesehatan tanaman secara signifikan.',
    symptoms: ['Bercak lonjong besar berwarna cokelat', 'Tepi bercak terlihat basah atau menguning', 'Daun mengering dari area infeksi'],
    recommendations: ['Pisahkan tanaman dengan gejala berat', 'Perbaiki sirkulasi udara tanaman', 'Konsultasikan penggunaan fungisida sesuai rekomendasi setempat'],
    riskLevel: 'high',
    accentColor: 'amber',
    displayOrder: 2,
  },
  {
    slug: 'karat-daun',
    label: 'Karat Daun',
    commonName: 'Common Rust',
    summary: 'Infeksi jamur yang terlihat sebagai pustula kecil berwarna jingga hingga cokelat pada permukaan daun.',
    description:
      'Karat Daun sering mudah dikenali dari pustula seperti serbuk karat dan perlu dipantau agar tidak menyebar di lahan.',
    symptoms: ['Bintik/pustula jingga kecokelatan', 'Serbuk spora menempel di permukaan daun', 'Daun menguning pada serangan lanjut'],
    recommendations: ['Pantau daun bagian bawah dan tengah', 'Kurangi kelembapan berlebih', 'Gunakan benih atau varietas yang lebih tahan'],
    riskLevel: 'medium',
    accentColor: 'orange',
    displayOrder: 3,
  },
  {
    slug: 'daun-sehat',
    label: 'Daun Sehat',
    commonName: 'Healthy Leaf',
    summary: 'Daun jagung tampak normal tanpa gejala utama penyakit target ZeaVis Edu.',
    description:
      'Daun sehat tetap perlu dipantau berkala karena kondisi lahan dapat berubah dan gejala awal sering terlihat samar.',
    symptoms: ['Warna hijau merata', 'Tidak ada lesi atau pustula', 'Bentuk daun normal dan tidak mengering'],
    recommendations: ['Lanjutkan pemantauan rutin', 'Jaga sanitasi lahan', 'Catat perubahan visual sejak dini'],
    riskLevel: 'low',
    accentColor: 'emerald',
    displayOrder: 4,
  },
];

export function isDiseaseSlug(value: string): value is DiseaseSlug {
  return diseaseSlugs.includes(value as DiseaseSlug);
}

export function getDiseaseBySlug(slug: string): DiseaseCatalogItem | undefined {
  return diseaseCatalogSeed.find((item) => item.slug === slug);
}
  • Step 2: Create classification and dashboard shared types

Write packages/shared/src/classifications.ts:

import type { DiseaseCatalogItem, DiseaseSlug, RiskLevel } from './diseases';

export type ManualClassificationRequest = {
  diseaseSlug: DiseaseSlug;
  observation: string;
  location: string;
};

export type ManualClassificationRecord = {
  id: string;
  diseaseSlug: DiseaseSlug;
  observation: string;
  location: string;
  createdAt: string;
  disease: DiseaseCatalogItem;
};

export type DashboardSummary = {
  diseaseCount: number;
  classificationCount: number;
  latestClassification: ManualClassificationRecord | null;
  riskDistribution: Record<RiskLevel, number>;
};
  • Step 3: Export shared modules

Modify packages/shared/src/index.ts to:

export type { AppStatus } from './app-status';
export { createAppStatus } from './app-status';
export type { DiseaseCatalogItem, DiseaseLabel, DiseaseSlug, RiskLevel } from './diseases';
export { diseaseCatalogSeed, diseaseLabels, diseaseSlugs, getDiseaseBySlug, isDiseaseSlug, riskLevels } from './diseases';
export type { DashboardSummary, ManualClassificationRecord, ManualClassificationRequest } from './classifications';
  • Step 4: Verify shared package typecheck

Run: bun --filter @zeavis/shared run typecheck

Expected: TypeScript exits with code 0.

  • Step 5: Commit
git add packages/shared/src/diseases.ts packages/shared/src/classifications.ts packages/shared/src/index.ts
git commit -m "Add shared education catalog domain"

Task 2: Database schema and API routes

Files:

  • Modify: apps/api/src/db/schema.ts

  • Create: apps/api/src/db/seed-data.ts

  • Create: apps/api/src/lib/http-errors.ts

  • Create: apps/api/src/routes/diseases.ts

  • Create: apps/api/src/routes/classifications.ts

  • Create: apps/api/src/routes/dashboard.ts

  • Modify: apps/api/src/index.ts

  • Step 1: Replace database schema

Write apps/api/src/db/schema.ts:

import { pgTable, text, timestamp, uuid, varchar, integer } from 'drizzle-orm/pg-core';

export const diseaseCatalog = pgTable('disease_catalog', {
  slug: varchar('slug', { length: 80 }).primaryKey(),
  label: varchar('label', { length: 80 }).notNull(),
  commonName: varchar('common_name', { length: 120 }).notNull(),
  summary: text('summary').notNull(),
  description: text('description').notNull(),
  symptoms: text('symptoms').array().notNull(),
  recommendations: text('recommendations').array().notNull(),
  riskLevel: varchar('risk_level', { length: 20 }).notNull(),
  accentColor: varchar('accent_color', { length: 40 }).notNull(),
  displayOrder: integer('display_order').notNull(),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});

export const manualClassifications = pgTable('manual_classifications', {
  id: uuid('id').primaryKey().defaultRandom(),
  diseaseSlug: varchar('disease_slug', { length: 80 }).notNull().references(() => diseaseCatalog.slug),
  observation: text('observation').notNull(),
  location: varchar('location', { length: 160 }).notNull(),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
  • Step 2: Add seed data mapper

Write apps/api/src/db/seed-data.ts:

import { diseaseCatalogSeed } from '@zeavis/shared';

export const diseaseCatalogRows = diseaseCatalogSeed.map((item) => ({
  slug: item.slug,
  label: item.label,
  commonName: item.commonName,
  summary: item.summary,
  description: item.description,
  symptoms: item.symptoms,
  recommendations: item.recommendations,
  riskLevel: item.riskLevel,
  accentColor: item.accentColor,
  displayOrder: item.displayOrder,
}));
  • Step 3: Add HTTP error helpers

Write apps/api/src/lib/http-errors.ts:

export function badRequest(message: string) {
  return new Response(JSON.stringify({ error: message }), {
    status: 400,
    headers: { 'content-type': 'application/json' },
  });
}

export function notFound(message: string) {
  return new Response(JSON.stringify({ error: message }), {
    status: 404,
    headers: { 'content-type': 'application/json' },
  });
}

export function serviceUnavailable(message: string) {
  return new Response(JSON.stringify({ error: message }), {
    status: 503,
    headers: { 'content-type': 'application/json' },
  });
}
  • Step 4: Add diseases route

Write apps/api/src/routes/diseases.ts:

import type { DiseaseCatalogItem, DiseaseSlug, RiskLevel } from '@zeavis/shared';
import { asc, eq } from 'drizzle-orm';
import { Elysia } from 'elysia';
import { createDbClient } from '../db/client';
import { diseaseCatalog } from '../db/schema';
import { notFound, serviceUnavailable } from '../lib/http-errors';

function toDisease(row: typeof diseaseCatalog.$inferSelect): DiseaseCatalogItem {
  return {
    slug: row.slug as DiseaseSlug,
    label: row.label as DiseaseCatalogItem['label'],
    commonName: row.commonName,
    summary: row.summary,
    description: row.description,
    symptoms: row.symptoms,
    recommendations: row.recommendations,
    riskLevel: row.riskLevel as RiskLevel,
    accentColor: row.accentColor,
    displayOrder: row.displayOrder,
  };
}

export const diseaseRoutes = new Elysia({ prefix: '/api/v1' })
  .get('/diseases', async () => {
    try {
      const db = createDbClient();
      const rows = await db.select().from(diseaseCatalog).orderBy(asc(diseaseCatalog.displayOrder));
      return rows.map(toDisease);
    } catch {
      return serviceUnavailable('Database is unavailable');
    }
  })
  .get('/diseases/:slug', async ({ params }) => {
    try {
      const db = createDbClient();
      const [row] = await db.select().from(diseaseCatalog).where(eq(diseaseCatalog.slug, params.slug)).limit(1);
      if (!row) {
        return notFound('Disease was not found');
      }
      return toDisease(row);
    } catch {
      return serviceUnavailable('Database is unavailable');
    }
  });
  • Step 5: Add classifications route

Write apps/api/src/routes/classifications.ts:

import type { DiseaseCatalogItem, DiseaseSlug, ManualClassificationRecord, RiskLevel } from '@zeavis/shared';
import { desc, eq } from 'drizzle-orm';
import { Elysia } from 'elysia';
import { createDbClient } from '../db/client';
import { diseaseCatalog, manualClassifications } from '../db/schema';
import { badRequest, serviceUnavailable } from '../lib/http-errors';

function toRecord(row: { manual_classifications: typeof manualClassifications.$inferSelect; disease_catalog: typeof diseaseCatalog.$inferSelect }): ManualClassificationRecord {
  return {
    id: row.manual_classifications.id,
    diseaseSlug: row.manual_classifications.diseaseSlug as DiseaseSlug,
    observation: row.manual_classifications.observation,
    location: row.manual_classifications.location,
    createdAt: row.manual_classifications.createdAt.toISOString(),
    disease: {
      slug: row.disease_catalog.slug as DiseaseSlug,
      label: row.disease_catalog.label as DiseaseCatalogItem['label'],
      commonName: row.disease_catalog.commonName,
      summary: row.disease_catalog.summary,
      description: row.disease_catalog.description,
      symptoms: row.disease_catalog.symptoms,
      recommendations: row.disease_catalog.recommendations,
      riskLevel: row.disease_catalog.riskLevel as RiskLevel,
      accentColor: row.disease_catalog.accentColor,
      displayOrder: row.disease_catalog.displayOrder,
    },
  };
}

export const classificationRoutes = new Elysia({ prefix: '/api/v1' })
  .get('/classifications/manual', async () => {
    try {
      const db = createDbClient();
      const rows = await db
        .select()
        .from(manualClassifications)
        .innerJoin(diseaseCatalog, eq(manualClassifications.diseaseSlug, diseaseCatalog.slug))
        .orderBy(desc(manualClassifications.createdAt))
        .limit(20);
      return rows.map(toRecord);
    } catch {
      return serviceUnavailable('Database is unavailable');
    }
  })
  .post('/classifications/manual', async ({ body }) => {
    const payload = body as { diseaseSlug?: string; observation?: string; location?: string };
    if (!payload.diseaseSlug || !payload.observation || !payload.location) {
      return badRequest('diseaseSlug, observation, and location are required');
    }

    try {
      const db = createDbClient();
      const [disease] = await db.select().from(diseaseCatalog).where(eq(diseaseCatalog.slug, payload.diseaseSlug)).limit(1);
      if (!disease) {
        return badRequest('diseaseSlug is not registered in the catalog');
      }

      const [created] = await db
        .insert(manualClassifications)
        .values({ diseaseSlug: payload.diseaseSlug, observation: payload.observation, location: payload.location })
        .returning();

      return {
        id: created.id,
        diseaseSlug: created.diseaseSlug as DiseaseSlug,
        observation: created.observation,
        location: created.location,
        createdAt: created.createdAt.toISOString(),
        disease: {
          slug: disease.slug as DiseaseSlug,
          label: disease.label as DiseaseCatalogItem['label'],
          commonName: disease.commonName,
          summary: disease.summary,
          description: disease.description,
          symptoms: disease.symptoms,
          recommendations: disease.recommendations,
          riskLevel: disease.riskLevel as RiskLevel,
          accentColor: disease.accentColor,
          displayOrder: disease.displayOrder,
        },
      } satisfies ManualClassificationRecord;
    } catch {
      return serviceUnavailable('Database is unavailable');
    }
  });
  • Step 6: Add dashboard route

Write apps/api/src/routes/dashboard.ts:

import type { DashboardSummary, DiseaseCatalogItem, DiseaseSlug, ManualClassificationRecord, RiskLevel } from '@zeavis/shared';
import { count, desc, eq } from 'drizzle-orm';
import { Elysia } from 'elysia';
import { createDbClient } from '../db/client';
import { diseaseCatalog, manualClassifications } from '../db/schema';
import { serviceUnavailable } from '../lib/http-errors';

export const dashboardRoutes = new Elysia({ prefix: '/api/v1' }).get('/dashboard/summary', async () => {
  try {
    const db = createDbClient();
    const diseases = await db.select().from(diseaseCatalog);
    const [{ value: classificationCount }] = await db.select({ value: count() }).from(manualClassifications);
    const [latestRow] = await db
      .select()
      .from(manualClassifications)
      .innerJoin(diseaseCatalog, eq(manualClassifications.diseaseSlug, diseaseCatalog.slug))
      .orderBy(desc(manualClassifications.createdAt))
      .limit(1);

    const riskDistribution = diseases.reduce<DashboardSummary['riskDistribution']>(
      (totals, disease) => {
        totals[disease.riskLevel as RiskLevel] += 1;
        return totals;
      },
      { low: 0, medium: 0, high: 0 },
    );

    const latestClassification: ManualClassificationRecord | null = latestRow
      ? {
          id: latestRow.manual_classifications.id,
          diseaseSlug: latestRow.manual_classifications.diseaseSlug as DiseaseSlug,
          observation: latestRow.manual_classifications.observation,
          location: latestRow.manual_classifications.location,
          createdAt: latestRow.manual_classifications.createdAt.toISOString(),
          disease: {
            slug: latestRow.disease_catalog.slug as DiseaseSlug,
            label: latestRow.disease_catalog.label as DiseaseCatalogItem['label'],
            commonName: latestRow.disease_catalog.commonName,
            summary: latestRow.disease_catalog.summary,
            description: latestRow.disease_catalog.description,
            symptoms: latestRow.disease_catalog.symptoms,
            recommendations: latestRow.disease_catalog.recommendations,
            riskLevel: latestRow.disease_catalog.riskLevel as RiskLevel,
            accentColor: latestRow.disease_catalog.accentColor,
            displayOrder: latestRow.disease_catalog.displayOrder,
          },
        }
      : null;

    return { diseaseCount: diseases.length, classificationCount, latestClassification, riskDistribution } satisfies DashboardSummary;
  } catch {
    return serviceUnavailable('Database is unavailable');
  }
});
  • Step 7: Register API routes

Modify apps/api/src/index.ts:

import { Elysia } from 'elysia';
import { env } from './config/env';
import { dashboardRoutes } from './routes/dashboard';
import { classificationRoutes } from './routes/classifications';
import { diseaseRoutes } from './routes/diseases';
import { healthRoutes } from './routes/health';
import { statusRoutes } from './routes/status';

const app = new Elysia().use(healthRoutes).use(statusRoutes).use(diseaseRoutes).use(classificationRoutes).use(dashboardRoutes).listen(env.port);

console.log(`ZeaVis Edu API running at http://${app.server?.hostname}:${app.server?.port}`);

export type App = typeof app;
  • Step 8: Generate migration and typecheck API

Run: bun --filter @zeavis/api run db:generate

Expected: Drizzle creates a migration under apps/api/drizzle/.

Run: bun --filter @zeavis/api run typecheck

Expected: TypeScript exits with code 0.

  • Step 9: Commit
git add apps/api/src apps/api/drizzle
git commit -m "Add education catalog API and schema"

Task 3: Web API client and reusable components

Files:

  • Create: apps/web/src/lib/api-client.ts

  • Create: apps/web/src/components/risk-badge.tsx

  • Create: apps/web/src/components/disease-card.tsx

  • Create: apps/web/src/components/manual-classification-form.tsx

  • Step 1: Add typed API client

Write apps/web/src/lib/api-client.ts:

import type { DashboardSummary, DiseaseCatalogItem, DiseaseSlug, ManualClassificationRecord, ManualClassificationRequest } from '@zeavis/shared';

const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '';

async function request<T>(path: string, init?: RequestInit): Promise<T> {
  const response = await fetch(`${apiBaseUrl}${path}`, {
    headers: { 'content-type': 'application/json', ...init?.headers },
    ...init,
  });

  if (!response.ok) {
    const fallback = `Request failed with status ${response.status}`;
    const payload = (await response.json().catch(() => ({ error: fallback }))) as { error?: string };
    throw new Error(payload.error ?? fallback);
  }

  return response.json() as Promise<T>;
}

export const apiClient = {
  getDiseases: () => request<DiseaseCatalogItem[]>('/api/v1/diseases'),
  getDisease: (slug: DiseaseSlug) => request<DiseaseCatalogItem>(`/api/v1/diseases/${slug}`),
  getManualClassifications: () => request<ManualClassificationRecord[]>('/api/v1/classifications/manual'),
  createManualClassification: (payload: ManualClassificationRequest) =>
    request<ManualClassificationRecord>('/api/v1/classifications/manual', { method: 'POST', body: JSON.stringify(payload) }),
  getDashboardSummary: () => request<DashboardSummary>('/api/v1/dashboard/summary'),
};
  • Step 2: Add risk badge

Write apps/web/src/components/risk-badge.tsx:

import type { RiskLevel } from '@zeavis/shared';

const riskLabels: Record<RiskLevel, string> = {
  low: 'Risiko rendah',
  medium: 'Risiko sedang',
  high: 'Risiko tinggi',
};

const riskClasses: Record<RiskLevel, string> = {
  low: 'bg-emerald-100 text-emerald-800',
  medium: 'bg-amber-100 text-amber-800',
  high: 'bg-red-100 text-red-800',
};

export function RiskBadge({ riskLevel }: { riskLevel: RiskLevel }) {
  return <span className={`rounded-full px-3 py-1 text-xs font-semibold ${riskClasses[riskLevel]}`}>{riskLabels[riskLevel]}</span>;
}
  • Step 3: Add disease card

Write apps/web/src/components/disease-card.tsx:

import type { DiseaseCatalogItem } from '@zeavis/shared';
import { ArrowRight } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { RiskBadge } from '@/components/risk-badge';

export function DiseaseCard({ disease }: { disease: DiseaseCatalogItem }) {
  return (
    <Card className="h-full transition hover:-translate-y-1 hover:shadow-md">
      <CardHeader className="space-y-4">
        <div className="flex items-center justify-between gap-3">
          <RiskBadge riskLevel={disease.riskLevel} />
          <span className="text-sm text-muted-foreground">{disease.commonName}</span>
        </div>
        <div>
          <CardTitle>{disease.label}</CardTitle>
          <CardDescription className="mt-2 leading-6">{disease.summary}</CardDescription>
        </div>
        <ul className="space-y-2 text-sm text-muted-foreground">
          {disease.symptoms.slice(0, 2).map((symptom) => (
            <li key={symptom}> {symptom}</li>
          ))}
        </ul>
        <Link className="inline-flex items-center text-sm font-semibold text-primary" to={`/catalog/${disease.slug}`}>
          Pelajari detail <ArrowRight className="ml-2 h-4 w-4" />
        </Link>
      </CardHeader>
    </Card>
  );
}
  • Step 4: Add manual classification form

Write apps/web/src/components/manual-classification-form.tsx:

import type { DiseaseCatalogItem, DiseaseSlug, ManualClassificationRequest } from '@zeavis/shared';
import { useState } from 'react';
import { Button } from '@/components/ui/button';

export function ManualClassificationForm({ diseases, onSubmit, isSubmitting }: { diseases: DiseaseCatalogItem[]; onSubmit: (payload: ManualClassificationRequest) => void; isSubmitting: boolean }) {
  const [diseaseSlug, setDiseaseSlug] = useState<DiseaseSlug>(diseases[0]?.slug ?? 'daun-sehat');
  const [observation, setObservation] = useState('Daun diamati secara manual melalui aplikasi ZeaVis Edu.');
  const [location, setLocation] = useState('Lahan observasi');

  return (
    <form
      className="space-y-4 rounded-3xl border bg-card p-5"
      onSubmit={(event) => {
        event.preventDefault();
        onSubmit({ diseaseSlug, observation, location });
      }}
    >
      <div>
        <label className="text-sm font-medium" htmlFor="diseaseSlug">Label hasil pengamatan</label>
        <select id="diseaseSlug" className="mt-2 w-full rounded-2xl border bg-background px-4 py-3" value={diseaseSlug} onChange={(event) => setDiseaseSlug(event.target.value as DiseaseSlug)}>
          {diseases.map((disease) => (
            <option key={disease.slug} value={disease.slug}>{disease.label}</option>
          ))}
        </select>
      </div>
      <div>
        <label className="text-sm font-medium" htmlFor="observation">Catatan observasi</label>
        <textarea id="observation" className="mt-2 min-h-24 w-full rounded-2xl border bg-background px-4 py-3" value={observation} onChange={(event) => setObservation(event.target.value)} />
      </div>
      <div>
        <label className="text-sm font-medium" htmlFor="location">Lokasi/catatan lahan</label>
        <input id="location" className="mt-2 w-full rounded-2xl border bg-background px-4 py-3" value={location} onChange={(event) => setLocation(event.target.value)} />
      </div>
      <Button disabled={isSubmitting || diseases.length === 0} type="submit">Simpan klasifikasi manual</Button>
    </form>
  );
}
  • Step 5: Typecheck web components

Run: bun --filter @zeavis/web run typecheck

Expected: TypeScript exits with code 0.

  • Step 6: Commit
git add apps/web/src/lib/api-client.ts apps/web/src/components
git commit -m "Add web catalog API client and components"

Task 4: Web pages and routes

Files:

  • Modify: apps/web/src/app.tsx

  • Modify: apps/web/src/pages/landing-page.tsx

  • Modify: apps/web/src/pages/dashboard-page.tsx

  • Create: apps/web/src/pages/catalog-page.tsx

  • Create: apps/web/src/pages/disease-detail-page.tsx

  • Step 1: Add catalog page

Write apps/web/src/pages/catalog-page.tsx with a TanStack Query call to apiClient.getDiseases(), a search input, risk filter select, and DiseaseCard grid. The page must show loading text Memuat katalog..., error text Katalog belum tersedia, and an empty state Tidak ada penyakit yang cocok dengan filter ini..

  • Step 2: Add disease detail page

Write apps/web/src/pages/disease-detail-page.tsx using useParams, isDiseaseSlug, and apiClient.getDisease(slug). Render description, symptoms, recommendations, risk badge, and links back to /catalog and /dashboard. Invalid slugs should render Materi tidak ditemukan.

  • Step 3: Replace dashboard page with data-backed dashboard

Modify apps/web/src/pages/dashboard-page.tsx to query diseases, summary, and manual classification history. Include summary cards, risk distribution, recent history, and ManualClassificationForm. Use useMutation for apiClient.createManualClassification, then invalidate dashboard-summary and manual-classifications queries.

  • Step 4: Update landing page

Modify apps/web/src/pages/landing-page.tsx so the primary CTA links to /dashboard, secondary CTA links to /catalog, and copy describes education catalog plus manual observation tracking instead of scaffold placeholders.

  • Step 5: Register routes

Modify apps/web/src/app.tsx:

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { CatalogPage } from '@/pages/catalog-page';
import { DashboardPage } from '@/pages/dashboard-page';
import { DiseaseDetailPage } from '@/pages/disease-detail-page';
import { LandingPage } from '@/pages/landing-page';

const queryClient = new QueryClient();

const router = createBrowserRouter([
  { path: '/', element: <LandingPage /> },
  { path: '/dashboard', element: <DashboardPage /> },
  { path: '/catalog', element: <CatalogPage /> },
  { path: '/catalog/:slug', element: <DiseaseDetailPage /> },
]);

export function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
    </QueryClientProvider>
  );
}
  • Step 6: Typecheck web pages

Run: bun --filter @zeavis/web run typecheck

Expected: TypeScript exits with code 0.

  • Step 7: Commit
git add apps/web/src/app.tsx apps/web/src/pages
git commit -m "Build education catalog web experience"

Task 5: Verification and manual run

Files:

  • Modify only if verification exposes a concrete bug.

  • Step 1: Run full typecheck

Run: bun run typecheck

Expected: all Moon typecheck tasks pass.

  • Step 2: Run production build

Run: bun run build

Expected: API/shared/web builds complete successfully.

  • Step 3: Start API and web

Run API in one terminal: bun --filter @zeavis/api run start

Run web in another terminal: bun --filter @zeavis/web run dev

Expected: API logs ZeaVis Edu API running...; Vite logs a local URL.

  • Step 4: Browser-check frontend flow

Open the Vite URL. Check /, /dashboard, /catalog, and one /catalog/:slug page. If no PostgreSQL database is configured, verify error states render instead of a blank screen. If DATABASE_URL and migrated/seeded data are available, submit one manual classification and verify it appears in dashboard/history.

  • Step 5: Final commit for verification fixes

If fixes were required:

git add <fixed-files>
git commit -m "Fix education catalog verification issues"

If no fixes were required, do not create an empty commit.


Self-review notes

  • Spec coverage: shared contract, backend schema/routes, frontend pages/manual flow, error states, and verification are all covered.
  • Placeholder scan: no TBD/TODO/fill-later placeholders are present. Task 4 uses explicit behavior requirements for page files because page markup is lengthy, but all required states and wiring are specified.
  • Type consistency: shared names (DiseaseSlug, DiseaseCatalogItem, ManualClassificationRequest, ManualClassificationRecord, DashboardSummary) are consistent across tasks.