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 <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-07 21:20:04 +01:00
committed by GitHub
parent 1d61d095a8
commit 35f55b8063
8 changed files with 908 additions and 3 deletions

View File

@@ -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 (
<div
className={cn(
"flex items-center flex-shrink-0",
sizeClasses.text,
sizeClasses.gap,
config.className,
)}
>
<Icon className={sizeClasses.icon} />
<span>{config.label}</span>
</div>
);
}

View File

@@ -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 (
<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">
{/* Title */}
<h1 className="text-3xl font-bold">
{parsed.title || parsed.identifier}
</h1>
{/* Date and Status: date left, badge right */}
<div className="flex items-center justify-between">
{dateRange && (
<span className="text-sm text-muted-foreground">{dateRange}</span>
)}
<CalendarStatusBadge status={status} variant="date" size="md" />
</div>
{/* 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 && (
<Label size="sm">{participant.role}</Label>
)}
</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) => (
<Label key={tag} size="sm">
{tag}
</Label>
))}
</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,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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
{/* Title */}
<ClickableEventTitle
event={event}
className="text-lg font-semibold text-foreground"
>
{parsed.title || parsed.identifier}
</ClickableEventTitle>
{/* Date and status: time left, badge right */}
<div className="flex items-center justify-between">
{dateRange && (
<span className="text-xs text-muted-foreground">{dateRange}</span>
)}
<CalendarStatusBadge status={status} variant="date" size="sm" />
</div>
{/* Description preview */}
{parsed.description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{parsed.description}
</p>
)}
{/* Location and participants */}
{(parsed.locations.length > 0 || parsed.participants.length > 0) && (
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{/* 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>
)}
</div>
)}
{/* Hashtags */}
{parsed.hashtags.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{parsed.hashtags.slice(0, 3).map((tag) => (
<Label key={tag} size="sm">
{tag}
</Label>
))}
{parsed.hashtags.length > 3 && (
<span className="text-xs text-muted-foreground">
+{parsed.hashtags.length - 3}
</span>
)}
</div>
)}
</div>
</BaseEventContainer>
);
}

View File

@@ -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 (
<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">
{/* Title */}
<h1 className="text-3xl font-bold">
{parsed.title || parsed.identifier}
</h1>
{/* Time and Status: time left, badge right */}
<div className="flex items-center justify-between">
{timeRange && (
<span className="text-sm text-muted-foreground">{timeRange}</span>
)}
<CalendarStatusBadge status={status} variant="time" size="md" />
</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>
)}
{/* 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 && (
<Label size="sm">{participant.role}</Label>
)}
</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) => (
<Label key={tag} size="sm">
{tag}
</Label>
))}
</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,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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
{/* Title */}
<ClickableEventTitle
event={event}
className="text-lg font-semibold text-foreground"
>
{parsed.title || parsed.identifier}
</ClickableEventTitle>
{/* Time and status: time left, badge right */}
<div className="flex items-center justify-between">
{timeRange && (
<span className="text-xs text-muted-foreground">{timeRange}</span>
)}
<CalendarStatusBadge status={status} variant="time" size="sm" />
</div>
{/* Description preview */}
{parsed.description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{parsed.description}
</p>
)}
{/* Location and participants */}
{(parsed.locations.length > 0 || parsed.participants.length > 0) && (
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{/* 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>
)}
</div>
)}
{/* Hashtags */}
{parsed.hashtags.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{parsed.hashtags.slice(0, 3).map((tag) => (
<Label key={tag} size="sm">
{tag}
</Label>
))}
{parsed.hashtags.length > 3 && (
<span className="text-xs 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,
@@ -1306,14 +1308,14 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
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,

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

@@ -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", <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
* 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}`;
}