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
This commit is contained in:
Claude
2026-01-07 19:01:33 +00:00
parent 1d61d095a8
commit 212857f97b
7 changed files with 1046 additions and 4 deletions

View File

@@ -0,0 +1,199 @@
import type { NostrEvent } from "@/types/nostr";
import {
parseDateCalendarEvent,
getDateEventStatus,
formatDateRange,
type CalendarEventStatus,
} from "@/lib/calendar-event";
import { UserName } from "../UserName";
import { MarkdownContent } from "../MarkdownContent";
import {
CalendarDays,
MapPin,
Users,
Clock,
CheckCircle,
Hash,
ExternalLink,
} from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Status badge for calendar events (larger variant for detail view)
*/
function CalendarStatusBadge({ status }: { status: CalendarEventStatus }) {
const config = {
upcoming: {
label: "Upcoming",
className: "bg-blue-600 text-white",
icon: Clock,
},
ongoing: {
label: "Happening Now",
className: "bg-green-600 text-white",
icon: CalendarDays,
},
past: {
label: "Past Event",
className: "bg-neutral-600 text-white",
icon: CheckCircle,
},
}[status];
const Icon = config.icon;
return (
<div
className={cn(
"rounded px-3 py-1.5 text-sm font-bold flex items-center gap-2 flex-shrink-0",
config.className,
)}
>
<Icon className="w-4 h-4" />
<span>{config.label}</span>
</div>
);
}
/**
* 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 (
<div className="flex flex-col gap-6 p-6 max-w-3xl mx-auto">
{/* Event Header */}
<header className="flex flex-col gap-4 border-b border-border pb-6">
{/* Status and Date */}
<div className="flex items-center gap-3 flex-wrap">
<CalendarStatusBadge status={status} />
{dateRange && (
<span className="text-lg font-medium text-muted-foreground">
{dateRange}
</span>
)}
</div>
{/* Title */}
<h1 className="text-3xl font-bold">
{parsed.title || "Untitled Event"}
</h1>
{/* Organizer */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Organized by</span>
<UserName pubkey={event.pubkey} className="font-semibold" />
</div>
</header>
{/* Event Details */}
<div className="flex flex-col gap-6">
{/* Locations */}
{parsed.locations.length > 0 && (
<section className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<MapPin className="w-4 h-4" />
Location{parsed.locations.length > 1 ? "s" : ""}
</h2>
<ul className="flex flex-col gap-1">
{parsed.locations.map((location, i) => (
<li key={i} className="text-foreground">
{location}
</li>
))}
</ul>
</section>
)}
{/* Description */}
{parsed.description && (
<section className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
About this event
</h2>
<div className="prose prose-sm dark:prose-invert max-w-none">
<MarkdownContent content={parsed.description} />
</div>
</section>
)}
{/* Participants */}
{parsed.participants.length > 0 && (
<section className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Users className="w-4 h-4" />
Participants ({parsed.participants.length})
</h2>
<ul className="flex flex-col gap-2">
{parsed.participants.map((participant) => (
<li
key={participant.pubkey}
className="flex items-center gap-2"
>
<UserName pubkey={participant.pubkey} />
{participant.role && (
<span className="text-xs text-muted-foreground px-2 py-0.5 bg-muted rounded">
{participant.role}
</span>
)}
</li>
))}
</ul>
</section>
)}
{/* Hashtags */}
{parsed.hashtags.length > 0 && (
<section className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Hash className="w-4 h-4" />
Tags
</h2>
<div className="flex flex-wrap gap-2">
{parsed.hashtags.map((tag) => (
<span
key={tag}
className="px-2 py-1 text-sm bg-muted text-muted-foreground rounded"
>
#{tag}
</span>
))}
</div>
</section>
)}
{/* References */}
{parsed.references.length > 0 && (
<section className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<ExternalLink className="w-4 h-4" />
Links
</h2>
<ul className="flex flex-col gap-1">
{parsed.references.map((ref, i) => (
<li key={i}>
<a
href={ref}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline truncate block"
>
{ref}
</a>
</li>
))}
</ul>
</section>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,149 @@
import {
parseDateCalendarEvent,
getDateEventStatus,
formatDateRange,
type CalendarEventStatus,
} from "@/lib/calendar-event";
import {
BaseEventContainer,
ClickableEventTitle,
type BaseEventProps,
} from "./BaseEventRenderer";
import { Label } from "@/components/ui/label";
import { CalendarDays, MapPin, Users, Clock, CheckCircle } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Status badge for calendar events
*/
function CalendarStatusBadge({
status,
size = "sm",
}: {
status: CalendarEventStatus;
size?: "sm" | "md";
}) {
const config = {
upcoming: {
label: "UPCOMING",
className: "bg-blue-600 text-white",
icon: Clock,
},
ongoing: {
label: "NOW",
className: "bg-green-600 text-white",
icon: CalendarDays,
},
past: {
label: "PAST",
className: "bg-neutral-600 text-white",
icon: CheckCircle,
},
}[status];
const Icon = config.icon;
const sizeClasses = {
sm: "px-2 py-0.5 text-xs gap-1",
md: "px-3 py-1 text-sm gap-2",
};
const iconSizeClasses = {
sm: "w-3 h-3",
md: "w-4 h-4",
};
return (
<div
className={cn(
"rounded font-bold flex items-center flex-shrink-0",
config.className,
sizeClasses[size],
)}
>
<Icon className={iconSizeClasses[size]} />
<span>{config.label}</span>
</div>
);
}
/**
* 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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
{/* Header: Status badge and date */}
<div className="flex items-center gap-2 flex-wrap">
<CalendarStatusBadge status={status} />
{dateRange && (
<span className="text-sm font-medium text-muted-foreground">
{dateRange}
</span>
)}
</div>
{/* Title */}
<ClickableEventTitle
event={event}
className="text-lg font-semibold text-foreground"
>
{parsed.title || "Untitled Event"}
</ClickableEventTitle>
{/* Description preview */}
{parsed.description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{parsed.description}
</p>
)}
{/* Metadata row: location, participants, hashtags */}
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap">
{/* Location */}
{parsed.locations.length > 0 && (
<div className="flex items-center gap-1">
<MapPin className="w-3 h-3" />
<span className="truncate max-w-[200px]">
{parsed.locations[0]}
{parsed.locations.length > 1 &&
` +${parsed.locations.length - 1}`}
</span>
</div>
)}
{/* Participant count */}
{parsed.participants.length > 0 && (
<div className="flex items-center gap-1">
<Users className="w-3 h-3" />
<span>
{parsed.participants.length}{" "}
{parsed.participants.length === 1
? "participant"
: "participants"}
</span>
</div>
)}
{/* Hashtags */}
{parsed.hashtags.slice(0, 3).map((tag) => (
<Label key={tag} size="sm">
{tag}
</Label>
))}
{parsed.hashtags.length > 3 && (
<span className="text-muted-foreground">
+{parsed.hashtags.length - 3}
</span>
)}
</div>
</div>
</BaseEventContainer>
);
}

