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
29 changes: 27 additions & 2 deletions src/components/CreatePoll.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -33,6 +33,9 @@ export default function CreatePoll({ pollBase }: { pollBase: string }) {
const [slots, setSlots] = useState<Slot[]>([])
const [theme, setTheme] = useState<Theme>('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<number | null>(30)
const [notifyOnResponse, setNotifyOnResponse] = useState(false)
const [email, setEmail] = useState('')
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -328,6 +337,22 @@ export default function CreatePoll({ pollBase }: { pollBase: string }) {
</div>
</div>

{/* Location / meeting link (whole-event, optional) */}
<div className="mt-6">
<label className="block">
<span className="text-sm font-semibold text-slate-800">Location or meeting link <span className="font-normal text-slate-400">(optional)</span></span>
<input
type="text"
value={location}
maxLength={500}
onChange={(e) => setLocation(e.target.value)}
placeholder="e.g. Meeting room 5, or a Teams / Zoom / Meet link"
className="mt-1.5 w-full h-11 rounded-lg border border-slate-300 px-3 text-slate-900 focus:border-[var(--accent)] focus:ring-2 focus:ring-[var(--accent-soft)] outline-none"
/>
</label>
<p className="mt-1 text-xs text-slate-500">Shown to everyone on the poll and added to the calendar invite.</p>
</div>

{/* More options */}
<div className="mt-6 border-t border-slate-100 pt-4">
<button
Expand Down
29 changes: 29 additions & 0 deletions src/components/PollPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ export default function PollPage({ id, pollBase }: { id: string; pollBase: strin
<>{' · '}Times in <span className="font-medium">{tzAbbrev(activeTz, anchor)}</span></>
)}
</p>
{poll.location && <PollLocation location={poll.location} className="mt-3 justify-center" />}
</header>

{!dayMode && (
Expand Down Expand Up @@ -415,6 +416,7 @@ function ConfirmedBanner({ poll, slot, pollUrl, viewerTz, activeTz, dayMode, isH
<div className="text-xs font-semibold uppercase tracking-wide text-emerald-700">✓ Confirmed time</div>
<div className="mt-0.5 text-lg font-bold text-slate-900 break-words">{when}</div>
{tzNote && <div className="text-xs text-slate-500">{viewerTimeNote(formatRange(inst, slot.durationMins, viewerTz), inst, activeTz, viewerTz)}</div>}
{poll.location && <PollLocation location={poll.location} className="mt-1.5" />}
</div>
<div className="flex items-center gap-2">
{isHost && (
Expand All @@ -434,6 +436,33 @@ function ConfirmedBanner({ poll, slot, pollUrl, viewerTz, activeTz, dayMode, isH
)
}

/** 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 (
<div className={`flex items-center gap-1.5 text-sm text-slate-600 ${className}`}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4 shrink-0 text-slate-400" aria-hidden="true">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
{isLink ? (
<a href={location} target="_blank" rel="noopener noreferrer" className="min-w-0 truncate font-medium text-[var(--accent-text)] hover:underline underline-offset-2">
{location}
</a>
) : (
<span className="min-w-0 break-words font-medium text-slate-700">{location}</span>
)}
</div>
)
}

/** Tells the viewer which timezone the poll's times are in, and lets them
* re-render every time on the page in their own zone (one click) or any other
* zone (searchable picker). */
Expand Down
1 change: 1 addition & 0 deletions src/lib/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
16 changes: 16 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ export async function createPoll(
theme: p.theme,
branding: p.branding,
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
Expand Down Expand Up @@ -130,6 +134,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<void> {
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(
Expand Down
18 changes: 18 additions & 0 deletions src/lib/calendar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ function timedPoll(overrides: Partial<Poll> = {}): Poll {
slots: [],
theme: 'orange',
branding: null,
location: null,
final_slot_id: null,
notify_on_response: false,
created_at: NOW.toISOString(),
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
11 changes: 9 additions & 2 deletions src/lib/calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ─────────────────────────────────────────────────────────────────────
Expand All @@ -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) {
Expand Down Expand Up @@ -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()}`
}

Expand All @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -79,5 +83,6 @@ export interface NewPoll {
slots: Slot[]
theme: Theme
branding: PollBranding | null
location: string | null
expires_at: string | null
}