Files
BoxBrain/src/schedule.ts

163 lines
5.7 KiB
TypeScript

import type {
AvailabilityMode,
AvailabilityRange,
DateTimeInput,
MemorySpace,
ScheduleBlock,
ScheduleEntry,
ScheduledAvailabilitySnapshot,
} from './types';
const DAY_MS = 24 * 60 * 60 * 1000;
export function toDate(input: DateTimeInput): Date {
const date = input instanceof Date ? new Date(input.getTime()) : new Date(input);
if (Number.isNaN(date.getTime())) {
throw new Error(`Invalid datetime: ${String(input)}`);
}
return date;
}
export function toIso(input: DateTimeInput): string {
return toDate(input).toISOString();
}
export function startOfUtcDay(input: DateTimeInput): Date {
const date = toDate(input);
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
}
export function addUtcDays(input: DateTimeInput, days: number): Date {
return new Date(startOfUtcDay(input).getTime() + days * DAY_MS);
}
export function scheduleTargetDay(now: DateTimeInput): Date {
return addUtcDays(now, 1);
}
export function daysInMonth(date: Date): number {
const year = date.getUTCFullYear();
const month = date.getUTCMonth();
return new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
}
export function scheduleInstruction(): string {
return [
'Generate realistic schedule blocks for the persona based on their profile and the provided message.',
'Activity types should be creative and context-appropriate; do not limit yourself to a fixed list.',
'For each block, choose an availability mode: online (available to chat), do-not-disturb (busy but reachable for urgent matters), or offline (completely unavailable).',
'Return blocks covering the requested time range with startTime and endTime in HH:MM 24-hour format.',
].join('\n');
}
export function blocksToDailySchedule(input: {
persona: MemorySpace;
targetDay: DateTimeInput;
message: string;
blocks: ScheduleBlock[];
}): ScheduleEntry[] {
const target = startOfUtcDay(input.targetDay);
return input.blocks.map((block) => {
const [startHour, startMinute] = block.startTime.split(':').map(Number) as [number, number];
const [endHour, endMinute] = block.endTime.split(':').map(Number) as [number, number];
const start = new Date(target.getTime() + ((startHour * 60 + startMinute) * 60 * 1000));
const end = new Date(target.getTime() + ((endHour * 60 + endMinute) * 60 * 1000));
return {
id: crypto.randomUUID(),
spaceId: input.persona.id,
startAt: start.toISOString(),
endAt: end.toISOString(),
activity: block.activity,
title: block.title,
description: block.description ?? `Realistic ${block.title.toLowerCase()} block for ${input.persona.displayName}.`,
granularity: 'ten-minute',
sourceMessage: input.message,
metadata: {
boxbrainType: 'schedule-entry',
availabilityMode: block.availabilityMode,
targetDate: target.toISOString().slice(0, 10),
},
};
});
}
export function blocksToMonthlySchedule(input: {
persona: MemorySpace;
fromDay: DateTimeInput;
message: string;
blocks: ScheduleBlock[];
}): ScheduleEntry[] {
const start = startOfUtcDay(input.fromDay);
return input.blocks.map((block, day) => {
const dayStart = new Date(start.getTime() + day * DAY_MS);
const [startHour, startMinute] = block.startTime.split(':').map(Number) as [number, number];
const [endHour, endMinute] = block.endTime.split(':').map(Number) as [number, number];
const entryStart = new Date(dayStart.getTime() + ((startHour * 60 + startMinute) * 60 * 1000));
const entryEnd = new Date(dayStart.getTime() + ((endHour * 60 + endMinute) * 60 * 1000));
return {
id: crypto.randomUUID(),
spaceId: input.persona.id,
startAt: entryStart.toISOString(),
endAt: entryEnd.toISOString(),
activity: block.activity,
title: block.title,
description: block.description ?? `Daily outline for ${input.persona.displayName}.`,
granularity: 'day',
sourceMessage: input.message,
metadata: {
boxbrainType: 'schedule-entry',
availabilityMode: block.availabilityMode,
targetDate: dayStart.toISOString().slice(0, 10),
},
};
});
}
export function availabilityModeForEntry(entry: ScheduleEntry): AvailabilityMode {
const mode = entry.metadata['availabilityMode'];
if (mode === 'online' || mode === 'do-not-disturb' || mode === 'offline') return mode;
return 'online';
}
export function buildAvailabilitySnapshot(input: {
now: DateTimeInput;
generatedAt?: DateTimeInput;
entries: ScheduleEntry[];
}): ScheduledAvailabilitySnapshot {
const windowStart = startOfUtcDay(input.now);
const windowEnd = new Date(windowStart.getTime() + 2 * DAY_MS);
const sorted = input.entries
.filter((entry) => entry.startAt < windowEnd.toISOString() && entry.endAt > windowStart.toISOString())
.sort((a, b) => a.startAt.localeCompare(b.startAt));
const ranges: AvailabilityRange[] = [];
for (const entry of sorted) {
const mode = availabilityModeForEntry(entry);
const previous = ranges.at(-1);
if (previous && previous.mode === mode && previous.endAt === entry.startAt) {
previous.endAt = entry.endAt;
previous.sourceScheduleIds.push(entry.id);
continue;
}
ranges.push({
startAt: entry.startAt,
endAt: entry.endAt,
mode,
sourceScheduleIds: [entry.id],
reason: entry.title,
});
}
return {
generatedAt: toIso(input.generatedAt ?? input.now),
windowStartAt: windowStart.toISOString(),
windowEndAt: windowEnd.toISOString(),
ranges,
};
}
export function dateKeysAround(input: DateTimeInput): string[] {
const today = startOfUtcDay(input);
return [-1, 0, 1].map((offset) => new Date(today.getTime() + offset * DAY_MS).toISOString().slice(0, 10));
}