Files
solidtime/resources/js/utils/timesheet/cellMath.ts
2026-07-28 16:52:36 +02:00

213 lines
6.9 KiB
TypeScript

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;
}
}