Skip to content

fix: stats logging, bonfire animation, timezone and streak fixes#30

Merged
MinitJain merged 2 commits intomainfrom
fix/session-stats-bonfire-polish
Apr 21, 2026
Merged

fix: stats logging, bonfire animation, timezone and streak fixes#30
MinitJain merged 2 commits intomainfrom
fix/session-stats-bonfire-polish

Conversation

@MinitJain
Copy link
Copy Markdown
Owner

@MinitJain MinitJain commented Apr 21, 2026

Summary

  • Watcher stats bug: participants in host/jam mode never got pomodoros logged - applyState blocked onExpire for remote completions. Fixed by logging in onTimerUpdate with a startedAt dedup guard to prevent jam-mode double-counting
  • Streak timezone: increment_profile_stats was using server UTC (current_date), causing wrong day attribution for non-UTC users. Now accepts p_today (client local date) in both RPC call sites
  • Bonfire animation: flame was static at 0.28 intensity and surged on completion. Now grows from 0.12 to 0.85 as timer progresses, resets on completion, each session independent
  • Dead streak display: profile showed stale streak value even after missing days. Now computes effective streak at render time using a 2-day UTC window
  • Calendar timezone: buildCalendarCells and buildWeekDays used local date arithmetic but data keys are UTC - fixed to use UTC throughout
  • Hours consistency: standardized to Math.floor across StatsGrid, WeeklyChart, StreakCalendar
  • Legend duplicate color: StreakCalendar legend showed two identical swatches - fixed to [0, 1, 25, 60, 120] matching actual color buckets
  • Missing component: KeyboardShortcutsModal was imported but didn't exist, causing build failure
  • DB migrations: 012 adds p_today param to function, 013 drops stale overloads from prior migrations

Test plan

  • Complete a pomodoro as a watcher (not host) in host mode - verify stats update on profile
  • Complete a pomodoro in jam mode - verify only one stat increment per user
  • Check profile page for a user in a non-UTC timezone - calendar cells should align with actual session days
  • Verify bonfire starts small and grows over a focus session, resets after completion
  • Check streak shows 0 on profile if last session was 3+ days ago
  • Verify StreakCalendar legend shows 5 visually distinct colors
  • Run npx supabase db push is already done - increment_profile_stats has 4 params including p_today

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added keyboard shortcuts modal (toggle with ? key, close with Escape)
    • Added theme toggle to header
    • Improved sign-in flow with popover interface
  • Bug Fixes

    • Fixed timezone handling for calendar, streak, and stats calculations
    • Improved streak accuracy with activity date tracking
    • Added deduplication guard for pomodoro counting
  • UI/UX Improvements

    • Focus hours now display as whole numbers
    • Updated flame intensity labels and thresholds
    • Adjusted legend intensity buckets for better clarity

Stats:
- Watchers in host/jam mode now get pomodoros logged (applyState was
  blocking onExpire for remote timer completions)
- Dedup guard by startedAt prevents double-counting in jam mode races
- Pass client local date (p_today) to increment_profile_stats so streak
  day attribution uses the user timezone, not server UTC
- Drop stale function overloads from migrations 001 and 005

Bonfire:
- Flame now grows from small (0.12) to large (0.85) as timer progresses
- Resets to low on completion instead of surging; each session independent
- Fix missing mode prop on BonfireScene

Timezone:
- buildCalendarCells and buildWeekDays use UTC arithmetic to match
  toDayKey output; fixes off-by-one cells for non-UTC users
- Month labels use noon UTC to avoid day boundary misread
- Today count query uses UTC midnight

Profile:
- Dead streak shows 0 if last_active_date is stale (2-day UTC window
  covers all timezones)
- Hours display standardized to Math.floor across all three components
- StreakCalendar legend fixed: 5 distinct swatches instead of duplicate
- Add last_active_date to Profile type and useProfile schema
- Create KeyboardShortcutsModal (missing component causing build error)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 21, 2026

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

Project Deployment Actions Updated (UTC)
bonfire Ready Ready Preview, Comment Apr 21, 2026 8:08am

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 21, 2026

Warning

Rate limit exceeded

@MinitJain has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 29 minutes and 33 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 29 minutes and 33 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8815883e-a694-4057-b5a3-21e578b7221f

