Skip to content

Commit fa9db35

Browse files
vayungodaraclaude
andcommitted
fix: daily triage 2026-04-14 — Stats page + landing TTFB
Closes 5 Notion findings: - **LI-302** MonthlyCalendar cutoff: grid was clipping weeks 2-6 vertically (overflow: hidden). Changed to overflow-x: clip + explicit grid-template-rows for all 6 week rows. - **LI-300** Streak mismatch: TodayBar read denormalized profiles.current_streak; Stats called calculateStreak(). Unified — TodayBar now also uses calculateStreak() in its Promise.all so both surfaces share one source. - **LI-314** StatsPageClient fetched 5000 pacts + 5000 focus sessions then filtered in JS. Replaced with 12 parallel Supabase count queries (head: true). - **LI-334** Stats page polish: added fadeInUp entrance + editorial streak row (promoted count to text-3xl bold, pluralized Best: N day(s), semantic 3-slot layout). Removed AI-slop identical-card hover (translateY) — kept only border-color transition per dashboard calm-motion principle. Replaced deprecated Fire icon with Flame. - **LI-337** Landing TTFB: app/page.js had force-dynamic + server getUser() adding ~3s TTFB. Moved authenticated redirect fully into lib/supabase/middleware.js (with validated returnTo), dropped force-dynamic. Landing now renders as ○ (Static) — verified via build route table. Also: fixed unused `options` destructure in middleware cookie forEach. LI-270 (partnerships N+1) was already fixed in a prior commit — code uses supabase.rpc('notify_partner') with p_recipients array (not a loop). Notion row updated to reflect. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bf340ef commit fa9db35

6 files changed

Lines changed: 165 additions & 118 deletions

File tree

app/dashboard/stats/StatsPage.module.css

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -39,24 +39,37 @@
3939
gap: var(--space-6);
4040
}
4141

42-
/* Streak — compact inline summary */
43-
.streakInline {
44-
font-size: var(--text-sm);
45-
font-weight: 500;
46-
color: var(--text-secondary);
47-
margin: 0;
42+
/* Streak — hero stat row with weighted hierarchy */
43+
.streakRow {
4844
display: flex;
49-
align-items: center;
50-
gap: var(--space-2);
45+
align-items: baseline;
5146
flex-wrap: wrap;
47+
gap: var(--space-4);
48+
margin: 0;
5249
}
5350

54-
.streakEmoji {
55-
font-size: 1.1rem;
51+
.streakPrimary {
52+
display: inline-flex;
53+
align-items: baseline;
54+
gap: var(--space-2);
55+
font-family: var(--font-display);
56+
font-size: var(--text-3xl);
57+
font-weight: 700;
58+
color: var(--text-primary);
59+
letter-spacing: -0.02em;
5660
line-height: 1;
5761
}
5862

59-
.streakSep {
63+
.streakPrimaryLabel {
64+
font-family: var(--font-sans);
65+
font-size: var(--text-base);
66+
font-weight: 500;
67+
color: var(--text-secondary);
68+
letter-spacing: 0;
69+
}
70+
71+
.streakMeta {
72+
font-size: var(--text-sm);
6073
color: var(--text-tertiary);
6174
}
6275

@@ -79,6 +92,11 @@
7992
border: 1px solid var(--border-subtle);
8093
border-radius: var(--radius-lg);
8194
padding: var(--space-5);
95+
transition: border-color var(--transition-fast);
96+
}
97+
98+
.analyticsCard:hover {
99+
border-color: var(--border-default);
82100
}
83101

84102
.analyticsCard h3 {

app/dashboard/stats/StatsPageClient.js

Lines changed: 92 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
'use client';
22

33
import { useState, useEffect, useCallback, useMemo } from 'react';
4+
import { motion } from 'framer-motion';
45
import { createClient } from '@/lib/supabase/client';
56
import { calculateStreak } from '@/lib/streaks';
67
import MonthlyCalendar from '@/components/MonthlyCalendar';
78
import EmptyState from '@/components/EmptyState';
89
import { SkeletonCard, SkeletonText } from '@/components/Skeleton';
9-
import { Fire } from '@phosphor-icons/react';
10+
import { Flame } from '@phosphor-icons/react';
11+
import { fadeInUp } from '@/lib/animations';
1012
import styles from './StatsPage.module.css';
1113

1214
export default function StatsPageClient({ user }) {
@@ -25,6 +27,9 @@ export default function StatsPageClient({ user }) {
2527
weekAgo.setDate(weekAgo.getDate() - 7);
2628
const monthAgo = new Date(now);
2729
monthAgo.setMonth(monthAgo.getMonth() - 1);
30+
const sevenDaysAgo = new Date();
31+
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
32+
sevenDaysAgo.setHours(0, 0, 0, 0);
2833

2934
// Detect the user's IANA timezone so streak calculations bucket
3035
// activity into their local day (matches DashboardLayout persistence).
@@ -35,84 +40,91 @@ export default function StatsPageClient({ user }) {
3540
// Intl API unavailable — fall back to UTC
3641
}
3742

38-
// Fetch streak data using the shared calculation from lib/streaks
39-
const streak = await calculateStreak(supabase, user.id, timezone);
40-
setStreakData(streak);
43+
const uid = user.id;
44+
45+
// Run streak + all count/lightweight queries in parallel. Count
46+
// queries (head: true) return only a count, not rows — a massive
47+
// win over the previous "fetch 5000 rows then filter in JS" pattern.
48+
const [
49+
streak,
50+
totalPactsRes,
51+
completedPactsRes,
52+
missedPactsRes,
53+
activePactsRes,
54+
thisWeekCompletedRes,
55+
thisMonthCompletedRes,
56+
focusTotalsRes,
57+
thisWeekFocusRes,
58+
thisMonthFocusRes,
59+
firstFocusRes,
60+
recentSessionsRes,
61+
] = await Promise.all([
62+
calculateStreak(supabase, uid, timezone),
63+
// Pact counts
64+
supabase.from('pacts').select('*', { count: 'exact', head: true }).eq('user_id', uid),
65+
supabase.from('pacts').select('*', { count: 'exact', head: true }).eq('user_id', uid).eq('status', 'completed'),
66+
supabase.from('pacts').select('*', { count: 'exact', head: true }).eq('user_id', uid).eq('status', 'missed'),
67+
supabase.from('pacts').select('*', { count: 'exact', head: true }).eq('user_id', uid).eq('status', 'active'),
68+
supabase.from('pacts').select('*', { count: 'exact', head: true })
69+
.eq('user_id', uid).eq('status', 'completed').gte('completed_at', weekAgo.toISOString()),
70+
supabase.from('pacts').select('*', { count: 'exact', head: true })
71+
.eq('user_id', uid).eq('status', 'completed').gte('completed_at', monthAgo.toISOString()),
72+
// Focus totals — only duration_minutes column, needed for lifetime sum/avg
73+
supabase.from('focus_sessions').select('duration_minutes').eq('user_id', uid),
74+
supabase.from('focus_sessions').select('*', { count: 'exact', head: true })
75+
.eq('user_id', uid).gte('started_at', weekAgo.toISOString()),
76+
supabase.from('focus_sessions').select('*', { count: 'exact', head: true })
77+
.eq('user_id', uid).gte('started_at', monthAgo.toISOString()),
78+
// Earliest session for avg-per-day denominator
79+
supabase.from('focus_sessions').select('started_at').eq('user_id', uid)
80+
.order('started_at', { ascending: true }).limit(1),
81+
// Recent 7 days of sessions for the "Recent Focus Sessions" list
82+
supabase.from('focus_sessions').select('id, started_at, duration_minutes, ended_at')
83+
.eq('user_id', uid).gte('started_at', sevenDaysAgo.toISOString())
84+
.order('started_at', { ascending: false }).limit(20),
85+
]);
86+
87+
if (totalPactsRes.error) throw totalPactsRes.error;
88+
if (focusTotalsRes.error) throw focusTotalsRes.error;
89+
if (recentSessionsRes.error) throw recentSessionsRes.error;
4190

42-
// Fetch pact stats
43-
const { data: pacts, error: pactsError } = await supabase
44-
.from('pacts')
45-
.select('status, completed_at, created_at')
46-
.eq('user_id', user.id)
47-
.order('created_at', { ascending: false })
48-
.limit(5000);
49-
50-
if (pactsError) throw pactsError;
91+
setStreakData(streak);
5192

52-
const completedCount = (pacts || []).filter(p => p.status === 'completed').length;
53-
const missedCount = (pacts || []).filter(p => p.status === 'missed').length;
54-
const activeCount = (pacts || []).filter(p => p.status === 'active').length;
55-
const thisWeekCompleted = (pacts || []).filter(p =>
56-
p.status === 'completed' && p.completed_at && new Date(p.completed_at) >= weekAgo
57-
).length;
58-
const thisMonthCompleted = (pacts || []).filter(p =>
59-
p.status === 'completed' && p.completed_at && new Date(p.completed_at) >= monthAgo
60-
).length;
93+
const completedCount = completedPactsRes.count || 0;
94+
const missedCount = missedPactsRes.count || 0;
95+
const activeCount = activePactsRes.count || 0;
6196

6297
setPactStats({
63-
total: (pacts || []).length,
98+
total: totalPactsRes.count || 0,
6499
completed: completedCount,
65100
missed: missedCount,
66101
active: activeCount,
67-
thisWeek: thisWeekCompleted,
68-
thisMonth: thisMonthCompleted,
102+
thisWeek: thisWeekCompletedRes.count || 0,
103+
thisMonth: thisMonthCompletedRes.count || 0,
69104
completionRate: completedCount + missedCount > 0
70105
? Math.round((completedCount / (completedCount + missedCount)) * 100)
71-
: 0
106+
: 0,
72107
});
73108

74-
// Fetch focus stats
75-
const { data: sessions, error: focusError } = await supabase
76-
.from('focus_sessions')
77-
.select('id, started_at, duration_minutes, ended_at')
78-
.eq('user_id', user.id)
79-
.order('started_at', { ascending: false })
80-
.limit(5000);
81-
82-
if (focusError) throw focusError;
83-
84-
const totalMinutes = (sessions || []).reduce((acc, s) => acc + (s.duration_minutes || 0), 0);
85-
const sessionsCount = (sessions || []).length;
86-
const thisWeekSessions = (sessions || []).filter(s =>
87-
s.started_at && new Date(s.started_at) >= weekAgo
88-
).length;
89-
const thisMonthSessions = (sessions || []).filter(s =>
90-
s.started_at && new Date(s.started_at) >= monthAgo
91-
).length;
92-
const daysSinceFirst = sessionsCount > 0
93-
? Math.max(1, Math.ceil((now - new Date((sessions || [])[(sessions || []).length - 1].started_at)) / (1000 * 60 * 60 * 24)))
109+
const focusSessions = focusTotalsRes.data || [];
110+
const totalMinutes = focusSessions.reduce((acc, s) => acc + (s.duration_minutes || 0), 0);
111+
const sessionsCount = focusSessions.length;
112+
const firstStartedAt = firstFocusRes.data?.[0]?.started_at;
113+
const daysSinceFirst = firstStartedAt
114+
? Math.max(1, Math.ceil((now - new Date(firstStartedAt)) / (1000 * 60 * 60 * 24)))
94115
: 1;
95116
const avgPerDay = sessionsCount > 0 ? Math.round(totalMinutes / daysSinceFirst) : 0;
96117

97118
setFocusStats({
98119
totalMinutes,
99120
sessionsCount,
100121
avgDuration: sessionsCount > 0 ? Math.round(totalMinutes / sessionsCount) : 0,
101-
thisWeekSessions,
102-
thisMonthSessions,
103-
avgPerDay
122+
thisWeekSessions: thisWeekFocusRes.count || 0,
123+
thisMonthSessions: thisMonthFocusRes.count || 0,
124+
avgPerDay,
104125
});
105126

106-
// Get recent sessions (last 7 days, grouped by day)
107-
const sevenDaysAgo = new Date();
108-
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
109-
sevenDaysAgo.setHours(0, 0, 0, 0);
110-
111-
const recentSessionsData = (sessions || [])
112-
.filter(s => new Date(s.started_at) >= sevenDaysAgo)
113-
.slice(0, 20);
114-
115-
setRecentSessions(recentSessionsData);
127+
setRecentSessions(recentSessionsRes.data || []);
116128

117129
} catch (err) {
118130
console.error('Error fetching stats:', err);
@@ -203,12 +215,17 @@ export default function StatsPageClient({ user }) {
203215

204216
return (
205217
<div className={styles.container}>
206-
<header className={styles.header}>
218+
<motion.header
219+
className={styles.header}
220+
variants={fadeInUp}
221+
initial="initial"
222+
animate="animate"
223+
>
207224
<div>
208225
<h1>Your Stats</h1>
209226
<p className={styles.subtitle}>Track your productivity and progress</p>
210227
</div>
211-
</header>
228+
</motion.header>
212229

213230
<div className={styles.content}>
214231
{/* Top-level empty state when user has zero activity */}
@@ -233,15 +250,20 @@ export default function StatsPageClient({ user }) {
233250
/>
234251
)}
235252

236-
{/* Streak Summary — compact inline */}
237-
<p className={styles.streakInline}>
238-
<Fire size={18} weight="fill" color="var(--urgency-amber)" style={{ verticalAlign: 'text-bottom', display: 'inline' }} />{' '}
239-
{streakData.currentStreak} day streak{' '}
240-
<span className={styles.streakSep}>&middot;</span>{' '}
241-
Best: {streakData.longestStreak} day{' '}
242-
<span className={styles.streakSep}>&middot;</span>{' '}
243-
{streakData.totalCompleted} completed
244-
</p>
253+
{/* Streak Summary — hero row */}
254+
<div className={styles.streakRow}>
255+
<span className={styles.streakPrimary}>
256+
<Flame size={22} weight="fill" color="var(--urgency-amber)" />
257+
{streakData.currentStreak}
258+
<span className={styles.streakPrimaryLabel}>day streak</span>
259+
</span>
260+
<span className={styles.streakMeta}>
261+
Best: {streakData.longestStreak} {streakData.longestStreak === 1 ? 'day' : 'days'}
262+
</span>
263+
<span className={styles.streakMeta}>
264+
{streakData.totalCompleted} completed
265+
</span>
266+
</div>
245267

246268
{/* Activity Calendar */}
247269
<MonthlyCalendar userId={user.id} />

app/page.js

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,10 @@
1-
import { createClient } from '@/lib/supabase/server'
2-
import { redirect } from 'next/navigation'
31
import LandingPageClient from '@/components/LandingPageClient'
42

5-
export const dynamic = 'force-dynamic'
6-
73
export const metadata = {
84
title: 'LockIn - Stop Procrastinating, Start Delivering',
95
description: 'The accountability app that uses social pressure to help you follow through.',
106
}
117

12-
export default async function HomePage({ searchParams }) {
13-
const supabase = await createClient()
14-
const { data: { user } } = await supabase.auth.getUser()
15-
const params = await searchParams
16-
17-
if (user && params?.preview !== 'true') {
18-
let destination = '/dashboard';
19-
const returnTo = params?.returnTo;
20-
if (typeof returnTo === 'string' && returnTo.startsWith('/') && !returnTo.startsWith('//')) {
21-
destination = returnTo;
22-
}
23-
redirect(destination)
24-
}
25-
26-
return <LandingPageClient isAuthenticated={!!user} returnTo={params?.returnTo} />
8+
export default function HomePage() {
9+
return <LandingPageClient />
2710
}

components/MonthlyCalendar.module.css

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,9 @@
102102

103103
/* Calendar Section */
104104
.calendarSection {
105-
overflow: hidden;
105+
/* Clip horizontal slide-animation overflow without hiding vertical rows.
106+
Previously `overflow: hidden` was clipping rows 2-6 of the month grid. */
107+
overflow-x: clip;
106108
}
107109

108110
.weekdayRow {
@@ -123,6 +125,9 @@
123125
.daysGrid {
124126
display: grid;
125127
grid-template-columns: repeat(7, 1fr);
128+
/* Explicit 6 rows so every week of the month renders — prevents the
129+
first-row-only cutoff that occurred when rows 2-6 had no guaranteed height. */
130+
grid-template-rows: repeat(6, minmax(var(--cell-size), 1fr));
126131
gap: var(--cell-gap);
127132
}
128133

components/TodayBar.js

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useState, useEffect, useMemo } from 'react';
44
import { motion, AnimatePresence } from 'framer-motion';
55
import { createClient } from '@/lib/supabase/client';
66
import { fadeInUp, streakCelebration } from '@/lib/animations';
7+
import { calculateStreak } from '@/lib/streaks';
78
import { checkStreakAtRisk, applyStreakFreeze, getStreakFreezeStatus, FREEZE_COOLDOWN_DAYS } from '@/lib/streaks-advanced';
89
import { fireMilestoneConfetti } from '@/lib/confetti';
910
import { playStreakMilestone } from '@/lib/sounds';
@@ -131,28 +132,24 @@ export default function TodayBar({ userId, refreshKey, currentStreak, longestStr
131132
(sum, s) => sum + (s.duration_minutes || 0), 0
132133
);
133134

134-
// If last_activity_date is more than 1 day ago, streak is broken
135-
// regardless of what current_streak says (cron may not have reset it).
136-
let streak = profile?.current_streak || 0;
137-
if (streak > 0 && profile?.last_activity_date) {
138-
const now = new Date();
139-
const todayUTC = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
140-
const lastActivity = new Date(profile.last_activity_date + 'T00:00:00Z').getTime();
141-
const daysSince = Math.round((todayUTC - lastActivity) / (1000 * 60 * 60 * 24));
142-
if (daysSince > 1) streak = 0;
143-
}
144-
145-
// Resolve user's local timezone for streak risk calculation so
146-
// "at risk" matches the day boundary they see locally.
135+
// Resolve user's local timezone so streak + risk calculations use
136+
// the user's local day boundary (matches Stats page / Share page).
147137
let timezone = 'UTC';
148138
try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch {}
149139

150-
// Check streak risk and freeze status in parallel
151-
const [risk, freeze] = await Promise.all([
140+
// Compute streak live from pacts via the shared `calculateStreak`
141+
// helper so Dashboard matches the Stats page. Previously we read
142+
// the denormalized `profiles.current_streak` column, which could
143+
// drift when cron jobs hadn't run yet. Keep profile reads for XP,
144+
// level, and freezes — those aren't re-derivable from pacts.
145+
const [streakResult, risk, freeze] = await Promise.all([
146+
calculateStreak(supabase, userId, timezone),
152147
checkStreakAtRisk(supabase, userId, timezone),
153148
getStreakFreezeStatus(supabase, userId),
154149
]);
155150

151+
const streak = streakResult?.currentStreak ?? 0;
152+
156153
setSummary({
157154
dueToday,
158155
overdue,

0 commit comments

Comments
 (0)