From 4189f8d407f385179fb861fc9aa4323d7658d0d4 Mon Sep 17 00:00:00 2001 From: James Markey Date: Fri, 24 Jul 2026 11:59:28 +0100 Subject: [PATCH 1/3] feat: add an optional event location (meeting link or physical place) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The poll creator can attach one location to the whole event (not per-slot): a Teams / Zoom / Meet link or a physical place. It's shown to respondents on the poll page and confirmed banner, and carried into the Add-to-calendar export (ICS LOCATION + Google/Outlook deep-links). - New nullable polls.location column (migration 0060_polls_location.sql; belongs in backoffice/universal-platform — renumber before applying). - Poll/NewPoll gain a location field; createPoll includes it in the insert, and the gated RPC path sets it as a follow-up update (like the notify-on-response opt-in), since create_poll_gated has no location arg. - CreatePoll: 'Location or meeting link' input. - PollPage: link-aware location line (anchor for URLs, text otherwise). - calendar.ts: CalendarEvent.location threaded into ICS/Google/Outlook, with unit tests. --- src/components/CreatePoll.tsx | 29 +++++++++++++++++++-- src/components/PollPage.tsx | 29 +++++++++++++++++++++ src/lib/api.test.ts | 1 + src/lib/api.ts | 13 +++++++++ src/lib/calendar.test.ts | 18 +++++++++++++ src/lib/calendar.ts | 11 ++++++-- src/lib/types.ts | 5 ++++ supabase/migrations/0060_polls_location.sql | 18 +++++++++++++ 8 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 supabase/migrations/0060_polls_location.sql diff --git a/src/components/CreatePoll.tsx b/src/components/CreatePoll.tsx index f6a0b21..08da5da 100644 --- a/src/components/CreatePoll.tsx +++ b/src/components/CreatePoll.tsx @@ -3,7 +3,7 @@ import { useAppFreeToken, useOrg, useOrgBranding, useSubscription, useUniversal, import type { NewPoll, PollBranding, PollMode, Slot, Theme } from '../lib/types' import { isHexTheme, THEMES } from '../lib/types' import { hexOfTheme, themeAttr, themeVars } from '../lib/theme' -import { createPoll, createPollGated, currentUser, sendHostCode, setNotifyOnResponse as apiSetNotify, shortId, uploadPollLogo, verifyHostCode } from '../lib/api' +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 { listTimezones, localTimezone, tzAbbrev } from '../lib/time' import SlotPicker from './SlotPicker' @@ -33,6 +33,9 @@ export default function CreatePoll({ pollBase }: { pollBase: string }) { const [slots, setSlots] = useState([]) const [theme, setTheme] = useState('orange') const [timezone, setTimezone] = useState(localTimezone()) + // Optional EVENT location — a meeting link or a physical place — for the whole + // poll (not per-slot). Shown to respondents and carried into the export. + const [location, setLocation] = useState('') const [validityDays, setValidityDays] = useState(30) const [notifyOnResponse, setNotifyOnResponse] = useState(false) const [email, setEmail] = useState('') @@ -183,7 +186,7 @@ export default function CreatePoll({ pollBase }: { pollBase: string }) { function draft(branding: PollBranding | null): NewPoll { const expires_at = validityDays == null ? null : new Date(Date.now() + validityDays * 86_400_000).toISOString() - return { id: shortId(), title, timezone, mode, slots, theme, branding, expires_at } + return { id: shortId(), title, timezone, mode, slots, theme, branding, location: location.trim() || null, expires_at } } // `client` must be signed in as `hostUserId` (suite client for any Universal @@ -199,6 +202,12 @@ export default function CreatePoll({ pollBase }: { pollBase: string }) { const poll = freeGated ? await createPollGated(client, pollDraft, hostEmail) : await createPoll(client, pollDraft, hostUserId, hostEmail) + // The gated create RPC doesn't take a location, so set it as a follow-up + // (the direct insert above already carries it). Non-fatal — the poll is + // already created. + if (freeGated && pollDraft.location) { + try { await apiSetLocation(client, poll.id, pollDraft.location) } catch { /* poll still created */ } + } // Response alerts are a follow-up update (keeps the create RPC/insert // untouched); non-fatal, since the poll itself is already created. if (notifyOnResponse) { @@ -328,6 +337,22 @@ export default function CreatePoll({ pollBase }: { pollBase: string }) { + {/* Location / meeting link (whole-event, optional) */} +
+ +