📥 Commits

Reviewing files that changed from the base of the PR and between 1ac1d1c and 8a00c90.

📒 Files selected for processing (5)
  • app/profile/[username]/page.tsx
  • components/session/KeyboardShortcutsModal.tsx
  • components/session/SessionProvider.tsx
  • hooks/useBonfireState.ts
  • supabase/migrations/014_sanitize_stats_inputs.sql

Walkthrough

This PR overhauls streak tracking and display logic to use UTC-based dates, introduces a keyboard shortcuts modal, refactors session management with OAuth signin and theme toggle, rewrites flame intensity calculation based on timer progress, and adds a database function to atomically update streak stats with client-provided dates.

Changes

Cohort / File(s) Summary
Profile Display & UTC Dates
app/profile/[username]/page.tsx, components/profile/StreakCalendar.tsx
Switched calendar and month-label computation from local time arithmetic to UTC-based Date construction and UTC accessor methods (getUTCDay(), getUTCMonth(), toLocaleDateString(..., { timeZone: 'UTC' })). Updated legend thresholds from [0, 25, 60, 120, 180] to [0, 1, 25, 60, 120].
Streak & Stats Calculation
components/profile/StatsGrid.tsx, components/profile/WeeklyChart.tsx, hooks/useProfile.ts
Added last_active_date to Profile schema. Changed focus-hour display from toFixed(1) to Math.floor(). Introduced effectiveStreak() helper using 2-day UTC-based cutoff to compute displayed streak; current_streak resets to 0 if last activity is older than cutoff.
Keyboard Shortcuts & Session UI
components/session/KeyboardShortcutsModal.tsx, components/session/SessionProvider.tsx
Added new KeyboardShortcutsModal component with Escape/overlay-click dismissal. Replaced Guide modal with shortcuts modal in SessionProvider. Replaced user dropdown with sign-in popover (OAuth GitHub/Google with error toast). Added ThemeToggle to header. Updated round-completion label, notification title (Bonfire → PomodoroJam), and background styling.
Pomodoro Completion & Stats Tracking
components/session/SessionProvider.tsx
Changed completion-day detection from local midnight to UTC-based toDayKey() cutoff. Added deduplication guard via startedAt ref to prevent double-counting. Shifted remote/joined stat logging to onTimerUpdate; handleExpire now logs via dedup ref. Added p_today (ISO-like date) argument to stats RPC. Always increment local count on focus skip; conditionally set pomos_done based on skippingFocus.
Flame Intensity State
hooks/useBonfireState.ts
Removed accountabilityMode and baseIntensityFor. Rewired intensity as progress-driven: progress = (totalTime - timeLeft) / totalTime during focus, with dims on pause. Removed accountability decay interval. Changed completion behavior to toggle isSurging for 2 seconds only. Updated flame labels to bands (BLAZING/THRIVING/GROWING/KINDLING) based solely on intensity thresholds. Changed pause label from FADING to PAUSED.
Database Functions & Schema
supabase/migrations/012_streak_client_date.sql, supabase/migrations/013_drop_stale_stat_overloads.sql, types/index.ts
Added increment_profile_stats(p_user_id uuid, p_minutes int, p_session_id text, p_today date) with row-level locking and streak-continuation logic based on last_active_date vs. p_today. Dropped legacy 2-parameter and 3-parameter overloads. Added `last_active_date: string

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Title clearly identifies the main categories of fixes (stats logging, bonfire animation, timezone, streak) without being vague, and accurately reflects the primary intent of the multi-faceted changeset.
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.
Description check ✅ Passed PR description is comprehensive with clear summary of 8 fixes, detailed rationale for each change, and explicit test plan checklist covering all major modifications.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/session-stats-bonfire-polish

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
Copy Markdown

@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: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
components/session/SessionProvider.tsx (2)

856-886: ⚠️ Potential issue | 🟠 Major

Focus-skip increments local counters but never logs to the DB — stats will silently diverge.

handleSkip on a focus mode now bumps focusCountRef and todayCount (and writes pomos_done into the session row), so the UI shows "N pomodoros completed today", but increment_profile_stats is never called on this path. Consequence: the badge and the profile page will disagree for anyone who skips instead of letting the timer run out, and the streak/day attribution this PR was designed to fix is skipped entirely on this branch.

Either call the RPC here too (with the actual elapsed minutes, not the configured duration), or don't bump the "today" counter on skip — the current state is the worst of both.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/session/SessionProvider.tsx` around lines 856 - 886, handleSkip
currently increments focusCountRef and todayCount and enqueuesSessionUpdate but
never calls the backend RPC to persist the skipped pomodoro; update handleSkip
to call increment_profile_stats (or the existing RPC wrapper used elsewhere)
when skipping a focus round, passing the actual elapsed minutes computed from
the new timer state (use newState.totalTime - newState.timeLeft, converted to
minutes) rather than sessionSettings.durations, and include the same pomos_done
value (focusCountRef.current) so DB stats stay in sync with the UI.

