Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 17 additions & 18 deletions app/profile/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,40 +45,39 @@ export async function generateMetadata({ params }: ProfilePageProps): Promise<Me
}
}

// Build the 364-cell calendar grid (52 weeks × 7 days, Mon → Sun, oldest first)
function buildCalendarCells(dayMap: Record<string, number>) {
const today = new Date()
// UTC midnight for a given UTC year/month/day offset
function utcDay(offsetDays = 0): Date {
const now = new Date()
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + offsetDays))
}

// Find the Monday 53 weeks ago
const start = new Date(today)
start.setDate(start.getDate() - 371)
// Rewind to the nearest Monday on or before `start`
const dow = start.getDay() // 0=Sun…6=Sat
start.setDate(start.getDate() - (dow === 0 ? 6 : dow - 1))
// Build the 371-cell calendar grid (53 weeks × 7 days, Mon → Sun, oldest first)
// All date arithmetic is in UTC to match toDayKey output and pomodoro_logs timestamps.
function buildCalendarCells(dayMap: Record<string, number>) {
const start = utcDay(-371)
// Rewind to nearest Monday on or before start (UTC day-of-week)
const dow = start.getUTCDay() // 0=Sun…6=Sat
start.setUTCDate(start.getUTCDate() - (dow === 0 ? 6 : dow - 1))

const cells: { date: string; minutes: number }[] = []
const cur = new Date(start)
while (cells.length < 53 * 7) {
const dateStr = toDayKey(cur)
cells.push({
date: dateStr,
minutes: dayMap[dateStr] ?? 0,
})
cur.setDate(cur.getDate() + 1)
cells.push({ date: dateStr, minutes: dayMap[dateStr] ?? 0 })
cur.setUTCDate(cur.getUTCDate() + 1)
}
return cells
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Build last-7-days bars with labels
// All date arithmetic is in UTC to match toDayKey output.
function buildWeekDays(dayMap: Record<string, number>) {
const today = new Date()
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(today)
d.setDate(d.getDate() - (6 - i))
const d = utcDay(i - 6)
const dateStr = toDayKey(d)
return {
date: dateStr,
label: d.toLocaleDateString('en-US', { weekday: 'short' }),
label: d.toLocaleDateString('en-US', { weekday: 'short', timeZone: 'UTC' }),
minutes: dayMap[dateStr] ?? 0,
isToday: i === 6,
}
Expand Down
18 changes: 15 additions & 3 deletions components/profile/StatsGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
import { Timer, Flame, Trophy, Clock } from 'lucide-react'
import type { Profile } from '@/types'
import { toDayKey } from '@/lib/date'
import { cn } from '@/lib/utils'

// 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
}
Comment on lines +6 to +14
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.


interface StatsGridProps {
profile: Profile
className?: string
Expand Down Expand Up @@ -58,7 +69,8 @@ function StatCard({ icon, label, value, suffix, highlight }: StatCardProps) {
}

export function StatsGrid({ profile, className }: StatsGridProps) {
const focusHours = Math.round(profile.total_focus_minutes / 60)
const focusHours = Math.floor(profile.total_focus_minutes / 60)
const streak = effectiveStreak(profile.current_streak, profile.last_active_date)

if (profile.total_pomodoros === 0) {
return (
Expand Down Expand Up @@ -95,9 +107,9 @@ export function StatsGrid({ profile, className }: StatsGridProps) {
<StatCard
icon={<Flame className="w-4 h-4" />}
label="Current Streak"
value={profile.current_streak}
value={streak}
suffix="days"
highlight={profile.current_streak > 0}
highlight={streak > 0}
/>
<StatCard
icon={<Trophy className="w-4 h-4" />}
Expand Down
6 changes: 3 additions & 3 deletions components/profile/StreakCalendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@ export function StreakCalendar({ cells, totalMinutesYear, totalPomodorosYear }:
let lastMonth = -1
cells.forEach((cell, i) => {
const col = Math.floor(i / 7)
const month = new Date(cell.date + 'T00:00:00').getMonth()
const month = new Date(cell.date + 'T12:00:00Z').getUTCMonth()
if (month !== lastMonth) {
lastMonth = month
const label = new Date(cell.date + 'T00:00:00').toLocaleDateString('en-US', { month: 'short' })
const label = new Date(cell.date + 'T12:00:00Z').toLocaleDateString('en-US', { month: 'short', timeZone: 'UTC' })
// Only add if there's room (not last column)
if (col < WEEKS - 1) monthLabels.push({ label, col })
}
Expand Down Expand Up @@ -132,7 +132,7 @@ export function StreakCalendar({ cells, totalMinutesYear, totalPomodorosYear }:
{/* Legend */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-[var(--text-muted)]">Less</span>
{[0, 25, 60, 120, 180].map(m => (
{[0, 1, 25, 60, 120].map(m => (
<div
key={m}
style={{
Expand Down
2 changes: 1 addition & 1 deletion components/profile/WeeklyChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ interface WeeklyChartProps {
export function WeeklyChart({ days }: WeeklyChartProps) {
const maxMinutes = Math.max(...days.map(d => d.minutes), 30) // floor at 30 so bars aren't full-height for tiny values
const totalMinutes = days.reduce((s, d) => s + d.minutes, 0)
const totalHours = (totalMinutes / 60).toFixed(1)
const totalHours = Math.floor(totalMinutes / 60)

return (
<div
Expand Down
80 changes: 80 additions & 0 deletions components/session/KeyboardShortcutsModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
'use client'

import { useEffect } from 'react'
import { X } from 'lucide-react'

interface KeyboardShortcutsModalProps {
onClose: () => void
}

const SHORTCUTS = [
{ keys: ['Space'], description: 'Start / pause timer' },
{ keys: ['?'], description: 'Toggle this help modal' },
]

export function KeyboardShortcutsModal({ onClose }: KeyboardShortcutsModalProps) {
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [onClose])

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>
Comment on lines +38 to +73
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.


<div className="flex flex-col gap-2">
{SHORTCUTS.map(({ keys, description }) => (
<div key={description} className="flex items-center justify-between gap-4">
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{description}
</span>
<div className="flex items-center gap-1 shrink-0">
{keys.map(k => (
<kbd
key={k}
className="px-2 py-0.5 rounded text-xs font-mono font-medium"
style={{
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
>
{k}
</kbd>
))}
</div>
</div>
))}
</div>
</div>
</div>
)
}
Loading