···2929 import Avatar from 'svelte-boring-avatars';
3030 import DateTimePicker from '$lib/components/DateTimePicker.svelte';
3131 import TimezonePicker from '$lib/components/TimezonePicker.svelte';
3232+ import { parseDateTime } from '@internationalized/date';
3333+ import { datetimeLocalToISO, isoToDatetimeLocalInTz } from '$lib/date-format';
3234 import ThumbnailPresets from '$lib/components/ThumbnailPresets.svelte';
3335 import { designs } from '$lib/components/thumbnails/designs';
3436 import type { FlatEventRecord } from '$lib/contrail';
···132134 }
133135 });
134136135135- const pad = (n: number) => n.toString().padStart(2, '0');
136136-137137- function isoToDatetimeLocal(iso: string): string {
138138- const date = new Date(iso);
139139- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
140140- }
141141-142142- function dateToDatetimeLocal(date: Date): string {
143143- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
144144- }
145145-146146- /**
147147- * Convert a datetime-local string (e.g. "2026-04-09T15:00") to an ISO string
148148- * interpreting the date/time as being in the selected timezone.
149149- */
150150- function datetimeLocalToISO(dt: string, tz: string): string {
151151- // Parse the datetime-local components
152152- const [datePart, timePart] = dt.split('T');
153153- const [year, month, day] = datePart.split('-').map(Number);
154154- const [hour, minute] = timePart.split(':').map(Number);
155155-156156- // Create a date and find the offset for the target timezone
157157- // by formatting a known date in that timezone and comparing
158158- const utcGuess = Date.UTC(year, month - 1, day, hour, minute);
159159-160160- // Get what time it would be in the target timezone at our UTC guess
161161- const inTz = new Date(utcGuess).toLocaleString('en-US', { timeZone: tz });
162162- const tzDate = new Date(inTz);
163163-164164- // The difference tells us the timezone offset
165165- const offsetMs = tzDate.getTime() - utcGuess;
166166-167167- // Subtract the offset to get the correct UTC time
168168- return new Date(utcGuess - offsetMs).toISOString();
169169- }
170170-171137 function stripModePrefix(modeStr: string): EventMode {
172138 const stripped = modeStr.replace('community.lexicon.calendar.event#', '');
173139 if (stripped === 'virtual' || stripped === 'hybrid' || stripped === 'inperson') return stripped;
···214180 if (!eventData) return;
215181 name = eventData.name || '';
216182 description = eventData.description || '';
217217- startsAt = eventData.startsAt ? isoToDatetimeLocal(eventData.startsAt) : '';
218218- endsAt = eventData.endsAt ? isoToDatetimeLocal(eventData.endsAt) : '';
183183+ // Restore the event's authored timezone first so the wall-clock fields we
184184+ // populate below land in that zone (not the viewer's browser zone).
185185+ if (eventData.timezone) timezone = eventData.timezone;
186186+ startsAt = eventData.startsAt ? isoToDatetimeLocalInTz(eventData.startsAt, timezone) : '';
187187+ endsAt = eventData.endsAt ? isoToDatetimeLocalInTz(eventData.endsAt, timezone) : '';
219188 mode = eventData.mode ? stripModePrefix(eventData.mode) : 'inperson';
220189 links = eventData.uris ? eventData.uris.map((l) => ({ uri: l.uri, name: l.name || '' })) : [];
221190 if (eventData.theme) eventTheme = { ...eventData.theme };
···473442 }
474443 });
475444445445+ // Trim a CalendarDateTime.toString() ("YYYY-MM-DDTHH:mm:ss[.sss]") down to
446446+ // the "YYYY-MM-DDTHH:mm" shape that <input type="datetime-local"> expects.
447447+ function cdtToDatetimeLocal(s: string): string {
448448+ return s.slice(0, 16);
449449+ }
450450+476451 // Auto-set end date to 1 hour after start if empty
477452 $effect(() => {
478453 if (startsAt && !endsAt) {
479479- const s = new Date(startsAt);
480480- s.setHours(s.getHours() + 1);
481481- endsAt = isoToDatetimeLocal(s.toISOString());
454454+ endsAt = cdtToDatetimeLocal(parseDateTime(startsAt).add({ hours: 1 }).toString());
482455 }
483456 });
484457485458 // Auto-adjust end date if start moves past it
486459 $effect(() => {
487460 if (startsAt && endsAt) {
488488- const s = new Date(startsAt);
489489- const e = new Date(endsAt);
490490- if (s >= e) {
491491- // eslint-disable-next-line svelte/prefer-svelte-reactivity -- temporary local, not reactive state
492492- const adjusted = new Date(s);
493493- adjusted.setHours(adjusted.getHours() + 1);
494494- endsAt = isoToDatetimeLocal(adjusted.toISOString());
461461+ const s = parseDateTime(startsAt);
462462+ const e = parseDateTime(endsAt);
463463+ if (s.compare(e) >= 0) {
464464+ endsAt = cdtToDatetimeLocal(s.add({ hours: 1 }).toString());
495465 }
496466 }
497467 });
···622592 mode: `community.lexicon.calendar.event#${mode}`,
623593 status: 'community.lexicon.calendar.event#scheduled',
624594 startsAt: datetimeLocalToISO(startsAt, timezone),
595595+ timezone,
625596 createdAt,
626597 theme: eventTheme
627598 };
···731702 recurringCreated = 0;
732703733704 try {
734734- const baseStart = new Date(startsAt);
735735- const baseEnd = endsAt ? new Date(endsAt) : null;
736736- const duration = baseEnd ? baseEnd.getTime() - baseStart.getTime() : 0;
705705+ // Recurring instances advance by wall-clock duration (e.g. "every week
706706+ // at 10am"), so operate on CalendarDateTime — not absolute instants —
707707+ // to preserve the wall time across DST transitions.
708708+ const baseStart = parseDateTime(startsAt);
709709+ const baseEnd = endsAt ? parseDateTime(endsAt) : null;
710710+ const durationMs = baseEnd
711711+ ? baseEnd.toDate(timezone).getTime() - baseStart.toDate(timezone).getTime()
712712+ : 0;
737713 const baseName = recurringNumberInTitle && titleNumberMatch
738714 ? name.replace(/#?\d+\s*$/, '').trimEnd()
739715 : name.trim();
···784760 const parentUri = `at://${user.did}/community.lexicon.calendar.event/${rkey}`;
785761786762 for (let i = 0; i < recurringCount; i++) {
787787- const eventStart = new Date(baseStart);
788788- const offset = (i + 1);
789789- if (recurringUnit === 'days') eventStart.setDate(eventStart.getDate() + offset * recurringInterval);
790790- else if (recurringUnit === 'weeks') eventStart.setDate(eventStart.getDate() + offset * recurringInterval * 7);
791791- else if (recurringUnit === 'months') eventStart.setMonth(eventStart.getMonth() + offset * recurringInterval);
792792- else if (recurringUnit === 'years') eventStart.setFullYear(eventStart.getFullYear() + offset * recurringInterval);
763763+ const offset = i + 1;
764764+ const step = offset * recurringInterval;
765765+ const eventStart =
766766+ recurringUnit === 'days'
767767+ ? baseStart.add({ days: step })
768768+ : recurringUnit === 'weeks'
769769+ ? baseStart.add({ weeks: step })
770770+ : recurringUnit === 'months'
771771+ ? baseStart.add({ months: step })
772772+ : baseStart.add({ years: step });
793773794794- const eventEnd = duration ? new Date(eventStart.getTime() + duration) : null;
774774+ const eventStartIso = eventStart.toDate(timezone).toISOString();
775775+ // Preserve the original absolute duration (handles events that
776776+ // span midnight or odd wall-clock lengths correctly).
777777+ const eventEndIso = durationMs
778778+ ? new Date(eventStart.toDate(timezone).getTime() + durationMs).toISOString()
779779+ : null;
795780796781 let eventName = baseName;
797782 if (recurringNumberInTitle) {
···806791 name: eventName,
807792 mode: `community.lexicon.calendar.event#${mode}`,
808793 status: 'community.lexicon.calendar.event#scheduled',
809809- startsAt: datetimeLocalToISO(dateToDatetimeLocal(eventStart), timezone),
794794+ startsAt: eventStartIso,
795795+ timezone,
810796 createdAt: new Date().toISOString(),
811797 recurringEventOf: parentUri
812798 };
···815801 if (trimmedDescription) {
816802 record.description = trimmedDescription;
817803 }
818818- if (eventEnd) {
819819- record.endsAt = datetimeLocalToISO(dateToDatetimeLocal(eventEnd), timezone);
804804+ if (eventEndIso) {
805805+ record.endsAt = eventEndIso;
820806 }
821807 if (media) {
822808 record.media = media;
+55
src/lib/date-format.ts
···11+import { parseAbsolute, parseDateTime } from '@internationalized/date';
22+33+/**
44+ * Convert a datetime-local string (e.g. "2026-04-09T22:00") to a UTC ISO string,
55+ * interpreting the wall-clock components in the given IANA timezone.
66+ */
77+export function datetimeLocalToISO(dt: string, tz: string): string {
88+ return parseDateTime(dt).toDate(tz).toISOString();
99+}
1010+1111+/**
1212+ * Convert a UTC ISO string to a datetime-local string ("YYYY-MM-DDTHH:mm") whose
1313+ * components represent the wall-clock time in the given IANA timezone.
1414+ */
1515+export function isoToDatetimeLocalInTz(iso: string, tz: string): string {
1616+ const zdt = parseAbsolute(iso, tz);
1717+ const pad = (n: number) => n.toString().padStart(2, '0');
1818+ return `${zdt.year}-${pad(zdt.month)}-${pad(zdt.day)}T${pad(zdt.hour)}:${pad(zdt.minute)}`;
1919+}
2020+2121+/**
2222+ * Format an ISO timestamp using Intl options, rendered in the event's timezone
2323+ * when available. Falls back to the viewer's local zone for legacy events that
2424+ * predate the timezone field.
2525+ */
2626+export function formatInTz(
2727+ iso: string,
2828+ tz: string | undefined,
2929+ options: Intl.DateTimeFormatOptions,
3030+ locale: string = 'en-US'
3131+): string {
3232+ const date = new Date(iso);
3333+ return new Intl.DateTimeFormat(locale, { ...options, timeZone: tz || undefined }).format(date);
3434+}
3535+3636+/**
3737+ * Returns the parts of an ISO timestamp in the given timezone (or viewer-local
3838+ * when tz is falsy). Useful when the caller wants numeric components like the
3939+ * day-of-month rendered in the event's zone.
4040+ */
4141+export function partsInTz(
4242+ iso: string,
4343+ tz: string | undefined,
4444+ options: Intl.DateTimeFormatOptions,
4545+ locale: string = 'en-US'
4646+): Record<string, string> {
4747+ const date = new Date(iso);
4848+ const parts = new Intl.DateTimeFormat(locale, {
4949+ ...options,
5050+ timeZone: tz || undefined
5151+ }).formatToParts(date);
5252+ const out: Record<string, string> = {};
5353+ for (const p of parts) if (p.type !== 'literal') out[p.type] = p.value;
5454+ return out;
5555+}
+6
src/lib/event-types.ts
···77export type EventData = CommunityLexiconCalendarEvent.Main & {
88 /** startsAt is always present on actual records even though the lexicon marks it optional */
99 startsAt: string;
1010+ /**
1111+ * IANA timezone id (e.g. "Europe/Berlin") in which the event's wall-clock times
1212+ * were authored. Not part of the upstream lexicon, but persisted on records we
1313+ * write so display code can render startsAt/endsAt in the author's intended zone.
1414+ */
1515+ timezone?: string;
1016 media?: Array<{
1117 role: string;
1218 alt?: string;
+3
src/routes/(app)/p/[actor]/e/[rkey]/+page.svelte
···3737 typeof window !== 'undefined' ? `${window.location.origin}${eventPath}` : eventPath
3838 );
39394040+ // Times are always rendered in the viewer's local timezone — the stored UTC
4141+ // instant is what the Date constructor parses, and toLocaleString/Time uses
4242+ // the browser's zone by default.
4043 let startDate = $derived(new Date(eventData.startsAt));
4144 let endDate = $derived(eventData.endsAt ? new Date(eventData.endsAt) : null);
4245