diff --git a/.claude/skills/api-route.md b/.claude/skills/api-route.md new file mode 100644 index 0000000..d082944 --- /dev/null +++ b/.claude/skills/api-route.md @@ -0,0 +1,90 @@ +# Skill: add a Next.js Route Handler + +Use when adding or modifying a `dashboard/app/api/**/route.ts` endpoint. + +## Location + +- App Router: `dashboard/app/api//route.ts` +- For versioned APIs: `dashboard/app/api/v1//route.ts` +- Subresource: `dashboard/app/api/v1//[id]/route.ts` + +## Structure (every handler) + +1. Parse the request — `await req.json()`, query params via `req.nextUrl.searchParams`, route params via the second arg. +2. Validate with a Zod schema. Schemas live in `dashboard/server/schemas/.ts` — never inline in the route handler. **Derive from the generated schemas at `@/generated/zod/schemas/objects/UncheckedCreateInput.schema`** (or similar) using `.omit()` / `.extend()` / `.pick()`. The `Unchecked*` variants expose foreign keys as scalars (e.g., `sessionId: string`), matching what API clients send. +3. Delegate to a `dashboard/server/.ts` module — never inline business logic. +4. Return typed JSON via `NextResponse.json(...)`. + +## Example: POST /api/v1/events + +`dashboard/server/schemas/events.ts`: +```ts +import { z } from "zod"; +import { EventUncheckedCreateInputObjectZodSchema } from "@/generated/zod/schemas/objects/EventUncheckedCreateInput.schema"; + +export const CreateEventInput = EventUncheckedCreateInputObjectZodSchema + .omit({ id: true, createdAt: true }) + .extend({ + sessionId: z.string().regex(/^c[a-z0-9]{24}$/, "must be a CUID"), + url: z.url(), + title: z.string().min(1), + }); + +export type CreateEventInput = z.infer; +``` + +Notes on the derived pattern: +- **Base from generated:** all fields and types come from Prisma — no manual duplication. +- **`.omit()` server-managed fields:** clients don't set `id` or `createdAt`. +- **`.extend()` for stricter validation:** the generated schema just says `z.string()` for FKs and URLs; we tighten with a CUID regex, `z.url()`, and `z.string().min(1)`. +- **CUID regex** not `z.cuid()`: Zod 4 deprecated `z.cuid()`. Our Prisma uses cuid v1 (`@default(cuid())`), validated by `/^c[a-z0-9]{24}$/`. + +`dashboard/app/api/v1/events/route.ts`: +```ts +import { NextRequest, NextResponse } from "next/server"; +import { createEvent } from "@/server/events"; +import { CreateEventInput } from "@/server/schemas/events"; + +export async function POST(req: NextRequest) { + const body = await req.json(); + const parsed = CreateEventInput.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.format() }, { status: 400 }); + } + + const event = await createEvent(parsed.data); + return NextResponse.json(event, { status: 201 }); +} +``` + +## HTTP semantics + +- `200` OK — successful GET / PATCH / DELETE-with-body +- `201` Created — successful POST that created a resource +- `204` No Content — DELETE / accepted POST with no response body +- `400` Bad Request — Zod validation error +- `401` Unauthorized — missing/invalid JWT +- `403` Forbidden — authenticated but not allowed +- `404` Not Found — resource missing + +## CORS for the Chrome Extension + +Endpoints called from `chrome-extension://*` need CORS headers. Use the shared helpers in `@/server/cors`: + +```ts +import { withCors, corsPreflight } from "@/server/cors"; + +export const POST = withCors(async (req) => { /* ... */ }); +export function OPTIONS(req: NextRequest) { return corsPreflight(req); } +``` + +`withCors` echoes the request's `Origin` back in `Access-Control-Allow-Origin` if it matches the Chrome extension format (`^chrome-extension://[a-p]{32}$`); otherwise it sends `null` (browsers block reading the response; Postman/curl ignore CORS and still see the body). + +Always pair non-GET handlers with an `OPTIONS` handler for the CORS preflight. + +## Anti-patterns + +- ❌ Doing DB queries directly in the handler — delegate to `server/`. +- ❌ Swallowing errors with `try/catch` that returns `200` — let real errors bubble or map them to typed responses. +- ❌ `req.json()` without `safeParse` — always validate. +- ❌ Adding business logic to the handler to "make it easier" — refactor the server module instead. diff --git a/.claude/skills/extension-message.md b/.claude/skills/extension-message.md new file mode 100644 index 0000000..059d166 --- /dev/null +++ b/.claude/skills/extension-message.md @@ -0,0 +1,85 @@ +# Skill: messaging inside the Chrome Extension + +The extension has 3 isolated execution contexts that communicate via Chrome message APIs. + +## Topology + +``` ++----------+ +-------------------+ +-----------+ +| popup | <--> | background (SW) | <--> | content | ++----------+ +-------------------+ +-----------+ + | + v + +---------------+ + | dashboard API | + +---------------+ +``` + +## Rules + +- **All `fetch` calls to the dashboard API live in `background.ts`.** + Never `fetch` from `content.ts` or `popup.ts` — they don't hold the JWT and CORS blocks them anyway. +- **JWT is stored in `chrome.storage.local`, read only by the service worker.** + Content scripts and popup never touch the token directly. +- **content.ts** parses the current page (DOM, metadata, tracks) and sends events to background. +- **popup.ts** controls UI; it queries background for state and triggers actions. + +## Typed message envelope + +Define a single discriminated union and a matching Zod schema in `extension/shared/messages.ts`: + +```ts +import { z } from "zod"; + +export const ExtensionMessage = z.discriminatedUnion("type", [ + z.object({ type: z.literal("PAGE_PARSED"), data: ParsedPage }), + z.object({ type: z.literal("TRACK_CAPTURED"), data: TrackInfo }), + z.object({ type: z.literal("GET_SESSION_STATE") }), + z.object({ type: z.literal("START_SESSION") }), + z.object({ type: z.literal("STOP_SESSION") }), + z.object({ type: z.literal("SYNC_NOW") }), +]); +export type ExtensionMessage = z.infer; +``` + +Always validate incoming messages — content scripts from other extensions can post to your listener: + +```ts +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + const parsed = ExtensionMessage.safeParse(msg); + if (!parsed.success) return; + handleMessage(parsed.data).then(sendResponse); + return true; // keep channel open for async response +}); +``` + +## Send direction + +- popup → background: `chrome.runtime.sendMessage(msg)` +- background → content (specific tab): `chrome.tabs.sendMessage(tabId, msg)` +- content → background: `chrome.runtime.sendMessage(msg)` +- background → popup: response to the popup's `sendMessage` promise + +## Service worker lifecycle + +Chrome may unload a Manifest V3 service worker at any time when idle. + +- Persist all state in `chrome.storage.local` — never in module-level variables. +- For periodic work (sync polling, session timer), use `chrome.alarms`, not `setInterval` (it dies on unload). +- On every event the worker handles, re-read state from storage; don't trust cached in-memory state. + +## Network failures + +Events from content scripts must not be lost when the network drops: + +1. Try to send the event/batch via `fetch`. +2. On failure, queue it in `chrome.storage.local` under a `pendingEvents` key. +3. Re-try on `online` event with exponential backoff (1s, 2s, 4s, …, cap at 60s). +4. After N failed attempts, surface a sync-error indicator to the popup. + +## Anti-patterns + +- ❌ `fetch` directly from `content.ts` — JWT not available; CORS blocks the request anyway. +- ❌ Storing JWT in any tab's `localStorage` — per-origin and gets cleared. +- ❌ `setInterval` in service worker — unreliable in MV3. +- ❌ Importing TypeScript types from `dashboard/` — breaks isolation, complicates bundling. diff --git a/.claude/skills/prisma-model.md b/.claude/skills/prisma-model.md new file mode 100644 index 0000000..ad1c2e5 --- /dev/null +++ b/.claude/skills/prisma-model.md @@ -0,0 +1,94 @@ +# Skill: add or change a Prisma model + +Use when editing `dashboard/prisma/schema.prisma`. + +## Steps + +1. Edit `dashboard/prisma/schema.prisma` — add/modify model, field, or relation. +2. Add `@@index([fk_field])` on every foreign-key column. + Postgres does NOT auto-index FKs (unlike MySQL); without this, joins do full table scans. +3. Add `@unique` (single column) or `@@unique([a, b])` (compound) for natural keys. +4. For owned children (e.g., `Session` owned by `User`), set `onDelete: Cascade`: + ```prisma + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + ``` +5. Ensure local Postgres is running: `docker compose up -d` (from repo root). +6. Generate the migration AND apply it locally: + ``` + cd dashboard + npx prisma migrate dev --name + ``` +7. Inspect `dashboard/prisma/migrations/_/migration.sql` — make sure the SQL matches what you expected. +8. Commit BOTH the schema change AND the migration files. + +## Conventions + +- IDs: `String @id @default(cuid())` +- Server-set timestamps: `createdAt DateTime @default(now())`, `updatedAt DateTime @updatedAt` +- Client-set timestamps (e.g., when an event happened in the browser): explicit, no default +- Optional fields: `?` suffix — `content String?` +- Arrays of primitives: `tags String[]` (Postgres native array) + +## Generated artifacts + +Two generators run on every `prisma generate`: + +- `dashboard/generated/prisma/` — Prisma client (entry: `client.ts`) +- `dashboard/generated/zod/` — Zod schemas via `prisma-zod-generator` (one schema file per Prisma input/output type, under `schemas/objects/`) + +Both folders are gitignored. + +## Imports + +```ts +// Prisma client (DB access) +import { PrismaClient } from "@/generated/prisma/client"; + +// Zod base schemas (for derived API schemas in server/schemas/) +import { EventUncheckedCreateInputObjectZodSchema } from "@/generated/zod/schemas/objects/EventUncheckedCreateInput.schema"; +``` + +NOT `@prisma/client` — that path is for Prisma 6 and older. Prisma 7 outputs to the custom folder defined by `generator client { output = "..." }` in `schema.prisma`. + +## When schema changes + +Running `npx prisma migrate dev --name ` regenerates BOTH the Prisma client and the Zod schemas. Any `server/schemas/.ts` derived schemas will automatically pick up the new fields via `.omit()` / `.extend()` chains — no manual updates needed unless field semantics changed. + +## Singleton in dev + +Next.js dev hot-reload would create a new `PrismaClient` on every reload, exhausting connections. Use a singleton in `dashboard/server/db.ts`. + +Prisma 7's new `prisma-client` generator requires a driver adapter at runtime (no built-in engine). Use `@prisma/adapter-pg` for local Postgres / Neon direct connection. For Neon production with pooled connection, swap to `@prisma/adapter-neon` (in a future deploy PR). + +```ts +import { PrismaPg } from "@prisma/adapter-pg"; +import { PrismaClient } from "@/generated/prisma/client"; + +const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; + +function createPrismaClient() { + const connectionString = process.env.DATABASE_URL; + if (!connectionString) { + throw new Error("DATABASE_URL is not set"); + } + const adapter = new PrismaPg({ connectionString }); + return new PrismaClient({ adapter, log: ["warn", "error"] }); +} + +export const prisma = globalForPrisma.prisma ?? createPrismaClient(); + +if (process.env.NODE_ENV !== "production") { + globalForPrisma.prisma = prisma; +} +``` + +## Production migrations + +On Vercel deploy, run `npx prisma migrate deploy` (not `migrate dev`) — it applies pending migrations without prompting and never alters the schema. + +## Anti-patterns + +- ❌ Editing schema without running `migrate dev` — DB drifts from code. +- ❌ Editing existing migration SQL files — generate a new migration instead. +- ❌ Using `db push` in production — it skips migration history. +- ❌ Importing `@prisma/client` — wrong path for Prisma 7. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e8f7a24 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +# Normalize line endings — LF in repo, LF in working tree on all platforms. +* text=auto eol=lf + +# Binary file types — never touch line endings. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.webp binary +*.zip binary +*.pdf binary +*.woff binary +*.woff2 binary diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..05368cf --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,44 @@ +## What + + + +## Changes + +### Code + + +- + +### Tooling / config + + +- + +### Docs / plan + + +- `plans/weekN/.md` — design rationale +- + +## Design decisions + + +- + +## Verification + + +- [ ] `npx tsc --noEmit` (dashboard) — clean +- [ ] `npm run lint` (dashboard) — clean +- [ ] `npm run typecheck` (extension) — clean +- [ ] Manual test: + +## Out of scope (future PRs) + + +- + +## Closes + + +- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..351dfb9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + pull_request: + branches: [development, main] + push: + branches: [development, main] + +jobs: + dashboard: + runs-on: ubuntu-latest + defaults: + run: + working-directory: dashboard + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: dashboard/package-lock.json + - run: npm ci + - run: npx prisma generate + env: + # prisma.config.ts uses prisma/config's strict env() helper, which + # throws if DATABASE_URL is unset. `prisma generate` does not connect + # to the database — it only reads the schema and writes the client — + # so a dummy URL satisfies the config loader. + DATABASE_URL: "postgresql://prisma:prisma@localhost:5432/prisma" + - run: npm run lint + - run: npx tsc --noEmit + + extension: + runs-on: ubuntu-latest + defaults: + run: + working-directory: extension + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: extension/package-lock.json + - run: npm ci + - run: npm run typecheck diff --git a/.github/workflows/close-stale.yml b/.github/workflows/close-stale.yml new file mode 100644 index 0000000..37dfd32 --- /dev/null +++ b/.github/workflows/close-stale.yml @@ -0,0 +1,17 @@ +name: Close stale sessions + +on: + schedule: + - cron: "*/15 * * * *" + workflow_dispatch: + +jobs: + close-stale: + runs-on: ubuntu-latest + steps: + - name: Call cron endpoint + run: | + curl --fail --silent --show-error \ + -X GET \ + -H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \ + "${{ secrets.VERCEL_APP_URL }}/api/cron/close-stale" diff --git a/.github/workflows/release-extension.yml b/.github/workflows/release-extension.yml new file mode 100644 index 0000000..fd86a1f --- /dev/null +++ b/.github/workflows/release-extension.yml @@ -0,0 +1,42 @@ +name: Release extension + +on: + push: + branches: [main] + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: extension/package-lock.json + + - name: Build extension + working-directory: extension + run: npm ci && npm run build + + - name: Package extension + working-directory: extension + shell: pwsh + run: Compress-Archive -Path dist/* -DestinationPath worktrace-extension.zip -Force + + - name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "extension-build-${{ github.run_number }}" \ + extension/worktrace-extension.zip \ + --title "WorkTrace Extension — build ${{ github.run_number }}" \ + --notes "Автоматичний реліз при мерджі в main. + + Щоб завантажити розширення: + 1. Скачай \`worktrace-extension.zip\` + 2. Розпакуй у будь-яку папку + 3. chrome://extensions → Developer mode → Load unpacked → обери розпаковану папку" diff --git a/.gitignore b/.gitignore index 045e03a..a1ac299 100644 --- a/.gitignore +++ b/.gitignore @@ -19,9 +19,8 @@ extension/dist/ extension/build/ *.zip -# Prisma generated client -**/app/generated/ -**/generated/prisma/ +# Prisma generated artifacts (client + zod schemas) +**/generated/ # Environment .env diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..2312dc5 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..89e596a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,75 @@ +# WorkTrace — Claude Code rules + +Chrome extension + Next.js dashboard for capturing dev session context +and generating AI session reports. + +## ⚡ Local dev checklist — verify before starting work + +1. **Docker Desktop is running** — open it from the Start menu or tray. +2. **PostgreSQL container is up** — `docker-compose up -d` from repo root. + - Verify: `docker ps` → should show `worktrace-postgres` with status `Up`. + - If Prisma throws `upsert / query` errors → DB is down, start Docker first. +3. **Dashboard dev server** — `npm run dev` inside `/dashboard` (port 3000). +4. **Extension points to localhost** — `extension/.env` has `VITE_DASHBOARD_URL=http://localhost:3000`. + - After any `.env` change → rebuild: `npm run build` inside `/extension`. + +> **Symptom guide** +> - `ERR_CONNECTION_REFUSED` on fetch → dashboard not running or wrong URL +> - `Invalid prisma.X() invocation` / `Can't reach database` → Docker / PostgreSQL not running +> - Extension shows `⚠ JWT exchange failed` → dashboard not reachable (check steps 2–3 above) + +## Repo layout +- `/dashboard` — Next.js 16 (App Router) web app +- `/extension` — Chrome Extension (Manifest V3) — coming in AC 4 +- `/docs` — task spec +- Database: PostgreSQL via Prisma 7 + +## Type safety +- TypeScript strict. **`any` is forbidden** — use `unknown` + type guards, or define proper types. + +## Dashboard structure +- **Business logic lives in `dashboard/server/`** (server-only modules). +- Route Handlers in `dashboard/app/api/**/route.ts` are thin: parse → validate (Zod) → call `server/` → respond. +- React components NEVER contain business logic or direct DB access. +- Server Components MAY call `server/` modules directly (no fetch round-trip). + +## Auth +Auth logic lives ONLY in: +- `dashboard/app/api/auth/**/route.ts` — Google token verification, JWT issuance +- `dashboard/middleware.ts` — route guarding for `/dashboard/*` + +Do NOT duplicate auth checks in components, server modules, or other Route Handlers. + +## Extension ↔ Dashboard isolation +- NEVER import from `dashboard/` inside `extension/` (or vice versa). +- All extension API calls go through `background.ts` (service worker) — never directly from `content.ts` or `popup.ts`. +- JWT lives in `chrome.storage.local`, read/written only by service worker. + +## Database (Prisma 7) +- Schema: `dashboard/prisma/schema.prisma`. +- Two generators run on `prisma generate`: + - `dashboard/generated/prisma/` — Prisma client (entry: `client.ts`) + - `dashboard/generated/zod/` — Zod schemas (`prisma-zod-generator`) +- Both folders gitignored. +- Imports: + - `import { PrismaClient } from "@/generated/prisma/client"` — **not** `@prisma/client` (Prisma 7 uses `client.ts` entry). + - Base Zod schemas: `import { EventUncheckedCreateInputObjectZodSchema } from "@/generated/zod/schemas/objects/EventUncheckedCreateInput.schema"`. +- Singleton `PrismaClient` per process — see `dashboard/server/db.ts`. Uses `@prisma/adapter-pg` (Prisma 7 requires a driver adapter at runtime). +- Migrations are committed to git. + +## Validation +- Zod for all external input (Route Handlers, extension messages, env vars). +- API-facing schemas live in `dashboard/server/schemas/.ts` — derived from the generated `@/generated/zod` base schemas via `.omit()` / `.extend()` / `.pick()`. **Never hand-write a schema that duplicates Prisma model fields.** +- Validate at the boundary, not deep inside business logic. + +## Skills +Task-specific recipes live in `.claude/skills/`: +- `api-route.md` — adding a Next.js Route Handler +- `prisma-model.md` — adding/changing a Prisma model +- `extension-message.md` — messaging between extension contexts + +## Workflow +Use `Plan → Review → Implement` for non-trivial work: +1. Write a plan markdown to `/plans/.md` describing scope and decisions +2. User reviews and approves +3. Implement following the approved plan diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7fd89bd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,94 @@ +# Contributing + +Short reference for working in this repo. Full context, weekly roadmap, and acceptance criteria live in [docs/Task.md](docs/Task.md). + +## Workflow: Plan → Review → Implement + +Never start a non-trivial change by writing code. The loop is: + +1. **Plan** — write a markdown file at `plans/weekN/.md` describing scope, files touched, design decisions, and a verification checklist. The plan goes on the feature branch as its first commit (or alongside the implementation commit when small). +2. **Review** — the user reads the plan and approves (or asks for adjustments) before any code is written. +3. **Implement** — only after approval. The implementation should follow the plan; if reality diverges, update the plan in the same PR. + +See [docs/Task.md § "Головне правило воркфлоу"](docs/Task.md#L180) for the rationale. + +## Branches + +Two protected branches: + +- `main` — production. Direct pushes blocked. Only merges from `development` after final review. +- `development` — integration branch. Direct pushes blocked. All feature work merges here via PR. + +Each task gets its own branch off `development`: + +``` +feature/ new functionality +fix/ bug fix +chore/ deps, config, repo meta — no logic changes +refactor/ behavior-preserving refactor +docs/ docs-only changes +``` + +PR → `development`. When a week (or milestone) is done: `development` → PR → `main`. + +See [docs/Task.md § "Git воркфлоу"](docs/Task.md#L104) for full naming conventions and examples. + +## Commit messages + +[Conventional Commits](https://www.conventionalcommits.org/). Format: + +``` +(): +``` + +Types: `feat`, `fix`, `chore`, `refactor`, `style`, `test`, `docs`. + +- Description in English, lowercase, no trailing period. +- First line ≤ 72 characters. +- Body (after a blank line) only if extra context is needed. + +Examples: + +``` +feat(extension): add content script for page metadata parsing +fix(popup): reset timer on service worker restart +docs(readme): add full root readme (AC 5) +chore(deps): update prisma to 7.9 +``` + +Full guidance: [docs/Task.md § "Commit messages"](docs/Task.md#L144). + +## Branch protection (one-time GitHub UI setup — repo owner) + +Settings → Branches → add rule for each of `main` and `development`: + +- [x] Require a pull request before merging +- [x] Require status checks to pass before merging + - Required checks (once CI lands): `CI / dashboard`, `CI / extension` +- [x] Do not allow bypassing the above settings + +This step is GitHub UI only — it isn't expressible in a commit. Enable after the CI workflow has run at least once on `development` so the status check names become selectable. + +## Where things live + +Architectural invariants enforced in [CLAUDE.md](CLAUDE.md). Quick map: + +| Concern | Location | +|---|---| +| Business logic (dashboard) | `dashboard/server/` — never inside React components or Route Handlers | +| Route Handlers | `dashboard/app/api/**/route.ts` — thin: parse → validate (Zod) → call `server/` → respond | +| Auth (Google token, JWT) | `dashboard/app/api/auth/**/route.ts` + `dashboard/middleware.ts` — nowhere else | +| Zod schemas for API input | `dashboard/server/schemas/.ts` — derived from `@/generated/zod/...`, never hand-written from scratch | +| Prisma schema + migrations | `dashboard/prisma/` | +| Extension service worker | `extension/src/background/` — only place that talks to the API or `chrome.storage.local` | +| Extension content/popup | `extension/src/content/`, `extension/src/popup/` — message the background, never `fetch` directly | +| Task recipes (for the AI) | `.claude/skills/` | +| Plans (per-AC) | `plans/weekN/` | + +## Type safety + +TypeScript strict. `any` is forbidden — use `unknown` + type guards, or define proper types. Enforced by `tsc --noEmit` in CI. + +## `extension/` ↔ `dashboard/` isolation + +Never import from `dashboard/` inside `extension/` or vice versa. They communicate only via HTTP (the API routes). Separate `package.json`, `tsconfig.json`, and `node_modules` per app. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ebfcc52 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 BODMAT + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 6fea290..2eb7313 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,130 @@ # WorkTrace -Browser extension + web dashboard that captures developer -session context and generates AI-powered reports. +Browser extension + web dashboard that captures developer session context and generates AI-powered session reports. -See [docs/Task.md](docs/Task.md) for full scope and acceptance criteria. +## What it is + +A Chrome extension (Manifest V3) silently records the pages a developer opens during a coding session — URLs, titles, page metadata, tags, optionally the music they listen to — and ships them to a Next.js dashboard. The dashboard renders a per-session timeline and asks an LLM to summarize each session into a structured Markdown report. + +See [docs/Task.md](docs/Task.md) for the full specification, acceptance criteria, and four-week roadmap. + +## Repo layout + +``` +worktrace/ +├─ dashboard/ Next.js 16 web app (App Router) + API routes + Prisma +├─ extension/ Chrome Manifest V3 extension (Vite + @crxjs/vite-plugin) +├─ docs/ Project specification (Task.md) +├─ plans/ Per-AC implementation plans (Plan → Review → Implement) +├─ .claude/skills/ Task recipes for the AI assistant +├─ CLAUDE.md AI agent rules (architecture invariants) +└─ docker-compose.yml Local PostgreSQL for development +``` + +The two apps are isolated — `extension/` never imports from `dashboard/` and vice versa. They communicate over HTTP only. + +## Tech stack + +- **Dashboard** — Next.js 16 (App Router, Server Components), React 19, Tailwind CSS v4, TanStack Query v5, Framer Motion, D3.js +- **Backend** — Next.js Route Handlers, Prisma 7 (with `@prisma/adapter-pg`), Zod, `google-auth-library`, `jose` +- **Extension** — Manifest V3, Vite 7 + `@crxjs/vite-plugin`, TypeScript (strict) +- **Database** — PostgreSQL 16 (Docker for local, Neon for production) +- **AI** — Groq (`llama-3.3-70b-versatile`), з можливістю вказати власний API ключ у налаштуваннях +- **Deploy** — Vercel (dashboard), `.zip` для локального завантаження extension + +Versions in `dashboard/package.json` and `extension/package.json` are authoritative. + +## Prerequisites + +- **Node.js** 20 or newer +- **Docker Desktop** (or Docker Engine + Compose plugin) +- **Chrome** 120+ (for loading the unpacked extension) + +## Setup + +### 1. Clone and configure env + +```bash +git clone https://github.com/BODMAT/worktrace.git +cd worktrace +cp dashboard/.env.example dashboard/.env +cp extension/.env.example extension/.env +``` + +The dashboard `.env` contains `DATABASE_URL` (already pointing at the docker-compose Postgres) plus `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `JWT_SECRET`, `GROQ_API_KEY`, and `CRON_SECRET`. See comments in `.env.example` for how to generate each value. + +### 2. Start PostgreSQL + +```bash +docker-compose up -d +``` + +This boots a `worktrace-postgres` container on host port **5433** (mapped to container 5432, so it won't collide with a local Postgres on 5432). The `DATABASE_URL` in `dashboard/.env.example` already uses this port. + +### 3. Run the dashboard + +```bash +cd dashboard +npm install +npx prisma migrate dev +npm run dev +``` + +The dashboard is now at `http://localhost:3000`. The `npm run dev` step also implicitly runs `prisma generate` on first build, materializing the Prisma client and Zod schemas into `dashboard/generated/` (both gitignored). + +### 4. Build and load the extension + +```bash +cd extension +npm install +npm run build +``` + +Then in Chrome: + +1. Open `chrome://extensions` +2. Toggle **Developer mode** (top right) +3. Click **Load unpacked** +4. Select `extension/dist/` + +The extension card should appear with no errors. For HMR-rebuild during active development use `npm run dev` instead of `npm run build`. + +### Load from `.zip` (without source code) + +```bash +cd extension +npm run zip # builds and creates worktrace-extension.zip +``` + +Then in Chrome: + +1. Unzip `worktrace-extension.zip` to any folder +2. Open `chrome://extensions` → **Developer mode** → **Load unpacked** +3. Select the unzipped folder + +## Verifying it works + +1. **Dashboard** — відкрий `http://localhost:3000`, натисни "Sign in with Google" → має відкритись `/dashboard` з даними. +2. **Extension** — картка на `chrome://extensions` без помилок; іконка в тулбарі відкриває popup; сервіс воркер логує `[worktrace] installed` (видно через **Inspect** посилання розширення). +3. **API** — Bearer токен з extension працює: + + ```bash + curl -X POST http://localhost:3000/api/v1/events \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{"sessionId":"","url":"https://github.com","title":"Test","timestamp":"2026-06-01T10:00:00Z"}' + ``` + + Відповідь `201` означає що dashboard, Prisma і Postgres з'єднані коректно. + +## Project docs + +- [docs/Task.md](docs/Task.md) — full acceptance criteria, weekly roadmap, git workflow +- [CLAUDE.md](CLAUDE.md) — AI agent rules: architecture invariants, type-safety constraints, import boundaries +- [.claude/skills/](.claude/skills/) — task recipes (`api-route.md`, `prisma-model.md`, `extension-message.md`) +- [plans/](plans/) — per-AC implementation plans authored before each commit (the Plan → Review → Implement loop) + +## Deploy + +- **Dashboard** — Vercel with **Root Directory: `dashboard`**. The build command `prisma generate && next build` (declared in `dashboard/package.json`) regenerates the Prisma client at build time since `dashboard/generated/` is gitignored. Production database is Neon. +- **Extension** — `npm run zip` inside `extension/` builds and packages `dist/` into `worktrace-extension.zip`. Load unpacked in Chrome via Developer mode. diff --git a/dashboard/.env.example b/dashboard/.env.example new file mode 100644 index 0000000..7fa4ac7 --- /dev/null +++ b/dashboard/.env.example @@ -0,0 +1,59 @@ +# ─── Database ────────────────────────────────────────────────────────────────── +# Local dev: credentials from docker-compose.yml (port 5433). +# Neon (prod): https://console.neon.tech → your project → Connection string +# Use the "Pooled connection" string for DATABASE_URL. +DATABASE_URL="postgresql://postgres:postgres@localhost:5433/worktrace" + +# ─── Google OAuth ────────────────────────────────────────────────────────────── +# https://console.cloud.google.com → APIs & Services → Credentials +# → "+ CREATE CREDENTIALS" → "OAuth 2.0 Client ID" +# +# For the Extension auth flow (Week 2): +# Application type: Chrome Extension +# Application ID: your unpacked extension ID from chrome://extensions +# → copy the generated Client ID here +# +# For the Web Dashboard OAuth (Week 3): +# Create a SEPARATE Client ID with Application type: Web application +# Authorized redirect URIs: http://localhost:3000/api/auth/callback/google +GOOGLE_CLIENT_ID="" + +# Same value as GOOGLE_CLIENT_ID — exposed to the browser so the GIS +# "Sign in with Google" button can initialize. Backend still verifies +# via GOOGLE_CLIENT_ID, this one is read by the client bundle only. +NEXT_PUBLIC_GOOGLE_CLIENT_ID="" + +# Client secret — needed in Week 3 for the web dashboard authorization code flow. +# Same credentials page as GOOGLE_CLIENT_ID (Web application type). +GOOGLE_CLIENT_SECRET="" + +# ─── JWT ─────────────────────────────────────────────────────────────────────── +# Random 256-bit secret used to sign/verify our own JWTs. +# Generate: openssl rand -base64 32 +JWT_SECRET="" + +# Token lifetime — any value accepted by the `ms` library (e.g. 7d, 24h, 3600). +JWT_EXPIRES_IN="7d" + +# ─── AI Reports (Week 3 AC 3) ────────────────────────────────────────────────── +# Groq (free-tier llama). https://console.groq.com → API Keys → Create. +# Default key used when a user has not configured their own via the dashboard UI. +GROQ_API_KEY="" + +# Override the default model if you want to experiment. Must be an OpenAI-compatible +# model exposed by Groq (https://console.groq.com/docs/models). +GROQ_MODEL="llama-3.3-70b-versatile" + +# AES-256 key for encrypting per-user API keys stored in UserSetting.groqApiKey. +# Must be base64 of exactly 32 random bytes. Generate: +# openssl rand -base64 32 +REPORTS_ENCRYPTION_KEY="" + +# ─── Cron ────────────────────────────────────────────────────────────────────── +# Secret for the stale-data cleanup cron endpoint. +# GitHub Actions sends this in Authorization: Bearer . +# Generate: openssl rand -base64 32 +CRON_SECRET="" + +# ─── App ─────────────────────────────────────────────────────────────────────── +NEXT_PUBLIC_APP_URL="http://localhost:3000" diff --git a/dashboard/.gitignore b/dashboard/.gitignore new file mode 100644 index 0000000..9178f0a --- /dev/null +++ b/dashboard/.gitignore @@ -0,0 +1,45 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# prisma generated artifacts (client + zod schemas) +/generated/ diff --git a/dashboard/app/api/auth/delete-account/route.ts b/dashboard/app/api/auth/delete-account/route.ts new file mode 100644 index 0000000..855c102 --- /dev/null +++ b/dashboard/app/api/auth/delete-account/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { getCurrentUser } from "@/server/current-user"; +import { prisma } from "@/server/db"; +import { SESSION_COOKIE, clearedCookieOptions } from "@/server/cookies"; + +export async function DELETE() { + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + // Cascade in schema handles Sessions → Events/Tracks, and UserSetting + await prisma.user.delete({ where: { id: user.id } }); + + const res = NextResponse.json({ ok: true }); + res.cookies.set(SESSION_COOKIE, "", clearedCookieOptions()); + return res; +} diff --git a/dashboard/app/api/auth/extension/route.ts b/dashboard/app/api/auth/extension/route.ts new file mode 100644 index 0000000..34aafdb --- /dev/null +++ b/dashboard/app/api/auth/extension/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withCors, corsPreflight } from "@/server/cors"; +import { ExtensionAuthInput } from "@/server/schemas/auth"; +import { authenticateGoogleUser } from "@/server/auth"; +import { apiError } from "@/server/api-error"; + +export const POST = withCors(async (req: NextRequest) => { + let body: unknown; + try { + body = await req.json(); + } catch { + return apiError("VALIDATION_ERROR", "Invalid JSON body", 400); + } + + const parsed = ExtensionAuthInput.safeParse(body); + if (!parsed.success) { + return apiError("VALIDATION_ERROR", "Validation failed", 400); + } + + try { + const token = await authenticateGoogleUser(parsed.data.googleToken); + return NextResponse.json({ token }, { status: 200 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Authentication failed"; + return apiError("UNAUTHORIZED", message, 401); + } +}); + +export function OPTIONS(req: NextRequest) { + return corsPreflight(req); +} diff --git a/dashboard/app/api/auth/google/route.ts b/dashboard/app/api/auth/google/route.ts new file mode 100644 index 0000000..1a54245 --- /dev/null +++ b/dashboard/app/api/auth/google/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { WebAuthInput } from "@/server/schemas/auth"; +import { authenticateGoogleUser } from "@/server/auth"; +import { SESSION_COOKIE, sessionCookieOptions } from "@/server/cookies"; +import { apiError } from "@/server/api-error"; + +export async function POST(req: NextRequest) { + let body: unknown; + try { + body = await req.json(); + } catch { + return apiError("VALIDATION_ERROR", "Invalid JSON body", 400); + } + + const parsed = WebAuthInput.safeParse(body); + if (!parsed.success) { + return apiError("VALIDATION_ERROR", "Validation failed", 400); + } + + let token: string; + try { + token = await authenticateGoogleUser(parsed.data.googleToken); + } catch (err) { + const message = err instanceof Error ? err.message : "Authentication failed"; + return apiError("UNAUTHORIZED", message, 401); + } + + const res = NextResponse.json({ ok: true }, { status: 200 }); + res.cookies.set(SESSION_COOKIE, token, sessionCookieOptions()); + return res; +} diff --git a/dashboard/app/api/auth/logout/route.ts b/dashboard/app/api/auth/logout/route.ts new file mode 100644 index 0000000..19d4391 --- /dev/null +++ b/dashboard/app/api/auth/logout/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; +import { SESSION_COOKIE, clearedCookieOptions } from "@/server/cookies"; + +export function POST() { + const res = NextResponse.json({ ok: true }, { status: 200 }); + res.cookies.set(SESSION_COOKIE, "", clearedCookieOptions()); + return res; +} diff --git a/dashboard/app/api/cron/close-stale/route.ts b/dashboard/app/api/cron/close-stale/route.ts new file mode 100644 index 0000000..e546712 --- /dev/null +++ b/dashboard/app/api/cron/close-stale/route.ts @@ -0,0 +1,17 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { closeStaleSessions } from "@/server/sessions"; +import { closeStaleTracksForClosedSessions } from "@/server/tracks"; + +const STALE_THRESHOLD_MINUTES = 30; + +export async function GET(req: NextRequest): Promise { + const secret = process.env["CRON_SECRET"]; + if (!secret || req.headers.get("authorization") !== `Bearer ${secret}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { closed, deleted } = await closeStaleSessions(STALE_THRESHOLD_MINUTES); + const tracksFixed = await closeStaleTracksForClosedSessions(); + + return NextResponse.json({ closed, deleted, tracksFixed }); +} diff --git a/dashboard/app/api/v1/events/route.ts b/dashboard/app/api/v1/events/route.ts new file mode 100644 index 0000000..bd02f3d --- /dev/null +++ b/dashboard/app/api/v1/events/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + createEventForUser, + listEventsForUser, + SessionNotFoundError, +} from "@/server/events"; +import { CreateEventInput, EventListFilters } from "@/server/schemas/events"; +import { withCors, corsPreflight } from "@/server/cors"; +import { requireUser, UnauthorizedError } from "@/server/jwt"; +import { apiError } from "@/server/api-error"; + +export const GET = withCors(async (req) => { + let user; + try { + user = await requireUser(req); + } catch (err) { + if (err instanceof UnauthorizedError) { + return apiError("UNAUTHORIZED", err.message, 401); + } + throw err; + } + + const params = Object.fromEntries(req.nextUrl.searchParams); + const parsed = EventListFilters.safeParse(params); + if (!parsed.success) { + return apiError("VALIDATION_ERROR", "Validation failed", 400); + } + + const page = await listEventsForUser(user.id, parsed.data); + return NextResponse.json(page); +}); + +export const POST = withCors(async (req) => { + let user; + try { + user = await requireUser(req); + } catch (err) { + if (err instanceof UnauthorizedError) { + return apiError("UNAUTHORIZED", err.message, 401); + } + throw err; + } + + const body = await req.json(); + const parsed = CreateEventInput.safeParse(body); + if (!parsed.success) { + return apiError("VALIDATION_ERROR", "Validation failed", 400); + } + + try { + const event = await createEventForUser(user.id, parsed.data); + return NextResponse.json(event, { status: 201 }); + } catch (err) { + if (err instanceof SessionNotFoundError) { + return apiError("NOT_FOUND", "Session not found", 404); + } + throw err; + } +}); + +export function OPTIONS(req: NextRequest) { + return corsPreflight(req); +} diff --git a/dashboard/app/api/v1/events/stats/route.ts b/dashboard/app/api/v1/events/stats/route.ts new file mode 100644 index 0000000..e8e0671 --- /dev/null +++ b/dashboard/app/api/v1/events/stats/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getEventStatsForUser } from "@/server/events"; +import { EventStatsFilters } from "@/server/schemas/events"; +import { withCors, corsPreflight } from "@/server/cors"; +import { requireUser, UnauthorizedError } from "@/server/jwt"; +import { apiError } from "@/server/api-error"; + +export const GET = withCors(async (req) => { + let user; + try { + user = await requireUser(req); + } catch (err) { + if (err instanceof UnauthorizedError) { + return apiError("UNAUTHORIZED", err.message, 401); + } + throw err; + } + + const params = Object.fromEntries(req.nextUrl.searchParams); + const parsed = EventStatsFilters.safeParse(params); + if (!parsed.success) { + return apiError("VALIDATION_ERROR", "Validation failed", 400); + } + + const stats = await getEventStatsForUser(user.id, parsed.data); + return NextResponse.json(stats); +}); + +export function OPTIONS(req: NextRequest) { + return corsPreflight(req); +} diff --git a/dashboard/app/api/v1/music/stats/route.ts b/dashboard/app/api/v1/music/stats/route.ts new file mode 100644 index 0000000..45f367f --- /dev/null +++ b/dashboard/app/api/v1/music/stats/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/server/current-user"; +import { requireUser, UnauthorizedError } from "@/server/jwt"; +import { withCors, corsPreflight } from "@/server/cors"; +import { MusicStatsQuery } from "@/server/schemas/music"; +import { resolveRange } from "@/server/range"; +import { + getTopArtists, + getTopTracks, + getMusicProductivity, + getHourlyPattern, + getMusicTotals, +} from "@/server/music"; +import type { MusicStats } from "@/types/music-stats"; +import { apiError } from "@/server/api-error"; + +export const GET = withCors(async (req: NextRequest) => { + // Cookie auth (dashboard) — fallback to Bearer (extension) + let userId: string; + try { + const cookie = await getCurrentUser(); + if (cookie) { + userId = cookie.id; + } else { + userId = (await requireUser(req)).id; + } + } catch (err) { + if (err instanceof UnauthorizedError) { + return apiError("UNAUTHORIZED", err.message, 401); + } + throw err; + } + + const params = Object.fromEntries(req.nextUrl.searchParams); + const parsed = MusicStatsQuery.safeParse(params); + if (!parsed.success) { + return apiError("VALIDATION_ERROR", "Validation failed", 400); + } + + const { from, to, label } = await resolveRange( + { range: parsed.data.range, from: parsed.data.from, to: parsed.data.to }, + userId, + ); + + const [topArtists, topTracks, productivity, hourlyPattern, totals] = await Promise.all([ + getTopArtists(userId, from, to), + getTopTracks(userId, from, to), + getMusicProductivity(userId, from, to), + getHourlyPattern(userId, from, to), + getMusicTotals(userId, from, to), + ]); + + const body: MusicStats = { + topArtists, + topTracks, + productivity, + hourlyPattern, + totalListenedMs: totals.totalListenedMs, + totalArtists: totals.totalArtists, + totalTracks: totals.totalTracks, + range: { from: from.toISOString(), to: to.toISOString(), label }, + }; + + return NextResponse.json(body); +}); + +export function OPTIONS(req: NextRequest) { + return corsPreflight(req); +} diff --git a/dashboard/app/api/v1/reports/generate/route.ts b/dashboard/app/api/v1/reports/generate/route.ts new file mode 100644 index 0000000..ce6803c --- /dev/null +++ b/dashboard/app/api/v1/reports/generate/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireUser, UnauthorizedError } from "@/server/jwt"; +import { withCors, corsPreflight } from "@/server/cors"; +import { GenerateReportInput } from "@/server/schemas/reports"; +import { + generateReport, + MissingApiKeyError, + GroqAuthError, + GroqContextLimitError, + GroqRateLimitError, + GroqTimeoutError, + GroqUpstreamError, +} from "@/server/reports"; +import { apiError } from "@/server/api-error"; + +export const POST = withCors(async (req) => { + let user; + try { + user = await requireUser(req); + } catch (err) { + if (err instanceof UnauthorizedError) { + return apiError("UNAUTHORIZED", err.message, 401); + } + throw err; + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return apiError("VALIDATION_ERROR", "Invalid JSON body", 400); + } + + const parsed = GenerateReportInput.safeParse(body); + if (!parsed.success) { + return apiError("VALIDATION_ERROR", "Validation failed", 400); + } + + try { + const result = await generateReport(user.id, parsed.data); + return NextResponse.json(result); + } catch (err) { + if (err instanceof MissingApiKeyError) { + return apiError("SERVER_ERROR", "No Groq API key configured. Add your own via the API KEY button.", 503); + } + if (err instanceof GroqContextLimitError) { + return apiError("SERVER_ERROR", err.message, 422); + } + if (err instanceof GroqAuthError) { + return apiError("SERVER_ERROR", "Groq rejected the API key. Clear it in settings or set a valid key.", 402); + } + if (err instanceof GroqRateLimitError) { + return apiError("SERVER_ERROR", err.message, 429); + } + if (err instanceof GroqTimeoutError) { + return apiError("SERVER_ERROR", "AI request timed out. Try again in a moment.", 504); + } + if (err instanceof GroqUpstreamError) { + console.error("[reports] upstream error", err); + return apiError("SERVER_ERROR", "AI provider unavailable. Try again later.", 502); + } + throw err; + } +}); + +export function OPTIONS(req: NextRequest) { + return corsPreflight(req); +} diff --git a/dashboard/app/api/v1/sessions/[id]/route.ts b/dashboard/app/api/v1/sessions/[id]/route.ts new file mode 100644 index 0000000..e797199 --- /dev/null +++ b/dashboard/app/api/v1/sessions/[id]/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { endSessionForUser, SessionNotFoundError } from "@/server/sessions"; +import { SessionIdParam } from "@/server/schemas/sessions"; +import { withCors, corsPreflight } from "@/server/cors"; +import { requireUser, UnauthorizedError } from "@/server/jwt"; +import { apiError } from "@/server/api-error"; + +interface RouteContext { + params: Promise<{ id: string }>; +} + +export function PATCH(req: NextRequest, ctx: RouteContext): Promise { + return withCors(async (r) => { + let user; + try { + user = await requireUser(r); + } catch (err) { + if (err instanceof UnauthorizedError) { + return apiError("UNAUTHORIZED", err.message, 401); + } + throw err; + } + + const params = await ctx.params; + const parsed = SessionIdParam.safeParse(params); + if (!parsed.success) { + return apiError("VALIDATION_ERROR", "Validation failed", 400); + } + + try { + const session = await endSessionForUser(user.id, parsed.data.id); + return NextResponse.json(session, { status: 200 }); + } catch (err) { + if (err instanceof SessionNotFoundError) { + return apiError("NOT_FOUND", "Session not found", 404); + } + throw err; + } + })(req); +} + +export function OPTIONS(req: NextRequest) { + return corsPreflight(req); +} diff --git a/dashboard/app/api/v1/sessions/route.ts b/dashboard/app/api/v1/sessions/route.ts new file mode 100644 index 0000000..8f47a45 --- /dev/null +++ b/dashboard/app/api/v1/sessions/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createSessionForUser } from "@/server/sessions"; +import { withCors, corsPreflight } from "@/server/cors"; +import { requireUser, UnauthorizedError } from "@/server/jwt"; +import { apiError } from "@/server/api-error"; + +export const POST = withCors(async (req) => { + let user; + try { + user = await requireUser(req); + } catch (err) { + if (err instanceof UnauthorizedError) { + return apiError("UNAUTHORIZED", err.message, 401); + } + throw err; + } + + const session = await createSessionForUser(user.id); + return NextResponse.json(session, { status: 201 }); +}); + +export function OPTIONS(req: NextRequest) { + return corsPreflight(req); +} diff --git a/dashboard/app/api/v1/sessions/top/route.ts b/dashboard/app/api/v1/sessions/top/route.ts new file mode 100644 index 0000000..f0e1119 --- /dev/null +++ b/dashboard/app/api/v1/sessions/top/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getTopSessionsForUser } from "@/server/sessions"; +import { withCors, corsPreflight } from "@/server/cors"; +import { requireUser, UnauthorizedError } from "@/server/jwt"; +import { apiError } from "@/server/api-error"; + +export const GET = withCors(async (req) => { + let user; + try { + user = await requireUser(req); + } catch (err) { + if (err instanceof UnauthorizedError) { + return apiError("UNAUTHORIZED", err.message, 401); + } + throw err; + } + + const sessions = await getTopSessionsForUser(user.id, 3); + return NextResponse.json({ sessions }); +}); + +export function OPTIONS(req: NextRequest) { + return corsPreflight(req); +} diff --git a/dashboard/app/api/v1/tracks/[id]/route.ts b/dashboard/app/api/v1/tracks/[id]/route.ts new file mode 100644 index 0000000..4d9bf39 --- /dev/null +++ b/dashboard/app/api/v1/tracks/[id]/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; +import { endTrackForUser, TrackNotFoundError } from "@/server/tracks"; +import { UpdateTrackInput } from "@/server/schemas/tracks"; +import { withCors, corsPreflight } from "@/server/cors"; +import { requireUser, UnauthorizedError } from "@/server/jwt"; +import { apiError } from "@/server/api-error"; + +interface RouteContext { + params: Promise<{ id: string }>; +} + +export function PATCH(req: NextRequest, ctx: RouteContext): Promise { + return withCors(async (r) => { + let user; + try { + user = await requireUser(r); + } catch (err) { + if (err instanceof UnauthorizedError) { + return apiError("UNAUTHORIZED", err.message, 401); + } + throw err; + } + + const { id } = await ctx.params; + + const body = await r.json(); + const parsed = UpdateTrackInput.safeParse(body); + if (!parsed.success) { + return apiError("VALIDATION_ERROR", "Validation failed", 400); + } + + try { + const track = await endTrackForUser(user.id, id, parsed.data); + return NextResponse.json(track, { status: 200 }); + } catch (err) { + if (err instanceof TrackNotFoundError) { + return apiError("NOT_FOUND", "Track not found", 404); + } + throw err; + } + })(req); +} + +export function OPTIONS(req: NextRequest) { + return corsPreflight(req); +} diff --git a/dashboard/app/api/v1/tracks/route.ts b/dashboard/app/api/v1/tracks/route.ts new file mode 100644 index 0000000..f25c5a8 --- /dev/null +++ b/dashboard/app/api/v1/tracks/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createTrackForUser, SessionNotFoundError } from "@/server/tracks"; +import { CreateTrackInput } from "@/server/schemas/tracks"; +import { withCors, corsPreflight } from "@/server/cors"; +import { requireUser, UnauthorizedError } from "@/server/jwt"; +import { apiError } from "@/server/api-error"; + +export const POST = withCors(async (req) => { + let user; + try { + user = await requireUser(req); + } catch (err) { + if (err instanceof UnauthorizedError) { + return apiError("UNAUTHORIZED", err.message, 401); + } + throw err; + } + + const body = await req.json(); + const parsed = CreateTrackInput.safeParse(body); + if (!parsed.success) { + return apiError("VALIDATION_ERROR", "Validation failed", 400); + } + + try { + const track = await createTrackForUser(user.id, parsed.data); + return NextResponse.json(track, { status: 201 }); + } catch (err) { + if (err instanceof SessionNotFoundError) { + return apiError("NOT_FOUND", "Session not found", 404); + } + throw err; + } +}); + +export function OPTIONS(req: NextRequest) { + return corsPreflight(req); +} diff --git a/dashboard/app/api/v1/user/settings/route.ts b/dashboard/app/api/v1/user/settings/route.ts new file mode 100644 index 0000000..8085a48 --- /dev/null +++ b/dashboard/app/api/v1/user/settings/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireUser, UnauthorizedError } from "@/server/jwt"; +import { withCors, corsPreflight } from "@/server/cors"; +import { SetApiKeyInput } from "@/server/schemas/user-settings"; +import { getUserSettingsView, setGroqApiKey } from "@/server/user-settings"; +import { apiError } from "@/server/api-error"; + +export const GET = withCors(async (req) => { + let user; + try { + user = await requireUser(req); + } catch (err) { + if (err instanceof UnauthorizedError) { + return apiError("UNAUTHORIZED", err.message, 401); + } + throw err; + } + const view = await getUserSettingsView(user.id); + return NextResponse.json(view); +}); + +export const PUT = withCors(async (req) => { + let user; + try { + user = await requireUser(req); + } catch (err) { + if (err instanceof UnauthorizedError) { + return apiError("UNAUTHORIZED", err.message, 401); + } + throw err; + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return apiError("VALIDATION_ERROR", "Invalid JSON body", 400); + } + + const parsed = SetApiKeyInput.safeParse(body); + if (!parsed.success) { + return apiError("VALIDATION_ERROR", "Validation failed", 400); + } + + const view = await setGroqApiKey(user.id, parsed.data.groqApiKey); + return NextResponse.json(view); +}); + +export function OPTIONS(req: NextRequest) { + return corsPreflight(req); +} diff --git a/dashboard/app/apple-icon.png b/dashboard/app/apple-icon.png new file mode 100644 index 0000000..06012ed Binary files /dev/null and b/dashboard/app/apple-icon.png differ diff --git a/dashboard/app/dashboard/account-menu.tsx b/dashboard/app/dashboard/account-menu.tsx new file mode 100644 index 0000000..183a24a --- /dev/null +++ b/dashboard/app/dashboard/account-menu.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { AnimatePresence, motion } from "framer-motion"; +import { useRouter } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; + +type Menu = null | "account" | "delete-confirm"; + +const backdrop = { + initial: { opacity: 0 }, + animate: { opacity: 1 }, + exit: { opacity: 0 }, + transition: { duration: 0.2 }, +}; + +const panel = { + initial: { opacity: 0, scale: 0.92, y: 16 }, + animate: { opacity: 1, scale: 1, y: 0 }, + exit: { opacity: 0, scale: 0.95, y: 8 }, + transition: { type: "spring" as const, stiffness: 400, damping: 28, mass: 0.8 }, +}; + +// Remove all cached report keys for a given email (or all wt:report:* keys) +function clearReportCache(email: string) { + try { + const prefix = `wt:report:${email.toLowerCase().trim()}:`; + Object.keys(localStorage) + .filter((k) => k.startsWith(prefix)) + .forEach((k) => localStorage.removeItem(k)); + } catch { /* localStorage unavailable */ } +} + +export function AccountMenu({ userEmail }: { userEmail: string }) { + const router = useRouter(); + const queryClient = useQueryClient(); + const [menu, setMenu] = useState(null); + const [pending, setPending] = useState(false); + const [deleteErr, setDeleteErr] = useState(null); + + // Escape key closes the active modal + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + if (menu === "delete-confirm") setMenu("account"); + else if (menu === "account") setMenu(null); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [menu]); + + async function handleLogout() { + setPending(true); + try { + await fetch("/api/auth/logout", { method: "POST" }); + clearReportCache(userEmail); + } finally { + queryClient.clear(); + router.replace("/login"); + router.refresh(); + } + } + + async function handleDelete() { + setPending(true); + setDeleteErr(null); + try { + const res = await fetch("/api/auth/delete-account", { method: "DELETE" }); + if (!res.ok) { + const body = await res.json().catch(() => ({})) as { error?: string }; + throw new Error(body.error ?? `HTTP ${res.status}`); + } + clearReportCache(userEmail); + queryClient.clear(); + router.replace("/login"); + router.refresh(); + } catch (err) { + setDeleteErr(err instanceof Error ? err.message : "Failed to delete account"); + setPending(false); + } + } + + return ( + <> + + + {typeof document !== "undefined" && createPortal( + + + {/* ── Modal 1: account options ── */} + {menu === "account" && ( + <> + setMenu(null)} + aria-hidden + /> + e.stopPropagation()} + > +
+
+

