From 990de296835ae8313a7352b40af9317c53c36bf6 Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 19 Sep 2026 13:47:59 +0200 Subject: [PATCH] =?UTF-8?q?docs(plan):=20Phase=207=20=E2=80=94=20logged=20?= =?UTF-8?q?work=20entries=20implementation=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- .../2026-09-19-phase-7-logged-work-entries.md | 1319 +++++++++++++++++ 1 file changed, 1319 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-19-phase-7-logged-work-entries.md diff --git a/docs/superpowers/plans/2026-09-19-phase-7-logged-work-entries.md b/docs/superpowers/plans/2026-09-19-phase-7-logged-work-entries.md new file mode 100644 index 0000000..064741e --- /dev/null +++ b/docs/superpowers/plans/2026-09-19-phase-7-logged-work-entries.md @@ -0,0 +1,1319 @@ +# Phase 7 — Logged Work Entries Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let the owner log a block of off-repo work (a date, a declared number of hours, and a message) as a row in the `commits` collection tagged `source: "manual"`, so it flows through every existing commit render path and adds honestly to the dashboard's hours — distinguished from a real commit everywhere it appears by a small "Logged" tag. + +**Architecture:** No new collection. `commits` gains `source` and `manual_minutes`; `computeProjectMetrics` stays pure and gets one more optional input field (`manualMinutes`), added to the total directly instead of being session-clustered. A new owner-only server action writes the row (via the superuser connection, since `commits.createRule` is `null` — this is the one collection in the app that has never had a direct user-facing write path). Rendering reuses `RecentActivity` and `CommitHistory` unmodified except for one small conditional each. + +**Tech Stack:** Next.js 16 Server Actions · PocketBase 0.30 · Tailwind 4 · vitest + +**Spec:** `docs/superpowers/specs/2026-09-19-phase-7-logged-work-entries-design.md` + +**Prerequisite:** Phases 1–3 complete (commits collection, AI provider settings). Phase 6 is not required. + +--- + +## File structure + +``` +scripts/setup-remote.ts # + ensureManualEntryFields +src/ +├── lib/ +│ ├── pb/types.ts # + CommitSource, source, manual_minutes +│ ├── metrics/ +│ │ ├── compute.ts # + manualMinutes handling +│ │ ├── compute.test.ts # + manual-entry tests +│ │ └── store.ts # + source/manual_minutes in the query + mapping +│ └── ai/ +│ ├── summarise.ts # excludes source="manual" from the batch queue +│ └── polish.ts # NEW — single-shot note polishing +├── app/ +│ └── actions/ +│ └── log-work.ts # NEW — logWorkAction, polishNoteAction +└── components/ + └── dashboard/ + ├── recent-activity.tsx # + "Logged" tag + ├── methodology.tsx # + manual-hours line + └── log-work-panel.tsx # NEW — button + form +src/components/card/commit-history.tsx # + "Logged" tag, hides raw-detail for manual rows +src/app/p/[clientSlug]/[projectSlug]/dashboard/page.tsx # renders LogWorkPanel +tests/integration/log-work.test.ts # NEW +``` + +--- + +## Task 1: The `source` and `manual_minutes` fields + +**Files:** +- Modify: `scripts/setup-remote.ts` +- Modify: `src/lib/pb/types.ts` + +- [ ] **Step 1: Add the type** + +In `src/lib/pb/types.ts`, add beside `CommitLinkSource`: + +```ts +export type CommitSource = "git" | "manual"; +``` + +Add `source` and `manual_minutes` to `CommitRecord`: + +```ts +export interface CommitRecord extends BaseRecord { + project: string; + sha: string; + short_sha: string; + message: string; + subject: string; + type: string; + scope: string; + author_name: string; + author_email: string; + /** Authoring time, not commit or ingest time. See the API notes. */ + authored_at: string; + is_merge: boolean; + additions: number; + deletions: number; + card: string; + link_source: CommitLinkSource; + url: string; + /** Plain-English one-liner. Empty when not yet generated or generation failed. */ + summary: string; + /** PROMPT_VERSION this summary was written under. */ + summary_version: number; + /** Consecutive failed attempts. Capped so a poison message stops retrying. */ + summary_attempts: number; + /** "git" for every ingested commit, "manual" for a logged entry. */ + source: CommitSource; + /** Only meaningful when source is "manual" — the owner's declared minutes. */ + manual_minutes: number; +} +``` + +- [ ] **Step 2: Add the migration helper** + +In `scripts/setup-remote.ts`, add a new function beside `ensureCommitSummaryFields`: + +```ts +/** + * Add the `source` and `manual_minutes` fields to commits, for Phase 7's + * logged work entries. + * + * Backfilled to "git" for every existing row — every commit ingested before + * this phase came from the repository, by definition. + * + * Re-runs are safe: fields are not duplicated, and backfill only touches rows + * where source is still unset. + */ +async function ensureManualEntryFields(pb: PocketBase): Promise { + const commits = await pb.collections.getOne("commits"); + const have = new Set(commits.fields.map((f: { name: string }) => f.name)); + + const additions = [ + { + name: "source", + type: "select", + required: false, + maxSelect: 1, + values: ["git", "manual"], + }, + { name: "manual_minutes", type: "number", required: false, onlyInt: true }, + ]; + + const fields = [...(commits.fields as unknown[])]; + let changed = false; + + for (const field of additions) { + if (!have.has(field.name)) { + fields.push({ ...field, presentable: false, hidden: false, system: false }); + changed = true; + } + } + + if (changed) { + await pb.collections.update(commits.id, { fields }); + console.log(" + commits fields: source, manual_minutes"); + } else { + console.log(" = manual entry fields already present"); + } + + const unset = await pb.collection("commits").getFullList({ + filter: 'source = ""', + fields: "id", + }); + for (const commit of unset) { + await pb.collection("commits").update(commit.id, { source: "git" }); + } + console.log(` = commit source backfilled (${unset.length} row(s))`); +} +``` + +- [ ] **Step 3: Call it from `main()`** + +Directly after `await ensureCommitSummaryFields(pb);`: + +```ts + // Add the per-commit summary fields used by Phase 3b's AI one-liner job. + await ensureCommitSummaryFields(pb); + + // Add the source/manual_minutes fields for Phase 7's logged work entries. + await ensureManualEntryFields(pb); +``` + +- [ ] **Step 4: Run it against the live PocketBase** + +```bash +set -a && source .env.local && set +a && npm run setup-remote +``` + +Expected: prints `+ commits fields: source, manual_minutes` (or `= manual +entry fields already present` on a re-run) followed by `= commit source +backfilled (N row(s))`, where N is the total number of existing commits. + +- [ ] **Step 5: Verify the build** + +```bash +npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/setup-remote.ts src/lib/pb/types.ts +git commit -m "feat(commits): add source and manual_minutes fields" +``` + +--- + +## Task 2: Manual minutes in `computeProjectMetrics` + +Pure, test-first. Implements the spec's "Metrics: where the hours go" section. + +**Files:** +- Modify: `src/lib/metrics/compute.ts` +- Modify: `src/lib/metrics/compute.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Append to `src/lib/metrics/compute.test.ts` (the file already has a `commit()` +helper and imports `computeProjectMetrics`, `MetricCommit`, +`DEFAULT_SESSION_CONSTANTS` — do not duplicate those): + +```ts +describe("computeProjectMetrics — manual entries", () => { + it("adds manual minutes to the total without clustering them", () => { + // One real commit alone forms one session: 0 span + 45 pre + 20 post = 65. + // The manual entry's 180 is added flat, regardless of how close its own + // timestamp sits to the real commit's — it is never fed to the clusterer. + const m = computeProjectMetrics( + [ + commit({ authoredAt: "2026-09-01T10:00:00Z" }), + commit({ manualMinutes: 180, authoredAt: "2026-09-01T10:05:00Z" }), + ], + DEFAULT_SESSION_CONSTANTS + ); + expect(m.estimatedMinutes).toBe(65 + 180); + expect(m.manualMinutes).toBe(180); + }); + + it("still counts a manual entry toward totals, activity and type", () => { + const m = computeProjectMetrics( + [commit({ manualMinutes: 60, type: "chore", scope: "", authoredAt: "2026-09-01T10:00:00Z" })], + DEFAULT_SESSION_CONSTANTS + ); + expect(m.totalCommits).toBe(1); + expect(m.activeDays).toBe(1); + expect(m.byType.find((t) => t.key === "chore")?.count).toBe(1); + }); + + it("folds a manual entry's minutes into its own month", () => { + const m = computeProjectMetrics( + [commit({ manualMinutes: 90, authoredAt: "2026-03-15T10:00:00Z" })], + DEFAULT_SESSION_CONSTANTS + ); + expect(m.monthly).toEqual([{ month: "2026-03", minutes: 90 }]); + }); + + it("computes identically to before when nothing is manual", () => { + const withoutField = computeProjectMetrics([commit({}), commit({})], DEFAULT_SESSION_CONSTANTS); + const withUndefined = computeProjectMetrics( + [commit({ manualMinutes: undefined }), commit({ manualMinutes: undefined })], + DEFAULT_SESSION_CONSTANTS + ); + expect(withUndefined).toEqual(withoutField); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +npm test +``` + +Expected: FAIL — `m.manualMinutes` is `undefined` (property does not exist +yet), and the monthly test fails because manual entries are not folded in. + +- [ ] **Step 3: Update the implementation** + +In `src/lib/metrics/compute.ts`, widen `MetricCommit`: + +```ts +export interface MetricCommit { + type: string; + scope: string; + authoredAt: string; + isMerge: boolean; + /** Present only for a manually logged entry; minutes, never clustered. */ + manualMinutes?: number; +} +``` + +Add `manualMinutes: 0` to the `empty` object (in the `ProjectMetrics` shape, +alongside `estimatedMinutes: 0,`), and add the field to `ProjectMetrics`: + +```ts +export interface ProjectMetrics { + totalCommits: number; + mergeCommits: number; + features: number; + fixes: number; + byType: Tally[]; + byScope: Tally[]; + firstCommit: string; + lastCommit: string; + calendarDays: number; + workdays: number; + activeDays: number; + activity: Record; + estimatedMinutes: number; + /** The portion of estimatedMinutes logged directly rather than estimated + * from commit timing. */ + manualMinutes: number; + sessionCount: number; + longestSessionMinutes: number; + longestSessionCommits: number; + longestSessionDay: string; + monthly: MonthlyEffort[]; + constants: SessionConstants; +} +``` + +Replace the session-clustering block: + +```ts + const sessions = clusterSessions( + real.map((c) => ({ authoredAt: c.authoredAt })), + constants + ); + const summary = summariseSessions(sessions); + + const monthlyMap = new Map(); + for (const session of sessions) { + const month = session.day.slice(0, 7); + monthlyMap.set(month, (monthlyMap.get(month) ?? 0) + session.minutes); + } +``` + +with: + +```ts + // A manually logged entry is a single declared block of time, not a series + // of commits with gaps to cluster — it is excluded from the clusterer and + // its minutes are added directly instead. + const clusterable = real.filter((c) => c.manualMinutes === undefined); + const sessions = clusterSessions( + clusterable.map((c) => ({ authoredAt: c.authoredAt })), + constants + ); + const summary = summariseSessions(sessions); + + const manualMinutes = real.reduce((sum, c) => sum + (c.manualMinutes ?? 0), 0); + + const monthlyMap = new Map(); + for (const session of sessions) { + const month = session.day.slice(0, 7); + monthlyMap.set(month, (monthlyMap.get(month) ?? 0) + session.minutes); + } + // A manual entry's minutes belong in its own month too, or the monthly + // chart would under-report a month relative to the KPI total that includes it. + for (const c of real) { + if (c.manualMinutes === undefined) continue; + const month = day(c.authoredAt).slice(0, 7); + monthlyMap.set(month, (monthlyMap.get(month) ?? 0) + c.manualMinutes); + } +``` + +Finally, in the return statement, change `estimatedMinutes: summary.totalMinutes,` to: + +```ts + estimatedMinutes: summary.totalMinutes + manualMinutes, + manualMinutes, +``` + +(add `manualMinutes,` as a new line right after `estimatedMinutes`). + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +npm test +``` + +Expected: PASS — all `compute.test.ts` tests, including the 4 new ones. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/metrics/compute.ts src/lib/metrics/compute.test.ts +git commit -m "feat(metrics): fold manually logged minutes into project totals" +``` + +--- + +## Task 3: Wire it into `recomputeMetrics`, and the methodology panel + +**Files:** +- Modify: `src/lib/metrics/store.ts` +- Modify: `src/components/dashboard/methodology.tsx` + +- [ ] **Step 1: Feed `manualMinutes` from the database** + +In `src/lib/metrics/store.ts`, change the `commits` query and mapping inside +`recomputeMetrics`: + +```ts + const commits = await pb.collection("commits").getFullList({ + filter: `project = "${projectId}"`, + fields: "type,scope,authored_at,is_merge,source,manual_minutes", + }); + + const metrics = computeProjectMetrics( + commits.map((c) => ({ + type: c.type ?? "other", + scope: c.scope ?? "", + authoredAt: c.authored_at, + isMerge: Boolean(c.is_merge), + manualMinutes: c.source === "manual" ? (c.manual_minutes ?? 0) : undefined, + })), + constantsFor(project) + ); +``` + +- [ ] **Step 2: State it in the methodology panel** + +In `src/components/dashboard/methodology.tsx`, add a paragraph after the +"Merge commits are excluded" one: + +```tsx +

