OUT-3825 | Grouped content composer (pure function)#1303
Conversation
Add composeGroupedEmail(events) -> { totalEventCount, sections[] }, the
render-agnostic step that turns one recipient's buffered window of events
into the grouped email structure.
- Sections in fixed order ASSIGNED -> SHARED -> COMMENT -> COMPLETED;
empty sections omitted
- Per section: sort by createdAt then title, list up to 3 distinct task
names, overflowCount = max(0, distinctTasks - 3) for "+N other tasks"
- totalEventCount = all buffered events (the N in the subject); section
counts sum to it
- No I/O, no rendering, no brand prefix — pure function
Multiple comments on one task count as separate events (toward N) but the
task name is listed once. 100% unit coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryAdds
Confidence Score: 4/5Safe to merge; the composer is a pure function with no side-effects and solid test coverage for its primary paths. The implementation is clean and the core logic is correct. The one design nuance worth watching is that Both files are straightforward; Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["composeGroupedEmail(events)"] --> B["Iterate SECTION_ORDER\n(ASSIGNED → SHARED → COMMENT → COMPLETED)"]
B --> C["buildSection(eventType, events)"]
C --> D["filter by eventType"]
D --> E{"any events?"}
E -- No --> F["return null\n(section omitted)"]
E -- Yes --> G["sort by createdAt ↑ then title ↑"]
G --> H["distinctTaskNames(sorted)\n(dedup by taskId, picks oldest snapshot)"]
H --> I["slice(0, 3) → taskNames\nMath.max(0, distinct-3) → overflowCount"]
I --> J["GroupedEmailSection"]
J --> K["filter nulls → sections[]"]
K --> L["{ totalEventCount: events.length, sections }"]
Reviews (1): Last reviewed commit: "OUT-3825 | Grouped content composer (pur..." | Re-trigger Greptile |
| const seen = new Set<string>() | ||
| const names: string[] = [] | ||
| for (const event of events) { | ||
| if (seen.has(event.taskId)) continue | ||
| seen.add(event.taskId) | ||
| names.push(event.taskTitleSnapshot) | ||
| } | ||
| return names | ||
| } |
There was a problem hiding this comment.
distinctTaskNames shows the oldest title snapshot when a task is renamed mid-window
distinctTaskNames iterates events already sorted ascending by createdAt, so for a given taskId it always picks the first occurrence — the oldest snapshot. If a task is renamed between two buffered events in the same window (e.g., two COMMENT events where the title changed), the email will display the stale name rather than the most recent snapshot. Passing the events sorted descending (or reducing to last-seen title) would surface the latest snapshot instead.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| import { GroupedEmailEventType } from '@prisma/client' | ||
| import { composeGroupedEmail, GroupedEmailEventInput, MAX_TASK_NAMES_PER_SECTION } from './groupedEmail.composer' | ||
|
|
||
| let seq = 0 |
There was a problem hiding this comment.
The module-level
seq counter is never reset between test cases, so each it block inherits whichever value seq reached at the end of the previous test. While no current test relies on specific sequential values, adding or reordering tests could produce surprising taskId/taskTitleSnapshot values. A beforeEach reset would make each case self-contained.
| let seq = 0 | |
| let seq = 0 | |
| beforeEach(() => { | |
| seq = 0 | |
| }) |
- distinctTaskNames now keeps the latest taskTitleSnapshot for a repeated taskId (last-wins), so a task renamed mid-window shows its current name rather than the oldest snapshot. First-seen ordering preserved. Only affects repeated taskIds (e.g. multiple comments on one task). - Reset the module-level seq counter in beforeEach so each test is self-contained. - Add a test for the renamed-task scenario. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Deployment failed with the following error: Learn More: https://vercel.link/multiple-function-regions |
|
Thanks @greptile-apps — both P2s addressed in 1. Stale title snapshot for a renamed task. 2. Test 10 tests, still 100% coverage; |
|
Both fixes look correct. The The Both P2s fully addressed. LGTM. ✅ |
Per review: scope seq, the event factory, sectionFor, and the seq reset
inside describe('composeGroupedEmail') so they don't sit at module level.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Plain-text render (markdown dependency OUT-3830 dropped for M2) + email-only
Copilot send, mirroring the reminder pipeline.
- renderGroupedEmail(content) -> { subject, header, title, body }: section
headings per type (assigned/shared/comment/completed) with correct
pluralization, task names as dashed quoted lines, "+N other tasks"
overflow, sections separated by blank lines
- subject "You have N new task updates" (no brand prefix; Copilot prepends
"{Brand} portal:"), header "Catch up on task activity", CTA "View all tasks"
- sendGroupedEmail(): email-only deliveryTargets (in-product stays
immediate), IU sender (resolved by the dispatcher), workspace-scoped token,
mirrors send-reminder-email.ts
17 unit tests (PRD sample, overflow, pluralization, every section heading,
empty body, email-only payload shape, error propagation).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
OUT-3826 | Render + send adapter for the grouped email
What
Adds
composeGroupedEmail(events)— the render-agnostic step that turns one recipient's buffered window ofGroupedEmailEvents into the grouped-email structure. Pure function: no DB, no Copilot, no rendering, no brand prefix. The render adapter (OUT-3826) consumes its output.Linear: OUT-3825
Shape
ASSIGNED → SHARED → COMMENT → COMPLETED; empty sections omitted. (COMPLETEDstays in the order but is dormant for CU in M2 — it'll light up for IU in M3 with no composer change.)createdAtthen title; up to 3 distinct task names listed;overflowCount = max(0, distinctTasks - 3)drives the renderer's "+N other tasks".totalEventCount= every buffered event (the N in "You have N new task updates"); sectioncounts sum to it.Design note: recipient vs. event grouping
The composer operates on one recipient's window — recipient-level grouping happens upstream via the
windowKeyclaim (OUT-3823/3824). It deliberately never reads recipient IDs; sections-by-type is only the layout inside that single per-recipient email.Design note: multiple comments on one task
Each comment is a separate buffered event, so it counts individually toward
totalEventCountand the COMMENT sectioncount(e.g. "3 comments were added"), but the task name is listed once in the bullets. That's why sectioncount(events) can exceed the number of task names shown — intentional, and covered by a test.Testing
{ totalEventCount: 0, sections: [] }(caller's skip-send signal), the N count, count-sums-to-N, 3-cap/overflow math, createdAt-then-title sort, and the multi-comment-one-task case.tsc --noEmit✅ · eslint ✅ · prettier ✅🤖 Generated with Claude Code