View File

@@ -0,0 +1,218 @@
import type { NostrEvent } from "@/types/nostr";
import {
parseTimeCalendarEvent,
getTimeEventStatus,
formatTimeRange,
type CalendarEventStatus,
} from "@/lib/calendar-event";
import { UserName } from "../UserName";
import { MarkdownContent } from "../MarkdownContent";
import {
CalendarClock,
MapPin,
Users,
Clock,
CheckCircle,
Hash,
ExternalLink,
Globe,
} from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Status badge for calendar events (larger variant for detail view)
*/
function CalendarStatusBadge({ status }: { status: CalendarEventStatus }) {
const config = {
upcoming: {
label: "Upcoming",
className: "bg-blue-600 text-white",
icon: Clock,
},
ongoing: {
label: "Happening Now",
className: "bg-green-600 text-white",
icon: CalendarClock,
},
past: {
label: "Past Event",
className: "bg-neutral-600 text-white",
icon: CheckCircle,
},
}[status];
const Icon = config.icon;
return (
<div
className={cn(
"rounded px-3 py-1.5 text-sm font-bold flex items-center gap-2 flex-shrink-0",
config.className,
)}
>
<Icon className="w-4 h-4" />
<span>{config.label}</span>
</div>
);
}
/**
* 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 (
<div className="flex flex-col gap-6 p-6 max-w-3xl mx-auto">
{/* Event Header */}
<header className="flex flex-col gap-4 border-b border-border pb-6">
{/* Status and Time */}
<div className="flex items-center gap-3 flex-wrap">
<CalendarStatusBadge status={status} />
{timeRange && (
<span className="text-lg font-medium text-muted-foreground">
{timeRange}
</span>
)}
</div>
{/* Timezone indicator */}
{parsed.startTzid && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Globe className="w-4 h-4" />
<span>{parsed.startTzid}</span>
{parsed.endTzid && parsed.endTzid !== parsed.startTzid && (
<span className="text-muted-foreground/70">
(ends in {parsed.endTzid})
</span>
)}
</div>
)}
{/* Title */}
<h1 className="text-3xl font-bold">
{parsed.title || "Untitled Event"}
</h1>
{/* Organizer */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Organized by</span>
<UserName pubkey={event.pubkey} className="font-semibold" />
</div>
</header>
{/* Event Details */}
<div className="flex flex-col gap-6">
{/* Locations */}
{parsed.locations.length > 0 && (
<section className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<MapPin className="w-4 h-4" />
Location{parsed.locations.length > 1 ? "s" : ""}
</h2>
<ul className="flex flex-col gap-1">
{parsed.locations.map((location, i) => (
<li key={i} className="text-foreground">
{location}
</li>
))}
</ul>
</section>
)}
{/* Description */}
{parsed.description && (
<section className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
About this event
</h2>
<div className="prose prose-sm dark:prose-invert max-w-none">
<MarkdownContent content={parsed.description} />
</div>
</section>
)}
{/* Participants */}
{parsed.participants.length > 0 && (
<section className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Users className="w-4 h-4" />
Participants ({parsed.participants.length})
</h2>
<ul className="flex flex-col gap-2">
{parsed.participants.map((participant) => (
<li
key={participant.pubkey}
className="flex items-center gap-2"
>
<UserName pubkey={participant.pubkey} />
{participant.role && (
<span className="text-xs text-muted-foreground px-2 py-0.5 bg-muted rounded">
{participant.role}
</span>
)}
</li>
))}
</ul>
</section>
)}
{/* Hashtags */}
{parsed.hashtags.length > 0 && (
<section className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Hash className="w-4 h-4" />
Tags
</h2>
<div className="flex flex-wrap gap-2">
{parsed.hashtags.map((tag) => (
<span
key={tag}
className="px-2 py-1 text-sm bg-muted text-muted-foreground rounded"
>
#{tag}
</span>
))}
</div>
</section>
)}
{/* References */}
{parsed.references.length > 0 && (
<section className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<ExternalLink className="w-4 h-4" />
Links
</h2>
<ul className="flex flex-col gap-1">
{parsed.references.map((ref, i) => (
<li key={i}>
<a
href={ref}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline truncate block"
>
{ref}
</a>
</li>
))}
</ul>
</section>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,159 @@
import {
parseTimeCalendarEvent,
getTimeEventStatus,
formatTimeRange,
type CalendarEventStatus,
} from "@/lib/calendar-event";
import {
BaseEventContainer,
ClickableEventTitle,
type BaseEventProps,
} from "./BaseEventRenderer";
import { Label } from "@/components/ui/label";
import { CalendarClock, MapPin, Users, Clock, CheckCircle } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Status badge for calendar events
*/
function CalendarStatusBadge({
status,
size = "sm",
}: {
status: CalendarEventStatus;
size?: "sm" | "md";
}) {
const config = {
upcoming: {
label: "UPCOMING",
className: "bg-blue-600 text-white",
icon: Clock,
},
ongoing: {
label: "NOW",
className: "bg-green-600 text-white",
icon: CalendarClock,
},
past: {
label: "PAST",
className: "bg-neutral-600 text-white",
icon: CheckCircle,
},
}[status];
const Icon = config.icon;
const sizeClasses = {
sm: "px-2 py-0.5 text-xs gap-1",
md: "px-3 py-1 text-sm gap-2",
};
const iconSizeClasses = {
sm: "w-3 h-3",
md: "w-4 h-4",
};
return (
<div
className={cn(
"rounded font-bold flex items-center flex-shrink-0",
config.className,
sizeClasses[size],
)}
>
<Icon className={iconSizeClasses[size]} />
<span>{config.label}</span>
</div>
);
}
/**
* 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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
{/* Header: Status badge and time */}
<div className="flex items-center gap-2 flex-wrap">
<CalendarStatusBadge status={status} />
{timeRange && (
<span className="text-sm font-medium text-muted-foreground">
{timeRange}
</span>
)}
</div>
{/* Title */}
<ClickableEventTitle
event={event}
className="text-lg font-semibold text-foreground"
>
{parsed.title || "Untitled Event"}
</ClickableEventTitle>
{/* Description preview */}
{parsed.description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{parsed.description}
</p>
)}
{/* Metadata row: location, participants, hashtags */}
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap">
{/* Location */}
{parsed.locations.length > 0 && (
<div className="flex items-center gap-1">
<MapPin className="w-3 h-3" />
<span className="truncate max-w-[200px]">
{parsed.locations[0]}
{parsed.locations.length > 1 &&
` +${parsed.locations.length - 1}`}
</span>
</div>
)}
{/* Participant count */}
{parsed.participants.length > 0 && (
<div className="flex items-center gap-1">
<Users className="w-3 h-3" />
<span>
{parsed.participants.length}{" "}
{parsed.participants.length === 1
? "participant"
: "participants"}
</span>
</div>
)}
{/* Timezone indicator */}
{parsed.startTzid && (
<span className="text-muted-foreground/70">{parsed.startTzid}</span>
)}
{/* Hashtags */}
{parsed.hashtags.slice(0, 3).map((tag) => (
<Label key={tag} size="sm">
{tag}
</Label>
))}
{parsed.hashtags.length > 3 && (
<span className="text-muted-foreground">
+{parsed.hashtags.length - 3}
</span>
)}
</div>
</div>
</BaseEventContainer>
);
}

