From 7de28a8f77925036f444a0150dde77a1a0cc5e2e Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Thu, 11 Jun 2026 22:33:01 -0700 Subject: [PATCH] Implement meeting transcript platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next.js 16 + Prisma 7 + SQLite implementation of the take-home: - Calendar events with timezone-aware times (wall-clock + IANA zone stored as UTC instant + zone, DST-correct conversion) - Immutable raw transcripts (1:1 per event) and append-only processed transcript versions (PIPELINE | MANUAL_EDIT source) - Async processing pipeline: explicit job rows (PENDING -> PROCESSING -> COMPLETED | FAILED), atomic claims, in-process drain loop + sweeper crash recovery, user-triggered retry, deterministic [[FLAKY]] failure injection for demos - Per-meeting-type summaries via a Record registry emitting a shared block vocabulary; adding a type is one enum value + one summarizer file, enforced at compile time - Deterministic rule-based mock processor (parse -> clean -> summarize) - 63 tests: parser/cleaner edge cases, all three summarizers against the sample data, job lifecycle incl. concurrency and retry, sweeper recovery, validation, DST characterization, and HTTP route contract See DESIGN.md for the data model, pipeline design, and trade-offs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .gitignore | 9 + DESIGN.md | 46 + README.md | 16 + app/api/events/[id]/raw-transcript/route.ts | 60 + app/api/events/[id]/regenerate/route.ts | 26 + app/api/events/[id]/route.ts | 18 + app/api/events/[id]/versions/route.ts | 30 + app/api/events/route.ts | 28 + app/api/jobs/[id]/retry/route.ts | 20 + app/events/[id]/page.tsx | 28 + app/globals.css | 315 ++ app/layout.tsx | 23 + app/page.tsx | 78 + components/AttachTranscript.tsx | 82 + components/CreateEventForm.tsx | 110 + components/EventDetail.tsx | 125 + components/JobStatusCard.tsx | 73 + components/ProcessedTranscript.tsx | 133 + components/SummaryView.tsx | 59 + components/api-types.ts | 35 + components/labels.ts | 11 + instrumentation.ts | 8 + lib/api.ts | 21 + lib/db.ts | 21 + lib/events.ts | 26 + lib/jobs/runner.ts | 189 + lib/jobs/sweeper.ts | 47 + lib/serialize.ts | 67 + lib/time.ts | 49 + lib/transcript/clean.ts | 48 + lib/transcript/parse.ts | 51 + lib/transcript/pipeline.ts | 39 + lib/transcript/summarize/expert-call.ts | 49 + lib/transcript/summarize/helpers.ts | 71 + lib/transcript/summarize/index.ts | 19 + lib/transcript/summarize/roadshow.ts | 54 + lib/transcript/summarize/weekly-group-call.ts | 108 + lib/types.ts | 111 + lib/versions.ts | 62 + next.config.ts | 8 + package-lock.json | 4333 +++++++++++++++++ package.json | 32 + prisma.config.ts | 15 + .../20260612042709_init/migration.sql | 62 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 83 + prisma/seed.ts | 57 + tests/api.test.ts | 145 + tests/clean.test.ts | 50 + tests/global-setup.ts | 14 + tests/jobs.test.ts | 182 + tests/parse.test.ts | 49 + tests/pipeline.test.ts | 25 + tests/setup-env.ts | 4 + tests/summarize.test.ts | 125 + tests/sweeper.test.ts | 61 + tests/time.test.ts | 52 + tests/validation.test.ts | 37 + tsconfig.json | 41 + vitest.config.ts | 15 + 60 files changed, 7758 insertions(+) create mode 100644 .gitignore create mode 100644 DESIGN.md create mode 100644 app/api/events/[id]/raw-transcript/route.ts create mode 100644 app/api/events/[id]/regenerate/route.ts create mode 100644 app/api/events/[id]/route.ts create mode 100644 app/api/events/[id]/versions/route.ts create mode 100644 app/api/events/route.ts create mode 100644 app/api/jobs/[id]/retry/route.ts create mode 100644 app/events/[id]/page.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 components/AttachTranscript.tsx create mode 100644 components/CreateEventForm.tsx create mode 100644 components/EventDetail.tsx create mode 100644 components/JobStatusCard.tsx create mode 100644 components/ProcessedTranscript.tsx create mode 100644 components/SummaryView.tsx create mode 100644 components/api-types.ts create mode 100644 components/labels.ts create mode 100644 instrumentation.ts create mode 100644 lib/api.ts create mode 100644 lib/db.ts create mode 100644 lib/events.ts create mode 100644 lib/jobs/runner.ts create mode 100644 lib/jobs/sweeper.ts create mode 100644 lib/serialize.ts create mode 100644 lib/time.ts create mode 100644 lib/transcript/clean.ts create mode 100644 lib/transcript/parse.ts create mode 100644 lib/transcript/pipeline.ts create mode 100644 lib/transcript/summarize/expert-call.ts create mode 100644 lib/transcript/summarize/helpers.ts create mode 100644 lib/transcript/summarize/index.ts create mode 100644 lib/transcript/summarize/roadshow.ts create mode 100644 lib/transcript/summarize/weekly-group-call.ts create mode 100644 lib/types.ts create mode 100644 lib/versions.ts create mode 100644 next.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 prisma.config.ts create mode 100644 prisma/migrations/20260612042709_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed.ts create mode 100644 tests/api.test.ts create mode 100644 tests/clean.test.ts create mode 100644 tests/global-setup.ts create mode 100644 tests/jobs.test.ts create mode 100644 tests/parse.test.ts create mode 100644 tests/pipeline.test.ts create mode 100644 tests/setup-env.ts create mode 100644 tests/summarize.test.ts create mode 100644 tests/sweeper.test.ts create mode 100644 tests/time.test.ts create mode 100644 tests/validation.test.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6440049 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +.next/ +generated/ +*.db +*.db-journal +*.tsbuildinfo +next-env.d.ts +.env.local +.DS_Store diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..71a553e --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,46 @@ +# Design notes + +## Data model + +Four entities (Prisma + SQLite, `prisma/schema.prisma`): + +- **CalendarEvent** — the anchor. `title`, `meetingType`, `startTime`/`endTime` stored as UTC instants plus the IANA `timezone` they were entered in (input is wall-clock time + zone, converted server-side with a DST-correct double-offset lookup, `lib/time.ts`), `status`. +- **RawTranscript** — 1:1 with an event (unique `eventId`). Immutable by construction: no update path exists, and a second attach returns 409. +- **ProcessedTranscriptVersion** — append-only versions per event (`@@unique(eventId, version)`). Holds `segments` (ordered `{speaker, text, timestamp}`) and a structured `summary`, both JSON. `source` distinguishes `PIPELINE` from `MANUAL_EDIT`; `jobId` links a pipeline version to the job that produced it. "Current" = highest version; everything else is history. +- **TranscriptJob** — explicit pipeline state: `PENDING → PROCESSING → COMPLETED | FAILED`, with `attempts`, `error`, and timing fields. + +SQLite has no enums, so enum-like columns are strings validated with zod at every API boundary (`lib/types.ts` is the single source of truth for the unions). + +## Async pipeline + +Attaching a raw transcript creates the job and returns immediately; nothing is fire-and-forget because **every transition is a database row update**: + +- An in-process runner (`lib/jobs/runner.ts`) drains PENDING jobs. Claims are atomic conditional updates (`UPDATE ... WHERE status = 'PENDING'`), so concurrent runners execute a job at most once — covered by a test that races three drains. +- The new version + the COMPLETED transition commit in one batch transaction; failures store the error message on the job, and a per-job error can never kill the drain loop. There are deliberately **no interactive transactions**: with one SQLite connection behind the driver adapter, an open interactive transaction can swallow interleaved writes on rollback, so writers use single statements / batch transactions with the `(eventId, version)` unique constraint as the race backstop. +- A sweeper (`lib/jobs/sweeper.ts`, started once per server via `instrumentation.ts`) covers the two crash modes the per-request kick can't: PENDING jobs left behind by a restart, and PROCESSING rows orphaned by a mid-job crash (reset to PENDING after 60s). +- **Retry** is user-triggered (FAILED → PENDING, attempts preserved). For demos, a transcript containing `[[FLAKY]]` deterministically fails its first attempt and succeeds on retry; a transcript with no parseable speaker lines fails permanently with a clear error. + +Trade-off, made consciously: the queue is the database and the worker lives in the web process. For a single-user local app this gives full observability with zero infrastructure; the claim discipline means moving to a real worker (BullMQ, pg-boss, or a cron-driven runner) changes only who calls `drainJobs()`. + +## Per-meeting-type formats + +Processing is parse → clean → summarize (`lib/transcript/`). The split that keeps types cheap to add: + +- Summarizers are a `Record` registry (`lib/transcript/summarize/index.ts`). Each one *builds a different document* (Q&A pairs, roadshow sections, minutes with owners) but emits the same small block vocabulary (`paragraph | bullets | qa | actionItems`). +- Storage, API, and rendering only know that vocabulary, so they are type-agnostic. + +**Adding a fourth meeting type** = add the value to `MEETING_TYPES` (the `Record` then fails to compile until you register a summarizer — the gap can't ship silently), write one summarizer file, and map its sample/label. No schema change, no API change, no UI change. A new *block kind* (e.g. a table) would be one renderer case + the zod union. + +The processor is deterministic and rule-based (filler/stutter removal, role inference by word count, question-cue extraction, action-item/owner matching by content-word overlap). An LLM-backed processor would replace one function (`processRawTranscript`) behind the same signature. + +## Tests (63) + +The things that would actually break: parsing edge cases (near-miss speaker lines, hyphenated fillers, filler-only turns), cleaner idempotence and meaning preservation, all three summarizers against the real sample files (with count and absence assertions, not just substring presence), the full job lifecycle (success, parse failure, flaky-then-retry, atomic claims under concurrency, the one-active-job guard for enqueue *and* retry), sweeper crash recovery, version monotonicity for regenerate + manual edit, DST boundary conversion (including characterization of nonexistent/ambiguous wall-clock times), input validation (impossible calendar dates are 400s, not 500s), and the HTTP contract itself — the route handlers are called directly as functions for the attach→process→edit→retry flow and its 4xx cases. + +## What I'd build next + +1. **SSE or polling-with-ETag** instead of the 2.5s client poll for job status. +2. **Real queue + LLM processor** behind the existing interfaces (worker process, provider key via env). +3. **Segment-level diffing** between versions, and an explicit "restore version N" action. +4. **Full-text search** across processed transcripts (SQLite FTS5 would do locally). +5. Pagination, auth, and multi-tenancy — consciously skipped: single-user was in scope, and none of them change the core design. diff --git a/README.md b/README.md index 135b160..6cdefe3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,21 @@ # Funda Take-Home: Meeting Transcript Platform +## Quick start (solution) + +```bash +npm install +npm run setup # prisma generate + migrate + seed (3 demo events) +npm run dev # http://localhost:3000 +``` + +`npm test` runs the suite (63 tests). See [DESIGN.md](DESIGN.md) for the data model, pipeline, and trade-offs. + +Demo walkthrough: open a seeded event → **Load sample for this meeting type** (or paste/upload a `.txt`) → watch the job go `PENDING → PROCESSING → COMPLETED` → the processed transcript appears with the per-type summary. **Regenerate** or **Edit segments** to create new versions. To see the failure path, paste a sample with a line containing `[[FLAKY]]` added — the job fails on the first attempt and succeeds on **Retry** (a transcript with no `[HH:MM:SS] Speaker N:` lines fails permanently with a clear error). + +--- + +*Original assignment below.* + At Funda we record many kinds of investor meetings — expert calls, company roadshows, internal weekly group calls — and turn their recordings into clean, structured, searchable transcripts. This assignment asks you to build a miniature version of that system. **Timebox: aim for 4–6 focused hours.** We'd rather see a well-designed core than a feature-complete rush job. If you run out of time, write down what you'd do next in your design notes. diff --git a/app/api/events/[id]/raw-transcript/route.ts b/app/api/events/[id]/raw-transcript/route.ts new file mode 100644 index 0000000..bdd34c1 --- /dev/null +++ b/app/api/events/[id]/raw-transcript/route.ts @@ -0,0 +1,60 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { prisma } from '@/lib/db'; +import { attachRawTranscriptSchema, type MeetingType } from '@/lib/types'; +import { kickJobRunner } from '@/lib/jobs/runner'; +import { jsonError, readJsonBody, zodErrorResponse } from '@/lib/api'; +import { serializeJob } from '@/lib/serialize'; + +// Attach a raw transcript: pasted text, an uploaded .txt file's contents, or +// `{ sample: true }` to load the bundled sample for the event's meeting type. +// Attaching automatically enqueues the processing job — there is no separate +// "process" action. + +const bodySchema = z.union([z.object({ sample: z.literal(true) }), attachRawTranscriptSchema]); + +const SAMPLE_FILES: Record = { + EXPERT_CALL: 'expert-call-raw.txt', + ROADSHOW: 'roadshow-raw.txt', + WEEKLY_GROUP_CALL: 'weekly-group-call-raw.txt', +}; + +export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const parsed = bodySchema.safeParse(await readJsonBody(req)); + if (!parsed.success) return zodErrorResponse(parsed.error); + + const event = await prisma.calendarEvent.findUnique({ + where: { id }, + include: { rawTranscript: { select: { id: true } } }, + }); + if (!event) return jsonError(404, 'Event not found'); + if (event.rawTranscript) { + return jsonError(409, 'A raw transcript is already attached; raw transcripts are immutable'); + } + + let content: string; + let fileName: string | undefined; + if ('sample' in parsed.data) { + fileName = SAMPLE_FILES[event.meetingType as MeetingType]; + content = await readFile(path.join(process.cwd(), 'sample-data', fileName), 'utf8'); + } else { + content = parsed.data.text; + fileName = parsed.data.fileName; + } + + // Raw transcript + its processing job are created atomically (nested + // write): a crash between the two can't leave a transcript with no job. + const raw = await prisma.rawTranscript.create({ + data: { eventId: event.id, content, fileName, jobs: { create: { eventId: event.id } } }, + include: { jobs: true }, + }); + kickJobRunner(); + + return NextResponse.json( + { rawTranscriptId: raw.id, job: serializeJob(raw.jobs[0]) }, + { status: 201 }, + ); +} diff --git a/app/api/events/[id]/regenerate/route.ts b/app/api/events/[id]/regenerate/route.ts new file mode 100644 index 0000000..bc9a52c --- /dev/null +++ b/app/api/events/[id]/regenerate/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; +import { ActiveJobError, enqueueProcessingJob } from '@/lib/jobs/runner'; +import { jsonError } from '@/lib/api'; +import { serializeJob } from '@/lib/serialize'; + +// Re-run the pipeline against the immutable raw transcript. The result is a +// NEW processed transcript version; history is never overwritten. + +export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const event = await prisma.calendarEvent.findUnique({ + where: { id }, + include: { rawTranscript: { select: { id: true } } }, + }); + if (!event) return jsonError(404, 'Event not found'); + if (!event.rawTranscript) return jsonError(409, 'No raw transcript attached yet'); + + try { + const job = await enqueueProcessingJob(event.id, event.rawTranscript.id); + return NextResponse.json({ job: serializeJob(job) }, { status: 201 }); + } catch (err) { + if (err instanceof ActiveJobError) return jsonError(409, err.message); + throw err; + } +} diff --git a/app/api/events/[id]/route.ts b/app/api/events/[id]/route.ts new file mode 100644 index 0000000..cab1077 --- /dev/null +++ b/app/api/events/[id]/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; +import { jsonError } from '@/lib/api'; +import { serializeEventDetail } from '@/lib/serialize'; + +export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const event = await prisma.calendarEvent.findUnique({ + where: { id }, + include: { + rawTranscript: true, + jobs: { orderBy: { createdAt: 'desc' } }, + processedVersions: { orderBy: { version: 'desc' } }, + }, + }); + if (!event) return jsonError(404, 'Event not found'); + return NextResponse.json(serializeEventDetail(event)); +} diff --git a/app/api/events/[id]/versions/route.ts b/app/api/events/[id]/versions/route.ts new file mode 100644 index 0000000..cf70560 --- /dev/null +++ b/app/api/events/[id]/versions/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; +import { manualEditSchema, type MeetingType } from '@/lib/types'; +import { createManualVersion, NothingToEditError } from '@/lib/versions'; +import { jsonError, readJsonBody, zodErrorResponse } from '@/lib/api'; +import { serializeVersion } from '@/lib/serialize'; + +// Manual edit: the user submits corrected segments, which become a NEW +// version (source = MANUAL_EDIT) — history is never overwritten. + +export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const parsed = manualEditSchema.safeParse(await readJsonBody(req)); + if (!parsed.success) return zodErrorResponse(parsed.error); + + const event = await prisma.calendarEvent.findUnique({ where: { id } }); + if (!event) return jsonError(404, 'Event not found'); + + try { + const version = await createManualVersion( + id, + event.meetingType as MeetingType, + parsed.data.segments, + ); + return NextResponse.json(serializeVersion(version), { status: 201 }); + } catch (err) { + if (err instanceof NothingToEditError) return jsonError(409, err.message); + throw err; + } +} diff --git a/app/api/events/route.ts b/app/api/events/route.ts new file mode 100644 index 0000000..7688d3c --- /dev/null +++ b/app/api/events/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; +import { listEventSummaries } from '@/lib/events'; +import { createEventSchema } from '@/lib/types'; +import { zonedNaiveToUtc } from '@/lib/time'; +import { readJsonBody, zodErrorResponse } from '@/lib/api'; + +export async function GET() { + return NextResponse.json(await listEventSummaries()); +} + +export async function POST(req: Request) { + const parsed = createEventSchema.safeParse(await readJsonBody(req)); + if (!parsed.success) return zodErrorResponse(parsed.error); + + const { title, meetingType, startLocal, endLocal, timezone, status } = parsed.data; + const event = await prisma.calendarEvent.create({ + data: { + title, + meetingType, + startTime: zonedNaiveToUtc(startLocal, timezone), + endTime: zonedNaiveToUtc(endLocal, timezone), + timezone, + status, + }, + }); + return NextResponse.json(event, { status: 201 }); +} diff --git a/app/api/jobs/[id]/retry/route.ts b/app/api/jobs/[id]/retry/route.ts new file mode 100644 index 0000000..6d17172 --- /dev/null +++ b/app/api/jobs/[id]/retry/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; +import { ActiveJobError, retryJob } from '@/lib/jobs/runner'; +import { jsonError } from '@/lib/api'; + +export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + try { + const retried = await retryJob(id); + if (!retried) { + const job = await prisma.transcriptJob.findUnique({ where: { id }, select: { status: true } }); + if (!job) return jsonError(404, 'Job not found'); + return jsonError(409, `Only FAILED jobs can be retried (job is ${job.status})`); + } + return NextResponse.json({ ok: true }); + } catch (err) { + if (err instanceof ActiveJobError) return jsonError(409, err.message); + throw err; + } +} diff --git a/app/events/[id]/page.tsx b/app/events/[id]/page.tsx new file mode 100644 index 0000000..ea5bf54 --- /dev/null +++ b/app/events/[id]/page.tsx @@ -0,0 +1,28 @@ +import { notFound } from 'next/navigation'; +import { prisma } from '@/lib/db'; +import { serializeEventDetail } from '@/lib/serialize'; +import { EventDetail } from '@/components/EventDetail'; +import type { EventDetailView } from '@/components/api-types'; + +export const dynamic = 'force-dynamic'; + +export default async function EventPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const event = await prisma.calendarEvent.findUnique({ + where: { id }, + include: { + rawTranscript: true, + jobs: { orderBy: { createdAt: 'desc' } }, + processedVersions: { orderBy: { version: 'desc' } }, + }, + }); + if (!event) notFound(); + + // Round-trip through JSON so the client component receives exactly the + // wire shape its polling fetches will produce (dates as ISO strings). + const initialData = JSON.parse( + JSON.stringify(serializeEventDetail(event)), + ) as EventDetailView; + + return ; +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..2dc346b --- /dev/null +++ b/app/globals.css @@ -0,0 +1,315 @@ +:root { + --border: #d9dce3; + --muted: #5d6470; + --bg: #f6f7f9; + --card: #ffffff; + --accent: #1d4ed8; + --danger: #b91c1c; + --ok: #15803d; + --warn: #b45309; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + background: var(--bg); + color: #15181e; + line-height: 1.5; +} + +.site-header { + background: #101524; + padding: 0.75rem 1.5rem; +} + +.site-title { + color: #fff; + font-weight: 700; + text-decoration: none; + font-size: 1.05rem; +} + +.container { + max-width: 920px; + margin: 0 auto; + padding: 1.5rem 1rem 4rem; +} + +h1 { + font-size: 1.4rem; + margin: 0.5rem 0 1rem; +} + +h2 { + font-size: 1.05rem; + margin: 0 0 0.75rem; +} + +h3 { + font-size: 0.95rem; + margin: 1rem 0 0.4rem; +} + +a { + color: var(--accent); +} + +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem 1.25rem; + margin-bottom: 1.25rem; +} + +.muted { + color: var(--muted); + font-size: 0.85rem; +} + +/* ---- tables ---- */ + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +th, +td { + text-align: left; + padding: 0.5rem 0.6rem; + border-bottom: 1px solid var(--border); + vertical-align: top; +} + +th { + color: var(--muted); + font-weight: 600; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +/* ---- badges ---- */ + +.badge { + display: inline-block; + padding: 0.1rem 0.5rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 600; + border: 1px solid var(--border); + background: #eef0f4; + color: #333; + white-space: nowrap; +} + +.badge.type { + background: #e8edfb; + color: #1e3a8a; + border-color: #c7d4f4; +} + +.badge.PENDING { + background: #fef3c7; + color: var(--warn); + border-color: #fde68a; +} + +.badge.PROCESSING { + background: #dbeafe; + color: #1d4ed8; + border-color: #bfdbfe; +} + +.badge.COMPLETED, +.badge.SCHEDULED { + background: #dcfce7; + color: var(--ok); + border-color: #bbf7d0; +} + +.badge.FAILED, +.badge.CANCELLED { + background: #fee2e2; + color: var(--danger); + border-color: #fecaca; +} + +/* ---- forms ---- */ + +form .row { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 0.75rem; +} + +label.field { + display: flex; + flex-direction: column; + gap: 0.2rem; + font-size: 0.8rem; + font-weight: 600; + color: var(--muted); + flex: 1; + min-width: 160px; +} + +input, +select, +textarea { + font: inherit; + padding: 0.45rem 0.55rem; + border: 1px solid var(--border); + border-radius: 6px; + background: #fff; +} + +textarea { + width: 100%; + resize: vertical; +} + +button { + font: inherit; + font-weight: 600; + padding: 0.45rem 0.9rem; + border-radius: 6px; + border: 1px solid var(--accent); + background: var(--accent); + color: #fff; + cursor: pointer; +} + +button.secondary { + background: #fff; + color: var(--accent); +} + +button.danger { + background: #fff; + color: var(--danger); + border-color: var(--danger); +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.error-box { + background: #fee2e2; + border: 1px solid #fecaca; + color: var(--danger); + border-radius: 6px; + padding: 0.5rem 0.75rem; + margin: 0.5rem 0; + font-size: 0.85rem; + white-space: pre-wrap; +} + +/* ---- transcripts ---- */ + +pre.raw-transcript { + background: #f8f9fb; + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.75rem; + max-height: 320px; + overflow: auto; + font-size: 0.78rem; + white-space: pre-wrap; +} + +.segment { + margin-bottom: 0.6rem; +} + +.segment .speaker { + font-weight: 700; + margin-right: 0.4rem; +} + +.segment .timestamp { + color: var(--muted); + font-size: 0.75rem; + margin-right: 0.4rem; + font-variant-numeric: tabular-nums; +} + +.qa-pair { + margin-bottom: 0.75rem; + padding-left: 0.75rem; + border-left: 3px solid #c7d4f4; +} + +.qa-pair .q { + font-weight: 600; +} + +.owner-chip { + display: inline-block; + background: #eef0f4; + border-radius: 999px; + padding: 0 0.5rem; + font-size: 0.72rem; + font-weight: 600; + margin-left: 0.4rem; + color: #444; +} + +.version-bar { + display: flex; + gap: 0.4rem; + align-items: center; + flex-wrap: wrap; + margin-bottom: 0.75rem; +} + +.version-bar button.version { + padding: 0.15rem 0.6rem; + font-size: 0.78rem; + background: #fff; + color: var(--accent); +} + +.version-bar button.version.active { + background: var(--accent); + color: #fff; +} + +.actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-top: 0.5rem; +} + +.detail-header { + display: flex; + gap: 0.5rem; + align-items: baseline; + flex-wrap: wrap; +} + +.segment-edit { + display: flex; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.segment-edit input { + width: 120px; + flex-shrink: 0; +} + +.segment-edit textarea { + flex: 1; + min-height: 60px; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..038eadf --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import './globals.css'; + +export const metadata: Metadata = { + title: 'Funda Transcripts', + description: 'Meeting transcript platform take-home', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + +
+ + Funda Transcripts + +
+
{children}
+ + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..9a8f645 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,78 @@ +import Link from 'next/link'; +import { listEventSummaries } from '@/lib/events'; +import { formatInZone } from '@/lib/time'; +import { meetingTypeLabel } from '@/components/labels'; +import { CreateEventForm } from '@/components/CreateEventForm'; + +export const dynamic = 'force-dynamic'; + +export default async function EventsPage() { + const events = await listEventSummaries(); + + return ( + <> +

Calendar events

+ +
+

New event

+ +
+ +
+

+ Events ({events.length}) +

+ {events.length === 0 ? ( +

No events yet — create one above.

+ ) : ( + + + + + + + + + + + + {events.map((e) => ( + + + + + + + + ))} + +
TitleTypeWhenStatusTranscript
+ {e.title} + + {meetingTypeLabel(e.meetingType)} + + {formatInZone(e.startTime, e.timezone)} +
+ → {formatInZone(e.endTime, e.timezone)} ({e.timezone}) +
+
+ {e.status} + + {e.hasRawTranscript ? ( + <> + {e.latestJobStatus && ( + {e.latestJobStatus} + )}{' '} + + {e.versionCount} version{e.versionCount === 1 ? '' : 's'} + + + ) : ( + none + )} +
+ )} +
+ + ); +} diff --git a/components/AttachTranscript.tsx b/components/AttachTranscript.tsx new file mode 100644 index 0000000..43c77a6 --- /dev/null +++ b/components/AttachTranscript.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useState } from 'react'; +import { postJson } from './api-types'; + +export function AttachTranscript({ + eventId, + onAttached, +}: { + eventId: string; + onAttached: () => void; +}) { + const [text, setText] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function attach(body: { text: string; fileName?: string } | { sample: true }) { + setBusy(true); + setError(null); + try { + await postJson(`/api/events/${eventId}/raw-transcript`, body); + onAttached(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + async function onFileChosen(e: React.ChangeEvent) { + const input = e.currentTarget; + const file = input.files?.[0]; + if (!file) return; + try { + const content = await file.text(); + await attach({ text: content, fileName: file.name }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + // Allow re-selecting the same file after a failed attach. + input.value = ''; + } + } + + return ( +
+

+ Paste the raw speech-recognition output, upload a .txt file, or load the bundled sample. + Attaching starts processing automatically. +

+