+ Merge commits are excluded — a merge records when a branch was + integrated, not work done. + {metrics.mergeCommits > 0 && ( + <> + {" "} + {metrics.mergeCommits}{" "} + {metrics.mergeCommits === 1 ? "merge was" : "merges were"} left out. + + )} +

+ + {metrics.manualMinutes > 0 && ( +

+ {Math.round(metrics.manualMinutes / 60)}{" "} + {Math.round(metrics.manualMinutes / 60) === 1 ? "hour was" : "hours were"}{" "} + logged directly by the developer rather than estimated from commit + timing. +

+ )} +``` + +- [ ] **Step 3: Verify the build** + +```bash +npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/metrics/store.ts src/components/dashboard/methodology.tsx +git commit -m "feat(metrics): recompute reads manual entries, methodology states them" +``` + +--- + +## Task 4: Keep logged entries out of the AI batch summarizer + +A logged entry's message is written once, by the owner, at creation time. It +must never be picked up by the same pending-commits queue that re-summarizes +stale AI one-liners — that queue would treat `summary_version: 0` as stale and +overwrite the owner's own text. + +**Files:** +- Modify: `src/lib/ai/summarise.ts` + +- [ ] **Step 1: Exclude manual entries from `pendingCommits`** + +Change the filter in `pendingCommits`: + +```ts + return pb.collection("commits").getFullList({ + filter: [ + `project = "${projectId}"`, + "is_merge = false", + `source != "manual"`, + `(summary = "" || summary_version < ${PROMPT_VERSION})`, + `summary_attempts < ${MAX_ATTEMPTS}`, + ].join(" && "), + sort: "-authored_at", + fields: "id,subject,type,scope,summary_attempts", + }); +``` + +- [ ] **Step 2: Exclude manual entries from `summaryStatus`'s counts** + +Change `base` inside `summaryStatus`: + +```ts + const base = `project = "${projectId}" && is_merge = false && source != "manual"`; +``` + +- [ ] **Step 3: Verify the build** + +```bash +npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/ai/summarise.ts +git commit -m "fix(ai): never queue a logged entry for AI re-summarization" +``` + +--- + +## Task 5: A single-shot AI polish helper + +**Files:** +- Create: `src/lib/ai/polish.ts` + +- [ ] **Step 1: Write the helper** + +`src/lib/ai/polish.ts`: + +```ts +import "server-only"; + +import { getProvider, getSystemPrompt, AiError } from "@/lib/ai"; + +/** Longer than this and the model has ignored the "one or two sentences" instruction. */ +const MAX_POLISHED_LENGTH = 600; + +/** + * Rewrite a single owner-typed note in the same voice as a commit summary. + * + * Unlike summarise.ts's summariseProject, this is not batch- or DB-coupled — + * it is one piece of text being composed interactively in a form, so it is + * one direct provider call rather than the pending-rows pipeline built for + * re-summarizing hundreds of ingested commits at once. + */ +export async function polishNote(text: string): Promise { + const provider = await getProvider(); + const system = await getSystemPrompt(); + + const result = await provider.complete({ + system, + user: `Rewrite the note below as one or two plain-English sentences for the client, in the same voice you use for commit summaries. Stay grounded in exactly what is written — do not invent detail, dates, or claims of completion beyond what the note itself says. Reply with only the rewritten text, nothing else. + +Note: +${text.trim()}`, + // 16k, not a number that looks sufficient for two sentences — see the + // note in summarise.ts about MiniMax's inline reasoning being billed + // against max_tokens. A too-low cap here would silently return an + // empty string with no error, exactly as it did for commit summaries. + maxTokens: 16384, + }); + + const polished = result.text.trim(); + if (!polished) { + throw new AiError("The assistant returned nothing usable", true); + } + + return polished.slice(0, MAX_POLISHED_LENGTH); +} +``` + +- [ ] **Step 2: Verify the build** + +```bash +npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/ai/polish.ts +git commit -m "feat(ai): single-shot note polishing for logged work entries" +``` + +--- + +## Task 6: The server actions + +**Files:** +- Create: `src/app/actions/log-work.ts` + +- [ ] **Step 1: Write the actions** + +`src/app/actions/log-work.ts`: + +```ts +"use server"; + +import { randomUUID } from "node:crypto"; +import { revalidatePath } from "next/cache"; + +import { requireSession } from "@/lib/auth/session"; +import { getAdminPb } from "@/lib/pb/admin"; +import { recomputeMetrics } from "@/lib/metrics/store"; +import { aiConfigured } from "@/lib/ai/settings"; +import { polishNote } from "@/lib/ai/polish"; +import { AiError } from "@/lib/ai"; +import type { CommitRecord } from "@/lib/pb/types"; + +const MAX_MESSAGE_LENGTH = 2000; +const MAX_HOURS = 24; +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; + +/** See the security note at the top of src/app/actions/cards.ts. */ +async function requireOwner() { + const session = await requireSession(); + if (session.role !== "owner") throw new Error("Not found"); + return session; +} + +/** + * Log a block of off-repo work as a commits row. + * + * Writes through the superuser connection, not the owner's own token — + * `commits.createRule` is `null` (superuser-only) because every OTHER row in + * this collection comes from the ingestion pipeline, which already writes + * this way. The owner-only check above is what stands in for a collection + * rule here, exactly as it does for every client-manager write in this app. + */ +export async function logWorkAction(input: { + projectId: string; + clientSlug: string; + projectSlug: string; + date: string; + hours: number; + message: string; + cardId?: string; +}): Promise<{ ok: boolean; message?: string }> { + await requireOwner(); + + const message = input.message.trim().slice(0, MAX_MESSAGE_LENGTH); + if (!message) return { ok: false, message: "A message is required" }; + + if (!Number.isFinite(input.hours) || input.hours <= 0 || input.hours > MAX_HOURS) { + return { ok: false, message: `Hours must be between 0 and ${MAX_HOURS}` }; + } + + if (!ISO_DATE.test(input.date)) { + return { ok: false, message: "Invalid date" }; + } + + const pb = await getAdminPb(); + const cardId = input.cardId ?? ""; + + await pb.collection("commits").create({ + project: input.projectId, + // Guaranteed not to collide with a real 40-character git sha, and unique + // enough to satisfy the (project, sha) index every other commit relies on. + sha: `manual-${randomUUID()}`, + short_sha: "manual", + message, + subject: message, + type: "", + scope: "", + author_name: "", + author_email: "", + authored_at: `${input.date} 12:00:00.000Z`, + is_merge: false, + additions: 0, + deletions: 0, + card: cardId, + // Reuses the existing meaning of "manual" on link_source: a card link the + // owner chose by hand, not one the linking rules derived. + link_source: cardId ? "manual" : "none", + url: "", + summary: message, + summary_version: 0, + summary_attempts: 0, + source: "manual", + manual_minutes: Math.round(input.hours * 60), + }); + + // So the dashboard's KPIs reflect it on the very next load, not only after + // the owner separately clicks Recompute. + await recomputeMetrics(input.projectId); + + revalidatePath(`/p/${input.clientSlug}/${input.projectSlug}/dashboard`); + return { ok: true }; +} + +export async function polishNoteAction(input: { + text: string; +}): Promise<{ ok: boolean; text?: string; message?: string }> { + await requireOwner(); + + const text = input.text.trim().slice(0, MAX_MESSAGE_LENGTH); + if (!text) return { ok: false, message: "Nothing to polish yet" }; + + if (!(await aiConfigured())) { + return { ok: false, message: "No AI provider is configured" }; + } + + try { + const polished = await polishNote(text); + return { ok: true, text: polished }; + } catch (error) { + return { + ok: false, + message: error instanceof AiError ? error.message : "The assistant is unavailable", + }; + } +} +``` + +- [ ] **Step 2: Verify the build** + +```bash +npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add src/app/actions/log-work.ts +git commit -m "feat(dashboard): logWorkAction and polishNoteAction" +``` + +--- + +## Task 7: The "Logged" tag in the two commit render paths + +**Files:** +- Modify: `src/components/dashboard/recent-activity.tsx` +- Modify: `src/components/card/commit-history.tsx` + +- [ ] **Step 1: Tag it in the dashboard's recent-work feed** + +Replace `src/components/dashboard/recent-activity.tsx`: + +```tsx +import { relativeTime } from "@/lib/time/relative"; +import type { CommitRecord } from "@/lib/pb/types"; + +export function RecentActivity({ commits }: { commits: CommitRecord[] }) { + if (commits.length === 0) return null; + + return ( +
+

+ Recent work +

+ +
    + {commits.map((commit) => ( +
  1. +

    + {commit.summary || commit.subject} +

    + {commit.source === "manual" && ( + + Logged + + )} + + {relativeTime(commit.authored_at)} + +
  2. + ))} +
+
+ ); +} +``` + +- [ ] **Step 2: Tag it in a card's commit history, and hide the raw-detail toggle for a manual row** + +In `src/components/card/commit-history.tsx`, change the row rendering inside +the `
    `: + +```tsx +
      + {commits.map((commit) => ( +
    1. + +
      +
      +

      + {/* Fall back to the raw subject — never render nothing. */} + {commit.summary || commit.subject} +

      + {commit.source === "manual" && ( + + Logged + + )} +
      +

      + {relativeTime(commit.authored_at)} +

      + + {/* No real diff or hash sits behind a logged entry — showing one + anyway, even honestly labelled, would be the fabrication this + feature deliberately avoids. */} + {showRaw && commit.source !== "manual" && ( +

      + {commit.url ? ( + + {commit.short_sha} + + ) : ( + commit.short_sha + )}{" "} + {commit.subject} +

      + )} +
      +
    2. + ))} +
    +``` + +The rest of the file (the `showRaw` state, the header with the change count +and toggle button) is unchanged. + +- [ ] **Step 3: Verify the build** + +```bash +npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add src/components/dashboard/recent-activity.tsx src/components/card/commit-history.tsx +git commit -m "feat(dashboard): tag logged entries, hide fabricated raw detail" +``` + +--- + +## Task 8: The "Log work" button and form + +**Files:** +- Create: `src/components/dashboard/log-work-panel.tsx` +- Modify: `src/app/p/[clientSlug]/[projectSlug]/dashboard/page.tsx` + +- [ ] **Step 1: Write the panel** + +`src/components/dashboard/log-work-panel.tsx`: + +```tsx +"use client"; + +import { useState, useTransition } from "react"; +import { Plus, Sparkles } from "lucide-react"; + +import { logWorkAction, polishNoteAction } from "@/app/actions/log-work"; +import type { CardRecord } from "@/lib/pb/types"; + +/** + * The owner's way to put off-repo work onto the dashboard. + * + * Follows the same inline-expand pattern as NewProjectForm and OwnerAdd + * elsewhere in this app, rather than introducing a modal/dialog component + * that does not otherwise exist in the codebase. + */ +export function LogWorkPanel({ + projectId, + clientSlug, + projectSlug, + cards, + assistantAvailable, +}: { + projectId: string; + clientSlug: string; + projectSlug: string; + cards: CardRecord[]; + assistantAvailable: boolean; +}) { + const [open, setOpen] = useState(false); + const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10)); + const [hours, setHours] = useState(""); + const [message, setMessage] = useState(""); + const [cardId, setCardId] = useState(""); + const [pending, startTransition] = useTransition(); + const [polishing, setPolishing] = useState(false); + const [error, setError] = useState(null); + + if (!open) { + return ( + + ); + } + + const field = + "w-full rounded-lg border border-border-subtle bg-panel px-3 py-2 text-sm text-text-primary outline-none focus:border-brand"; + + function submit(event: React.FormEvent) { + event.preventDefault(); + const parsedHours = Number(hours); + if (!message.trim() || !Number.isFinite(parsedHours) || parsedHours <= 0) return; + setError(null); + + startTransition(async () => { + const result = await logWorkAction({ + projectId, + clientSlug, + projectSlug, + date, + hours: parsedHours, + message, + cardId: cardId || undefined, + }); + + if (!result.ok) { + setError(result.message ?? "Could not log that work"); + return; + } + + setOpen(false); + setHours(""); + setMessage(""); + setCardId(""); + }); + } + + async function polish() { + if (!message.trim()) return; + setPolishing(true); + setError(null); + try { + const result = await polishNoteAction({ text: message }); + if (result.ok && result.text) setMessage(result.text); + else setError(result.message ?? "Could not polish that note"); + } finally { + setPolishing(false); + } + } + + return ( +
    +
    + + + +
    + +