Skip to content

feat(home): streak milestones + sunday memory digest (PR-B)#39

Merged
ryota-murakami merged 3 commits into
mainfrom
feat/heatmap-cathedral-pr-b
May 11, 2026
Merged

feat(home): streak milestones + sunday memory digest (PR-B)#39
ryota-murakami merged 3 commits into
mainfrom
feat/heatmap-cathedral-pr-b

Conversation

@ryota-murakami
Copy link
Copy Markdown
Collaborator

@ryota-murakami ryota-murakami commented May 11, 2026

Summary

Heatmap Cathedral PR-B — implements DESIGN.md §3.3-3.4 (Streak Tier
Notifications + Memory Lane Sunday Digest).

  • Streak milestones — Electron notification fires once when the user
    crosses Day 7 / 30 / 100 / 365 of showing up. localStorage-keyed dedupe
    (corelive.streak-max-tier-notified) is monotonic by tier, so a broken
    streak does not re-fire a previously celebrated tier (DESIGN.md D12:
    additive, never decreases without explanation).
  • Memory Lane Sunday digest — quiet card on the home page that
    appears only on local Sunday, summarises the last 7 days (total + the
    brightest day + top categories), and persists a per-week dismiss via
    corelive.sunday-digest-dismissed.<local-Sunday-ISO>. Zero-week
    fallback: "the room was quiet this week — that is fine too."

Files

  • src/lib/calc-streak.ts + tests — TZ-safe streak / tier calculator
    using shiftIsoDate (24 unit tests covering grace period, gaps,
    longest-preserved, leap day, DST spring-forward, all four tier
    transitions)
  • src/hooks/useStreakNotifications.ts + tests — Electron-only one-shot
    notification hook (12 unit tests covering web no-op, loading gate,
    persister-restoring gate, sub-tier suppression, dedupe, corrupted /
    spurious storage values)
  • src/app/(main)/home/_components/SundayDigestCard.tsx + tests —
    Sunday-only digest card (10 unit tests covering non-Sunday hide,
    zero-week fallback, brightest-day rendering, singular "1 thing",
    per-week dismiss persistence)
  • src/app/(main)/home/_components/TodoList.tsx — wires both consumers
    to the existing useHeatmapData() query so no extra fetch is created
    (React Query dedupes the underlying request with ContributionGraph)

Voice (DESIGN.md §Voice)

  • "Day 7 of showing up. / the cathedral remembers."
  • "Day 30 — a month of light. / the room is filling in, one quiet day at
    a time."
  • "Day 100 — steady hands. / something gets built when you keep showing
    up."
  • "A year of showing up. / this is yours now."
  • "{N} things made it onto the wall." / "the room was quiet this week —
    that is fine too."

Pre-PR review

Ran 3-way adversarial check (specialized agent modern-react-pro +
codex-rescue + Claude Code advisor). All findings applied in commit
676b37a:

  1. Blocker fixSundayDigestCard: today = now ?? new Date()
    was creating a new Date every render and busting useMemo. Wrapped
    in useMemo so identity is stable.
  2. Major fixSundayDigestCard: aggregation anchor was today
    (UTC), but visibility was local Sunday — for JST users at Sunday
    06:00 the UTC day is still Saturday, so the digest showed wrong
    data. Now anchored on UTC midnight of the local Sunday ISO so the
    data window matches the visibility window.
  3. Major fixuseStreakNotifications: gated effect on
    useIsRestoring() so a stale TanStack persister snapshot cannot
    latch a wrong tier permanently before live data arrives.
  4. Minor fixuseStreakNotifications.readStoredTier: now
    validates against STREAK_TIERS so a tampered / spurious
    "5000" cannot block every real future milestone.
  5. Nit fix — voice: "best day" → "brightest day", spelled-out
    "6 things" (DESIGN.md bans KPI / ranking framing).

Deferred (documented, not fixed in this PR)

  • 365-day window edge case in calcStreak: when the heatmap query
    returns a window shorter than 365 days, longestStreak is bounded
    by that window. This is fine for tier semantics (the only thing
    consumed) but could surface as a stat in a future PR — track with
    src/hooks/useHeatmapData.ts window expansion if/when needed.

