import { CHAT_PROVIDER_IDS } from "../types/timeline"; import { UNASSIGNED_COLOR } from "./colors"; import { calculateBillableMinutes } from "./time-ranges"; export interface TimelineQueryParams { start?: string; end?: string; deviceIds?: string[]; teamId?: string; userIds?: string[]; projectIds?: string[]; sources?: string[]; limit?: number; } export interface TimelineStatsEventInput { source: string; projectId?: string | null; timestamp: Date | string | number; estimatedMinutes?: number | null; } export type MissingDateRangeMode = "last-7-days" | "all-time"; export type TimelineDatePreset = | { label: "1W"; days: number } | { label: "1M"; months: number } | { label: "3M"; months: number } | { label: "6M"; months: number } | { label: "1Y"; years: number } | { label: "5Y"; years: number } | { label: "ALL"; all: true }; export const TIMELINE_DATE_PRESETS: readonly TimelineDatePreset[] = [ { label: "1W", days: 7 }, { label: "1M", months: 1 }, { label: "3M", months: 3 }, { label: "6M", months: 6 }, { label: "1Y", years: 1 }, { label: "5Y", years: 5 }, { label: "ALL", all: true }, ] as const; export function isValidDateString(dateStr: string): boolean { if (!dateStr) return false; if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return false; const date = new Date(`${dateStr}T00:00:00`); if (Number.isNaN(date.getTime())) return false; const year = date.getFullYear(); return year >= 1900 && year <= 2100; } export function sanitizeDateParam(dateStr: string | null | undefined): string { if (!dateStr) return ""; return isValidDateString(dateStr) ? dateStr : ""; } export function formatDateOnly(date: Date): string { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart( 2, "0", )}-${String(date.getDate()).padStart(2, "0")}`; } export function getDefaultLast7Days(): { start: string; end: string } { const today = new Date(); const weekAgo = new Date(today); weekAgo.setDate(today.getDate() - 7); return { start: formatDateOnly(weekAgo), end: formatDateOnly(today), }; } export function normalizeDateRangeFromUrl( start: string, end: string, mode: MissingDateRangeMode = "last-7-days", ): { start: string; end: string } { if (start && end) return { start, end }; if (!start && !end) { return mode === "all-time" ? { start: "", end: "" } : getDefaultLast7Days(); } if (end && !start) { const endDate = new Date(`${end}T00:00:00`); const startDate = new Date(endDate); startDate.setDate(endDate.getDate() - 7); return { start: formatDateOnly(startDate), end }; } if (start && !end) { return { start, end: formatDateOnly(new Date()) }; } return mode === "all-time" ? { start: "", end: "" } : getDefaultLast7Days(); } export function getTimelineActivePreset( startDate: string, endDate: string, ): string | null { if (!startDate && !endDate) return "ALL"; if (!startDate || !endDate) return null; const today = new Date(); today.setHours(0, 0, 0, 0); const end = new Date(`${endDate}T00:00:00`); const start = new Date(`${startDate}T00:00:00`); const daysDiffEnd = Math.abs( (today.getTime() - end.getTime()) / (1000 * 60 * 60 * 24), ); if (daysDiffEnd > 1) return null; const daysDiff = Math.round( (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24), ); if (daysDiff >= 6 && daysDiff <= 8) return "1W"; if (daysDiff >= 28 && daysDiff <= 32) return "1M"; if (daysDiff >= 88 && daysDiff <= 93) return "3M"; if (daysDiff >= 178 && daysDiff <= 186) return "6M"; if (daysDiff >= 363 && daysDiff <= 368) return "1Y"; if (daysDiff >= 1820 && daysDiff <= 1830) return "5Y"; return null; } export function normalizeCsvValues(values?: Iterable | null): string[] { if (!values) return []; return [ ...new Set(Array.from(values, (value) => value.trim()).filter(Boolean)), ].sort(); } export function parseCsvParam( searchParams: URLSearchParams, keys: string | readonly string[], ): string[] | undefined { const keyList = Array.isArray(keys) ? keys : [keys]; const allValues: string[] = []; for (const key of keyList) { const raw = searchParams.get(key); if (!raw) continue; allValues.push(...raw.split(",")); } const normalized = normalizeCsvValues(allValues); return normalized.length > 0 ? normalized : undefined; } export function appendCsvParam( searchParams: URLSearchParams, key: string, values?: Iterable | null, ): void { const normalized = normalizeCsvValues(values); if (normalized.length > 0) { searchParams.set(key, normalized.join(",")); } } export function buildTimelineSearchParams( params: TimelineQueryParams, options: { includeLimit?: boolean } = {}, ): URLSearchParams { const searchParams = new URLSearchParams(); if (params.start) searchParams.set("start", params.start); if (params.end) searchParams.set("end", params.end); appendCsvParam(searchParams, "deviceIds", params.deviceIds); if (params.teamId) searchParams.set("teamId", params.teamId); appendCsvParam(searchParams, "userIds", params.userIds); appendCsvParam(searchParams, "projectIds", params.projectIds); appendCsvParam(searchParams, "sources", params.sources); if ( options.includeLimit && typeof params.limit === "number" && Number.isFinite(params.limit) ) { searchParams.set("limit", String(Math.floor(params.limit))); } return searchParams; } export function createEmptyTimelineSourceCounts(): Record { const counts: Record = { git: 0, file: 0, manual: 0, }; for (const provider of CHAT_PROVIDER_IDS) { counts[provider] = 0; } return counts; } /** * Timeline page stats from a loaded event list. * * `totalMinutes` uses the shared billable clock: per-project gap-merge sessions * summed across projects (concurrent multi-project work is fully billable). * Same function invoices and reports use — never fixed UTC epoch buckets. */ export function calculateTimelineStatsFromEvents( events: TimelineStatsEventInput[], ): { totalEvents: number; totalMinutes: number; bySource: Record; } { const bySource = createEmptyTimelineSourceCounts(); for (const event of events) { bySource[event.source] = (bySource[event.source] || 0) + 1; } return { totalEvents: events.length, totalMinutes: calculateBillableMinutes( events.map((event, i) => ({ id: `stats-${i}`, timestamp: event.timestamp, projectId: event.projectId, source: event.source, estimatedMinutes: event.estimatedMinutes, })), ), bySource, }; } export function getTimelineBucketSize(rangeMs: number): number { const day = 24 * 60 * 60 * 1000; const hour = 60 * 60 * 1000; if (rangeMs <= day) return 15 * 60 * 1000; // 15 min if (rangeMs <= 3 * day) return hour; // 1 hour if (rangeMs <= 7 * day) return 2 * hour; // 2 hours if (rangeMs <= 14 * day) return 4 * hour; // 4 hours if (rangeMs <= 30 * day) return 8 * hour; // 8 hours if (rangeMs <= 90 * day) return day; // 1 day if (rangeMs <= 365 * day) return 7 * day; // 1 week if (rangeMs <= 5 * 365 * day) return 30 * day; // ~1 month return 90 * day; // ~1 quarter } // ── Event clustering ─────────────────────────────────────────────────────── /** * Minimal shape `clusterEvents` needs. Any timeline event with a timestamp and * an optional project id satisfies it (e.g. `TimelineEventDisplay`). */ export interface ClusterableEvent { timestamp: Date | string | number; projectId?: string | null; } /** * An event annotated with cluster metadata: how many source events it * represents and (optionally) the events it absorbed. */ export type Clustered = T & { _clusterCount: number; _clusterEvents?: T[]; }; /** * Cap the number of scatter points by time-bucket clustering. When the input is * already at/under `maxPoints`, every event is returned unchanged with a cluster * count of 1. Above the cap, events are grouped into `maxPoints` equal time * windows and collapsed per (window, project) into a single representative that * carries the running `_clusterCount` and the absorbed `_clusterEvents`. * * Pure and DOM-free. */ export function clusterEvents( events: T[], maxPoints = 1000, ): Clustered[] { if (events.length <= maxPoints) { return events.map((e) => ({ ...e, _clusterCount: 1 })); } let minTime = Number.POSITIVE_INFINITY; let maxTime = Number.NEGATIVE_INFINITY; for (const event of events) { const timestamp = new Date(event.timestamp).getTime(); if (timestamp < minTime) minTime = timestamp; if (timestamp > maxTime) maxTime = timestamp; } const windowMs = (maxTime - minTime) / maxPoints; const buckets = new Map>(); for (const event of events) { const bucket = Math.floor( (new Date(event.timestamp).getTime() - minTime) / windowMs, ); const key = `${bucket}-${event.projectId || "unassigned"}`; const existing = buckets.get(key); if (existing) { existing._clusterCount++; existing._clusterEvents?.push(event); } else { buckets.set(key, { ...event, _clusterCount: 1, _clusterEvents: [event] }); } } return Array.from(buckets.values()); } // ── File size formatting ─────────────────────────────────────────────────── /** Format a byte count as a short human-readable size (B / KB / MB). */ export function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } // ── Color blending (oklch-aware, DOM-free) ───────────────────────────────── interface Oklch { l: number; c: number; h: number; a: number; } function parseComponent(raw: string): number { const s = raw.trim(); if (s.endsWith("%")) return Number.parseFloat(s) / 100; return Number.parseFloat(s); } function parseHueComponent(raw: string): number { const s = raw.trim().replace(/deg$/i, ""); if (s === "none" || s === "") return 0; return Number.parseFloat(s); } function parseOklch(value: string): Oklch | null { const m = /^oklch\(\s*(.+)\s*\)$/i.exec(value.trim()); if (!m) return null; const [coords, alphaRaw] = m[1].split("/"); const parts = coords .trim() .split(/[\s,]+/) .filter(Boolean); if (parts.length < 3) return null; const l = parseComponent(parts[0]); const c = parseComponent(parts[1]); const h = parseHueComponent(parts[2]); const a = alphaRaw != null ? parseComponent(alphaRaw) : 1; if ([l, c, h, a].some((n) => Number.isNaN(n))) return null; return { l, c, h, a }; } function srgbToLinear(channel: number): number { const c = channel / 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; } function rgbToOklch(r: number, g: number, b: number, a: number): Oklch { const lr = srgbToLinear(r); const lg = srgbToLinear(g); const lb = srgbToLinear(b); const lLms = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb; const mLms = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb; const sLms = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb; const l_ = Math.cbrt(lLms); const m_ = Math.cbrt(mLms); const s_ = Math.cbrt(sLms); const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_; const aLab = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_; const bLab = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_; const c = Math.sqrt(aLab * aLab + bLab * bLab); let h = (Math.atan2(bLab, aLab) * 180) / Math.PI; if (h < 0) h += 360; return { l: L, c, h, a }; } function parseHex(value: string): Oklch | null { const m = /^#([0-9a-f]{3,8})$/i.exec(value.trim()); if (!m) return null; let hex = m[1]; if (hex.length === 3 || hex.length === 4) { hex = hex .split("") .map((ch) => ch + ch) .join(""); } if (hex.length !== 6 && hex.length !== 8) return null; const r = Number.parseInt(hex.slice(0, 2), 16); const g = Number.parseInt(hex.slice(2, 4), 16); const b = Number.parseInt(hex.slice(4, 6), 16); const a = hex.length === 8 ? Number.parseInt(hex.slice(6, 8), 16) / 255 : 1; return rgbToOklch(r, g, b, a); } function parseRgb(value: string): Oklch | null { const m = /^rgba?\(\s*(.+)\s*\)$/i.exec(value.trim()); if (!m) return null; const parts = m[1].split(/[\s,/]+/).filter(Boolean); if (parts.length < 3) return null; const channel = (raw: string) => raw.trim().endsWith("%") ? (Number.parseFloat(raw) / 100) * 255 : Number.parseFloat(raw); const r = channel(parts[0]); const g = channel(parts[1]); const b = channel(parts[2]); const a = parts[3] != null ? parseComponent(parts[3]) : 1; if ([r, g, b, a].some((n) => Number.isNaN(n))) return null; return rgbToOklch(r, g, b, a); } /** Parse any supported CSS color (oklch / hex / rgb[a]) into oklch components. */ function toOklch(value: string): Oklch | null { return parseOklch(value) ?? parseHex(value) ?? parseRgb(value); } function round(n: number, places: number): number { const f = 10 ** places; return Math.round(n * f) / f; } /** * Blend weighted colors in oklch space. L, C and alpha are interpolated * linearly; hue is interpolated along the shortest arc via a weighted vector * mean (so it wraps correctly around 0/360). Inputs may be oklch, hex or * rgb[a]; the result is always an `oklch(...)` string (with `/ alpha` only when * the blended alpha is below 1). * * Pure and DOM-free — unlike the old hex-regex blender it does NOT silently * fall back to gray when handed the oklch project colors produced by * `getColorForName` / `UNASSIGNED_COLOR`. */ export function blendColors( colors: Array<{ color: string; weight: number }>, ): string { if (colors.length === 0) return UNASSIGNED_COLOR; if (colors.length === 1) return colors[0].color; const parsed: Array<{ ok: Oklch; weight: number }> = []; let totalWeight = 0; for (const { color, weight } of colors) { if (weight <= 0) continue; const ok = toOklch(color); if (!ok) continue; parsed.push({ ok, weight }); totalWeight += weight; } if (parsed.length === 0 || totalWeight <= 0) return UNASSIGNED_COLOR; let l = 0; let c = 0; let a = 0; let hx = 0; let hy = 0; for (const { ok, weight } of parsed) { const w = weight / totalWeight; l += ok.l * w; c += ok.c * w; a += ok.a * w; const rad = (ok.h * Math.PI) / 180; hx += Math.cos(rad) * w; hy += Math.sin(rad) * w; } let h = (Math.atan2(hy, hx) * 180) / Math.PI; if (h < 0) h += 360; const lr = round(l, 4); const cr = round(c, 4); const hr = round(h, 2); const ar = round(a, 4); return ar < 1 ? `oklch(${lr} ${cr} ${hr} / ${ar})` : `oklch(${lr} ${cr} ${hr})`; }