/** * Timeline rollup utilities — Level-of-Detail (LOD) pre-computation * * Pre-computes aggregations at multiple granularities (hour, day, week, month) * so chart rendering is O(buckets) not O(events). Like map tiles — compute * once at write time, serve instantly at read time. */ import { UNASSIGNED_PROJECT_ID } from "../db/schema"; export type RollupGranularity = "hour" | "day" | "week" | "month"; export const GRANULARITIES: RollupGranularity[] = [ "hour", "day", "week", "month", ]; /** Select the best rollup granularity for a given date range. */ export function selectRollupGranularity(rangeMs: number): RollupGranularity { const day = 86_400_000; if (rangeMs <= 7 * day) return "hour"; if (rangeMs <= 90 * day) return "day"; if (rangeMs <= 365 * day) return "week"; return "month"; } /** Get the next coarser granularity (for progressive fallback). */ export function getCoarserGranularity( g: RollupGranularity, ): RollupGranularity | null { switch (g) { case "hour": return "day"; case "day": return "week"; case "week": return "month"; case "month": return null; } } /** Compute bucket boundaries for each granularity given a Unix-seconds timestamp. */ export function getBucketStarts( timestampSec: number, ): Record { const date = new Date(timestampSec * 1000); // Hour: floor to hour const hourStart = Math.floor(timestampSec / 3600) * 3600; // Day: floor to midnight UTC const dayDate = new Date(date); dayDate.setUTCHours(0, 0, 0, 0); const dayStart = Math.floor(dayDate.getTime() / 1000); // Week: floor to Monday midnight UTC const weekDate = new Date(dayDate); const dow = weekDate.getUTCDay(); const mondayOffset = dow === 0 ? 6 : dow - 1; weekDate.setUTCDate(weekDate.getUTCDate() - mondayOffset); const weekStart = Math.floor(weekDate.getTime() / 1000); // Month: floor to 1st of month midnight UTC const monthStart = Math.floor( Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1) / 1000, ); const monthEnd = Math.floor( Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1) / 1000, ); return { hour: { start: hourStart, end: hourStart + 3600 }, day: { start: dayStart, end: dayStart + 86400 }, week: { start: weekStart, end: weekStart + 604800 }, month: { start: monthStart, end: monthEnd }, }; } /** * Map a rollup row to its display bucket. * * The display bucket size is chosen from the requested range * (getTimelineBucketSize) and can be FINER than the rollup rows that back it * — e.g. a 14-day range wants 4-hour buckets but day-granularity rollups are * the finest data available. Flooring a coarse row onto the finer epoch grid * collapses a whole day of work into one slim cell at midnight UTC followed * by an empty gap until the next day — visible as large bogus gaps on the * timeline at certain zoom levels. Calendar-sized rows (months) also can't * ride a fixed epoch-aligned grid without drift: a 28-day February behind a * 30-day grid eventually doubles up one cell and leaves the next one empty. * * Rules: * - Row at least as coarse as the display bucket → pass through the row's * own calendar-aligned boundaries unchanged. * - Display bucket coarser than the row → group calendar-aware (quarter / * month / Monday-aligned week, all UTC to match getBucketStarts), falling * back to epoch flooring only for sub-day buckets where hours divide * evenly. */ export function getDisplayBucketForRollupRow( rowStartSec: number, rowEndSec: number, bucketSizeSeconds: number, ): { start: number; end: number } { const DAY_S = 86_400; const rowSpan = rowEndSec - rowStartSec; if (rowSpan >= bucketSizeSeconds) { return { start: rowStartSec, end: rowEndSec }; } if (bucketSizeSeconds >= 80 * DAY_S) { // Quarter grouping (~90-day display buckets over month rollups) const d = new Date(rowStartSec * 1000); const qMonth = Math.floor(d.getUTCMonth() / 3) * 3; return { start: Date.UTC(d.getUTCFullYear(), qMonth, 1) / 1000, end: Date.UTC(d.getUTCFullYear(), qMonth + 3, 1) / 1000, }; } if (bucketSizeSeconds >= 28 * DAY_S) { // Calendar month grouping (~30-day display buckets) const d = new Date(rowStartSec * 1000); return { start: Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1) / 1000, end: Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1) / 1000, }; } if (bucketSizeSeconds >= 7 * DAY_S) { // Monday-aligned week grouping, matching rollup week boundaries const d = new Date(rowStartSec * 1000); d.setUTCHours(0, 0, 0, 0); const dow = d.getUTCDay(); d.setUTCDate(d.getUTCDate() - (dow === 0 ? 6 : dow - 1)); const start = Math.floor(d.getTime() / 1000); return { start, end: start + 7 * DAY_S }; } // Sub-day display buckets over hour rollups: epoch flooring is exact // because hours divide evenly into 2/4/8-hour buckets. const start = Math.floor(rowStartSec / bucketSizeSeconds) * bucketSizeSeconds; return { start, end: start + bucketSizeSeconds }; } /** A single rollup delta to upsert. */ export interface RollupDelta { organizationId: string; granularity: RollupGranularity; bucketStart: number; bucketEnd: number; projectId: string; projectName: string; projectColor: string; eventCount: number; estimatedMinutes: number; sourceCounts: Record; } /** Project lookup info for denormalization. */ export interface ProjectInfo { id: string; name: string; color: string; } /** Input event shape for rollup computation. */ export interface RollupEvent { timestamp: Date | number; source: string; projectRootId?: string | null; estimatedMinutes?: number | null; } /** * Compute rollup deltas from a batch of events. * * Groups events by (granularity, bucket, project) and returns * upsert-ready deltas for all 4 granularity levels. */ export function computeRollupDeltas( events: RollupEvent[], organizationId: string, projectLookup: Map, ): RollupDelta[] { // Key: `${granularity}:${bucketStart}:${projectId}` const deltaMap = new Map(); for (const event of events) { const ts = event.timestamp instanceof Date ? Math.floor(event.timestamp.getTime() / 1000) : typeof event.timestamp === "number" && event.timestamp > 1e12 ? Math.floor(event.timestamp / 1000) // ms timestamp : (event.timestamp as number); // already seconds const buckets = getBucketStarts(ts); const project = event.projectRootId ? projectLookup.get(event.projectRootId) : undefined; const projectId = project?.id ?? UNASSIGNED_PROJECT_ID; const projectName = project?.name ?? "Unassigned"; const projectColor = project?.color ?? "oklch(0.55 0.02 260)"; const minutes = event.estimatedMinutes ?? 15; for (const granularity of GRANULARITIES) { const bucket = buckets[granularity]; const key = `${granularity}:${bucket.start}:${projectId}`; let delta = deltaMap.get(key); if (!delta) { delta = { organizationId, granularity, bucketStart: bucket.start, bucketEnd: bucket.end, projectId, projectName, projectColor, eventCount: 0, estimatedMinutes: 0, sourceCounts: {}, }; deltaMap.set(key, delta); } delta.eventCount += 1; delta.estimatedMinutes += minutes; delta.sourceCounts[event.source] = (delta.sourceCounts[event.source] ?? 0) + 1; } } return Array.from(deltaMap.values()); } /** * Merge two source count objects (for upsert accumulation). */ export function mergeSourceCounts( existing: Record, incoming: Record, ): Record { const merged = { ...existing }; for (const [source, count] of Object.entries(incoming)) { merged[source] = (merged[source] ?? 0) + count; } return merged; } /** * Merge several rollup-delta batches into one, summing the additive fields * (eventCount, estimatedMinutes, sourceCounts) for deltas that share a * (granularity, bucketStart, projectId) key. * * This lets a large recompute run over bounded time windows instead of loading * every event into memory at once: each window is aggregated independently via * computeRollupDeltas, and merging the results yields exactly the totals a * single unbounded pass would. It is correct even when a bucket straddles a * window boundary (e.g. a week that spans two monthly windows) because every * rollup aggregate is additive and the per-bucket key is identical regardless * of which window contributed the events. */ export function mergeRollupDeltas(batches: RollupDelta[][]): RollupDelta[] { const merged = new Map(); for (const batch of batches) { for (const delta of batch) { const key = `${delta.granularity}:${delta.bucketStart}:${delta.projectId}`; const existing = merged.get(key); if (!existing) { merged.set(key, { ...delta, sourceCounts: { ...delta.sourceCounts } }); } else { existing.eventCount += delta.eventCount; existing.estimatedMinutes += delta.estimatedMinutes; existing.sourceCounts = mergeSourceCounts( existing.sourceCounts, delta.sourceCounts, ); } } } return Array.from(merged.values()); }