diff --git a/components/session/SessionProvider.tsx b/components/session/SessionProvider.tsx index 9365208..d4406da 100644 --- a/components/session/SessionProvider.tsx +++ b/components/session/SessionProvider.tsx @@ -22,6 +22,7 @@ import { BreakOverlay } from '@/components/session/BreakOverlay' import { GuestNicknamePrompt } from '@/components/session/GuestNicknamePrompt' import { SettingsRequestCard } from '@/components/session/SettingsRequestCard' import { AmbientPlayer } from '@/components/session/AmbientPlayer' +import { StatsTab } from '@/components/session/StatsTab' import { ModeTipBubble } from '@/components/session/ModeTipBubble' import { KeyboardShortcutsModal } from '@/components/session/KeyboardShortcutsModal' import { ToastProvider, useToast } from '@/components/ui/Toast' @@ -55,6 +56,7 @@ function SessionContent({ avatarUrl, }: SessionProviderProps) { const [isHost, setIsHost] = useState(isHostProp) + const [activeTab, setActiveTab] = useState<'timer' | 'tasks' | 'stats'>('timer') const [sessionMode, setSessionMode] = useState<'host' | 'jam' | 'solo'>(session.session_mode ?? 'host') const [isPublic, setIsPublic] = useState(session.is_public ?? true) const [showBreakOverlay, setShowBreakOverlay] = useState(false) @@ -70,8 +72,10 @@ function SessionContent({ autoStartBreaks: session.settings?.autoStartBreaks ?? false, autoStartPomodoros: session.settings?.autoStartPomodoros ?? false, }) - const focusCountRef = useRef(0) - const [focusCount, setFocusCount] = useState(0) + const initialPomosDone = session.pomos_done ?? 0 + const focusCountRef = useRef(initialPomosDone) + const [focusCount, setFocusCount] = useState(initialPomosDone) + const [todayCount, setTodayCount] = useState(null) const [activities, setActivities] = useState([]) const sessionLogRef = useRef([]) const totalLogCountRef = useRef(0) @@ -102,6 +106,19 @@ function SessionContent({ const supabase = useMemo(() => createClient(), []) const { toast } = useToast() + // Fetch today's completed pomodoro count for the counter badge + useEffect(() => { + if (!userId) return + const today = new Date() + today.setHours(0, 0, 0, 0) + supabase + .from('pomodoro_logs') + .select('*', { count: 'exact', head: true }) + .eq('user_id', userId) + .gte('completed_at', today.toISOString()) + .then(({ count }) => { setTodayCount(count ?? 0) }) + }, [userId, supabase]) + // Guest host detection via localStorage useEffect(() => { if (!isHostProp) { @@ -170,12 +187,13 @@ function SessionContent({ if (currentMode === 'focus' && settings.autoStartBreaks) { focusCountRef.current += 1 setFocusCount(focusCountRef.current) + setTodayCount(prev => (prev ?? 0) + 1) const nextMode = focusCountRef.current % settings.rounds === 0 ? 'long' : 'short' const newState = skipAndStartRef.current?.(nextMode, durations) if (!newState) return if (canControlRef.current) { broadcastTimerStateRef.current?.(newState) - enqueueSessionUpdate({ running: true, time_left: newState.timeLeft, total_time: newState.totalTime, mode: newState.mode }) + enqueueSessionUpdate({ running: true, time_left: newState.timeLeft, total_time: newState.totalTime, mode: newState.mode, pomos_done: focusCountRef.current }) } } else if ((currentMode === 'short' || currentMode === 'long') && settings.autoStartPomodoros) { const newState = skipAndStartRef.current?.('focus', durations) @@ -554,9 +572,11 @@ function SessionContent({ const handleSkip = useCallback(() => { let nextMode: TimerMode - if (mode === 'focus') { + const skippingFocus = mode === 'focus' + if (skippingFocus) { focusCountRef.current += 1 setFocusCount(focusCountRef.current) + setTodayCount(prev => (prev ?? 0) + 1) nextMode = focusCountRef.current % sessionSettings.rounds === 0 ? 'long' : 'short' } else { nextMode = 'focus' @@ -572,7 +592,13 @@ function SessionContent({ setShowBreakOverlay(false) if (canControl) { broadcastWithCount(newState) - enqueueSessionUpdate({ running: false, time_left: newState.timeLeft, total_time: newState.totalTime, mode: newState.mode }) + enqueueSessionUpdate({ + running: false, + time_left: newState.timeLeft, + total_time: newState.totalTime, + mode: newState.mode, + ...(skippingFocus ? { pomos_done: focusCountRef.current } : {}), + }) } }, [mode, actorName, setMode, canControl, broadcastWithCount, broadcastActivity, sessionSettings.durations, sessionSettings.rounds, enqueueSessionUpdate]) @@ -595,8 +621,6 @@ function SessionContent({ const handleApplySettings = useCallback(async (newSettings: SessionSettings) => { setSessionSettings(newSettings) setShowSettings(false) - focusCountRef.current = 0 - setFocusCount(0) const newState = reset(toSecs(newSettings.durations)) if (canControl) { const { error } = await supabase.from('sessions').update({ @@ -675,11 +699,7 @@ function SessionContent({ }, [pendingRequest, broadcastSettingsResponse]) const progress = computeProgress(timerState) - const isFirstRoundIdle = focusCount === 0 && mode === 'focus' - const focusRoundsLeft = sessionSettings.rounds - ((focusCount % sessionSettings.rounds) + 1) - const roundLabel = mode === 'focus' - ? `Round ${focusCount + 1} · ${focusRoundsLeft === 0 ? 'long break next' : `long break after ${focusRoundsLeft} more`}` - : `Round ${focusCount} of ${sessionSettings.rounds} · ${mode === 'long' ? 'long break' : 'short break'}` + const roundLabel = `${focusCount} pomodoro${focusCount !== 1 ? 's' : ''} completed` return (
- {/* Room name — centered absolutely so it doesn't shift the side controls */} - {session.title && ( - - {session.title} - - )} + {/* Tab nav — centered absolutely */} +
+ {(['timer', 'tasks', 'stats'] as const).map((tab) => { + const label = tab.charAt(0).toUpperCase() + tab.slice(1) + const active = activeTab === tab + return ( + + ) + })} +
@@ -749,8 +779,37 @@ function SessionContent({
)} + {/* Stats tab */} + {activeTab === 'stats' && ( +
+ +
+ )} + + {/* Tasks tab */} + {activeTab === 'tasks' && ( +
+

Tasks

+

Coming in an upcoming update.

+
+ )} + {/* Main */} -
+
+ {/* Room name */} + {session.title && ( +
+

Room

+

+ {session.title} +

+
+ )} + {/* Timer card */}
- {/* Round indicator — always visible; dimmed on round 1 idle */} + {/* Round indicator */}

{roundLabel}

+ {/* Today's pomodoro count — auth users only */} + {userId && todayCount !== null && ( +

+ #{todayCount} today +

+ )} + {/* Timer ring */}
diff --git a/components/session/StatsTab.tsx b/components/session/StatsTab.tsx new file mode 100644 index 0000000..2afdc7e --- /dev/null +++ b/components/session/StatsTab.tsx @@ -0,0 +1,269 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { createClient } from '@/lib/supabase/client' +import { WeeklyChart } from '@/components/profile/WeeklyChart' +import { StreakCalendar } from '@/components/profile/StreakCalendar' +import { Avatar } from '@/components/ui/Avatar' +import { toDayKey } from '@/lib/date' + +interface TodayStats { + pomodoros: number + minutes: number + sessions: number +} + +interface LifetimeStats { + total_pomodoros: number + total_focus_minutes: number + current_streak: number + longest_streak: number + display_name: string | null + bio: string | null +} + +interface DayBar { + date: string + label: string + minutes: number + isToday: boolean +} + +interface CalendarCell { + date: string + minutes: number +} + +function buildWeekDays(dayMap: Record): DayBar[] { + const today = new Date() + return Array.from({ length: 7 }, (_, i) => { + const d = new Date(today) + d.setDate(d.getDate() - (6 - i)) + const dateStr = toDayKey(d) + return { + date: dateStr, + label: d.toLocaleDateString('en-US', { weekday: 'short' }), + minutes: dayMap[dateStr] ?? 0, + isToday: i === 6, + } + }) +} + +function buildCalendarCells(dayMap: Record): CalendarCell[] { + const today = new Date() + const start = new Date(today) + start.setDate(start.getDate() - 371) + const dow = start.getDay() + start.setDate(start.getDate() - (dow === 0 ? 6 : dow - 1)) + const cells: CalendarCell[] = [] + 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) + } + return cells +} + +function formatDuration(minutes: number): string { + if (minutes === 0) return '0m' + if (minutes < 60) return `${minutes}m` + return `${(minutes / 60).toFixed(1)}h` +} + +interface StatsTabProps { + userId: string | null + username: string | null + avatarUrl: string | null +} + +export function StatsTab({ userId, username, avatarUrl }: StatsTabProps) { + const supabase = useMemo(() => createClient(), []) + const [today, setToday] = useState(null) + const [lifetime, setLifetime] = useState(null) + const [dayMap, setDayMap] = useState>({}) + const [yearStats, setYearStats] = useState({ totalMinutes: 0, totalPomodoros: 0 }) + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (!userId) { setLoading(false); return } + + const oneYearAgo = new Date() + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1) + + Promise.all([ + supabase + .from('pomodoro_logs') + .select('duration_minutes, completed_at, session_id') + .eq('user_id', userId) + .gte('completed_at', oneYearAgo.toISOString()), + supabase + .from('profiles') + .select('total_pomodoros, total_focus_minutes, current_streak, longest_streak, display_name, bio') + .eq('id', userId) + .single(), + ]).then(([{ data: logs }, { data: profile }]) => { + if (logs) { + const todayStr = toDayKey(new Date()) + const map: Record = {} + let totalMinutes = 0 + let totalPomodoros = 0 + let todayPomodoros = 0 + let todayMinutes = 0 + const todaySessions = new Set() + + for (const log of logs) { + const dateStr = toDayKey(log.completed_at) + map[dateStr] = (map[dateStr] ?? 0) + log.duration_minutes + totalMinutes += log.duration_minutes + totalPomodoros += 1 + if (dateStr === todayStr) { + todayPomodoros += 1 + todayMinutes += log.duration_minutes + if (log.session_id) todaySessions.add(log.session_id) + } + } + + setDayMap(map) + setYearStats({ totalMinutes, totalPomodoros }) + setToday({ pomodoros: todayPomodoros, minutes: todayMinutes, sessions: todaySessions.size }) + } + if (profile) setLifetime(profile as LifetimeStats) + setLoading(false) + }) + }, [userId, supabase]) + + const weekDays = useMemo(() => buildWeekDays(dayMap), [dayMap]) + const calendarCells = useMemo(() => buildCalendarCells(dayMap), [dayMap]) + + const todayLabel = new Date().toLocaleDateString('en-US', { + weekday: 'long', month: 'short', day: 'numeric', + }) + + if (!userId) { + return ( +
+

Sign in to see your stats

+
+ ) + } + + const todayPills = [ + { label: 'POMODOROS', value: loading ? '—' : String(today?.pomodoros ?? 0) }, + { label: 'FOCUS MIN', value: loading ? '—' : String(today?.minutes ?? 0) }, + { label: 'SESSIONS', value: loading ? '—' : String(today?.sessions ?? 0) }, + { label: 'DURATION', value: loading ? '—' : formatDuration(today?.minutes ?? 0) }, + ] + + const lifetimePills = [ + { label: 'TOTAL POMODOROS', value: loading ? '—' : String(lifetime?.total_pomodoros ?? 0) }, + { label: 'TOTAL FOCUS HOURS', value: loading ? '—' : (lifetime ? (lifetime.total_focus_minutes / 60).toFixed(1) : '0') }, + { label: 'CURRENT STREAK', value: loading ? '—' : `${lifetime?.current_streak ?? 0} Days` }, + { label: 'LONGEST STREAK', value: loading ? '—' : `${lifetime?.longest_streak ?? 0} Days` }, + ] + + return ( +
+ {/* Left column */} +
+ {/* Today header */} +
+

Today

+ {todayLabel} +
+ + {/* Today stat pills */} +
+ {todayPills.map(({ label, value }) => ( +
+ + {label} + + + {value} + +
+ ))} +
+ + {/* Weekly Momentum */} + + + {/* Lifetime Mastery */} +
+

Lifetime Mastery

+
+ {lifetimePills.map(({ label, value }) => ( +
+ + {label} + + + {value} + +
+ ))} +
+
+ + {/* Activity Heatmap */} + +
+ + {/* Right column */} +
+ {/* Profile card */} +
+ +
+

+ {lifetime?.display_name ?? username ?? 'You'} +

+ {lifetime?.bio && ( +

+ {lifetime.bio} +

+ )} +
+
+ + {/* Tasks placeholder */} +
+
+

Focus Tasks

+ + Coming soon + +
+

+ Task tracking arrives in an upcoming update. +

+
+
+
+ ) +}