All source files · Workbench

src/lib/planner.ts

export interface PlanItem { id: string; title: string; deadlineUtc: string; buildHours?: number; humanMinutes?: number; url?: string; dependencies?: string[] }
export interface ScheduledItem extends PlanItem { start: number; end: number; deadline: number; slackHours: number; late: boolean }

/** Include effective source defaults so refreshes cannot silently reuse an old planning decision. */
export function fingerprintWorkInputs(items: PlanItem[], overrides: ReadonlyMap<string, number> = new Map()): string {
  return JSON.stringify(items.map(item => [item.id, overrides.get(item.id) ?? item.buildHours ?? 4, item.humanMinutes ?? 10, [...(item.dependencies ?? [])].sort()]).sort((a, b) => String(a[0]).localeCompare(String(b[0]))));
}

export function timezoneOffset(epoch: number, timezone: string): number {
  const parts = new Intl.DateTimeFormat('en-GB', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }).formatToParts(new Date(epoch));
  const get = (type: string) => Number(parts.find(p => p.type === type)?.value || 0);
  return Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second')) - epoch;
}

export function localTimeToUtc(wall: string, timezone: string): number {
  const [date, time] = wall.split('T');
  if (!date || !time) return NaN;
  const [year, month, day] = date.split('-').map(Number);
  const [hour, minute] = time.split(':').map(Number);
  const guessed = Date.UTC(year, month - 1, day, hour, minute);
  let epoch = guessed - timezoneOffset(guessed, timezone);
  return guessed - timezoneOffset(epoch, timezone);
}

/** Work estimates become elapsed time using daily capacity; earliest deadlines go first. */
export function scheduleItems(items: PlanItem[], start: number, dailyHours: number, override: {id: string; deadline: number} | null = null): ScheduledItem[] {
  if (!Number.isFinite(start) || !Number.isFinite(dailyHours) || dailyHours <= 0) throw new Error('A valid start and positive daily capacity are required.');
  const queue = items.map(item => ({...item, deadline: override?.id === item.id ? override.deadline : Date.parse(item.deadlineUtc)})).sort((a,b) => a.deadline-b.deadline);
  const plan: ScheduledItem[] = [];
  let cursor = start;
  for (const item of queue) {
    const effort = Math.max(0.25, Number(item.buildHours ?? 4));
    const duration = effort / dailyHours * 24 * 3600000 + Math.max(0, Number(item.humanMinutes ?? 10)) * 60000;
    const end = cursor + duration;
    const slackHours = (item.deadline - end) / 3600000;
    plan.push({...item, start: cursor, end, slackHours, late: slackHours < 0});
    cursor = end;
  }
  return plan;
}

const calendarText = (value: string) => value.replace(/\\/g,'\\\\').replace(/\n/g,'\\n').replace(/;/g,'\\;').replace(/,/g,'\\,');
const stamp = (epoch: number) => new Date(epoch).toISOString().replace(/[-:]/g,'').replace(/\.\d{3}Z$/,'Z');
export function exportCalendar(plan: ScheduledItem[], created: number = Date.now()): string {
  const lines = ['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//Deadline Loom//Scenario Planner//EN','CALSCALE:GREGORIAN','METHOD:PUBLISH'];
  for (const item of plan) {
    lines.push('BEGIN:VEVENT', `UID:${calendarText(item.id)}-${stamp(created)}@deadline-loom.local`, `DTSTAMP:${stamp(created)}`, `DTSTART:${stamp(item.start)}`, `DTEND:${stamp(item.end)}`, `SUMMARY:${calendarText('Build: '+item.title)}`, `DESCRIPTION:${calendarText('Planning assumption. Estimated agent work: '+item.buildHours+' hours. Human work: '+item.humanMinutes+' minutes. Recorded deadline: '+new Date(item.deadline).toISOString()+'. Source: '+(item.url || '')+'. No prize or eligibility is guaranteed.')}`, 'END:VEVENT');
  }
  lines.push('END:VCALENDAR');
  const encoder = new TextEncoder();
  return lines.flatMap(line => { const chunks: string[] = []; let chunk = ''; let bytes = 0; for (const char of line) { const size = encoder.encode(char).length; if (bytes + size > 75) { chunks.push(chunk); chunk = ' '; bytes = 1; } chunk += char; bytes += size; } chunks.push(chunk); return chunks; }).join('\r\n')+'\r\n';
}