+ ACCOUNT +

+ +
+ +
+ + +
+
+
+ + )} + + {/* ── Modal 2: delete confirmation ── */} + {menu === "delete-confirm" && ( + <> + setMenu("account")} + aria-hidden + /> + e.stopPropagation()} + > +
+
+

+ DELETE ACCOUNT +

+ +
+ +

+ This will permanently delete all your events, + sessions, music data, and settings. This action cannot be undone. +

+ + {deleteErr ? ( +

{deleteErr}

+ ) : null} + +
+ + +
+
+
+ + )} + +
, + document.body, + )} + + ); +} diff --git a/dashboard/app/dashboard/app-header.tsx b/dashboard/app/dashboard/app-header.tsx new file mode 100644 index 0000000..0623675 --- /dev/null +++ b/dashboard/app/dashboard/app-header.tsx @@ -0,0 +1,60 @@ +import { AccountMenu } from "./account-menu"; +import { HeaderNav } from "./header-nav"; +import { MobileNav } from "./mobile-nav"; + +type Props = { + email: string; + name: string | null; + picture: string | null; +}; + +export function AppHeader({ email, name, picture }: Props) { + const initial = (name ?? email).trim().charAt(0).toUpperCase() || "?"; + const displayName = name ?? email; + + return ( +
+
+
+ + ⬡ + + + WORKTRACE + +
+ +
+ +
+ {picture ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + {initial} + + )} + + {displayName} + + + +
+
+ ); +} diff --git a/dashboard/app/dashboard/charts.tsx b/dashboard/app/dashboard/charts.tsx new file mode 100644 index 0000000..8f26b6e --- /dev/null +++ b/dashboard/app/dashboard/charts.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { + ArcElement, + CategoryScale, + Chart as ChartJS, + Filler, + LineElement, + LinearScale, + PointElement, + Tooltip, + Legend, +} from "chart.js"; +import { + fetchEventStats, + statsQueryKey, + type FeedFilters, +} from "./feed-shared"; +import { EventsByDayChart } from "./events-by-day-chart"; +import { TopTagsChart } from "./top-tags-chart"; +import { AnimatePresence, motion } from "framer-motion"; + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + ArcElement, + Filler, + Tooltip, + Legend, +); + +export function Charts({ filters }: { filters: FeedFilters }) { + const { data, isLoading, isError } = useQuery({ + queryKey: statsQueryKey(filters), + queryFn: () => fetchEventStats(filters), + placeholderData: keepPreviousData, + staleTime: 30_000, + }); + + if (isLoading) { + return ( + <> +
+
+ + ); + } + + if (isError || !data) { + return ( +
+ FAILED TO LOAD CHART DATA +
+ ); + } + + return ( + + + + + + + ); +} diff --git a/dashboard/app/dashboard/event-card.tsx b/dashboard/app/dashboard/event-card.tsx new file mode 100644 index 0000000..d7908f2 --- /dev/null +++ b/dashboard/app/dashboard/event-card.tsx @@ -0,0 +1,79 @@ +"use client"; + +import type { EventDTO } from "@/types/event"; +import { useClientLocalized } from "./use-client-localized"; +import { motion } from "framer-motion"; + +function hostnameOf(url: string): string { + try { + return new URL(url).hostname; + } catch { + return url; + } +} + +function utcShort(iso: string): string { + return iso.slice(0, 16).replace("T", " ") + " UTC"; +} + +function localShort(iso: string): string { + const d = new Date(iso); + return d.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + +export function EventCard({ event }: { event: EventDTO }) { + const time = useClientLocalized(event.timestamp, utcShort, localShort); + + return ( + +
+ + {event.title} + + +
+ +
+ + {hostnameOf(event.url)} +
+ + {event.content ? ( +

{event.content}

+ ) : null} + + {event.tags.length ? ( +
+ {event.tags.map((t) => ( + + {t} + + ))} +
+ ) : null} +
+ ); +} diff --git a/dashboard/app/dashboard/event-feed.tsx b/dashboard/app/dashboard/event-feed.tsx new file mode 100644 index 0000000..6d9e576 --- /dev/null +++ b/dashboard/app/dashboard/event-feed.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useEffect, useMemo, useRef } from "react"; +import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query"; +import { useLenis } from "lenis/react"; +import { motion } from "framer-motion"; +import { EventCard } from "./event-card"; +import { + eventsQueryKey, + fetchEventsPage, + type FeedFilters, +} from "./feed-shared"; + +type Props = { + filters: FeedFilters; + onClearFilters: () => void; +}; + +export function EventFeed({ filters, onClearFilters }: Props) { + const { + data, + isLoading, + isError, + refetch, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isFetching, + } = useInfiniteQuery({ + queryKey: eventsQueryKey(filters), + queryFn: ({ pageParam }) => fetchEventsPage(filters, pageParam), + initialPageParam: null as string | null, + getNextPageParam: (last) => last.nextCursor, + placeholderData: keepPreviousData, + staleTime: 30_000, + }); + + const events = useMemo( + () => data?.pages.flatMap((p) => p.events) ?? [], + [data], + ); + + const sentinelRef = useRef(null); + const lenis = useLenis(); + + // After each new batch loads, tell Lenis the page height changed + useEffect(() => { + lenis?.resize(); + }, [data?.pages.length, lenis]); + + useEffect(() => { + const el = sentinelRef.current; + if (!el || !hasNextPage) return; + + const io = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && !isFetchingNextPage) { + fetchNextPage(); + } + }, + { rootMargin: "200px" }, + ); + io.observe(el); + return () => io.disconnect(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + if (isLoading) return ; + if (isError) return refetch()} />; + if (events.length === 0) return ; + + const listVariants = { + visible: { transition: { staggerChildren: 0.05 } }, + }; + const itemVariants = { + hidden: { opacity: 0, y: 16 }, + visible: { opacity: 1, y: 0, transition: { duration: 0.3, ease: [0.25, 0.1, 0.25, 1] as const } }, + }; + + return ( + <> + + {events.map((event, idx) => ( + + + + ))} + + + {hasNextPage ? ( +
+ {isFetchingNextPage ? "LOADING…" : "SCROLL TO LOAD MORE"} +
+ ) : ( +
+ END OF FEED +
+ )} + + ); +} + +function FeedSkeleton() { + return ( +
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+ ); +} + +function EmptyState({ onClear }: { onClear: () => void }) { + return ( +
+

No events match the current filters.

+ +
+ ); +} + +function ErrorState({ onRetry }: { onRetry: () => void }) { + return ( +
+

Failed to load events.

+ +
+ ); +} diff --git a/dashboard/app/dashboard/event-filters.tsx b/dashboard/app/dashboard/event-filters.tsx new file mode 100644 index 0000000..bc87a17 --- /dev/null +++ b/dashboard/app/dashboard/event-filters.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { DEFAULT_FILTERS, type FeedFilters } from "./feed-shared"; + +type Props = { + value: FeedFilters; + onChange: (next: FeedFilters) => void; +}; + +function todayISO(): string { + const d = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +const INPUT_CLASS = + "rounded border border-border bg-bg px-2 py-1.5 text-xs text-text " + + "outline-none transition-colors hover:border-purple/60 focus:border-purple " + + "[color-scheme:dark]"; + +export function EventFilters({ value, onChange }: Props) { + const set = (k: K, v: FeedFilters[K]) => + onChange({ ...value, [k]: v }); + + const today = todayISO(); + const isDirty = value.from !== "" || value.to !== "" || value.tags !== ""; + + return ( +
+ + set("from", e.target.value)} + className={INPUT_CLASS} + /> + + + set("to", e.target.value)} + className={INPUT_CLASS} + /> + + + set("tags", e.target.value)} + placeholder="react, typescript" + className={INPUT_CLASS + " placeholder:text-muted"} + /> + +
+ +
+
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + ); +} diff --git a/dashboard/app/dashboard/events-by-day-chart.tsx b/dashboard/app/dashboard/events-by-day-chart.tsx new file mode 100644 index 0000000..c0f20f1 --- /dev/null +++ b/dashboard/app/dashboard/events-by-day-chart.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { Line } from "react-chartjs-2"; +import type { DayBucket } from "@/types/event"; + +function rangeDays(from: string, to: string): string[] { + const out: string[] = []; + const start = new Date(from + "T00:00:00.000Z"); + const end = new Date(to + "T00:00:00.000Z"); + for (const cur = new Date(start); cur <= end; cur.setUTCDate(cur.getUTCDate() + 1)) { + out.push(cur.toISOString().slice(0, 10)); + } + return out; +} + +function fillGaps(byDay: DayBucket[]): DayBucket[] { + if (byDay.length === 0) return []; + const present = new Map(byDay.map((b) => [b.date, b.count])); + const labels = rangeDays(byDay[0].date, byDay[byDay.length - 1].date); + return labels.map((date) => ({ date, count: present.get(date) ?? 0 })); +} + +export function EventsByDayChart({ byDay }: { byDay: DayBucket[] }) { + const filled = fillGaps(byDay); + + if (filled.length === 0) { + return ( +
+ NO EVENTS TO CHART +
+ ); + } + + return ( +
+
+ EVENTS PER DAY +
+
+
+ d.date.slice(5)), + datasets: [ + { + data: filled.map((d) => d.count), + borderColor: "#00e5b0", + backgroundColor: "rgba(0, 229, 176, 0.15)", + pointBackgroundColor: "#00e5b0", + pointRadius: 3, + tension: 0.3, + fill: true, + }, + ], + }} + options={{ + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false }, tooltip: { intersect: false, mode: "index" } }, + scales: { + x: { + ticks: { color: "#3e3e60", font: { size: 9, family: "Courier New" } }, + grid: { color: "rgba(28, 28, 56, 0.5)" }, + }, + y: { + beginAtZero: true, + ticks: { + color: "#3e3e60", + font: { size: 9, family: "Courier New" }, + precision: 0, + stepSize: 1, + }, + grid: { color: "rgba(28, 28, 56, 0.5)" }, + }, + }, + }} + /> +
+
+
+ ); +} diff --git a/dashboard/app/dashboard/feed-root.tsx b/dashboard/app/dashboard/feed-root.tsx new file mode 100644 index 0000000..53f8520 --- /dev/null +++ b/dashboard/app/dashboard/feed-root.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useState } from "react"; +import { DEFAULT_FILTERS, type FeedFilters } from "./feed-shared"; +import { EventFilters } from "./event-filters"; +import { EventFeed } from "./event-feed"; +import { Charts } from "./charts"; +import { TopSessions } from "./top-sessions"; + +export function FeedRoot() { + const [filters, setFilters] = useState(DEFAULT_FILTERS); + + return ( +
+ + +
+ + {/* Top sessions intentionally ignores feed filters: it is an all-time leaderboard (90-day window). */} + +
+ + setFilters(DEFAULT_FILTERS)} /> +
+ ); +} diff --git a/dashboard/app/dashboard/feed-shared.ts b/dashboard/app/dashboard/feed-shared.ts new file mode 100644 index 0000000..a91ebc6 --- /dev/null +++ b/dashboard/app/dashboard/feed-shared.ts @@ -0,0 +1,106 @@ +import { z } from "zod"; +import type { EventDTO, EventStats, TopSession } from "@/types/event"; + +export type FeedFilters = { + from: string; + to: string; + tags: string; +}; + +export const DEFAULT_FILTERS: FeedFilters = { from: "", to: "", tags: "" }; +export const PAGE_SIZE = 30; + +export type EventsPage = { + events: EventDTO[]; + nextCursor: string | null; +}; + +export function eventsQueryKey(filters: FeedFilters) { + return ["events", filters] as const; +} + +export function statsQueryKey(filters: FeedFilters) { + return ["event-stats", filters] as const; +} + +export const topSessionsQueryKey = ["top-sessions"] as const; + +// ─── Response schemas ───────────────────────────────────────────────────────── + +const EventDtoSchema: z.ZodType = z.object({ + id: z.string(), + sessionId: z.string(), + url: z.string(), + title: z.string(), + content: z.string().nullable(), + tags: z.array(z.string()), + timestamp: z.string(), +}); + +const EventsPageSchema: z.ZodType = z.object({ + events: z.array(EventDtoSchema), + nextCursor: z.string().nullable(), +}); + +const EventStatsSchema: z.ZodType = z.object({ + byDay: z.array(z.object({ date: z.string(), count: z.number() })), + topTags: z.array(z.object({ tag: z.string(), count: z.number() })), +}); + +const TopSessionSchema: z.ZodType = z.object({ + id: z.string(), + startedAt: z.string(), + endedAt: z.string().nullable(), + totalSeconds: z.number(), + topHost: z.string().nullable(), + topHostSeconds: z.number(), +}); + +const TopSessionsResponse = z.object({ sessions: z.array(TopSessionSchema) }); + +// ─── URL builders ───────────────────────────────────────────────────────────── + +function applyFilters(sp: URLSearchParams, filters: FeedFilters): void { + if (filters.from) sp.set("from", filters.from); + if (filters.to) sp.set("to", filters.to); + if (filters.tags) sp.set("tags", filters.tags); +} + +export function buildEventsUrl(filters: FeedFilters, cursor: string | null): string { + const sp = new URLSearchParams(); + applyFilters(sp, filters); + if (cursor) sp.set("cursor", cursor); + sp.set("limit", String(PAGE_SIZE)); + return `/api/v1/events?${sp.toString()}`; +} + +export function buildStatsUrl(filters: FeedFilters): string { + const sp = new URLSearchParams(); + applyFilters(sp, filters); + const qs = sp.toString(); + return qs ? `/api/v1/events/stats?${qs}` : "/api/v1/events/stats"; +} + +// ─── Fetchers ───────────────────────────────────────────────────────────────── + +async function getJson(url: string): Promise { + const res = await fetch(url, { credentials: "same-origin" }); + if (!res.ok) throw new Error(`Request failed: ${url} → ${res.status}`); + return res.json(); +} + +export async function fetchEventsPage( + filters: FeedFilters, + cursor: string | null, +): Promise { + return EventsPageSchema.parse(await getJson(buildEventsUrl(filters, cursor))); +} + +export async function fetchEventStats(filters: FeedFilters): Promise { + return EventStatsSchema.parse(await getJson(buildStatsUrl(filters))); +} + +export async function fetchTopSessions(): Promise { + const parsed = TopSessionsResponse.parse(await getJson("/api/v1/sessions/top")); + return parsed.sessions; +} diff --git a/dashboard/app/dashboard/header-nav.tsx b/dashboard/app/dashboard/header-nav.tsx new file mode 100644 index 0000000..aea349c --- /dev/null +++ b/dashboard/app/dashboard/header-nav.tsx @@ -0,0 +1,42 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { motion } from "framer-motion"; + +const ITEMS: { href: string; label: string }[] = [ + { href: "/dashboard", label: "FEED" }, + { href: "/dashboard/reports", label: "REPORTS" }, + { href: "/dashboard/music", label: "MUSIC" }, +]; + +export function HeaderNav() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/dashboard/app/dashboard/layout.tsx b/dashboard/app/dashboard/layout.tsx new file mode 100644 index 0000000..d8c4bc7 --- /dev/null +++ b/dashboard/app/dashboard/layout.tsx @@ -0,0 +1,32 @@ +import { redirect } from "next/navigation"; +import { getCurrentUser } from "@/server/current-user"; +import { AppHeader } from "./app-header"; +import { ParallaxBg } from "@/components/parallax-bg"; +import { PageTransition } from "@/components/page-transition"; + +export default async function DashboardLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + const user = await getCurrentUser(); + if (!user) redirect("/login"); + + return ( +
+ + +
+ {children} +
+ +
+ ); +} diff --git a/dashboard/app/dashboard/mobile-nav.tsx b/dashboard/app/dashboard/mobile-nav.tsx new file mode 100644 index 0000000..78703ad --- /dev/null +++ b/dashboard/app/dashboard/mobile-nav.tsx @@ -0,0 +1,91 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; + +const ITEMS: { href: string; label: string }[] = [ + { href: "/dashboard", label: "FEED" }, + { href: "/dashboard/reports", label: "REPORTS" }, + { href: "/dashboard/music", label: "MUSIC" }, +]; + +export function MobileNav() { + const pathname = usePathname(); + const [open, setOpen] = useState(false); + + return ( + <> + {/* Burger button */} + + + {/* Backdrop */} + + {open && ( + setOpen(false)} + /> + )} + + + {/* Slide-down panel */} + + {open && ( + + + + )} + + + ); +} diff --git a/dashboard/app/dashboard/music/hourly-chart.tsx b/dashboard/app/dashboard/music/hourly-chart.tsx new file mode 100644 index 0000000..f8ebe27 --- /dev/null +++ b/dashboard/app/dashboard/music/hourly-chart.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { Bar } from "react-chartjs-2"; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + BarElement, + Tooltip, + Legend, +} from "chart.js"; +import type { HourBucket } from "@/types/music-stats"; + +ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip, Legend); + +const HOUR_LABELS = Array.from({ length: 24 }, (_, h) => + `${String(h).padStart(2, "0")}:00`, +); + +export function HourlyChart({ buckets }: { buckets: HourBucket[] }) { + const sorted = [...buckets].sort((a, b) => a.hour - b.hour); + const hasData = sorted.some((b) => b.listenedMs > 0 || b.eventCount > 0); + + if (!hasData) { + return ( +
+ NO HOURLY DATA IN RANGE +
+ ); + } + + return ( +
+
+ HOURLY ACTIVITY PATTERN +
+
+ Math.round(b.listenedMs / 60000)), + backgroundColor: "rgba(0, 229, 176, 0.7)", + borderColor: "#00e5b0", + borderWidth: 1, + borderRadius: 2, + }, + { + label: "events", + data: sorted.map((b) => b.eventCount), + backgroundColor: "rgba(168, 85, 247, 0.7)", + borderColor: "#a855f7", + borderWidth: 1, + borderRadius: 2, + }, + ], + }} + options={{ + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: "top", + align: "end", + labels: { color: "#c8d0f0", font: { size: 9, family: "Courier New" }, boxWidth: 12 }, + }, + tooltip: { mode: "index", intersect: false }, + }, + scales: { + x: { + ticks: { color: "#3e3e60", font: { size: 8, family: "Courier New" }, maxRotation: 45 }, + grid: { color: "rgba(28, 28, 56, 0.5)" }, + }, + y: { + beginAtZero: true, + ticks: { color: "#3e3e60", font: { size: 9, family: "Courier New" }, precision: 0 }, + grid: { color: "rgba(28, 28, 56, 0.5)" }, + }, + }, + }} + /> +
+
+ ); +} diff --git a/dashboard/app/dashboard/music/music-client.tsx b/dashboard/app/dashboard/music/music-client.tsx new file mode 100644 index 0000000..292bcc7 --- /dev/null +++ b/dashboard/app/dashboard/music/music-client.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { animate } from "framer-motion"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import type { MusicStats } from "@/types/music-stats"; +import { TopArtistsChart } from "./top-artists-chart"; +import { ProductivityChart } from "./productivity-chart"; +import { HourlyChart } from "./hourly-chart"; + +type Preset = "today" | "yesterday" | "last_7d" | "last_30d" | "all_time"; + +const PRESETS: { value: Preset; label: string }[] = [ + { value: "today", label: "TODAY" }, + { value: "yesterday", label: "YESTERDAY" }, + { value: "last_7d", label: "LAST 7 DAYS" }, + { value: "last_30d", label: "LAST 30 DAYS" }, + { value: "all_time", label: "ALL TIME" }, +]; + +async function fetchMusicStats(range: Preset): Promise { + const res = await fetch(`/api/v1/music/stats?range=${range}`); + if (!res.ok) { + const body = await res.json().catch(() => ({})) as { error?: string }; + throw new Error(body.error ?? `HTTP ${res.status}`); + } + return res.json() as Promise; +} + +function useCountUp(target: number): number { + const [display, setDisplay] = useState(0); + const prev = useRef(0); + useEffect(() => { + const from = prev.current; + prev.current = target; + const controls = animate(from, target, { + duration: 0.8, + ease: "easeOut", + onUpdate: (v) => setDisplay(Math.round(v)), + }); + return () => controls.stop(); + }, [target]); + return display; +} + +function fmtListened(ms: number): string { + const h = Math.floor(ms / 3600000); + const m = Math.floor((ms % 3600000) / 60000); + if (h === 0) return `${String(m)}m`; + return `${String(h)}h ${String(m)}m`; +} + +export function MusicClient() { + const [range, setRange] = useState("last_7d"); + + const { data, isLoading, isError, error } = useQuery({ + queryKey: ["music-stats", range], + queryFn: () => fetchMusicStats(range), + placeholderData: keepPreviousData, + staleTime: 60_000, + }); + + return ( +
+ {/* Range picker */} +
+
RANGE
+
+ {PRESETS.map((p) => ( + + ))} +
+
+ + {isLoading && !data ? : null} + + {isError ? ( +
+ {error instanceof Error ? error.message : "Failed to load music data"} +
+ ) : null} + + {data ? ( + <> + {/* Summary row with count-up */} + + + {data.totalTracks === 0 ? ( +
+ NO MUSIC DATA FOR THIS RANGE +
+ ) : ( + <> + {/* Top Artists + Productivity side by side on lg */} +
+ + +
+ + {/* Hourly pattern full width */} + + + {/* Top Tracks text table */} +
+
+ TOP TRACKS +
+
+ {data.topTracks.map((t, i) => ( +
+ + {i + 1} + + + {t.title} + + + {t.artist} + + + {fmtListened(t.listenedMs)} + +
+ ))} +
+
+ + )} + + ) : null} +
+ ); +} + +function SummaryRow({ listenedMs, artists, tracks }: { listenedMs: number; artists: number; tracks: number }) { + const animArtists = useCountUp(artists); + const animTracks = useCountUp(tracks); + return ( +
+ + LISTENED {fmtListened(listenedMs)} + + + ARTISTS {animArtists} + + + TRACKS {animTracks} + +
+ ); +} + +function SkeletonGrid() { + return ( +
+
+
+
+
+
+
+
+
+ ); +} diff --git a/dashboard/app/dashboard/music/page.tsx b/dashboard/app/dashboard/music/page.tsx new file mode 100644 index 0000000..dc0ea12 --- /dev/null +++ b/dashboard/app/dashboard/music/page.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { getCurrentUser } from "@/server/current-user"; +import { MusicClient } from "./music-client"; + +export const metadata: Metadata = { + title: "Music Analytics", + description: "Correlation between music listening and session activity.", + robots: { index: false, follow: false }, +}; + +export default async function MusicPage() { + const user = await getCurrentUser(); + if (!user) redirect("/login"); + + return ( +
+

MUSIC ANALYTICS

+ +
+ ); +} diff --git a/dashboard/app/dashboard/music/productivity-chart.tsx b/dashboard/app/dashboard/music/productivity-chart.tsx new file mode 100644 index 0000000..06aa915 --- /dev/null +++ b/dashboard/app/dashboard/music/productivity-chart.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { Bar } from "react-chartjs-2"; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + BarElement, + Tooltip, +} from "chart.js"; +import type { ProductivityRow } from "@/types/music-stats"; + +ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip); + +export function ProductivityChart({ rows }: { rows: ProductivityRow[] }) { + const withActivity = rows.filter((r) => r.perMin > 0); + + if (withActivity.length === 0) { + return ( +
+ NO PRODUCTIVITY DATA IN RANGE +
+ ); + } + + const labels = withActivity.map((r) => `${r.title} / ${r.artist}`); + + return ( +
+
+ PRODUCTIVITY CORRELATION +
+
+ events / min while track was active +
+
+ Math.round(r.perMin * 100) / 100), + backgroundColor: "#a855f7", + borderColor: "#a855f7", + borderWidth: 0, + borderRadius: 2, + }, + ], + }} + options={{ + indexAxis: "y", + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + label: (ctx) => { + const r = withActivity[ctx.dataIndex]; + return r + ? ` ${r.perMin.toFixed(2)} events/min · ${r.minutes.toFixed(1)} min listened · ${String(r.events)} events` + : ""; + }, + }, + }, + }, + scales: { + x: { + ticks: { color: "#3e3e60", font: { size: 9, family: "Courier New" } }, + grid: { color: "rgba(28, 28, 56, 0.5)" }, + title: { display: true, text: "events / min", color: "#3e3e60", font: { size: 9, family: "Courier New" } }, + }, + y: { + ticks: { + color: "#c8d0f0", + font: { size: 9, family: "Courier New" }, + callback: (_, i) => { + const label = labels[i] ?? ""; + return label.length > 30 ? `${label.slice(0, 28)}…` : label; + }, + }, + grid: { display: false }, + }, + }, + }} + /> +
+
+ ); +} diff --git a/dashboard/app/dashboard/music/top-artists-chart.tsx b/dashboard/app/dashboard/music/top-artists-chart.tsx new file mode 100644 index 0000000..9d8812b --- /dev/null +++ b/dashboard/app/dashboard/music/top-artists-chart.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { Bar } from "react-chartjs-2"; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + BarElement, + Tooltip, +} from "chart.js"; +import type { TopArtist } from "@/types/music-stats"; + +ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip); + +function fmtMin(ms: number): string { + const m = Math.floor(ms / 60000); + const s = Math.round((ms % 60000) / 1000); + return s > 0 ? `${String(m)}m ${String(s)}s` : `${String(m)}m`; +} + +export function TopArtistsChart({ artists }: { artists: TopArtist[] }) { + if (artists.length === 0) { + return ( +
+ NO ARTISTS IN RANGE +
+ ); + } + + const sorted = [...artists].sort((a, b) => b.listenedMs - a.listenedMs).slice(0, 12); + + return ( +
+
+ TOP ARTISTS BY LISTENING TIME +
+
+ a.artist), + datasets: [ + { + data: sorted.map((a) => Math.round(a.listenedMs / 60000)), + backgroundColor: "#00e5b0", + borderColor: "#00e5b0", + borderWidth: 0, + borderRadius: 2, + }, + ], + }} + options={{ + indexAxis: "y", + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + label: (ctx) => { + const artist = sorted[ctx.dataIndex]; + return artist ? ` ${fmtMin(artist.listenedMs)} · ${String(artist.trackCount)} track${artist.trackCount !== 1 ? "s" : ""}` : ""; + }, + }, + }, + }, + scales: { + x: { + ticks: { color: "#3e3e60", font: { size: 9, family: "Courier New" } }, + grid: { color: "rgba(28, 28, 56, 0.5)" }, + title: { display: true, text: "minutes", color: "#3e3e60", font: { size: 9, family: "Courier New" } }, + }, + y: { + ticks: { color: "#c8d0f0", font: { size: 9, family: "Courier New" } }, + grid: { display: false }, + }, + }, + }} + /> +
+
+ ); +} diff --git a/dashboard/app/dashboard/page.tsx b/dashboard/app/dashboard/page.tsx new file mode 100644 index 0000000..be2f72d --- /dev/null +++ b/dashboard/app/dashboard/page.tsx @@ -0,0 +1,59 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { + HydrationBoundary, + QueryClient, + dehydrate, +} from "@tanstack/react-query"; +import { getCurrentUser } from "@/server/current-user"; +import { getEventStatsForUser, listEventsForUser } from "@/server/events"; +import { getTopSessionsForUser } from "@/server/sessions"; +import { + DEFAULT_FILTERS, + PAGE_SIZE, + eventsQueryKey, + statsQueryKey, + topSessionsQueryKey, +} from "./feed-shared"; +import { FeedRoot } from "./feed-root"; + +export const metadata: Metadata = { + title: "Dashboard", + description: "Browse captured events, filter by date or tag, and review your top sessions.", + robots: { index: false, follow: false }, +}; + +export default async function DashboardPage() { + const user = await getCurrentUser(); + if (!user) redirect("/login"); + + const qc = new QueryClient(); + await Promise.all([ + qc.prefetchInfiniteQuery({ + queryKey: eventsQueryKey(DEFAULT_FILTERS), + queryFn: () => + listEventsForUser(user.id, { tags: [], limit: PAGE_SIZE }), + initialPageParam: null as string | null, + }), + qc.prefetchQuery({ + queryKey: statsQueryKey(DEFAULT_FILTERS), + queryFn: () => getEventStatsForUser(user.id, { tags: [] }), + }), + qc.prefetchQuery({ + queryKey: topSessionsQueryKey, + queryFn: () => getTopSessionsForUser(user.id, 3), + }), + ]); + + return ( +
+
+

EVENT FEED

+
+ + + + +
+ ); +} diff --git a/dashboard/app/dashboard/reports/api-key-modal.tsx b/dashboard/app/dashboard/reports/api-key-modal.tsx new file mode 100644 index 0000000..9025204 --- /dev/null +++ b/dashboard/app/dashboard/reports/api-key-modal.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { AnimatePresence, motion } from "framer-motion"; +import type { UserSettingsView } from "@/server/user-settings"; +import { saveApiKey } from "./reports-client"; + +type Props = { + open: boolean; + status: UserSettingsView; + onClose: () => void; + onSaved: (next: UserSettingsView) => void; +}; + +export function ApiKeyModal({ open, status, onClose, onSaved }: Props) { + const [value, setValue] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const trimmed = value.trim(); + const keyFormatOk = trimmed.startsWith("gsk_") && trimmed.length >= 20; + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + async function commit(key: string | null) { + setBusy(true); + setError(null); + try { + const next = await saveApiKey(key); + onSaved(next); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save"); + } finally { + setBusy(false); + } + } + + return createPortal( + + {open && ( + <> + {/* Backdrop */} + + + {/* Panel */} + e.stopPropagation()} + > +
+
+

+ GROQ API KEY +

+ +
+ +

+ {status.hasGroqApiKey + ? <>Currently using your own key (••••{status.last4 ?? "????"}). To change, enter a new key below. + : <>Currently using the shared default key. Paste your own Groq API key to override. + } +

+ + + setValue(e.target.value)} + placeholder="gsk_…" + disabled={busy} + autoComplete="off" + className="mb-2 w-full rounded border border-border bg-bg px-2 py-1.5 text-xs text-text outline-none transition-colors focus:border-purple disabled:opacity-50" + /> + + {trimmed.length > 0 && !trimmed.startsWith("gsk_") ? ( +

Key must start with gsk_

+ ) : error ? ( +

{error}

+ ) : null} + +

+ Stored encrypted (AES-256-GCM) and never returned to the browser. +

+ +
+ {status.hasGroqApiKey ? ( + + ) : null} + + +
+
+
+ + )} +
, + document.body, + ); +} diff --git a/dashboard/app/dashboard/reports/markdown-view.tsx b/dashboard/app/dashboard/reports/markdown-view.tsx new file mode 100644 index 0000000..b571a05 --- /dev/null +++ b/dashboard/app/dashboard/reports/markdown-view.tsx @@ -0,0 +1,49 @@ +"use client"; + +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +export function MarkdownView({ markdown }: { markdown: string }) { + return ( +
+

, + h2: (p) =>

, + h3: (p) =>

, + p: (p) =>

, + ul: (p) =>