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>
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
commitscollection, 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) whensource = "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.