Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
14 changes: 11 additions & 3 deletions src/components/BoostApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout>;
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(
Expand Down
2 changes: 1 addition & 1 deletion src/components/lib/backup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
19 changes: 6 additions & 13 deletions src/components/lib/database-service.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<number> {
const midnight = new Date();
midnight.setHours(0, 0, 0, 0);
const { data } = await localDb
.from<FocusSession>("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<Tag[]> {
Expand Down
31 changes: 14 additions & 17 deletions src/components/pages/HistoryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,21 +42,25 @@ import {
buildDayData,
calculateComparisonStats,
calculateStats,
calendarDay,
chartLayout,
chartRange,
focusMinutes,
formatDuration,
formatSessionTime,
formatTimeOnly,
formatTimeValue,
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";
Expand All @@ -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";
Expand Down Expand Up @@ -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<FocusSession[]>([]);
const [chartPauseMap, setChartPauseMap] = useState<Map<string, number>>(new Map());
const [chartPauseMap, setChartPauseMap] = useState<Map<string, PauseDetail[]>>(new Map());
const [chartLoading, setChartLoading] = useState(false);

const sessionsRef = useRef(sessions);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -575,11 +576,7 @@ export function HistoryPage({ userId }: { userId: string }) {
<Section title={<h2 className="text-lg font-semibold text-white">Session History</h2>} open={showSessionHistory} onToggle={toggleSessionHistory} className="">
<div className="space-y-6">
{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 (
<div key={date} className="border border-gray-800 rounded-xl p-4">
<div className="flex items-center justify-between mb-4">
Expand Down
5 changes: 3 additions & 2 deletions src/components/pages/goals/goal-rows.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> };
Expand Down Expand Up @@ -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
Expand Down
44 changes: 20 additions & 24 deletions src/components/pages/history/TagsAnalytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string> {
Expand All @@ -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");
Expand Down Expand Up @@ -192,7 +184,7 @@ export function TagsAnalytics({ userId }: { userId: string }) {
const [viewMode, setViewMode] = useState<ViewMode>(() => (readStored("tagsAnalytics_viewMode") as ViewMode | null) || "7days");
const [displayedViewMode, setDisplayedViewMode] = useState<ViewMode>(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<ChartStyle>(() => (readStored("tagsAnalytics_chartStyle") as ChartStyle | null) || "grouped");
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(readSelectedTagIds);
const [allTags, setAllTags] = useState<Tag[]>([]);
Expand Down Expand Up @@ -223,13 +215,11 @@ export function TagsAnalytics({ userId }: { userId: string }) {
.then((allTagStats) => {
if (cancelled) return;
const data: DayRow[] = [];
const dayIndexByKey = new Map<string, number>();
const aggregatedMap = new Map<string, Aggregate>();
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" }),
Expand All @@ -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 });
});
});

Expand Down Expand Up @@ -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);
Expand Down
24 changes: 13 additions & 11 deletions src/components/pages/history/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, number> }> {
): Promise<{ sessions: FocusSession[]; pauseMap: Map<string, PauseInterval[]> }> {
let allSessions: FocusSession[] = [];
const batchSize = 1000;
let offset = 0;
Expand All @@ -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;
Expand All @@ -104,18 +106,18 @@ export async function fetchSessionsForDateRange(
}

const sessionIds = allSessions.map((s) => s.id);
const pauseMap = new Map<string, number>();
const pauseMap = new Map<string, PauseInterval[]>();
if (sessionIds.length > 0) {
const { data: pauses, error: pErr } = await localDb
.from<FocusSessionPause>("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);
});
}
}
Expand Down Expand Up @@ -178,10 +180,10 @@ export async function fetchFocusTimeByTag(
userId: string,
startDate: Date,
endDate: Date,
): Promise<{ sessions: FocusSession[]; sessionTagsMap: Map<string, Tag[]> }> {
): Promise<{ sessions: FocusSession[]; pauseMap: Map<string, PauseInterval[]>; sessionTagsMap: Map<string, Tag[]> }> {
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 {
Expand Down
Loading
Loading