import { habitProgressColor } from "../../lib/habit-palette"; import type { Theme } from "../../lib/theme"; export const PROGRESS_SHADES = [ "#eeeeee", "#d8d8d8", "#bfbfbf", "#a5a5a5", "#8a8a8a", "#707070", "#555555", "#363636", "#111111", ] as const; export type CalendarDay = { color?: string; unit?: string; date: string; value: number; target: number; future?: boolean; state: "due" | "not-due" | "future"; }; export const MONTH_VIEWS = [1, 3, 4, 6, 12] as const; export type MonthView = (typeof MONTH_VIEWS)[number]; /** Whole calendar months, including the month containing the anchor date. */ export function monthWindow(anchor: string, months: MonthView) { const date = new Date(`${anchor}T12:00:00Z`); const first = new Date( Date.UTC(date.getUTCFullYear(), date.getUTCMonth() - months + 1, 1), ); const last = new Date( Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0), ); return { from: first.toISOString().slice(0, 10), to: last.toISOString().slice(0, 10), }; } export function visibleCalendarDays( days: CalendarDay[], months: MonthView, anchor: string, ) { const range = monthWindow(anchor, months); return days.filter((day) => day.date >= range.from && day.date <= range.to); } /** One uninterrupted Sunday-first timeline, including partial boundary weeks. */ export function calendarTimeline(days: CalendarDay[]) { const first = days[0]; if (!first) return { weeks: 0, entries: [], months: [] }; const start = new Date(`${first.date}T00:00:00Z`); const origin = start.getTime() - start.getUTCDay() * 86_400_000; const entries = days.map((day, index) => { const offset = Math.round((Date.parse(`${day.date}T00:00:00Z`) - origin) / 86_400_000); return { day, index, column: Math.floor(offset / 7) + 1, row: offset % 7 + 1 }; }); const weeks = entries.at(-1)!.column; const months = entries.filter(({ day }, index) => index === 0 || day.date.slice(0, 7) !== entries[index - 1]!.day.date.slice(0, 7), ).map(({ day, column }) => ({ month: day.date.slice(0, 7), column })) // A short custom range can cross a month boundary inside a single week. // The exact date-range caption names both; avoid overlapping axis labels. .filter((month, index, labels) => index === 0 || month.column !== labels[index - 1]!.column); return { weeks, entries, months }; } export const HABIT_COLORS = { water: "#426582", reading: "#977344", tasks: "#79618d", movement: "#58765b", } as const; export function progressShade(day: CalendarDay, color = "#111111") { if (day.color) return day.color; if (day.state === "not-due") return "#ffffff"; if (day.state === "future") return PROGRESS_SHADES[0]; if (day.target <= 0 || day.value <= 0) return PROGRESS_SHADES[0]; const step = Math.max( 1, Math.min(8, Math.round((day.value / day.target) * 8)), ); if (color === "#111111" || !/^#[0-9a-f]{6}$/i.test(color)) return PROGRESS_SHADES[step]!; const weight = step / 8; return `#${[1, 3, 5] .map((offset) => Math.round( 238 + (parseInt(color.slice(offset, offset + 2), 16) - 238) * weight, ) .toString(16) .padStart(2, "0"), ) .join("")}`; } export function describeDay(day: CalendarDay, unit: string) { if (day.state === "future") return "Upcoming · Logging is not available yet"; if (day.state === "not-due") return "Nothing scheduled · Not included in the score"; return `${day.value} of ${day.target} ${day.unit ?? unit} · ${day.value >= day.target ? "Complete" : "Incomplete"}`; } /** Only show shades that the chart's completion targets can produce. */ export function legendShades(days: CalendarDay[], color = "#111111", theme?: Theme) { const levels = new Set(); for (const target of new Set(days.map((day) => day.target))) { if (!Number.isFinite(target) || target <= 0) continue; for (let value = 1; value <= Math.min(target, 8); value++) { levels.add( target > 8 ? value : Math.max(1, Math.round((value / target) * 8)), ); } } return [ PROGRESS_SHADES[0], ...[...levels] .sort((a, b) => a - b) .map((value) => theme ? habitProgressColor(color, (value - 1) / 7, theme) : progressShade({ date: "", value, target: 8, state: "due" }, color), ), ]; } export function combinedProgress( habits: { value: number; target: number; due: boolean }[], ) { const due = habits.filter((habit) => habit.due && habit.target > 0); return { completed: due.filter((habit) => habit.value >= habit.target).length, due: due.length, }; } /** A full 12 calendar months of stable demo history, including future dates. */ export function demoCalendar(value: number, target: number): CalendarDay[] { return Array.from({ length: 365 }, (_, index) => { const timestamp = Date.UTC(2025, 9, 1 + index); // Keep the previously displayed dates' fixture values unchanged. const fixtureIndex = (timestamp - Date.UTC(2026, 2, 8)) / 86_400_000; const rawValue = fixtureIndex * 17 + Math.floor(fixtureIndex / 7) * 3; const date = new Date(timestamp).toISOString().slice(0, 10); return { date, target, value: date === "2026-09-04" ? value : ((rawValue % (target + 1)) + target + 1) % (target + 1), state: date > "2026-09-04" ? "future" : fixtureIndex % 19 === 0 ? "not-due" : "due", }; }); }