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:
parent
2d87cd505b
commit
1cb05f4689
1 changed files with 162 additions and 0 deletions
|
|
@ -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.
|
||||
Loading…
Reference in a new issue