Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions src/components/CalendarWeekView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export default function CalendarWeekView({
busyByDay,
busySyncing,
onWeekChange,
focus,
}: {
slots: Slot[]
onChange: (s: Slot[]) => void
Expand All @@ -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<Date>(todayStart)
Expand Down Expand Up @@ -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()
Expand Down
121 changes: 119 additions & 2 deletions src/components/CreatePoll.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 }) {
Expand Down Expand Up @@ -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<string | null>(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),
Expand Down Expand Up @@ -637,9 +724,39 @@ export default function CreatePoll({ pollBase }: { pollBase: string }) {
busyByDay={busyByDay}
busySyncing={anyConnected && calSyncing > 0}
onWeekChange={onWeekChange}
focus={calFocus}
/>
</div>

{/* 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 && (
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg bg-slate-50 ring-1 ring-slate-200 px-3 py-2.5">
<span className="flex-1 min-w-[16rem] text-xs text-slate-600">
<span className="font-medium text-slate-700">Not sure what to propose?</span> 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.
</span>
<button
type="button"
onClick={suggestTimes}
disabled={suggesting}
className="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-100 disabled:opacity-60"
>
{suggesting ? 'Finding free time…' : `Suggest ${SUGGEST_COUNT} times`}
</button>
{suggestNote && (
<span className="basis-full text-[11px] text-slate-500">{suggestNote}</span>
)}
</div>
)}
{/* 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. */}
<span aria-live="polite" className="sr-only">
{suggesting ? 'Finding free time in your calendar.' : suggestNote ?? ''}
</span>

{/* 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). */}
Expand Down
6 changes: 4 additions & 2 deletions src/components/SlotPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
<div>
Expand All @@ -69,7 +71,7 @@ export default function SlotPicker({
{view === 'days' ? (
<DayPicker slots={slots} onChange={onChange} />
) : view === 'calendar' ? (
<CalendarWeekView slots={slots} onChange={onChange} busyByDay={busyByDay} busySyncing={busySyncing} onWeekChange={onWeekChange} />
<CalendarWeekView slots={slots} onChange={onChange} busyByDay={busyByDay} busySyncing={busySyncing} onWeekChange={onWeekChange} focus={focus} />
) : (
<FormPicker slots={slots} onChange={onChange} timezone={timezone} />
)}
Expand Down
Loading