470-501: ⚠️ Potential issue | 🔴 Critical

Host never broadcasts status: "finished"—the watcher-log fix is inert.

The new watcher-log code at lines 481–498 depends on receiving a remote broadcast with state.status === "finished" && state.mode === "focus". However, handleExpire (238–303) only broadcasts the next state after auto-starting: either a break (mode: "short"|"long", status: "running") or the next focus session (mode: "focus", status: "running"). The intermediate finished state is created locally in useTimer on tick-to-zero but never broadcast.

Since watchers only receive the transitions between running modes (never the finished state itself), the condition at line 481 will never match and remote watchers will not have their stats logged via this path. The original issue for watcher stats-logging remains unresolved.

To fix: Broadcast the finished state before transitioning to the next mode, or log stats when receiving the post-expiry running state by deduping on (startedAt, mode) pairs instead of relying on a finished-state broadcast.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/session/SessionProvider.tsx` around lines 470 - 501, The watcher
logging path never sees status === "finished" because handleExpire triggers
broadcasting of the next running state; update the logic so watchers can log:
either modify handleExpire to emit the intermediate finished state before
emitting the next running state (reference handleExpire and the broadcast
function used there), or change the onTimerUpdate logging callback to detect the
post-expiry running broadcast and dedupe by (startedAt, mode) instead of only
startedAt (update the condition in the onTimerUpdate callback that uses
lastLoggedTimerRef and minutes calculation to key by startedAt + mode and use
lastLoggedTimerRef/currentLoggedKey accordingly). Ensure applyState,
onTimerUpdate, and lastLoggedTimerRef are used consistently so the same session
isn't double-counted.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/profile/`[username]/page.tsx:
- Around line 56-70: buildCalendarCells currently computes start from
utcDay(-371) which, after rewinding to the previous Monday, makes the grid end
before today; change the logic to anchor the grid on today: compute end =
utcDay(0) (today), set start = new Date(end) and subtract (53*7 - 1) days to get
the window start, then rewind that start to the Monday on-or-before as before
and iterate forward 53*7 cells; update buildCalendarCells (and keep
toDayKey/utcDay usage) so the last pushed cell can equal todayStr used in
StreakCalendar.

In `@components/profile/StatsGrid.tsx`:
- Around line 6-14: Update the comment above function effectiveStreak to
accurately describe the grace period as a ~3-day UTC window (today, yesterday,
and 2-days-ago) rather than "2-day", and note this extra day accommodates
timezone skew (clients can be up to ±14 hours or a full calendar day off server
UTC); leave the function logic (effectiveStreak, toDayKey, comparison with
twoDaysAgo) unchanged.

In `@components/session/KeyboardShortcutsModal.tsx`:
- Around line 24-51: The KeyboardShortcutsModal lacks dialog semantics and focus
management: update the root modal container rendered by KeyboardShortcutsModal
to include role="dialog", aria-modal="true" and aria-labelledby pointing to the
header (give the <h2> a stable id), add a ref for the close <button> (the X
button) and on mount save document.activeElement, focus the close button, and on
unmount restore the saved element via onClose cleanup; additionally implement a
minimal focus trap inside the modal container so Tab/Shift+Tab cycle between
focusable elements in the modal (prevent Tab from escaping to background).
Ensure the close button retains an aria-label and the header id matches
aria-labelledby.

