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)
This commit is contained in:
Claude
2026-01-07 20:13:27 +00:00
parent 1c9c127a93
commit ba84cc6dbb
6 changed files with 114 additions and 208 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

@@ -3,58 +3,12 @@ import {
parseDateCalendarEvent,
getDateEventStatus,
formatDateRange,
type CalendarEventStatus,
} from "@/lib/calendar-event";
import { UserName } from "../UserName";
import { MarkdownContent } from "../MarkdownContent";
import { CalendarStatusBadge } from "../calendar/CalendarStatusBadge";
import { Label } from "@/components/ui/label";
import {
CalendarDays,
MapPin,
Users,
Clock,
CheckCircle,
Hash,
ExternalLink,
} from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Status badge for calendar events
*/
function CalendarStatusBadge({ status }: { status: CalendarEventStatus }) {
const config = {
upcoming: {
label: "upcoming",
className: "text-blue-500",
icon: Clock,
},
ongoing: {
label: "now",
className: "text-green-500",
icon: CalendarDays,
},
past: {
label: "past",
className: "text-muted-foreground",
icon: CheckCircle,
},
}[status];
const Icon = config.icon;
return (
<div
className={cn(
"flex items-center gap-1 text-sm flex-shrink-0",
config.className,
)}
>
<Icon className="w-4 h-4" />
<span>{config.label}</span>
</div>
);
}
import { MapPin, Users, Hash, ExternalLink } from "lucide-react";
/**
* Detail renderer for Kind 31922 - Date-Based Calendar Event
@@ -75,7 +29,7 @@ export function CalendarDateEventDetailRenderer({
<header className="flex flex-col gap-4 border-b border-border pb-6">
{/* Title */}
<h1 className="text-3xl font-bold">
{parsed.title || "Untitled Event"}
{parsed.title || parsed.identifier}
</h1>
{/* Date and Status: date left, badge right */}
@@ -83,7 +37,7 @@ export function CalendarDateEventDetailRenderer({
{dateRange && (
<span className="text-sm text-muted-foreground">{dateRange}</span>
)}
<CalendarStatusBadge status={status} />
<CalendarStatusBadge status={status} variant="date" size="md" />
</div>
{/* Organizer */}

View File

@@ -2,53 +2,15 @@ import {
parseDateCalendarEvent,
getDateEventStatus,
formatDateRange,
type CalendarEventStatus,
} from "@/lib/calendar-event";
import {
BaseEventContainer,
ClickableEventTitle,
type BaseEventProps,
} from "./BaseEventRenderer";
import { CalendarStatusBadge } from "../calendar/CalendarStatusBadge";
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 }: { status: CalendarEventStatus }) {
const config = {
upcoming: {
label: "upcoming",
className: "text-blue-500",
icon: Clock,
},
ongoing: {
label: "now",
className: "text-green-500",
icon: CalendarDays,
},
past: {
label: "past",
className: "text-muted-foreground",
icon: CheckCircle,
},
}[status];
const Icon = config.icon;
return (
<div
className={cn(
"flex items-center gap-1 text-xs flex-shrink-0",
config.className,
)}
>
<Icon className="w-3 h-3" />
<span>{config.label}</span>
</div>
);
}
import { MapPin, Users } from "lucide-react";
/**
* Renderer for Kind 31922 - Date-Based Calendar Event
@@ -67,7 +29,7 @@ export function CalendarDateEventRenderer({ event }: BaseEventProps) {
event={event}
className="text-lg font-semibold text-foreground"
>
{parsed.title || "Untitled Event"}
{parsed.title || parsed.identifier}
</ClickableEventTitle>
{/* Date and status: time left, badge right */}
@@ -75,7 +37,7 @@ export function CalendarDateEventRenderer({ event }: BaseEventProps) {
{dateRange && (
<span className="text-xs text-muted-foreground">{dateRange}</span>
)}
<CalendarStatusBadge status={status} />
<CalendarStatusBadge status={status} variant="date" size="sm" />
</div>
{/* Description preview */}

View File

@@ -3,59 +3,12 @@ import {
parseTimeCalendarEvent,
getTimeEventStatus,
formatTimeRange,
type CalendarEventStatus,
} from "@/lib/calendar-event";
import { UserName } from "../UserName";
import { MarkdownContent } from "../MarkdownContent";
import { CalendarStatusBadge } from "../calendar/CalendarStatusBadge";
import { Label } from "@/components/ui/label";
import {
CalendarClock,
MapPin,
Users,
Clock,
CheckCircle,
Hash,
ExternalLink,
Globe,
} from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Status badge for calendar events
*/
function CalendarStatusBadge({ status }: { status: CalendarEventStatus }) {
const config = {
upcoming: {
label: "upcoming",
className: "text-blue-500",
icon: Clock,
},
ongoing: {
label: "now",
className: "text-green-500",
icon: CalendarClock,
},
past: {
label: "past",
className: "text-muted-foreground",
icon: CheckCircle,
},
}[status];
const Icon = config.icon;
return (
<div
className={cn(
"flex items-center gap-1 text-sm flex-shrink-0",
config.className,
)}
>
<Icon className="w-4 h-4" />
<span>{config.label}</span>
</div>
);
}
import { MapPin, Users, Hash, ExternalLink, Globe } from "lucide-react";
/**
* Detail renderer for Kind 31923 - Time-Based Calendar Event
@@ -81,7 +34,7 @@ export function CalendarTimeEventDetailRenderer({
<header className="flex flex-col gap-4 border-b border-border pb-6">
{/* Title */}
<h1 className="text-3xl font-bold">
{parsed.title || "Untitled Event"}
{parsed.title || parsed.identifier}
</h1>
{/* Time and Status: time left, badge right */}
@@ -89,7 +42,7 @@ export function CalendarTimeEventDetailRenderer({
{timeRange && (
<span className="text-sm text-muted-foreground">{timeRange}</span>
)}
<CalendarStatusBadge status={status} />
<CalendarStatusBadge status={status} variant="time" size="md" />
</div>
{/* Timezone indicator */}

View File

@@ -2,53 +2,15 @@ import {
parseTimeCalendarEvent,
getTimeEventStatus,
formatTimeRange,
type CalendarEventStatus,
} from "@/lib/calendar-event";
import {
BaseEventContainer,
ClickableEventTitle,
type BaseEventProps,
} from "./BaseEventRenderer";
import { CalendarStatusBadge } from "../calendar/CalendarStatusBadge";
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 }: { status: CalendarEventStatus }) {
const config = {
upcoming: {
label: "upcoming",
className: "text-blue-500",
icon: Clock,
},
ongoing: {
label: "now",
className: "text-green-500",
icon: CalendarClock,
},
past: {
label: "past",
className: "text-muted-foreground",
icon: CheckCircle,
},
}[status];
const Icon = config.icon;
return (
<div
className={cn(
"flex items-center gap-1 text-xs flex-shrink-0",
config.className,
)}
>
<Icon className="w-3 h-3" />
<span>{config.label}</span>
</div>
);
}
import { MapPin, Users } from "lucide-react";
/**
* Renderer for Kind 31923 - Time-Based Calendar Event
@@ -72,7 +34,7 @@ export function CalendarTimeEventRenderer({ event }: BaseEventProps) {
event={event}
className="text-lg font-semibold text-foreground"
>
{parsed.title || "Untitled Event"}
{parsed.title || parsed.identifier}
</ClickableEventTitle>
{/* Time and status: time left, badge right */}
@@ -80,7 +42,7 @@ export function CalendarTimeEventRenderer({ event }: BaseEventProps) {
{timeRange && (
<span className="text-xs text-muted-foreground">{timeRange}</span>
)}
<CalendarStatusBadge status={status} />
<CalendarStatusBadge status={status} variant="time" size="sm" />
</div>
{/* Description preview */}

View File

@@ -1,5 +1,5 @@
import type { NostrEvent } from "@/types/nostr";
import { getTagValue } from "applesauce-core/helpers";
import { getTagValue, getOrComputeCachedValue } from "applesauce-core/helpers";
/**
* Participant in a calendar event (NIP-52)
@@ -14,7 +14,6 @@ export interface CalendarParticipant {
* Parsed Date-Based Calendar Event (kind 31922)
*/
export interface ParsedDateCalendarEvent {
event: NostrEvent;
identifier: string;
title: string;
start: string; // YYYY-MM-DD
@@ -31,7 +30,6 @@ export interface ParsedDateCalendarEvent {
* Parsed Time-Based Calendar Event (kind 31923)
*/
export interface ParsedTimeCalendarEvent {
event: NostrEvent;
identifier: string;
title: string;
start: number; // Unix timestamp
@@ -51,6 +49,10 @@ export interface ParsedTimeCalendarEvent {
*/
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
*/
@@ -74,12 +76,12 @@ function parseParticipants(event: NostrEvent): CalendarParticipant[] {
/**
* Parse a kind 31922 Date-Based Calendar Event
* Results are cached on the event object for performance
*/
export function parseDateCalendarEvent(
event: NostrEvent,
): ParsedDateCalendarEvent {
return {
event,
return getOrComputeCachedValue(event, ParsedDateCalendarEventSymbol, () => ({
identifier: getTagValue(event, "d") || "",
title: getTagValue(event, "title") || "",
start: getTagValue(event, "start") || "",
@@ -90,33 +92,35 @@ export function parseDateCalendarEvent(
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 {
const startStr = getTagValue(event, "start");
const endStr = getTagValue(event, "end");
return getOrComputeCachedValue(event, ParsedTimeCalendarEventSymbol, () => {
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"),
};
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"),
};
});
}
/**