The activity timeline is the GitHub-style vertical-rail event log on the transaction detail page. It is the canonical visualisation of a transaction's audit trail — every comment, tag change, category set, rule application, and sync event for a single transaction renders as a row threaded onto a continuous left rail, with a composer at the bottom for typing the next comment.
This document captures the rendering contract, CSS invariants, dedup rules, soft-delete behaviour, optimistic-update strategy, and the recipe for adding a new system-event kind. It exists so future timeline-shaped surfaces (sync-log detail, agent-run logs, etc.) can reuse the same component without rediscovering the gotchas baked into the transaction detail implementation.
Cross-references to the canonical specs:
- Schema and enums:
docs/data-model.md(annotation kinds live in theannotations.kindCHECK constraint). - Service-layer conventions for the
service.Annotationshape:.claude/rules/service.md. - UI invariants for daisyUI / Tailwind / Alpine (rail-agnostic):
docs/design-system.mdand.claude/rules/ui.md.
/transactions/{id}— the activity card under the main transaction body. This is the canonical per-event implementation and the original consumer.- The home Feed (now served at
/) renders the global, grouped view of the same shape: sync runs, agent reports, MCP agent sessions, bulk-action bursts, and standalone comments threaded onto one rail across the whole household. It composesTimelineSystemRowCustomTileandTimelineCommentRowRawfrom the shared primitives and never forks the row chrome. - Future: sync-log detail and agent-run logs are the obvious next reuse targets. They share the same row-on-rail shape and should reuse the shared primitives below rather than fork them. See "Shared primitives" next, and "Future extensions" for the surfaces still on the roadmap.
The reusable layout primitives live in:
internal/templates/components/timeline.templ— the templ components.internal/templates/components/timeline_helpers.go— local helpers (icon-tone palette, timestamp formatters, heading-id derivation).
Exported surface (data-shape-agnostic — callers pass templ children for sentence bodies, custom tiles, and comment markup):
| Component | Purpose |
|---|---|
Timeline |
Section wrapper: heading + event count + the <ol> rail container. |
TimelineDay |
Horizontal day separator; dot anchored on the rail at 24px tile size. |
TimelineSystemRow |
One-liner row with icon tile + sentence body + optional timestamp. |
TimelineSystemRowCustomTile |
Same row chrome, but the caller renders the entire 24px tile. |
TimelineCommentRow |
Comment bubble row with default <actor> commented · <time> header. |
TimelineCommentRowRaw |
Comment row escape hatch — caller renders the full meta line + body. |
TimelineEmpty |
"No activity yet" treatment (icon + muted message). No rail wrapper. |
Supporting prop types: TimelineProps, TimelineRowProps, TimelineActor.
The primitives intentionally cover only the rail / tile / day-separator
chrome and the timestamp suffix. They do not know about service.Annotation,
service.ActivityEntry, or any domain enum — callers compose row content via
templ children. The IconTone palette (neutral / info / success /
warning / error) maps to the same Tailwind shades the txn-detail tiles
already use; callers that need a more bespoke tile (e.g. category color +
icon, review-status tint) drop down to TimelineSystemRowCustomTile.
A day-grouped feed of system events using Timeline + TimelineDay +
TimelineSystemRow:
templ MyFeed(p MyFeedProps) {
@components.Timeline(components.TimelineProps{
Heading: "Activity", HeadingIcon: "activity",
EventCount: len(p.Events), AriaLabel: "Activity timeline",
}) {
for _, day := range p.Days {
@components.TimelineDay(day.Label, day.First)
for _, e := range day.Events {
@components.TimelineSystemRow(components.TimelineRowProps{
Icon: "zap", IconTone: "info",
Timestamp: e.Timestamp, Now: p.Now,
}) {
<strong>{ e.Actor }</strong>
<span>{ " " + e.Verb + " " }</span>
<span class="font-medium">{ e.Subject }</span>
}
}
}
}
}The caller owns the day-bucketing logic (whatever shape its events come in) and the per-row sentence; the primitives own the rail centring, the 24px tile geometry, the day-separator dot, and the relative-time pill.
/transactions/{id} still ships its own txd* helpers
(txdTimelineSystem, txdSystemSentence, txdSystemIcon,
txdTimelineComment, txdTimelineCommentBubble,
txdTimelineDeletedComment, txdTimelineComposer) on top of the
primitives' chrome. Those helpers carry the page-local sentence rendering,
icon-kind switch, tombstone branching, and composer — see "Page-local
responsibilities" below for the carve-out. Migrating the txn-detail page
fully onto TimelineSystemRow / TimelineCommentRow is tracked as a
follow-up; until then, treat the primitives as the canonical reuse layer
for new timeline-shaped surfaces and the txd* helpers as the
canonical consumer of the row chrome.
The primitives intentionally don't handle:
- Tombstone rendering. The
IsDeletedbranch intxdTimelineComment(PR #892) lives at the page layer because the dedup contract requiresEnrichAnnotationsupstream — see "Tombstones never fold" below. Tombstones aren't a styling decision; they're a forensic-audit invariant that has to flow through the service layer first. - System-row sentence formatting.
txdSystemSentence,txdRuleSentence, and the per-kind verb/subject composition are page-local because each kind's wording is domain-specific (rule names, tag chips, category links, sync provider labels). The primitives accept a children slot precisely so each consumer can render its own phrasing. - Icon mapping.
txdSystemIconswitches one.Typeto pick a Lucide glyph + tile shade per kind. That mapping is page-local because each kind's icon depends on domain context (category icon, rule zap, sync landmark, etc.).TimelineRowProps.IconTonecovers the common 5-tone palette; for anything kind-driven, drop intoTimelineSystemRowCustomTileand render the tile yourself.
If you find yourself wanting to push any of these into the primitives, stop — the primitives are layout only. The reuse boundary is "rail chrome stays shared, sentence/icon/tombstone branching stays page-local."
service.Annotation (in internal/service/annotations.go) is the canonical DB
projection of a single timeline event:
- Structural columns:
Kind,ActorType,ActorID,ActorName,CreatedAt,Payload,TagID,RuleID,IsDeleted. - Derived fields populated by
EnrichAnnotations:Action,Summary,Subject,Origin,Source,Note,Content,TagSlug,CategorySlug,RuleName. ActorAvatarVersion— unix timestamp ofusers.updated_atfor the actor, used as a?v=<ts>cache-buster on avatar URLs.
service.ActivityEntry (defined alongside Annotation) is the UI projection
the templ component reads. The admin handler converts each enriched
Annotation to an ActivityEntry in activityEntryFromAnnotation
(internal/admin/transactions.go). The conversion adds presentation-only
fields the service layer doesn't know about: TagColor, CategoryColor,
CategoryIcon, ReviewStatus, and a normalised Type value used for the
if e.Type == "..." branches in the templ.
Today's Type values:
| Type | Source kind(s) | Renderer |
|---|---|---|
comment |
comment |
txdTimelineComment |
tag |
tag_added, tag_removed |
txdTimelineSystem |
category |
category_set |
txdTimelineSystem |
rule |
rule_applied |
txdTimelineSystem |
sync |
sync_started, sync_updated |
txdTimelineSystem |
series |
series_assigned, series_unlinked |
txdTimelineSystem |
counterparty |
counterparty_assigned, counterparty_unlinked |
txdTimelineSystem |
review |
(legacy, retained for fallback) | txdTimelineSystem |
Both series and counterparty collapse their two source kinds onto a
single UI Type (the icon is identical; the prebuilt Summary from
EnrichAnnotations differentiates the verb — repeat for series, store
for counterparty). They render via the txdSystemSentence Summary-fallback
branch, the same path rule and sync use.
The transaction_deleted and transaction_restored kinds are written by the REST API soft-delete / restore endpoints (internal/service/transactions_lifecycle.go) but are not yet rendered by activityEntryFromAnnotation — the renderer drops them as unknown. Surfacing them in the timeline is a known follow-up. Until then they're visible to MCP via list_annotations (Raw: true or filtered by kind) and via the raw annotations table for audit.
syncEntryType collapses both DB-level sync_* kinds onto a single sync
type because they share an icon (landmark) and the differentiated verb
already lives on the Summary string.
IsDeleted is preserved on the comment entry — it gates the tombstone branch
in txdTimelineComment (see "Comment soft-delete" below).
Unknown / unrecognised kinds are dropped at this layer:
activityEntryFromAnnotation returns (zero, false) for kinds it doesn't
recognise. The caller skips them. This keeps the timeline forward-compatible
with kinds added by other workers as long as the renderer is updated in the
same release.
groupActivityByDay (in internal/admin/transactions.go) groups the sorted
entry list into per-day buckets in the server's local timezone. It expects the
input slice to already be sorted ascending (oldest first) — that is what
buildActivityTimeline produces:
sort.Slice(entries, func(i, j int) bool {
return entries[i].Timestamp < entries[j].Timestamp
})The composer sits at the bottom of the timeline, so newer events appear where the user typed them. This is the inverse of the convention many activity feeds use; the choice is deliberate — chat-style "new at the bottom" reads better when the primary interaction is composing a comment.
Each bucket emits an ActivityDayGroup:
type ActivityDayGroup struct {
Date string // ISO date, e.g. "2026-04-16"
Label string // "Today", "Yesterday", "Thursday, April 16"
Events []service.ActivityEntry // newest first within the day, ASC overall
}Day labels (activityDayLabel):
- Same calendar day as
now->Today - Previous calendar day ->
Yesterday - Same year, older ->
Monday, April 14 - Different year ->
Monday, April 14, 2025
Entries with unparseable timestamps are silently dropped rather than mis-bucketed. The few that hit this path are bugs in the writer; surfacing them on the timeline as "no timestamp" rows would be worse than dropping.
The composer renders after all day groups via txdTimelineComposer. When
there are no entries at all, txdTimelineEmptyComposer takes over and
removes the <ol> wrapper entirely so we don't render an empty rail.
TransactionDetailProps.Now is the single time.Time captured at the top of
TransactionDetailHandler:
now := time.Now()
activity := buildActivityTimeline(annotations, ...)
activityDays := groupActivityByDay(activity, now)
// ... props.Now = nowBoth day-bucket labels (Today / Yesterday) and per-row relative
timestamps read from the same anchor. This matters whenever a render begins
just before midnight and the bottom of the page paints just after — without a
shared anchor, the day group says Yesterday while a row inside it says
5 minutes ago, or vice versa.
The cross-cutting helper is timefmt.RelativeAt(t, now) in
internal/timefmt/timefmt.go — a pure function with the same now anchor
threaded in. Buckets:
< 1 minute->just now< 1 hour->N minute(s) ago< 1 day->N hour(s) ago>= 1 day->N day(s) ago
The templ component calls a thin wrapper, relativeTimeStrAt(s, now), which
parses the RFC3339 string off the entry and delegates to RelativeAt. Future
timeline surfaces should follow this exact pattern: capture one now in the
handler, thread it through both grouping and per-row formatting, and never
call time.Now() from inside the templ.
The same contract is wired through the shared primitives so day-grouped callers don't have to reinvent it:
TimelineSystemRowreadsProps.Nowand passes it to the relative-time helper for the row's timestamp pill.TimelineSystemRowCustomTileandTimelineCommentRowacceptnowas a positional parameter for the same reason.- The internal
relativeTimelineTimestamphelper delegates totimefmt.RelativeRFC3339At(s, now). A zerotime.Timefalls back to wall-clocktime.Now()at render — that's the contract for non-day-grouped callers (single-entry log rows, sidebars without buckets), so they can drop the primitives in without ceremony.
The wiring rule for any day-grouped surface is the same as the txn-detail page:
- Capture
now := time.Now()once in the handler. - Pass
nowinto the day-bucketing helper (whatever yields theToday/Yesterday/Monday, April 14labels). - Pass the same
nowinto every row primitive —TimelineRowProps.Now,TimelineSystemRowCustomTile'snowargument,TimelineCommentRow'snowargument. - Never call
time.Now()from inside the templ.
Skip step 3 and a render that begins just before midnight will paint a
Yesterday separator above a 5 minutes ago row — exactly the bug the
shared anchor is there to prevent.
The rail and rows are tuned together; changing one usually breaks the other. The invariants below are what makes every icon centre sit dead-on the rail.
<span class="absolute left-[28px] sm:left-[32px] top-2 bottom-12 w-px bg-base-200" aria-hidden="true"></span>left-[28px](mobile) /sm:left-[32px](>=640px) is container padding + tile half-width: the<ol>haspl-4 sm:pl-5(16px / 20px) and every row's icon tile is 24px wide (half = 12px). 16 + 12 = 28; 20 + 12 = 32. If you change the container padding or the tile size, the rail'sleftoffsets must change in lockstep.top-2 bottom-12anchors the rail so it threads through every row tile and through the composer below — but stops short of the bottom edge so it doesn't visually escape the card.w-px bg-base-200keeps the rail at 1px regardless of zoom, in a colour that adapts to dark mode automatically.
Every row's first child is a 24px opaque tile:
<div class="relative z-10 shrink-0 w-6 h-6 rounded-full bg-base-100">
...icon...
</div>bg-base-100 is critical: the tile is opaque by design so it visually
masks the rail behind it, producing the "rail enters the tile, doesn't
exit" silhouette that gives the timeline its rhythm. Inside the tile the
icon span itself adds ring-4 ring-base-100 to draw a soft halo around the
coloured pill — that ring is what hides the rail seam without an extra
masking element.
The text container next to the tile uses leading-6 (24px line-height):
<div class="flex-1 min-w-0 text-xs leading-6 text-base-content/75 break-words">
...sentence...
</div>A 24px line-box gives the first line of text the same vertical centre as the 24px icon tile next to it, so the actor name reads horizontally aligned with the rail circle without per-element margin tweaks. Comment bubbles preserve this on the meta line for the same reason.
<li class="relative flex items-center gap-3 select-none ..." role="separator" aria-label="...">
<div class="relative z-10 shrink-0 w-6 h-6 flex items-center justify-center" aria-hidden="true">
<span class="w-3 h-3 rounded-full bg-base-300 ring-4 ring-base-100"></span>
</div>
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/40">{ label }</h3>
<span class="flex-1 h-px bg-base-200" aria-hidden="true"></span>
</li>Note the dot lives inside the same 24px tile geometry as every other row — it
sits on the rail by construction, not by a manual top: ...px tweak. The
first day's separator gets pt-1 pb-3; later days get pt-5 pb-3 so groups
breathe.
Relative timestamps wrap a daisyUI tooltip with the absolute time as the tip:
<span class="tooltip tooltip-top" data-tip={ formatDateTimeStr(e.Timestamp) }>
<time
datetime={ e.Timestamp }
class="tabular-nums cursor-default hover:underline underline-offset-2 decoration-base-content/30"
title={ formatDateTimeStr(e.Timestamp) }>
{ relativeTimeStrAt(e.Timestamp, now) }
</time>
</span>The hover affordances — hover:underline underline-offset-2 decoration-base-content/30 cursor-default — exist to communicate "this is
hover-y" without pointer-fingering it as a link. tabular-nums keeps numeric
widths stable so neighbouring rows don't jitter when their relative-time
string changes length.
Enrichment is a pure transformation in internal/service/annotations_enrich.go
that runs on every list of annotations before they reach the UI (or MCP). It
does three things:
-
Drop rule-source structural rows. When a rule fires (during sync or a retroactive apply), the engine writes a
rule_appliedannotation and a structural side-effect row —tag_added/tag_removed/category_set, or acounterparty_assigned/counterparty_unlinked/series_assigned/series_unlinkedmembership row — carryingpayload.source = "rule". Therule_appliedrow is the canonical audit record; its rule-source siblings are noise.isRuleSourceDuplicatefilters them out. Comments andrule_applieditself are never deduped here — only structural side-effects flagged withsource: "rule". The membership kinds were added after the original dedup and were initially omitted, which surfaced a rule-driven counterparty/series assignment as two adjacent feed rows (therule_appliedrow plus a system-actor "Breadbox assigned …" row) — issue #1915. When you add a new rule-write path, stampsource: "rule"on its side-effect rows and add the kind toisRuleSourceDuplicate. -
Drop adjacent same-actor comment-vs-tag-note duplicates. The MCP
update_transactionstool can write atag_addedwithpayload.notealongside a standalonecommentwith the same body, both in one call. The note already inlines the rationale on the tag row; the parallel comment is redundant.isCommentDuplicateOfTagNotecollapses them within a 2-second window when actor identity matches (preferringActorID, falling back toActorNamefor system actors). -
Compute derived fields. Action, Summary, Subject, Origin, Source, Note, Content, TagSlug, CategorySlug, RuleName are all derived per kind in
enrichOne. Unknown kinds round-trip with empty derived fields rather than being dropped — keeps the timeline forward-compatible with new kinds that haven't shipped a UI branch yet.
This is the PR 4 invariant: a soft-deleted comment is never deduped away.
Even if the same actor wrote a tag_added.note adjacent to a now-tombstoned
comment, the tombstone survives because it carries audit value of its own
("Alice deleted a comment at 14:32"). The check lives at the top of step 2:
if src.Kind == "comment" && !src.IsDeleted && isCommentDuplicateOfTagNote(in, src) {
continue
}If you find yourself extending dedup logic, preserve this invariant. Tombstones are forensic; they exist precisely because the body is gone.
PR 4 of activity-log v2 introduced soft-delete for transaction comments. Salient points:
-
The DB row is not removed.
annotations.deleted_atis set; the rest of the row stays put. -
annotationFromActorRowreadsDeletedAt.Validand setsIsDeleted = trueon the projectedAnnotation. -
Enrichment overrides
Summaryto the tombstone phrase (formatDeletedCommentSummary:"<Actor> deleted a comment"or"Comment deleted"for anonymous actors), and clears the original body (a.Detail = ""in the admin mapper) so the retired content never re-renders. -
The
CommentIDshort_id stays populated even on tombstones — the optimistic update path uses it to identify which bubble to swap. The bubble's trash button is gated onCommentID != "" && !IsDeleted, so retaining the ID here doesn't accidentally re-surface the delete affordance. -
The templ entry point
txdTimelineCommentbranches at the top:templ txdTimelineComment(e service.ActivityEntry, now time.Time) { if e.IsDeleted { @txdTimelineDeletedComment(e, now) } else { @txdTimelineCommentBubble(e, now) } }
The deleted variant mirrors the system-row layout (24px tile, single muted line) so a tombstoned comment reads like a system event preserving who removed what and when, without re-displaying retired content.
PR 5 of activity-log v2 replaced the post-mutation location.reload() with
in-place row inserts. The strategy is documented at the top of
static/js/admin/components/transaction_detail.js; the contract:
- The server is the single source of truth for row markup. There is no client-side row template in JS.
- After every mutation (POST/PATCH/DELETE), the JS
GETs/-/transactions/{id}/timeline/rows?since=<lastTs>to fetch the rendered HTML for the rows that were just written. - The handler reuses
buildActivityTimelineand the sametxdTimelineDay/txdTimelineSystem/txdTimelineCommenttempl helpers as the main page render, so partial rows are byte-equivalent to the full-page render. No drift, no parallel renderer to keep in sync.
TimelineRowsHandler (internal/admin/transactions.go) accepts:
since(RFC3339) — return entries withTimestamp > since. An empty or missingsincereturns no entries (sentinel for "first load — page already has every row").comment_ids— comma-separated list of comment short_ids. Comments in this set are returned even when theirTimestampis older thansince. This is the soft-delete tombstone path:is_deletedflips on an existing row, the row'sCreatedAtstays in the past, and the JS asks the endpoint to render the tombstone variant for that specific ID.
The response is text/html with <li> rows (and an optional preceding
<li> day separator when the new rows fall on a different calendar day from
the most recent prior entry — or when the page had no prior entries). The
JS unwraps the fragment and inserts each <li> immediately before the
composer.
The data-last-activity-ts attribute on the #activity section seeds the
cursor; txdLastActivityTimestamp reads it from the last entry of
p.Activity so the JS has a starting point on first paint.
The base layout's SPA progress bar auto-starts on internal link clicks and fades the main content (opacity / blur / pointer-events). Any async error path must clear that state — otherwise the page stays blurred and the trickling progress bar never finishes.
The convention (see .claude/rules/ui.md "SPA progress bar"): every Alpine
component defines or shares a restorePageState() helper at module scope and
calls it on every error / non-2xx branch. The transaction-detail JS shares a
single module-level implementation across all three factories
(category, tags, comments).
In addition to clearing the admin progress fade, each call site rolls back the
optimistic local chip state at the call site so the UI reflects the prior
state (category chip reverts, tag chips re-add, etc.) and surfaces a toast
via window.showToast.
Worked example: sync_started and sync_updated from PR 6 of the
activity-log v2 stack. To add a new kind, work top-to-bottom through these
six steps.
internal/db/migrations/<timestamp>_<name>.sql — drop and re-add the
constraint with the new kind. Goose wraps each migration in a transaction by
default, so the table is never without a constraint mid-run.
-- +goose Up
ALTER TABLE annotations DROP CONSTRAINT IF EXISTS annotations_kind_check;
ALTER TABLE annotations ADD CONSTRAINT annotations_kind_check
CHECK (kind IN (
'comment',
'rule_applied',
'tag_added',
'tag_removed',
'category_set',
'sync_started',
'sync_updated',
'your_new_kind'
));
-- +goose Down
-- (mirror the up direction without your new kind)This is an additive migration in the shared-DB sense (see
.claude/rules/migrations.md) — adding a new accepted value doesn't break
older breadbox serve processes; they'll just never write the new kind. Run
sqlc generate afterward; the generated code rarely changes for CHECK-only
edits.
Mirror the helper-shape patterns:
internal/ruleapply/annotations.gofor rule-attributed writes (WriteRuleApplied,WriteCategorySet).internal/sync/annotations.gofor sync-attributed writes (writeSyncStartedAnnotation,writeSyncUpdatedAnnotation).
Each helper takes a pgx.Tx so the annotation insert commits atomically with
the originating action. Don't write annotations in a separate transaction —
the sync engine relies on "either everything commits or nothing does"
(.claude/rules/sync.md).
The canonical payload pattern: serialise actor metadata into the JSON payload
so the consumer can cross-link without parsing the untyped map. Example from
writeSyncUpdatedAnnotation:
payload := map[string]any{
"provider": providerType,
"connection_id": connShortID,
"sync_log_id": syncLogShortID,
"status_change": map[string]any{
"from": pendingLabel(fromPending),
"to": pendingLabel(toPending),
},
}actor_type = "system" for engine-driven kinds; actor_id should hold the
short_id of the closest entity (a connection short_id for sync events; a
rule short_id for rule events). actor_name should be the human-readable
display string the timeline will surface ("Plaid", "Teller", "CSV import" for sync; the rule name for rule events).
In internal/service/annotations_enrich.go, extend the enrichOne switch
with a case for the new kind and a format<Kind>Summary helper:
case "your_new_kind":
a.Action = "your-verb"
a.Subject = a.ActorName
a.Summary = formatYourNewKindSummary(a.ActorName, ...)The Summary is the canonical sentence both MCP consumers and the templ
fallback branch read. Keep it self-contained — agents that look at MCP
output don't read Origin separately, so trailing qualifiers belong in the
sentence ("during sync", " · pending → posted", etc.) when relevant.
In internal/templates/components/pages/transaction_detail.templ, extend
txdSystemIcon with the new event type (Lucide icon name, tile background,
ring colour):
} else if e.Type == "your_new_kind" {
<span class="flex items-center justify-center w-6 h-6 rounded-full bg-base-200 ring-4 ring-base-100" aria-hidden="true">
<i data-lucide="your-icon" class="w-3 h-3 text-base-content/60"></i>
</span>
}Pick a Lucide name that already appears in the codebase when possible —
landmark for sync, zap for rules, etc. New icons are fine but check
docs/design-system.md -> "Icons" first.
For the sentence, decide whether your kind should:
- Render its prebuilt
Summarydirectly (thetxdSystemSentencefallback branch — same path used byruleandsync); or - Compose its own actor-verb-object phrase like
tagandcategorydo.
For service-emitted system events with a static phrase, prefer the Summary path — keeps the sentence consistent across MCP and the UI.
Then map the DB kind to the Type value the templ branches on. For sync
events both sync_started and sync_updated collapse to "sync" via
syncEntryType because they share an icon. If your two kinds need
different icons, give them distinct Type values.
Finally, regenerate templ:
templ generateand commit both *.templ and the generated *_templ.go siblings.
internal/mcp/tools_tags.go:
var mcpAnnotationKinds = map[string][]string{
"comment": {"comment"},
"rule": {"rule_applied"},
"tag": {"tag_added", "tag_removed"},
"category": {"category_set"},
"sync": {"sync_started", "sync_updated"},
"series": {"series_assigned", "series_unlinked"},
"counterparty": {"counterparty_assigned", "counterparty_unlinked"},
"your": {"your_new_kind"},
}The map's keys are the agent-facing names (one normalised name plus an
action field on each row); the values are the raw DB kinds the service
filters by. Keep the boundary narrow — agents shouldn't have to know about
tag_added vs tag_removed, just tag plus a verb.
Pair each new kind with at least one integration test that proves the row
appears end-to-end. The existing reference is
internal/sync/sync_annotations_integration_test.go — drives a real sync,
asserts that sync_started and sync_updated rows are written, and that
ListAnnotations returns them with the expected Summary.
Also pin a unit test on the enrichment branch
(internal/service/annotations_enrich_test.go) so future refactors can't
silently break the Summary string.
- Sync-log detail page. Each connection sync produces a
sync_logsrow with a structured outcome. A sync-log detail view could reuse this component to thread the connection's lifecycle (started, succeeded, errored, reauth-required, etc.) onto the same rail. - Agent-run logs. When an MCP agent runs a workflow (review queue, bulk recategorize), the sequence of writes is already captured as annotations — surfacing them as a per-run timeline is mostly a routing / filtering problem, not a rendering one.
- Cross-transaction activity feed. A household-wide "what happened recently" page would need scrolling and pagination, but the per-day grouping and row markup are reusable.
The shared primitive layer landed in PR #908 — see "Shared primitives"
above. New surfaces should compose Timeline / TimelineDay /
TimelineSystemRow / TimelineCommentRow directly and keep their
sentence formatting and icon mapping page-local. Migrating the txn-detail
page itself off txdTimelineSystem / txdTimelineComment onto the
primitives is tracked separately; until then both layers coexist on the
same rail chrome without drift.
internal/templates/components/timeline.templ+timeline_helpers.go— the shared primitive layer:Timeline,TimelineDay,TimelineSystemRow,TimelineSystemRowCustomTile,TimelineCommentRow,TimelineCommentRowRaw,TimelineEmpty. Start here when building a new timeline-shaped surface.internal/admin/transactions.go—buildActivityTimeline,groupActivityByDay,activityDayLabel,activityEntryFromAnnotation,txdLastActivityTimestamp,TimelineRowsHandler. Canonical consumer of the primitive layer for the txn-detail page.internal/templates/components/pages/transaction_detail.templ— page-local helpers that compose on top of the primitives:txdActivity,txdTimelineDay,txdTimelineSystem,txdSystemSentence,txdSystemIcon,txdTimelineComment,txdTimelineCommentBubble,txdTimelineDeletedComment,txdTimelineComposer,TimelineRows. Sentence formatting, icon mapping, and tombstone branching live here, not in the primitives.internal/service/annotations.go—Annotationshape,ListAnnotations,IsDeletedplumbing.internal/service/annotations_enrich.go—EnrichAnnotations, dedup rules, per-kind Summary helpers.internal/ruleapply/annotations.go— rule-attributed write helpers.internal/sync/annotations.go— sync-attributed write helpers.internal/timefmt/timefmt.go—RelativeAt(the page-sharednow-anchor relative-time formatter).static/js/admin/components/transaction_detail.js— Strategy A optimistic updates,restorePageStaterollback.internal/mcp/tools_tags.go—mcpAnnotationKindsmapping..claude/rules/ui.md— admin UI / Tailwind / Alpine invariants..claude/rules/sync.md— atomicity rules for sync-driven writes..claude/rules/migrations.md— additive-only migration safety in shared dev DB.