Shown to everyone on the poll and added to the calendar invite.

+
+ {/* More options */}
{isHost && ( @@ -417,6 +419,33 @@ function ConfirmedBanner({ poll, slot, pollUrl, viewerTz, dayMode, isHost, confi ) } +/** Whether a location string is a clickable http(s) link (a Teams / Zoom / Meet + * URL) rather than a physical place ("Meeting room 5"). */ +function isUrlLike(s: string): boolean { + return /^https?:\/\/\S+$/i.test(s.trim()) +} + +/** The poll's event location: a link icon + the value, rendered as an anchor for + * a meeting URL, or plain text for a physical place. */ +function PollLocation({ location, className = '' }: { location: string; className?: string }) { + const isLink = isUrlLike(location) + return ( +
+ + {isLink ? ( + + {location} + + ) : ( + {location} + )} +
+ ) +} + function BrandingHeader({ branding }: { branding: PollBranding }) { const img = branding.logo_url ?? branding.icon_url if (!img && !branding.name) return null diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index 2bbbe78..a6b7008 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -12,6 +12,7 @@ const fakePoll = (id: string): Poll => ({ slots: [], theme: 'orange', branding: null, + location: null, final_slot_id: null, notify_on_response: false, created_at: '2026-07-24T10:00:00.000Z', diff --git a/src/lib/api.ts b/src/lib/api.ts index f0e0476..f21475f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -63,6 +63,7 @@ export async function createPoll( slots: p.slots, theme: p.theme, branding: p.branding, + location: p.location, expires_at: p.expires_at, } const { data, error } = await client.from('polls').insert(row).select().single() @@ -130,6 +131,18 @@ export async function setFinalSlot( if (error) throw error } +/** Host-only: set (or clear, with `null`) the poll's event location. `client` + * must be signed in as the host — RLS (`polls_owner_update`) gates it. Used as a + * follow-up write for the gated create path, whose RPC doesn't take a location. */ +export async function setPollLocation( + client: SupabaseClient, + pollId: string, + location: string | null, +): Promise { + const { error } = await client.from('polls').update({ location }).eq('id', pollId) + if (error) throw error +} + /** Host-only: turn per-response email alerts on/off for a poll. `client` must be * signed in as the host — RLS (`polls_owner_update`) gates it. */ export async function setNotifyOnResponse( diff --git a/src/lib/calendar.test.ts b/src/lib/calendar.test.ts index b5663e2..3a71236 100644 --- a/src/lib/calendar.test.ts +++ b/src/lib/calendar.test.ts @@ -16,6 +16,7 @@ function timedPoll(overrides: Partial = {}): Poll { slots: [], theme: 'orange', branding: null, + location: null, final_slot_id: null, notify_on_response: false, created_at: NOW.toISOString(), @@ -82,6 +83,16 @@ describe('buildIcs', () => { expect(ics).toContain('SUMMARY:Lunch\\, drinks\\; then talk') }) + it('emits a LOCATION line when the poll has a location, escaped', () => { + const ics = buildIcs(timedPoll({ location: 'Meeting room 5, floor 2' }), timedSlot, POLL_URL, NOW) + expect(ics).toContain('LOCATION:Meeting room 5\\, floor 2') + }) + + it('omits LOCATION when the poll has none', () => { + const ics = buildIcs(timedPoll({ location: null }), timedSlot, POLL_URL, NOW) + expect(ics).not.toContain('LOCATION:') + }) + // RFC 5545 folding is a 75-OCTET limit and must never split a code point. // The poll title is unconstrained Unicode, so emoji/CJK have to survive. it('folds long lines at 75 octets without splitting surrogate pairs', () => { @@ -127,6 +138,13 @@ describe('googleCalendarUrl', () => { const url = googleCalendarUrl(poll, { id: 'd1', start: '2026-06-10T00:00', durationMins: 0 }, POLL_URL) expect(new URL(url).searchParams.get('dates')).toBe('20260610/20260611') }) + + it('passes the location through as a query param, else omits it', () => { + const withLoc = googleCalendarUrl(timedPoll({ location: 'https://zoom.us/j/123' }), timedSlot, POLL_URL) + expect(new URL(withLoc).searchParams.get('location')).toBe('https://zoom.us/j/123') + const without = googleCalendarUrl(timedPoll({ location: null }), timedSlot, POLL_URL) + expect(new URL(without).searchParams.has('location')).toBe(false) + }) }) describe('outlookCalendarUrl', () => { diff --git a/src/lib/calendar.ts b/src/lib/calendar.ts index 8008200..604a606 100644 --- a/src/lib/calendar.ts +++ b/src/lib/calendar.ts @@ -18,6 +18,9 @@ interface CalendarEventBase { description: string /** Poll page URL, surfaced as the event URL / in the body. */ url: string + /** Event location — a meeting link or physical place — or '' when the poll has + * none. Carried into ICS LOCATION and the Google/Outlook deep-links. */ + location: string } /** A calendar event is EITHER timed (absolute start/end instants) or all-day @@ -43,19 +46,20 @@ export type CalendarEvent = * poll page link, woven into the event body so an attendee can get back to it. */ export function eventForSlot(poll: Poll, slot: Slot, pollUrl: string): CalendarEvent { const title = poll.title.trim() || 'Meeting' + const location = poll.location?.trim() || '' const description = `Scheduled with Universal Date Polling.${pollUrl ? `\n\nView or update the poll: ${pollUrl}` : ''}` if (poll.mode === 'days') { const startDay = slotDayKey(slot) return { - title, description, url: pollUrl, allDay: true, + title, description, url: pollUrl, location, allDay: true, startDay, endDay: addCalendarDays(startDay, 1), } } const start = slotInstant(slot.start, poll.timezone) const end = slotEnd(start, slot.durationMins) - return { title, description, url: pollUrl, allDay: false, start, end } + return { title, description, url: pollUrl, location, allDay: false, start, end } } // ── ICS ───────────────────────────────────────────────────────────────────── @@ -78,6 +82,7 @@ export function buildIcs(poll: Poll, slot: Slot, pollUrl: string, now: Date = ne `SUMMARY:${escapeIcs(ev.title)}`, `DESCRIPTION:${escapeIcs(ev.description)}`, ] + if (ev.location) lines.push(`LOCATION:${escapeIcs(ev.location)}`) if (pollUrl) lines.push(`URL:${escapeIcs(pollUrl)}`) if (ev.allDay) { @@ -121,6 +126,7 @@ export function googleCalendarUrl(poll: Poll, slot: Slot, pollUrl: string): stri dates, details: ev.description, }) + if (ev.location) params.set('location', ev.location) return `https://calendar.google.com/calendar/render?${params.toString()}` } @@ -135,6 +141,7 @@ export function outlookCalendarUrl(poll: Poll, slot: Slot, pollUrl: string): str body: ev.description, allday: String(ev.allDay), }) + if (ev.location) params.set('location', ev.location) if (ev.allDay) { params.set('startdt', ev.startDay) params.set('enddt', ev.endDay) diff --git a/src/lib/types.ts b/src/lib/types.ts index 8f54465..087bcf1 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -48,6 +48,10 @@ export interface Poll { slots: Slot[] theme: Theme branding: PollBranding | null + /** Optional EVENT location — a meeting link (Teams / Zoom / Google Meet) or a + * physical place ("Meeting room 5"). One value for the whole poll (not + * per-slot); shown to respondents and carried into the calendar export. */ + location: string | null /** The slot the host has confirmed as the final chosen time (a `Slot.id`), or * null while undecided. Only the host can set it. */ final_slot_id: string | null @@ -79,5 +83,6 @@ export interface NewPoll { slots: Slot[] theme: Theme branding: PollBranding | null + location: string | null expires_at: string | null } diff --git a/supabase/migrations/0060_polls_location.sql b/supabase/migrations/0060_polls_location.sql new file mode 100644 index 0000000..a9da8e2 --- /dev/null +++ b/supabase/migrations/0060_polls_location.sql @@ -0,0 +1,18 @@ +-- Universal Date Polling — add an optional EVENT location to a poll. +-- +-- A single free-text field the host can set once for the whole poll (not per +-- slot): a meeting link (Teams / Zoom / Google Meet) OR a physical place +-- ("Meeting room 5"). Shown to respondents on the poll page and woven into the +-- Add-to-calendar export (ICS LOCATION + Google/Outlook deep-links). +-- +-- Nullable, no default: existing polls simply carry no location. Reads/writes go +-- through the existing `polls` RLS policies (public SELECT, owner INSERT/UPDATE +-- via auth.uid() = host_user_id from 0025_polls.sql), so no policy change is +-- needed — the host sets it with the same client that owns the row. +-- +-- NOTE: this file lives in the Universal_Date_Polling repo for review; the poll +-- schema is actually owned by `backoffice/universal-platform`. Renumber to that +-- repo's next free migration index before applying it to the hosted Supabase. + +alter table public.polls + add column if not exists location text; From 74741791ab72fb5bd8feb9e1a6e436f9dab5b371 Mon Sep 17 00:00:00 2001 From: James Markey Date: Fri, 24 Jul 2026 12:00:15 +0100 Subject: [PATCH 2/3] fix: omit location from insert when unset so pre-migration builds still create polls --- src/lib/api.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index f21475f..7e0a2b8 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -63,8 +63,11 @@ export async function createPoll( slots: p.slots, theme: p.theme, branding: p.branding, - location: p.location, expires_at: p.expires_at, + // Only send `location` when set: omitting the key when it's null keeps the + // insert from referencing the column at all, so a build that shipped before + // migration 0060 added `polls.location` still creates location-less polls. + ...(p.location ? { location: p.location } : {}), } const { data, error } = await client.from('polls').insert(row).select().single() if (error) throw error From a1c6c8c5b72019b5a4187c5f83627384a2c715a2 Mon Sep 17 00:00:00 2001 From: James Markey Date: Fri, 24 Jul 2026 12:47:14 +0100 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20drop=20the=20migration=20file=20?= =?UTF-8?q?=E2=80=94=20the=20polls.location=20column=20is=20owned=20+=20ap?= =?UTF-8?q?plied=20via=20universal-platform=200063?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- supabase/migrations/0060_polls_location.sql | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 supabase/migrations/0060_polls_location.sql diff --git a/supabase/migrations/0060_polls_location.sql b/supabase/migrations/0060_polls_location.sql deleted file mode 100644 index a9da8e2..0000000 --- a/supabase/migrations/0060_polls_location.sql +++ /dev/null @@ -1,18 +0,0 @@ --- Universal Date Polling — add an optional EVENT location to a poll. --- --- A single free-text field the host can set once for the whole poll (not per --- slot): a meeting link (Teams / Zoom / Google Meet) OR a physical place --- ("Meeting room 5"). Shown to respondents on the poll page and woven into the --- Add-to-calendar export (ICS LOCATION + Google/Outlook deep-links). --- --- Nullable, no default: existing polls simply carry no location. Reads/writes go --- through the existing `polls` RLS policies (public SELECT, owner INSERT/UPDATE --- via auth.uid() = host_user_id from 0025_polls.sql), so no policy change is --- needed — the host sets it with the same client that owns the row. --- --- NOTE: this file lives in the Universal_Date_Polling repo for review; the poll --- schema is actually owned by `backoffice/universal-platform`. Renumber to that --- repo's next free migration index before applying it to the hosted Supabase. - -alter table public.polls - add column if not exists location text;