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 */}
+
+
+ {/* 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}`;
+}