Files
solidtime/resources/js/utils/timesheet/breakPlacementMath.ts
2026-07-28 15:11:03 +02:00

650 lines
23 KiB
TypeScript

import { getDayJsInstance } from '@/packages/ui/src/utils/time';
import { BREAK_GAP_TOLERANCE_MINUTES } from '@/packages/ui/src/utils/breakPlacement';
/**
* Break placement solver for the timesheet.
*
* A break only means something sitting between work, ideally within a tolerance
* of it on both sides (see BREAK_GAP_TOLERANCE_MINUTES). When a break is added
* to a day we first try to drop it into an existing gap without touching any
* other entry — preferring a gap where the tolerance holds, but accepting any
* gap big enough to hold the break. The tolerance is a soft, read-time hint
* (see getBreakPlacementHint), never a reason to rearrange entries the user
* tracked deliberately. Only when no gap can physically hold the break does the
* caller resolve it via a modal that either splits the single work entry or
* moves the surrounding entries to open a slot — always keeping everything
* inside the day.
*
* All timestamps are UTC ISO strings. Shift arithmetic is done in epoch
* milliseconds so it is DST-safe (a wall-clock day can be 23h or 25h long).
*/
export const BREAK_GAP_TOLERANCE_SECONDS = BREAK_GAP_TOLERANCE_MINUTES * 60;
export interface Interval {
start: string;
end: string;
}
export interface MovableInterval extends Interval {
id: string;
}
export interface MovePlan {
breakSlot: Interval;
// Entries whose start/end changed to make room for the break
shifted: MovableInterval[];
}
export interface SplitPlan {
firstHalf: Interval;
breakSlot: Interval;
secondHalf: Interval;
// Entries pushed later to clear the extended second half.
shifted: MovableInterval[];
}
/**
* A break that could not be auto-placed within tolerance. The timesheet raises
* one of these so the page can open the placement modal, where the user either
* splits the single work entry or shifts entries to open a slot.
*/
export interface BreakPlacementRequest {
date: string;
durationSeconds: number;
dayStart: string;
dayEnd: string;
// Work entries on the day (finished, movable), used to split or shift
workEntries: MovableInterval[];
// Existing breaks on the day (minus the one being re-placed). They shift
// along with the surrounding work in move mode so a plan can never land
// on top of them.
otherEntries: MovableInterval[];
defaultBreakStart: string;
// When re-placing an existing break (an edit), the id to update in place
replaceBreakId: string | null;
}
/**
* How a request will be resolved: a single work entry is split around the
* break; with several, the surrounding entries move to open a slot.
*/
export function placementMode(request: BreakPlacementRequest): 'split' | 'move' {
return request.workEntries.length === 1 ? 'split' : 'move';
}
/** The minimal shape of a time entry the day-context builder needs. */
export interface DayEntryLike {
id: string;
start: string;
end: string | null;
type: string;
}
/**
* Everything the placement flow needs to know about one local day:
* finished work and break entries fully inside the day (both movable), and the
* usable day window. Entries that reach across a day boundary belong partly to
* another day and must not be moved — they shrink `dayStart`/`dayEnd` instead,
* so no plan can shift anything into them.
*/
export interface DayPlacementContext {
work: MovableInterval[];
breaks: MovableInterval[];
// Immovable blockers: a running entry keeps growing from its start, so it
// blocks placement from there through the end of the day.
blocked: Interval[];
dayStart: string;
dayEnd: string;
}
export type BreakPlacementDecision =
| { kind: 'save'; slot: Interval }
| { kind: 'place-in-free-window' }
| { kind: 'needs-input'; request: BreakPlacementRequest }
| { kind: 'reject' };
/**
* Decide how a requested break should be placed without performing any writes.
* Keeping this policy pure lets the composable focus on UI state and persistence.
*/
export function decideBreakPlacement({
date,
durationSeconds,
context,
anchorStart = null,
replaceBreakId = null,
}: {
date: string;
durationSeconds: number;
context: DayPlacementContext;
anchorStart?: string | null;
replaceBreakId?: string | null;
}): BreakPlacementDecision {
const { work, breaks, blocked, dayStart, dayEnd } = context;
const obstacles = [...breaks, ...blocked];
const gap =
(anchorStart !== null
? findValidBreakGapNear(work, durationSeconds, anchorStart, obstacles)
: null) ?? findValidBreakGap(work, durationSeconds, obstacles);
if (gap) return { kind: 'save', slot: gap };
if (work.length === 0) {
if (anchorStart === null) return { kind: 'place-in-free-window' };
const slot = findBreakSlotNearInDay(
dayStart,
dayEnd,
durationSeconds,
anchorStart,
obstacles
);
return slot ? { kind: 'save', slot } : { kind: 'reject' };
}
const defaultPlan =
work.length === 1
? planSplitEntry(work[0]!, durationSeconds, undefined, {
dayStart,
dayEnd,
otherEntries: breaks,
})
: suggestMovePlan(work, dayStart, dayEnd, durationSeconds, breaks);
if (!defaultPlan) {
const adjacent = findAdjacentBreakSlot(work, dayStart, dayEnd, durationSeconds, obstacles);
return adjacent ? { kind: 'save', slot: adjacent } : { kind: 'reject' };
}
return {
kind: 'needs-input',
request: {
date,
durationSeconds,
dayStart,
dayEnd,
workEntries: work,
otherEntries: breaks,
defaultBreakStart: defaultPlan.breakSlot.start,
replaceBreakId,
},
};
}
export function buildDayPlacementContext(
entries: DayEntryLike[],
dayStart: string,
dayEnd: string,
excludeBreakId: string | null = null
): DayPlacementContext {
const dayjs = getDayJsInstance();
const dayStartMs = dayjs.utc(dayStart).valueOf();
const dayEndMs = dayjs.utc(dayEnd).valueOf();
let effStartMs = dayStartMs;
let effEndMs = dayEndMs;
const work: MovableInterval[] = [];
const breaks: MovableInterval[] = [];
const blocked: Interval[] = [];
for (const entry of entries) {
if (entry.id === excludeBreakId) continue;
const startMs = dayjs.utc(entry.start).valueOf();
// A running entry keeps growing from its start: nothing can be placed
// at or after it, so it caps the usable window and blocks the rest of
// the day instead of being movable.
if (entry.end === null) {
if (startMs < dayEndMs) {
if (startMs < effEndMs) effEndMs = startMs;
blocked.push({ start: entry.start, end: dayEnd });
}
continue;
}
const endMs = dayjs.utc(entry.end).valueOf();
if (startMs >= dayEndMs || endMs <= dayStartMs) continue;
const crossesStart = startMs < dayStartMs;
const crossesEnd = endMs > dayEndMs;
if (crossesStart || crossesEnd) {
if (crossesStart && endMs > effStartMs) effStartMs = endMs;
if (crossesEnd && startMs < effEndMs) effEndMs = startMs;
continue;
}
const interval = { id: entry.id, start: entry.start, end: entry.end };
if (entry.type === 'break') {
breaks.push(interval);
} else {
work.push(interval);
}
}
return {
work: sortByStart(work),
breaks: sortByStart(breaks),
blocked: sortByStart(blocked),
dayStart: dayjs.utc(effStartMs).format(),
dayEnd: dayjs.utc(effEndMs).format(),
};
}
function sortByStart<T extends Interval>(intervals: T[]): T[] {
return [...intervals].sort((a, b) => a.start.localeCompare(b.start));
}
interface IntervalMs {
startMs: number;
endMs: number;
}
function toIntervalMs(interval: Interval): IntervalMs {
const dayjs = getDayJsInstance();
return {
startMs: dayjs.utc(interval.start).valueOf(),
endMs: dayjs.utc(interval.end).valueOf(),
};
}
function slotAt(startMs: number, durationMs: number): Interval {
const dayjs = getDayJsInstance();
return {
start: dayjs.utc(startMs).format(),
end: dayjs.utc(startMs + durationMs).format(),
};
}
/**
* Merge overlapping/touching work intervals so the space between two
* consecutive merged intervals is genuinely work-free. Without this, an entry
* contained in a longer one would fabricate a "gap" that overlaps work.
*/
function mergedWorkMs(work: Interval[]): IntervalMs[] {
const sorted = work.map(toIntervalMs).sort((a, b) => a.startMs - b.startMs);
const merged: IntervalMs[] = [];
for (const current of sorted) {
const last = merged[merged.length - 1];
if (last && current.startMs <= last.endMs) {
last.endMs = Math.max(last.endMs, current.endMs);
} else {
merged.push({ ...current });
}
}
return merged;
}
/** Work-free gaps between consecutive merged work intervals, in day order. */
function workFreeGapsMs(work: Interval[]): IntervalMs[] {
const merged = mergedWorkMs(work);
const gaps: IntervalMs[] = [];
for (let i = 0; i < merged.length - 1; i++) {
gaps.push({ startMs: merged[i]!.endMs, endMs: merged[i + 1]!.startMs });
}
return gaps;
}
/**
* Find a gap between work entries that can hold a break of `durationSeconds`,
* without touching any other entry.
*
* Preference order: first a gap where the centered break stays within
* `toleranceSeconds` of work on both sides. When no such gap exists, any gap
* big enough to physically hold the break is accepted — the break is placed
* flush after the preceding work (sliding past obstacles such as existing
* breaks) and the rest of the gap is left untouched. Such a break may end up
* further from work than the tolerance; that is surfaced as a read-time hint
* (getBreakPlacementHint), not treated as infeasible. Returns null only when
* no work-free gap can hold the break at all.
*/
export function findValidBreakGap(
work: Interval[],
durationSeconds: number,
obstacles: Interval[] = [],
toleranceSeconds: number = BREAK_GAP_TOLERANCE_SECONDS
): Interval | null {
if (durationSeconds <= 0) return null;
const durationMs = durationSeconds * 1000;
const gaps = workFreeGapsMs(work);
const obstaclesMs = obstacles.map(toIntervalMs);
const blockers = (startMs: number): IntervalMs[] =>
obstaclesMs.filter((o) => startMs < o.endMs && o.startMs < startMs + durationMs);
// Pass 1: a gap where the centered break keeps both sides within tolerance.
for (const gap of gaps) {
const gapMs = gap.endMs - gap.startMs;
if (gapMs < durationMs || gapMs > durationMs + 2 * toleranceSeconds * 1000) continue;
const startMs = gap.startMs + Math.floor((gapMs - durationMs) / 2000) * 1000;
if (blockers(startMs).length > 0) continue;
return slotAt(startMs, durationMs);
}
// Pass 2: any gap that can physically hold the break. Start flush after the
// preceding work and slide right past obstacles until the slot is free.
for (const gap of gaps) {
let startMs = gap.startMs;
while (startMs + durationMs <= gap.endMs) {
const blocking = blockers(startMs);
if (blocking.length === 0) return slotAt(startMs, durationMs);
startMs = Math.max(...blocking.map((o) => o.endMs));
}
}
return null;
}
/**
* Re-place an existing break as close to `anchorStart` as possible, instead of
* jumping to the first gap (which findValidBreakGap does). Only the gap the
* anchor currently sits in is considered — the break keeps its position when
* that gap can still physically hold the new duration, clamped only to stay
* inside the gap (how far it then sits from work is a soft read-time hint, not
* a constraint). Obstacles (other breaks) don't evict the break from its gap:
* it settles into the free window of the gap closest to the anchor, sliding
* just past whatever is in the way. Returns null only when the anchor sits in
* no work-free gap or that gap has no free window big enough; the caller then
* falls back to findValidBreakGap.
*/
export function findValidBreakGapNear(
work: Interval[],
durationSeconds: number,
anchorStart: string,
obstacles: Interval[] = []
): Interval | null {
if (durationSeconds <= 0) return null;
const dayjs = getDayJsInstance();
const durationMs = durationSeconds * 1000;
const anchorMs = dayjs.utc(anchorStart).valueOf();
const obstaclesMs = obstacles.map(toIntervalMs);
for (const gap of workFreeGapsMs(work)) {
// The anchor must fall inside this gap for it to be "where the break is".
if (anchorMs < gap.startMs || anchorMs >= gap.endMs) continue;
const best = closestFreeStartMs(gap, durationMs, anchorMs, obstaclesMs);
if (best === null) return null;
return slotAt(best, durationMs);
}
return null;
}
/** Closest obstacle-free start to `anchorMs` that fits inside `range`. */
function closestFreeStartMs(
range: IntervalMs,
durationMs: number,
anchorMs: number,
obstaclesMs: IntervalMs[]
): number | null {
if (range.endMs - range.startMs < durationMs) return null;
const blockers = obstaclesMs
.filter((o) => o.startMs < range.endMs && o.endMs > range.startMs)
.sort((a, b) => a.startMs - b.startMs);
let best: number | null = null;
const consider = (winStartMs: number, winEndMs: number) => {
if (winEndMs - winStartMs < durationMs) return;
const candidate = Math.min(Math.max(anchorMs, winStartMs), winEndMs - durationMs);
if (best === null || Math.abs(candidate - anchorMs) < Math.abs(best - anchorMs)) {
best = candidate;
}
};
let cursor = range.startMs;
for (const blocker of blockers) {
consider(cursor, blocker.startMs);
cursor = Math.max(cursor, blocker.endMs);
}
consider(cursor, range.endMs);
return best;
}
/** Keep a workless-day break near its current start without leaving the day. */
export function findBreakSlotNearInDay(
dayStart: string,
dayEnd: string,
durationSeconds: number,
anchorStart: string,
obstacles: Interval[] = []
): Interval | null {
if (durationSeconds <= 0) return null;
const dayjs = getDayJsInstance();
const durationMs = durationSeconds * 1000;
const range = {
startMs: dayjs.utc(dayStart).valueOf(),
endMs: dayjs.utc(dayEnd).valueOf(),
};
const best = closestFreeStartMs(
range,
durationMs,
dayjs.utc(anchorStart).valueOf(),
obstacles.map(toIntervalMs)
);
if (best === null) return null;
return slotAt(best, durationMs);
}
export const MIN_SPLIT_FRAGMENT_SECONDS = 60;
export interface SplitOptions {
// Omitted bounds are unbounded.
dayStart?: string;
dayEnd?: string;
// Existing breaks that may block or move with the split.
otherEntries?: MovableInterval[];
}
/** Insert a break while preserving work duration and respecting the supplied bounds. */
export function planSplitEntry(
entry: Interval,
durationSeconds: number,
breakStart?: string,
options: SplitOptions = {}
): SplitPlan | null {
if (durationSeconds <= 0) return null;
const dayjs = getDayJsInstance();
const toMs = (iso: string) => dayjs.utc(iso).valueOf();
const iso = (ms: number) => dayjs.utc(ms).format();
const entryStartMs = toMs(entry.start);
const totalMs = toMs(entry.end) - entryStartMs;
const minMs = MIN_SPLIT_FRAGMENT_SECONDS * 1000;
if (totalMs < 2 * minMs) return null;
const durationMs = durationSeconds * 1000;
const dayEndMs = options.dayEnd !== undefined ? toMs(options.dayEnd) : Infinity;
// Pull the block earlier only enough to keep it inside the day.
const blockStartMs = Math.min(entryStartMs, dayEndMs - totalMs - durationMs);
let bStartMs = breakStart ? toMs(breakStart) : blockStartMs + Math.floor(totalMs / 2000) * 1000;
if (breakStart) {
if (bStartMs < blockStartMs + minMs || bStartMs > blockStartMs + totalMs - minMs) {
return null;
}
} else {
// Safety net for rounding of the centered position only.
bStartMs = Math.min(
Math.max(bStartMs, blockStartMs + minMs),
blockStartMs + totalMs - minMs
);
}
const bEndMs = bStartMs + durationMs;
const firstHalfMs = bStartMs - blockStartMs;
const secondHalfEndMs = bEndMs + totalMs - firstHalfMs;
// Carry the occupied end through the collision chain; stop at the first gap.
const others = options.otherEntries ?? [];
const later = others
.filter((other) => toMs(other.start) >= bStartMs)
.sort((a, b) => toMs(a.start) - toMs(b.start));
const shifted: MovableInterval[] = [];
let occupiedEndMs = secondHalfEndMs;
for (const other of later) {
const otherStartMs = toMs(other.start);
if (otherStartMs >= occupiedEndMs) break;
const otherEndMs = toMs(other.end);
const shiftMs = occupiedEndMs - otherStartMs;
const shiftedEndMs = otherEndMs + shiftMs;
if (shiftedEndMs > dayEndMs) return null;
shifted.push({
id: other.id,
start: iso(otherStartMs + shiftMs),
end: iso(shiftedEndMs),
});
occupiedEndMs = shiftedEndMs;
}
// Earlier breaks constrain how far the block may move back.
let earliestStartMs = options.dayStart !== undefined ? toMs(options.dayStart) : -Infinity;
for (const other of others) {
const otherEndMs = toMs(other.end);
if (
toMs(other.start) < bStartMs &&
otherEndMs <= bStartMs &&
otherEndMs > earliestStartMs
) {
earliestStartMs = otherEndMs;
}
}
if (blockStartMs < earliestStartMs) return null;
return {
firstHalf: { start: iso(blockStartMs), end: iso(bStartMs) },
breakSlot: { start: iso(bStartMs), end: iso(bEndMs) },
secondHalf: { start: iso(bEndMs), end: iso(secondHalfEndMs) },
shifted,
};
}
/** Last resort: place the break after the last work entry or before the first. */
export function findAdjacentBreakSlot(
work: Interval[],
dayStart: string,
dayEnd: string,
durationSeconds: number,
obstacles: Interval[] = []
): Interval | null {
if (durationSeconds <= 0 || work.length === 0) return null;
const dayjs = getDayJsInstance();
const durationMs = durationSeconds * 1000;
const dayStartMs = dayjs.utc(dayStart).valueOf();
const dayEndMs = dayjs.utc(dayEnd).valueOf();
const merged = mergedWorkMs(work);
const blockers = [...merged, ...obstacles.map(toIntervalMs)];
const candidates = [merged[merged.length - 1]!.endMs, merged[0]!.startMs - durationMs];
for (const startMs of candidates) {
if (startMs < dayStartMs || startMs + durationMs > dayEndMs) continue;
const clear = blockers.every(
(blocker) => blocker.endMs <= startMs || blocker.startMs >= startMs + durationMs
);
if (clear) return slotAt(startMs, durationMs);
}
return null;
}
/**
* Insert a break by translating overlapping entries on either side just enough
* to clear it. Existing gaps remain unchanged. Returns null if a shift would
* leave the day.
*/
export function planMoveInsert(
entries: MovableInterval[],
dayStart: string,
dayEnd: string,
breakStart: string,
durationSeconds: number
): MovePlan | null {
if (durationSeconds <= 0) return null;
const dayjs = getDayJsInstance();
const bStartMs = dayjs.utc(breakStart).valueOf();
const bEndMs = bStartMs + durationSeconds * 1000;
const dayStartMs = dayjs.utc(dayStart).valueOf();
const dayEndMs = dayjs.utc(dayEnd).valueOf();
if (bStartMs < dayStartMs || bEndMs > dayEndMs) return null;
const toMs = (iso: string) => dayjs.utc(iso).valueOf();
const left = entries.filter((e) => toMs(e.start) < bStartMs);
const right = entries.filter((e) => toMs(e.start) >= bStartMs);
const shifted: MovableInterval[] = [];
const translate = (block: MovableInterval[], shiftMs: number) => {
for (const e of block) {
shifted.push({
id: e.id,
start: dayjs.utc(toMs(e.start) + shiftMs).format(),
end: dayjs.utc(toMs(e.end) + shiftMs).format(),
});
}
};
if (left.length > 0) {
const maxLeftEnd = Math.max(...left.map((e) => toMs(e.end)));
const minLeftStart = Math.min(...left.map((e) => toMs(e.start)));
// Only pull earlier when the block overlaps the slot, never later.
const shift = Math.min(0, bStartMs - maxLeftEnd);
if (shift !== 0) {
if (minLeftStart + shift < dayStartMs) return null;
translate(left, shift);
}
}
if (right.length > 0) {
const minRightStart = Math.min(...right.map((e) => toMs(e.start)));
const maxRightEnd = Math.max(...right.map((e) => toMs(e.end)));
// Only push later when the slot overlaps the block, never earlier.
const shift = Math.max(0, bEndMs - minRightStart);
if (shift !== 0) {
if (maxRightEnd + shift > dayEndMs) return null;
translate(right, shift);
}
}
return {
breakSlot: {
start: dayjs.utc(bStartMs).format(),
end: dayjs.utc(bEndMs).format(),
},
shifted,
};
}
/**
* Pick a feasible default break position for the move case — only reached when
* no work-free gap can hold the break, so opening a slot requires shifting.
* Only boundaries *between* two consecutive work entries are considered, so the
* break always ends up flanked by work (a break before the first entry or after
* the last one would be misplaced). For each boundary it tries pushing the
* right block later first, then pulling the left block earlier, and returns the
* first placement whose shifts stay inside the day. `otherEntries` (existing
* breaks) shift along with the work around them. Null when nothing fits.
*/
export function suggestMovePlan(
work: MovableInterval[],
dayStart: string,
dayEnd: string,
durationSeconds: number,
otherEntries: MovableInterval[] = []
): MovePlan | null {
const dayjs = getDayJsInstance();
const sorted = sortByStart(work);
const movable = [...work, ...otherEntries];
for (let i = 0; i < sorted.length - 1; i++) {
// Push the right block later: break starts where the earlier entry ends.
const pushRight = planMoveInsert(
movable,
dayStart,
dayEnd,
sorted[i]!.end,
durationSeconds
);
if (pushRight) return pushRight;
// Pull the left block earlier: break ends where the later entry starts.
const before = dayjs
.utc(sorted[i + 1]!.start)
.subtract(durationSeconds, 'second')
.format();
const pullLeft = planMoveInsert(movable, dayStart, dayEnd, before, durationSeconds);
if (pullLeft) return pullLeft;
}
return null;
}