Skip to content

Latest commit

 

History

History
756 lines (611 loc) · 33.8 KB

File metadata and controls

756 lines (611 loc) · 33.8 KB

Activity Timeline

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 the annotations.kind CHECK constraint).
  • Service-layer conventions for the service.Annotation shape: .claude/rules/service.md.
  • UI invariants for daisyUI / Tailwind / Alpine (rail-agnostic): docs/design-system.md and .claude/rules/ui.md.

Where it's used

  • /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 composes TimelineSystemRowCustomTile and TimelineCommentRowRaw from 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.

Shared primitives

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.

Minimal usage example

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.

Relationship to the txn-detail page

/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.

Page-local responsibilities

The primitives intentionally don't handle:

  • Tombstone rendering. The IsDeleted branch in txdTimelineComment (PR #892) lives at the page layer because the dedup contract requires EnrichAnnotations upstream — 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. txdSystemIcon switches on e.Type to 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.IconTone covers the common 5-tone palette; for anything kind-driven, drop into TimelineSystemRowCustomTile and 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."

Rendering contract

service.Annotation -> service.ActivityEntry

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 of users.updated_at for 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.

Day grouping

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.

Shared now anchor

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 = now

Both 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.

Now anchor on the shared primitives

The same contract is wired through the shared primitives so day-grouped callers don't have to reinvent it:

  • TimelineSystemRow reads Props.Now and passes it to the relative-time helper for the row's timestamp pill.
  • TimelineSystemRowCustomTile and TimelineCommentRow accept now as a positional parameter for the same reason.
  • The internal relativeTimelineTimestamp helper delegates to timefmt.RelativeRFC3339At(s, now). A zero time.Time falls back to wall-clock time.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:

  1. Capture now := time.Now() once in the handler.
  2. Pass now into the day-bucketing helper (whatever yields the Today / Yesterday / Monday, April 14 labels).
  3. Pass the same now into every row primitive — TimelineRowProps.Now, TimelineSystemRowCustomTile's now argument, TimelineCommentRow's now argument.
  4. 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.

CSS / Tailwind invariants

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.

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> has pl-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's left offsets must change in lockstep.
  • top-2 bottom-12 anchors 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-200 keeps the rail at 1px regardless of zoom, in a colour that adapts to dark mode automatically.

Row geometry

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.

Day separators

<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.

Tooltips & relative timestamps

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.

Dedup contract (service.EnrichAnnotations)

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:

  1. Drop rule-source structural rows. When a rule fires (during sync or a retroactive apply), the engine writes a rule_applied annotation and a structural side-effect row — tag_added / tag_removed / category_set, or a counterparty_assigned / counterparty_unlinked / series_assigned / series_unlinked membership row — carrying payload.source = "rule". The rule_applied row is the canonical audit record; its rule-source siblings are noise. isRuleSourceDuplicate filters them out. Comments and rule_applied itself are never deduped here — only structural side-effects flagged with source: "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 (the rule_applied row plus a system-actor "Breadbox assigned …" row) — issue #1915. When you add a new rule-write path, stamp source: "rule" on its side-effect rows and add the kind to isRuleSourceDuplicate.

  2. Drop adjacent same-actor comment-vs-tag-note duplicates. The MCP update_transactions tool can write a tag_added with payload.note alongside a standalone comment with the same body, both in one call. The note already inlines the rationale on the tag row; the parallel comment is redundant. isCommentDuplicateOfTagNote collapses them within a 2-second window when actor identity matches (preferring ActorID, falling back to ActorName for system actors).

  3. 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.

Tombstones never fold

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.

Comment soft-delete (tombstones)

PR 4 of activity-log v2 introduced soft-delete for transaction comments. Salient points:

  • The DB row is not removed. annotations.deleted_at is set; the rest of the row stays put.

  • annotationFromActorRow reads DeletedAt.Valid and sets IsDeleted = true on the projected Annotation.

  • Enrichment overrides Summary to 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 CommentID short_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 on CommentID != "" && !IsDeleted, so retaining the ID here doesn't accidentally re-surface the delete affordance.

  • The templ entry point txdTimelineComment branches 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.

Optimistic in-place updates

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:

Strategy A — server-rendered partials

  • 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 buildActivityTimeline and the same txdTimelineDay / txdTimelineSystem / txdTimelineComment templ 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.

GET /-/transactions/{id}/timeline/rows

TimelineRowsHandler (internal/admin/transactions.go) accepts:

  • since (RFC3339) — return entries with Timestamp > since. An empty or missing since returns 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 their Timestamp is older than since. This is the soft-delete tombstone path: is_deleted flips on an existing row, the row's CreatedAt stays 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.

restorePageState() rollback

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.

Adding a new system-event kind

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.

1. Migration: extend the annotations.kind CHECK constraint

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.

2. Emit: write the row inside the originating transaction

Mirror the helper-shape patterns:

  • internal/ruleapply/annotations.go for rule-attributed writes (WriteRuleApplied, WriteCategorySet).
  • internal/sync/annotations.go for 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).

3. Enrich: add a Summary branch in EnrichAnnotations

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.

4. Render: add a txdSystemIcon branch and confirm the sentence

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 Summary directly (the txdSystemSentence fallback branch — same path used by rule and sync); or
  • Compose its own actor-verb-object phrase like tag and category do.

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 generate

and commit both *.templ and the generated *_templ.go siblings.

5. MCP: add the kind to mcpAnnotationKinds

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.

6. Test

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.

Future extensions

  • Sync-log detail page. Each connection sync produces a sync_logs row 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.

See also

  • 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.gobuildActivityTimeline, 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.goAnnotation shape, ListAnnotations, IsDeleted plumbing.
  • internal/service/annotations_enrich.goEnrichAnnotations, 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.goRelativeAt (the page-shared now-anchor relative-time formatter).
  • static/js/admin/components/transaction_detail.js — Strategy A optimistic updates, restorePageState rollback.
  • internal/mcp/tools_tags.gomcpAnnotationKinds mapping.
  • .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.