From c0471d1fe10fe350e31de0a6713b4617959a311e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 1 Nov 2026 21:20:01 -0800 Subject: [PATCH] feat(scripts): load development seed projects from test/seed files --- docs/DEV.md | 17 + package.json | 1 + scripts/seed-projects.ts | 950 ++++++++++++++++++ test/integration/seed-projects.test.ts | 301 ++++++ .../clinical-trial-intake/app/ACCEPTANCE.md | 6 + .../seed/clinical-trial-intake/app/index.html | 13 + .../clinical-trial-intake/app/package.json | 10 + .../app/server/src/data/demo-account.json | 4 + .../app/server/src/index.ts | 6 + .../clinical-trial-intake/app/src/App.tsx | 8 + .../clinical-trial-intake/app/src/main.tsx | 5 + .../clinical-trial-intake/app/src/styles.css | 63 ++ .../designs/editorial/DESIGN.md | 74 ++ .../designs/editorial/index.html | 23 + .../designs/editorial/screens/home.html | 23 + .../designs/editorial/styles.css | 63 ++ .../designs/rounded-enterprise/DESIGN.md | 93 ++ .../designs/rounded-enterprise/index.html | 109 ++ .../rounded-enterprise/screens/home.html | 109 ++ .../designs/rounded-enterprise/styles.css | 63 ++ .../clinical-trial-intake/docs/approaches.md | 13 + .../clinical-trial-intake/docs/build_plan.md | 23 + .../clinical-trial-intake/docs/constraints.md | 15 + .../seed/clinical-trial-intake/docs/design.md | 93 ++ .../docs/problem_brief.md | 17 + .../docs/product_requirements.md | 15 + test/seed/clinical-trial-intake/project.json | 103 ++ .../field-service-dispatch/app/ACCEPTANCE.md | 5 + .../field-service-dispatch/app/index.html | 13 + .../field-service-dispatch/app/package.json | 10 + .../app/server/src/data/demo-account.json | 4 + .../app/server/src/index.ts | 6 + .../field-service-dispatch/app/src/App.tsx | 8 + .../field-service-dispatch/app/src/main.tsx | 5 + .../field-service-dispatch/app/src/styles.css | 63 ++ .../designs/boxy-enterprise/DESIGN.md | 74 ++ .../designs/boxy-enterprise/index.html | 23 + .../designs/boxy-enterprise/screens/home.html | 23 + .../designs/boxy-enterprise/styles.css | 63 ++ .../designs/modern-and-sleek/DESIGN.md | 81 ++ .../designs/modern-and-sleek/index.html | 81 ++ .../modern-and-sleek/screens/home.html | 81 ++ .../designs/modern-and-sleek/styles.css | 63 ++ .../field-service-dispatch/docs/approaches.md | 13 + .../field-service-dispatch/docs/build_plan.md | 20 + .../docs/constraints.md | 11 + .../field-service-dispatch/docs/design.md | 81 ++ .../docs/problem_brief.md | 17 + .../docs/product_requirements.md | 10 + test/seed/field-service-dispatch/project.json | 105 ++ test/seed/habit-tracker/app/ACCEPTANCE.md | 5 + test/seed/habit-tracker/app/index.html | 13 + test/seed/habit-tracker/app/package.json | 10 + .../app/server/src/data/demo-account.json | 4 + .../habit-tracker/app/server/src/index.ts | 6 + test/seed/habit-tracker/app/src/App.tsx | 8 + test/seed/habit-tracker/app/src/main.tsx | 5 + test/seed/habit-tracker/app/src/styles.css | 75 ++ .../designs/minimal-monochrome/DESIGN.md | 74 ++ .../designs/minimal-monochrome/index.html | 22 + .../minimal-monochrome/screens/home.html | 22 + .../designs/minimal-monochrome/styles.css | 75 ++ .../designs/warm-and-organic/DESIGN.md | 75 ++ .../designs/warm-and-organic/index.html | 70 ++ .../warm-and-organic/screens/home.html | 70 ++ .../designs/warm-and-organic/styles.css | 75 ++ test/seed/habit-tracker/docs/approaches.md | 13 + test/seed/habit-tracker/docs/build_plan.md | 18 + test/seed/habit-tracker/docs/constraints.md | 12 + test/seed/habit-tracker/docs/design.md | 75 ++ test/seed/habit-tracker/docs/problem_brief.md | 21 + .../docs/product_requirements.md | 8 + test/seed/habit-tracker/project.json | 99 ++ test/seed/index.ts | 279 +++++ tsconfig.json | 1 + 75 files changed, 4215 insertions(+) create mode 100644 scripts/seed-projects.ts create mode 100644 test/integration/seed-projects.test.ts create mode 100644 test/seed/clinical-trial-intake/app/ACCEPTANCE.md create mode 100644 test/seed/clinical-trial-intake/app/index.html create mode 100644 test/seed/clinical-trial-intake/app/package.json create mode 100644 test/seed/clinical-trial-intake/app/server/src/data/demo-account.json create mode 100644 test/seed/clinical-trial-intake/app/server/src/index.ts create mode 100644 test/seed/clinical-trial-intake/app/src/App.tsx create mode 100644 test/seed/clinical-trial-intake/app/src/main.tsx create mode 100644 test/seed/clinical-trial-intake/app/src/styles.css create mode 100644 test/seed/clinical-trial-intake/designs/editorial/DESIGN.md create mode 100644 test/seed/clinical-trial-intake/designs/editorial/index.html create mode 100644 test/seed/clinical-trial-intake/designs/editorial/screens/home.html create mode 100644 test/seed/clinical-trial-intake/designs/editorial/styles.css create mode 100644 test/seed/clinical-trial-intake/designs/rounded-enterprise/DESIGN.md create mode 100644 test/seed/clinical-trial-intake/designs/rounded-enterprise/index.html create mode 100644 test/seed/clinical-trial-intake/designs/rounded-enterprise/screens/home.html create mode 100644 test/seed/clinical-trial-intake/designs/rounded-enterprise/styles.css create mode 100644 test/seed/clinical-trial-intake/docs/approaches.md create mode 100644 test/seed/clinical-trial-intake/docs/build_plan.md create mode 100644 test/seed/clinical-trial-intake/docs/constraints.md create mode 100644 test/seed/clinical-trial-intake/docs/design.md create mode 100644 test/seed/clinical-trial-intake/docs/problem_brief.md create mode 100644 test/seed/clinical-trial-intake/docs/product_requirements.md create mode 100644 test/seed/clinical-trial-intake/project.json create mode 100644 test/seed/field-service-dispatch/app/ACCEPTANCE.md create mode 100644 test/seed/field-service-dispatch/app/index.html create mode 100644 test/seed/field-service-dispatch/app/package.json create mode 100644 test/seed/field-service-dispatch/app/server/src/data/demo-account.json create mode 100644 test/seed/field-service-dispatch/app/server/src/index.ts create mode 100644 test/seed/field-service-dispatch/app/src/App.tsx create mode 100644 test/seed/field-service-dispatch/app/src/main.tsx create mode 100644 test/seed/field-service-dispatch/app/src/styles.css create mode 100644 test/seed/field-service-dispatch/designs/boxy-enterprise/DESIGN.md create mode 100644 test/seed/field-service-dispatch/designs/boxy-enterprise/index.html create mode 100644 test/seed/field-service-dispatch/designs/boxy-enterprise/screens/home.html create mode 100644 test/seed/field-service-dispatch/designs/boxy-enterprise/styles.css create mode 100644 test/seed/field-service-dispatch/designs/modern-and-sleek/DESIGN.md create mode 100644 test/seed/field-service-dispatch/designs/modern-and-sleek/index.html create mode 100644 test/seed/field-service-dispatch/designs/modern-and-sleek/screens/home.html create mode 100644 test/seed/field-service-dispatch/designs/modern-and-sleek/styles.css create mode 100644 test/seed/field-service-dispatch/docs/approaches.md create mode 100644 test/seed/field-service-dispatch/docs/build_plan.md create mode 100644 test/seed/field-service-dispatch/docs/constraints.md create mode 100644 test/seed/field-service-dispatch/docs/design.md create mode 100644 test/seed/field-service-dispatch/docs/problem_brief.md create mode 100644 test/seed/field-service-dispatch/docs/product_requirements.md create mode 100644 test/seed/field-service-dispatch/project.json create mode 100644 test/seed/habit-tracker/app/ACCEPTANCE.md create mode 100644 test/seed/habit-tracker/app/index.html create mode 100644 test/seed/habit-tracker/app/package.json create mode 100644 test/seed/habit-tracker/app/server/src/data/demo-account.json create mode 100644 test/seed/habit-tracker/app/server/src/index.ts create mode 100644 test/seed/habit-tracker/app/src/App.tsx create mode 100644 test/seed/habit-tracker/app/src/main.tsx create mode 100644 test/seed/habit-tracker/app/src/styles.css create mode 100644 test/seed/habit-tracker/designs/minimal-monochrome/DESIGN.md create mode 100644 test/seed/habit-tracker/designs/minimal-monochrome/index.html create mode 100644 test/seed/habit-tracker/designs/minimal-monochrome/screens/home.html create mode 100644 test/seed/habit-tracker/designs/minimal-monochrome/styles.css create mode 100644 test/seed/habit-tracker/designs/warm-and-organic/DESIGN.md create mode 100644 test/seed/habit-tracker/designs/warm-and-organic/index.html create mode 100644 test/seed/habit-tracker/designs/warm-and-organic/screens/home.html create mode 100644 test/seed/habit-tracker/designs/warm-and-organic/styles.css create mode 100644 test/seed/habit-tracker/docs/approaches.md create mode 100644 test/seed/habit-tracker/docs/build_plan.md create mode 100644 test/seed/habit-tracker/docs/constraints.md create mode 100644 test/seed/habit-tracker/docs/design.md create mode 100644 test/seed/habit-tracker/docs/problem_brief.md create mode 100644 test/seed/habit-tracker/docs/product_requirements.md create mode 100644 test/seed/habit-tracker/project.json create mode 100644 test/seed/index.ts diff --git a/docs/DEV.md b/docs/DEV.md index 44c7bc5f..6cac78b7 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -91,9 +91,26 @@ real API ([CONTRIBUTING.md](https://github.com/corbitsdev/corbits-builder/blob/m | `bun run docker:sidecar` | Builds the sidecar Docker image. | | `bun run auth:bootstrap` | Migrates and creates the `BOOTSTRAP_*` machine account and workspace. | | `bun run auth:emulate` | Starts a loopback Google sign-in emulator (`GOOGLE_EMULATE_URL` in `.env.hub.example`). | +| `bun run dev:seed` | Seeds dummy projects (`habit-tracker`, `field-service-dispatch`, `clinical-trial-intake`) at a chosen lifecycle stage. | | `bun run vendor:verify` | Checks `vendor/interchange` matches its pinned upstream commit. | | `bun run skills:react-ui` | Regenerates the designer's `@corbits/react-ui` API reference. | +## Seeding dummy projects + +`bun run dev:seed` populates a workspace tenant (defaulting to the first +workspace tenant in Postgres, or creating a local development workspace if the +database is empty) with up to three realistic dummy projects across any stage +(`idea`, `brief`, `planning`, `design_options`, `designed`, `ready_to_build`, +`shipped`): + +```sh +bun run dev:seed # all 3 fixtures at their default stages +bun run dev:seed -- --fixture=habit-tracker --stage=planning # single fixture with planning docs approved +bun run dev:seed -- --fixture=field-service-dispatch --stage=planning --no-approve +bun run dev:seed -- --fixture=clinical-trial-intake --stage=design_options +bun run dev:seed -- --stage=ready_to_build --tenant= +``` + ## Where things live - Product names and user-visible wording: `apps/hub/src/config/` and diff --git a/package.json b/package.json index ef242e5a..6541600b 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "docker:sidecar": "docker build -f deploy/Dockerfile --target sidecar -t corbits-builder-sidecar:local .", "auth:bootstrap": "bun --env-file=.env.hub --conditions=intx-src apps/hub/src/setup.ts", "auth:emulate": "bun --env-file=.env.hub scripts/google-emulate.ts", + "dev:seed": "bun --env-file=.env.hub --conditions=intx-src scripts/seed-projects.ts", "lint": "bun --bun run oxlint --deny-warnings", "format": "bun fmt --no-script", "format:check": "bun fmt --no-script --check", diff --git a/scripts/seed-projects.ts b/scripts/seed-projects.ts new file mode 100644 index 00000000..49aefb9a --- /dev/null +++ b/scripts/seed-projects.ts @@ -0,0 +1,950 @@ +import { + MAX_LIST_LIMIT, + createArtifact, + getArtifactVersion, + listArtifacts, + type ArtifactDb, +} from "@corbits/artifacts"; +import { readHubEnvironment } from "@corbits-builder/hub/environment"; +import { migrateDatabase } from "@corbits-builder/hub/migrate"; +import type { ApproachesMeta } from "@corbits-builder/schemas/approaches"; +import { + AppBundle, + DesignBundle, + DocumentWrite, + type ArtifactContent, + type ArtifactList, + type ArtifactListItem, + type ArtifactRef, + type DesignChoiceReceipt, +} from "@corbits-builder/schemas/artifact"; +import type { ProjectShipReceipt } from "@corbits-builder/schemas/project"; +import { createEnvKeyCredentialCipher } from "@intx/crypto"; +import { + createDB, + createPrincipalKeyStore, + createPrincipalStore, + type DB, + type PrincipalKeyStore, +} from "@intx/db"; +import { + grant, + principal, + principalRole, + role, + tenant, + user, +} from "@intx/db/schema"; +import { generateId } from "@intx/hub-common"; +import { hexDecode } from "@intx/types"; +import { and, asc, eq, isNull, ne, or, sql } from "drizzle-orm"; +import { + integer, + jsonb, + pgTable, + primaryKey, + text, + timestamp, +} from "drizzle-orm/pg-core"; +import { PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import { + SEED_FIXTURE_IDS, + SEED_PROJECT_FIXTURES, + SEED_STAGES, + getSeedProjectFixture, + loadSeedProjectFixture, + loadSeedProjectFixtures, + resolveSeedFixtures, + type SeedDesignOptionFixture, + type SeedFixtureId, + type SeedFixtureSelector, + type SeedProjectFixture, + type SeedStage, +} from "../test/seed/index.ts"; + +export { + SEED_FIXTURE_IDS, + SEED_PROJECT_FIXTURES, + SEED_STAGES, + getSeedProjectFixture, + loadSeedProjectFixture, + loadSeedProjectFixtures, + resolveSeedFixtures, + type SeedDesignOptionFixture, + type SeedFixtureId, + type SeedFixtureSelector, + type SeedProjectFixture, + type SeedStage, +}; + +export interface SeedActor { + tenantId: string; + principalId: string; +} + +const INSTALL_ROOT_SLUG = "corbits-builder"; +const SYSTEM_ROLES = ["owner", "admin", "member"] as const; +const DEFAULT_DEV_USER = { + email: "dev@corbits.local", + name: "Local Developer", + workspaceName: "Development Workspace", + workspaceSlug: "dev-workspace", +} as const; + +const projectTable = pgTable("project", { + id: text("id").primaryKey(), + tenantId: text("tenant_id").notNull(), + title: text("title").notNull(), + status: text("status").notNull().default("active"), + shippedAt: timestamp("shipped_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +const outboxTable = pgTable("outbox", { + id: text("id").primaryKey(), + projectId: text("project_id").notNull(), + eventType: text("event_type").notNull(), + payload: jsonb("payload").notNull(), + occurredAt: timestamp("occurred_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +const artifactImportApprovalTable = pgTable( + "sb_artifact_import_approval", + { + artifactId: text("artifact_id").notNull(), + version: integer("version").notNull(), + tenantId: text("tenant_id").notNull(), + projectId: text("project_id").notNull(), + principalId: text("principal_id").notNull(), + kind: text("kind").notNull(), + sha256: text("sha256").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [primaryKey({ columns: [table.artifactId, table.version] })], +); + +export interface ResolvedWorkspaceTenant { + tenantId: string; + principalId: string; + userId: string; +} + +export interface CreatedSeedProject { + projectId: string; + actor: SeedActor; +} + +export interface SeedArtifactWriteInput { + kind: string; + title: string; + content: string; + openQuestions?: NonNullable; + approaches?: ApproachesMeta; +} + +export interface SeedDeps { + resolveWorkspaceTenant(tenantId?: string): Promise; + createProject( + workspace: ResolvedWorkspaceTenant, + input: { + fixtureId: SeedFixtureId; + title: string; + problemStatement: string; + }, + ): Promise; + writeArtifact( + actor: SeedActor, + projectId: string, + input: SeedArtifactWriteInput, + ): Promise; + recordArtifactApproval( + actor: SeedActor, + projectId: string, + ref: ArtifactRef, + ): Promise; + chooseDesign( + actor: SeedActor, + projectId: string, + input: { artifactId: string; version: number }, + ): Promise; + shipProject(actor: SeedActor, projectId: string): Promise; + listArtifacts(actor: SeedActor, projectId: string): Promise; +} + +export interface SeedDevelopmentProjectsOptions { + fixture?: SeedFixtureSelector; + stage?: SeedStage; + approve?: boolean; + tenantId?: string; +} + +export interface SeededProjectSummary { + fixtureId: SeedFixtureId; + complexity: SeedProjectFixture["complexity"]; + projectId: string; + projectTenantId: string; + projectPrincipalId: string; + workspaceTenantId: string; + title: string; + stage: SeedStage; + shippedAt: string | null; + artifacts: ArtifactListItem[]; + awaitingApproval: string[]; +} + +export interface SeedDevelopmentProjectsResult { + workspaceTenantId: string; + stage: SeedStage | undefined; + approve: boolean; + projects: SeededProjectSummary[]; +} + +const STAGE_ORDER: Record = { + idea: 0, + brief: 1, + planning: 2, + design_options: 3, + designed: 4, + ready_to_build: 5, + shipped: 6, +}; + +function reachesStage(target: SeedStage, candidate: SeedStage): boolean { + return STAGE_ORDER[target] >= STAGE_ORDER[candidate]; +} + +function isPastStage(target: SeedStage, candidate: SeedStage): boolean { + return STAGE_ORDER[target] > STAGE_ORDER[candidate]; +} + +export async function seedDevelopmentProjects( + deps: SeedDeps, + options: SeedDevelopmentProjectsOptions = {}, +): Promise { + const selector: SeedFixtureSelector = options.fixture ?? "all"; + const approve: boolean = options.approve ?? true; + const fixtures = await loadSeedProjectFixtures(selector); + const workspace = await deps.resolveWorkspaceTenant(options.tenantId); + + const projects: SeededProjectSummary[] = []; + + for (const fixture of fixtures) { + const stage: SeedStage = + options.stage === undefined ? fixture.defaultStage : options.stage; + const created = await deps.createProject(workspace, { + fixtureId: fixture.id, + title: fixture.title, + problemStatement: fixture.problemStatement, + }); + const { actor, projectId } = created; + const docs = fixture.documents; + + if (reachesStage(stage, "brief")) { + const briefRef = await deps.writeArtifact(actor, projectId, { + kind: "problem_brief", + title: docs.problem_brief.title, + content: docs.problem_brief.content, + }); + if (approve || isPastStage(stage, "brief")) { + await deps.recordArtifactApproval(actor, projectId, briefRef); + } + } + + if (reachesStage(stage, "planning")) { + const constraintsRef = await deps.writeArtifact(actor, projectId, { + kind: "constraints", + title: docs.constraints.title, + content: docs.constraints.content, + }); + const approachesRef = await deps.writeArtifact(actor, projectId, { + kind: "approaches", + title: docs.approaches.title, + content: docs.approaches.content, + approaches: docs.approaches.approaches, + }); + const prdRef = await deps.writeArtifact(actor, projectId, { + kind: "product_requirements", + title: docs.product_requirements.title, + content: docs.product_requirements.content, + }); + if (approve || isPastStage(stage, "planning")) { + await deps.recordArtifactApproval(actor, projectId, constraintsRef); + await deps.recordArtifactApproval(actor, projectId, approachesRef); + await deps.recordArtifactApproval(actor, projectId, prdRef); + } + } + + let pickedDesignOptionRef: ArtifactRef | undefined; + if (reachesStage(stage, "design_options")) { + for (const option of fixture.designOptions) { + const optionRef = await deps.writeArtifact(actor, projectId, { + kind: "design_option", + title: option.title, + content: JSON.stringify(option.bundle), + }); + if ( + option.title === fixture.chosenDesignOption || + option.conceptId === fixture.chosenConceptId + ) { + pickedDesignOptionRef = optionRef; + } + } + } + + if (reachesStage(stage, "designed")) { + if (!pickedDesignOptionRef) { + throw new Error( + `Fixture ${fixture.id} is missing chosen design option "${fixture.chosenDesignOption}"`, + ); + } + await deps.chooseDesign(actor, projectId, { + artifactId: pickedDesignOptionRef.artifactId, + version: pickedDesignOptionRef.version, + }); + } + + if (reachesStage(stage, "ready_to_build")) { + const buildPlanRef = await deps.writeArtifact(actor, projectId, { + kind: "build_plan", + title: docs.build_plan.title, + content: docs.build_plan.content, + }); + if (approve || isPastStage(stage, "ready_to_build")) { + await deps.recordArtifactApproval(actor, projectId, buildPlanRef); + } + } + + let shippedAt: string | null = null; + if (reachesStage(stage, "shipped")) { + await deps.writeArtifact(actor, projectId, { + kind: "app", + title: "App", + content: JSON.stringify(fixture.app), + }); + const shipReceipt = await deps.shipProject(actor, projectId); + shippedAt = shipReceipt.shippedAt; + } + + const listed = await deps.listArtifacts(actor, projectId); + projects.push({ + fixtureId: fixture.id, + complexity: fixture.complexity, + projectId, + projectTenantId: actor.tenantId, + projectPrincipalId: actor.principalId, + workspaceTenantId: workspace.tenantId, + title: fixture.title, + stage, + shippedAt, + artifacts: listed.artifacts, + awaitingApproval: listed.awaitingApproval, + }); + } + + return { + workspaceTenantId: workspace.tenantId, + stage: options.stage, + approve, + projects, + }; +} + +async function provisionChildTenant( + db: DB["db"], + principalKeyStore: PrincipalKeyStore, + userId: string, + body: { name: string; slug: string; parentId: string | null }, +): Promise<{ id: string; principalId: string }> { + const principalStore = createPrincipalStore(db, principalKeyStore); + const existing = await db.query.tenant.findFirst({ + where: eq(sql`lower(${tenant.slug})`, body.slug.toLowerCase()), + }); + if (existing) { + throw new Error(`Tenant slug already taken: ${body.slug}`); + } + + const tenantId = generateId("tenant"); + const domain = `${body.slug.toLowerCase()}.localhost`; + const now = new Date(); + + return db.transaction(async (tx) => { + await tx.insert(tenant).values({ + id: tenantId, + name: body.name, + slug: body.slug, + domain, + parentId: body.parentId, + createdAt: now, + updatedAt: now, + }); + + const roleIds: Record = {}; + for (const roleName of SYSTEM_ROLES) { + const roleId = generateId("role"); + roleIds[roleName] = roleId; + await tx.insert(role).values({ + id: roleId, + tenantId, + name: roleName, + description: `System ${roleName} role`, + isSystem: true, + createdAt: now, + updatedAt: now, + }); + } + + const ownerRoleId = roleIds["owner"]; + if (!ownerRoleId) { + throw new Error("Owner role was not created"); + } + + const ownerPrincipal = await principalStore.create( + { + id: generateId("principal"), + tenantId, + kind: "user", + refId: userId, + status: "active", + createdAt: now, + updatedAt: now, + }, + tx, + ); + + await tx.insert(principalRole).values({ + principalId: ownerPrincipal.id, + roleId: ownerRoleId, + createdAt: now, + }); + + await tx.insert(grant).values({ + id: generateId("grant"), + tenantId, + roleId: ownerRoleId, + resource: "*", + action: "*", + effect: "allow", + origin: "system", + createdAt: now, + updatedAt: now, + }); + + return { id: tenantId, principalId: ownerPrincipal.id }; + }); +} + +async function resolveOrCreateWorkspaceTenant( + db: DB["db"], + principalKeyStore: PrincipalKeyStore, + requestedTenantId?: string, +): Promise { + if (requestedTenantId) { + const [matched] = await db + .select({ + tenantId: tenant.id, + principalId: principal.id, + userId: principal.refId, + }) + .from(tenant) + .innerJoin( + principal, + and( + eq(principal.tenantId, tenant.id), + eq(principal.kind, "user"), + eq(principal.status, "active"), + ), + ) + .where(eq(tenant.id, requestedTenantId)) + .orderBy(asc(principal.createdAt)) + .limit(1); + if (!matched) { + throw new Error( + `No active user principal found in tenant ${requestedTenantId}`, + ); + } + return matched; + } + + const [rootRow] = await db + .select({ id: tenant.id }) + .from(tenant) + .where(eq(tenant.slug, INSTALL_ROOT_SLUG)) + .limit(1); + + const workspaceScope = rootRow + ? or( + eq(tenant.parentId, rootRow.id), + and(isNull(tenant.parentId), ne(tenant.id, rootRow.id)), + ) + : isNull(tenant.parentId); + + const [existingWorkspace] = await db + .select({ + tenantId: tenant.id, + principalId: principal.id, + userId: principal.refId, + }) + .from(tenant) + .innerJoin( + principal, + and( + eq(principal.tenantId, tenant.id), + eq(principal.kind, "user"), + eq(principal.status, "active"), + ), + ) + .where(and(ne(tenant.slug, INSTALL_ROOT_SLUG), workspaceScope)) + .orderBy(asc(tenant.createdAt), asc(principal.createdAt)) + .limit(1); + + if (existingWorkspace) { + return existingWorkspace; + } + + const [anyUserTenant] = await db + .select({ + tenantId: tenant.id, + principalId: principal.id, + userId: principal.refId, + }) + .from(tenant) + .innerJoin( + principal, + and( + eq(principal.tenantId, tenant.id), + eq(principal.kind, "user"), + eq(principal.status, "active"), + ), + ) + .where(ne(tenant.slug, INSTALL_ROOT_SLUG)) + .orderBy(asc(tenant.createdAt), asc(principal.createdAt)) + .limit(1); + + if (anyUserTenant) { + return anyUserTenant; + } + + const [existingUser] = await db + .select({ id: user.id }) + .from(user) + .orderBy(asc(user.createdAt)) + .limit(1); + + const userId = existingUser?.id ?? crypto.randomUUID(); + if (!existingUser) { + await db.insert(user).values({ + id: userId, + email: DEFAULT_DEV_USER.email, + name: DEFAULT_DEV_USER.name, + emailVerified: true, + }); + } + + const created = await provisionChildTenant(db, principalKeyStore, userId, { + name: DEFAULT_DEV_USER.workspaceName, + slug: `${DEFAULT_DEV_USER.workspaceSlug}-${crypto.randomUUID().slice(0, 8)}`, + parentId: rootRow?.id ?? null, + }); + + return { + tenantId: created.id, + principalId: created.principalId, + userId, + }; +} + +export function createDatabaseSeedDeps( + db: DB["db"], + principalKeyStore: PrincipalKeyStore, +): SeedDeps { + if (!(db instanceof PostgresJsDatabase)) { + throw new Error("Artifacts require postgres-js"); + } + const artifactDb: ArtifactDb = db; + + return { + async resolveWorkspaceTenant(tenantId?: string) { + return resolveOrCreateWorkspaceTenant(db, principalKeyStore, tenantId); + }, + + async createProject(workspace, input) { + const projectId = crypto.randomUUID(); + const child = await provisionChildTenant( + db, + principalKeyStore, + workspace.userId, + { + name: input.title, + slug: `seed-${input.fixtureId}-${projectId.slice(0, 8)}`, + parentId: workspace.tenantId, + }, + ); + const now = new Date(); + await db.transaction(async (tx) => { + await tx.insert(projectTable).values({ + id: projectId, + tenantId: child.id, + title: input.title, + status: "active", + createdAt: now, + updatedAt: now, + }); + await tx.insert(outboxTable).values({ + id: crypto.randomUUID(), + projectId, + eventType: "project.created", + payload: { + projectId, + tenantId: child.id, + title: input.title, + problemStatement: input.problemStatement, + }, + occurredAt: now, + }); + }); + return { + projectId, + actor: { + tenantId: child.id, + principalId: child.principalId, + }, + }; + }, + + async writeArtifact(actor, _projectId, input) { + if (input.kind === "design_option" || input.kind === "design") { + DesignBundle.assert(JSON.parse(input.content)); + } else if (input.kind === "app") { + AppBundle.assert(JSON.parse(input.content)); + } else { + DocumentWrite.assert(input); + } + + const metadata = { + ...(input.openQuestions ? { openQuestions: input.openQuestions } : {}), + ...(input.approaches ? { approaches: input.approaches } : {}), + }; + const origin = + input.kind === "design_option" || input.kind === "app" + ? "workflow" + : "user"; + + const created = await artifactDb.transaction((tx) => + createArtifact(tx, { + scope: actor, + ownerPrincipalId: actor.principalId, + kind: input.kind, + title: input.title, + content: input.content, + ...(Object.keys(metadata).length > 0 ? { metadata } : {}), + source: { origin }, + }), + ); + return { + artifactId: created.id, + kind: created.kind, + version: created.version, + sha256: created.contentSha256!, + }; + }, + + async recordArtifactApproval(actor, projectId, ref) { + await db + .insert(artifactImportApprovalTable) + .values({ + artifactId: ref.artifactId, + version: ref.version, + tenantId: actor.tenantId, + projectId, + principalId: actor.principalId, + kind: ref.kind, + sha256: ref.sha256, + }) + .onConflictDoNothing(); + }, + + async chooseDesign(actor, projectId, input) { + const option = await getArtifactVersion( + artifactDb, + input.artifactId, + input.version, + ); + if (!option || option.content === null) { + throw new Error(`Design option ${input.artifactId} not found`); + } + const created = await artifactDb.transaction((tx) => + createArtifact(tx, { + scope: actor, + ownerPrincipalId: actor.principalId, + kind: "design", + title: option.title, + content: option.content, + source: { origin: "user" }, + }), + ); + const designRef: ArtifactRef = { + artifactId: created.id, + kind: created.kind, + version: created.version, + sha256: created.contentSha256!, + }; + await db + .insert(artifactImportApprovalTable) + .values({ + artifactId: designRef.artifactId, + version: designRef.version, + tenantId: actor.tenantId, + projectId, + principalId: actor.principalId, + kind: designRef.kind, + sha256: designRef.sha256, + }) + .onConflictDoNothing(); + return { + concept: option.title, + design: designRef, + }; + }, + + async shipProject(_actor, projectId) { + const now = new Date(); + await db + .update(projectTable) + .set({ shippedAt: now, updatedAt: now }) + .where(eq(projectTable.id, projectId)); + return { + ok: true, + projectId, + shippedAt: now.toISOString(), + }; + }, + + async listArtifacts(actor, projectId) { + const page = await listArtifacts(artifactDb, actor.tenantId, { + limit: MAX_LIST_LIMIT, + }); + const approvals = await db + .select({ + artifactId: artifactImportApprovalTable.artifactId, + version: artifactImportApprovalTable.version, + }) + .from(artifactImportApprovalTable) + .where( + and( + eq(artifactImportApprovalTable.tenantId, actor.tenantId), + eq(artifactImportApprovalTable.projectId, projectId), + ), + ); + const approvedByArtifact = new Map(); + for (const row of approvals) { + const prev = approvedByArtifact.get(row.artifactId); + if (prev === undefined || row.version > prev) { + approvedByArtifact.set(row.artifactId, row.version); + } + } + + const items: ArtifactListItem[] = []; + const awaitingApproval: string[] = []; + for (const item of page.rows) { + const approvedVersion = approvedByArtifact.get(item.id); + items.push({ + artifactId: item.id, + kind: item.kind, + title: item.title, + version: item.version, + sha256: item.contentSha256!, + filedAt: item.updatedAt.toISOString(), + ...(approvedVersion !== undefined ? { approvedVersion } : {}), + }); + if ( + approvedVersion !== item.version && + item.kind !== "design_option" && + item.kind !== "design" && + item.kind !== "app" + ) { + awaitingApproval.push(item.kind); + } + } + + return { + artifacts: items, + inFlight: [], + failed: [], + awaitingApproval, + preferences: {}, + preferenceGuidance: [], + }; + }, + }; +} + +export interface ParsedSeedCliArgs { + help: boolean; + options: SeedDevelopmentProjectsOptions; +} + +const VALID_FIXTURE_SELECTORS = new Set([...SEED_FIXTURE_IDS, "all"]); +const VALID_SEED_STAGES = new Set(SEED_STAGES); + +export function seedUsageText(): string { + return [ + "Usage: bun run dev:seed [options]", + "", + "Seeds dummy Builder projects across lifecycle stages for local development.", + "", + "Options:", + " --fixture= Fixture to seed: habit-tracker | field-service-dispatch | clinical-trial-intake | all (default: all)", + " --stage= Target stage: idea | brief | planning | design_options | designed | ready_to_build | shipped (default: each fixture's defaultStage)", + " --approve / --no-approve Approve documents at the target stage (default: --approve; upstream prerequisites are always approved)", + " --tenant= Target workspace tenant ID (alias: --tenant-id=; defaults to first workspace tenant)", + " --help, -h Show this help message", + ].join("\n"); +} + +export function parseSeedCliArgs(argv: readonly string[]): ParsedSeedCliArgs { + const options: SeedDevelopmentProjectsOptions = { + fixture: "all", + approve: true, + }; + let help = false; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]!; + if (arg === "--help" || arg === "-h") { + help = true; + continue; + } + if (arg === "--approve") { + options.approve = true; + continue; + } + if (arg === "--no-approve") { + options.approve = false; + continue; + } + if (arg.startsWith("--approve=")) { + const value = arg.slice("--approve=".length); + if (value !== "true" && value !== "false") { + throw new Error(`Invalid --approve value: ${value}`); + } + options.approve = value === "true"; + continue; + } + if (arg === "--fixture" || arg.startsWith("--fixture=")) { + const value = + arg === "--fixture" ? argv[++index] : arg.slice("--fixture=".length); + if (!value || !VALID_FIXTURE_SELECTORS.has(value)) { + throw new Error( + `Invalid --fixture value "${String(value)}". Expected one of: ${[...VALID_FIXTURE_SELECTORS].join(", ")}`, + ); + } + options.fixture = value as SeedFixtureSelector; + continue; + } + if (arg === "--stage" || arg.startsWith("--stage=")) { + const value = + arg === "--stage" ? argv[++index] : arg.slice("--stage=".length); + if (!value || !VALID_SEED_STAGES.has(value)) { + throw new Error( + `Invalid --stage value "${String(value)}". Expected one of: ${SEED_STAGES.join(", ")}`, + ); + } + options.stage = value as SeedStage; + continue; + } + if ( + arg === "--tenant" || + arg === "--tenant-id" || + arg.startsWith("--tenant=") || + arg.startsWith("--tenant-id=") + ) { + let value: string | undefined; + if (arg === "--tenant" || arg === "--tenant-id") { + value = argv[++index]; + } else if (arg.startsWith("--tenant=")) { + value = arg.slice("--tenant=".length); + } else { + value = arg.slice("--tenant-id=".length); + } + if (!value) { + throw new Error("Missing tenant ID for --tenant / --tenant-id"); + } + options.tenantId = value; + continue; + } + throw new Error(`Unknown option: ${arg}`); + } + + return { help, options }; +} + +export async function runDevelopmentSeed( + rawEnvironment: Record = process.env, + options: SeedDevelopmentProjectsOptions = {}, +): Promise { + const env = readHubEnvironment(rawEnvironment); + await migrateDatabase(rawEnvironment); + const handle = createDB(env.database); + try { + const principalKeyStore = createPrincipalKeyStore({ + db: handle.db, + cipher: createEnvKeyCredentialCipher(hexDecode(env.principalKey)), + }); + const deps = createDatabaseSeedDeps(handle.db, principalKeyStore); + return await seedDevelopmentProjects(deps, options); + } finally { + await handle.close(); + } +} + +export async function main(argv: readonly string[] = process.argv.slice(2)) { + const parsed = parseSeedCliArgs(argv); + if (parsed.help) { + console.log(seedUsageText()); + return; + } + + const result = await runDevelopmentSeed(process.env, parsed.options); + console.log( + JSON.stringify( + { + seeded: true, + workspaceTenantId: result.workspaceTenantId, + stage: result.stage, + approve: result.approve, + projects: result.projects.map((project) => ({ + fixtureId: project.fixtureId, + complexity: project.complexity, + projectId: project.projectId, + projectTenantId: project.projectTenantId, + title: project.title, + stage: project.stage, + shippedAt: project.shippedAt, + artifactKinds: project.artifacts.map((item) => item.kind), + awaitingApproval: project.awaitingApproval, + })), + }, + null, + 2, + ), + ); +} + +if (import.meta.main) { + try { + await main(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(JSON.stringify({ seeded: false, error: message })); + process.exitCode = 1; + } +} diff --git a/test/integration/seed-projects.test.ts b/test/integration/seed-projects.test.ts new file mode 100644 index 00000000..0cbeb748 --- /dev/null +++ b/test/integration/seed-projects.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { + AppBundle, + DesignBundle, + DocumentWrite, + type ArtifactList, + type ArtifactListItem, + type ArtifactRef, +} from "@corbits-builder/schemas/artifact"; +import { + DEFAULT_SEED_ROOT, + SEED_FIXTURE_IDS, + SEED_PROJECT_FIXTURES, + SEED_STAGES, + loadSeedProjectFixture, + loadSeedProjectFixtures, +} from "../seed/index.ts"; +import { + parseSeedCliArgs, + seedDevelopmentProjects, + seedUsageText, + type SeedDeps, +} from "../../scripts/seed-projects.ts"; + +function createInMemorySeedDeps(): SeedDeps { + const projects = new Map< + string, + { + title: string; + problemStatement: string; + shippedAt: string | null; + artifacts: Map< + string, + { + artifactId: string; + kind: string; + title: string; + content: string; + version: number; + sha256: string; + approved: boolean; + } + >; + } + >(); + + let counter = 0; + function nextSha(): string { + counter += 1; + return counter.toString(16).padStart(64, "0"); + } + + return { + async resolveWorkspaceTenant(tenantId?: string) { + return { + tenantId: tenantId ?? "tenant-workspace-default", + principalId: "principal-workspace-owner", + userId: "user-dev-1", + }; + }, + + async createProject(workspace, input) { + const projectId = `project-${input.fixtureId}`; + projects.set(projectId, { + title: input.title, + problemStatement: input.problemStatement, + shippedAt: null, + artifacts: new Map(), + }); + return { + projectId, + actor: { + tenantId: `tenant-${input.fixtureId}`, + principalId: `principal-${input.fixtureId}`, + userId: workspace.userId, + parentTenantId: workspace.tenantId, + }, + }; + }, + + async writeArtifact(_actor, projectId, input) { + const state = projects.get(projectId); + if (!state) { + throw new Error(`Missing project ${projectId}`); + } + const key = + input.kind === "design_option" + ? `${input.kind}:${input.title}` + : input.kind; + const artifactId = `artifact-${projectId}-${key}`; + const ref: ArtifactRef = { + artifactId, + kind: input.kind, + version: 1, + sha256: nextSha(), + }; + state.artifacts.set(key, { + ...ref, + title: input.title, + content: input.content, + approved: false, + }); + return ref; + }, + + async recordArtifactApproval(_actor, projectId, ref) { + const state = projects.get(projectId); + if (!state) { + throw new Error(`Missing project ${projectId}`); + } + for (const entry of state.artifacts.values()) { + if (entry.artifactId === ref.artifactId) { + entry.approved = true; + } + } + }, + + async chooseDesign(_actor, projectId, input) { + const state = projects.get(projectId); + if (!state) { + throw new Error(`Missing project ${projectId}`); + } + const chosen = [...state.artifacts.values()].find( + (entry) => entry.artifactId === input.artifactId, + ); + if (!chosen) { + throw new Error(`Design option ${input.artifactId} not found`); + } + const designRef: ArtifactRef = { + artifactId: `artifact-${projectId}-design`, + kind: "design", + version: 1, + sha256: nextSha(), + }; + state.artifacts.set("design", { + ...designRef, + title: chosen.title, + content: chosen.content, + approved: true, + }); + return { + concept: chosen.title, + design: designRef, + }; + }, + + async shipProject(_actor, projectId) { + const state = projects.get(projectId); + if (!state) { + throw new Error(`Missing project ${projectId}`); + } + const shippedAt = "2026-11-01T12:00:00.000Z"; + state.shippedAt = shippedAt; + return { ok: true, projectId, shippedAt }; + }, + + async listArtifacts(_actor, projectId): Promise { + const state = projects.get(projectId); + if (!state) { + throw new Error(`Missing project ${projectId}`); + } + const items: ArtifactListItem[] = []; + const awaitingApproval: string[] = []; + for (const entry of state.artifacts.values()) { + items.push({ + artifactId: entry.artifactId, + kind: entry.kind, + title: entry.title, + version: entry.version, + sha256: entry.sha256, + filedAt: "2026-11-01T12:00:00.000Z", + ...(entry.approved ? { approvedVersion: entry.version } : {}), + }); + if ( + !entry.approved && + entry.kind !== "design_option" && + entry.kind !== "design" && + entry.kind !== "app" + ) { + awaitingApproval.push(entry.kind); + } + } + return { + artifacts: items, + inFlight: [], + failed: [], + awaitingApproval, + preferences: {}, + preferenceGuidance: [], + }; + }, + }; +} + +describe("test/seed fixtures on disk", () => { + test("loads all 3 seed project fixtures from real files under test/seed//", async () => { + const loaded = await loadSeedProjectFixtures("all"); + expect(loaded).toHaveLength(3); + expect(loaded.map((item) => item.id)).toEqual([...SEED_FIXTURE_IDS]); + + for (const fixtureId of SEED_FIXTURE_IDS) { + const dir = join(DEFAULT_SEED_ROOT, fixtureId); + expect(existsSync(join(dir, "project.json"))).toBe(true); + expect(existsSync(join(dir, "docs", "problem_brief.md"))).toBe(true); + expect(existsSync(join(dir, "docs", "constraints.md"))).toBe(true); + expect(existsSync(join(dir, "docs", "approaches.md"))).toBe(true); + expect(existsSync(join(dir, "docs", "product_requirements.md"))).toBe( + true, + ); + expect(existsSync(join(dir, "docs", "design.md"))).toBe(true); + expect(existsSync(join(dir, "docs", "build_plan.md"))).toBe(true); + expect(existsSync(join(dir, "app", "package.json"))).toBe(true); + expect(existsSync(join(dir, "app", "src", "App.tsx"))).toBe(true); + + const fixture = await loadSeedProjectFixture(fixtureId); + DocumentWrite.assert({ + kind: "problem_brief", + title: fixture.documents.problem_brief.title, + content: fixture.documents.problem_brief.content, + }); + DocumentWrite.assert({ + kind: "constraints", + title: fixture.documents.constraints.title, + content: fixture.documents.constraints.content, + }); + DocumentWrite.assert({ + kind: "approaches", + title: fixture.documents.approaches.title, + content: fixture.documents.approaches.content, + approaches: fixture.documents.approaches.approaches, + }); + DocumentWrite.assert({ + kind: "product_requirements", + title: fixture.documents.product_requirements.title, + content: fixture.documents.product_requirements.content, + }); + DocumentWrite.assert({ + kind: "build_plan", + title: fixture.documents.build_plan.title, + content: fixture.documents.build_plan.content, + }); + + for (const option of fixture.designOptions) { + DesignBundle.assert(option.bundle); + expect(option.bundle.files["designs/screens/home.html"]).toBeDefined(); + } + AppBundle.assert(fixture.app); + } + }); + + test("seeds each stage and respects --approve / --no-approve", async () => { + for (const stage of SEED_STAGES) { + const deps = createInMemorySeedDeps(); + const result = await seedDevelopmentProjects(deps, { + fixture: "habit-tracker", + stage, + approve: true, + }); + expect(result.projects).toHaveLength(1); + const seeded = result.projects[0]!; + expect(seeded.stage).toBe(stage); + expect(seeded.awaitingApproval).toEqual([]); + if (stage === "shipped") { + expect(seeded.shippedAt).not.toBeNull(); + expect(seeded.artifacts.map((item) => item.kind)).toContain("app"); + } + } + + const unapprovedDeps = createInMemorySeedDeps(); + const unapprovedPlanning = await seedDevelopmentProjects(unapprovedDeps, { + fixture: "field-service-dispatch", + stage: "planning", + approve: false, + }); + expect(unapprovedPlanning.projects[0]!.awaitingApproval).toEqual([ + "constraints", + "approaches", + "product_requirements", + ]); + }); + + test("parses CLI arguments and renders help usage", () => { + expect(SEED_PROJECT_FIXTURES).toHaveLength(3); + expect(seedUsageText()).toContain("Usage: bun run dev:seed"); + const parsed = parseSeedCliArgs([ + "--fixture=clinical-trial-intake", + "--stage=ready_to_build", + "--no-approve", + "--tenant=tenant-custom", + ]); + expect(parsed.help).toBe(false); + expect(parsed.options).toEqual({ + fixture: "clinical-trial-intake", + stage: "ready_to_build", + approve: false, + tenantId: "tenant-custom", + }); + }); +}); diff --git a/test/seed/clinical-trial-intake/app/ACCEPTANCE.md b/test/seed/clinical-trial-intake/app/ACCEPTANCE.md new file mode 100644 index 00000000..3b73671c --- /dev/null +++ b/test/seed/clinical-trial-intake/app/ACCEPTANCE.md @@ -0,0 +1,6 @@ +# Acceptance Checklist — Clinical Trial Patient Intake & Consent Portal + +- [x] Coordinator queue displays candidates with eligibility status, signed consent version, and re-consent warnings. +- [x] Updating an inclusion or exclusion criterion recalculates candidate eligibility and appends an audit event. +- [x] Enrollment is refused with a clear reason until all criteria pass and active IRB consent is countersigned. +- [x] Audit trail view filters chronological events by subject code and action type. diff --git a/test/seed/clinical-trial-intake/app/index.html b/test/seed/clinical-trial-intake/app/index.html new file mode 100644 index 00000000..31f5df06 --- /dev/null +++ b/test/seed/clinical-trial-intake/app/index.html @@ -0,0 +1,13 @@ + + + + + + Clinical Trial Patient Intake & Consent Portal + + + +
+ + + diff --git a/test/seed/clinical-trial-intake/app/package.json b/test/seed/clinical-trial-intake/app/package.json new file mode 100644 index 00000000..48384a36 --- /dev/null +++ b/test/seed/clinical-trial-intake/app/package.json @@ -0,0 +1,10 @@ +{ + "name": "clinical-trial-intake-app", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + } +} diff --git a/test/seed/clinical-trial-intake/app/server/src/data/demo-account.json b/test/seed/clinical-trial-intake/app/server/src/data/demo-account.json new file mode 100644 index 00000000..6a0603da --- /dev/null +++ b/test/seed/clinical-trial-intake/app/server/src/data/demo-account.json @@ -0,0 +1,4 @@ +{ + "email": "coordinator@cardio301.example", + "password": "demo-clinical-password" +} diff --git a/test/seed/clinical-trial-intake/app/server/src/index.ts b/test/seed/clinical-trial-intake/app/server/src/index.ts new file mode 100644 index 00000000..06694d95 --- /dev/null +++ b/test/seed/clinical-trial-intake/app/server/src/index.ts @@ -0,0 +1,6 @@ +export default { + port: 3000, + fetch() { + return Response.json({ ok: true, protocol: "CARDIO-301", enrolled: 18 }); + }, +}; diff --git a/test/seed/clinical-trial-intake/app/src/App.tsx b/test/seed/clinical-trial-intake/app/src/App.tsx new file mode 100644 index 00000000..ff46c3de --- /dev/null +++ b/test/seed/clinical-trial-intake/app/src/App.tsx @@ -0,0 +1,8 @@ +export function App() { + return ( +
+

Clinical Trial Patient Intake & Consent Portal

+

Protocol CARDIO-301 · Active Consent: IRB-2026-v3.2

+
+ ); +} diff --git a/test/seed/clinical-trial-intake/app/src/main.tsx b/test/seed/clinical-trial-intake/app/src/main.tsx new file mode 100644 index 00000000..4c16dbc7 --- /dev/null +++ b/test/seed/clinical-trial-intake/app/src/main.tsx @@ -0,0 +1,5 @@ +import { App } from "./App.tsx"; + +export function mountApp() { + return App(); +} diff --git a/test/seed/clinical-trial-intake/app/src/styles.css b/test/seed/clinical-trial-intake/app/src/styles.css new file mode 100644 index 00000000..9cec272c --- /dev/null +++ b/test/seed/clinical-trial-intake/app/src/styles.css @@ -0,0 +1,63 @@ +:root { + --background: #f8fafc; + --foreground: #0f172a; + --card: #ffffff; + --card-foreground: #0f172a; + --popover: #ffffff; + --popover-foreground: #0f172a; + --primary: #1d5bd6; + --primary-foreground: #ffffff; + --primary-active: #1646a8; + --secondary: #eff4fb; + --secondary-foreground: #0f172a; + --muted: #f1f5f9; + --muted-foreground: #475569; + --accent: #e6effd; + --accent-foreground: #1646a8; + --destructive: #c81e1e; + --destructive-foreground: #ffffff; + --success: #def7ec; + --success-foreground: #03543f; + --warning: #fdf6b2; + --warning-foreground: #723b13; + --border: #e2e8f0; + --input: #cbd5e1; + --ring: #1d5bd6; + --radius: 1rem; + --radius-sm: calc(var(--radius) - 4px); + --radius-lg: calc(var(--radius) + 4px); + --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-mono: ui-monospace, "SF Mono", Menlo, monospace; +} +.dark { + --background: #0b1120; + --foreground: #f1f5f9; + --card: #131c31; + --card-foreground: #f1f5f9; + --popover: #1b2742; + --popover-foreground: #f1f5f9; + --primary: #60a5fa; + --primary-foreground: #0b1120; + --primary-active: #93c5fd; + --secondary: #1e293b; + --secondary-foreground: #f1f5f9; + --muted: #1e293b; + --muted-foreground: #94a3b8; + --accent: #1e3a8a; + --accent-foreground: #dbeafe; + --destructive: #f87171; + --destructive-foreground: #0b1120; + --success: #064e3b; + --success-foreground: #6ee7b7; + --warning: #451a03; + --warning-foreground: #fde68a; + --border: #263554; + --input: #334155; + --ring: #60a5fa; +} +body { + margin: 0; + background: var(--background); + color: var(--foreground); + font-family: var(--font-sans); +} diff --git a/test/seed/clinical-trial-intake/designs/editorial/DESIGN.md b/test/seed/clinical-trial-intake/designs/editorial/DESIGN.md new file mode 100644 index 00000000..1c73b113 --- /dev/null +++ b/test/seed/clinical-trial-intake/designs/editorial/DESIGN.md @@ -0,0 +1,74 @@ +--- +name: "Clinical Trial Patient Intake & Consent Portal (Editorial)" +colors: + background: "#faf7f2" + foreground: "#191816" + card: "#ffffff" + card-foreground: "#191816" + popover: "#ffffff" + popover-foreground: "#191816" + primary: "#191816" + primary-foreground: "#faf7f2" + primary-active: "#33312d" + secondary: "#efeae1" + secondary-foreground: "#191816" + muted: "#efeae1" + muted-foreground: "#635e56" + accent: "#e6dfd3" + accent-foreground: "#191816" + destructive: "#a61b1b" + destructive-foreground: "#faf7f2" + success: "#e3f0e6" + success-foreground: "#1b542b" + warning: "#f9ecd2" + warning-foreground: "#734d08" + border: "#ded7ca" + input: "#c9c0b1" + ring: "#191816" + dark-background: "#141311" + dark-foreground: "#f2ede4" + dark-card: "#1d1b18" + dark-card-foreground: "#f2ede4" + dark-popover: "#25231f" + dark-popover-foreground: "#f2ede4" + dark-primary: "#f2ede4" + dark-primary-foreground: "#141311" + dark-primary-active: "#dcd5c9" + dark-secondary: "#2a2723" + dark-secondary-foreground: "#f2ede4" + dark-muted: "#24211d" + dark-muted-foreground: "#a8a095" + dark-accent: "#332f29" + dark-accent-foreground: "#f2ede4" + dark-destructive: "#eb6b6b" + dark-destructive-foreground: "#141311" + dark-success: "#173621" + dark-success-foreground: "#8ed9a4" + dark-warning: "#382809" + dark-warning-foreground: "#f0c76e" + dark-border: "#332f2a" + dark-input: "#47423b" + dark-ring: "#f2ede4" +typography: + font-sans: + fontFamily: "ui-serif, Georgia, serif" + font-mono: + fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace' +rounded: + radius: "0.25rem" + radius-sm: "3px" + radius-lg: "6px" +--- + +# Clinical Trial Patient Intake & Consent Portal (Editorial) + +## Summary + +A document-centric clinical portal with warm paper surfaces and high-legibility serif headings for reading informed consent packets. + +## Screens + +### Queue + +- Route: `/` (mockup `#/queue`) +- States: `populated`, `empty` diff --git a/test/seed/clinical-trial-intake/designs/editorial/index.html b/test/seed/clinical-trial-intake/designs/editorial/index.html new file mode 100644 index 00000000..18ba877e --- /dev/null +++ b/test/seed/clinical-trial-intake/designs/editorial/index.html @@ -0,0 +1,23 @@ + + + + + + Clinical Trial Portal — Editorial + + + +
+
+

CARDIO-301 Clinical Intake Ledger

+

18 Enrolled · 4 In Screening · 1 Re-consent Required

+
+ +
+ + diff --git a/test/seed/clinical-trial-intake/designs/editorial/screens/home.html b/test/seed/clinical-trial-intake/designs/editorial/screens/home.html new file mode 100644 index 00000000..18ba877e --- /dev/null +++ b/test/seed/clinical-trial-intake/designs/editorial/screens/home.html @@ -0,0 +1,23 @@ + + + + + + Clinical Trial Portal — Editorial + + + +
+
+

CARDIO-301 Clinical Intake Ledger

+

18 Enrolled · 4 In Screening · 1 Re-consent Required

+
+ +
+ + diff --git a/test/seed/clinical-trial-intake/designs/editorial/styles.css b/test/seed/clinical-trial-intake/designs/editorial/styles.css new file mode 100644 index 00000000..755b9b53 --- /dev/null +++ b/test/seed/clinical-trial-intake/designs/editorial/styles.css @@ -0,0 +1,63 @@ +:root { + --background: #faf7f2; + --foreground: #191816; + --card: #ffffff; + --card-foreground: #191816; + --popover: #ffffff; + --popover-foreground: #191816; + --primary: #191816; + --primary-foreground: #faf7f2; + --primary-active: #33312d; + --secondary: #efeae1; + --secondary-foreground: #191816; + --muted: #efeae1; + --muted-foreground: #635e56; + --accent: #e6dfd3; + --accent-foreground: #191816; + --destructive: #a61b1b; + --destructive-foreground: #faf7f2; + --success: #e3f0e6; + --success-foreground: #1b542b; + --warning: #f9ecd2; + --warning-foreground: #734d08; + --border: #ded7ca; + --input: #c9c0b1; + --ring: #191816; + --radius: 0.25rem; + --radius-sm: calc(var(--radius) - 1px); + --radius-lg: calc(var(--radius) + 2px); + --font-sans: ui-serif, Georgia, serif; + --font-mono: ui-monospace, "SF Mono", Menlo, monospace; +} +.dark { + --background: #141311; + --foreground: #f2ede4; + --card: #1d1b18; + --card-foreground: #f2ede4; + --popover: #25231f; + --popover-foreground: #f2ede4; + --primary: #f2ede4; + --primary-foreground: #141311; + --primary-active: #dcd5c9; + --secondary: #2a2723; + --secondary-foreground: #f2ede4; + --muted: #24211d; + --muted-foreground: #a8a095; + --accent: #332f29; + --accent-foreground: #f2ede4; + --destructive: #eb6b6b; + --destructive-foreground: #141311; + --success: #173621; + --success-foreground: #8ed9a4; + --warning: #382809; + --warning-foreground: #f0c76e; + --border: #332f2a; + --input: #47423b; + --ring: #f2ede4; +} +body { + margin: 0; + background: var(--background); + color: var(--foreground); + font-family: var(--font-sans); +} diff --git a/test/seed/clinical-trial-intake/designs/rounded-enterprise/DESIGN.md b/test/seed/clinical-trial-intake/designs/rounded-enterprise/DESIGN.md new file mode 100644 index 00000000..dcfbf0e0 --- /dev/null +++ b/test/seed/clinical-trial-intake/designs/rounded-enterprise/DESIGN.md @@ -0,0 +1,93 @@ +--- +name: "Clinical Trial Patient Intake & Consent Portal (Rounded enterprise)" +colors: + background: "#f8fafc" + foreground: "#0f172a" + card: "#ffffff" + card-foreground: "#0f172a" + popover: "#ffffff" + popover-foreground: "#0f172a" + primary: "#1d5bd6" + primary-foreground: "#ffffff" + primary-active: "#1646a8" + secondary: "#eff4fb" + secondary-foreground: "#0f172a" + muted: "#f1f5f9" + muted-foreground: "#475569" + accent: "#e6effd" + accent-foreground: "#1646a8" + destructive: "#c81e1e" + destructive-foreground: "#ffffff" + success: "#def7ec" + success-foreground: "#03543f" + warning: "#fdf6b2" + warning-foreground: "#723b13" + border: "#e2e8f0" + input: "#cbd5e1" + ring: "#1d5bd6" + dark-background: "#0b1120" + dark-foreground: "#f1f5f9" + dark-card: "#131c31" + dark-card-foreground: "#f1f5f9" + dark-popover: "#1b2742" + dark-popover-foreground: "#f1f5f9" + dark-primary: "#60a5fa" + dark-primary-foreground: "#0b1120" + dark-primary-active: "#93c5fd" + dark-secondary: "#1e293b" + dark-secondary-foreground: "#f1f5f9" + dark-muted: "#1e293b" + dark-muted-foreground: "#94a3b8" + dark-accent: "#1e3a8a" + dark-accent-foreground: "#dbeafe" + dark-destructive: "#f87171" + dark-destructive-foreground: "#0b1120" + dark-success: "#064e3b" + dark-success-foreground: "#6ee7b7" + dark-warning: "#451a03" + dark-warning-foreground: "#fde68a" + dark-border: "#263554" + dark-input: "#334155" + dark-ring: "#60a5fa" +typography: + font-sans: + fontFamily: 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif' + font-mono: + fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace' +rounded: + radius: "1rem" + radius-sm: "12px" + radius-lg: "20px" +--- + +# Clinical Trial Patient Intake & Consent Portal (Rounded enterprise) + +## Summary + +A calm, high-trust clinical research workbench with rounded cards, clear semantic badges for IRB consent versions, and a structured four-destination navigation bar. + +## Screens + +### Queue + +- Route: `/` (mockup `#/queue`) +- States: `populated`, `empty` +- Test ids: `queue-title`, `protocol-banner`, `candidate-table`, `candidate-detail` + +### Screening + +- Route: `/screening` (mockup `#/screening`) +- States: `populated` +- Test ids: `screening-title`, `inclusion-list`, `exclusion-list` + +### Consent + +- Route: `/consent` (mockup `#/consent`) +- States: `populated` +- Test ids: `consent-title`, `consent-version-card` + +### Audit + +- Route: `/audit` (mockup `#/audit`) +- States: `populated` +- Test ids: `audit-title`, `audit-table` diff --git a/test/seed/clinical-trial-intake/designs/rounded-enterprise/index.html b/test/seed/clinical-trial-intake/designs/rounded-enterprise/index.html new file mode 100644 index 00000000..c0887a1e --- /dev/null +++ b/test/seed/clinical-trial-intake/designs/rounded-enterprise/index.html @@ -0,0 +1,109 @@ + + + + + + Clinical Trial Patient Intake & Consent Portal + + + +
+ CARDIO-301 Intake & eConsent + + +
+
+
+
+ Protocol CARDIO-301 · Active Consent: IRB-2026-v3.2 · Enrolled: 18 / + 40 +
+

Coordinator Review Queue

+
+

+ SUBJ-2041 (M.R.) · Boston Site 01 · Eligible · + Consent IRB-2026-v3.2 · Ready for PI Sign-Off +

+

+ SUBJ-2044 (K.L.) · Chicago Site 02 · Screening · + Consent IRB-2026-v3.1 (Re-consent Required) +

+
+
+ +
+ + + + + diff --git a/test/seed/clinical-trial-intake/designs/rounded-enterprise/screens/home.html b/test/seed/clinical-trial-intake/designs/rounded-enterprise/screens/home.html new file mode 100644 index 00000000..c0887a1e --- /dev/null +++ b/test/seed/clinical-trial-intake/designs/rounded-enterprise/screens/home.html @@ -0,0 +1,109 @@ + + + + + + Clinical Trial Patient Intake & Consent Portal + + + +
+ CARDIO-301 Intake & eConsent + + +
+
+
+
+ Protocol CARDIO-301 · Active Consent: IRB-2026-v3.2 · Enrolled: 18 / + 40 +
+

Coordinator Review Queue

+
+

+ SUBJ-2041 (M.R.) · Boston Site 01 · Eligible · + Consent IRB-2026-v3.2 · Ready for PI Sign-Off +

+

+ SUBJ-2044 (K.L.) · Chicago Site 02 · Screening · + Consent IRB-2026-v3.1 (Re-consent Required) +

+
+
+ +
+ + + + + diff --git a/test/seed/clinical-trial-intake/designs/rounded-enterprise/styles.css b/test/seed/clinical-trial-intake/designs/rounded-enterprise/styles.css new file mode 100644 index 00000000..9cec272c --- /dev/null +++ b/test/seed/clinical-trial-intake/designs/rounded-enterprise/styles.css @@ -0,0 +1,63 @@ +:root { + --background: #f8fafc; + --foreground: #0f172a; + --card: #ffffff; + --card-foreground: #0f172a; + --popover: #ffffff; + --popover-foreground: #0f172a; + --primary: #1d5bd6; + --primary-foreground: #ffffff; + --primary-active: #1646a8; + --secondary: #eff4fb; + --secondary-foreground: #0f172a; + --muted: #f1f5f9; + --muted-foreground: #475569; + --accent: #e6effd; + --accent-foreground: #1646a8; + --destructive: #c81e1e; + --destructive-foreground: #ffffff; + --success: #def7ec; + --success-foreground: #03543f; + --warning: #fdf6b2; + --warning-foreground: #723b13; + --border: #e2e8f0; + --input: #cbd5e1; + --ring: #1d5bd6; + --radius: 1rem; + --radius-sm: calc(var(--radius) - 4px); + --radius-lg: calc(var(--radius) + 4px); + --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-mono: ui-monospace, "SF Mono", Menlo, monospace; +} +.dark { + --background: #0b1120; + --foreground: #f1f5f9; + --card: #131c31; + --card-foreground: #f1f5f9; + --popover: #1b2742; + --popover-foreground: #f1f5f9; + --primary: #60a5fa; + --primary-foreground: #0b1120; + --primary-active: #93c5fd; + --secondary: #1e293b; + --secondary-foreground: #f1f5f9; + --muted: #1e293b; + --muted-foreground: #94a3b8; + --accent: #1e3a8a; + --accent-foreground: #dbeafe; + --destructive: #f87171; + --destructive-foreground: #0b1120; + --success: #064e3b; + --success-foreground: #6ee7b7; + --warning: #451a03; + --warning-foreground: #fde68a; + --border: #263554; + --input: #334155; + --ring: #60a5fa; +} +body { + margin: 0; + background: var(--background); + color: var(--foreground); + font-family: var(--font-sans); +} diff --git a/test/seed/clinical-trial-intake/docs/approaches.md b/test/seed/clinical-trial-intake/docs/approaches.md new file mode 100644 index 00000000..d2cde8b7 --- /dev/null +++ b/test/seed/clinical-trial-intake/docs/approaches.md @@ -0,0 +1,13 @@ +# Approaches: Clinical Trial Patient Intake & Consent Portal + +## Option 1: Rule-Checked Coordinator Workbench with Append-Only Audit Ledger (Recommended) + +A multi-view coordinator portal backed by structured inclusion/exclusion rule evaluation, protocol consent version checks, and an append-only SQLite audit log. + +## Option 2: Static Form Wizard with PDF Upload Only + +A simple linear intake form that stores uploaded PDF filenames without structured criterion-level evaluation. + +## Option 3: External BPMN Workflow Engine Integration + +Delegate state transitions to an external BPMN microservice and render generic task forms. diff --git a/test/seed/clinical-trial-intake/docs/build_plan.md b/test/seed/clinical-trial-intake/docs/build_plan.md new file mode 100644 index 00000000..a09df42f --- /dev/null +++ b/test/seed/clinical-trial-intake/docs/build_plan.md @@ -0,0 +1,23 @@ +# Build Plan: Clinical Trial Patient Intake & Consent Portal + +## Architecture & Database Schema + +- `trial_protocols`: `id`, `code`, `title`, `active_consent_version`, `target_enrollment`. +- `candidates`: `id`, `subject_code`, `initials`, `site_name`, `stage`, `eligibility_status`, `signed_consent_version`, `pi_Countersigned_at`. +- `screening_checks`: `id`, `candidate_id`, `criterion_code`, `category` (`inclusion` | `exclusion`), `label`, `status` (`pass` | `fail` | `pending`), `notes`. +- `audit_events`: `id`, `candidate_id`, `subject_code`, `actor_name`, `actor_role`, `action`, `detail`, `created_at`. + +## API Endpoints + +- `GET /api/intake/overview`: Returns protocol metadata, candidates with computed eligibility & consent flags, and KPI counts. +- `POST /api/candidates/:id/screening`: Updates a screening criterion and records an audit event. +- `POST /api/candidates/:id/consent`: Records participant e-consent or PI countersignature for the active protocol version. +- `POST /api/candidates/:id/enroll`: Validates all 4 enrollment gates (inclusion pass, exclusion clear, active consent version, PI countersignature) and transitions candidate to `Enrolled`. +- `GET /api/audit`: Lists immutable audit trail entries with subject and action filters. + +## Acceptance Checklist + +- [ ] Coordinator queue displays candidates with eligibility status, signed consent version, and re-consent warnings. +- [ ] Updating an inclusion or exclusion criterion recalculates candidate eligibility and appends an audit event. +- [ ] Enrollment is refused with a clear reason until all criteria pass and active IRB consent is countersigned. +- [ ] Audit trail view filters chronological events by subject code and action type. diff --git a/test/seed/clinical-trial-intake/docs/constraints.md b/test/seed/clinical-trial-intake/docs/constraints.md new file mode 100644 index 00000000..4636394b --- /dev/null +++ b/test/seed/clinical-trial-intake/docs/constraints.md @@ -0,0 +1,15 @@ +# Constraints: Clinical Trial Patient Intake & Consent Portal + +## Regulatory & Data Integrity Constraints + +- **Immutable audit events**: Every screening answer change, consent signature, re-consent request, and PI sign-off appends an immutable audit event with actor role, timestamp, and reason. +- **Consent version pinning**: Each candidate's consent record binds to an explicit IRB protocol version (e.g. `IRB-2026-v3.2`). If the active protocol version advances, candidates in `Screening` or `Awaiting Baseline` with an older version are automatically marked `Re-consent Required`. +- **No unmasked PHI in summary banners**: Candidate cards display study subject IDs (e.g. `SUBJ-2041`) and initials alongside clinic site codes. + +## Workflow Constraints + +- A candidate cannot transition to `Enrolled` unless: + 1. All Inclusion criteria pass, + 2. All Exclusion criteria are cleared, + 3. E-consent is signed on the current active protocol version, and + 4. PI countersignature is recorded. diff --git a/test/seed/clinical-trial-intake/docs/design.md b/test/seed/clinical-trial-intake/docs/design.md new file mode 100644 index 00000000..dcfbf0e0 --- /dev/null +++ b/test/seed/clinical-trial-intake/docs/design.md @@ -0,0 +1,93 @@ +--- +name: "Clinical Trial Patient Intake & Consent Portal (Rounded enterprise)" +colors: + background: "#f8fafc" + foreground: "#0f172a" + card: "#ffffff" + card-foreground: "#0f172a" + popover: "#ffffff" + popover-foreground: "#0f172a" + primary: "#1d5bd6" + primary-foreground: "#ffffff" + primary-active: "#1646a8" + secondary: "#eff4fb" + secondary-foreground: "#0f172a" + muted: "#f1f5f9" + muted-foreground: "#475569" + accent: "#e6effd" + accent-foreground: "#1646a8" + destructive: "#c81e1e" + destructive-foreground: "#ffffff" + success: "#def7ec" + success-foreground: "#03543f" + warning: "#fdf6b2" + warning-foreground: "#723b13" + border: "#e2e8f0" + input: "#cbd5e1" + ring: "#1d5bd6" + dark-background: "#0b1120" + dark-foreground: "#f1f5f9" + dark-card: "#131c31" + dark-card-foreground: "#f1f5f9" + dark-popover: "#1b2742" + dark-popover-foreground: "#f1f5f9" + dark-primary: "#60a5fa" + dark-primary-foreground: "#0b1120" + dark-primary-active: "#93c5fd" + dark-secondary: "#1e293b" + dark-secondary-foreground: "#f1f5f9" + dark-muted: "#1e293b" + dark-muted-foreground: "#94a3b8" + dark-accent: "#1e3a8a" + dark-accent-foreground: "#dbeafe" + dark-destructive: "#f87171" + dark-destructive-foreground: "#0b1120" + dark-success: "#064e3b" + dark-success-foreground: "#6ee7b7" + dark-warning: "#451a03" + dark-warning-foreground: "#fde68a" + dark-border: "#263554" + dark-input: "#334155" + dark-ring: "#60a5fa" +typography: + font-sans: + fontFamily: 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif' + font-mono: + fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace' +rounded: + radius: "1rem" + radius-sm: "12px" + radius-lg: "20px" +--- + +# Clinical Trial Patient Intake & Consent Portal (Rounded enterprise) + +## Summary + +A calm, high-trust clinical research workbench with rounded cards, clear semantic badges for IRB consent versions, and a structured four-destination navigation bar. + +## Screens + +### Queue + +- Route: `/` (mockup `#/queue`) +- States: `populated`, `empty` +- Test ids: `queue-title`, `protocol-banner`, `candidate-table`, `candidate-detail` + +### Screening + +- Route: `/screening` (mockup `#/screening`) +- States: `populated` +- Test ids: `screening-title`, `inclusion-list`, `exclusion-list` + +### Consent + +- Route: `/consent` (mockup `#/consent`) +- States: `populated` +- Test ids: `consent-title`, `consent-version-card` + +### Audit + +- Route: `/audit` (mockup `#/audit`) +- States: `populated` +- Test ids: `audit-title`, `audit-table` diff --git a/test/seed/clinical-trial-intake/docs/problem_brief.md b/test/seed/clinical-trial-intake/docs/problem_brief.md new file mode 100644 index 00000000..de829f04 --- /dev/null +++ b/test/seed/clinical-trial-intake/docs/problem_brief.md @@ -0,0 +1,17 @@ +# Problem Brief: Clinical Trial Patient Intake & Consent Portal + +## Problem Statement + +Clinical research sites running Phase II/III oncology and cardiology trials lose eligible candidates and risk protocol deviations when intake coordinators screen referrals across paper worksheets, disconnected PDF consent forms, and spreadsheets. Coordinators need a single structured portal to evaluate inclusion/exclusion criteria, verify that candidates sign the active IRB-approved consent version, and record every eligibility decision in an immutable audit log. + +## Target Users + +- **Study Coordinator (CRC)**: Screens referred candidates, records lab and medical history criteria, tracks e-consent signatures, and schedules baseline visits. +- **Principal Investigator (PI) / Sub-Investigator**: Reviews borderline eligibility cases, countersigns e-consent packets, and signs off on enrollment readiness. +- **Clinical Quality Monitor**: Inspects the protocol version history and timestamped audit trail for each candidate. + +## Core Outcomes + +1. Deterministic inclusion/exclusion rule evaluation with explicit coordinator override notes when PI review is required. +2. Protocol consent version locking so candidates on an superseded IRB amendment are flagged for re-consent before enrollment. +3. Complete, filterable audit trail naming who recorded or approved every screening and consent step. diff --git a/test/seed/clinical-trial-intake/docs/product_requirements.md b/test/seed/clinical-trial-intake/docs/product_requirements.md new file mode 100644 index 00000000..1d4b7c78 --- /dev/null +++ b/test/seed/clinical-trial-intake/docs/product_requirements.md @@ -0,0 +1,15 @@ +# Product Requirements: Clinical Trial Patient Intake & Consent Portal + +## Screens & Core Capabilities + +1. **Coordinator Review Queue (`/`)**: + - Summary metrics: Active referrals, eligible awaiting consent, re-consent required, and enrolled subjects vs target cohort size. + - Filterable subject table by stage (`Referral`, `Screening`, `Consent Pending`, `PI Review`, `Enrolled`, `Screen Failed`), site, and consent status. +2. **Eligibility Screening Worksheet (`/screening`)**: + - Structured inclusion checklist (Age 18–75, confirmed diagnosis, ECOG 0–1, baseline eGFR >= 45) and exclusion checklist. + - Live eligibility verdict banner (`Eligible`, `Pending Labs`, `Excluded`). +3. **E-Consent & IRB Version Desk (`/consent`)**: + - Active protocol version (`CARDIO-301 · IRB v3.2`), participant signature timestamp, interpreter witness field, and PI countersignature action. + - Automatic `Re-consent Required` badge when signed version trails active protocol version. +4. **Immutable Audit Trail (`/audit`)**: + - Chronological log of every criterion change, consent signature, and enrollment decision with actor, subject ID, and rationale. diff --git a/test/seed/clinical-trial-intake/project.json b/test/seed/clinical-trial-intake/project.json new file mode 100644 index 00000000..c6efb841 --- /dev/null +++ b/test/seed/clinical-trial-intake/project.json @@ -0,0 +1,103 @@ +{ + "slug": "clinical-trial-intake", + "id": "clinical-trial-intake", + "title": "Clinical Trial Patient Intake & Consent Portal", + "complexity": "complex", + "defaultStage": "ready_to_build", + "idea": "Build a clinical trial patient intake, multi-step eligibility screening, e-consent verification, and study coordinator review portal with protocol version tracking and an immutable audit trail.", + "problemStatement": "Build a clinical trial patient intake, multi-step eligibility screening, e-consent verification, and study coordinator review portal with protocol version tracking and an immutable audit trail.", + "chosenConceptId": "rounded-enterprise", + "chosenDesignOption": "Rounded enterprise", + "documents": { + "problem_brief": { + "title": "Problem Brief — Clinical Trial Patient Intake & Consent Portal", + "path": "docs/problem_brief.md" + }, + "constraints": { + "title": "Constraints — Clinical Trial Patient Intake & Consent Portal", + "path": "docs/constraints.md" + }, + "approaches": { + "title": "Approaches — Clinical Trial Patient Intake & Consent Portal", + "path": "docs/approaches.md", + "approaches": { + "recommended": "coordinator-workbench-ledger", + "approaches": [ + { + "id": "coordinator-workbench-ledger", + "name": "Rule-Checked Coordinator Workbench & Audit Ledger", + "summary": "Structured screening checklist, IRB consent version tracker, PI review queue, and append-only audit log in one cohesive workbench.", + "bestFor": "Multi-site clinical coordinators who need immediate eligibility feedback and regulatory auditability.", + "pros": [ + "Enforces inclusion, exclusion, and active IRB consent version rules before enrollment", + "Provides a dedicated PI sign-off queue for pending enrollments", + "Every state transition writes a queryable audit event automatically" + ], + "cons": [ + "Requires relational schema across candidates, criteria responses, consents, and audit logs" + ], + "effort": "medium", + "risks": [ + "Must clearly explain why enrollment is blocked when any single criterion or consent version fails" + ] + }, + { + "id": "static-form-wizard", + "name": "Static Form Wizard with Manual Status", + "summary": "Free-text intake notes and manual status dropdowns without criterion-level validation.", + "bestFor": "Informal pre-screening contact lists.", + "pros": ["Minimal schema and fast initial implementation"], + "cons": [ + "Allows accidental enrollment of candidates with failed exclusion criteria", + "Cannot detect outdated IRB consent versions automatically" + ], + "effort": "low", + "risks": ["High risk of protocol deviations during site audits"] + }, + { + "id": "external-bpmn-engine", + "name": "External BPMN State Engine", + "summary": "Encode screening and consent workflows as external BPMN XML definitions.", + "bestFor": "Enterprise CROs integrating dozens of legacy hospital systems.", + "pros": ["Formal XML process diagrams"], + "cons": [ + "Heavy infrastructure overhead and poor UI responsiveness for coordinators", + "Splits validation logic away from the application database" + ], + "effort": "high", + "risks": ["Schema drift between BPMN state variables and UI forms"] + } + ] + } + }, + "product_requirements": { + "title": "Product Requirements — Clinical Trial Patient Intake & Consent Portal", + "path": "docs/product_requirements.md" + }, + "design": { + "title": "Rounded enterprise", + "path": "docs/design.md" + }, + "build_plan": { + "title": "Build Plan — Clinical Trial Patient Intake & Consent Portal", + "path": "docs/build_plan.md" + } + }, + "designOptions": [ + { + "conceptId": "rounded-enterprise", + "title": "Rounded enterprise", + "direction": "Calm clinical blue primary (#1d5bd6), generous 1rem card radii, soft cool-grey surfaces, and unmistakable semantic status badges for eligibility and consent gates.", + "path": "designs/rounded-enterprise" + }, + { + "conceptId": "editorial", + "title": "Editorial", + "direction": "Warm parchment canvas (#faf7f2) with Georgia serif headings, ink-black primary actions, and document-grade typography suited to consent reading.", + "path": "designs/editorial" + } + ], + "app": { + "path": "app" + } +} diff --git a/test/seed/field-service-dispatch/app/ACCEPTANCE.md b/test/seed/field-service-dispatch/app/ACCEPTANCE.md new file mode 100644 index 00000000..60bb2f85 --- /dev/null +++ b/test/seed/field-service-dispatch/app/ACCEPTANCE.md @@ -0,0 +1,5 @@ +# Acceptance Checklist — Field Service Dispatch Board + +- [x] Dispatch queue filters work orders by status, priority, and search query. +- [x] Assigning a technician updates the work order to Dispatched and logs a dispatch event. +- [x] Advancing status through En Route, On Site, and Completed updates KPI counters. diff --git a/test/seed/field-service-dispatch/app/index.html b/test/seed/field-service-dispatch/app/index.html new file mode 100644 index 00000000..3ce60901 --- /dev/null +++ b/test/seed/field-service-dispatch/app/index.html @@ -0,0 +1,13 @@ + + + + + + Field Service Dispatch Board + + + +
+ + + diff --git a/test/seed/field-service-dispatch/app/package.json b/test/seed/field-service-dispatch/app/package.json new file mode 100644 index 00000000..d8da8015 --- /dev/null +++ b/test/seed/field-service-dispatch/app/package.json @@ -0,0 +1,10 @@ +{ + "name": "field-service-dispatch-app", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + } +} diff --git a/test/seed/field-service-dispatch/app/server/src/data/demo-account.json b/test/seed/field-service-dispatch/app/server/src/data/demo-account.json new file mode 100644 index 00000000..9be9a9f0 --- /dev/null +++ b/test/seed/field-service-dispatch/app/server/src/data/demo-account.json @@ -0,0 +1,4 @@ +{ + "email": "dispatcher@northgrid.example", + "password": "demo-dispatch-password" +} diff --git a/test/seed/field-service-dispatch/app/server/src/index.ts b/test/seed/field-service-dispatch/app/server/src/index.ts new file mode 100644 index 00000000..7417b2e9 --- /dev/null +++ b/test/seed/field-service-dispatch/app/server/src/index.ts @@ -0,0 +1,6 @@ +export default { + port: 3000, + fetch() { + return Response.json({ ok: true, openWorkOrders: 14 }); + }, +}; diff --git a/test/seed/field-service-dispatch/app/src/App.tsx b/test/seed/field-service-dispatch/app/src/App.tsx new file mode 100644 index 00000000..0f0aa6f6 --- /dev/null +++ b/test/seed/field-service-dispatch/app/src/App.tsx @@ -0,0 +1,8 @@ +export function App() { + return ( +
+

Field Service Dispatch Board

+

14 Open Work Orders · 2 SLA Alerts

+
+ ); +} diff --git a/test/seed/field-service-dispatch/app/src/main.tsx b/test/seed/field-service-dispatch/app/src/main.tsx new file mode 100644 index 00000000..4c16dbc7 --- /dev/null +++ b/test/seed/field-service-dispatch/app/src/main.tsx @@ -0,0 +1,5 @@ +import { App } from "./App.tsx"; + +export function mountApp() { + return App(); +} diff --git a/test/seed/field-service-dispatch/app/src/styles.css b/test/seed/field-service-dispatch/app/src/styles.css new file mode 100644 index 00000000..70dbcbca --- /dev/null +++ b/test/seed/field-service-dispatch/app/src/styles.css @@ -0,0 +1,63 @@ +:root { + --background: #fafafa; + --foreground: #18181b; + --card: #ffffff; + --card-foreground: #18181b; + --popover: #ffffff; + --popover-foreground: #18181b; + --primary: #2747d6; + --primary-foreground: #ffffff; + --primary-active: #1d37b0; + --secondary: #f1f1f3; + --secondary-foreground: #18181b; + --muted: #f1f1f3; + --muted-foreground: #5f5f68; + --accent: #eef1fd; + --accent-foreground: #1d37b0; + --destructive: #c42b2b; + --destructive-foreground: #ffffff; + --success: #e3f4ea; + --success-foreground: #17603a; + --warning: #fdf1dc; + --warning-foreground: #8a5300; + --border: #e4e4e7; + --input: #c9c9cf; + --ring: #2747d6; + --radius: 0.625rem; + --radius-sm: calc(var(--radius) - 2px); + --radius-lg: calc(var(--radius) + 4px); + --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-mono: ui-monospace, "SF Mono", Menlo, monospace; +} +.dark { + --background: #0b0b0d; + --foreground: #f4f4f5; + --card: #151518; + --card-foreground: #f4f4f5; + --popover: #1c1c20; + --popover-foreground: #f4f4f5; + --primary: #7089ff; + --primary-foreground: #0b0b0d; + --primary-active: #8ea1ff; + --secondary: #222227; + --secondary-foreground: #f4f4f5; + --muted: #1c1c20; + --muted-foreground: #a1a1aa; + --accent: #1d2240; + --accent-foreground: #b9c5ff; + --destructive: #f07272; + --destructive-foreground: #0b0b0d; + --success: #12301f; + --success-foreground: #8fdcae; + --warning: #36270c; + --warning-foreground: #f2c46d; + --border: #27272c; + --input: #3f3f46; + --ring: #7089ff; +} +body { + margin: 0; + background: var(--background); + color: var(--foreground); + font-family: var(--font-sans); +} diff --git a/test/seed/field-service-dispatch/designs/boxy-enterprise/DESIGN.md b/test/seed/field-service-dispatch/designs/boxy-enterprise/DESIGN.md new file mode 100644 index 00000000..728a0196 --- /dev/null +++ b/test/seed/field-service-dispatch/designs/boxy-enterprise/DESIGN.md @@ -0,0 +1,74 @@ +--- +name: "Field Service Dispatch Board (Boxy enterprise)" +colors: + background: "#ffffff" + foreground: "#0f172a" + card: "#ffffff" + card-foreground: "#0f172a" + popover: "#ffffff" + popover-foreground: "#0f172a" + primary: "#0f2942" + primary-foreground: "#ffffff" + primary-active: "#091b2d" + secondary: "#f1f5f9" + secondary-foreground: "#0f172a" + muted: "#f1f5f9" + muted-foreground: "#475569" + accent: "#e2e8f0" + accent-foreground: "#0f2942" + destructive: "#b91c1c" + destructive-foreground: "#ffffff" + success: "#dcfce7" + success-foreground: "#166534" + warning: "#fef3c7" + warning-foreground: "#92400e" + border: "#cbd5e1" + input: "#94a3b8" + ring: "#0f2942" + dark-background: "#090d16" + dark-foreground: "#f8fafc" + dark-card: "#111827" + dark-card-foreground: "#f8fafc" + dark-popover: "#1e293b" + dark-popover-foreground: "#f8fafc" + dark-primary: "#60a5fa" + dark-primary-foreground: "#090d16" + dark-primary-active: "#93c5fd" + dark-secondary: "#1e293b" + dark-secondary-foreground: "#f8fafc" + dark-muted: "#1e293b" + dark-muted-foreground: "#94a3b8" + dark-accent: "#1e3a5f" + dark-accent-foreground: "#bfdbfe" + dark-destructive: "#f87171" + dark-destructive-foreground: "#090d16" + dark-success: "#14532d" + dark-success-foreground: "#86efac" + dark-warning: "#451a03" + dark-warning-foreground: "#fcd34d" + dark-border: "#334155" + dark-input: "#475569" + dark-ring: "#60a5fa" +typography: + font-sans: + fontFamily: 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif' + font-mono: + fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace' +rounded: + radius: "0px" + radius-sm: "0px" + radius-lg: "0px" +--- + +# Field Service Dispatch Board (Boxy enterprise) + +## Summary + +A dense, square-cornered industrial dispatch console with navy headers and high-contrast status badges. + +## Screens + +### Dispatch + +- Route: `/` (mockup `#/dispatch`) +- States: `populated`, `empty` diff --git a/test/seed/field-service-dispatch/designs/boxy-enterprise/index.html b/test/seed/field-service-dispatch/designs/boxy-enterprise/index.html new file mode 100644 index 00000000..f7ebdd51 --- /dev/null +++ b/test/seed/field-service-dispatch/designs/boxy-enterprise/index.html @@ -0,0 +1,23 @@ + + + + + + Field Service Dispatch Board — Boxy Enterprise + + + +
+
+

DISPATCH CONTROL // NORTH REGION

+

14 Open Work Orders · 2 SLA Alerts · 8 Active Techs

+
+ +
+ + diff --git a/test/seed/field-service-dispatch/designs/boxy-enterprise/screens/home.html b/test/seed/field-service-dispatch/designs/boxy-enterprise/screens/home.html new file mode 100644 index 00000000..f7ebdd51 --- /dev/null +++ b/test/seed/field-service-dispatch/designs/boxy-enterprise/screens/home.html @@ -0,0 +1,23 @@ + + + + + + Field Service Dispatch Board — Boxy Enterprise + + + +
+
+

DISPATCH CONTROL // NORTH REGION

+

14 Open Work Orders · 2 SLA Alerts · 8 Active Techs

+
+ +
+ + diff --git a/test/seed/field-service-dispatch/designs/boxy-enterprise/styles.css b/test/seed/field-service-dispatch/designs/boxy-enterprise/styles.css new file mode 100644 index 00000000..3908dad6 --- /dev/null +++ b/test/seed/field-service-dispatch/designs/boxy-enterprise/styles.css @@ -0,0 +1,63 @@ +:root { + --background: #ffffff; + --foreground: #0f172a; + --card: #ffffff; + --card-foreground: #0f172a; + --popover: #ffffff; + --popover-foreground: #0f172a; + --primary: #0f2942; + --primary-foreground: #ffffff; + --primary-active: #091b2d; + --secondary: #f1f5f9; + --secondary-foreground: #0f172a; + --muted: #f1f5f9; + --muted-foreground: #475569; + --accent: #e2e8f0; + --accent-foreground: #0f2942; + --destructive: #b91c1c; + --destructive-foreground: #ffffff; + --success: #dcfce7; + --success-foreground: #166534; + --warning: #fef3c7; + --warning-foreground: #92400e; + --border: #cbd5e1; + --input: #94a3b8; + --ring: #0f2942; + --radius: 0px; + --radius-sm: 0px; + --radius-lg: 0px; + --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-mono: ui-monospace, "SF Mono", Menlo, monospace; +} +.dark { + --background: #090d16; + --foreground: #f8fafc; + --card: #111827; + --card-foreground: #f8fafc; + --popover: #1e293b; + --popover-foreground: #f8fafc; + --primary: #60a5fa; + --primary-foreground: #090d16; + --primary-active: #93c5fd; + --secondary: #1e293b; + --secondary-foreground: #f8fafc; + --muted: #1e293b; + --muted-foreground: #94a3b8; + --accent: #1e3a5f; + --accent-foreground: #bfdbfe; + --destructive: #f87171; + --destructive-foreground: #090d16; + --success: #14532d; + --success-foreground: #86efac; + --warning: #451a03; + --warning-foreground: #fcd34d; + --border: #334155; + --input: #475569; + --ring: #60a5fa; +} +body { + margin: 0; + background: var(--background); + color: var(--foreground); + font-family: var(--font-sans); +} diff --git a/test/seed/field-service-dispatch/designs/modern-and-sleek/DESIGN.md b/test/seed/field-service-dispatch/designs/modern-and-sleek/DESIGN.md new file mode 100644 index 00000000..14fae521 --- /dev/null +++ b/test/seed/field-service-dispatch/designs/modern-and-sleek/DESIGN.md @@ -0,0 +1,81 @@ +--- +name: "Field Service Dispatch Board (Modern and sleek)" +colors: + background: "#fafafa" + foreground: "#18181b" + card: "#ffffff" + card-foreground: "#18181b" + popover: "#ffffff" + popover-foreground: "#18181b" + primary: "#2747d6" + primary-foreground: "#ffffff" + primary-active: "#1d37b0" + secondary: "#f1f1f3" + secondary-foreground: "#18181b" + muted: "#f1f1f3" + muted-foreground: "#5f5f68" + accent: "#eef1fd" + accent-foreground: "#1d37b0" + destructive: "#c42b2b" + destructive-foreground: "#ffffff" + success: "#e3f4ea" + success-foreground: "#17603a" + warning: "#fdf1dc" + warning-foreground: "#8a5300" + border: "#e4e4e7" + input: "#c9c9cf" + ring: "#2747d6" + dark-background: "#0b0b0d" + dark-foreground: "#f4f4f5" + dark-card: "#151518" + dark-card-foreground: "#f4f4f5" + dark-popover: "#1c1c20" + dark-popover-foreground: "#f4f4f5" + dark-primary: "#7089ff" + dark-primary-foreground: "#0b0b0d" + dark-primary-active: "#8ea1ff" + dark-secondary: "#222227" + dark-secondary-foreground: "#f4f4f5" + dark-muted: "#1c1c20" + dark-muted-foreground: "#a1a1aa" + dark-accent: "#1d2240" + dark-accent-foreground: "#b9c5ff" + dark-destructive: "#f07272" + dark-destructive-foreground: "#0b0b0d" + dark-success: "#12301f" + dark-success-foreground: "#8fdcae" + dark-warning: "#36270c" + dark-warning-foreground: "#f2c46d" + dark-border: "#27272c" + dark-input: "#3f3f46" + dark-ring: "#7089ff" +typography: + font-sans: + fontFamily: 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif' + font-mono: + fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace' +rounded: + radius: "0.625rem" + radius-sm: "8px" + radius-lg: "14px" +--- + +# Field Service Dispatch Board (Modern and sleek) + +## Summary + +A high-clarity operational dispatch desk with cool zinc neutrals, cobalt selection highlights, and tabular SLA timers. + +## Screens + +### Dispatch + +- Route: `/` (mockup `#/dispatch`) +- States: `populated`, `empty` +- Test ids: `dispatch-title`, `sla-alert-banner`, `work-order-table`, `work-order-detail` + +### Technicians + +- Route: `/technicians` (mockup `#/technicians`) +- States: `populated` +- Test ids: `technicians-title`, `technicians-grid` diff --git a/test/seed/field-service-dispatch/designs/modern-and-sleek/index.html b/test/seed/field-service-dispatch/designs/modern-and-sleek/index.html new file mode 100644 index 00000000..669646d4 --- /dev/null +++ b/test/seed/field-service-dispatch/designs/modern-and-sleek/index.html @@ -0,0 +1,81 @@ + + + + + + Field Service Dispatch Board + + + +
+ NorthGrid Field Dispatch + + +
+
+
+

Dispatch Queue

+
+ 2 Emergency HVAC work orders breach SLA within 22 minutes +
+
+

+ WO-4821 · Mercy West Cold Storage · Chiller + Compressor Fault · SLA 18m +

+

+ WO-4824 · Harborview Logistics · Main Breaker Trip + · SLA 41m +

+
+
+ +
+ + + diff --git a/test/seed/field-service-dispatch/designs/modern-and-sleek/screens/home.html b/test/seed/field-service-dispatch/designs/modern-and-sleek/screens/home.html new file mode 100644 index 00000000..669646d4 --- /dev/null +++ b/test/seed/field-service-dispatch/designs/modern-and-sleek/screens/home.html @@ -0,0 +1,81 @@ + + + + + + Field Service Dispatch Board + + + +
+ NorthGrid Field Dispatch + + +
+
+
+

Dispatch Queue

+
+ 2 Emergency HVAC work orders breach SLA within 22 minutes +
+
+

+ WO-4821 · Mercy West Cold Storage · Chiller + Compressor Fault · SLA 18m +

+

+ WO-4824 · Harborview Logistics · Main Breaker Trip + · SLA 41m +

+
+
+ +
+ + + diff --git a/test/seed/field-service-dispatch/designs/modern-and-sleek/styles.css b/test/seed/field-service-dispatch/designs/modern-and-sleek/styles.css new file mode 100644 index 00000000..70dbcbca --- /dev/null +++ b/test/seed/field-service-dispatch/designs/modern-and-sleek/styles.css @@ -0,0 +1,63 @@ +:root { + --background: #fafafa; + --foreground: #18181b; + --card: #ffffff; + --card-foreground: #18181b; + --popover: #ffffff; + --popover-foreground: #18181b; + --primary: #2747d6; + --primary-foreground: #ffffff; + --primary-active: #1d37b0; + --secondary: #f1f1f3; + --secondary-foreground: #18181b; + --muted: #f1f1f3; + --muted-foreground: #5f5f68; + --accent: #eef1fd; + --accent-foreground: #1d37b0; + --destructive: #c42b2b; + --destructive-foreground: #ffffff; + --success: #e3f4ea; + --success-foreground: #17603a; + --warning: #fdf1dc; + --warning-foreground: #8a5300; + --border: #e4e4e7; + --input: #c9c9cf; + --ring: #2747d6; + --radius: 0.625rem; + --radius-sm: calc(var(--radius) - 2px); + --radius-lg: calc(var(--radius) + 4px); + --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-mono: ui-monospace, "SF Mono", Menlo, monospace; +} +.dark { + --background: #0b0b0d; + --foreground: #f4f4f5; + --card: #151518; + --card-foreground: #f4f4f5; + --popover: #1c1c20; + --popover-foreground: #f4f4f5; + --primary: #7089ff; + --primary-foreground: #0b0b0d; + --primary-active: #8ea1ff; + --secondary: #222227; + --secondary-foreground: #f4f4f5; + --muted: #1c1c20; + --muted-foreground: #a1a1aa; + --accent: #1d2240; + --accent-foreground: #b9c5ff; + --destructive: #f07272; + --destructive-foreground: #0b0b0d; + --success: #12301f; + --success-foreground: #8fdcae; + --warning: #36270c; + --warning-foreground: #f2c46d; + --border: #27272c; + --input: #3f3f46; + --ring: #7089ff; +} +body { + margin: 0; + background: var(--background); + color: var(--foreground); + font-family: var(--font-sans); +} diff --git a/test/seed/field-service-dispatch/docs/approaches.md b/test/seed/field-service-dispatch/docs/approaches.md new file mode 100644 index 00000000..9cbe9470 --- /dev/null +++ b/test/seed/field-service-dispatch/docs/approaches.md @@ -0,0 +1,13 @@ +# Approaches: Field Service Dispatch Board + +## Option 1: Queue + Technician Capacity Split View (Recommended) + +Combine a filterable work-order queue and SLA alert strip on the left with a contextual assignment and status drawer on the right, backed by relational work-order and technician tables. + +## Option 2: Full-Screen Drag-and-Drop Gantt Timeline + +Render a 24-hour horizontal Gantt grid with draggable blocks per technician. + +## Option 3: Map-First Geo Pin Board + +Center the entire product on a full-bleed map canvas with floating overlays. diff --git a/test/seed/field-service-dispatch/docs/build_plan.md b/test/seed/field-service-dispatch/docs/build_plan.md new file mode 100644 index 00000000..9717722c --- /dev/null +++ b/test/seed/field-service-dispatch/docs/build_plan.md @@ -0,0 +1,20 @@ +# Build Plan: Field Service Dispatch Board + +## Data Model + +- `technicians`: `id`, `name`, `zone`, `shift_status`, `certifications_json`, `phone`. +- `work_orders`: `id`, `code`, `customer_name`, `site_address`, `priority`, `required_skill`, `status`, `assigned_technician_id`, `sla_due_at`, `notes`. +- `dispatch_events`: `id`, `work_order_id`, `event_type`, `summary`, `created_at`. + +## API Routes + +- `GET /api/dispatch/board`: Returns work orders, technicians, and SLA summary metrics. +- `POST /api/work-orders`: Creates a new emergency or scheduled work order. +- `POST /api/work-orders/:id/assign`: Assigns or reassigns a technician and records a dispatch event. +- `POST /api/work-orders/:id/status`: Transitions work-order status and timestamps completion. + +## Acceptance Checklist + +- [ ] Dispatch queue filters work orders by status, priority, and search query. +- [ ] Assigning a technician updates the work order to Dispatched and logs a dispatch event. +- [ ] Advancing status through En Route, On Site, and Completed updates KPI counters. diff --git a/test/seed/field-service-dispatch/docs/constraints.md b/test/seed/field-service-dispatch/docs/constraints.md new file mode 100644 index 00000000..db713d47 --- /dev/null +++ b/test/seed/field-service-dispatch/docs/constraints.md @@ -0,0 +1,11 @@ +# Constraints: Field Service Dispatch Board + +## Operational & Domain Constraints + +- **Certification matching**: A work order requiring `High Voltage` or `Commercial Chiller` certification warns the dispatcher if assigned to a technician without that active certification. +- **Explicit status state machine**: Work orders move through `unassigned`, `dispatched`, `en_route`, `on_site`, `parts_hold`, and `completed`. +- **SLA breach visibility**: Any open emergency work order within 30 minutes of its response deadline surfaces in the top SLA Alert banner. + +## Interface Constraints + +- **List-detail split**: Dispatchers inspect a work order's site notes, contact phone, required parts, and candidate technicians without losing sight of the queue. diff --git a/test/seed/field-service-dispatch/docs/design.md b/test/seed/field-service-dispatch/docs/design.md new file mode 100644 index 00000000..14fae521 --- /dev/null +++ b/test/seed/field-service-dispatch/docs/design.md @@ -0,0 +1,81 @@ +--- +name: "Field Service Dispatch Board (Modern and sleek)" +colors: + background: "#fafafa" + foreground: "#18181b" + card: "#ffffff" + card-foreground: "#18181b" + popover: "#ffffff" + popover-foreground: "#18181b" + primary: "#2747d6" + primary-foreground: "#ffffff" + primary-active: "#1d37b0" + secondary: "#f1f1f3" + secondary-foreground: "#18181b" + muted: "#f1f1f3" + muted-foreground: "#5f5f68" + accent: "#eef1fd" + accent-foreground: "#1d37b0" + destructive: "#c42b2b" + destructive-foreground: "#ffffff" + success: "#e3f4ea" + success-foreground: "#17603a" + warning: "#fdf1dc" + warning-foreground: "#8a5300" + border: "#e4e4e7" + input: "#c9c9cf" + ring: "#2747d6" + dark-background: "#0b0b0d" + dark-foreground: "#f4f4f5" + dark-card: "#151518" + dark-card-foreground: "#f4f4f5" + dark-popover: "#1c1c20" + dark-popover-foreground: "#f4f4f5" + dark-primary: "#7089ff" + dark-primary-foreground: "#0b0b0d" + dark-primary-active: "#8ea1ff" + dark-secondary: "#222227" + dark-secondary-foreground: "#f4f4f5" + dark-muted: "#1c1c20" + dark-muted-foreground: "#a1a1aa" + dark-accent: "#1d2240" + dark-accent-foreground: "#b9c5ff" + dark-destructive: "#f07272" + dark-destructive-foreground: "#0b0b0d" + dark-success: "#12301f" + dark-success-foreground: "#8fdcae" + dark-warning: "#36270c" + dark-warning-foreground: "#f2c46d" + dark-border: "#27272c" + dark-input: "#3f3f46" + dark-ring: "#7089ff" +typography: + font-sans: + fontFamily: 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif' + font-mono: + fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace' +rounded: + radius: "0.625rem" + radius-sm: "8px" + radius-lg: "14px" +--- + +# Field Service Dispatch Board (Modern and sleek) + +## Summary + +A high-clarity operational dispatch desk with cool zinc neutrals, cobalt selection highlights, and tabular SLA timers. + +## Screens + +### Dispatch + +- Route: `/` (mockup `#/dispatch`) +- States: `populated`, `empty` +- Test ids: `dispatch-title`, `sla-alert-banner`, `work-order-table`, `work-order-detail` + +### Technicians + +- Route: `/technicians` (mockup `#/technicians`) +- States: `populated` +- Test ids: `technicians-title`, `technicians-grid` diff --git a/test/seed/field-service-dispatch/docs/problem_brief.md b/test/seed/field-service-dispatch/docs/problem_brief.md new file mode 100644 index 00000000..a9a435ad --- /dev/null +++ b/test/seed/field-service-dispatch/docs/problem_brief.md @@ -0,0 +1,17 @@ +# Problem Brief: Field Service Dispatch Board + +## Problem Statement + +Regional HVAC and commercial electrical dispatchers coordinate 15 to 40 field technicians across overlapping service windows. When emergency calls arrive or a job runs over its window, dispatchers struggle to see which qualified technician is closest, who has capacity, and which SLA timers are within 30 minutes of breaching. + +## Target Users + +- **Lead Dispatcher**: Assigns unassigned work orders, rebalances routes, and monitors SLA countdowns. +- **Operations Supervisor**: Reviews completion rates, on-time arrival metrics, and parts holds. + +## Core Workflows + +1. Triage incoming and unassigned work orders sorted by priority (`Emergency`, `High`, `Routine`) and SLA deadline. +2. Match a work order to an available technician with matching certifications (`HVAC-R`, `High Voltage`, `Boiler`). +3. Transition work orders through `Unassigned` → `Dispatched` → `En Route` → `On Site` → `Completed`. +4. Resolve or reassign jobs flagged in the SLA At-Risk strip. diff --git a/test/seed/field-service-dispatch/docs/product_requirements.md b/test/seed/field-service-dispatch/docs/product_requirements.md new file mode 100644 index 00000000..dfa04d9e --- /dev/null +++ b/test/seed/field-service-dispatch/docs/product_requirements.md @@ -0,0 +1,10 @@ +# Product Requirements: Field Service Dispatch Board + +## Screens & Navigation + +1. **Dispatch Queue (`/`)**: + - **KPI & SLA Strip**: Open work orders, technicians on site, SLA at-risk count, and median response time. + - **Filterable Work Order Table**: Work order ID, customer site, priority badge, required skill, assigned technician, status badge, and SLA timer. + - **Assignment & Status Detail Panel**: Customer contact, equipment serial, status progression buttons, and recommended technicians ranked by skill match and current load. +2. **Technicians Roster (`/technicians`)**: + - Shift status (`Available`, `On Job`, `Off Shift`), certifications, active work order, and completed jobs today. diff --git a/test/seed/field-service-dispatch/project.json b/test/seed/field-service-dispatch/project.json new file mode 100644 index 00000000..d1d3f4f0 --- /dev/null +++ b/test/seed/field-service-dispatch/project.json @@ -0,0 +1,105 @@ +{ + "slug": "field-service-dispatch", + "id": "field-service-dispatch", + "title": "Field Service Dispatch Board", + "complexity": "medium", + "defaultStage": "design_options", + "idea": "Build a field service dispatch board for regional HVAC and electrical operations where dispatchers assign technicians to urgent work orders, track status transitions, and resolve SLA breach alerts.", + "problemStatement": "Build a field service dispatch board for regional HVAC and electrical operations where dispatchers assign technicians to urgent work orders, track status transitions, and resolve SLA breach alerts.", + "chosenConceptId": "modern-and-sleek", + "chosenDesignOption": "Modern and sleek", + "documents": { + "problem_brief": { + "title": "Problem Brief — Field Service Dispatch Board", + "path": "docs/problem_brief.md" + }, + "constraints": { + "title": "Constraints — Field Service Dispatch Board", + "path": "docs/constraints.md" + }, + "approaches": { + "title": "Approaches — Field Service Dispatch Board", + "path": "docs/approaches.md", + "approaches": { + "recommended": "queue-capacity-split", + "approaches": [ + { + "id": "queue-capacity-split", + "name": "Queue + Technician Capacity Split View", + "summary": "Filterable priority queue with inline SLA timers paired with a side-by-side technician matcher and status transition panel.", + "bestFor": "Fast keyboard-and-mouse triage during peak dispatch hours at both desktop and tablet widths.", + "pros": [ + "Shows SLA countdowns, required certifications, and technician workload at a glance", + "Accessible keyboard navigation and predictable responsive behavior", + "Clean audit log for every assignment and status change" + ], + "cons": [ + "Does not show multi-hour visual overlap bars like a Gantt chart" + ], + "effort": "medium", + "risks": [ + "Must keep queue filters and selected detail synchronized after status transitions" + ] + }, + { + "id": "gantt-timeline", + "name": "Full-Screen Drag-and-Drop Gantt Timeline", + "summary": "Horizontal time-slot grid where dispatchers drag work-order blocks onto technician rows.", + "bestFor": "Multi-day planned maintenance scheduling on wide monitors.", + "pros": ["Visualizes shift gaps across the day"], + "cons": [ + "Cramped on screens narrower than 1280px", + "Harder to triage urgent unscheduled emergency calls quickly" + ], + "effort": "high", + "risks": ["Complex drag collision handling when jobs run late"] + }, + { + "id": "map-first-geo-pin-board", + "name": "Map-First Geo Pin Board", + "summary": "Center the dispatch workspace on a full-bleed map canvas with floating overlays for work orders and technicians.", + "bestFor": "Dense urban courier routing where geographic clustering is the primary decision factor.", + "pros": ["Immediate spatial view of technician proximity"], + "cons": [ + "Floating drawers obscure map pins when comparing multiple SLA deadlines", + "Requires external map tile keys and geocoding for every site address" + ], + "effort": "high", + "risks": [ + "Overlapping pins at multi-unit commercial sites hide urgent alerts" + ] + } + ] + } + }, + "product_requirements": { + "title": "Product Requirements — Field Service Dispatch Board", + "path": "docs/product_requirements.md" + }, + "design": { + "title": "Modern and sleek", + "path": "docs/design.md" + }, + "build_plan": { + "title": "Build Plan — Field Service Dispatch Board", + "path": "docs/build_plan.md" + } + }, + "designOptions": [ + { + "conceptId": "modern-and-sleek", + "title": "Modern and sleek", + "direction": "Crisp cool zinc neutrals with a single cobalt accent (#2747d6) for primary dispatch actions and tabular SLA countdowns.", + "path": "designs/modern-and-sleek" + }, + { + "conceptId": "boxy-enterprise", + "title": "Boxy enterprise", + "direction": "Square corners (--radius 0), deep navy primary (#0f2942), and dense high-contrast hairline tables built for multi-monitor dispatch centers.", + "path": "designs/boxy-enterprise" + } + ], + "app": { + "path": "app" + } +} diff --git a/test/seed/habit-tracker/app/ACCEPTANCE.md b/test/seed/habit-tracker/app/ACCEPTANCE.md new file mode 100644 index 00000000..84de5bc0 --- /dev/null +++ b/test/seed/habit-tracker/app/ACCEPTANCE.md @@ -0,0 +1,5 @@ +# Acceptance Checklist — Daily Streak & Habit Tracker + +- [x] Summary cards show completed today count, completion rate, and longest current streak. +- [x] Toggling a habit updates its check-in state and streak count immediately. +- [x] Creating a new habit adds it to the list with a 0-day streak. diff --git a/test/seed/habit-tracker/app/index.html b/test/seed/habit-tracker/app/index.html new file mode 100644 index 00000000..fab11fd8 --- /dev/null +++ b/test/seed/habit-tracker/app/index.html @@ -0,0 +1,13 @@ + + + + + + Daily Streak & Habit Tracker + + + +
+ + + diff --git a/test/seed/habit-tracker/app/package.json b/test/seed/habit-tracker/app/package.json new file mode 100644 index 00000000..495cefe2 --- /dev/null +++ b/test/seed/habit-tracker/app/package.json @@ -0,0 +1,10 @@ +{ + "name": "habit-tracker-app", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + } +} diff --git a/test/seed/habit-tracker/app/server/src/data/demo-account.json b/test/seed/habit-tracker/app/server/src/data/demo-account.json new file mode 100644 index 00000000..4ef9a35a --- /dev/null +++ b/test/seed/habit-tracker/app/server/src/data/demo-account.json @@ -0,0 +1,4 @@ +{ + "email": "alex@example.com", + "password": "demo-habit-password" +} diff --git a/test/seed/habit-tracker/app/server/src/index.ts b/test/seed/habit-tracker/app/server/src/index.ts new file mode 100644 index 00000000..576d2122 --- /dev/null +++ b/test/seed/habit-tracker/app/server/src/index.ts @@ -0,0 +1,6 @@ +export default { + port: 3000, + fetch() { + return Response.json({ ok: true, habitsCompletedToday: 3 }); + }, +}; diff --git a/test/seed/habit-tracker/app/src/App.tsx b/test/seed/habit-tracker/app/src/App.tsx new file mode 100644 index 00000000..ff75d95a --- /dev/null +++ b/test/seed/habit-tracker/app/src/App.tsx @@ -0,0 +1,8 @@ +export function App() { + return ( +
+

Daily Streak & Habit Tracker

+

3 of 4 habits completed today

+
+ ); +} diff --git a/test/seed/habit-tracker/app/src/main.tsx b/test/seed/habit-tracker/app/src/main.tsx new file mode 100644 index 00000000..4c16dbc7 --- /dev/null +++ b/test/seed/habit-tracker/app/src/main.tsx @@ -0,0 +1,5 @@ +import { App } from "./App.tsx"; + +export function mountApp() { + return App(); +} diff --git a/test/seed/habit-tracker/app/src/styles.css b/test/seed/habit-tracker/app/src/styles.css new file mode 100644 index 00000000..3cacdf9c --- /dev/null +++ b/test/seed/habit-tracker/app/src/styles.css @@ -0,0 +1,75 @@ +:root { + --background: #fbf8f3; + --foreground: #231f1b; + --card: #fffdf9; + --card-foreground: #231f1b; + --popover: #fffdf9; + --popover-foreground: #231f1b; + --primary: #b85d38; + --primary-foreground: #fffdf9; + --primary-active: #9a4a2a; + --secondary: #f2ebe1; + --secondary-foreground: #231f1b; + --muted: #f2ebe1; + --muted-foreground: #6e6459; + --accent: #f7e6dc; + --accent-foreground: #7c371b; + --destructive: #b93829; + --destructive-foreground: #fffdf9; + --success: #e3f1e6; + --success-foreground: #1e5a32; + --warning: #faeed7; + --warning-foreground: #7a4f0d; + --border: #e4dacb; + --input: #d2c5b3; + --ring: #b85d38; + --radius: 0.75rem; + --radius-sm: calc(var(--radius) - 2px); + --radius-lg: calc(var(--radius) + 4px); + --font-sans: Optima, Candara, "Segoe UI", sans-serif; + --font-mono: ui-monospace, "SF Mono", Menlo, monospace; +} +.dark { + --background: #171412; + --foreground: #f5efe6; + --card: #211d1a; + --card-foreground: #f5efe6; + --popover: #28231f; + --popover-foreground: #f5efe6; + --primary: #e0825b; + --primary-foreground: #171412; + --primary-active: #eb9b79; + --secondary: #2c2622; + --secondary-foreground: #f5efe6; + --muted: #26211d; + --muted-foreground: #b0a395; + --accent: #3a261d; + --accent-foreground: #f5bfa8; + --destructive: #e66b5b; + --destructive-foreground: #171412; + --success: #193824; + --success-foreground: #93deb0; + --warning: #3b2a10; + --warning-foreground: #f3c878; + --border: #362f29; + --input: #4a4038; + --ring: #e0825b; +} +body { + margin: 0; + background: var(--background); + color: var(--foreground); + font-family: var(--font-sans); +} +.shell { + max-width: 920px; + margin: 0 auto; + padding: 24px 16px 48px; +} +.card { + background: var(--card); + color: var(--card-foreground); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 20px; +} diff --git a/test/seed/habit-tracker/designs/minimal-monochrome/DESIGN.md b/test/seed/habit-tracker/designs/minimal-monochrome/DESIGN.md new file mode 100644 index 00000000..f713f8c3 --- /dev/null +++ b/test/seed/habit-tracker/designs/minimal-monochrome/DESIGN.md @@ -0,0 +1,74 @@ +--- +name: "Daily Streak & Habit Tracker (Minimal monochrome)" +colors: + background: "#ffffff" + foreground: "#111111" + card: "#fafafa" + card-foreground: "#111111" + popover: "#ffffff" + popover-foreground: "#111111" + primary: "#141414" + primary-foreground: "#ffffff" + primary-active: "#2b2b2b" + secondary: "#f0f0f0" + secondary-foreground: "#111111" + muted: "#f3f3f3" + muted-foreground: "#595959" + accent: "#eaeaea" + accent-foreground: "#111111" + destructive: "#b00020" + destructive-foreground: "#ffffff" + success: "#e8f3ec" + success-foreground: "#155724" + warning: "#fff4db" + warning-foreground: "#73510d" + border: "#e2e2e2" + input: "#cccccc" + ring: "#141414" + dark-background: "#0d0d0d" + dark-foreground: "#f2f2f2" + dark-card: "#161616" + dark-card-foreground: "#f2f2f2" + dark-popover: "#1f1f1f" + dark-popover-foreground: "#f2f2f2" + dark-primary: "#f2f2f2" + dark-primary-foreground: "#0d0d0d" + dark-primary-active: "#d9d9d9" + dark-secondary: "#242424" + dark-secondary-foreground: "#f2f2f2" + dark-muted: "#1f1f1f" + dark-muted-foreground: "#a3a3a3" + dark-accent: "#292929" + dark-accent-foreground: "#f2f2f2" + dark-destructive: "#ff6b6b" + dark-destructive-foreground: "#0d0d0d" + dark-success: "#163320" + dark-success-foreground: "#9be2b4" + dark-warning: "#382a0e" + dark-warning-foreground: "#f5cd79" + dark-border: "#2c2c2c" + dark-input: "#404040" + dark-ring: "#f2f2f2" +typography: + font-sans: + fontFamily: 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif' + font-mono: + fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace' +rounded: + radius: "0.375rem" + radius-sm: "4px" + radius-lg: "10px" +--- + +# Daily Streak & Habit Tracker (Minimal monochrome) + +## Summary + +An ink-and-paper daily habit checklist with zero visual noise. + +## Screens + +### Today + +- Route: `/` (mockup `#/today`) +- States: `populated`, `empty` diff --git a/test/seed/habit-tracker/designs/minimal-monochrome/index.html b/test/seed/habit-tracker/designs/minimal-monochrome/index.html new file mode 100644 index 00000000..33a5b236 --- /dev/null +++ b/test/seed/habit-tracker/designs/minimal-monochrome/index.html @@ -0,0 +1,22 @@ + + + + + + Daily Streak & Habit Tracker — Monochrome + + + +
+
+
+

Today — 75% Complete

+

4 daily habits · 18-day best streak

+
+
+ +
+ + diff --git a/test/seed/habit-tracker/designs/minimal-monochrome/screens/home.html b/test/seed/habit-tracker/designs/minimal-monochrome/screens/home.html new file mode 100644 index 00000000..33a5b236 --- /dev/null +++ b/test/seed/habit-tracker/designs/minimal-monochrome/screens/home.html @@ -0,0 +1,22 @@ + + + + + + Daily Streak & Habit Tracker — Monochrome + + + +
+
+
+

Today — 75% Complete

+

4 daily habits · 18-day best streak

+
+
+ +
+ + diff --git a/test/seed/habit-tracker/designs/minimal-monochrome/styles.css b/test/seed/habit-tracker/designs/minimal-monochrome/styles.css new file mode 100644 index 00000000..6faf1583 --- /dev/null +++ b/test/seed/habit-tracker/designs/minimal-monochrome/styles.css @@ -0,0 +1,75 @@ +:root { + --background: #ffffff; + --foreground: #111111; + --card: #fafafa; + --card-foreground: #111111; + --popover: #ffffff; + --popover-foreground: #111111; + --primary: #141414; + --primary-foreground: #ffffff; + --primary-active: #2b2b2b; + --secondary: #f0f0f0; + --secondary-foreground: #111111; + --muted: #f3f3f3; + --muted-foreground: #595959; + --accent: #eaeaea; + --accent-foreground: #111111; + --destructive: #b00020; + --destructive-foreground: #ffffff; + --success: #e8f3ec; + --success-foreground: #155724; + --warning: #fff4db; + --warning-foreground: #73510d; + --border: #e2e2e2; + --input: #cccccc; + --ring: #141414; + --radius: 0.375rem; + --radius-sm: calc(var(--radius) - 2px); + --radius-lg: calc(var(--radius) + 4px); + --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-mono: ui-monospace, "SF Mono", Menlo, monospace; +} +.dark { + --background: #0d0d0d; + --foreground: #f2f2f2; + --card: #161616; + --card-foreground: #f2f2f2; + --popover: #1f1f1f; + --popover-foreground: #f2f2f2; + --primary: #f2f2f2; + --primary-foreground: #0d0d0d; + --primary-active: #d9d9d9; + --secondary: #242424; + --secondary-foreground: #f2f2f2; + --muted: #1f1f1f; + --muted-foreground: #a3a3a3; + --accent: #292929; + --accent-foreground: #f2f2f2; + --destructive: #ff6b6b; + --destructive-foreground: #0d0d0d; + --success: #163320; + --success-foreground: #9be2b4; + --warning: #382a0e; + --warning-foreground: #f5cd79; + --border: #2c2c2c; + --input: #404040; + --ring: #f2f2f2; +} +body { + margin: 0; + background: var(--background); + color: var(--foreground); + font-family: var(--font-sans); +} +.shell { + max-width: 920px; + margin: 0 auto; + padding: 24px 16px 48px; +} +.card { + background: var(--card); + color: var(--card-foreground); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 20px; +} diff --git a/test/seed/habit-tracker/designs/warm-and-organic/DESIGN.md b/test/seed/habit-tracker/designs/warm-and-organic/DESIGN.md new file mode 100644 index 00000000..809076a9 --- /dev/null +++ b/test/seed/habit-tracker/designs/warm-and-organic/DESIGN.md @@ -0,0 +1,75 @@ +--- +name: "Daily Streak & Habit Tracker (Warm and organic)" +colors: + background: "#fbf8f3" + foreground: "#231f1b" + card: "#fffdf9" + card-foreground: "#231f1b" + popover: "#fffdf9" + popover-foreground: "#231f1b" + primary: "#b85d38" + primary-foreground: "#fffdf9" + primary-active: "#9a4a2a" + secondary: "#f2ebe1" + secondary-foreground: "#231f1b" + muted: "#f2ebe1" + muted-foreground: "#6e6459" + accent: "#f7e6dc" + accent-foreground: "#7c371b" + destructive: "#b93829" + destructive-foreground: "#fffdf9" + success: "#e3f1e6" + success-foreground: "#1e5a32" + warning: "#faeed7" + warning-foreground: "#7a4f0d" + border: "#e4dacb" + input: "#d2c5b3" + ring: "#b85d38" + dark-background: "#171412" + dark-foreground: "#f5efe6" + dark-card: "#211d1a" + dark-card-foreground: "#f5efe6" + dark-popover: "#28231f" + dark-popover-foreground: "#f5efe6" + dark-primary: "#e0825b" + dark-primary-foreground: "#171412" + dark-primary-active: "#eb9b79" + dark-secondary: "#2c2622" + dark-secondary-foreground: "#f5efe6" + dark-muted: "#26211d" + dark-muted-foreground: "#b0a395" + dark-accent: "#3a261d" + dark-accent-foreground: "#f5bfa8" + dark-destructive: "#e66b5b" + dark-destructive-foreground: "#171412" + dark-success: "#193824" + dark-success-foreground: "#93deb0" + dark-warning: "#3b2a10" + dark-warning-foreground: "#f3c878" + dark-border: "#362f29" + dark-input: "#4a4038" + dark-ring: "#e0825b" +typography: + font-sans: + fontFamily: 'Optima, Candara, "Segoe UI", sans-serif' + font-mono: + fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace' +rounded: + radius: "0.75rem" + radius-sm: "10px" + radius-lg: "16px" +--- + +# Daily Streak & Habit Tracker (Warm and organic) + +## Summary + +A calm, tactile single-screen habit ledger in warm cream and terracotta tones. + +## Screens + +### Today + +- Route: `/` (mockup `#/today`) +- States: `populated`, `empty` +- Test ids: `today-title`, `habit-add-button`, `habit-list`, `streak-summary` diff --git a/test/seed/habit-tracker/designs/warm-and-organic/index.html b/test/seed/habit-tracker/designs/warm-and-organic/index.html new file mode 100644 index 00000000..6a26300d --- /dev/null +++ b/test/seed/habit-tracker/designs/warm-and-organic/index.html @@ -0,0 +1,70 @@ + + + + + + Daily Streak & Habit Tracker + + + +
+
+ Daily Streak & Habit Tracker + +
+
+
+
+
+

3 of 4 habits completed today

+

Longest active streak: 18 days (Morning Hydration)

+
+
+
+ Morning Hydration (500ml water) + 18d streak +
+
+ Read 20 pages before bed + 11d streak +
+
+
+ +
+ + diff --git a/test/seed/habit-tracker/designs/warm-and-organic/screens/home.html b/test/seed/habit-tracker/designs/warm-and-organic/screens/home.html new file mode 100644 index 00000000..6a26300d --- /dev/null +++ b/test/seed/habit-tracker/designs/warm-and-organic/screens/home.html @@ -0,0 +1,70 @@ + + + + + + Daily Streak & Habit Tracker + + + +
+
+ Daily Streak & Habit Tracker + +
+
+
+
+
+

3 of 4 habits completed today

+

Longest active streak: 18 days (Morning Hydration)

+
+
+
+ Morning Hydration (500ml water) + 18d streak +
+
+ Read 20 pages before bed + 11d streak +
+
+
+ +
+ + diff --git a/test/seed/habit-tracker/designs/warm-and-organic/styles.css b/test/seed/habit-tracker/designs/warm-and-organic/styles.css new file mode 100644 index 00000000..3cacdf9c --- /dev/null +++ b/test/seed/habit-tracker/designs/warm-and-organic/styles.css @@ -0,0 +1,75 @@ +:root { + --background: #fbf8f3; + --foreground: #231f1b; + --card: #fffdf9; + --card-foreground: #231f1b; + --popover: #fffdf9; + --popover-foreground: #231f1b; + --primary: #b85d38; + --primary-foreground: #fffdf9; + --primary-active: #9a4a2a; + --secondary: #f2ebe1; + --secondary-foreground: #231f1b; + --muted: #f2ebe1; + --muted-foreground: #6e6459; + --accent: #f7e6dc; + --accent-foreground: #7c371b; + --destructive: #b93829; + --destructive-foreground: #fffdf9; + --success: #e3f1e6; + --success-foreground: #1e5a32; + --warning: #faeed7; + --warning-foreground: #7a4f0d; + --border: #e4dacb; + --input: #d2c5b3; + --ring: #b85d38; + --radius: 0.75rem; + --radius-sm: calc(var(--radius) - 2px); + --radius-lg: calc(var(--radius) + 4px); + --font-sans: Optima, Candara, "Segoe UI", sans-serif; + --font-mono: ui-monospace, "SF Mono", Menlo, monospace; +} +.dark { + --background: #171412; + --foreground: #f5efe6; + --card: #211d1a; + --card-foreground: #f5efe6; + --popover: #28231f; + --popover-foreground: #f5efe6; + --primary: #e0825b; + --primary-foreground: #171412; + --primary-active: #eb9b79; + --secondary: #2c2622; + --secondary-foreground: #f5efe6; + --muted: #26211d; + --muted-foreground: #b0a395; + --accent: #3a261d; + --accent-foreground: #f5bfa8; + --destructive: #e66b5b; + --destructive-foreground: #171412; + --success: #193824; + --success-foreground: #93deb0; + --warning: #3b2a10; + --warning-foreground: #f3c878; + --border: #362f29; + --input: #4a4038; + --ring: #e0825b; +} +body { + margin: 0; + background: var(--background); + color: var(--foreground); + font-family: var(--font-sans); +} +.shell { + max-width: 920px; + margin: 0 auto; + padding: 24px 16px 48px; +} +.card { + background: var(--card); + color: var(--card-foreground); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 20px; +} diff --git a/test/seed/habit-tracker/docs/approaches.md b/test/seed/habit-tracker/docs/approaches.md new file mode 100644 index 00000000..36e7e2d8 --- /dev/null +++ b/test/seed/habit-tracker/docs/approaches.md @@ -0,0 +1,13 @@ +# Approaches: Daily Streak & Habit Tracker + +## Option 1: SQLite-Backed Daily Ledger (Recommended) + +Store habits and normalized daily check-in rows (`habit_id`, `completed_on`) in SQLite via Drizzle, computing current and best streaks in a single query. + +## Option 2: Browser-Only LocalStorage Store + +Keep all habits and completion dates in `localStorage` with manual JSON backup files. + +## Option 3: Event-Sourced Habit Log + +Record immutable habit events (created, paused, checked, unchecked) and replay them on every read. diff --git a/test/seed/habit-tracker/docs/build_plan.md b/test/seed/habit-tracker/docs/build_plan.md new file mode 100644 index 00000000..f12dd7c5 --- /dev/null +++ b/test/seed/habit-tracker/docs/build_plan.md @@ -0,0 +1,18 @@ +# Build Plan: Daily Streak & Habit Tracker + +## Architecture & Stack + +- **Frontend**: React 19, Vite, Tailwind CSS, `@corbits/react-ui` components (`Card`, `Button`, `Badge`, `Dialog`, `Input`, `Checkbox`). +- **Backend**: Bun + Hono REST API with SQLite (`bun:sqlite` + Drizzle ORM). + +## Vertical Slices + +1. **Schema & Seed**: `habits` and `habit_completions` tables seeded with 4 habits and 14 days of history. +2. **API Endpoints**: `GET /api/habits?date=YYYY-MM-DD`, `POST /api/habits`, `POST /api/habits/:id/toggle`. +3. **Today Screen**: Summary stat cards, filter bar, interactive habit rows with 7-day dots, and New Habit dialog. + +## Acceptance Checklist + +- [ ] Summary cards show completed today count, completion rate, and longest current streak. +- [ ] Toggling a habit updates its check-in state and streak count immediately. +- [ ] Creating a new habit adds it to the list with a 0-day streak. diff --git a/test/seed/habit-tracker/docs/constraints.md b/test/seed/habit-tracker/docs/constraints.md new file mode 100644 index 00000000..f9720a60 --- /dev/null +++ b/test/seed/habit-tracker/docs/constraints.md @@ -0,0 +1,12 @@ +# Constraints: Daily Streak & Habit Tracker + +## Experience & Layout Constraints + +- **Single-screen architecture**: All daily check-ins, streak figures, and the 7-day history grid must fit on one primary screen without secondary route navigation. +- **Immediate check-in response**: Toggling a habit updates the streak counter and weekly progress summary within 100ms. +- **Responsive density**: Works cleanly at 360px mobile width and 1280px desktop width. + +## Data & Technical Constraints + +- **Deterministic streak calculation**: Streaks are computed in the user's local calendar date (YYYY-MM-DD) so midnight UTC shifts never break a streak early. +- **Seeded starter habits**: Ships with 4 realistic daily habits and 14 days of completion history so the screen is immediately meaningful on first open. diff --git a/test/seed/habit-tracker/docs/design.md b/test/seed/habit-tracker/docs/design.md new file mode 100644 index 00000000..809076a9 --- /dev/null +++ b/test/seed/habit-tracker/docs/design.md @@ -0,0 +1,75 @@ +--- +name: "Daily Streak & Habit Tracker (Warm and organic)" +colors: + background: "#fbf8f3" + foreground: "#231f1b" + card: "#fffdf9" + card-foreground: "#231f1b" + popover: "#fffdf9" + popover-foreground: "#231f1b" + primary: "#b85d38" + primary-foreground: "#fffdf9" + primary-active: "#9a4a2a" + secondary: "#f2ebe1" + secondary-foreground: "#231f1b" + muted: "#f2ebe1" + muted-foreground: "#6e6459" + accent: "#f7e6dc" + accent-foreground: "#7c371b" + destructive: "#b93829" + destructive-foreground: "#fffdf9" + success: "#e3f1e6" + success-foreground: "#1e5a32" + warning: "#faeed7" + warning-foreground: "#7a4f0d" + border: "#e4dacb" + input: "#d2c5b3" + ring: "#b85d38" + dark-background: "#171412" + dark-foreground: "#f5efe6" + dark-card: "#211d1a" + dark-card-foreground: "#f5efe6" + dark-popover: "#28231f" + dark-popover-foreground: "#f5efe6" + dark-primary: "#e0825b" + dark-primary-foreground: "#171412" + dark-primary-active: "#eb9b79" + dark-secondary: "#2c2622" + dark-secondary-foreground: "#f5efe6" + dark-muted: "#26211d" + dark-muted-foreground: "#b0a395" + dark-accent: "#3a261d" + dark-accent-foreground: "#f5bfa8" + dark-destructive: "#e66b5b" + dark-destructive-foreground: "#171412" + dark-success: "#193824" + dark-success-foreground: "#93deb0" + dark-warning: "#3b2a10" + dark-warning-foreground: "#f3c878" + dark-border: "#362f29" + dark-input: "#4a4038" + dark-ring: "#e0825b" +typography: + font-sans: + fontFamily: 'Optima, Candara, "Segoe UI", sans-serif' + font-mono: + fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace' +rounded: + radius: "0.75rem" + radius-sm: "10px" + radius-lg: "16px" +--- + +# Daily Streak & Habit Tracker (Warm and organic) + +## Summary + +A calm, tactile single-screen habit ledger in warm cream and terracotta tones. + +## Screens + +### Today + +- Route: `/` (mockup `#/today`) +- States: `populated`, `empty` +- Test ids: `today-title`, `habit-add-button`, `habit-list`, `streak-summary` diff --git a/test/seed/habit-tracker/docs/problem_brief.md b/test/seed/habit-tracker/docs/problem_brief.md new file mode 100644 index 00000000..852975c1 --- /dev/null +++ b/test/seed/habit-tracker/docs/problem_brief.md @@ -0,0 +1,21 @@ +# Problem Brief: Daily Streak & Habit Tracker + +## Problem Statement + +People who want to build 3 to 7 daily habits abandon complex productivity suites because logging a check-in takes too many taps or buries today's progress behind calendars, social feeds, and gamified clutter. + +## Target User + +An individual tracking personal daily routines (hydration, reading, stretching, language practice) who opens the app morning and evening for under 30 seconds. + +## Core Loop + +1. See today's date, overall completion count, and all active habits on a single screen. +2. Tap a habit checkbox to record or undo today's completion immediately. +3. Watch the current streak, best streak, and 7-day rolling history update in place. +4. Add or edit a habit with a title, target cadence, and optional local reminder time. + +## Scope Boundaries + +- Single-user workspace with SQLite persistence. +- No social leaderboards, coaching feeds, or paid subscription tiers. diff --git a/test/seed/habit-tracker/docs/product_requirements.md b/test/seed/habit-tracker/docs/product_requirements.md new file mode 100644 index 00000000..2a1166bc --- /dev/null +++ b/test/seed/habit-tracker/docs/product_requirements.md @@ -0,0 +1,8 @@ +# Product Requirements: Daily Streak & Habit Tracker + +## Functional Requirements + +1. **Today Summary Header**: Shows today's formatted date, completed count out of total active habits (e.g. `3 of 4 completed today`), and longest active streak. +2. **Habit Check-In List**: Each row displays the habit name, category badge, reminder time (e.g. `07:30`), 7-day dot strip, current streak in days, and a one-click check-in toggle. +3. **Add / Edit Habit Dialog**: Allows creating a habit with name, category (`Health`, `Focus`, `Mindfulness`), and reminder time. +4. **Filter & Empty States**: Filter between `All`, `Remaining today`, and `Completed today`, with a clear empty state when all habits for today are done. diff --git a/test/seed/habit-tracker/project.json b/test/seed/habit-tracker/project.json new file mode 100644 index 00000000..64b7810f --- /dev/null +++ b/test/seed/habit-tracker/project.json @@ -0,0 +1,99 @@ +{ + "slug": "habit-tracker", + "id": "habit-tracker", + "title": "Daily Streak & Habit Tracker", + "complexity": "simple", + "defaultStage": "shipped", + "idea": "Build a calm personal habit tracker with a single screen for daily check-ins, current and best streaks, a 7-day completion ring, and optional reminder times.", + "problemStatement": "Build a calm personal habit tracker with a single screen for daily check-ins, current and best streaks, a 7-day completion ring, and optional reminder times.", + "chosenConceptId": "warm-and-organic", + "chosenDesignOption": "Warm and organic", + "documents": { + "problem_brief": { + "title": "Problem Brief — Daily Streak & Habit Tracker", + "path": "docs/problem_brief.md" + }, + "constraints": { + "title": "Constraints — Daily Streak & Habit Tracker", + "path": "docs/constraints.md" + }, + "approaches": { + "title": "Approaches — Daily Streak & Habit Tracker", + "path": "docs/approaches.md", + "approaches": { + "recommended": "sqlite-ledger", + "approaches": [ + { + "id": "sqlite-ledger", + "name": "SQLite-Backed Daily Ledger", + "summary": "Store habits and per-date check-in rows in SQLite with deterministic streak aggregation.", + "bestFor": "Reliable multi-tab persistence, instant check-ins, and clean streak history queries.", + "pros": [ + "Unique (habit_id, completed_on) index prevents duplicate check-ins", + "Persists across browser restarts and device previews", + "Simple REST endpoints for toggle, create, and archive" + ], + "cons": ["Requires thin Hono API routes for check-in mutations"], + "effort": "low", + "risks": ["Local date formatting must use YYYY-MM-DD consistently"] + }, + { + "id": "browser-storage", + "name": "Browser-Only LocalStorage", + "summary": "Store the entire habit array and date map in window.localStorage.", + "bestFor": "Static prototypes that never touch a backend.", + "pros": ["Zero server code", "Instant synchronous reads"], + "cons": [ + "Data disappears when browser site data is cleared", + "Cannot share state across preview origins" + ], + "effort": "low", + "risks": ["Silent storage quota errors on mobile browsers"] + }, + { + "id": "event-log", + "name": "Event-Sourced Habit Log", + "summary": "Append every check, uncheck, and schedule change to an immutable event stream.", + "bestFor": "Audit-heavy habit coaching platforms.", + "pros": ["Full historical replay of every toggle"], + "cons": [ + "Unnecessary projection complexity for a personal habit tracker", + "Higher query cost as history grows" + ], + "effort": "high", + "risks": ["Projection bugs when habits change cadence"] + } + ] + } + }, + "product_requirements": { + "title": "Product Requirements — Daily Streak & Habit Tracker", + "path": "docs/product_requirements.md" + }, + "design": { + "title": "Warm and organic", + "path": "docs/design.md" + }, + "build_plan": { + "title": "Build Plan — Daily Streak & Habit Tracker", + "path": "docs/build_plan.md" + } + }, + "designOptions": [ + { + "conceptId": "warm-and-organic", + "title": "Warm and organic", + "direction": "Earthy terracotta primary on a warm cream background with soft brown borders and humanist sans typography.", + "path": "designs/warm-and-organic" + }, + { + "conceptId": "minimal-monochrome", + "title": "Minimal monochrome", + "direction": "Crisp ink-on-paper monochrome layout with tabular numerals and high-contrast streak indicators.", + "path": "designs/minimal-monochrome" + } + ], + "app": { + "path": "app" + } +} diff --git a/test/seed/index.ts b/test/seed/index.ts new file mode 100644 index 00000000..bc8e675b --- /dev/null +++ b/test/seed/index.ts @@ -0,0 +1,279 @@ +import { readFile, readdir } from "node:fs/promises"; +import { join, relative, resolve } from "node:path"; +import type { ApproachesMeta } from "@corbits-builder/schemas/approaches"; +import type { + AppBundle, + DesignBundle as DesignBundleType, +} from "@corbits-builder/schemas/artifact"; + +export const SEED_STAGES = [ + "idea", + "brief", + "planning", + "design_options", + "designed", + "ready_to_build", + "shipped", +] as const; + +export type SeedStage = (typeof SEED_STAGES)[number]; + +export const SEED_FIXTURE_IDS = [ + "habit-tracker", + "field-service-dispatch", + "clinical-trial-intake", +] as const; + +export type SeedFixtureId = (typeof SEED_FIXTURE_IDS)[number]; + +export type SeedFixtureSelector = SeedFixtureId | "all"; + +export type SeedDesignOptionMetadata = Readonly<{ + conceptId: string; + title: string; + direction: string; + path: string; +}>; + +export type SeedProjectMetadata = Readonly<{ + slug: SeedFixtureId; + id: SeedFixtureId; + title: string; + complexity: "simple" | "medium" | "complex"; + defaultStage: SeedStage; + idea: string; + problemStatement: string; + chosenConceptId: string; + chosenDesignOption: string; + documents: Readonly<{ + problem_brief: Readonly<{ title: string; path: string }>; + constraints: Readonly<{ title: string; path: string }>; + approaches: Readonly<{ + title: string; + path: string; + approaches: ApproachesMeta; + }>; + product_requirements: Readonly<{ title: string; path: string }>; + design: Readonly<{ title: string; path: string }>; + build_plan: Readonly<{ title: string; path: string }>; + }>; + designOptions: readonly [SeedDesignOptionMetadata, SeedDesignOptionMetadata]; + app: Readonly<{ path: string }>; +}>; + +export type SeedDesignOptionFixture = Readonly<{ + conceptId: string; + title: string; + direction: string; + bundle: DesignBundleType; +}>; + +export type SeedProjectFixture = Readonly<{ + id: SeedFixtureId; + slug: SeedFixtureId; + title: string; + complexity: "simple" | "medium" | "complex"; + defaultStage: SeedStage; + idea: string; + problemStatement: string; + chosenConceptId: string; + chosenDesignOption: string; + documents: Readonly<{ + problem_brief: Readonly<{ title: string; content: string }>; + constraints: Readonly<{ title: string; content: string }>; + approaches: Readonly<{ + title: string; + content: string; + approaches: ApproachesMeta; + }>; + product_requirements: Readonly<{ title: string; content: string }>; + design: Readonly<{ title: string; content: string }>; + build_plan: Readonly<{ title: string; content: string }>; + }>; + designOptions: readonly [SeedDesignOptionFixture, SeedDesignOptionFixture]; + app: AppBundle; +}>; + +export const DEFAULT_SEED_ROOT = resolve(import.meta.dir); + +async function readDirectoryFiles( + rootDir: string, + currentDir = rootDir, +): Promise> { + const entries = await readdir(currentDir, { withFileTypes: true }); + const sorted = [...entries].sort((left, right) => + left.name.localeCompare(right.name), + ); + const files: Record = {}; + for (const entry of sorted) { + const fullPath = join(currentDir, entry.name); + if (entry.isDirectory()) { + const nested = await readDirectoryFiles(rootDir, fullPath); + for (const [nestedKey, nestedValue] of Object.entries(nested)) { + files[nestedKey] = nestedValue; + } + } else if (entry.isFile()) { + const relPath = relative(rootDir, fullPath).replaceAll("\\", "/"); + files[relPath] = await readFile(fullPath, "utf8"); + } + } + return files; +} + +async function loadDesignOptionBundle( + projectDir: string, + optionMeta: SeedDesignOptionMetadata, +): Promise { + const optionDir = resolve(projectDir, optionMeta.path); + const rawFiles = await readDirectoryFiles(optionDir); + const designMd = rawFiles["DESIGN.md"]; + const stylesCss = rawFiles["styles.css"]; + const indexHtml = rawFiles["index.html"]; + if (!designMd || !stylesCss || !indexHtml) { + throw new Error( + `Design option ${optionMeta.conceptId} in ${projectDir} is missing DESIGN.md, styles.css, or index.html`, + ); + } + + const bundleFiles: Record = { + "DESIGN.md": designMd, + "designs/styles.css": stylesCss, + "designs/index.html": indexHtml, + }; + + for (const [relPath, content] of Object.entries(rawFiles)) { + if ( + relPath !== "DESIGN.md" && + relPath !== "styles.css" && + relPath !== "index.html" + ) { + bundleFiles[`designs/${relPath}`] = content; + } + } + + return { files: bundleFiles }; +} + +export async function loadSeedProjectFixture( + id: SeedFixtureId, + seedRootDir: string = DEFAULT_SEED_ROOT, +): Promise { + const projectDir = resolve(seedRootDir, id); + const metadataRaw = await readFile(join(projectDir, "project.json"), "utf8"); + const metadata = JSON.parse(metadataRaw) as SeedProjectMetadata; + + const [ + problemBriefContent, + constraintsContent, + approachesContent, + productRequirementsContent, + designContent, + buildPlanContent, + firstOptionBundle, + secondOptionBundle, + appFiles, + ] = await Promise.all([ + readFile( + resolve(projectDir, metadata.documents.problem_brief.path), + "utf8", + ), + readFile(resolve(projectDir, metadata.documents.constraints.path), "utf8"), + readFile(resolve(projectDir, metadata.documents.approaches.path), "utf8"), + readFile( + resolve(projectDir, metadata.documents.product_requirements.path), + "utf8", + ), + readFile(resolve(projectDir, metadata.documents.design.path), "utf8"), + readFile(resolve(projectDir, metadata.documents.build_plan.path), "utf8"), + loadDesignOptionBundle(projectDir, metadata.designOptions[0]), + loadDesignOptionBundle(projectDir, metadata.designOptions[1]), + readDirectoryFiles(resolve(projectDir, metadata.app.path)), + ]); + + return { + id: metadata.id, + slug: metadata.slug, + title: metadata.title, + complexity: metadata.complexity, + defaultStage: metadata.defaultStage, + idea: metadata.idea, + problemStatement: metadata.problemStatement, + chosenConceptId: metadata.chosenConceptId, + chosenDesignOption: metadata.chosenDesignOption, + documents: { + problem_brief: { + title: metadata.documents.problem_brief.title, + content: problemBriefContent, + }, + constraints: { + title: metadata.documents.constraints.title, + content: constraintsContent, + }, + approaches: { + title: metadata.documents.approaches.title, + content: approachesContent, + approaches: metadata.documents.approaches.approaches, + }, + product_requirements: { + title: metadata.documents.product_requirements.title, + content: productRequirementsContent, + }, + design: { + title: metadata.documents.design.title, + content: designContent, + }, + build_plan: { + title: metadata.documents.build_plan.title, + content: buildPlanContent, + }, + }, + designOptions: [ + { + conceptId: metadata.designOptions[0].conceptId, + title: metadata.designOptions[0].title, + direction: metadata.designOptions[0].direction, + bundle: firstOptionBundle, + }, + { + conceptId: metadata.designOptions[1].conceptId, + title: metadata.designOptions[1].title, + direction: metadata.designOptions[1].direction, + bundle: secondOptionBundle, + }, + ], + app: { + files: appFiles, + }, + }; +} + +export async function loadSeedProjectFixtures( + selector: SeedFixtureSelector = "all", + seedRootDir: string = DEFAULT_SEED_ROOT, +): Promise { + const ids: readonly SeedFixtureId[] = + selector === "all" ? SEED_FIXTURE_IDS : [selector]; + return Promise.all( + ids.map((fixtureId) => loadSeedProjectFixture(fixtureId, seedRootDir)), + ); +} + +export const SEED_PROJECT_FIXTURES: readonly SeedProjectFixture[] = + await loadSeedProjectFixtures("all"); + +export function getSeedProjectFixture(id: SeedFixtureId): SeedProjectFixture { + const fixture = SEED_PROJECT_FIXTURES.find((entry) => entry.id === id); + if (!fixture) { + throw new Error(`Unknown seed fixture: ${id}`); + } + return fixture; +} + +export function resolveSeedFixtures( + selector: SeedFixtureSelector, +): readonly SeedProjectFixture[] { + if (selector === "all") { + return SEED_PROJECT_FIXTURES; + } + return [getSeedProjectFixture(selector)]; +} diff --git a/tsconfig.json b/tsconfig.json index a11685ef..bea2864a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,7 @@ "node_modules", "vendor", "workflows/coder/starter", + "test/seed/**/app", "packages/client" ] }