From 761d3f3a59ec0d4cc9e4d7beffe8bb65234a8ec2 Mon Sep 17 00:00:00 2001 From: Truong Trung Nghia <149591043+ttncode@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:43:22 +0700 Subject: [PATCH] fix(history): split sessions crossing midnight across days A session counted entirely on the day it started, so work after midnight was missing from the new day. Reports now count each session's unpaused time inside each local day. Also use the local date for date inputs and backup names, follow edited start times, and refresh the today counter at midnight. --- src/components/BoostApp.tsx | 14 ++- src/components/lib/backup.ts | 2 +- src/components/lib/database-service.ts | 19 ++-- src/components/pages/HistoryPage.tsx | 31 +++---- src/components/pages/goals/goal-rows.ts | 5 +- .../pages/history/TagsAnalytics.tsx | 44 ++++----- src/components/pages/history/data.ts | 24 ++--- src/components/pages/history/stats.ts | 93 ++++++++++++------- tests/stats.test.ts | 47 ++++++++++ 9 files changed, 173 insertions(+), 106 deletions(-) create mode 100644 tests/stats.test.ts 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); +});