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>
This commit is contained in:
Dave 2026-09-19 08:10:55 +02:00
parent 2d87cd505b
commit 1cb05f4689

View file

@ -0,0 +1,162 @@
# 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:
```ts
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:
```ts
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.