From 6ff97a002d074b65fb4a9a46be051f0ca84c12c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 17:33:02 +0000 Subject: [PATCH] Suggest four times from the host's own free space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a calendar connected, the create screen can now fill the poll in itself: "Suggest 4 times" reads the host's free/busy and proposes four candidate slots — 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, so four options can't all be one Tuesday. The picking is a pure function (`suggestFreeSlots`, unit-tested) fed the same per-day busy segments the week grid shades from, so a suggestion lands exactly where the shading says the host is free. Spread beats density: one option per day first, alternating which half of the day it tries so the set isn't four identical 10am slots, doubling up only once the days run out. Afternoons prefer 13:00 onwards and drop into the lunch hour only when nothing later fits; starts snap to the same 30-minute grid a drawn slot does; slots the poll already has count as busy, so clicking again adds four more rather than four duplicates. The click reads the whole 21-day range itself rather than trusting whichever weeks the grid has loaded, and aborts if a connected provider's read fails — half a diary would propose times the host isn't free for. The grid then jumps to the week the first suggestion landed in, since slots off-screen look like nothing happened. `zonedDayAndMinute` moves into time.ts (it was hostCalendar's private `zonedParts`) so "now" and the busy segments are read in one frame, and `calendarWeekday` joins it for the Mon–Fri test. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W4A85YwKbANRATBa496oug --- README.md | 5 + docs/README.md | 36 ++++++ src/components/CalendarWeekView.tsx | 16 +++ src/components/CreatePoll.tsx | 121 +++++++++++++++++- src/components/SlotPicker.tsx | 6 +- src/lib/autoSlots.test.ts | 142 +++++++++++++++++++++ src/lib/autoSlots.ts | 188 ++++++++++++++++++++++++++++ src/lib/hostCalendar.ts | 20 +-- src/lib/time.ts | 26 ++++ 9 files changed, 539 insertions(+), 21 deletions(-) create mode 100644 src/lib/autoSlots.test.ts create mode 100644 src/lib/autoSlots.ts 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). */