View File

@@ -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<number, React.ComponentType<BaseEventProps>> = {
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)
};

View File

@@ -6,6 +6,8 @@ import {
BarChart3,
Bookmark,
Calendar,
CalendarClock,
CalendarDays,
CheckCircle2,
CircleDot,
Cloud,
@@ -1303,17 +1305,17 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
// },
31922: {
kind: 31922,
name: "Calendar Event",
name: "Date-Based Event",
description: "Date-Based Calendar Event",
nip: "52",
icon: Calendar,
icon: CalendarDays,
},
31923: {
kind: 31923,
name: "Time Event",
name: "Time-Based Event",
description: "Time-Based Calendar Event",
nip: "52",
icon: Calendar,
icon: CalendarClock,
},
31924: {
kind: 31924,

307
src/lib/calendar-event.ts Normal file
View File

@@ -0,0 +1,307 @@
import type { NostrEvent } from "@/types/nostr";
import { getTagValue } 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 {
event: NostrEvent;
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 {
event: NostrEvent;
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";
/**
* 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", <pubkey>, <relay>?, <role>?]
*/
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
*/
export function parseDateCalendarEvent(
event: NostrEvent,
): ParsedDateCalendarEvent {
return {
event,
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
*/
export function parseTimeCalendarEvent(
event: NostrEvent,
): ParsedTimeCalendarEvent {
const startStr = getTagValue(event, "start");
const endStr = getTagValue(event, "end");
return {
event,
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}`;
}