atmo.rsvp
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix timezone stuff

Florian 6a5ed10f c2d0a228

+119 -67
+47 -61
src/lib/components/EventEditor.svelte
··· 29 29 import Avatar from 'svelte-boring-avatars'; 30 30 import DateTimePicker from '$lib/components/DateTimePicker.svelte'; 31 31 import TimezonePicker from '$lib/components/TimezonePicker.svelte'; 32 + import { parseDateTime } from '@internationalized/date'; 33 + import { datetimeLocalToISO, isoToDatetimeLocalInTz } from '$lib/date-format'; 32 34 import ThumbnailPresets from '$lib/components/ThumbnailPresets.svelte'; 33 35 import { designs } from '$lib/components/thumbnails/designs'; 34 36 import type { FlatEventRecord } from '$lib/contrail'; ··· 132 134 } 133 135 }); 134 136 135 - const pad = (n: number) => n.toString().padStart(2, '0'); 136 - 137 - function isoToDatetimeLocal(iso: string): string { 138 - const date = new Date(iso); 139 - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; 140 - } 141 - 142 - function dateToDatetimeLocal(date: Date): string { 143 - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; 144 - } 145 - 146 - /** 147 - * Convert a datetime-local string (e.g. "2026-04-09T15:00") to an ISO string 148 - * interpreting the date/time as being in the selected timezone. 149 - */ 150 - function datetimeLocalToISO(dt: string, tz: string): string { 151 - // Parse the datetime-local components 152 - const [datePart, timePart] = dt.split('T'); 153 - const [year, month, day] = datePart.split('-').map(Number); 154 - const [hour, minute] = timePart.split(':').map(Number); 155 - 156 - // Create a date and find the offset for the target timezone 157 - // by formatting a known date in that timezone and comparing 158 - const utcGuess = Date.UTC(year, month - 1, day, hour, minute); 159 - 160 - // Get what time it would be in the target timezone at our UTC guess 161 - const inTz = new Date(utcGuess).toLocaleString('en-US', { timeZone: tz }); 162 - const tzDate = new Date(inTz); 163 - 164 - // The difference tells us the timezone offset 165 - const offsetMs = tzDate.getTime() - utcGuess; 166 - 167 - // Subtract the offset to get the correct UTC time 168 - return new Date(utcGuess - offsetMs).toISOString(); 169 - } 170 - 171 137 function stripModePrefix(modeStr: string): EventMode { 172 138 const stripped = modeStr.replace('community.lexicon.calendar.event#', ''); 173 139 if (stripped === 'virtual' || stripped === 'hybrid' || stripped === 'inperson') return stripped; ··· 214 180 if (!eventData) return; 215 181 name = eventData.name || ''; 216 182 description = eventData.description || ''; 217 - startsAt = eventData.startsAt ? isoToDatetimeLocal(eventData.startsAt) : ''; 218 - endsAt = eventData.endsAt ? isoToDatetimeLocal(eventData.endsAt) : ''; 183 + // Restore the event's authored timezone first so the wall-clock fields we 184 + // populate below land in that zone (not the viewer's browser zone). 185 + if (eventData.timezone) timezone = eventData.timezone; 186 + startsAt = eventData.startsAt ? isoToDatetimeLocalInTz(eventData.startsAt, timezone) : ''; 187 + endsAt = eventData.endsAt ? isoToDatetimeLocalInTz(eventData.endsAt, timezone) : ''; 219 188 mode = eventData.mode ? stripModePrefix(eventData.mode) : 'inperson'; 220 189 links = eventData.uris ? eventData.uris.map((l) => ({ uri: l.uri, name: l.name || '' })) : []; 221 190 if (eventData.theme) eventTheme = { ...eventData.theme }; ··· 473 442 } 474 443 }); 475 444 445 + // Trim a CalendarDateTime.toString() ("YYYY-MM-DDTHH:mm:ss[.sss]") down to 446 + // the "YYYY-MM-DDTHH:mm" shape that <input type="datetime-local"> expects. 447 + function cdtToDatetimeLocal(s: string): string { 448 + return s.slice(0, 16); 449 + } 450 + 476 451 // Auto-set end date to 1 hour after start if empty 477 452 $effect(() => { 478 453 if (startsAt && !endsAt) { 479 - const s = new Date(startsAt); 480 - s.setHours(s.getHours() + 1); 481 - endsAt = isoToDatetimeLocal(s.toISOString()); 454 + endsAt = cdtToDatetimeLocal(parseDateTime(startsAt).add({ hours: 1 }).toString()); 482 455 } 483 456 }); 484 457 485 458 // Auto-adjust end date if start moves past it 486 459 $effect(() => { 487 460 if (startsAt && endsAt) { 488 - const s = new Date(startsAt); 489 - const e = new Date(endsAt); 490 - if (s >= e) { 491 - // eslint-disable-next-line svelte/prefer-svelte-reactivity -- temporary local, not reactive state 492 - const adjusted = new Date(s); 493 - adjusted.setHours(adjusted.getHours() + 1); 494 - endsAt = isoToDatetimeLocal(adjusted.toISOString()); 461 + const s = parseDateTime(startsAt); 462 + const e = parseDateTime(endsAt); 463 + if (s.compare(e) >= 0) { 464 + endsAt = cdtToDatetimeLocal(s.add({ hours: 1 }).toString()); 495 465 } 496 466 } 497 467 }); ··· 622 592 mode: `community.lexicon.calendar.event#${mode}`, 623 593 status: 'community.lexicon.calendar.event#scheduled', 624 594 startsAt: datetimeLocalToISO(startsAt, timezone), 595 + timezone, 625 596 createdAt, 626 597 theme: eventTheme 627 598 }; ··· 731 702 recurringCreated = 0; 732 703 733 704 try { 734 - const baseStart = new Date(startsAt); 735 - const baseEnd = endsAt ? new Date(endsAt) : null; 736 - const duration = baseEnd ? baseEnd.getTime() - baseStart.getTime() : 0; 705 + // Recurring instances advance by wall-clock duration (e.g. "every week 706 + // at 10am"), so operate on CalendarDateTime — not absolute instants — 707 + // to preserve the wall time across DST transitions. 708 + const baseStart = parseDateTime(startsAt); 709 + const baseEnd = endsAt ? parseDateTime(endsAt) : null; 710 + const durationMs = baseEnd 711 + ? baseEnd.toDate(timezone).getTime() - baseStart.toDate(timezone).getTime() 712 + : 0; 737 713 const baseName = recurringNumberInTitle && titleNumberMatch 738 714 ? name.replace(/#?\d+\s*$/, '').trimEnd() 739 715 : name.trim(); ··· 784 760 const parentUri = `at://${user.did}/community.lexicon.calendar.event/${rkey}`; 785 761 786 762 for (let i = 0; i < recurringCount; i++) { 787 - const eventStart = new Date(baseStart); 788 - const offset = (i + 1); 789 - if (recurringUnit === 'days') eventStart.setDate(eventStart.getDate() + offset * recurringInterval); 790 - else if (recurringUnit === 'weeks') eventStart.setDate(eventStart.getDate() + offset * recurringInterval * 7); 791 - else if (recurringUnit === 'months') eventStart.setMonth(eventStart.getMonth() + offset * recurringInterval); 792 - else if (recurringUnit === 'years') eventStart.setFullYear(eventStart.getFullYear() + offset * recurringInterval); 763 + const offset = i + 1; 764 + const step = offset * recurringInterval; 765 + const eventStart = 766 + recurringUnit === 'days' 767 + ? baseStart.add({ days: step }) 768 + : recurringUnit === 'weeks' 769 + ? baseStart.add({ weeks: step }) 770 + : recurringUnit === 'months' 771 + ? baseStart.add({ months: step }) 772 + : baseStart.add({ years: step }); 793 773 794 - const eventEnd = duration ? new Date(eventStart.getTime() + duration) : null; 774 + const eventStartIso = eventStart.toDate(timezone).toISOString(); 775 + // Preserve the original absolute duration (handles events that 776 + // span midnight or odd wall-clock lengths correctly). 777 + const eventEndIso = durationMs 778 + ? new Date(eventStart.toDate(timezone).getTime() + durationMs).toISOString() 779 + : null; 795 780 796 781 let eventName = baseName; 797 782 if (recurringNumberInTitle) { ··· 806 791 name: eventName, 807 792 mode: `community.lexicon.calendar.event#${mode}`, 808 793 status: 'community.lexicon.calendar.event#scheduled', 809 - startsAt: datetimeLocalToISO(dateToDatetimeLocal(eventStart), timezone), 794 + startsAt: eventStartIso, 795 + timezone, 810 796 createdAt: new Date().toISOString(), 811 797 recurringEventOf: parentUri 812 798 }; ··· 815 801 if (trimmedDescription) { 816 802 record.description = trimmedDescription; 817 803 } 818 - if (eventEnd) { 819 - record.endsAt = datetimeLocalToISO(dateToDatetimeLocal(eventEnd), timezone); 804 + if (eventEndIso) { 805 + record.endsAt = eventEndIso; 820 806 } 821 807 if (media) { 822 808 record.media = media;
+55
src/lib/date-format.ts
··· 1 + import { parseAbsolute, parseDateTime } from '@internationalized/date'; 2 + 3 + /** 4 + * Convert a datetime-local string (e.g. "2026-04-09T22:00") to a UTC ISO string, 5 + * interpreting the wall-clock components in the given IANA timezone. 6 + */ 7 + export function datetimeLocalToISO(dt: string, tz: string): string { 8 + return parseDateTime(dt).toDate(tz).toISOString(); 9 + } 10 + 11 + /** 12 + * Convert a UTC ISO string to a datetime-local string ("YYYY-MM-DDTHH:mm") whose 13 + * components represent the wall-clock time in the given IANA timezone. 14 + */ 15 + export function isoToDatetimeLocalInTz(iso: string, tz: string): string { 16 + const zdt = parseAbsolute(iso, tz); 17 + const pad = (n: number) => n.toString().padStart(2, '0'); 18 + return `${zdt.year}-${pad(zdt.month)}-${pad(zdt.day)}T${pad(zdt.hour)}:${pad(zdt.minute)}`; 19 + } 20 + 21 + /** 22 + * Format an ISO timestamp using Intl options, rendered in the event's timezone 23 + * when available. Falls back to the viewer's local zone for legacy events that 24 + * predate the timezone field. 25 + */ 26 + export function formatInTz( 27 + iso: string, 28 + tz: string | undefined, 29 + options: Intl.DateTimeFormatOptions, 30 + locale: string = 'en-US' 31 + ): string { 32 + const date = new Date(iso); 33 + return new Intl.DateTimeFormat(locale, { ...options, timeZone: tz || undefined }).format(date); 34 + } 35 + 36 + /** 37 + * Returns the parts of an ISO timestamp in the given timezone (or viewer-local 38 + * when tz is falsy). Useful when the caller wants numeric components like the 39 + * day-of-month rendered in the event's zone. 40 + */ 41 + export function partsInTz( 42 + iso: string, 43 + tz: string | undefined, 44 + options: Intl.DateTimeFormatOptions, 45 + locale: string = 'en-US' 46 + ): Record<string, string> { 47 + const date = new Date(iso); 48 + const parts = new Intl.DateTimeFormat(locale, { 49 + ...options, 50 + timeZone: tz || undefined 51 + }).formatToParts(date); 52 + const out: Record<string, string> = {}; 53 + for (const p of parts) if (p.type !== 'literal') out[p.type] = p.value; 54 + return out; 55 + }
+6
src/lib/event-types.ts
··· 7 7 export type EventData = CommunityLexiconCalendarEvent.Main & { 8 8 /** startsAt is always present on actual records even though the lexicon marks it optional */ 9 9 startsAt: string; 10 + /** 11 + * IANA timezone id (e.g. "Europe/Berlin") in which the event's wall-clock times 12 + * were authored. Not part of the upstream lexicon, but persisted on records we 13 + * write so display code can render startsAt/endsAt in the author's intended zone. 14 + */ 15 + timezone?: string; 10 16 media?: Array<{ 11 17 role: string; 12 18 alt?: string;
+3
src/routes/(app)/p/[actor]/e/[rkey]/+page.svelte
··· 37 37 typeof window !== 'undefined' ? `${window.location.origin}${eventPath}` : eventPath 38 38 ); 39 39 40 + // Times are always rendered in the viewer's local timezone — the stored UTC 41 + // instant is what the Date constructor parses, and toLocaleString/Time uses 42 + // the browser's zone by default. 40 43 let startDate = $derived(new Date(eventData.startsAt)); 41 44 let endDate = $derived(eventData.endsAt ? new Date(eventData.endsAt) : null); 42 45
+8 -6
src/routes/(app)/p/[actor]/e/[rkey]/og.png/+server.ts
··· 4 4 import EventOgImage from './EventOgImage.svelte'; 5 5 import { getActor } from '$lib/actor'; 6 6 import { flattenEventRecord, getEventRecordFromContrail, getServerClient } from '$lib/contrail'; 7 + import { formatInTz, partsInTz } from '$lib/date-format'; 7 8 import { render } from 'svelte/server'; 8 9 9 - function formatDate(dateStr: string): string { 10 - const date = new Date(dateStr); 11 - const weekday = date.toLocaleDateString('en-US', { weekday: 'long' }); 12 - const month = date.toLocaleDateString('en-US', { month: 'long' }); 13 - const day = date.getDate(); 10 + function formatDate(dateStr: string, tz: string | undefined): string { 11 + // Render in the event's authored timezone when known so OG images match 12 + // what the event page shows, regardless of the edge server's local zone. 13 + const weekday = formatInTz(dateStr, tz, { weekday: 'long' }); 14 + const month = formatInTz(dateStr, tz, { month: 'long' }); 15 + const day = partsInTz(dateStr, tz, { day: 'numeric' }).day; 14 16 return `${weekday}, ${month} ${day}`; 15 17 } 16 18 ··· 38 40 throw error(404, 'Event not found'); 39 41 } 40 42 41 - const dateStr = formatDate(eventData.startsAt); 43 + const dateStr = formatDate(eventData.startsAt, eventData.timezone); 42 44 43 45 let thumbnailUrl: string | null = null; 44 46 if (eventData.media && eventData.media.length > 0) {