From 35f55b8063a5fdc7f3ed3fa6634612367ce97478 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Wed, 7 Jan 2026 21:20:04 +0100 Subject: [PATCH] feat(calendar): add renderers for NIP-52 calendar events (kinds 31922 & 31923) (#43) * feat(calendar): add renderers for NIP-52 calendar events (kinds 31922 & 31923) - Add CalendarDays and CalendarClock icons for date/time event kinds - Create calendar-event.ts helper with parsing and formatting functions - Add feed renderers showing status badge, title, date/time, location, participant count - Add detail renderers with full description, participant list with names, tags, and links - Register renderers in kinds/index.tsx for both feed and detail views - Use locale-aware date/time formatting throughout * refactor(calendar): improve feed renderer layout - Rename both kinds to "Calendar Event" for consistency - Move date/time info below title - Place time on left, status badge on right with justify-between - Remove timezone indicator from feed view (keep in detail) * refactor(calendar): apply feed layout to detail views - Move title above date/time in detail views - Use justify-between for time left, status badge right - Use Label component for hashtags (no # prefix, consistent with feed) - Use Label component for participant roles (subtle dotted border style) * refactor(calendar): separate location/tags rows and tone down time font - Separate location/participants from hashtags onto different rows in feed - Reduce time font size: text-xs in feed, text-sm in detail (was text-sm/text-lg) - Remove font-medium from time display * style(calendar): tone down status badges to match date/time styling Replace bold background-colored badges with subtle text-colored badges: - Use text-blue-500, text-green-500, text-muted-foreground instead of backgrounds - Lowercase labels ("upcoming", "now", "past") - Consistent sizing with date/time text * refactor(calendar): extract shared components and add caching - Extract CalendarStatusBadge to shared component with variant/size props - Add symbol-based caching to parseDateCalendarEvent and parseTimeCalendarEvent using applesauce's getOrComputeCachedValue for performance - Use 'd' tag (identifier) as title fallback instead of "Untitled Event" - Remove duplicate CalendarStatusBadge implementations from all 4 renderers - Remove unused imports (cn, CalendarDays, CalendarClock, Clock, CheckCircle) --------- Co-authored-by: Claude --- .../nostr/calendar/CalendarStatusBadge.tsx | 71 ++++ .../kinds/CalendarDateEventDetailRenderer.tsx | 147 +++++++++ .../nostr/kinds/CalendarDateEventRenderer.tsx | 98 ++++++ .../kinds/CalendarTimeEventDetailRenderer.tsx | 165 ++++++++++ .../nostr/kinds/CalendarTimeEventRenderer.tsx | 103 ++++++ src/components/nostr/kinds/index.tsx | 8 + src/constants/kinds.ts | 8 +- src/lib/calendar-event.ts | 311 ++++++++++++++++++ 8 files changed, 908 insertions(+), 3 deletions(-) create mode 100644 src/components/nostr/calendar/CalendarStatusBadge.tsx create mode 100644 src/components/nostr/kinds/CalendarDateEventDetailRenderer.tsx create mode 100644 src/components/nostr/kinds/CalendarDateEventRenderer.tsx create mode 100644 src/components/nostr/kinds/CalendarTimeEventDetailRenderer.tsx create mode 100644 src/components/nostr/kinds/CalendarTimeEventRenderer.tsx create mode 100644 src/lib/calendar-event.ts diff --git a/src/components/nostr/calendar/CalendarStatusBadge.tsx b/src/components/nostr/calendar/CalendarStatusBadge.tsx new file mode 100644 index 0000000..a42ffc9 --- /dev/null +++ b/src/components/nostr/calendar/CalendarStatusBadge.tsx @@ -0,0 +1,71 @@ +import type { LucideIcon } from "lucide-react"; +import { Clock, CheckCircle, CalendarDays, CalendarClock } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { CalendarEventStatus } from "@/lib/calendar-event"; + +interface StatusConfig { + label: string; + className: string; + icon: LucideIcon; +} + +const STATUS_CONFIG: Record< + CalendarEventStatus, + { label: string; className: string } +> = { + upcoming: { label: "upcoming", className: "text-blue-500" }, + ongoing: { label: "now", className: "text-green-500" }, + past: { label: "past", className: "text-muted-foreground" }, +}; + +interface CalendarStatusBadgeProps { + status: CalendarEventStatus; + /** Icon variant - use CalendarDays for date events, CalendarClock for time events */ + variant?: "date" | "time"; + /** Size variant - sm for feed, md for detail views */ + size?: "sm" | "md"; +} + +/** + * Status badge for calendar events (NIP-52) + * Displays the current status (upcoming/now/past) with an appropriate icon + */ +export function CalendarStatusBadge({ + status, + variant = "date", + size = "sm", +}: CalendarStatusBadgeProps) { + const baseConfig = STATUS_CONFIG[status]; + const OngoingIcon = variant === "time" ? CalendarClock : CalendarDays; + + const config: StatusConfig = { + ...baseConfig, + icon: + status === "ongoing" + ? OngoingIcon + : status === "upcoming" + ? Clock + : CheckCircle, + }; + + const Icon = config.icon; + + const sizeClasses = { + sm: { text: "text-xs", icon: "w-3 h-3", gap: "gap-1" }, + md: { text: "text-sm", icon: "w-4 h-4", gap: "gap-1" }, + }[size]; + + return ( +
+ + {config.label} +
+ ); +} diff --git a/src/components/nostr/kinds/CalendarDateEventDetailRenderer.tsx b/src/components/nostr/kinds/CalendarDateEventDetailRenderer.tsx new file mode 100644 index 0000000..48d464f --- /dev/null +++ b/src/components/nostr/kinds/CalendarDateEventDetailRenderer.tsx @@ -0,0 +1,147 @@ +import type { NostrEvent } from "@/types/nostr"; +import { + parseDateCalendarEvent, + getDateEventStatus, + formatDateRange, +} from "@/lib/calendar-event"; +import { UserName } from "../UserName"; +import { MarkdownContent } from "../MarkdownContent"; +import { CalendarStatusBadge } from "../calendar/CalendarStatusBadge"; +import { Label } from "@/components/ui/label"; +import { MapPin, Users, Hash, ExternalLink } from "lucide-react"; + +/** + * Detail renderer for Kind 31922 - Date-Based Calendar Event + * Displays full event details with participant list + */ +export function CalendarDateEventDetailRenderer({ + event, +}: { + event: NostrEvent; +}) { + const parsed = parseDateCalendarEvent(event); + const status = getDateEventStatus(parsed); + const dateRange = formatDateRange(parsed.start, parsed.end); + + return ( +
+ {/* Event Header */} +
+ {/* Title */} +

+ {parsed.title || parsed.identifier} +

+ + {/* Date and Status: date left, badge right */} +
+ {dateRange && ( + {dateRange} + )} + +
+ + {/* Organizer */} +
+ Organized by + +
+
+ + {/* Event Details */} +
+ {/* Locations */} + {parsed.locations.length > 0 && ( +
+

+ + Location{parsed.locations.length > 1 ? "s" : ""} +

+
    + {parsed.locations.map((location, i) => ( +
  • + {location} +
  • + ))} +
+
+ )} + + {/* Description */} + {parsed.description && ( +
+

+ About this event +

+
+ +
+
+ )} + + {/* Participants */} + {parsed.participants.length > 0 && ( +
+

+ + Participants ({parsed.participants.length}) +

+
    + {parsed.participants.map((participant) => ( +
  • + + {participant.role && ( + + )} +
  • + ))} +
+
+ )} + + {/* Hashtags */} + {parsed.hashtags.length > 0 && ( +
+

+ + Tags +

+
+ {parsed.hashtags.map((tag) => ( + + ))} +
+
+ )} + + {/* References */} + {parsed.references.length > 0 && ( +
+

+ + Links +

+
    + {parsed.references.map((ref, i) => ( +
  • + + {ref} + +
  • + ))} +
+
+ )} +
+
+ ); +} diff --git a/src/components/nostr/kinds/CalendarDateEventRenderer.tsx b/src/components/nostr/kinds/CalendarDateEventRenderer.tsx new file mode 100644 index 0000000..cd0fb4c --- /dev/null +++ b/src/components/nostr/kinds/CalendarDateEventRenderer.tsx @@ -0,0 +1,98 @@ +import { + parseDateCalendarEvent, + getDateEventStatus, + formatDateRange, +} from "@/lib/calendar-event"; +import { + BaseEventContainer, + ClickableEventTitle, + type BaseEventProps, +} from "./BaseEventRenderer"; +import { CalendarStatusBadge } from "../calendar/CalendarStatusBadge"; +import { Label } from "@/components/ui/label"; +import { MapPin, Users } from "lucide-react"; + +/** + * Renderer for Kind 31922 - Date-Based Calendar Event + * Displays event title, date range, location, and participant count in feed + */ +export function CalendarDateEventRenderer({ event }: BaseEventProps) { + const parsed = parseDateCalendarEvent(event); + const status = getDateEventStatus(parsed); + const dateRange = formatDateRange(parsed.start, parsed.end); + + return ( + +
+ {/* Title */} + + {parsed.title || parsed.identifier} + + + {/* Date and status: time left, badge right */} +
+ {dateRange && ( + {dateRange} + )} + +
+ + {/* Description preview */} + {parsed.description && ( +

+ {parsed.description} +

+ )} + + {/* Location and participants */} + {(parsed.locations.length > 0 || parsed.participants.length > 0) && ( +
+ {/* Location */} + {parsed.locations.length > 0 && ( +
+ + + {parsed.locations[0]} + {parsed.locations.length > 1 && + ` +${parsed.locations.length - 1}`} + +
+ )} + + {/* Participant count */} + {parsed.participants.length > 0 && ( +
+ + + {parsed.participants.length}{" "} + {parsed.participants.length === 1 + ? "participant" + : "participants"} + +
+ )} +
+ )} + + {/* Hashtags */} + {parsed.hashtags.length > 0 && ( +
+ {parsed.hashtags.slice(0, 3).map((tag) => ( + + ))} + {parsed.hashtags.length > 3 && ( + + +{parsed.hashtags.length - 3} + + )} +
+ )} +
+
+ ); +} diff --git a/src/components/nostr/kinds/CalendarTimeEventDetailRenderer.tsx b/src/components/nostr/kinds/CalendarTimeEventDetailRenderer.tsx new file mode 100644 index 0000000..e3c088e --- /dev/null +++ b/src/components/nostr/kinds/CalendarTimeEventDetailRenderer.tsx @@ -0,0 +1,165 @@ +import type { NostrEvent } from "@/types/nostr"; +import { + parseTimeCalendarEvent, + getTimeEventStatus, + formatTimeRange, +} from "@/lib/calendar-event"; +import { UserName } from "../UserName"; +import { MarkdownContent } from "../MarkdownContent"; +import { CalendarStatusBadge } from "../calendar/CalendarStatusBadge"; +import { Label } from "@/components/ui/label"; +import { MapPin, Users, Hash, ExternalLink, Globe } from "lucide-react"; + +/** + * Detail renderer for Kind 31923 - Time-Based Calendar Event + * Displays full event details with participant list and timezone info + */ +export function CalendarTimeEventDetailRenderer({ + event, +}: { + event: NostrEvent; +}) { + const parsed = parseTimeCalendarEvent(event); + const status = getTimeEventStatus(parsed); + const timeRange = formatTimeRange( + parsed.start, + parsed.end, + parsed.startTzid, + parsed.endTzid, + ); + + return ( +
+ {/* Event Header */} +
+ {/* Title */} +

+ {parsed.title || parsed.identifier} +

+ + {/* Time and Status: time left, badge right */} +
+ {timeRange && ( + {timeRange} + )} + +
+ + {/* Timezone indicator */} + {parsed.startTzid && ( +
+ + {parsed.startTzid} + {parsed.endTzid && parsed.endTzid !== parsed.startTzid && ( + + (ends in {parsed.endTzid}) + + )} +
+ )} + + {/* Organizer */} +
+ Organized by + +
+
+ + {/* Event Details */} +
+ {/* Locations */} + {parsed.locations.length > 0 && ( +
+

+ + Location{parsed.locations.length > 1 ? "s" : ""} +

+
    + {parsed.locations.map((location, i) => ( +
  • + {location} +
  • + ))} +
+
+ )} + + {/* Description */} + {parsed.description && ( +
+

+ About this event +

+
+ +
+
+ )} + + {/* Participants */} + {parsed.participants.length > 0 && ( +
+

+ + Participants ({parsed.participants.length}) +

+
    + {parsed.participants.map((participant) => ( +
  • + + {participant.role && ( + + )} +
  • + ))} +
+
+ )} + + {/* Hashtags */} + {parsed.hashtags.length > 0 && ( +
+

+ + Tags +

+
+ {parsed.hashtags.map((tag) => ( + + ))} +
+
+ )} + + {/* References */} + {parsed.references.length > 0 && ( +
+

+ + Links +

+
    + {parsed.references.map((ref, i) => ( +
  • + + {ref} + +
  • + ))} +
+
+ )} +
+
+ ); +} diff --git a/src/components/nostr/kinds/CalendarTimeEventRenderer.tsx b/src/components/nostr/kinds/CalendarTimeEventRenderer.tsx new file mode 100644 index 0000000..968420b --- /dev/null +++ b/src/components/nostr/kinds/CalendarTimeEventRenderer.tsx @@ -0,0 +1,103 @@ +import { + parseTimeCalendarEvent, + getTimeEventStatus, + formatTimeRange, +} from "@/lib/calendar-event"; +import { + BaseEventContainer, + ClickableEventTitle, + type BaseEventProps, +} from "./BaseEventRenderer"; +import { CalendarStatusBadge } from "../calendar/CalendarStatusBadge"; +import { Label } from "@/components/ui/label"; +import { MapPin, Users } from "lucide-react"; + +/** + * Renderer for Kind 31923 - Time-Based Calendar Event + * Displays event title, time range with timezone, location, and participant count in feed + */ +export function CalendarTimeEventRenderer({ event }: BaseEventProps) { + const parsed = parseTimeCalendarEvent(event); + const status = getTimeEventStatus(parsed); + const timeRange = formatTimeRange( + parsed.start, + parsed.end, + parsed.startTzid, + parsed.endTzid, + ); + + return ( + +
+ {/* Title */} + + {parsed.title || parsed.identifier} + + + {/* Time and status: time left, badge right */} +
+ {timeRange && ( + {timeRange} + )} + +
+ + {/* Description preview */} + {parsed.description && ( +

+ {parsed.description} +

+ )} + + {/* Location and participants */} + {(parsed.locations.length > 0 || parsed.participants.length > 0) && ( +
+ {/* Location */} + {parsed.locations.length > 0 && ( +
+ + + {parsed.locations[0]} + {parsed.locations.length > 1 && + ` +${parsed.locations.length - 1}`} + +
+ )} + + {/* Participant count */} + {parsed.participants.length > 0 && ( +
+ + + {parsed.participants.length}{" "} + {parsed.participants.length === 1 + ? "participant" + : "participants"} + +
+ )} +
+ )} + + {/* Hashtags */} + {parsed.hashtags.length > 0 && ( +
+ {parsed.hashtags.slice(0, 3).map((tag) => ( + + ))} + {parsed.hashtags.length > 3 && ( + + +{parsed.hashtags.length - 3} + + )} +
+ )} +
+
+ ); +} diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index 7409372..671f92a 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -48,6 +48,10 @@ import { ApplicationHandlerRenderer } from "./ApplicationHandlerRenderer"; import { ApplicationHandlerDetailRenderer } from "./ApplicationHandlerDetailRenderer"; import { HandlerRecommendationRenderer } from "./HandlerRecommendationRenderer"; import { HandlerRecommendationDetailRenderer } from "./HandlerRecommendationDetailRenderer"; +import { CalendarDateEventRenderer } from "./CalendarDateEventRenderer"; +import { CalendarDateEventDetailRenderer } from "./CalendarDateEventDetailRenderer"; +import { CalendarTimeEventRenderer } from "./CalendarTimeEventRenderer"; +import { CalendarTimeEventDetailRenderer } from "./CalendarTimeEventDetailRenderer"; import { NostrEvent } from "@/types/nostr"; import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer"; @@ -94,6 +98,8 @@ const kindRenderers: Record> = { 30618: RepositoryStateRenderer, // Repository State (NIP-34) 30777: SpellbookRenderer, // Spellbook (Grimoire) 30817: CommunityNIPRenderer, // Community NIP + 31922: CalendarDateEventRenderer, // Date-Based Calendar Event (NIP-52) + 31923: CalendarTimeEventRenderer, // Time-Based Calendar Event (NIP-52) 31989: HandlerRecommendationRenderer, // Handler Recommendation (NIP-89) 31990: ApplicationHandlerRenderer, // Application Handler (NIP-89) 39701: Kind39701Renderer, // Web Bookmarks (NIP-B0) @@ -154,6 +160,8 @@ const detailRenderers: Record< 30618: RepositoryStateDetailRenderer, // Repository State Detail (NIP-34) 30777: SpellbookDetailRenderer, // Spellbook Detail (Grimoire) 30817: CommunityNIPDetailRenderer, // Community NIP Detail + 31922: CalendarDateEventDetailRenderer, // Date-Based Calendar Event Detail (NIP-52) + 31923: CalendarTimeEventDetailRenderer, // Time-Based Calendar Event Detail (NIP-52) 31989: HandlerRecommendationDetailRenderer, // Handler Recommendation Detail (NIP-89) 31990: ApplicationHandlerDetailRenderer, // Application Handler Detail (NIP-89) }; diff --git a/src/constants/kinds.ts b/src/constants/kinds.ts index 0aa9109..eb0299a 100644 --- a/src/constants/kinds.ts +++ b/src/constants/kinds.ts @@ -6,6 +6,8 @@ import { BarChart3, Bookmark, Calendar, + CalendarClock, + CalendarDays, CheckCircle2, CircleDot, Cloud, @@ -1306,14 +1308,14 @@ export const EVENT_KINDS: Record = { name: "Calendar Event", description: "Date-Based Calendar Event", nip: "52", - icon: Calendar, + icon: CalendarDays, }, 31923: { kind: 31923, - name: "Time Event", + name: "Calendar Event", description: "Time-Based Calendar Event", nip: "52", - icon: Calendar, + icon: CalendarClock, }, 31924: { kind: 31924, diff --git a/src/lib/calendar-event.ts b/src/lib/calendar-event.ts new file mode 100644 index 0000000..5ee6876 --- /dev/null +++ b/src/lib/calendar-event.ts @@ -0,0 +1,311 @@ +import type { NostrEvent } from "@/types/nostr"; +import { getTagValue, getOrComputeCachedValue } from "applesauce-core/helpers"; + +/** + * Participant in a calendar event (NIP-52) + */ +export interface CalendarParticipant { + pubkey: string; + relay?: string; + role?: string; +} + +/** + * Parsed Date-Based Calendar Event (kind 31922) + */ +export interface ParsedDateCalendarEvent { + identifier: string; + title: string; + start: string; // YYYY-MM-DD + end?: string; // YYYY-MM-DD (exclusive) + description: string; + locations: string[]; + geohash?: string; + participants: CalendarParticipant[]; + hashtags: string[]; + references: string[]; +} + +/** + * Parsed Time-Based Calendar Event (kind 31923) + */ +export interface ParsedTimeCalendarEvent { + identifier: string; + title: string; + start: number; // Unix timestamp + end?: number; // Unix timestamp + startTzid?: string; // IANA timezone identifier + endTzid?: string; // IANA timezone identifier + description: string; + locations: string[]; + geohash?: string; + participants: CalendarParticipant[]; + hashtags: string[]; + references: string[]; +} + +/** + * Status of a calendar event relative to current time + */ +export type CalendarEventStatus = "upcoming" | "ongoing" | "past"; + +// Caching symbols for parsed calendar events +const ParsedDateCalendarEventSymbol = Symbol("ParsedDateCalendarEvent"); +const ParsedTimeCalendarEventSymbol = Symbol("ParsedTimeCalendarEvent"); + +/** + * Get all values for a given tag name + */ +function getTagValues(event: NostrEvent, tagName: string): string[] { + return event.tags.filter((t) => t[0] === tagName).map((t) => t[1] || ""); +} + +/** + * Parse participants from p tags + * Format: ["p", , ?, ?] + */ +function parseParticipants(event: NostrEvent): CalendarParticipant[] { + return event.tags + .filter((t) => t[0] === "p") + .map((t) => ({ + pubkey: t[1], + relay: t[2] || undefined, + role: t[3] || undefined, + })); +} + +/** + * Parse a kind 31922 Date-Based Calendar Event + * Results are cached on the event object for performance + */ +export function parseDateCalendarEvent( + event: NostrEvent, +): ParsedDateCalendarEvent { + return getOrComputeCachedValue(event, ParsedDateCalendarEventSymbol, () => ({ + identifier: getTagValue(event, "d") || "", + title: getTagValue(event, "title") || "", + start: getTagValue(event, "start") || "", + end: getTagValue(event, "end") || undefined, + description: event.content || "", + locations: getTagValues(event, "location"), + geohash: getTagValue(event, "g") || undefined, + participants: parseParticipants(event), + hashtags: getTagValues(event, "t"), + references: getTagValues(event, "r"), + })); +} + +/** + * Parse a kind 31923 Time-Based Calendar Event + * Results are cached on the event object for performance + */ +export function parseTimeCalendarEvent( + event: NostrEvent, +): ParsedTimeCalendarEvent { + return getOrComputeCachedValue(event, ParsedTimeCalendarEventSymbol, () => { + const startStr = getTagValue(event, "start"); + const endStr = getTagValue(event, "end"); + + return { + identifier: getTagValue(event, "d") || "", + title: getTagValue(event, "title") || "", + start: startStr ? parseInt(startStr, 10) : 0, + end: endStr ? parseInt(endStr, 10) : undefined, + startTzid: getTagValue(event, "start_tzid") || undefined, + endTzid: getTagValue(event, "end_tzid") || undefined, + description: event.content || "", + locations: getTagValues(event, "location"), + geohash: getTagValue(event, "g") || undefined, + participants: parseParticipants(event), + hashtags: getTagValues(event, "t"), + references: getTagValues(event, "r"), + }; + }); +} + +/** + * Get status of a date-based calendar event + */ +export function getDateEventStatus( + parsed: ParsedDateCalendarEvent, +): CalendarEventStatus { + if (!parsed.start) return "upcoming"; + + const now = new Date(); + const todayStr = now.toISOString().split("T")[0]; + + // Parse start date + const startDate = parsed.start; + + // Parse end date (exclusive) - if not provided, event is single day + const endDate = parsed.end || parsed.start; + + if (todayStr < startDate) { + return "upcoming"; + } else if (todayStr >= endDate) { + // End is exclusive, so if today >= end, event is past + // But if no end provided, check if today > start + if (!parsed.end && todayStr > startDate) { + return "past"; + } else if (parsed.end) { + return "past"; + } + return "ongoing"; + } else { + return "ongoing"; + } +} + +/** + * Get status of a time-based calendar event + */ +export function getTimeEventStatus( + parsed: ParsedTimeCalendarEvent, +): CalendarEventStatus { + if (!parsed.start) return "upcoming"; + + const now = Date.now() / 1000; + + if (now < parsed.start) { + return "upcoming"; + } else if (parsed.end && now >= parsed.end) { + return "past"; + } else if (!parsed.end && now > parsed.start + 3600) { + // If no end time, consider past after 1 hour + return "past"; + } else { + return "ongoing"; + } +} + +/** + * Format a date string (YYYY-MM-DD) for display using locale + */ +export function formatDateForDisplay( + dateStr: string, + options?: Intl.DateTimeFormatOptions, +): string { + if (!dateStr) return ""; + + // Parse as local date (not UTC) + const [year, month, day] = dateStr.split("-").map(Number); + const date = new Date(year, month - 1, day); + + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: + date.getFullYear() !== new Date().getFullYear() ? "numeric" : undefined, + ...options, + }); +} + +/** + * Format a date range for display + */ +export function formatDateRange(start: string, end?: string): string { + if (!start) return ""; + + const startFormatted = formatDateForDisplay(start); + + if (!end || end === start) { + return startFormatted; + } + + // Check if same month/year for compact display + const [startYear, startMonth] = start.split("-"); + const [endYear, endMonth] = end.split("-"); + const endDay = end.split("-")[2]; + + if (startYear === endYear && startMonth === endMonth) { + // Same month: "Jan 15-17" + return `${startFormatted}–${parseInt(endDay, 10)}`; + } + + // Different months: "Jan 15 – Feb 2" + const endFormatted = formatDateForDisplay(end); + return `${startFormatted} – ${endFormatted}`; +} + +/** + * Format a Unix timestamp for display using locale + */ +export function formatTimeForDisplay( + timestamp: number, + tzid?: string, + options?: Intl.DateTimeFormatOptions, +): string { + if (!timestamp) return ""; + + const date = new Date(timestamp * 1000); + + const defaultOptions: Intl.DateTimeFormatOptions = { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + year: + date.getFullYear() !== new Date().getFullYear() ? "numeric" : undefined, + timeZone: tzid || undefined, + }; + + try { + return date.toLocaleString(undefined, { ...defaultOptions, ...options }); + } catch { + // Fallback if timezone is invalid + return date.toLocaleString(undefined, { + ...defaultOptions, + ...options, + timeZone: undefined, + }); + } +} + +/** + * Format a time range for display + */ +export function formatTimeRange( + start: number, + end?: number, + startTzid?: string, + endTzid?: string, +): string { + if (!start) return ""; + + const startDate = new Date(start * 1000); + const endDate = end ? new Date(end * 1000) : null; + + // Check if same day + const sameDay = + endDate && + startDate.toDateString() === endDate.toDateString() && + startTzid === endTzid; + + if (sameDay) { + // Same day: "Jan 15, 7:00 PM – 9:00 PM" + const dateStr = formatTimeForDisplay(start, startTzid, { + hour: undefined, + minute: undefined, + }); + const startTime = startDate.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + timeZone: startTzid || undefined, + }); + const endTime = endDate.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + timeZone: endTzid || startTzid || undefined, + }); + return `${dateStr}, ${startTime} – ${endTime}`; + } + + const startFormatted = formatTimeForDisplay(start, startTzid); + + if (!end) { + return startFormatted; + } + + const endFormatted = formatTimeForDisplay(end, endTzid || startTzid); + return `${startFormatted} – ${endFormatted}`; +}