import { type Dayjs } from 'dayjs'; import type { TimeEntry } from '@/packages/api/src'; import { getDayJsInstance } from '@/packages/ui/src/utils/time'; // `getDayJsInstance()` reads window-injected settings (week-start), which // aren't available at module load. Each function calls it lazily at use // time. The cost is a per-call locale update; cellMath doesn't use any // week-start-aware APIs so it's a no-op functionally. /** * UTC ISO of 09:00 local on `date` — the preferred placement for new * work when an empty day needs a default start time. */ export function workDayStartOn(date: string, tz: string): string { const dayjs = getDayJsInstance(); return dayjs.tz(`${date} 09:00:00`, tz).utc().format(); } export interface FreeWindow { start: string; end: string; } interface Interval { start: Dayjs; end: Dayjs; } export function localDayBounds(date: string, tz: string): { dayStart: Dayjs; dayEnd: Dayjs } { const dayjs = getDayJsInstance(); // `.add(1, 'day')` on a Dayjs instance advances by a fixed 24h, which is // wrong on DST-transition days (the local day is 23h or 25h long). Derive // the next calendar date in UTC (no DST) and take its local midnight, so // `dayEnd` is always the real next local midnight. const nextDate = dayjs.utc(date).add(1, 'day').format('YYYY-MM-DD'); return { dayStart: dayjs.tz(`${date} 00:00:00`, tz).utc(), dayEnd: dayjs.tz(`${nextDate} 00:00:00`, tz).utc(), }; } /** * Collect entries that intersect the day `[dayStart, dayEnd)`, clipped * to those bounds. Running entries use `nowDayjs` as their end. */ function collectDayObstacles( entries: TimeEntry[], dayStart: Dayjs, dayEnd: Dayjs, nowDayjs: Dayjs ): Interval[] { const dayjs = getDayJsInstance(); const obstacles: Interval[] = []; for (const entry of entries) { const entryStart = dayjs.utc(entry.start); const entryEnd = entry.end ? dayjs.utc(entry.end) : nowDayjs; if (entryEnd.isSameOrBefore(dayStart)) continue; if (entryStart.isSameOrAfter(dayEnd)) continue; const clippedStart = entryStart.isBefore(dayStart) ? dayStart : entryStart; const clippedEnd = entryEnd.isAfter(dayEnd) ? dayEnd : entryEnd; obstacles.push({ start: clippedStart, end: clippedEnd }); } return obstacles; } /** * First free window on the local calendar day that fits `requiredSeconds` * without colliding with any existing entry. Returns `null` if nothing fits * — never crosses midnight. * * Obstacles include same-day entries, spillovers from adjacent days, and * running entries (treated as `end = now`). All are clipped to the day's * `[00:00, 24:00)` boundaries. * * `preferredStart` (UTC ISO) is a hard floor — windows with `start` before * it are rejected. Use it to place "after some cursor." */ export function findFreeWindowOnDay( entries: TimeEntry[], date: string, requiredSeconds: number, tz: string, preferredStart?: string | null, now?: string | Dayjs ): FreeWindow | null { if (requiredSeconds <= 0) return null; const dayjs = getDayJsInstance(); const { dayStart, dayEnd } = localDayBounds(date, tz); if (requiredSeconds > dayEnd.diff(dayStart, 'second')) return null; const nowDayjs = now ? dayjs.utc(now) : dayjs.utc(); const obstacles = collectDayObstacles(entries, dayStart, dayEnd, nowDayjs); // Sort + merge so we can walk a clean [gap, obstacle, gap, ...] sequence. obstacles.sort((a, b) => a.start.diff(b.start)); // merge overlaps const merged: Interval[] = []; for (const obs of obstacles) { const last = merged[merged.length - 1]; if (last && obs.start.isSameOrBefore(last.end)) { if (obs.end.isAfter(last.end)) { last.end = obs.end; } } else { merged.push({ start: obs.start, end: obs.end }); } } let cursor: Dayjs = dayStart; if (preferredStart) { const pref = dayjs.utc(preferredStart); if (pref.isAfter(cursor)) cursor = pref; } if (cursor.isSameOrAfter(dayEnd)) return null; for (const obs of merged) { if (obs.end.isSameOrBefore(cursor)) continue; if (obs.start.isAfter(cursor)) { const gapSeconds = obs.start.diff(cursor, 'second'); if (gapSeconds >= requiredSeconds) { return { start: cursor.format(), end: cursor.add(requiredSeconds, 'second').format(), }; } } if (obs.end.isAfter(cursor)) cursor = obs.end; if (cursor.isSameOrAfter(dayEnd)) return null; } const trailingSeconds = dayEnd.diff(cursor, 'second'); if (trailingSeconds >= requiredSeconds) { return { start: cursor.format(), end: cursor.add(requiredSeconds, 'second').format(), }; } return null; } /** * Seconds of free space starting at `cursor` until the next obstacle * (or end of day). Returns 0 if the cursor is inside an obstacle or past * midnight. Used by the extend path: "how far can I push this end forward?" */ export function freeGapSecondsAfter( entries: TimeEntry[], date: string, tz: string, cursor: string, now?: string | Dayjs ): number { const dayjs = getDayJsInstance(); const { dayStart, dayEnd } = localDayBounds(date, tz); const cursorDjs = dayjs.utc(cursor); if (cursorDjs.isSameOrAfter(dayEnd)) return 0; if (cursorDjs.isBefore(dayStart)) return 0; const nowDayjs = now ? dayjs.utc(now) : dayjs.utc(); // Drop obstacles ending at/before the cursor — they're behind us. const obstacles = collectDayObstacles(entries, dayStart, dayEnd, nowDayjs).filter((obs) => obs.end.isAfter(cursorDjs) ); obstacles.sort((a, b) => a.start.diff(b.start)); // Cursor inside an obstacle → no gap. for (const obs of obstacles) { if (obs.start.isSameOrBefore(cursorDjs) && obs.end.isAfter(cursorDjs)) { return 0; } } // Distance to first obstacle strictly after cursor, or to end of day. for (const obs of obstacles) { if (obs.start.isAfter(cursorDjs)) { return Math.max(0, obs.start.diff(cursorDjs, 'second')); } } return Math.max(0, dayEnd.diff(cursorDjs, 'second')); } /** * Thrown when a required duration cannot fit on the target day without * introducing an overlap. Callers reformat the message for end users. */ export class NoFreeWindowError extends Error { public readonly code = 'no_free_window' as const; public readonly date: string; public readonly requiredSeconds: number; constructor(date: string, requiredSeconds: number) { super( `Cannot fit ${requiredSeconds} seconds on ${date} without overlapping existing time entries.` ); this.name = 'NoFreeWindowError'; this.date = date; this.requiredSeconds = requiredSeconds; } }