mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
213 lines
6.9 KiB
TypeScript
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;
|
|
}
|
|
}
|