In `@components/session/SessionProvider.tsx`:
- Around line 248-265: The current handleExpire block uses
Math.round(settings.durations.focus) which diverges from the onTimerUpdate path
that uses the broadcasted state.totalTime/60; change handleExpire (the code
using timerStartedAtRef, lastLoggedTimerRef and the
supabase.rpc("increment_profile_stats") call) to compute minutes from the
broadcasted elapsed seconds (or state.totalTime) rather than
settings.durations.focus, and drop the redundant Math.round; ensure the same
source (broadcasted totalTime converted to minutes or actual elapsed seconds) is
used in both onTimerUpdate and this rpc call so logged minutes are consistent.
- Around line 481-498: The current onTimerUpdate block unconditionally calls
supabase.rpc("increment_profile_stats") for any authenticated user when a focus
session finishes; change it to only log stats for active participants by gating
the RPC call behind an "active participation" check: inside the same function
(onTimerUpdate) require either state.mode === "jam" OR a new boolean/condition
like isActiveParticipant === true (where isActiveParticipant is determined from
presence/heartbeat duration >= N seconds or a session-specific flag) before
updating lastLoggedTimerRef and calling increment_profile_stats; make the
active-threshold configurable (e.g.,
sessionSettingsRef.current.activeThresholdSeconds) and ensure callers that
manage presence set/update isActiveParticipant via heartbeat/presence logic so
passive watchers do not earn credit.

In `@hooks/useBonfireState.ts`:
- Around line 73-81: The useEffect that watches focusCount schedules
surgeTimerRef and can call setIsSurging after unmount; add a cleanup to that
effect that clears surgeTimerRef (if set) and prevents the pending callback from
running on an unmounted component. Specifically, inside the effect that
references prevFocusCountRef, surgeTimerRef, and setIsSurging, return a cleanup
function that clearsTimeout(surgeTimerRef.current) and nulls the ref (or use an
isMounted ref to guard setIsSurging) so the timeout is removed on unmount and no
state update occurs after the component is unmounted.

In `@supabase/migrations/012_streak_client_date.sql`:
- Line 24: The comparison uses timestamp arithmetic causing an implicit cast;
update the conditional that currently reads "if v_last_active = v_today -
interval '1 day' then" to use date arithmetic instead by replacing the RHS with
"v_today - 1" so v_today and v_last_active remain dates; locate the conditional
referencing v_last_active and v_today and make this single-token replacement to
avoid the timestamp cast.
- Around line 5-22: The SECURITY DEFINER function increment_profile_stats
currently trusts the caller-supplied p_today and p_minutes; clamp p_today to a
small window around the server date (e.g., if abs(coalesce(p_today,current_date)
- current_date) > 1 day then set v_today := current_date else set v_today :=
coalesce(p_today,current_date)) so client-supplied dates outside ±1 day are
rejected, and also cap p_minutes to a sane maximum (e.g., if p_minutes > 180
then set p_minutes := 180) before any streak/last_active_date logic runs to
prevent forged streaks or inflated session times; make these adjustments at the
start of increment_profile_stats (refer to parameters p_today/p_minutes and
local variable v_today) so all subsequent logic uses the sanitized values.

---

Outside diff comments:
In `@components/session/SessionProvider.tsx`:
- Around line 856-886: handleSkip currently increments focusCountRef and
todayCount and enqueuesSessionUpdate but never calls the backend RPC to persist
the skipped pomodoro; update handleSkip to call increment_profile_stats (or the
existing RPC wrapper used elsewhere) when skipping a focus round, passing the
actual elapsed minutes computed from the new timer state (use newState.totalTime
- newState.timeLeft, converted to minutes) rather than
sessionSettings.durations, and include the same pomos_done value
(focusCountRef.current) so DB stats stay in sync with the UI.
- Around line 470-501: The watcher logging path never sees status === "finished"
because handleExpire triggers broadcasting of the next running state; update the
logic so watchers can log: either modify handleExpire to emit the intermediate
finished state before emitting the next running state (reference handleExpire
and the broadcast function used there), or change the onTimerUpdate logging
callback to detect the post-expiry running broadcast and dedupe by (startedAt,
mode) instead of only startedAt (update the condition in the onTimerUpdate
callback that uses lastLoggedTimerRef and minutes calculation to key by
startedAt + mode and use lastLoggedTimerRef/currentLoggedKey accordingly).
Ensure applyState, onTimerUpdate, and lastLoggedTimerRef are used consistently
so the same session isn't double-counted.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9336759f-3d1d-4362-9b42-4e509cea3ab8

