kanban-dev-webapp/docs/superpowers/specs/2026-09-19-phase-7-logged-work-entries-design.md
Dave 1cb05f4689 docs(spec): Phase 7 — logged work entries
Design for letting the owner record off-repo work as a commits row
tagged source=manual, reusing every existing commit render path
(recent work, card history, activity/monthly aggregation) instead of
a parallel system, with a declared-hours figure added to the total
rather than session-clustered, and a small "Logged" tag distinguishing
it from a real commit everywhere it appears.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 08:10:55 +02:00

8 KiB

Phase 7: Logged Work Entries — Design

Problem

The dashboard's hours and activity figures are entirely derived from git commit timestamps. Real work that never touches the repo — third-party configuration, meetings that produced a decision, non-code work done for a client — is invisible to it, even though it happened.

Goals

  • Let the owner record a block of off-repo work with a message, a date, and a declared number of hours.
  • Have it appear alongside real commits everywhere commits already appear — the dashboard's recent-work feed, the activity heatmap, monthly totals, and (optionally) a card's commit history — by reusing that machinery rather than building a parallel one.
  • Keep the totals honestly explainable. A logged entry's hours are exactly what the owner typed, added to the total, and the methodology panel says so.
  • Keep it visually distinguishable from a real commit. A small "Logged" tag replaces where a commit hash would be — there is no diff behind these entries, and the dashboard does not pretend there is.

Non-goals

  • No new collection. Logged entries are rows in the existing commits collection, distinguished by one new field, so every existing render path (recent work, card commit history, activity/monthly/type aggregation) works on them for free.
  • No session-clustering for logged entries. A single declared block of time cannot be inferred from timestamp gaps the way real commit sessions are — the owner's declared hours are used directly instead.
  • No batch AI summarization queue for these. The owner polishes or writes the message once, at creation time; it never enters the same pending-commits queue real ingested commits use, and is never rewritten later.
  • No editing or deleting a logged entry after creation in this phase. If one is wrong, deleting it directly in the database is an acceptable stopgap — an edit/delete UI can be a follow-up if it turns out to be needed often.

Data model

Two additions to the commits collection:

  • source: select, "git" | "manual", default "git". Backfilled to "git" for every existing row, so nothing already ingested is misclassified.
  • manual_minutes: number, only set (and only meaningful) when source = "manual".

A logged entry otherwise reuses existing commits fields: subject/summary carry the message (identical to how a real commit's one-liner is displayed), authored_at is the date the owner picks, card is the existing optional relation to a card (reused as-is — a logged entry can be linked to a card exactly like a real commit can), type/scope are optional and left blank unless the owner wants the entry counted in the type/feature-area breakdowns.

sha still has to be non-empty and unique per project (existing unique index (project, sha)), so a logged entry is given a synthetic value like manual-<random 12 hex chars> — guaranteed not to collide with a real 40-hex git sha. is_merge is always false. link_source is set to "manual" (joining the existing CommitLinkSource union) so a card's linked-commit query does not need to special-case anything to include it.

Metrics: where the hours go

computeProjectMetrics (src/lib/metrics/compute.ts) currently takes MetricCommit[] — { type, scope, authoredAt, isMerge } — and is pure (no database, no clock). It gains one more optional field:

export interface MetricCommit {
  type: string;
  scope: string;
  authoredAt: string;
  isMerge: boolean;
  /** Present only for a manually logged entry. */
  manualMinutes?: number;
}

Every MetricCommit — git-derived or manual — still feeds the byType, byScope, activity (heatmap), and monthly aggregations exactly as today, since those only ever read type/scope/authoredAt/isMerge. Only the session-clustering call is filtered to git-derived entries:

const clusterable = commits.filter((c) => c.manualMinutes === undefined);
const sessions = clusterSessions(clusterable.map((c) => ({ authoredAt: c.authoredAt })), constants);
const manualMinutes = commits.reduce((sum, c) => sum + (c.manualMinutes ?? 0), 0);

estimatedMinutes becomes the clustered total plus manualMinutes. Nothing about the clustering math changes for real commits — a project with zero logged entries computes byte-for-byte the same numbers it does today.

src/lib/metrics/store.ts (recomputeMetrics) is the only caller; it already queries commits with a fields projection, which gains source and manual_minutes. Both manual_minutes (stored) and manualMinutes (the MetricCommit field) are plain minutes throughout — the same unit estimatedMinutes already uses — so no conversion happens anywhere in this path; the value the owner typed in hours is only ever multiplied by 60, once, in the form's submit handler before it reaches the server action.

src/components/dashboard/methodology.tsx gains one line when manualMinutes > 0: stating how many hours were logged directly rather than estimated from commit timing. This is the "keep it explainable" requirement — the panel already exists specifically to state how the numbers were produced.

Rendering

src/components/dashboard/recent-activity.tsx and src/components/card/commit-history.tsx both currently type their prop as CommitRecord[] and read commit.summary || commit.subject, which already works unmodified for a manual row. Each gains one conditional: when commit.source === "manual", render a small tag reading "Logged" (its own distinct color, not reused from an existing status color, so it reads as its own category at a glance) in the position a hash or icon would otherwise go. In CommitHistory, the "Show technical detail" toggle (which displays short_sha and links via commit.url) is hidden entirely for a manual row — there is no real diff or hash behind it, and showing a fabricated one would reintroduce the exact problem this design avoids.

Creating an entry

A new "Log work" button on the dashboard TopBar, beside the existing Refresh/Recompute buttons, following the same pattern (src/components/dashboard/recompute-button.tsx): owner-gated, a client component with useTransition, calling a new server action.

The action, logWorkAction in src/app/actions/log-work.ts: requires an owner session (same requireOwner() guard used throughout src/app/actions/*.ts), validates the date and hours, generates the synthetic sha, and creates the commits row directly.

The form itself: a date field (defaults to today), an hours field (a plain number — "3.5" — per the earlier decision to keep this a direct declared figure rather than inferring it from a start/end time), a message textarea, and an optional "Polish with AI" button. That button calls a new, small, single-shot helper — polishNote(text: string): Promise<string> in src/lib/ai/polish.ts — built from the same provider/system-prompt/ stripThinking pieces summarise.ts already uses, but as one direct provider.complete() call rather than the batch/DB-coupled pipeline summariseProject is built around (that pipeline reads pending rows from the database and writes results back in batches; a manual note is a single piece of text being composed interactively, so it needs the simpler shape). Per the max_tokens lesson from Phase 3 (docs/superpowers/plans/... — MiniMax's reasoning is billed against the cap), this call uses maxTokens: 16384, not a smaller number that looks sufficient for one sentence.

An optional card-link dropdown lets the owner attach the entry to a specific card, reusing the same relation real commits use — entirely optional, and omitted it just means the entry only shows up in the project-level feed, not on any card.

Permissions

Unchanged from the rest of the app. Creating a logged entry is owner-only, enforced server-side in logWorkAction, the same way every other owner-restricted write is. A client manager's read access to commits is already scoped by the existing Phase 1 row rules; a logged entry is visible to them under the exact same rule a real commit is, with no new rule and no new exception.