diff --git a/README.md b/README.md index 9bf72e5..9e3219c 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,11 @@ Part of the [Universal Apps](https://opensource.unisim.co.uk) suite by respondent who left an address** the confirmed time, with a `.ics` invite attached (the `notify-poll-respondents` Edge Function; never sent automatically — always an explicit host click). +- **Suggest times** — with a calendar connected, one click fills the poll in + from the host's own free time: 4 options, weekdays only, between 10:00 and + 16:00 in the poll's timezone, and at most one morning and one afternoon on any + given day. They're ordinary slots once added — drag one to move it, click to + remove it, click again for four more. - **Add to calendar** — each result slot (and the confirmed banner) has an "Add to calendar" button: Google Calendar, Outlook, or an `.ics` download (Apple Calendar, Outlook desktop). Generated entirely client-side, in the poll's diff --git a/docs/README.md b/docs/README.md index 04f8e54..4abce7f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -47,6 +47,34 @@ the host is already busy, so they don't propose a slot they can't make. poll's zone (unit-tested — busy shading has to land in the same frame the slots are drawn in, or it lies whenever the poll's zone isn't UTC). +**Suggest — build the poll out of the free space** (added 2026-08-14). Beside +the week view, "Suggest 4 times" fills the poll in from the host's own +availability instead of making them draw every slot. The picking is a pure +function, `suggestFreeSlots` in `src/lib/autoSlots.ts` (unit-tested in +`autoSlots.test.ts`), fed the same per-day busy segments the shading uses — so +a suggestion lands exactly where the grid says the host is free, in the poll's +timezone. The rules it encodes: + +- **Weekdays only, 10:00–16:00** wall-clock in the poll's zone. A slot starts no + earlier than 10:00 and ends no later than 16:00. +- **At most one morning and one afternoon per day** (split at noon, classified + by start time), so four options can't all be one Tuesday. +- **Spread before density** — the first pass takes one option per day, + alternating the half of the day it tries first so the set isn't four identical + 10am slots; only when the days run out does a second pass double up. +- **Afternoons prefer 13:00 onwards**, dropping into the 12:00–13:00 hour only + when nothing later fits. +- Starts snap to the same 30-minute grid a drawn slot does, existing slots count + as busy (so a second click adds four *more* options, not four duplicates), and + the first day is cut off an hour ahead of now. + +The click fetches the whole 21-day scan range itself rather than reusing +whichever weeks the grid has loaded, and **aborts if any connected provider's +read fails** — half a diary would propose times the host isn't actually free +for. The calendar view then jumps to the week the first suggestion landed in +(`focus` prop on `CalendarWeekView`), since slots off-screen look like nothing +happened. + **Write — put the confirmed time in the host's diary** (added 2026-08-13). Once a slot is confirmed, the banner offers "Add to my Google Calendar / Outlook", which creates a real event via the `calendar-event` Edge Function. @@ -138,6 +166,14 @@ so the shapes aren't re-derived by hand: `addCalendarDays` is pure date-string arithmetic in the UTC frame (exclusive all-day end dates), `addLocalDays` steps a `Date` in the viewer's local frame (the week-grid nav). **Different timezone frames — don't conflate them.** +- **`calendarWeekday(day)`** — the weekday (0 = Sunday) of a `'YYYY-MM-DD'` + string, in the same pure UTC frame as `addCalendarDays`; `new Date(day)` would + read UTC midnight back in the viewer's frame and be a day out west of + Greenwich. Used to keep auto-suggested slots on Mon–Fri. +- **`zonedDayAndMinute(instant, tz)`** — an instant's wall-clock day and + minutes-since-midnight in `tz`. The one frame busy shading + (`busySegmentsByDay`) and the auto-suggester both work in, so the two can be + compared minute for minute. - **`needsTzNote(poll, viewerTz)`** — whether a viewer-local time should be spelled out (timed poll whose zone differs from the viewer's). The poll page's viewer-timezone switcher generalises this to any active display zone. diff --git a/src/components/CalendarWeekView.tsx b/src/components/CalendarWeekView.tsx index b6f8bd0..0289914 100644 --- a/src/components/CalendarWeekView.tsx +++ b/src/components/CalendarWeekView.tsx @@ -74,6 +74,7 @@ export default function CalendarWeekView({ busyByDay, busySyncing, onWeekChange, + focus, }: { slots: Slot[] onChange: (s: Slot[]) => void @@ -88,6 +89,12 @@ export default function CalendarWeekView({ /** Fired on mount and whenever the visible week changes, so the parent can * fetch busy intervals for that range. */ onWeekChange?: (weekStart: Date) => void + /** Ask the grid to show the week holding `day` ('YYYY-MM-DD'). Set when slots + * arrive without the host drawing them — auto-suggested times can land in a + * later week, and slots you can't see may as well not have been added. A + * fresh object each time, so asking twice for the same day still moves the + * view back to it. */ + focus?: { day: string } | null }) { const todayStart = startOfWeek(new Date()) const [weekStart, setWeekStart] = useState(todayStart) @@ -126,6 +133,15 @@ export default function CalendarWeekView({ onWeekChange?.(weekStart) }, [weekStart, onWeekChange]) + // Jump to the week holding `focus.day` whenever one is asked for. Built from + // numeric parts (local midnight) so it's the week the host would call that + // day, whatever their offset from UTC. + useEffect(() => { + if (!focus) return + const [y, m, d] = focus.day.split('-').map(Number) + setWeekStart(startOfWeek(new Date(y, m - 1, d))) + }, [focus]) + const days = Array.from({ length: 7 }, (_, i) => addLocalDays(weekStart, i)) const atFirstWeek = weekStart.getTime() <= todayStart.getTime() const now = new Date() diff --git a/src/components/CreatePoll.tsx b/src/components/CreatePoll.tsx index 95a5a06..0bfcc93 100644 --- a/src/components/CreatePoll.tsx +++ b/src/components/CreatePoll.tsx @@ -5,11 +5,12 @@ import { isHexTheme, THEMES } from '../lib/types' import { hexOfTheme, themeAttr, themeVars } from '../lib/theme' import { createPoll, createPollGated, currentUser, sendHostCode, setNotifyOnResponse as apiSetNotify, setPollLocation as apiSetLocation, shortId, uploadPollLogo, verifyHostCode } from '../lib/api' import { SUPABASE_CONFIGURED, supabase } from '../lib/supabase' -import { addLocalDays, listTimezones, localTimezone, tzAbbrev } from '../lib/time' +import { addLocalDays, listTimezones, localTimezone, tzAbbrev, zonedDayAndMinute } from '../lib/time' import { busySegmentsByDay, calendarConfigured, calendarStatus, disconnectCalendar, fetchFreeBusy, startCalendarConnect, - type BusyInterval, type CalendarProvider, type CalendarStatus, + type BusyInterval, type CalendarProvider, type CalendarStatus, type ProviderFetchStatus, } from '../lib/hostCalendar' +import { suggestFreeSlots, SUGGEST_COUNT } from '../lib/autoSlots' import type { TextListPoll } from '../lib/textExport' import CopyAsText from './CopyAsText' import SlotPicker from './SlotPicker' @@ -33,6 +34,14 @@ const LOGO_TYPES = ['image/png', 'image/jpeg', 'image/webp'] // or a Retina screenshot. Matches the hub's org-branding upload. const MAX_LOGO_BYTES = 10 * 1024 * 1024 +// "Suggest times" — how far ahead to look for free space, and how much notice a +// suggested slot has to give (nobody wants to be offered a meeting that starts +// in ten minutes). The slot length copies whatever the host is already +// proposing; a poll with nothing in it yet gets an hour. +const SUGGEST_SCAN_DAYS = 21 +const SUGGEST_LEAD_MINS = 60 +const SUGGEST_DEFAULT_MINS = 60 + type Phase = 'edit' | 'sending' | 'code' | 'creating' | 'done' export default function CreatePoll({ pollBase }: { pollBase: string }) { @@ -364,6 +373,84 @@ export default function CreatePoll({ pollBase }: { pollBase: string }) { } } + // --- Suggest times out of the host's free space ----------------------------- + // Four options, weekdays only, 10:00–16:00 in the poll's timezone, at most one + // morning and one afternoon a day. The picking is pure (`suggestFreeSlots`); + // all that happens here is fetching the range it reasons over and folding the + // result into the slot list the host can then drag around. + const [suggesting, setSuggesting] = useState(false) + const [suggestNote, setSuggestNote] = useState(null) + // Which day the calendar view should show afterwards — free space can be a + // fortnight out, and slots off-screen look like nothing happened. + const [calFocus, setCalFocus] = useState<{ day: string } | null>(null) + + // Copy the length the host is already proposing. The 10–4 window is six + // hours, but a slot longer than half of it can't leave room for both a + // morning and an afternoon option — and a days-mode leftover (1440) is + // meaningless here — so anything over two hours falls back to the default. + const lastDuration = slots[slots.length - 1]?.durationMins + const suggestDuration = lastDuration > 0 && lastDuration <= 120 ? lastDuration : SUGGEST_DEFAULT_MINS + + async function suggestTimes() { + setCalError(null) + setSuggestNote(null) + setSuggesting(true) + try { + // Fetch the whole scan range rather than reusing whichever weeks the grid + // has loaded: the answer has to come from the host's real diary, not from + // how far they happen to have scrolled. + const now = new Date() + const { busy, providers } = await fetchFreeBusy( + calClient, + now.toISOString(), + addLocalDays(now, SUGGEST_SCAN_DAYS + 1).toISOString(), + ) + // Half a diary would propose times the host isn't free for, so a failed + // provider stops the suggestion instead of quietly narrowing it. + const failed = (s: ProviderFetchStatus) => s === 'error' || s === 'reconnect' + if (failed(providers.google) || failed(providers.microsoft)) { + if (providers.google === 'reconnect' || providers.microsoft === 'reconnect') { + setCalError('A calendar connection has expired — please connect it again, then suggest times.') + calendarStatus(calClientRef.current).then(setCalStatus).catch(() => {}) + } else { + setCalError("Couldn't read your calendar just now, so nothing was suggested — try again shortly.") + } + return + } + // The grid may as well have the freshly-read range too (overlapping + // intervals merge into the same shading). + setCalBusy((prev) => [...prev, ...busy]) + + const nowThere = zonedDayAndMinute(now, timezone) + const found = suggestFreeSlots({ + busyByDay: busySegmentsByDay([...calBusy, ...busy], timezone), + fromDay: nowThere.day, + fromMin: nowThere.min + SUGGEST_LEAD_MINS, + days: SUGGEST_SCAN_DAYS, + durationMins: suggestDuration, + count: SUGGEST_COUNT, + existing: slots, + }) + if (!found.length) { + setSuggestNote(`No free time between 10:00 and 16:00 on a weekday in the next ${SUGGEST_SCAN_DAYS} days — add times by hand, or free some space up and try again.`) + return + } + const next = [...slots, ...found.map((s) => ({ id: shortId(6), ...s }))] + next.sort((a, b) => a.start.localeCompare(b.start)) + setSlots(next) + setCalFocus({ day: found[0].start.slice(0, 10) }) + setSuggestNote( + found.length < SUGGEST_COUNT + ? `Added ${found.length} of ${SUGGEST_COUNT} — that was all the free time there was. Drag to move one, click to remove it.` + : `Added ${found.length} times from your free space. Drag to move one, click to remove it.`, + ) + } catch (e) { + setCalError(messageOf(e)) + } finally { + setSuggesting(false) + } + } + // Busy shading in the poll's timezone — the frame the grid's slots use. const busyByDay = useMemo( () => (anyConnected ? busySegmentsByDay(calBusy, timezone) : undefined), @@ -637,9 +724,39 @@ export default function CreatePoll({ pollBase }: { pollBase: string }) { busyByDay={busyByDay} busySyncing={anyConnected && calSyncing > 0} onWeekChange={onWeekChange} + focus={calFocus} /> + {/* Fill the poll in from the host's own free time — in the calendar + view, where the result lands in the grid beside the shading it was + picked from, and only with a calendar connected, since free space + is the whole input. */} + {view === 'calendar' && anyConnected && ( +
+ + Not sure what to propose? We'll pick {SUGGEST_COUNT} times you're free — weekdays, 10:00–16:00 {tzAbbrev(timezone)}, at most one morning and one afternoon a day. + + + {suggestNote && ( + {suggestNote} + )} +
+ )} + {/* The outcome again for a screen reader — a live region that's always + mounted, since text appearing inside a freshly-inserted node isn't + reliably announced. */} + + {suggesting ? 'Finding free time in your calendar.' : suggestNote ?? ''} + + {/* Connect prompt beside the calendar itself — the overlay lives in this view, so the invitation belongs here, not only buried in More options (where the connected/disconnect rows stay). */} diff --git a/src/components/SlotPicker.tsx b/src/components/SlotPicker.tsx index 83f04b7..46c60b7 100644 --- a/src/components/SlotPicker.tsx +++ b/src/components/SlotPicker.tsx @@ -37,7 +37,7 @@ function durationLabel(mins: number): string { * owned by the parent so it can derive the poll mode and clear incompatible * slots when crossing the timed↔days boundary. */ export default function SlotPicker({ - view, onViewChange, slots, onChange, timezone, busyByDay, busySyncing, onWeekChange, + view, onViewChange, slots, onChange, timezone, busyByDay, busySyncing, onWeekChange, focus, }: { view: SlotView onViewChange: (v: SlotView) => void @@ -51,6 +51,8 @@ export default function SlotPicker({ /** A busy read is in flight — passed straight through to the calendar view. */ busySyncing?: boolean onWeekChange?: (weekStart: Date) => void + /** A day the calendar view should jump to — see CalendarWeekView. */ + focus?: { day: string } | null }) { return (
@@ -69,7 +71,7 @@ export default function SlotPicker({ {view === 'days' ? ( ) : view === 'calendar' ? ( - + ) : ( )} diff --git a/src/lib/autoSlots.test.ts b/src/lib/autoSlots.test.ts new file mode 100644 index 0000000..6a9d8a5 --- /dev/null +++ b/src/lib/autoSlots.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' +import { suggestFreeSlots } from './autoSlots' +import { busySegmentsByDay, type DaySegment } from './hostCalendar' + +// 2026-08-17 is a Monday; 2026-08-21 a Friday, 2026-08-22/23 the weekend. +const MON = '2026-08-17' +const TUE = '2026-08-18' +const WED = '2026-08-19' +const THU = '2026-08-20' +const FRI = '2026-08-21' + +const busy = (...entries: [string, ...DaySegment[]][]) => + new Map(entries.map(([day, ...segs]) => [day, segs])) + +const at = (h: number, m = 0) => h * 60 + m +const seg = (fromH: number, toH: number): DaySegment => ({ fromMin: at(fromH), toMin: at(toH) }) + +const starts = (slots: { start: string }[]) => slots.map((s) => s.start) + +describe('suggestFreeSlots', () => { + it('spreads four options over four days, alternating morning and afternoon', () => { + const out = suggestFreeSlots({ busyByDay: busy(), fromDay: MON, days: 14, durationMins: 60 }) + expect(starts(out)).toEqual([ + `${MON}T10:00`, + `${TUE}T13:00`, + `${WED}T10:00`, + `${THU}T13:00`, + ]) + expect(out.every((s) => s.durationMins === 60)).toBe(true) + }) + + it('skips the weekend', () => { + const out = suggestFreeSlots({ busyByDay: busy(), fromDay: FRI, days: 14, durationMins: 60 }) + expect(starts(out)).toEqual([ + `${FRI}T10:00`, + '2026-08-24T13:00', // Monday + '2026-08-25T10:00', + '2026-08-26T13:00', + ]) + }) + + it('starts after a busy stretch, on the 30-minute grid', () => { + const out = suggestFreeSlots({ + busyByDay: busy([MON, { fromMin: at(9), toMin: at(10, 20) }]), + fromDay: MON, days: 1, durationMins: 60, count: 1, + }) + expect(starts(out)).toEqual([`${MON}T10:30`]) + }) + + it('never proposes anything outside 10:00–16:00', () => { + // Free only 08:00–10:00 and 15:30–18:00 — neither leaves room for an hour + // inside the window, so Monday contributes nothing. + const out = suggestFreeSlots({ + busyByDay: busy([MON, { fromMin: at(10), toMin: at(15, 30) }]), + fromDay: MON, days: 1, durationMins: 60, + }) + expect(out).toEqual([]) + }) + + it('gives a day at most one morning and one afternoon', () => { + const out = suggestFreeSlots({ busyByDay: busy(), fromDay: MON, days: 1, durationMins: 60, count: 4 }) + expect(starts(out)).toEqual([`${MON}T10:00`, `${MON}T13:00`]) + }) + + it('doubles up on days that worked once the days run out', () => { + const out = suggestFreeSlots({ busyByDay: busy(), fromDay: MON, days: 2, durationMins: 60, count: 4 }) + expect(starts(out)).toEqual([ + `${MON}T10:00`, `${MON}T13:00`, + `${TUE}T10:00`, `${TUE}T13:00`, + ]) + }) + + it('keeps the lunch hour free unless the rest of the afternoon is full', () => { + const open = suggestFreeSlots({ busyByDay: busy(), fromDay: MON, days: 1, durationMins: 60, count: 2 }) + expect(starts(open)[1]).toBe(`${MON}T13:00`) + + const packed = suggestFreeSlots({ + busyByDay: busy([MON, seg(13, 17)]), + fromDay: MON, days: 1, durationMins: 60, count: 2, + }) + expect(starts(packed)[1]).toBe(`${MON}T12:00`) + }) + + it('honours the earliest start on the first day only', () => { + const out = suggestFreeSlots({ + busyByDay: busy(), fromDay: MON, days: 14, fromMin: at(14, 15), durationMins: 60, count: 2, + }) + expect(starts(out)).toEqual([`${MON}T14:30`, `${TUE}T10:00`]) + }) + + it('treats slots the poll already has as busy', () => { + const out = suggestFreeSlots({ + busyByDay: busy(), + fromDay: MON, days: 1, durationMins: 60, count: 1, + existing: [{ start: `${MON}T10:00`, durationMins: 90 }], + }) + expect(starts(out)).toEqual([`${MON}T11:30`]) + }) + + it('returns what it could find when the calendar is too full for the count', () => { + const wall = { fromMin: 0, toMin: 1440 } + const out = suggestFreeSlots({ + busyByDay: busy([MON, wall], [TUE, wall], [WED, wall], [THU, seg(10, 13)]), + fromDay: MON, days: 4, durationMins: 60, + }) + expect(starts(out)).toEqual([`${THU}T13:00`]) + }) + + it('fits a longer slot inside the window and still keeps the halves apart', () => { + const out = suggestFreeSlots({ busyByDay: busy(), fromDay: MON, days: 1, durationMins: 120, count: 4 }) + expect(starts(out)).toEqual([`${MON}T10:00`, `${MON}T13:00`]) + }) + + it('leaves the caller`s busy map untouched', () => { + const map = busy([MON, seg(10, 11)]) + suggestFreeSlots({ busyByDay: map, fromDay: MON, days: 5, durationMins: 60 }) + expect(map.get(MON)).toEqual([seg(10, 11)]) + }) + + it('returns nothing for a nonsensical duration or count', () => { + expect(suggestFreeSlots({ busyByDay: busy(), fromDay: MON, days: 5, durationMins: 0 })).toEqual([]) + expect(suggestFreeSlots({ busyByDay: busy(), fromDay: MON, days: 5, durationMins: 60, count: 0 })).toEqual([]) + }) + + // The frame the whole feature turns on: the 10–4 window is wall-clock time in + // the poll's zone, so the busy intervals have to be read in that zone too. + it('reads the calendar in the poll timezone, not UTC', () => { + const raw = [{ start: `${MON}T09:00:00Z`, end: `${MON}T10:30:00Z` }] + + const london = suggestFreeSlots({ + busyByDay: busySegmentsByDay(raw, 'Europe/London'), // busy 10:00–11:30 BST + fromDay: MON, days: 1, durationMins: 60, count: 1, + }) + expect(starts(london)).toEqual([`${MON}T11:30`]) + + const utc = suggestFreeSlots({ + busyByDay: busySegmentsByDay(raw, 'UTC'), // the same wall clock, an hour earlier + fromDay: MON, days: 1, durationMins: 60, count: 1, + }) + expect(starts(utc)).toEqual([`${MON}T10:30`]) + }) +}) diff --git a/src/lib/autoSlots.ts b/src/lib/autoSlots.ts new file mode 100644 index 0000000..0c977ac --- /dev/null +++ b/src/lib/autoSlots.ts @@ -0,0 +1,188 @@ +// "Suggest some times" — with a calendar connected, the create screen can fill +// in a handful of candidate slots from the host's own free time instead of +// making them draw every one by hand. +// +// Everything here is pure. It takes the busy segments `hostCalendar` already +// derives for the week grid (per-day wall-clock minutes in the poll's +// timezone) and returns wall-clock slot shapes in that same frame, so a +// suggestion lands exactly where the shading says the host is free. Ids are +// minted by the caller, which keeps this module free of app dependencies. + +import type { Slot } from './types' +import type { DaySegment } from './hostCalendar' +import { addCalendarDays, calendarWeekday, slotDayKey } from './time' + +/** The working window suggestions stay inside: 10:00–16:00 wall-clock in the + * poll's timezone ("keep it between 10 and 4"). A suggestion starts no earlier + * than 10:00 and ends no later than 16:00. */ +export const WINDOW_START_MIN = 10 * 60 +export const WINDOW_END_MIN = 16 * 60 + +/** Noon divides a day in two. A day contributes at most one option each side, + * so a host is never offered two mornings on the same day — and four + * suggestions can't all land in one afternoon. A slot is classified by where + * it *starts*, so an 11:30 hour still counts as the morning one. */ +export const MIDDAY_MIN = 12 * 60 + +/** Afternoons are searched from 13:00 first, and only fall back into the + * 12:00–13:00 hour when nothing later fits — a free lunch hour is usually free + * for a reason, but proposing it beats proposing nothing. */ +const AFTERNOON_PREFERRED_MIN = 13 * 60 + +/** Suggested starts land on the same 30-minute grid the week view snaps drags + * to, so a suggested slot looks like a hand-drawn one. */ +const SNAP_MIN = 30 + +/** How many options one click proposes. */ +export const SUGGEST_COUNT = 4 + +/** A proposed slot, minus the id — see the module note. */ +export interface SuggestedSlot { + /** Wall-clock 'YYYY-MM-DDTHH:mm' in the poll's timezone, as `Slot.start`. */ + start: string + durationMins: number +} + +export interface SuggestInput { + /** Busy segments by wall-clock day in the poll's timezone — exactly what + * `busySegmentsByDay` returns. */ + busyByDay: Map + /** First day to consider ('YYYY-MM-DD', poll timezone). */ + fromDay: string + /** How many days forward to scan, `fromDay` included. Weekends are skipped + * but still counted, so this is a calendar reach, not a working-day count. */ + days: number + /** Earliest allowed start on `fromDay` in wall-clock minutes — "now" plus + * whatever notice the caller wants to give, so a suggestion is never in the + * past (or ten minutes away). Ignored on later days. */ + fromMin?: number + durationMins: number + count?: number + /** Slots the poll already carries. Their times are treated as busy, so a + * second click adds four *more* options rather than four duplicates. */ + existing?: readonly Pick[] +} + +type Half = 'morning' | 'afternoon' + +/** Ranges of candidate starts for a half-day, in preference order. */ +function searchRanges(half: Half): { fromMin: number; toMin: number }[] { + if (half === 'morning') return [{ fromMin: WINDOW_START_MIN, toMin: MIDDAY_MIN }] + return [ + { fromMin: AFTERNOON_PREFERRED_MIN, toMin: WINDOW_END_MIN }, + { fromMin: MIDDAY_MIN, toMin: AFTERNOON_PREFERRED_MIN }, + ] +} + +function otherHalf(half: Half): Half { + return half === 'morning' ? 'afternoon' : 'morning' +} + +/** Minutes since midnight of a wall-clock 'YYYY-MM-DDTHH:mm' start. */ +function minutesOf(start: string): number { + return Number(start.slice(11, 13)) * 60 + Number(start.slice(14, 16)) +} + +function hhmm(min: number): string { + return `${String(Math.floor(min / 60)).padStart(2, '0')}:${String(min % 60).padStart(2, '0')}` +} + +/** The first start on the snap grid that fits `durationMins` inside the working + * window without touching a busy segment, or null if the day's half is full. */ +function firstFreeStart( + ranges: { fromMin: number; toMin: number }[], + busy: DaySegment[], + durationMins: number, + earliestMin: number, +): number | null { + const lastStart = WINDOW_END_MIN - durationMins + for (const r of ranges) { + const from = Math.max(r.fromMin, earliestMin, WINDOW_START_MIN) + for ( + let start = Math.ceil(from / SNAP_MIN) * SNAP_MIN; + start < r.toMin && start <= lastStart; + start += SNAP_MIN + ) { + const end = start + durationMins + if (!busy.some((b) => b.fromMin < end && start < b.toMin)) return start + } + } + return null +} + +/** Pick up to `count` candidate times out of the host's free time. + * + * Weekdays only, 10:00–16:00, at most one morning and one afternoon per day. + * Spread beats density: the first pass takes one option per day (alternating + * which half of the day it tries first, so the set isn't four identical 10am + * slots), and only once the days run out does a second pass double up on days + * that already worked. */ +export function suggestFreeSlots({ + busyByDay, fromDay, days, fromMin = 0, durationMins, count = SUGGEST_COUNT, existing = [], +}: SuggestInput): SuggestedSlot[] { + if (durationMins <= 0 || count <= 0) return [] + + // What a suggestion has to dodge: real busy time, plus anything the poll + // already proposes. Copied out of the caller's map so we can add to it as we + // pick, without mutating the shading the grid is drawing from. + const blocked = new Map() + for (const [day, segs] of busyByDay) blocked.set(day, [...segs]) + const block = (day: string, seg: DaySegment) => { + const list = blocked.get(day) + if (list) list.push(seg) + else blocked.set(day, [seg]) + } + for (const s of existing) { + const from = minutesOf(s.start) + block(slotDayKey(s), { fromMin: from, toMin: from + s.durationMins }) + } + + const picked: SuggestedSlot[] = [] + const usedHalves = new Map>() + // The half the last successful pick landed in — the alternation follows what + // was actually taken, not the count, so a day that falls back to its + // afternoon doesn't leave the next day preferring an afternoon too. + let lastHalf: Half | null = null + + const take = (day: string, half: Half): boolean => { + const earliest = day === fromDay ? fromMin : 0 + const start = firstFreeStart(searchRanges(half), blocked.get(day) ?? [], durationMins, earliest) + if (start == null) return false + picked.push({ start: `${day}T${hhmm(start)}`, durationMins }) + block(day, { fromMin: start, toMin: start + durationMins }) + const halves = usedHalves.get(day) ?? new Set() + halves.add(half) + usedHalves.set(day, halves) + lastHalf = half + return true + } + + const weekdays: string[] = [] + for (let i = 0, day = fromDay; i < days; i++, day = addCalendarDays(day, 1)) { + const w = calendarWeekday(day) + if (w >= 1 && w <= 5) weekdays.push(day) + } + + // Pass 1 — one option per day, in day order. + for (const day of weekdays) { + if (picked.length >= count) break + const prefer: Half = lastHalf ? otherHalf(lastHalf) : 'morning' + if (!take(day, prefer)) take(day, otherHalf(prefer)) + } + + // Pass 2 — the days ran out before the options did, so double up on the days + // that worked: their other half, never a second slot in the same one. + for (const day of weekdays) { + if (picked.length >= count) break + const halves = usedHalves.get(day) + // No entry means the whole day was busy — both halves were already tried. + if (!halves) continue + for (const half of ['morning', 'afternoon'] as const) { + if (picked.length >= count) break + if (!halves.has(half)) take(day, half) + } + } + + picked.sort((a, b) => a.start.localeCompare(b.start)) + return picked +} diff --git a/src/lib/hostCalendar.ts b/src/lib/hostCalendar.ts index 9f427b8..b28b946 100644 --- a/src/lib/hostCalendar.ts +++ b/src/lib/hostCalendar.ts @@ -7,7 +7,7 @@ // wall-clock segments for the week grid, and is unit-tested in isolation. import type { SupabaseClient } from '@supabase/supabase-js' -import { addCalendarDays, zonedWallClockToInstant } from './time' +import { addCalendarDays, zonedDayAndMinute, zonedWallClockToInstant } from './time' export type CalendarProvider = 'google' | 'microsoft' @@ -206,20 +206,6 @@ export interface DaySegment { toMin: number } -/** Wall-clock day + minutes of a UTC instant in `tz`. */ -function zonedParts(instant: Date, tz: string): { day: string; min: number } { - const parts = new Intl.DateTimeFormat('en-CA', { - timeZone: tz, - year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit', hour12: false, - }).formatToParts(instant).reduce>((a, p) => { - if (p.type !== 'literal') a[p.type] = p.value - return a - }, {}) - const hour = parts.hour === '24' ? 0 : +parts.hour - return { day: `${parts.year}-${parts.month}-${parts.day}`, min: hour * 60 + +parts.minute } -} - /** Merge overlapping/adjacent segments so double-booked (or twice-fetched) * intervals paint as one clean block. */ export function mergeSegments(segs: DaySegment[]): DaySegment[] { @@ -257,8 +243,8 @@ export function busySegmentsByDay(busy: BusyInterval[], tz: string): Map>((a, p) => { + if (p.type !== 'literal') a[p.type] = p.value + return a + }, {}) + // '24' for midnight in some engines — normalise to 0. + const hour = parts.hour === '24' ? 0 : +parts.hour + return { day: `${parts.year}-${parts.month}-${parts.day}`, min: hour * 60 + +parts.minute } +} + /** Add whole days to a `Date` in the viewer's LOCAL frame — for stepping the * week grid, where "the next day" means the user's own next calendar day. * Distinct from `addCalendarDays` (pure date-string, UTC frame). */