Test plan

  • pnpm validate (test + lint + build + typecheck) — green (172
    unit tests, all PR-B suites included)
  • 3-way adversarial review applied
  • CodeRabbit review (auto-runs on PR open)
  • Manual smoke on the home page in pnpm dev:
    • Heatmap still renders, no regression on weekly summary
    • On a Sunday (test by mocking now) the digest card appears below
      WeeklySummaryCard with correct copy
    • Dismiss button hides the card and the dismissal sticks on reload
  • Manual smoke in Electron build: a 7-day streak fires the macOS
    banner exactly once; second mount does not re-fire

🤖 PR-B of the Heatmap Cathedral 3-PR split.

Summary by CodeRabbit

  • New Features

    • Sunday Digest Card: weekly Sunday recap card showing quiet/summary messaging, brightest day, and top categories; dismissible per-week.
    • Streak Milestone Notifications: desktop notifications for 7/30/100/365-day streaks with per-account dedupe and persistence.
  • Tests

    • Added comprehensive tests for streak calculations, milestone notification behavior, and Sunday digest rendering and dismissal persistence.

Review Change Stack

PR3 — Electron streak milestone notifications:
  - src/lib/calc-streak.ts: TZ-safe streak/longest/tier/shown-up math
    via shiftIsoDate; tiers 7 / 30 / 100 / 365 per DESIGN.md D12.
  - src/hooks/useStreakNotifications.ts: composes existing
    useElectronNotifications; localStorage 'streak-max-tier-notified'
    dedupes per tier so milestones never re-fire after a break.
  - Copy is additive only ("day 7 of showing up — the cathedral
    remembers") per DESIGN.md voice; never "streak ended".

PR4 — Memory Lane Sunday digest card:
  - SundayDigestCard renders only on local Sunday with a per-week
    localStorage dismiss key keyed on the local Sunday's ISO date so
    next Sunday auto-recovers. Surfaces 7-day total, best day, and
    top categories — falls through to a quiet "the room was quiet
    this week" line on zero-weeks (never failure framing).
  - SSR-safe via useMounted; no extra network round-trip (shares
    dataByDate with WeeklySummaryCard/ContributionGraph).

Wired into TodoList.tsx alongside the existing weekly summary; the
streak hook is fire-and-forget and no-ops outside Electron.

44 new unit tests covering empty data, grace period, tier dedupe,
TZ/DST boundaries, dismiss persistence, and the Sunday gating.
- SundayDigestCard: memoize `today` so useMemo stays stable across renders
- SundayDigestCard: anchor 7-day window on UTC midnight of local Sunday so
  JST users see Sunday data (UTC-Saturday-as-anchor was the prior bug)
- SundayDigestCard: rename "best day" → "brightest day" + spell out count
  ("6 things") — removes ranking framing flagged by Codex against DESIGN.md
- useStreakNotifications: gate on `useIsRestoring()` so a stale TanStack
  persister snapshot cannot latch a wrong tier permanently
- useStreakNotifications: validate stored tier against STREAK_TIERS so a
  tampered/spurious value (e.g. "5000") does not block real milestones
- TodoList: pass `isRestoring` through to useStreakNotifications

Tests: +2 (spurious-value defense, isRestoring gate, 1-thing singular).
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 11, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
corelive Ready Ready Preview, Comment May 11, 2026 4:08pm

Request Review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 11, 2026

📝 Walkthrough

Walkthrough

Adds deterministic UTC streak computation and STREAK_TIERS, a client hook that shows per-user milestone Electron notifications with localStorage dedupe, a SundayDigestCard that shows a weekly recap with per-week dismissal, and TodoList wiring to run the hook and render the card.

Changes

Streak Tracking & Sunday Digest Notifications

Layer / File(s) Summary
Streak Calculation
src/lib/calc-streak.ts, src/lib/calc-streak.test.ts
calcStreak() computes current streak (with grace period), longest streak, milestone tier via STREAK_TIERS, and monthly shown-up counts from a heatmap Map<string,HeatmapDay>. Tests cover grace-period rules, gaps, tier boundaries, month/year edges, and DST-safe date math.
Streak Notification Hook
src/hooks/useStreakNotifications.ts, src/hooks/useStreakNotifications.test.ts
useStreakNotifications computes current tier and triggers one-shot Electron notifications at tiers (7/30/100/365). Dedupe is per-Clerk-user via a localStorage key plus an in-session latch; effect is gated on Electron support/enabled, skips while isLoading or isRestoring. Tests validate gating, notification args/tags, persistence, dedupe, corrupted storage handling, rehydration suppression, and multi-user namespacing.
Sunday Digest Card
src/app/(main)/home/_components/SundayDigestCard.tsx, src/app/(main)/home/_components/SundayDigestCard.test.tsx
SundayDigestCard renders only on local Sunday when mounted and not loading/dismissed, computes 7-day window stats, picks the brightest day by highest count, shows quiet-week vs completed copy, lists top categories, and persists per-week dismissal to localStorage. Tests cover gating, content, brightest-day phrasing (singular/plural), and dismissal key behavior.
TodoList Integration
src/app/(main)/home/_components/TodoList.tsx
Imports and calls useStreakNotifications with heatmapByDate, heatmapLoading, and isRestoring; renders SundayDigestCard in the completed column alongside existing summary components.

Sequence Diagram(s)

sequenceDiagram
  participant TodoList as TodoList
  participant Hook as useStreakNotifications
  participant Calc as calcStreak
  participant Store as localStorage
  participant Electron as ElectronNotifier

  TodoList->>Hook: useStreakNotifications(dataByDate, isLoading, isRestoring, now)
  Hook->>Calc: calcStreak(dataByDate, now)
  Calc-->>Hook: StreakSummary (currentTier)
  Hook->>Store: readStoredTier(userKey)
  Store-->>Hook: previousMaxTier
  alt currentTier > previousMaxTier and not isRestoring and notifications supported
    Hook->>Store: writeStoredTier(currentTier)
    Store-->>Hook: ok
    Hook->>Electron: showNotification(title, body, tag)
    Electron-->>Hook: ack
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • laststance/corelive#38: Directly related at the code level; both PRs touch heatmap utilities, weekly aggregation, and home components.

Poem

🐰 I hopped through dates both near and far,
Counting streaks beneath the stars.
Seven, thirty, a hundred cheer—
A gentle ping when milestones near.
Sunday notes and tiny lights,
I nibble joy on quiet nights.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title concisely and accurately summarizes the two main features being added: streak milestones (7/30/100/365 day notifications) and a Sunday memory digest card.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/heatmap-cathedral-pr-b

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 11, 2026

Codecov Report

❌ Patch coverage is 95.45455% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.97%. Comparing base (3732fde) to head (bf4314f).

Files with missing lines Patch % Lines
src/hooks/useStreakNotifications.ts 90.90% 4 Missing ⚠️
...c/app/(main)/home/_components/SundayDigestCard.tsx 95.45% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #39      +/-   ##
==========================================
+ Coverage   72.05%   73.97%   +1.92%     
==========================================
  Files          24       30       +6     
  Lines         526      734     +208     
  Branches      130      188      +58     
==========================================
+ Hits          379      543     +164     
- Misses        137      176      +39     
- Partials       10       15       +5     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/app/`(main)/home/_components/SundayDigestCard.tsx:
- Around line 215-219: The early-return checking dataByDate.size === 0 in
SundayDigestCard prevents rendering the empty-heatmap "quiet week" fallback;
remove or change that guard so the component still renders when dataByDate is
empty and lets the internal Heatmap/fallback branch show the "the room was quiet
this week" message — keep the other guards (isMounted, isLoading, isSunday,
dismissed) intact and adjust any downstream rendering logic that assumes
non-empty data (in render paths inside SundayDigestCard and any
Heatmap/QuietWeek components) to handle an empty dataByDate Map safely.
- Around line 146-152: The localSundayIso function relies on
toLocaleDateString('en-CA') which is not deterministic; replace that call with a
manual YYYY-MM-DD formatter that uses the Date getters (getFullYear, getMonth,
getDate) and pads month/day to two digits (e.g.,
String(month+1).padStart(2,'0')), so localSundayIso(now: Date) computes the
previous Sunday as it does now but returns `${year}-${mm}-${dd}` explicitly;
update any callers like shiftIsoDate/localStorage keys to continue using the new
deterministic YYYY-MM-DD string.
- Around line 182-188: The component is freezing to the mount-day because today
is memoized; remove the useMemo and compute today directly as now ?? new Date()
so a fresh Date is produced each render, which will let derived values isSunday,
weekKey (and any memoized summary that depends on today) update across day
boundaries; update any related memo/computed values (e.g., summary,
localSundayIso usages) to depend on the non-memoized today or include
appropriate dependencies so they recompute when the date changes.

In `@src/hooks/useStreakNotifications.ts`:
- Around line 13-20: STORAGE_KEY currently uses a global browser key
('corelive.streak-max-tier-notified') which causes cross-account suppression;
change the dedupe to be user-scoped by incorporating the signed-in user's stable
id (e.g., append or prefix with currentUser.id) when reading/writing in
useStreakNotifications (references: STORAGE_KEY, any get/set calls in
useStreakNotifications.ts) or alternatively clear the stored key on auth changes
by hooking into the auth state change handler used by the hook; ensure all
reads/writes and the initial default use the new namespaced key so notifications
are per-account.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a080fd5b-6b30-4ee4-b697-f2fc167be5db

📥 Commits

Reviewing files that changed from the base of the PR and between 3732fde and 676b37a.

📒 Files selected for processing (7)
  • src/app/(main)/home/_components/SundayDigestCard.test.tsx
  • src/app/(main)/home/_components/SundayDigestCard.tsx
  • src/app/(main)/home/_components/TodoList.tsx
  • src/hooks/useStreakNotifications.test.ts
  • src/hooks/useStreakNotifications.ts
  • src/lib/calc-streak.test.ts
  • src/lib/calc-streak.ts

Comment thread src/app/(main)/home/_components/SundayDigestCard.tsx Outdated
Comment thread src/app/(main)/home/_components/SundayDigestCard.tsx Outdated
Comment thread src/app/(main)/home/_components/SundayDigestCard.tsx Outdated
Comment thread src/hooks/useStreakNotifications.ts Outdated
- SundayDigestCard.localSundayIso: replace toLocaleDateString('en-CA')
  with deterministic YYYY-MM-DD formatter. The locale's format is not
  spec-stable and has flipped to M/d/yyyy in past CLDR updates.
- SundayDigestCard.today: revert the useMemo wrap. Memoizing freezes the
  Date to mount-day, so the card would not appear if the home stays open
  across Sat→Sun. The per-render Date is cheap.
- SundayDigestCard: remove the empty-heatmap early-return so the
  "the room was quiet this week — that is fine too." fallback can render
  for new users and fully quiet weeks (this was the path the copy was
  added for).
- useStreakNotifications: namespace the dedupe localStorage key per
  Clerk userId (corelive.streak-max-tier-notified.<user_id>). Without
  this, two accounts on one Electron/browser profile suppress each
  other's milestones. Hook now short-circuits when userId is null.
- useStreakNotifications: key the in-memory latch ref by userId too so
  a sign-out → sign-in-as-different-user in the same session does not
  leak the previous account's tier into the new account's gate.

Tests: +2 (signed-out short-circuit, per-user namespace cross-account
isolation). Empty-heatmap test now asserts the quiet-week fallback
renders instead of being suppressed.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/hooks/useStreakNotifications.test.ts (2)

230-243: ⚡ Quick win

Test the isRestoring transition (true -> false) in one hook instance.

Line 230 currently covers only the static restoring gate. A rerender transition test will catch missed-fire or double-fire regressions during hydration completion.

Proposed transition test
+  it('fires exactly once after rehydration finishes', () => {
+    const { rerender } = renderHook(
+      ({ isRestoring }) =>
+        useStreakNotifications({
+          dataByDate: buildConsecutive(7),
+          isLoading: false,
+          isRestoring,
+          now: TODAY,
+        }),
+      { initialProps: { isRestoring: true } },
+    )
+
+    expect(electronMocks.showNotification).not.toHaveBeenCalled()
+    rerender({ isRestoring: false })
+    expect(electronMocks.showNotification).toHaveBeenCalledTimes(1)
+    expect(window.localStorage.getItem(STORAGE_KEY)).toBe('7')
+  })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/useStreakNotifications.test.ts` around lines 230 - 243, Add a test
that exercises the isRestoring transition on a single hook instance:
renderHook(() => useStreakNotifications(...)) with isRestoring: true, assert no
notification and STORAGE_KEY absent, then rerender the same hook with
isRestoring: false and the same inputs and assert electronMocks.showNotification
is called exactly once (no double-fire) and
window.localStorage.getItem(STORAGE_KEY) is set accordingly; this verifies the
hook correctly waits for the restoring -> not-restoring transition and prevents
missed or duplicate notifications.

150-161: ⚡ Quick win

Add explicit coverage for the 100/365 tiers.

Right now only Line 150 (30-day) and Line 134 (7-day) milestone paths are asserted; adding 100/365 checks would protect all shipped tiers from copy/tag/storage regressions.

Proposed test addition
+  it.each([100, 365])(
+    'fires the %i-day tier and stores it as max',
+    (tier) => {
+      renderHook(() =>
+        useStreakNotifications({
+          dataByDate: buildConsecutive(tier),
+          isLoading: false,
+          now: TODAY,
+        }),
+      )
+      expect(electronMocks.showNotification).toHaveBeenCalledTimes(1)
+      expect(electronMocks.showNotification.mock.calls[0]?.[0]).toMatch(
+        new RegExp(`day\\s+${tier}`, 'i'),
+      )
+      expect(window.localStorage.getItem(STORAGE_KEY)).toBe(String(tier))
+    },
+  )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/useStreakNotifications.test.ts` around lines 150 - 161, Add
explicit test cases that assert notifications and storage updates for the
100-day and 365-day tiers in the same style as the existing 7-day and 30-day
tests: render the hook via useStreakNotifications with dataByDate built by
buildConsecutive(100) and buildConsecutive(365) (isLoading: false, now: TODAY),
then expect electronMocks.showNotification to have been called and that the
notification text matches /day 100/i and /day 365/i respectively, and finally
assert window.localStorage.getItem(STORAGE_KEY) equals '100' and '365' for each
test; mirror the existing test structure and cleanup to ensure isolation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/hooks/useStreakNotifications.test.ts`:
- Around line 230-243: Add a test that exercises the isRestoring transition on a
single hook instance: renderHook(() => useStreakNotifications(...)) with
isRestoring: true, assert no notification and STORAGE_KEY absent, then rerender
the same hook with isRestoring: false and the same inputs and assert
electronMocks.showNotification is called exactly once (no double-fire) and
window.localStorage.getItem(STORAGE_KEY) is set accordingly; this verifies the
hook correctly waits for the restoring -> not-restoring transition and prevents
missed or duplicate notifications.
- Around line 150-161: Add explicit test cases that assert notifications and
storage updates for the 100-day and 365-day tiers in the same style as the
existing 7-day and 30-day tests: render the hook via useStreakNotifications with
dataByDate built by buildConsecutive(100) and buildConsecutive(365) (isLoading:
false, now: TODAY), then expect electronMocks.showNotification to have been
called and that the notification text matches /day 100/i and /day 365/i
respectively, and finally assert window.localStorage.getItem(STORAGE_KEY) equals
'100' and '365' for each test; mirror the existing test structure and cleanup to
ensure isolation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8fb9f6d3-e689-498f-96c8-8add0d61beba

📥 Commits

Reviewing files that changed from the base of the PR and between 676b37a and bf4314f.

📒 Files selected for processing (4)
  • src/app/(main)/home/_components/SundayDigestCard.test.tsx
  • src/app/(main)/home/_components/SundayDigestCard.tsx
  • src/hooks/useStreakNotifications.test.ts
  • src/hooks/useStreakNotifications.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/hooks/useStreakNotifications.ts
  • src/app/(main)/home/_components/SundayDigestCard.test.tsx

@ryota-murakami ryota-murakami merged commit 169df9e into main May 11, 2026
22 checks passed
@ryota-murakami ryota-murakami deleted the feat/heatmap-cathedral-pr-b branch May 11, 2026 16:12
ryota-murakami added a commit that referenced this pull request May 11, 2026
Heatmap Cathedral release — Design Doc §3.1–§3.8 fully implemented.

PRs since v0.4.0 (27 commits, 4 PRs):
- #38 PR-A: UNION heatmap + WeeklySummaryCard + day-nav < > + j/k + ?date= deep-link
- #39 PR-B: Streak milestone notifications (Electron) + Sunday Memory Lane digest
- #40 PR-C: Per-category WoW trend chips + shareable "this day" PNG + Year-in-Review modal
- #41 PR-D hotfix: blank share-image PNG — wrap card in 0×0 overflow:hidden clipper
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants