diff --git a/src/components/BoostApp.tsx b/src/components/BoostApp.tsx index 2324839..a39835c 100644 --- a/src/components/BoostApp.tsx +++ b/src/components/BoostApp.tsx @@ -62,9 +62,17 @@ function AppShell() { useEffect(() => suppressDesktopContextMenu(), []); useEffect(() => { - databaseService.getTodayFocusMinutes().then(setTodayMinutes, (error: unknown) => { - console.error("Error loading today minutes:", error); - }); + let timer: ReturnType; + const refreshNowAndAtMidnight = () => { + databaseService.getTodayFocusMinutes().then(setTodayMinutes, (error: unknown) => { + console.error("Error loading today minutes:", error); + }); + const nextMidnight = new Date(); + nextMidnight.setHours(24, 0, 0, 0); + timer = setTimeout(refreshNowAndAtMidnight, nextMidnight.getTime() - Date.now()); + }; + refreshNowAndAtMidnight(); + return () => clearTimeout(timer); }, []); const navigateFromCurrent = useCallback( diff --git a/src/components/lib/backup.ts b/src/components/lib/backup.ts index e1b1114..1aca676 100644 --- a/src/components/lib/backup.ts +++ b/src/components/lib/backup.ts @@ -23,7 +23,7 @@ function replaceEntries(storage: Storage, entries: Entries): void { } export function backupFileName(date: Date): string { - return `boost-focus-timer-backup-${date.toISOString().slice(0, 10)}.json`; + return `boost-focus-timer-backup-${date.toLocaleDateString("en-CA")}.json`; } export function createBackup(storage: Storage, date: Date): string { diff --git a/src/components/lib/database-service.ts b/src/components/lib/database-service.ts index 10397bd..f4a8f0d 100644 --- a/src/components/lib/database-service.ts +++ b/src/components/lib/database-service.ts @@ -1,3 +1,5 @@ +import { fetchSessionsForDateRange } from "../pages/history/data"; +import { focusMinutes } from "../pages/history/stats"; import { getLocalUserId, localDb, type Row } from "./local-db"; export type SessionType = "focus" | "break"; @@ -80,22 +82,13 @@ export const databaseService = { return data ?? []; }, - /** Minutes between start and end of today's finished focus sessions, pauses included. */ + /** Unpaused minutes of finished focus sessions since local midnight, including the part of a session started yesterday. */ async getTodayFocusMinutes(): Promise { const midnight = new Date(); midnight.setHours(0, 0, 0, 0); - const { data } = await localDb - .from("focus_sessions") - .select("start_time, end_time") - .eq("session_type", "focus") - .gte("created_at", midnight.toISOString()) - .not("start_time", "is", null) - .not("end_time", "is", null); - const total = (data ?? []).reduce( - (sum, row) => sum + (new Date(row.end_time ?? 0).getTime() - new Date(row.start_time ?? 0).getTime()) / 60000, - 0, - ); - return Math.round(total); + const now = new Date(); + const { sessions, pauseMap } = await fetchSessionsForDateRange(midnight, now); + return Math.round(focusMinutes({ sessions, pauses: pauseMap }, { start: midnight, end: now })); }, async getTags(): Promise { diff --git a/src/components/pages/HistoryPage.tsx b/src/components/pages/HistoryPage.tsx index e487141..a149c3e 100644 --- a/src/components/pages/HistoryPage.tsx +++ b/src/components/pages/HistoryPage.tsx @@ -42,8 +42,10 @@ import { buildDayData, calculateComparisonStats, calculateStats, + calendarDay, chartLayout, chartRange, + focusMinutes, formatDuration, formatSessionTime, formatTimeOnly, @@ -51,12 +53,14 @@ import { getDailyComparisonData, getYTicks, groupSessionsByDate, + sessionStart, toLocalInput, yAxisFormatter, type DailyComparison, } from "./history/stats"; import { hotkeyHint } from "../focus/GoalForm"; import { useHotkey, useHotkeyLabel } from "../hotkeys/HotkeysProvider"; +import { localDateString } from "./habits/habit-model"; import { TagSelector } from "./history/TagSelector"; import { TagsAnalytics } from "./history/TagsAnalytics"; import { useDialog } from "../dialogs/DialogProvider"; @@ -77,10 +81,6 @@ const CYCLED_PERIODS: readonly DailyFocusPeriod[] = PERIODS.map((p) => p.period) const AXIS = { axisLine: { stroke: "#374151" }, tickLine: { stroke: "#374151" } }; const CURSOR = { fill: "rgba(59, 130, 246, 0.1)" }; -function isoDay(date: Date): string { - return date.toISOString().split("T")[0]; -} - function storedFlag(key: string, fallback: boolean): boolean { const s = readStored(key); return s === null ? fallback : s === "true"; @@ -235,11 +235,11 @@ export function HistoryPage({ userId }: { userId: string }) { if (saved) return saved; const d = new Date(); d.setDate(d.getDate() - 6); - return isoDay(d); + return localDateString(d); }); - const [customEndDate, setCustomEndDate] = useState(() => readStored("focusHistory_customEndDate") || isoDay(new Date())); + const [customEndDate, setCustomEndDate] = useState(() => readStored("focusHistory_customEndDate") || localDateString(new Date())); const [chartSessions, setChartSessions] = useState([]); - const [chartPauseMap, setChartPauseMap] = useState>(new Map()); + const [chartPauseMap, setChartPauseMap] = useState>(new Map()); const [chartLoading, setChartLoading] = useState(false); const sessionsRef = useRef(sessions); @@ -407,11 +407,12 @@ export function HistoryPage({ userId }: { userId: string }) { const prevCombo = useHotkeyLabel("list.prev"); const nextCombo = useHotkeyLabel("list.next"); const periodCombo = useHotkeyLabel("history.period"); - const stats = calculateStats(sessions); - const comparison = calculateComparisonStats(sessions, weekOffset); - const dailyComparisonData = getDailyComparisonData(sessions, weekOffset); + const report = { sessions, pauses: sessionPauseDetails }; + const stats = calculateStats(report); + const comparison = calculateComparisonStats(report, weekOffset); + const dailyComparisonData = getDailyComparisonData(report, weekOffset); const dayData = useMemo( - () => buildDayData({ period: dailyFocusPeriod, dayOffset, customStartDate, customEndDate }, chartSessions, chartPauseMap), + () => buildDayData({ period: dailyFocusPeriod, dayOffset, customStartDate, customEndDate }, { sessions: chartSessions, pauses: chartPauseMap }), [chartSessions, chartPauseMap, dayOffset, dailyFocusPeriod, customStartDate, customEndDate], ); const layout = chartLayout(dailyFocusPeriod, dayData.length); @@ -479,7 +480,7 @@ export function HistoryPage({ userId }: { userId: string }) { type="date" value={customEndDate} min={customStartDate} - max={isoDay(new Date())} + max={localDateString(new Date())} onChange={(e) => { setCustomEndDate(e.target.value); store("focusHistory_customEndDate", e.target.value); @@ -575,11 +576,7 @@ export function HistoryPage({ userId }: { userId: string }) {
Session History} open={showSessionHistory} onToggle={toggleSessionHistory} className="">
{Object.entries(groupedSessions).map(([date, dateSessions]) => { - const totalMinutes = dateSessions.reduce((sum, session) => { - if (!session.start_time || !session.end_time) return sum; - const pauseDuration = sessionPauses.get(session.id) || 0; - return sum + Math.max(0, (new Date(session.end_time).getTime() - new Date(session.start_time).getTime() - pauseDuration) / 60000); - }, 0); + const totalMinutes = focusMinutes(report, calendarDay(sessionStart(dateSessions[0]))); return (
diff --git a/src/components/pages/goals/goal-rows.ts b/src/components/pages/goals/goal-rows.ts index 4069f4b..9badad1 100644 --- a/src/components/pages/goals/goal-rows.ts +++ b/src/components/pages/goals/goal-rows.ts @@ -1,4 +1,5 @@ import { localDb, type Row } from "../../lib/local-db"; +import { localDateString } from "../habits/habit-model"; /** Goals and their comments live in the shared `user_data` table, discriminated by `type`. */ export type UserDataRow = Row & { user_id: string; type: string; data: Record }; @@ -58,8 +59,8 @@ export function toComment(row: UserDataRow): GoalComment { export const byPosition = (a: Goal, b: Goal): number => a.data.position - b.data.position; -/** `YYYY-MM-DD` of the current UTC day, the value the date inputs start with. */ -export const todayIso = (): string => new Date().toISOString().slice(0, 10); +/** `YYYY-MM-DD` of the current local day, the value the date inputs start with. */ +export const todayIso = (): string => localDateString(new Date()); export function saveGoalData(goal: Goal): void { localDb diff --git a/src/components/pages/history/TagsAnalytics.tsx b/src/components/pages/history/TagsAnalytics.tsx index 3d40767..2cb7071 100644 --- a/src/components/pages/history/TagsAnalytics.tsx +++ b/src/components/pages/history/TagsAnalytics.tsx @@ -16,7 +16,9 @@ import { type TooltipContentProps, } from "recharts"; import { Clock, Layers, LayoutGrid, Loader2, Tag as TagIcon, TrendingUp } from "../../icons"; +import { localDateString } from "../habits/habit-model"; import { fetchFocusTimeByTag, fetchTags, readStored, store, type Tag } from "./data"; +import { calendarDay, focusMinutes } from "./stats"; type ViewMode = "today" | "7days" | "30days" | "90days" | "180days" | "365days" | "custom"; type ChartStyle = "grouped" | "stacked"; @@ -56,14 +58,10 @@ const VIEW_OPTIONS: { mode: ViewMode; label: string }[] = [ const MS_PER_DAY = 24 * 60 * 60 * 1000; const BASE_KEYS = new Set(["date", "dateStr", "day", "total"]); -function isoDay(date: Date): string { - return date.toISOString().split("T")[0]; -} - function defaultStartDate(): string { const d = new Date(); d.setDate(d.getDate() - 6); - return isoDay(d); + return localDateString(d); } function readSelectedTagIds(): Set { @@ -85,12 +83,6 @@ function formatTime(minutes: number): string { return Math.round(minutes) + "m"; } -// Local calendar-day key (DST-safe): bucket a timestamp by its local Y-M-D so long ranges -// spanning a daylight-saving change don't drift a day. -function dayKey(d: Date): string { - return d.getFullYear() + "-" + d.getMonth() + "-" + d.getDate(); -} - function rangeFor(viewMode: ViewMode, customStartDate: string, customEndDate: string) { if (viewMode === "custom") { const startDate = new Date(customStartDate + "T00:00:00"); @@ -192,7 +184,7 @@ export function TagsAnalytics({ userId }: { userId: string }) { const [viewMode, setViewMode] = useState(() => (readStored("tagsAnalytics_viewMode") as ViewMode | null) || "7days"); const [displayedViewMode, setDisplayedViewMode] = useState(viewMode); const [customStartDate, setCustomStartDate] = useState(() => readStored("tagsAnalytics_customStartDate") || defaultStartDate()); - const [customEndDate, setCustomEndDate] = useState(() => readStored("tagsAnalytics_customEndDate") || isoDay(new Date())); + const [customEndDate, setCustomEndDate] = useState(() => readStored("tagsAnalytics_customEndDate") || localDateString(new Date())); const [chartStyle, setChartStyle] = useState(() => (readStored("tagsAnalytics_chartStyle") as ChartStyle | null) || "grouped"); const [selectedTagIds, setSelectedTagIds] = useState>(readSelectedTagIds); const [allTags, setAllTags] = useState([]); @@ -223,13 +215,11 @@ export function TagsAnalytics({ userId }: { userId: string }) { .then((allTagStats) => { if (cancelled) return; const data: DayRow[] = []; - const dayIndexByKey = new Map(); const aggregatedMap = new Map(); for (let i = 0; i < numDays; i++) { const dayStart = new Date(startDate); dayStart.setDate(dayStart.getDate() + i); dayStart.setHours(0, 0, 0, 0); - dayIndexByKey.set(dayKey(dayStart), i); data.push({ date: dayStart, dateStr: dayStart.toLocaleDateString("en-US", { month: "short", day: "numeric" }), @@ -239,20 +229,26 @@ export function TagsAnalytics({ userId }: { userId: string }) { } allTagStats.sessions.forEach((session) => { - if (!session.start_time || !session.end_time) return; - const dayIndex = dayIndexByKey.get(dayKey(new Date(session.created_at))); - if (dayIndex === undefined) return; - const duration = (new Date(session.end_time).getTime() - new Date(session.start_time).getTime()) / 60000; + const report = { sessions: [session], pauses: allTagStats.pauseMap }; const sessionTags = allTagStats.sessionTagsMap.get(session.id) || []; + let minutesInRange = 0; + data.forEach((day) => { + const duration = focusMinutes(report, calendarDay(day.date)); + minutesInRange += duration; + sessionTags.forEach((tag) => { + const currentMinutes = Number(day[tag.name]) || 0; + day[tag.name] = Math.round(currentMinutes + duration); + day.total += duration; + }); + }); + if (minutesInRange === 0) return; + // Once per session, outside the day loop, so a split session is not counted twice. sessionTags.forEach((tag) => { - const currentMinutes = Number(data[dayIndex][tag.name]) || 0; - data[dayIndex][tag.name] = Math.round(currentMinutes + duration); - data[dayIndex].total += duration; const existing = aggregatedMap.get(tag.id); if (existing) { - existing.minutes += duration; + existing.minutes += minutesInRange; existing.sessions += 1; - } else aggregatedMap.set(tag.id, { tag, minutes: duration, sessions: 1 }); + } else aggregatedMap.set(tag.id, { tag, minutes: minutesInRange, sessions: 1 }); }); }); @@ -408,7 +404,7 @@ export function TagsAnalytics({ userId }: { userId: string }) { type="date" value={customEndDate} min={customStartDate} - max={isoDay(new Date())} + max={localDateString(new Date())} onChange={(e) => { setCustomEndDate(e.target.value); store("tagsAnalytics_customEndDate", e.target.value); diff --git a/src/components/pages/history/data.ts b/src/components/pages/history/data.ts index b8ed8dd..d153660 100644 --- a/src/components/pages/history/data.ts +++ b/src/components/pages/history/data.ts @@ -15,6 +15,8 @@ export type FocusSessionPause = Row & { pause_end_time: string | null; }; +export type PauseInterval = { start: string; end: string | null }; + export type Tag = Row & { user_id: string; name: string; @@ -81,7 +83,7 @@ export async function fetchSessionsWithTags(userId: string, sessionIds: string[] export async function fetchSessionsForDateRange( startDate: Date, endDate: Date, -): Promise<{ sessions: FocusSession[]; pauseMap: Map }> { +): Promise<{ sessions: FocusSession[]; pauseMap: Map }> { let allSessions: FocusSession[] = []; const batchSize = 1000; let offset = 0; @@ -92,8 +94,8 @@ export async function fetchSessionsForDateRange( .eq("session_type", "focus") .not("start_time", "is", null) .not("end_time", "is", null) - .gte("created_at", startDate.toISOString()) - .lte("created_at", endDate.toISOString()) + .gte("end_time", startDate.toISOString()) + .lte("start_time", endDate.toISOString()) .order("created_at", { ascending: true }) .range(offset, offset + batchSize - 1); if (error) throw error; @@ -104,18 +106,18 @@ export async function fetchSessionsForDateRange( } const sessionIds = allSessions.map((s) => s.id); - const pauseMap = new Map(); + const pauseMap = new Map(); if (sessionIds.length > 0) { const { data: pauses, error: pErr } = await localDb .from("focus_session_pauses") .select("*") - .in("session_id", sessionIds); + .in("session_id", sessionIds) + .order("pause_start_time", { ascending: true }); if (!pErr && pauses) { pauses.forEach((p) => { - if (p.pause_end_time) { - const dur = new Date(p.pause_end_time).getTime() - new Date(p.pause_start_time).getTime(); - pauseMap.set(p.session_id, (pauseMap.get(p.session_id) || 0) + dur); - } + const intervals = pauseMap.get(p.session_id) ?? []; + intervals.push({ start: p.pause_start_time, end: p.pause_end_time }); + pauseMap.set(p.session_id, intervals); }); } } @@ -178,10 +180,10 @@ export async function fetchFocusTimeByTag( userId: string, startDate: Date, endDate: Date, -): Promise<{ sessions: FocusSession[]; sessionTagsMap: Map }> { +): Promise<{ sessions: FocusSession[]; pauseMap: Map; sessionTagsMap: Map }> { const result = await fetchSessionsForDateRange(startDate, endDate); const tagsMap = await fetchSessionsWithTags(userId, result.sessions.map((s) => s.id)); - return { sessions: result.sessions, sessionTagsMap: tagsMap }; + return { sessions: result.sessions, pauseMap: result.pauseMap, sessionTagsMap: tagsMap }; } export function readStored(key: string): string | null { diff --git a/src/components/pages/history/stats.ts b/src/components/pages/history/stats.ts index 139a7ad..59801c9 100644 --- a/src/components/pages/history/stats.ts +++ b/src/components/pages/history/stats.ts @@ -1,7 +1,8 @@ import type { DailyFocusPeriod, DayData } from "./charts"; -import type { FocusSession } from "./data"; +import type { FocusSession, PauseInterval } from "./data"; const MS_PER_DAY = 1000 * 60 * 60 * 24; +const MS_PER_MINUTE = 60000; const PERIOD_DAYS: Record, number> = { "7days": 7, @@ -20,15 +21,43 @@ export interface ChartWindow { customEndDate: string; } -function rawMinutes(sessions: FocusSession[]): number { - return sessions.reduce((sum, s) => { - if (s.start_time && s.end_time) return sum + (new Date(s.end_time).getTime() - new Date(s.start_time).getTime()) / 60000; - return sum; - }, 0); +export type TimeWindow = { start: Date; end: Date }; + +export interface SessionReport { + sessions: FocusSession[]; + pauses: ReadonlyMap; +} + +function overlapMs(a: TimeWindow, b: TimeWindow): number { + return Math.max(0, Math.min(a.end.getTime(), b.end.getTime()) - Math.max(a.start.getTime(), b.start.getTime())); } -function focusOnly(sessions: FocusSession[]): FocusSession[] { - return sessions.filter((s) => s.session_type === "focus"); +function sessionSegments(session: FocusSession, pauses: PauseInterval[]): TimeWindow[] { + if (!session.start_time || !session.end_time) return []; + return activeSegments(session, pauses) ?? [{ start: new Date(session.start_time), end: new Date(session.end_time) }]; +} + +/** Unpaused minutes inside `window`, so a session crossing midnight counts part on each day. */ +export function focusMinutes({ sessions, pauses }: SessionReport, window: TimeWindow): number { + const segments = sessions.flatMap((session) => sessionSegments(session, pauses.get(session.id) ?? [])); + return segments.reduce((sum, segment) => sum + overlapMs(segment, window), 0) / MS_PER_MINUTE; +} + +export function calendarDay(date: Date): TimeWindow { + const start = new Date(date); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setDate(end.getDate() + 1); + return { start, end }; +} + +// start_time rather than created_at: only start_time follows an edit in History. +export function sessionStart(session: FocusSession): Date { + return new Date(session.start_time ?? session.created_at); +} + +function focusOnly(report: SessionReport): SessionReport { + return { ...report, sessions: report.sessions.filter((s) => s.session_type === "focus") }; } function startOfWeekWithOffset(weekOffset: number): Date { @@ -42,7 +71,7 @@ function startOfWeekWithOffset(weekOffset: number): Date { } function inRange(session: FocusSession, from: Date, to: Date): boolean { - const d = new Date(session.created_at); + const d = sessionStart(session); return d >= from && d < to; } @@ -61,8 +90,7 @@ export function chartRange({ period, dayOffset, customStartDate, customEndDate } export function buildDayData( window: ChartWindow, - chartSessions: FocusSession[], - chartPauseMap: Map, + report: SessionReport, ): DayData[] { const { period, dayOffset, customStartDate, customEndDate } = window; let numDays: number; @@ -84,13 +112,7 @@ export function buildDayData( date.setHours(0, 0, 0, 0); const nextDate = new Date(date); nextDate.setDate(nextDate.getDate() + 1); - const totalMinutes = chartSessions - .filter((session) => inRange(session, date, nextDate)) - .reduce((sum, session) => { - if (!session.start_time || !session.end_time) return sum; - const pauseDuration = chartPauseMap.get(session.id) || 0; - return sum + Math.max(0, (new Date(session.end_time).getTime() - new Date(session.start_time).getTime() - pauseDuration) / 60000); - }, 0); + const totalMinutes = focusMinutes(report, { start: date, end: nextDate }); return { date, day: date.toLocaleDateString("en-US", { weekday: "short" }), @@ -100,34 +122,35 @@ export function buildDayData( }); } -export function calculateStats(sessions: FocusSession[]) { - const focusSessions = focusOnly(sessions); +export function calculateStats(report: SessionReport) { + const focus = focusOnly(report); + const now = new Date(); const startOfWeek = startOfWeekWithOffset(0); const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0); - const todaySessions = focusSessions.filter((s) => new Date(s.created_at) >= startOfDay); - const weekSessions = focusSessions.filter((s) => new Date(s.created_at) >= startOfWeek); + const todaySessions = focus.sessions.filter((s) => sessionStart(s) >= startOfDay); + const weekSessions = focus.sessions.filter((s) => sessionStart(s) >= startOfWeek); return { - todayMinutes: Math.round(rawMinutes(todaySessions)), + todayMinutes: Math.round(focusMinutes(focus, { start: startOfDay, end: now })), todaySessions: todaySessions.length, todayCompleted: todaySessions.filter((s) => s.completed).length, - weekMinutes: Math.round(rawMinutes(weekSessions)), + weekMinutes: Math.round(focusMinutes(focus, { start: startOfWeek, end: now })), weekSessions: weekSessions.length, weekCompleted: weekSessions.filter((s) => s.completed).length, }; } -export function calculateComparisonStats(sessions: FocusSession[], weekOffset: number) { - const focusSessions = focusOnly(sessions); +export function calculateComparisonStats(report: SessionReport, weekOffset: number) { + const focus = focusOnly(report); const startOfThisWeek = startOfWeekWithOffset(weekOffset); const startOfLastWeek = new Date(startOfThisWeek); startOfLastWeek.setDate(startOfLastWeek.getDate() - 7); // "This week" has no upper bound, so older weeks include everything after them. - const thisWeekSessions = focusSessions.filter((s) => new Date(s.created_at) >= startOfThisWeek); - const lastWeekSessions = focusSessions.filter((s) => inRange(s, startOfLastWeek, startOfThisWeek)); + const thisWeekSessions = focus.sessions.filter((s) => sessionStart(s) >= startOfThisWeek); + const lastWeekSessions = focus.sessions.filter((s) => inRange(s, startOfLastWeek, startOfThisWeek)); return { - thisWeek: { minutes: Math.round(rawMinutes(thisWeekSessions)), sessions: thisWeekSessions.length }, - lastWeek: { minutes: Math.round(rawMinutes(lastWeekSessions)), sessions: lastWeekSessions.length }, + thisWeek: { minutes: Math.round(focusMinutes(focus, { start: startOfThisWeek, end: new Date() })), sessions: thisWeekSessions.length }, + lastWeek: { minutes: Math.round(focusMinutes(focus, { start: startOfLastWeek, end: startOfThisWeek })), sessions: lastWeekSessions.length }, }; } @@ -138,8 +161,8 @@ export interface DailyComparison { lastWeekMinutes: number; } -export function getDailyComparisonData(sessions: FocusSession[], weekOffset: number): DailyComparison[] { - const focusSessions = focusOnly(sessions); +export function getDailyComparisonData(report: SessionReport, weekOffset: number): DailyComparison[] { + const focus = focusOnly(report); const startOfThisWeek = startOfWeekWithOffset(weekOffset); const startOfLastWeek = new Date(startOfThisWeek); startOfLastWeek.setDate(startOfLastWeek.getDate() - 7); @@ -155,8 +178,8 @@ export function getDailyComparisonData(sessions: FocusSession[], weekOffset: num return { date: thisWeekDay, day: thisWeekDay.toLocaleDateString("en-US", { weekday: "short" }), - thisWeekMinutes: Math.round(rawMinutes(focusSessions.filter((s) => inRange(s, thisWeekDay, thisWeekDayEnd)))), - lastWeekMinutes: Math.round(rawMinutes(focusSessions.filter((s) => inRange(s, lastWeekDay, lastWeekDayEnd)))), + thisWeekMinutes: Math.round(focusMinutes(focus, { start: thisWeekDay, end: thisWeekDayEnd })), + lastWeekMinutes: Math.round(focusMinutes(focus, { start: lastWeekDay, end: lastWeekDayEnd })), }; }); } @@ -205,7 +228,7 @@ export function yAxisFormatter(value: number): string { export function groupSessionsByDate(sessions: FocusSession[]): Record { const grouped: Record = {}; sessions.forEach((session) => { - const date = new Date(session.created_at).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }); + const date = sessionStart(session).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }); if (!grouped[date]) grouped[date] = []; grouped[date].push(session); }); diff --git a/tests/stats.test.ts b/tests/stats.test.ts new file mode 100644 index 0000000..782be2a --- /dev/null +++ b/tests/stats.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { buildDayData, calculateStats, focusMinutes } from "../src/components/pages/history/stats.ts"; + +const at = (day: number, hour: number, minute = 0) => new Date(2026, 10, day, hour, minute); + +const crossingMidnight = { + id: "s1", + created_at: at(8, 23, 30).toISOString(), + start_time: at(8, 23, 30).toISOString(), + end_time: at(9, 0, 30).toISOString(), + session_type: "focus", + completed: true, +}; + +const noPauses = new Map(); +const chartWindow = { period: "custom", dayOffset: 0, customStartDate: "2026-11-08", customEndDate: "2026-11-09" } as const; + +function dailyMinutes(pauses: typeof noPauses): number[] { + return buildDayData(chartWindow, { sessions: [crossingMidnight], pauses }).map((day) => day.minutes); +} + +test("a session crossing midnight counts on both days", () => { + assert.deepEqual(dailyMinutes(noPauses), [30, 30]); +}); + +test("a pause reduces only the day it happened on", () => { + const pauses = new Map([["s1", [{ start: at(8, 23, 40).toISOString(), end: at(8, 23, 50).toISOString() }]]]); + assert.deepEqual(dailyMinutes(pauses), [20, 30]); +}); + +test("a pause spanning midnight reduces both days", () => { + const pauses = new Map([["s1", [{ start: at(8, 23, 50).toISOString(), end: at(9, 0, 10).toISOString() }]]]); + assert.deepEqual(dailyMinutes(pauses), [20, 20]); +}); + +test("focusMinutes ignores time outside the window", () => { + const report = { sessions: [crossingMidnight], pauses: noPauses }; + assert.equal(focusMinutes(report, { start: at(9, 1), end: at(10, 0) }), 0); +}); + +test("today's minutes include the part after midnight, and the session counts on its start day", (t) => { + t.mock.timers.enable({ apis: ["Date"], now: at(9, 1) }); + const stats = calculateStats({ sessions: [crossingMidnight], pauses: noPauses }); + assert.equal(stats.todayMinutes, 30); + assert.equal(stats.todaySessions, 0); +});