feat(feeds): subscribable ICS calendar feeds for trips

Adds TripIt-style live calendar subscriptions alongside the existing one-time
.ics download. A trip (or all of a user's trips) exposes a secret, revocable
feed URL that Google/Apple/Outlook poll to stay in sync.

- Public read endpoints GET /api/feed/trip/:token.ics and /api/feed/user/:token.ics
  (no auth — the secret token is the credential), reusing the existing exportICS()
  generator and adding REFRESH-INTERVAL / X-PUBLISHED-TTL hints.
- JWT-guarded token endpoints to generate (lazy, idempotent) and regenerate/revoke
  per-trip and per-user feed tokens; tokens stored in nullable feed_token columns.
- All-trips feed excludes archived trips and trips ended >90 days ago.
- UI: ICS toolbar button becomes a Download/Subscribe menu; modal offers one-click
  "Add to Google Calendar" (render?cid=webcal://) and a webcal:// link for
  Apple/Outlook, plus copy-link fallbacks. All-trips feed reachable from dashboard.
- Feed base URL read from the existing APP_URL env var.

Purely additive: new endpoints + two nullable columns, no breaking changes.

Tests: server/tests/e2e/feeds.e2e.test.ts covers lazy token generate + idempotency,
regenerate-invalidates-old, 401/404 auth+access, public feed content-type + hint
injection, unknown-token 404, and the archived/>90-day all-trips exclusion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
michael-bohr
2026-06-24 20:30:27 -04:00
committed by Maurice
parent 72dfa2c60c
commit 7173e82fe8
12 changed files with 920 additions and 35 deletions
@@ -1,9 +1,10 @@
import { useState } from 'react'
import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown } from 'lucide-react'
import React, { useState, useRef } from 'react'
import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown, CalendarPlus } from 'lucide-react'
import { downloadTripPDF } from '../PDF/TripPDF'
import { DayReorderPopup } from './DayReorderPopup'
import Tooltip from '../shared/Tooltip'
import { useToast } from '../shared/Toast'
import { IcsSubscribeModal } from './IcsSubscribeModal'
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
interface DayPlanSidebarToolbarProps {
@@ -36,11 +37,35 @@ interface DayPlanSidebarToolbarProps {
export function DayPlanSidebarToolbar({
tripId, trip, days, places, categories, assignments, reservations, dayNotes,
t, locale, toast, pdfHover, setPdfHover, icsHover, setIcsHover,
t, locale, toast, pdfHover, setPdfHover, setIcsHover,
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
canEditDays, onReorderDays, onAddDay,
}: DayPlanSidebarToolbarProps) {
const [reorderOpen, setReorderOpen] = useState(false)
const [subscribeOpen, setSubscribeOpen] = useState(false)
const icsMenuTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [icsMenuVisible, setIcsMenuVisible] = useState(false)
const showIcsMenu = () => {
if (icsMenuTimeoutRef.current) clearTimeout(icsMenuTimeoutRef.current)
setIcsMenuVisible(true)
setIcsHover(true)
}
const hideIcsMenu = () => {
icsMenuTimeoutRef.current = setTimeout(() => {
setIcsMenuVisible(false)
setIcsHover(false)
}, 120)
}
const menuItemStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 7,
width: '100%', padding: '7px 12px', border: 'none',
background: 'transparent', cursor: 'pointer',
fontSize: 11, fontWeight: 500, fontFamily: 'inherit',
color: 'var(--text-primary)', textAlign: 'left',
transition: 'background 0.1s',
}
return (
<div className="border-b border-edge-faint" style={{ padding: '12px 16px', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
@@ -83,25 +108,12 @@ export function DayPlanSidebarToolbar({
</div>
)}
</div>
<div style={{ position: 'relative', flexShrink: 0 }}>
<div
style={{ position: 'relative', flexShrink: 0 }}
onMouseEnter={showIcsMenu}
onMouseLeave={hideIcsMenu}
>
<button
onClick={async () => {
try {
const res = await fetch(`/api/trips/${tripId}/export.ics`, {
credentials: 'include',
})
if (!res.ok) throw new Error()
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${trip?.title || 'trip'}.ics`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error(t('planner.icsExportFailed')) }
}}
onMouseEnter={() => setIcsHover(true)}
onMouseLeave={() => setIcsHover(false)}
style={{
display: 'flex', alignItems: 'center', gap: 5,
padding: '5px 10px', borderRadius: 8,
@@ -113,19 +125,61 @@ export function DayPlanSidebarToolbar({
<FileDown size={13} strokeWidth={2} />
ICS
</button>
{icsHover && (
<div style={{
position: 'absolute', top: 'calc(100% + 6px)', right: 0,
whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 200,
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, padding: '5px 10px',
borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
border: '1px solid var(--border-faint, #e5e7eb)',
}}>
{t('dayplan.icsTooltip')}
{icsMenuVisible && (
<div
onMouseEnter={showIcsMenu}
onMouseLeave={hideIcsMenu}
style={{
position: 'absolute', top: 'calc(100% + 4px)', right: 0,
zIndex: 200, minWidth: 160,
background: 'var(--bg-card, white)',
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
border: '1px solid var(--border-faint, #e5e7eb)',
overflow: 'hidden',
}}
>
<button
onClick={async () => {
setIcsMenuVisible(false)
setIcsHover(false)
try {
const res = await fetch(`/api/trips/${tripId}/export.ics`, { credentials: 'include' })
if (!res.ok) throw new Error()
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${trip?.title || 'trip'}.ics`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error(t('planner.icsExportFailed')) }
}}
style={menuItemStyle}
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--bg-hover, #f3f4f6)' }}
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
<FileDown size={12} strokeWidth={2} />
Download ICS
</button>
<button
onClick={() => {
setIcsMenuVisible(false)
setIcsHover(false)
setSubscribeOpen(true)
}}
style={menuItemStyle}
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--bg-hover, #f3f4f6)' }}
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
<CalendarPlus size={12} strokeWidth={2} />
Subscribe to calendar
</button>
</div>
)}
</div>
{subscribeOpen && (
<IcsSubscribeModal tripId={tripId} onClose={() => setSubscribeOpen(false)} />
)}
{(() => {
const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id))
const label = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll')
@@ -0,0 +1,143 @@
import { useState, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { X, RefreshCw, Calendar } from 'lucide-react'
import { SubscribeLinks } from './SubscribeLinks'
interface IcsSubscribeModalProps {
tripId: number
onClose: () => void
}
export function IcsSubscribeModal({ tripId, onClose }: IcsSubscribeModalProps) {
const [feedUrl, setFeedUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [regenerating, setRegenerating] = useState(false)
const httpsUrl = feedUrl ?? ''
const webcalUrl = feedUrl ? feedUrl.replace(/^https?:\/\//, 'webcal://') : ''
const loadToken = useCallback(async () => {
setLoading(true)
try {
// Try to get existing token first
let res = await fetch(`/api/trips/${tripId}/feed/token`, { credentials: 'include' })
if (!res.ok) { setLoading(false); return }
const data = await res.json() as { feed_url: string | null }
if (data.feed_url) {
setFeedUrl(data.feed_url)
} else {
// Lazily generate on first open
res = await fetch(`/api/trips/${tripId}/feed/token`, {
method: 'POST',
credentials: 'include',
})
if (res.ok) {
const gen = await res.json() as { feed_url: string }
setFeedUrl(gen.feed_url)
}
}
} catch { /* ignore */ }
setLoading(false)
}, [tripId])
useEffect(() => { loadToken() }, [loadToken])
const regenerate = async () => {
setRegenerating(true)
try {
const res = await fetch(`/api/trips/${tripId}/feed/token`, {
method: 'DELETE',
credentials: 'include',
})
if (res.ok) {
const data = await res.json() as { feed_url: string }
setFeedUrl(data.feed_url)
}
} catch { /* ignore */ }
setRegenerating(false)
}
return createPortal(
<div
style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '16px',
}}
onClick={e => { if (e.target === e.currentTarget) onClose() }}
>
<div style={{
background: 'var(--bg-card, white)',
borderRadius: 14,
padding: '22px 24px',
width: '100%',
maxWidth: 420,
boxShadow: '0 16px 48px rgba(0,0,0,0.22)',
border: '1px solid var(--border-faint)',
color: 'var(--text-primary)',
fontFamily: 'inherit',
position: 'relative',
}}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Calendar size={16} strokeWidth={2} style={{ color: 'var(--accent, #6366f1)' }} />
<span style={{ fontWeight: 600, fontSize: 14 }}>Subscribe to Calendar</span>
</div>
<button
onClick={onClose}
style={{
background: 'none', border: 'none', cursor: 'pointer', padding: 4,
color: 'var(--text-muted)', borderRadius: 6, display: 'flex',
}}
>
<X size={15} strokeWidth={2} />
</button>
</div>
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 16, lineHeight: 1.5 }}>
This link stays in sync with your trip automatically. Calendar apps re-fetch it every hour.
</p>
{loading ? (
<div style={{ textAlign: 'center', padding: '16px 0', color: 'var(--text-muted)', fontSize: 12 }}>
Generating link…
</div>
) : !feedUrl ? (
<div style={{ textAlign: 'center', padding: '16px 0', color: 'var(--text-muted)', fontSize: 12 }}>
Could not generate feed link.
</div>
) : (
<>
<SubscribeLinks httpsUrl={httpsUrl} webcalUrl={webcalUrl} />
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border-faint)' }}>
<button
onClick={regenerate}
disabled={regenerating}
style={{
display: 'flex', alignItems: 'center', gap: 6,
background: 'none', border: '1px solid var(--border-primary)',
borderRadius: 7, padding: '5px 10px',
fontSize: 11, color: 'var(--text-muted)',
cursor: regenerating ? 'default' : 'pointer',
fontFamily: 'inherit', opacity: regenerating ? 0.6 : 1,
}}
>
<RefreshCw size={11} strokeWidth={2} style={{ animation: regenerating ? 'spin 0.8s linear infinite' : 'none' }} />
Regenerate link
</button>
<p style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 6, lineHeight: 1.4 }}>
Regenerating creates a new link and invalidates the old one.
</p>
</div>
</>
)}
</div>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>,
document.body
)
}
@@ -0,0 +1,133 @@
import { useState } from 'react'
import { Copy, Check, CalendarPlus, Calendar } from 'lucide-react'
interface SubscribeLinksProps {
httpsUrl: string
webcalUrl: string
}
/**
* Shared presentation for calendar subscription URLs. Renders one-click
* subscribe actions (Google deep link + webcal handoff) plus a copy fallback.
* Used by both the per-trip and all-trips subscribe modals.
*/
export function SubscribeLinks({ httpsUrl, webcalUrl }: SubscribeLinksProps) {
const [copied, setCopied] = useState<'https' | 'webcal' | null>(null)
// Google Calendar's add-by-URL deep link. The cid must carry the webcal://
// scheme (not https) and the feed must be served over HTTPS.
const googleUrl = `https://www.google.com/calendar/render?cid=${webcalUrl}`
const copy = async (url: string, which: 'https' | 'webcal') => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(url)
} else {
// Fallback for non-secure contexts (plain HTTP) where navigator.clipboard is unavailable
const ta = document.createElement('textarea')
ta.value = url
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.focus()
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
setCopied(which)
setTimeout(() => setCopied(null), 2000)
} catch { /* ignore */ }
}
return (
<div>
{/* One-click subscribe actions */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
<a
href={googleUrl}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
padding: '9px 14px', borderRadius: 9, textDecoration: 'none',
background: 'var(--accent, #6366f1)', color: 'var(--accent-text, #fff)',
fontSize: 12, fontWeight: 600, fontFamily: 'inherit',
}}
>
<CalendarPlus size={14} strokeWidth={2} />
Add to Google Calendar
</a>
<a
href={webcalUrl}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
padding: '9px 14px', borderRadius: 9, textDecoration: 'none',
background: 'none', border: '1px solid var(--border-primary)',
color: 'var(--text-primary)', fontSize: 12, fontWeight: 600, fontFamily: 'inherit',
}}
>
<Calendar size={14} strokeWidth={2} />
Add to Apple Calendar / Outlook
</a>
</div>
{/* Manual fallback — raw URLs for any other client / "From URL" boxes */}
<details style={{ fontSize: 11, color: 'var(--text-muted)' }}>
<summary style={{ cursor: 'pointer', userSelect: 'none', marginBottom: 8 }}>
Or copy a link manually
</summary>
<UrlRow
label="Google Calendar"
hint="paste into “From URL”"
url={httpsUrl}
copied={copied === 'https'}
onCopy={() => copy(httpsUrl, 'https')}
/>
<UrlRow
label="Apple Calendar / Outlook"
hint="webcal://"
url={webcalUrl}
copied={copied === 'webcal'}
onCopy={() => copy(webcalUrl, 'webcal')}
/>
</details>
</div>
)
}
function UrlRow({ label, hint, url, copied, onCopy }: {
label: string; hint: string; url: string; copied: boolean; onCopy: () => void
}) {
return (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, fontWeight: 500, marginBottom: 5, color: 'var(--text-primary)' }}>
{label} <span style={{ color: 'var(--text-muted)', fontWeight: 400 }}>— {hint}</span>
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<div style={{
flex: 1, fontSize: 10, fontFamily: 'monospace',
padding: '5px 8px', borderRadius: 6,
border: '1px solid var(--border-faint)',
background: 'var(--bg-subtle, #f9fafb)',
color: 'var(--text-muted)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{url}
</div>
<button
onClick={onCopy}
title="Copy"
style={{
flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 28, height: 28, borderRadius: 6,
border: '1px solid var(--border-primary)', background: 'none',
cursor: 'pointer', color: copied ? 'var(--accent, #6366f1)' : 'var(--text-muted)',
transition: 'color 0.15s',
}}
>
{copied ? <Check size={12} strokeWidth={2.5} /> : <Copy size={12} strokeWidth={2} />}
</button>
</div>
</div>
)
}