Context
Build a per-person activity timeline at /people/:id/timeline — one chronological
feed of everything that happened to/around a person: profile updates, scholarship
awarded, registration created, payment applied, membership renewed, account logins,
tagging changes, plus the person's internal comments and communications.
This evolves an in-progress /people/:id/communications page (which interleaves a
person's comments + communications/notifications) — that work is renamed to
timeline and broadened into a full activity feed.
Feasible because change history already exists: AhoyTrackable is mixed into
ApplicationRecord, so every model auto-writes create.*/update.*/destroy.* rows
to ahoy_events with before/after diffs in properties["changes"], plus auth.*
account events on User. PaperTrail additionally covers financial models. So the work
is mostly assembly of existing data + presentation.
Scope decisions (confirmed)
- Sources: everything — all lifecycle events (incl. profile edits, tagging,
subscriptions) + comments + communications.
- Rows: mixed — comments stay editable-in-place, communications stay rich
cards, and ahoy changes render as lightweight event rows (icon + label + diff).
- Filter: category chips (All / Changes / Comments / Communications) + a
subject/content keyword search.
- Authorship rule: credit records by
author_id, falling back to created_by_id
only when the model has no author_id — so an admin isn't credited for records that
have a real author. (Mirrors the existing PersonCommentAggregator story logic.)
- Imports/jobs creates must be included (console edits may be ignored).
- Infinite scroll — must not load all of a person's records/events per request.
Event sourcing (two change streams)
- "Created" rows derive from each record's own
created_at, NOT ahoy — ahoy
create.* only fires for logged-in web actions, so imports/jobs/console creates would
be missed. Every row has created_at regardless of origin → complete create coverage.
Ahoy create.* events are therefore excluded to avoid double-counting.
- "Updated"/"deleted" rows come from
ahoy_events (update.*/destroy.* +
auth.*/taggings.*) — web-only. Console/job/import edits are out of scope
(accepted).
- PaperTrail
versions intentionally not merged into the feed (avoid double rows /
second code path); stays available for a future financial drill-down.
- Limitation: update/delete rows only go back to when ahoy tracking was enabled; create
rows go back as far as the records themselves.
Technical approach
Service PersonTimeline (evolves person_communication_feed.rb) — exposes
#page(before:, limit: 20) => { items:, next_cursor: }. k-way-merges four streams,
each queried keyset-style (WHERE <ts> < before ORDER BY <ts> DESC LIMIT limit),
merges the ≤limit-per-stream rows in Ruby, emits the top limit, derives next_cursor
from the last row:
- Comments —
PersonCommentAggregator#comments (reuse).
- Communications —
Notification.email(person.communications_email).
- Creates — resolve the person's directly-associated records across ~20 source
types (FK: registrations, scholarships, memberships + invoices, licenses,
affiliations, form submissions, event staff, CE, payments; polymorphic: addresses,
contact methods, tags, sectors, bookmarks, the person; authored/created content via
the authorship rule) → one TimelineChange(action: :created, occurred_at: created_at, actor: author-or-created_by) each.
- Updates/deletes —
Ahoy::Event per {resource_type => ids} map (promoted
columns), name REGEXP '^(update|destroy|auth|taggings)\.' → TimelineChange.
subject/content filters + before/limit pushed into each stream's SQL so the
per-stream LIMIT stays honest. Category chip selects which streams run.
Presenter — reuse/extend app/presenters/activity_presenter.rb ("The ONE true
timeline") for ahoy rows; add label ("<Subject> <verb>", auth.* special-cased),
icon+theme (new per-model FA-glyph map keyed to DomainTheme colors), detail_lines
(from properties["changes"]), actor_name ("System / import" when nil), link.
Pagination — cursor/keyset infinite scroll (net-new; no existing pattern):
composite (occurred_at, key) cursor, mirroring the comparator at
admin/ahoy_activities_controller.rb:292. UI = a bottom sentinel turbo_frame_tag "timeline_after_<cursor>", loading: :lazy that fetches the next page (same frame id →
replaces itself with next rows + next sentinel, appending visually). Reuse
prefetch_lazy_controller.js (IntersectionObserver) to load ~500px early. Filter
change re-navigates the outer person_timeline_results frame → fresh first page.
will_paginate/tailwind_paginate are NOT used here (can't express a cursor).
Views: people/timeline.html.erb (shell: header, chips + keyword form, lazy
frame), people/person_timeline_results.html.erb (rows + sentinel), _timeline_feed
(branch on class: Comment → editable comment; Notification → comm card; TimelineChange
→ new _timeline_event_row).
Reuse: PersonCommentAggregator (id-resolution), users_controller#account_events_for
users/sections/_account_activity (ahoy diff rendering), ActivityPresenter,
lib/domain_theme.rb, application_helper#section_icon_class, config/initializers/ahoy.rb.
Testing
- Service spec: interleave order; keyset paging (
before/next_cursor, no
overlap, nil at end); no create.* ahoy dup; import create (nil actor) appears;
authorship rule (story authored by someone else but created_by this user → excluded;
author-nil + created_by-this-user → included); category/subject/content filters;
unrelated person excluded.
- Presenter spec: label/icon/detail_lines/actor_name for created/updated/destroyed
- Request spec: shell renders chips + frame; first page returns interleaved rows +
sentinel; sentinel before= URL returns next page w/ no dup; last page omits
sentinel; filters; non-admin → root; ahoy view-tracking on full page only.
- Light system spec: scroll appends later rows.
Rough effort
~4–5 focused days. Widest surface: create-derivation across ~20 source types with the
authorship scoping. Main correctness risks: the authorship rule, cursor tie/de-dup at
page boundaries, and per-stream filter-in-SQL keeping LIMIT honest.
🤖 From Claude: drafted from a planning session; scope decisions above were confirmed with the requester.
Context
Build a per-person activity timeline at
/people/:id/timeline— one chronologicalfeed of everything that happened to/around a person: profile updates, scholarship
awarded, registration created, payment applied, membership renewed, account logins,
tagging changes, plus the person's internal comments and communications.
This evolves an in-progress
/people/:id/communicationspage (which interleaves aperson's comments + communications/notifications) — that work is renamed to
timelineand broadened into a full activity feed.Feasible because change history already exists:
AhoyTrackableis mixed intoApplicationRecord, so every model auto-writescreate.*/update.*/destroy.*rowsto
ahoy_eventswith before/after diffs inproperties["changes"], plusauth.*account events on
User. PaperTrail additionally covers financial models. So the workis mostly assembly of existing data + presentation.
Scope decisions (confirmed)
subscriptions) + comments + communications.
cards, and ahoy changes render as lightweight event rows (icon + label + diff).
subject/content keyword search.
author_id, falling back tocreated_by_idonly when the model has no
author_id— so an admin isn't credited for records thathave a real author. (Mirrors the existing
PersonCommentAggregatorstory logic.)Event sourcing (two change streams)
created_at, NOT ahoy — ahoycreate.*only fires for logged-in web actions, so imports/jobs/console creates wouldbe missed. Every row has
created_atregardless of origin → complete create coverage.Ahoy
create.*events are therefore excluded to avoid double-counting.ahoy_events(update.*/destroy.*+auth.*/taggings.*) — web-only. Console/job/import edits are out of scope(accepted).
versionsintentionally not merged into the feed (avoid double rows /second code path); stays available for a future financial drill-down.
rows go back as far as the records themselves.
Technical approach
Service
PersonTimeline(evolvesperson_communication_feed.rb) — exposes#page(before:, limit: 20) => { items:, next_cursor: }. k-way-merges four streams,each queried keyset-style (
WHERE <ts> < before ORDER BY <ts> DESC LIMIT limit),merges the ≤limit-per-stream rows in Ruby, emits the top
limit, derivesnext_cursorfrom the last row:
PersonCommentAggregator#comments(reuse).Notification.email(person.communications_email).types (FK: registrations, scholarships, memberships + invoices, licenses,
affiliations, form submissions, event staff, CE, payments; polymorphic: addresses,
contact methods, tags, sectors, bookmarks, the person; authored/created content via
the authorship rule) → one
TimelineChange(action: :created, occurred_at: created_at, actor: author-or-created_by)each.Ahoy::Eventper{resource_type => ids}map (promotedcolumns), name
REGEXP '^(update|destroy|auth|taggings)\.'→TimelineChange.subject/contentfilters +before/limitpushed into each stream's SQL so theper-stream
LIMITstays honest. Category chip selects which streams run.Presenter — reuse/extend
app/presenters/activity_presenter.rb("The ONE truetimeline") for ahoy rows; add label (
"<Subject> <verb>", auth.* special-cased),icon+theme (new per-model FA-glyph map keyed to
DomainThemecolors),detail_lines(from
properties["changes"]),actor_name("System / import" when nil),link.Pagination — cursor/keyset infinite scroll (net-new; no existing pattern):
composite
(occurred_at, key)cursor, mirroring the comparator atadmin/ahoy_activities_controller.rb:292. UI = a bottom sentinelturbo_frame_tag "timeline_after_<cursor>", loading: :lazythat fetches the next page (same frame id →replaces itself with next rows + next sentinel, appending visually). Reuse
prefetch_lazy_controller.js(IntersectionObserver) to load ~500px early. Filterchange re-navigates the outer
person_timeline_resultsframe → fresh first page.will_paginate/tailwind_paginateare NOT used here (can't express a cursor).Views:
people/timeline.html.erb(shell: header, chips + keyword form, lazyframe),
people/person_timeline_results.html.erb(rows + sentinel),_timeline_feed(branch on class: Comment → editable comment; Notification → comm card; TimelineChange
→ new
_timeline_event_row).Reuse:
PersonCommentAggregator(id-resolution),users_controller#account_events_forusers/sections/_account_activity(ahoy diff rendering),ActivityPresenter,lib/domain_theme.rb,application_helper#section_icon_class,config/initializers/ahoy.rb.Testing
before/next_cursor, nooverlap, nil at end); no
create.*ahoy dup; import create (nil actor) appears;authorship rule (story authored by someone else but created_by this user → excluded;
author-nil + created_by-this-user → included); category/subject/content filters;
unrelated person excluded.
sentinel; sentinel
before=URL returns next page w/ no dup; last page omitssentinel; filters; non-admin → root; ahoy view-tracking on full page only.
Rough effort
~4–5 focused days. Widest surface: create-derivation across ~20 source types with the
authorship scoping. Main correctness risks: the authorship rule, cursor tie/de-dup at
page boundaries, and per-stream filter-in-SQL keeping
LIMIThonest.🤖 From Claude: drafted from a planning session; scope decisions above were confirmed with the requester.