📥 Commits

Reviewing files that changed from the base of the PR and between e721d72 and 1ac1d1c.

📒 Files selected for processing (11)
  • app/profile/[username]/page.tsx
  • components/profile/StatsGrid.tsx
  • components/profile/StreakCalendar.tsx
  • components/profile/WeeklyChart.tsx
  • components/session/KeyboardShortcutsModal.tsx
  • components/session/SessionProvider.tsx
  • hooks/useBonfireState.ts
  • hooks/useProfile.ts
  • supabase/migrations/012_streak_client_date.sql
  • supabase/migrations/013_drop_stale_stat_overloads.sql
  • types/index.ts

Comment thread app/profile/[username]/page.tsx
Comment on lines +6 to +14
// Returns 0 if the streak hasn't been continued recently enough to still be alive.
// Uses a 2-day UTC window to accommodate all timezones (UTC-12 to UTC+14):
// a user in UTC+14 stores last_active_date as their local date which can be
// 1 day ahead of server UTC, so we check >= 2 days ago UTC.
function effectiveStreak(currentStreak: number, lastActiveDate: string | null): number {
if (currentStreak === 0 || !lastActiveDate) return currentStreak
const twoDaysAgo = toDayKey(new Date(Date.now() - 2 * 24 * 60 * 60 * 1000))
return lastActiveDate >= twoDaysAgo ? currentStreak : 0
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Minor: comment undersells the window width.

lastActiveDate >= twoDaysAgo admits three UTC calendar days (today, yesterday, and 2-days-ago), so the effective grace window is ~3 days, not 2. The extra day is defensible as timezone slack (UTC±14 can put the client's stored date up to one day off server UTC), but the comment should reflect reality so the next reader doesn't "fix" it to match the comment:

📝 Suggested comment tweak
-// Returns 0 if the streak hasn't been continued recently enough to still be alive.
-// Uses a 2-day UTC window to accommodate all timezones (UTC-12 to UTC+14):
-// a user in UTC+14 stores last_active_date as their local date which can be
-// 1 day ahead of server UTC, so we check >= 2 days ago UTC.
+// Returns 0 if the streak hasn't been continued recently enough to still be alive.
+// Allows last_active_date as old as 2 days ago UTC (i.e. a ~3-day grace window)
+// to accommodate timezone skew: clients store their local YYYY-MM-DD, which can
+// be ±1 day from server UTC in UTC-12…UTC+14.

Logic itself is fine.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/profile/StatsGrid.tsx` around lines 6 - 14, Update the comment
above function effectiveStreak to accurately describe the grace period as a
~3-day UTC window (today, yesterday, and 2-days-ago) rather than "2-day", and
note this extra day accommodates timezone skew (clients can be up to ±14 hours
or a full calendar day off server UTC); leave the function logic
(effectiveStreak, toDayKey, comparison with twoDaysAgo) unchanged.

Comment on lines +24 to +51
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.55)' }}
onClick={onClose}
>
<div
className="relative rounded-2xl p-6 flex flex-col gap-4 w-full max-w-xs mx-4"
style={{
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
boxShadow: 'var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.4))',
}}
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
Keyboard shortcuts
</h2>
<button
onClick={onClose}
className="rounded-lg p-1 transition-colors"
style={{ color: 'var(--text-muted)' }}
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
</div>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Modal is missing dialog semantics and focus management.

Screen readers won't announce this as a dialog, and keyboard users aren't trapped inside it (Tab will escape to the background, and focus isn't restored to the trigger on close). For a modal overlay this is a real a11y gap.

♿ Minimal fix for dialog semantics
     <div
       className="fixed inset-0 z-50 flex items-center justify-center"
       style={{ background: 'rgba(0,0,0,0.55)' }}
       onClick={onClose}
     >
       <div
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby="kbd-shortcuts-title"
         className="relative rounded-2xl p-6 flex flex-col gap-4 w-full max-w-xs mx-4"
         ...
         onClick={e => e.stopPropagation()}
       >
         <div className="flex items-center justify-between">
-          <h2 className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
+          <h2 id="kbd-shortcuts-title" className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
             Keyboard shortcuts
           </h2>

Consider also auto-focusing the close button on mount and restoring focus to the previously focused element on unmount (or adopting a small focus-trap helper) — otherwise Tab will wander into the background UI behind the overlay.

As per coding guidelines: "Accessibility: interactive elements need aria-labels where text is absent."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.55)' }}
onClick={onClose}
>
<div
className="relative rounded-2xl p-6 flex flex-col gap-4 w-full max-w-xs mx-4"
style={{
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
boxShadow: 'var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.4))',
}}
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
Keyboard shortcuts
</h2>
<button
onClick={onClose}
className="rounded-lg p-1 transition-colors"
style={{ color: 'var(--text-muted)' }}
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
</div>
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.55)' }}
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="kbd-shortcuts-title"
className="relative rounded-2xl p-6 flex flex-col gap-4 w-full max-w-xs mx-4"
style={{
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
boxShadow: 'var(--shadow-lg, 0 8px 32px rgba(0,0,0,0.4))',
}}
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between">
<h2 id="kbd-shortcuts-title" className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
Keyboard shortcuts
</h2>
<button
onClick={onClose}
className="rounded-lg p-1 transition-colors"
style={{ color: 'var(--text-muted)' }}
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
</div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/session/KeyboardShortcutsModal.tsx` around lines 24 - 51, The
KeyboardShortcutsModal lacks dialog semantics and focus management: update the
root modal container rendered by KeyboardShortcutsModal to include
role="dialog", aria-modal="true" and aria-labelledby pointing to the header
(give the <h2> a stable id), add a ref for the close <button> (the X button) and
on mount save document.activeElement, focus the close button, and on unmount
restore the saved element via onClose cleanup; additionally implement a minimal
focus trap inside the modal container so Tab/Shift+Tab cycle between focusable
elements in the modal (prevent Tab from escaping to background). Ensure the
close button retains an aria-label and the header id matches aria-labelledby.

Comment on lines 248 to 265
if (userId && currentMode === "focus") {
const minutes = Math.round(settings.durations.focus);
supabase
.rpc("increment_profile_stats", {
p_user_id: userId,
p_minutes: minutes,
p_session_id: session.id,
})
.then(({ error }) => {
if (error)
console.error("[handleExpire] Failed to log pomodoro:", error);
});
const startedAt = timerStartedAtRef.current;
if (startedAt !== null && lastLoggedTimerRef.current !== startedAt) {
lastLoggedTimerRef.current = startedAt;
const minutes = Math.round(settings.durations.focus);
supabase
.rpc("increment_profile_stats", {
p_user_id: userId,
p_minutes: minutes,
p_session_id: session.id,
p_today: new Date().toLocaleDateString("en-CA"),
})
.then(({ error }) => {
if (error)
console.error("[handleExpire] Failed to log pomodoro:", error);
});
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Minor: minutes source diverges between the two log sites.

Here you use settings.durations.focus (local configured minutes); in onTimerUpdate at line 485 you use the broadcasted state.totalTime / 60. For watchers whose local settings differ from the host's actual run, these two paths will log different durations for the same timer if (pathologically) both paths ever fire. Using the broadcasted totalTime on both sides — or the actual elapsed seconds — would make the two paths consistent and honest about what was actually run.

Also, Math.round(settings.durations.focus) is redundant (duration is already an integer); harmless but noise.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/session/SessionProvider.tsx` around lines 248 - 265, The current
handleExpire block uses Math.round(settings.durations.focus) which diverges from
the onTimerUpdate path that uses the broadcasted state.totalTime/60; change
handleExpire (the code using timerStartedAtRef, lastLoggedTimerRef and the
supabase.rpc("increment_profile_stats") call) to compute minutes from the
broadcasted elapsed seconds (or state.totalTime) rather than
settings.durations.focus, and drop the redundant Math.round; ensure the same
source (broadcasted totalTime converted to minutes or actual elapsed seconds) is
used in both onTimerUpdate and this rpc call so logged minutes are consistent.

Comment thread components/session/SessionProvider.tsx Outdated
Comment thread hooks/useBonfireState.ts
Comment on lines +5 to +22
create or replace function public.increment_profile_stats(
p_user_id uuid,
p_minutes integer,
p_session_id text default null,
p_today date default null
)
returns void language plpgsql security definer set search_path = public as $$
declare
v_last_active date;
v_today date := coalesce(p_today, current_date);
begin
if p_minutes <= 0 then return; end if;

-- Lock the row to prevent concurrent streak miscalculation
select last_active_date into v_last_active
from public.profiles
where id = p_user_id
for update;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

p_today is fully client-controlled in a SECURITY DEFINER function — streaks can be forged.

Because this function runs with definer privileges and uses the caller-supplied p_today verbatim for all streak attribution, a user can trivially call the RPC with arbitrary dates (e.g. last_active_date + 1 day) to keep a streak alive indefinitely without ever completing a session on the right day. If streaks surface on public profiles or leaderboards (they do — see components/profile/StatsGrid.tsx), this is a data-integrity issue, not just a self-foot-gun.

Clamp p_today to a small window around the server date so legitimate timezone differences still work, but arbitrary dates are rejected.

🔒 Suggested clamp against server date (±1 day covers all timezones)
 declare
   v_last_active date;
-  v_today       date := coalesce(p_today, current_date);
+  v_today       date := coalesce(p_today, current_date);
 begin
   if p_minutes <= 0 then return; end if;
+
+  -- p_today is client-supplied; clamp to ±1 day of server date so timezone
+  -- skew is honored but arbitrary dates can't forge streaks.
+  if v_today < current_date - 1 or v_today > current_date + 1 then
+    v_today := current_date;
+  end if;

Also worth considering: cap p_minutes at a sane maximum (e.g. 180) for the same reason — nothing server-side prevents a client from submitting p_minutes = 999999.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/migrations/012_streak_client_date.sql` around lines 5 - 22, The
SECURITY DEFINER function increment_profile_stats currently trusts the
caller-supplied p_today and p_minutes; clamp p_today to a small window around
the server date (e.g., if abs(coalesce(p_today,current_date) - current_date) > 1
day then set v_today := current_date else set v_today :=
coalesce(p_today,current_date)) so client-supplied dates outside ±1 day are
rejected, and also cap p_minutes to a sane maximum (e.g., if p_minutes > 180
then set p_minutes := 180) before any streak/last_active_date logic runs to
prevent forged streaks or inflated session times; make these adjustments at the
start of increment_profile_stats (refer to parameters p_today/p_minutes and
local variable v_today) so all subsequent logic uses the sanitized values.

where id = p_user_id
for update;

if v_last_active = v_today - interval '1 day' then
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Nit: v_today - 1 is the idiomatic date arithmetic.

v_today - interval '1 day' returns a timestamp, which then implicitly casts back to date for the comparison. date - integer stays in date and avoids the cast surprise.

-  if v_last_active = v_today - interval '1 day' then
+  if v_last_active = v_today - 1 then
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/migrations/012_streak_client_date.sql` at line 24, The comparison
uses timestamp arithmetic causing an implicit cast; update the conditional that
currently reads "if v_last_active = v_today - interval '1 day' then" to use date
arithmetic instead by replacing the RHS with "v_today - 1" so v_today and
v_last_active remain dates; locate the conditional referencing v_last_active and
v_today and make this single-token replacement to avoid the timestamp cast.

- Watcher stats: detect focus->break transition via focusCount delta (handles autoStartBreaks=true where host never broadcasts finished state)
- handleSkip: remove counter increments - skip = no credit, no DB write
- Calendar grid: anchor end on coming Sunday so today always appears in a cell
- BonfireState: add surge timer cleanup on unmount to prevent leak
- KeyboardShortcutsModal: add role/aria-modal/aria-labelledby + focus management (save/restore on open/close)
- Migration 014: sanitize p_minutes (clamp 1-180) and p_today (clamp +-1 day) in increment_profile_stats

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@MinitJain MinitJain merged commit 6b033f5 into main Apr 21, 2026
4 checks passed
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.

1 participant