import { createOpaqueId } from "../core/ids"; import { areControlEffectsEqual, cloneControlEffect, cloneControlTargetRef, getControlEffectResolutionKey, type ControlEffect, type ControlTargetRef, createActorControlTargetRef, createSetActorPresenceControlEffect, getControlTargetRefKey } from "../controls/control-surface"; import { HOURS_PER_DAY, normalizeTimeOfDayHours } from "../document/project-time-settings"; export const PROJECT_SCHEDULE_WEEKDAYS = [ "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday" ] as const; export type ProjectScheduleWeekday = (typeof PROJECT_SCHEDULE_WEEKDAYS)[number]; export interface ProjectScheduleEveryDaySelection { mode: "everyDay"; } export interface ProjectScheduleSelectedDaysSelection { mode: "selectedDays"; days: ProjectScheduleWeekday[]; } export type ProjectScheduleDaySelection = | ProjectScheduleEveryDaySelection | ProjectScheduleSelectedDaysSelection; export interface ProjectScheduleRoutine { id: string; title: string; enabled: boolean; target: ControlTargetRef; sequenceId: string | null; days: ProjectScheduleDaySelection; startHour: number; endHour: number; priority: number; effects: ControlEffect[]; } export interface ProjectScheduler { routines: Record; } export interface ProjectScheduleTimelineSegment { key: string; startHour: number; endHour: number; } export const DEFAULT_PROJECT_SCHEDULE_START_HOUR = 9 as const; export const DEFAULT_PROJECT_SCHEDULE_END_HOUR = 17 as const; function assertNonEmptyString(value: string, label: string) { if (value.trim().length === 0) { throw new Error(`${label} must be non-empty.`); } } function assertFiniteNumber(value: number, label: string) { if (!Number.isFinite(value)) { throw new Error(`${label} must be a finite number.`); } } function normalizeProjectSchedulePriority(priority: number | undefined): number { if (priority === undefined || !Number.isFinite(priority)) { return 0; } return Math.trunc(priority); } function normalizeProjectScheduleDays( days: ProjectScheduleDaySelection | undefined ): ProjectScheduleDaySelection { if (days === undefined || days.mode === "everyDay") { return createProjectScheduleEveryDaySelection(); } const deduplicatedDays = [...new Set(days.days)].filter(isProjectScheduleWeekday); if (deduplicatedDays.length === 0) { throw new Error("Project schedule selected days must include at least one day."); } if (deduplicatedDays.length === PROJECT_SCHEDULE_WEEKDAYS.length) { return createProjectScheduleEveryDaySelection(); } return { mode: "selectedDays", days: deduplicatedDays }; } function normalizeProjectScheduleTitle(title: string | undefined): string { const normalizedTitle = title?.trim() ?? ""; if (normalizedTitle.length === 0) { throw new Error("Project schedule routine title must be non-empty."); } return normalizedTitle; } function normalizeProjectScheduleSequenceId( sequenceId: string | null | undefined ): string | null { if (sequenceId === undefined || sequenceId === null) { return null; } const normalizedSequenceId = sequenceId.trim(); if (normalizedSequenceId.length === 0) { throw new Error("Project schedule routine sequence ids must be non-empty."); } return normalizedSequenceId; } function normalizeProjectScheduleHours(value: number, label: string): number { assertFiniteNumber(value, label); return normalizeTimeOfDayHours(value); } function compareProjectScheduleEffectOrder( left: ControlEffect, right: ControlEffect ): number { const getOrder = (effect: ControlEffect): number => { switch (effect.type) { case "setActorPresence": return 0; case "playActorAnimation": return 1; case "followActorPath": return 2; default: return 10; } }; return ( getOrder(left) - getOrder(right) || getControlEffectResolutionKey(left).localeCompare( getControlEffectResolutionKey(right) ) ); } function normalizeProjectScheduleEffects( target: ControlTargetRef, sequenceId: string | null, options: { effect?: ControlEffect; effects?: ControlEffect[]; } ): ControlEffect[] { const authoredEffects = options.effects !== undefined ? options.effects : options.effect === undefined ? [] : [options.effect]; const normalizedEffects = authoredEffects.map(cloneControlEffect); if (normalizedEffects.length === 0 && target.kind === "global") { return []; } if (normalizedEffects.length === 0 && sequenceId !== null) { return []; } if (normalizedEffects.length === 0 && target.kind !== "actor") { throw new Error( "Project schedule routines must author an explicit control effect for non-actor targets." ); } for (const effect of normalizedEffects) { if (getControlTargetRefKey(effect.target) !== getControlTargetRefKey(target)) { throw new Error( "Project schedule routine effects must target the same authored control target." ); } } if ( target.kind === "actor" && !normalizedEffects.some((effect) => effect.type === "setActorPresence") ) { normalizedEffects.push( createSetActorPresenceControlEffect({ target, active: true }) ); } if (normalizedEffects.length === 0) { throw new Error("Project schedule routines must contain at least one control effect."); } if (target.kind !== "actor" && normalizedEffects.length !== 1) { throw new Error( "Non-actor project schedule routines must currently author exactly one control effect." ); } const seenResolutionKeys = new Set(); for (const effect of normalizedEffects) { const resolutionKey = getControlEffectResolutionKey(effect); if (seenResolutionKeys.has(resolutionKey)) { throw new Error( `Project schedule routines cannot author multiple effects for ${resolutionKey}.` ); } seenResolutionKeys.add(resolutionKey); } normalizedEffects.sort(compareProjectScheduleEffectOrder); return normalizedEffects; } export function isProjectScheduleWeekday( value: unknown ): value is ProjectScheduleWeekday { return PROJECT_SCHEDULE_WEEKDAYS.includes(value as ProjectScheduleWeekday); } export function createProjectScheduleEveryDaySelection(): ProjectScheduleEveryDaySelection { return { mode: "everyDay" }; } export function createProjectScheduleSelectedDaysSelection( days: ProjectScheduleWeekday[] ): ProjectScheduleSelectedDaysSelection { return normalizeProjectScheduleDays({ mode: "selectedDays", days }) as ProjectScheduleSelectedDaysSelection; } export function createEmptyProjectScheduler(): ProjectScheduler { return { routines: {} }; } export function createProjectScheduleRoutine( overrides: Partial< Pick< ProjectScheduleRoutine, | "id" | "title" | "enabled" | "target" | "sequenceId" | "days" | "startHour" | "endHour" | "priority" | "effects" > > & { effect?: ControlEffect; } & Pick = { target: createActorControlTargetRef("actor-default"), title: "Routine" } ): ProjectScheduleRoutine { const target = cloneControlTargetRef(overrides.target); const sequenceId = normalizeProjectScheduleSequenceId(overrides.sequenceId); const startHour = normalizeProjectScheduleHours( overrides.startHour ?? DEFAULT_PROJECT_SCHEDULE_START_HOUR, "Project schedule start hour" ); const endHour = normalizeProjectScheduleHours( overrides.endHour ?? DEFAULT_PROJECT_SCHEDULE_END_HOUR, "Project schedule end hour" ); if (startHour === endHour) { throw new Error("Project schedule routines must span at least part of the day."); } return { id: overrides.id ?? createOpaqueId("schedule-routine"), title: normalizeProjectScheduleTitle(overrides.title), enabled: overrides.enabled ?? true, target, sequenceId, days: normalizeProjectScheduleDays(overrides.days), startHour, endHour, priority: normalizeProjectSchedulePriority(overrides.priority), effects: normalizeProjectScheduleEffects(target, sequenceId, { effect: overrides.effect, effects: overrides.effects }) }; } export function cloneProjectScheduleDaySelection( days: ProjectScheduleDaySelection ): ProjectScheduleDaySelection { switch (days.mode) { case "everyDay": return createProjectScheduleEveryDaySelection(); case "selectedDays": return createProjectScheduleSelectedDaysSelection(days.days); } } export function cloneProjectScheduleRoutine( routine: ProjectScheduleRoutine ): ProjectScheduleRoutine { return { id: routine.id, title: routine.title, enabled: routine.enabled, target: cloneControlTargetRef(routine.target), sequenceId: routine.sequenceId, days: cloneProjectScheduleDaySelection(routine.days), startHour: routine.startHour, endHour: routine.endHour, priority: routine.priority, effects: routine.effects.map(cloneControlEffect) }; } export function cloneProjectScheduler( scheduler: ProjectScheduler ): ProjectScheduler { return { routines: Object.fromEntries( Object.entries(scheduler.routines).map(([routineId, routine]) => [ routineId, cloneProjectScheduleRoutine(routine) ]) ) }; } export function areProjectScheduleDaySelectionsEqual( left: ProjectScheduleDaySelection, right: ProjectScheduleDaySelection ): boolean { if (left.mode !== right.mode) { return false; } if (left.mode === "everyDay") { return true; } return ( right.mode === "selectedDays" && left.days.length === right.days.length && left.days.every((day, index) => day === right.days[index]) ); } export function areProjectScheduleRoutinesEqual( left: ProjectScheduleRoutine, right: ProjectScheduleRoutine ): boolean { return ( left.id === right.id && left.title === right.title && left.enabled === right.enabled && getControlTargetRefKey(left.target) === getControlTargetRefKey(right.target) && left.sequenceId === right.sequenceId && areProjectScheduleDaySelectionsEqual(left.days, right.days) && left.startHour === right.startHour && left.endHour === right.endHour && left.priority === right.priority && left.effects.length === right.effects.length && left.effects.every((effect, index) => areControlEffectsEqual(effect, right.effects[index] as ControlEffect) ) ); } export function findProjectScheduleRoutineEffect< TType extends ControlEffect["type"] >( routine: ProjectScheduleRoutine, type: TType ): Extract | null { return ( routine.effects.find( (effect): effect is Extract => effect.type === type ) ?? null ); } export function areProjectSchedulersEqual( left: ProjectScheduler, right: ProjectScheduler ): boolean { const leftRoutineIds = Object.keys(left.routines); const rightRoutineIds = Object.keys(right.routines); if (leftRoutineIds.length !== rightRoutineIds.length) { return false; } return leftRoutineIds.every((routineId) => { const leftRoutine = left.routines[routineId]; const rightRoutine = right.routines[routineId]; return ( leftRoutine !== undefined && rightRoutine !== undefined && areProjectScheduleRoutinesEqual(leftRoutine, rightRoutine) ); }); } export function resolveProjectScheduleWeekday( dayNumber: number ): ProjectScheduleWeekday { const normalizedDayNumber = Math.floor(Number.isFinite(dayNumber) ? dayNumber : 1); const zeroBasedDayIndex = ((normalizedDayNumber - 1) % PROJECT_SCHEDULE_WEEKDAYS.length + PROJECT_SCHEDULE_WEEKDAYS.length) % PROJECT_SCHEDULE_WEEKDAYS.length; return PROJECT_SCHEDULE_WEEKDAYS[zeroBasedDayIndex] ?? "monday"; } export function getPreviousProjectScheduleWeekday( weekday: ProjectScheduleWeekday ): ProjectScheduleWeekday { const dayIndex = PROJECT_SCHEDULE_WEEKDAYS.indexOf(weekday); return ( PROJECT_SCHEDULE_WEEKDAYS[ (dayIndex - 1 + PROJECT_SCHEDULE_WEEKDAYS.length) % PROJECT_SCHEDULE_WEEKDAYS.length ] ?? "sunday" ); } export function isProjectScheduleDaySelectionActive( days: ProjectScheduleDaySelection, weekday: ProjectScheduleWeekday ): boolean { switch (days.mode) { case "everyDay": return true; case "selectedDays": return days.days.includes(weekday); } } export function isProjectScheduleRoutineActiveAt( routine: ProjectScheduleRoutine, dayNumber: number, timeOfDayHours: number ): boolean { if (!routine.enabled) { return false; } const normalizedTimeOfDayHours = normalizeTimeOfDayHours(timeOfDayHours); const weekday = resolveProjectScheduleWeekday(dayNumber); if (routine.startHour < routine.endHour) { return ( isProjectScheduleDaySelectionActive(routine.days, weekday) && normalizedTimeOfDayHours >= routine.startHour && normalizedTimeOfDayHours < routine.endHour ); } return ( (isProjectScheduleDaySelectionActive(routine.days, weekday) && normalizedTimeOfDayHours >= routine.startHour) || (isProjectScheduleDaySelectionActive( routine.days, getPreviousProjectScheduleWeekday(weekday) ) && normalizedTimeOfDayHours < routine.endHour) ); } export function getProjectScheduleRoutineDurationHours( routine: Pick ): number { return routine.startHour < routine.endHour ? routine.endHour - routine.startHour : HOURS_PER_DAY - routine.startHour + routine.endHour; } export function getProjectScheduleRoutineElapsedHoursAt( routine: ProjectScheduleRoutine, dayNumber: number, timeOfDayHours: number ): number | null { if (!isProjectScheduleRoutineActiveAt(routine, dayNumber, timeOfDayHours)) { return null; } const normalizedTimeOfDayHours = normalizeTimeOfDayHours(timeOfDayHours); const weekday = resolveProjectScheduleWeekday(dayNumber); if (routine.startHour < routine.endHour) { return normalizedTimeOfDayHours - routine.startHour; } if ( isProjectScheduleDaySelectionActive(routine.days, weekday) && normalizedTimeOfDayHours >= routine.startHour ) { return normalizedTimeOfDayHours - routine.startHour; } return HOURS_PER_DAY - routine.startHour + normalizedTimeOfDayHours; } export function compareProjectScheduleRoutinePriority( left: ProjectScheduleRoutine, right: ProjectScheduleRoutine ): number { return ( right.priority - left.priority || left.startHour - right.startHour || left.title.localeCompare(right.title) || left.id.localeCompare(right.id) ); } export function getProjectScheduleTimelineSegments( routine: Pick ): ProjectScheduleTimelineSegment[] { if (routine.startHour < routine.endHour) { return [ { key: `${routine.id}:primary`, startHour: routine.startHour, endHour: routine.endHour } ]; } return [ { key: `${routine.id}:late`, startHour: routine.startHour, endHour: HOURS_PER_DAY }, { key: `${routine.id}:early`, startHour: 0, endHour: routine.endHour } ]; } export function formatProjectScheduleWeekdayLabel( weekday: ProjectScheduleWeekday ): string { switch (weekday) { case "monday": return "Mon"; case "tuesday": return "Tue"; case "wednesday": return "Wed"; case "thursday": return "Thu"; case "friday": return "Fri"; case "saturday": return "Sat"; case "sunday": return "Sun"; } } export function formatProjectScheduleDaySelection( days: ProjectScheduleDaySelection ): string { switch (days.mode) { case "everyDay": return "Every day"; case "selectedDays": return days.days.map(formatProjectScheduleWeekdayLabel).join(", "); } } export function upsertProjectScheduleRoutine( scheduler: ProjectScheduler, routine: ProjectScheduleRoutine ): ProjectScheduler { assertNonEmptyString(routine.id, "Project schedule routine id"); return { routines: { ...scheduler.routines, [routine.id]: cloneProjectScheduleRoutine(routine) } }; } export function removeProjectScheduleRoutine( scheduler: ProjectScheduler, routineId: string ): ProjectScheduler { if (scheduler.routines[routineId] === undefined) { return cloneProjectScheduler(scheduler); } const nextRoutines = { ...scheduler.routines }; delete nextRoutines[routineId]; return { routines: nextRoutines }; }