mirror of
https://github.com/layer-systems/website.git
synced 2026-09-12 05:33:12 +02:00
feat: Calendar app for NIP-52 date-based and time-based events (#38)
* feat: Calendar app for NIP-52 date-based and time-based events Adds a Calendar app with a polished month grid as the default view: today/selected-day highlighting, adjacent-month days, per-day event pills capped with a "+N more" indicator, and a day agenda panel with an ordered event list. Supports combinable filters (event type, author, free-text search) with a visible active-filter count and per-filter clearing, keyboard navigation (arrows/Home/End/PageUp/ PageDown) with roving tabindex, loading skeletons, empty/error states, and a NIP-19 share link for each event. naddr links for kind 31922/31923 now open the Calendar app instead of the Reader. Time-based events (kind 31923) are queried with a relay-side `#D` day-granularity filter; date-based events (kind 31922) have no indexable date field in NIP-52, so they're bounded by `limit` and filtered client-side against the viewed range — documented in useCalendarEvents.ts. Spike decision: built a custom month grid instead of using the existing react-day-picker wrapper or a new calendar library. react-day-picker is built for single/range date selection, not dense per-cell event content or multi-day spanning; a full calendar library would add real weight and its own theming to reconcile with shadcn. A CSS-grid month view is the smallest solution that meets the month-grid, filtering, and accessibility requirements with zero new dependencies. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto * fix: address Copilot review — keyboard trap, midnight formatting, nprofile hint - MonthGrid: exactly one gridcell must stay tab-focusable. When no day is selected and the displayed month doesn't contain today (e.g. after a PageUp/PageDown jump), every cell previously got tabIndex=-1, trapping keyboard users out of the grid. Falls back to the 1st of the month. - calendarEvents: formatEventTimeRange treated `end` as inclusive when checking same-day, so a time-based event ending exactly at local midnight formatted as a cross-day range even though eventDateKeys() attributes it to the start day only. Now compares against end-1ms, consistent with eventDateKeys(). - CalendarFilters: the author placeholder/error text only mentioned npub/hex even though nprofile is accepted (resolveAuthorInput handles it) — updated both to mention all three. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto * fix: address second Copilot review round on Calendar app - MonthGrid: focusKey preferred selectedDate even when it fell outside the currently rendered grid (selectedDate survives month navigation so the agenda keeps showing it, so after Prev/Next/PageUp/PageDown it commonly points at a day no longer on screen) — every cell got tabIndex=-1 again, reopening the keyboard trap fixed last round. Now only trusted when it's actually one of the rendered cells. - MonthGrid: the weekday header row sat as a sibling before the role="grid" element instead of inside it, so assistive tech couldn't associate the columnheaders with the grid. Grid role now wraps both the header row and the week rows. - MonthGrid: the loading skeleton always rendered 35 cells (5 weeks) even for 6-week months, causing a layout jump once the real 42-cell grid replaced it. Now sized from the same buildWeeks() count used for the real grid. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto * fix: guard arrow-key navigation against out-of-grid dates in MonthGrid Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com> --------- Co-authored-by: highperfocused <highperfocused@pm.me> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
127
src/apps/calendar/CalendarFilters.tsx
Normal file
127
src/apps/calendar/CalendarFilters.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import type { CalendarEventTypeFilter } from '@/hooks/useCalendarEvents';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { displayName } from '@/lib/nostrUtils';
|
||||
import { activeFilterCount, DEFAULT_CALENDAR_FILTERS, type CalendarFilterState } from './filterState';
|
||||
|
||||
const EVENT_TYPE_LABELS: Record<CalendarEventTypeFilter, string> = {
|
||||
all: 'All events',
|
||||
date: 'All-day',
|
||||
time: 'Timed',
|
||||
};
|
||||
|
||||
export interface CalendarFiltersProps {
|
||||
filters: CalendarFilterState;
|
||||
onChange: (filters: CalendarFilterState) => void;
|
||||
/** Set when `authorInput` doesn't decode to a usable pubkey. */
|
||||
authorError: boolean;
|
||||
/** The resolved hex pubkey, when `authorInput` is valid — used to show a friendly name in the active-filter chip. */
|
||||
resolvedAuthor: string | undefined;
|
||||
}
|
||||
|
||||
export function CalendarFilters({ filters, onChange, authorError, resolvedAuthor }: CalendarFiltersProps) {
|
||||
const count = activeFilterCount(filters, Boolean(resolvedAuthor));
|
||||
const set = <K extends keyof CalendarFilterState>(key: K, value: CalendarFilterState[K]) =>
|
||||
onChange({ ...filters, [key]: value });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Filters</h2>
|
||||
{count > 0 && (
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-[11px]" onClick={() => onChange(DEFAULT_CALENDAR_FILTERS)}>
|
||||
Clear all
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{count > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{filters.eventType !== 'all' && (
|
||||
<FilterChip label={EVENT_TYPE_LABELS[filters.eventType]} onClear={() => set('eventType', 'all')} />
|
||||
)}
|
||||
{resolvedAuthor && <AuthorFilterChip pubkey={resolvedAuthor} onClear={() => set('authorInput', '')} />}
|
||||
{filters.search.trim() && (
|
||||
<FilterChip label={`"${filters.search.trim()}"`} onClear={() => set('search', '')} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Event type</Label>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
value={filters.eventType}
|
||||
onValueChange={(value) => value && set('eventType', value as CalendarEventTypeFilter)}
|
||||
className="flex w-full"
|
||||
>
|
||||
<ToggleGroupItem value="all" className="flex-1 text-xs">
|
||||
All
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="date" className="flex-1 text-xs">
|
||||
All-day
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="time" className="flex-1 text-xs">
|
||||
Timed
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="calendar-filter-author" className="text-xs text-muted-foreground">
|
||||
Author
|
||||
</Label>
|
||||
<Input
|
||||
id="calendar-filter-author"
|
||||
placeholder="npub, nprofile, or hex pubkey"
|
||||
value={filters.authorInput}
|
||||
onChange={(event) => set('authorInput', event.target.value)}
|
||||
aria-invalid={authorError || undefined}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
{authorError && <p className="text-[11px] text-destructive">Not a valid npub, nprofile, or hex pubkey.</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="calendar-filter-search" className="text-xs text-muted-foreground">
|
||||
Search
|
||||
</Label>
|
||||
<Input
|
||||
id="calendar-filter-search"
|
||||
placeholder="Title or summary"
|
||||
value={filters.search}
|
||||
onChange={(event) => set('search', event.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChip({ label, onClear }: { label: string; onClear: () => void }) {
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1 pr-1 text-[11px] font-normal">
|
||||
<span className="max-w-32 truncate">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="rounded-full p-0.5 hover:bg-muted-foreground/20 focus-visible:outline-2 focus-visible:outline-ring"
|
||||
aria-label={`Clear filter: ${label}`}
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthorFilterChip({ pubkey, onClear }: { pubkey: string; onClear: () => void }) {
|
||||
const author = useAuthor(pubkey);
|
||||
return <FilterChip label={displayName(pubkey, author.data?.metadata)} onClear={onClear} />;
|
||||
}
|
||||
92
src/apps/calendar/DayAgenda.tsx
Normal file
92
src/apps/calendar/DayAgenda.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { CalendarClock, CalendarDays, MapPin } from 'lucide-react';
|
||||
import type { ParsedCalendarEvent } from '@/lib/calendarEvents';
|
||||
import { calendarEventKey } from '@/lib/calendarEvents';
|
||||
import { EmptyState } from '@/components/os/AppChrome';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function agendaTime(event: ParsedCalendarEvent): string {
|
||||
if (event.allDay) return 'All day';
|
||||
return event.start.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
export interface DayAgendaProps {
|
||||
date: Date | undefined;
|
||||
events: ParsedCalendarEvent[];
|
||||
isLoading: boolean;
|
||||
onOpenEvent: (event: ParsedCalendarEvent) => void;
|
||||
}
|
||||
|
||||
export function DayAgenda({ date, events, isLoading, onOpenEvent }: DayAgendaProps) {
|
||||
if (!date) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Pick a day"
|
||||
hint="Select a date on the calendar to see what's happening."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const heading = date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3 p-4">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<h2 className="shrink-0 border-b border-border px-4 py-3 text-sm font-semibold">{heading}</h2>
|
||||
{events.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-xs text-muted-foreground">No events on this day.</p>
|
||||
) : (
|
||||
<ul className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
{events.map((event) => (
|
||||
<li key={calendarEventKey(event)}>
|
||||
<AgendaRow event={event} onSelect={() => onOpenEvent(event)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgendaRow({ event, onSelect }: { event: ParsedCalendarEvent; onSelect: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-2.5 rounded-md p-2 text-left transition-colors',
|
||||
'hover:bg-muted focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full',
|
||||
event.allDay ? 'bg-chart-2/20' : 'bg-primary/15',
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{event.allDay ? <CalendarDays className="size-3.5" /> : <CalendarClock className="size-3.5" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13px] font-medium leading-snug">{event.title}</span>
|
||||
<span className="mt-0.5 block truncate text-[11px] text-muted-foreground">{agendaTime(event)}</span>
|
||||
{event.locations[0] && (
|
||||
<span className="mt-0.5 flex items-center gap-1 truncate text-[11px] text-muted-foreground">
|
||||
<MapPin className="size-3 shrink-0" aria-hidden />
|
||||
<span className="truncate">{event.locations[0]}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
126
src/apps/calendar/EventDetail.tsx
Normal file
126
src/apps/calendar/EventDetail.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { CalendarClock, CalendarDays, Link2, MapPin } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { ParsedCalendarEvent } from '@/lib/calendarEvents';
|
||||
import { formatEventTimeRange } from '@/lib/calendarEvents';
|
||||
import { AuthorLine } from '@/components/nostr/AuthorLine';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useRelayHints } from '@/hooks/useRelayHints';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { sanitizeUrl } from '@/lib/nostrUtils';
|
||||
|
||||
export function EventDetail({ event }: { event: ParsedCalendarEvent }) {
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="mb-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{event.allDay ? <CalendarDays className="size-3.5" aria-hidden /> : <CalendarClock className="size-3.5" aria-hidden />}
|
||||
{formatEventTimeRange(event)}
|
||||
</div>
|
||||
<h1 className="text-lg font-semibold leading-tight">{event.title}</h1>
|
||||
{event.summary && <p className="mt-1 text-sm text-muted-foreground">{event.summary}</p>}
|
||||
|
||||
<div className="mt-3">
|
||||
<AuthorLine pubkey={event.event.pubkey} size="sm" />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<CopyEventLink event={event} />
|
||||
{event.hashtags.map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="text-[11px]">
|
||||
#{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{event.image && (
|
||||
<img
|
||||
src={event.image}
|
||||
alt=""
|
||||
className="mt-4 max-h-64 w-full rounded-lg object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
|
||||
{event.locations.length > 0 && (
|
||||
<div className="mt-4 space-y-1">
|
||||
{event.locations.map((location, index) => {
|
||||
const url = sanitizeUrl(location);
|
||||
return (
|
||||
<div key={index} className="flex items-start gap-1.5 text-sm">
|
||||
<MapPin className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
{url ? (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className="break-words text-primary hover:underline">
|
||||
{location}
|
||||
</a>
|
||||
) : (
|
||||
<span className="break-words">{location}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.description.trim() && (
|
||||
<p className="mt-4 whitespace-pre-wrap text-sm text-foreground/90">{event.description}</p>
|
||||
)}
|
||||
|
||||
{event.references.length > 0 && (
|
||||
<div className="mt-4 space-y-1">
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Links</h2>
|
||||
{event.references.map((url) => (
|
||||
<a
|
||||
key={url}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 truncate text-sm text-primary hover:underline"
|
||||
>
|
||||
<Link2 className="size-3.5 shrink-0" aria-hidden />
|
||||
<span className="truncate">{url}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.participants.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<h2 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Participants</h2>
|
||||
{event.participants.map((participant) => (
|
||||
<AuthorLine key={participant.pubkey} pubkey={participant.pubkey} size="sm" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyEventLink({ event }: { event: ParsedCalendarEvent }) {
|
||||
const hints = useRelayHints();
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2 text-xs"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const naddr = nip19.naddrEncode({
|
||||
pubkey: event.event.pubkey,
|
||||
kind: event.kind,
|
||||
identifier: event.id,
|
||||
relays: hints,
|
||||
});
|
||||
await navigator.clipboard.writeText(`${window.location.origin}/${naddr}`);
|
||||
toast({ title: 'Link copied' });
|
||||
} catch {
|
||||
toast({ title: 'Could not copy the link', variant: 'destructive' });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Link2 className="size-3.5" aria-hidden />
|
||||
Copy link
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
222
src/apps/calendar/MonthGrid.tsx
Normal file
222
src/apps/calendar/MonthGrid.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import type { ParsedCalendarEvent } from '@/lib/calendarEvents';
|
||||
import { localDateKey } from '@/lib/calendarEvents';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { monthGridRange } from './monthGridRange';
|
||||
|
||||
const WEEKDAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
/** How many event pills a cell shows before collapsing into "+N more". */
|
||||
const MAX_VISIBLE_PILLS = 3;
|
||||
|
||||
function buildWeeks(monthAnchor: Date): Date[][] {
|
||||
const { start: gridStart, end: gridEndExclusive } = monthGridRange(monthAnchor);
|
||||
const gridEnd = new Date(gridEndExclusive.getTime() - 1);
|
||||
|
||||
const days: Date[] = [];
|
||||
for (const cursor = new Date(gridStart); cursor <= gridEnd; cursor.setDate(cursor.getDate() + 1)) {
|
||||
days.push(new Date(cursor));
|
||||
}
|
||||
|
||||
const weeks: Date[][] = [];
|
||||
for (let i = 0; i < days.length; i += 7) weeks.push(days.slice(i, i + 7));
|
||||
return weeks;
|
||||
}
|
||||
|
||||
export interface MonthGridProps {
|
||||
/** Any date within the month to display; only the year/month are used. */
|
||||
monthAnchor: Date;
|
||||
today: Date;
|
||||
selectedDate: string | undefined;
|
||||
eventsByDay: Map<string, ParsedCalendarEvent[]>;
|
||||
isLoading: boolean;
|
||||
onSelectDate: (key: string, options?: { focus?: boolean }) => void;
|
||||
onShiftMonth: (delta: number) => void;
|
||||
}
|
||||
|
||||
export function MonthGrid({
|
||||
monthAnchor,
|
||||
today,
|
||||
selectedDate,
|
||||
eventsByDay,
|
||||
isLoading,
|
||||
onSelectDate,
|
||||
onShiftMonth,
|
||||
}: MonthGridProps) {
|
||||
const weeks = useMemo(() => buildWeeks(monthAnchor), [monthAnchor]);
|
||||
const monthIndex = monthAnchor.getMonth();
|
||||
const todayKey = localDateKey(today);
|
||||
const cellRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
|
||||
const inViewKeys = useMemo(() => new Set(weeks.flat().map((date) => localDateKey(date))), [weeks]);
|
||||
|
||||
// Exactly one cell must be tab-focusable, or a keyboard user who tabs away
|
||||
// and back can never re-enter the grid. Prefer the selected day — but only
|
||||
// when it's actually one of the rendered cells: `selectedDate` survives
|
||||
// month navigation (so the agenda can keep showing it), so after Prev/Next/
|
||||
// PageUp/PageDown it commonly points outside the newly displayed grid.
|
||||
// Otherwise prefer today if it's in the displayed month, and only
|
||||
// otherwise fall back to the 1st.
|
||||
const selectedInView = selectedDate ? inViewKeys.has(selectedDate) : false;
|
||||
const isTodayInMonth = today.getFullYear() === monthAnchor.getFullYear() && today.getMonth() === monthIndex;
|
||||
const focusKey = selectedInView
|
||||
? selectedDate!
|
||||
: isTodayInMonth
|
||||
? todayKey
|
||||
: localDateKey(new Date(monthAnchor.getFullYear(), monthIndex, 1));
|
||||
|
||||
const focusDate = (date: Date) => {
|
||||
const key = localDateKey(date);
|
||||
// Arrow-key navigation can compute a date outside the currently rendered
|
||||
// grid (e.g. ArrowRight from the last cell); bail out rather than
|
||||
// selecting a cell with no matching ref, which would drop focus entirely.
|
||||
if (!inViewKeys.has(key)) return;
|
||||
onSelectDate(key, { focus: true });
|
||||
requestAnimationFrame(() => cellRefs.current.get(key)?.focus());
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent, date: Date) => {
|
||||
const deltas: Record<string, number> = {
|
||||
ArrowLeft: -1,
|
||||
ArrowRight: 1,
|
||||
ArrowUp: -7,
|
||||
ArrowDown: 7,
|
||||
};
|
||||
if (event.key in deltas) {
|
||||
event.preventDefault();
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() + deltas[event.key]);
|
||||
focusDate(next);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Home') {
|
||||
event.preventDefault();
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() - date.getDay());
|
||||
focusDate(next);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'End') {
|
||||
event.preventDefault();
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() + (6 - date.getDay()));
|
||||
focusDate(next);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'PageUp') {
|
||||
event.preventDefault();
|
||||
onShiftMonth(event.shiftKey ? -12 : -1);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'PageDown') {
|
||||
event.preventDefault();
|
||||
onShiftMonth(event.shiftKey ? 12 : 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div role="grid" aria-label="Month" className="flex h-full min-h-0 flex-col" aria-hidden={isLoading || undefined}>
|
||||
<div
|
||||
role="row"
|
||||
className="grid grid-cols-7 border-b border-border text-center text-[11px] font-medium text-muted-foreground"
|
||||
>
|
||||
{WEEKDAY_LABELS.map((label) => (
|
||||
<div key={label} role="columnheader" className="py-1.5" aria-label={label}>
|
||||
<span aria-hidden>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid flex-1 auto-rows-fr grid-cols-7 divide-x divide-y divide-border">
|
||||
{isLoading
|
||||
? Array.from({ length: weeks.length * 7 }).map((_, index) => (
|
||||
<div key={index} className="p-1.5">
|
||||
<Skeleton className="h-full w-full" />
|
||||
</div>
|
||||
))
|
||||
: weeks.map((week, weekIndex) => (
|
||||
<div key={weekIndex} role="row" className="col-span-7 grid grid-cols-7">
|
||||
{week.map((date) => {
|
||||
const key = localDateKey(date);
|
||||
const events = eventsByDay.get(key) ?? [];
|
||||
const isCurrentMonth = date.getMonth() === monthIndex;
|
||||
const isToday = key === todayKey;
|
||||
const isSelected = key === selectedDate;
|
||||
const isFocusable = key === focusKey;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
ref={(el) => {
|
||||
if (el) cellRefs.current.set(key, el);
|
||||
else cellRefs.current.delete(key);
|
||||
}}
|
||||
type="button"
|
||||
role="gridcell"
|
||||
aria-selected={isSelected}
|
||||
aria-current={isToday ? 'date' : undefined}
|
||||
aria-label={`${date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}${events.length ? `, ${events.length} ${events.length === 1 ? 'event' : 'events'}` : ', no events'}`}
|
||||
tabIndex={isFocusable ? 0 : -1}
|
||||
onClick={() => onSelectDate(key)}
|
||||
onKeyDown={(event) => handleKeyDown(event, date)}
|
||||
className={cn(
|
||||
'flex min-h-16 flex-col items-stretch gap-1 p-1.5 text-left transition-colors sm:min-h-24',
|
||||
'focus-visible:relative focus-visible:z-10 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring',
|
||||
isCurrentMonth ? 'bg-background hover:bg-muted/60' : 'bg-muted/30 text-muted-foreground hover:bg-muted/50',
|
||||
isSelected && 'bg-accent hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-6 shrink-0 items-center justify-center rounded-full text-[12px] font-medium',
|
||||
isToday && 'bg-primary text-primary-foreground',
|
||||
)}
|
||||
>
|
||||
{date.getDate()}
|
||||
</span>
|
||||
|
||||
<span className="flex flex-1 flex-col gap-0.5 overflow-hidden">
|
||||
{events.slice(0, MAX_VISIBLE_PILLS).map((calEvent) => (
|
||||
<EventPill key={`${calEvent.kind}:${calEvent.event.pubkey}:${calEvent.id}`} event={calEvent} />
|
||||
))}
|
||||
{events.length > MAX_VISIBLE_PILLS && (
|
||||
<span className="hidden truncate text-[10px] font-medium text-muted-foreground sm:block">
|
||||
+{events.length - MAX_VISIBLE_PILLS} more
|
||||
</span>
|
||||
)}
|
||||
{events.length > 0 && (
|
||||
<span className="mt-auto flex items-center gap-0.5 sm:hidden" aria-hidden>
|
||||
{events.slice(0, MAX_VISIBLE_PILLS).map((calEvent, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
'size-1.5 rounded-full',
|
||||
calEvent.allDay ? 'bg-chart-2' : 'bg-primary',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Purely a visual summary — opening an event happens from the day agenda, which has real buttons instead of interactive elements nested inside this cell's `<button>`. */
|
||||
function EventPill({ event }: { event: ParsedCalendarEvent }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'hidden truncate rounded px-1 py-0.5 text-[10px] font-medium leading-tight sm:block',
|
||||
event.allDay ? 'bg-chart-2/20 text-foreground' : 'bg-primary/15 text-foreground',
|
||||
)}
|
||||
>
|
||||
{event.title}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
28
src/apps/calendar/filterState.ts
Normal file
28
src/apps/calendar/filterState.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { CalendarEventTypeFilter } from '@/hooks/useCalendarEvents';
|
||||
|
||||
export interface CalendarFilterState {
|
||||
eventType: CalendarEventTypeFilter;
|
||||
/** Raw user input — an npub, nprofile, hex pubkey, or invalid text. */
|
||||
authorInput: string;
|
||||
search: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_CALENDAR_FILTERS: CalendarFilterState = {
|
||||
eventType: 'all',
|
||||
authorInput: '',
|
||||
search: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Number of filters actually narrowing the result set. An author input that
|
||||
* hasn't resolved to a usable pubkey has no effect on the query — it's
|
||||
* reported inline as a validation error, not counted or chipped as active —
|
||||
* so callers pass whether it currently resolves.
|
||||
*/
|
||||
export function activeFilterCount(filters: CalendarFilterState, authorResolved: boolean): number {
|
||||
let count = 0;
|
||||
if (filters.eventType !== 'all') count++;
|
||||
if (authorResolved) count++;
|
||||
if (filters.search.trim()) count++;
|
||||
return count;
|
||||
}
|
||||
305
src/apps/calendar/index.tsx
Normal file
305
src/apps/calendar/index.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { AlertCircle, ChevronLeft, ChevronRight, SlidersHorizontal } from 'lucide-react';
|
||||
import {
|
||||
AppBody,
|
||||
AppLayout,
|
||||
AppSidebar,
|
||||
AppSplit,
|
||||
AppToolbar,
|
||||
EmptyState,
|
||||
} from '@/components/os/AppChrome';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { CalendarFilters } from './CalendarFilters';
|
||||
import { DayAgenda } from './DayAgenda';
|
||||
import { EventDetail } from './EventDetail';
|
||||
import { activeFilterCount, DEFAULT_CALENDAR_FILTERS, type CalendarFilterState } from './filterState';
|
||||
import { MonthGrid } from './MonthGrid';
|
||||
import { monthGridRange } from './monthGridRange';
|
||||
import {
|
||||
useCalendarEvent,
|
||||
useCalendarEvents,
|
||||
useEventsByDay,
|
||||
useSearchedCalendarEvents,
|
||||
} from '@/hooks/useCalendarEvents';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { localDateKey, type ParsedCalendarEvent } from '@/lib/calendarEvents';
|
||||
import { decodeRelayHints } from '@/lib/nostrUtils';
|
||||
import type { AppParams, AppProps } from '@/os/types';
|
||||
|
||||
const MONTH_PARAM_RE = /^(\d{4})-(\d{2})$/;
|
||||
|
||||
function startOfMonth(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), 1);
|
||||
}
|
||||
|
||||
function parseMonthParam(value: string | undefined): Date | undefined {
|
||||
const match = value ? MONTH_PARAM_RE.exec(value) : null;
|
||||
if (!match) return undefined;
|
||||
const date = new Date(Number(match[1]), Number(match[2]) - 1, 1);
|
||||
return Number.isNaN(date.getTime()) ? undefined : date;
|
||||
}
|
||||
|
||||
function monthParamValue(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** A hex pubkey, or an npub/nprofile decoded down to one — anything else is not a usable author filter. */
|
||||
function resolveAuthorInput(raw: string): string | undefined {
|
||||
const value = raw.trim();
|
||||
if (!value) return undefined;
|
||||
if (/^[0-9a-f]{64}$/i.test(value)) return value.toLowerCase();
|
||||
try {
|
||||
const decoded = nip19.decode(value);
|
||||
if (decoded.type === 'npub') return decoded.data;
|
||||
if (decoded.type === 'nprofile') return decoded.data.pubkey;
|
||||
} catch {
|
||||
// fall through to "invalid"
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function withoutKeys(params: AppParams, keys: string[]): AppParams {
|
||||
const next = { ...params };
|
||||
for (const key of keys) delete next[key];
|
||||
return next;
|
||||
}
|
||||
|
||||
export default function CalendarApp({ params, setTitle, setParams }: AppProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const [filters, setFilters] = useState<CalendarFilterState>(DEFAULT_CALENDAR_FILTERS);
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
const today = useMemo(() => new Date(), []);
|
||||
|
||||
const monthAnchor = useMemo(() => parseMonthParam(params.month) ?? startOfMonth(today), [params.month, today]);
|
||||
const selectedDateKey = params.day;
|
||||
const { start: rangeStart, end: rangeEnd } = useMemo(() => monthGridRange(monthAnchor), [monthAnchor]);
|
||||
|
||||
const resolvedAuthor = useMemo(() => resolveAuthorInput(filters.authorInput), [filters.authorInput]);
|
||||
const authorError = filters.authorInput.trim().length > 0 && !resolvedAuthor;
|
||||
|
||||
const eventsQuery = useCalendarEvents(rangeStart, rangeEnd, {
|
||||
eventType: filters.eventType,
|
||||
authors: resolvedAuthor ? [resolvedAuthor] : undefined,
|
||||
});
|
||||
const searchedEvents = useSearchedCalendarEvents(eventsQuery.data, filters.search);
|
||||
const eventsByDay = useEventsByDay(searchedEvents);
|
||||
|
||||
const selectedDayEvents = selectedDateKey ? (eventsByDay.get(selectedDateKey) ?? []) : [];
|
||||
const selectedDate = selectedDateKey ? dateFromKey(selectedDateKey) : undefined;
|
||||
|
||||
// The deep-linked/opened event: fetched directly by coordinates so a
|
||||
// shared link resolves even when the event falls outside the currently
|
||||
// loaded month.
|
||||
const openEventKind = params.kind ? Number(params.kind) : undefined;
|
||||
const detailQuery = useCalendarEvent(params.pubkey, openEventKind, params.identifier, decodeRelayHints(params.relays));
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(detailQuery.data ? `Calendar — ${detailQuery.data.title}` : 'Calendar');
|
||||
}, [detailQuery.data, setTitle]);
|
||||
|
||||
// A fresh naddr deep link boots with no `month`/`day` yet — once the event
|
||||
// resolves, jump the grid to it once, so "back" from the detail view lands
|
||||
// somewhere relevant instead of the current month.
|
||||
const didInitFromDeepLink = useRef(false);
|
||||
useEffect(() => {
|
||||
if (didInitFromDeepLink.current) return;
|
||||
if (!detailQuery.data || params.month) return;
|
||||
didInitFromDeepLink.current = true;
|
||||
setParams({
|
||||
...params,
|
||||
month: monthParamValue(detailQuery.data.start),
|
||||
day: localDateKey(detailQuery.data.start),
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [detailQuery.data]);
|
||||
|
||||
const goToMonth = (date: Date) => setParams({ ...params, month: monthParamValue(date) });
|
||||
const shiftMonth = (delta: number) => {
|
||||
const next = new Date(monthAnchor);
|
||||
next.setMonth(next.getMonth() + delta);
|
||||
goToMonth(next);
|
||||
};
|
||||
const goToToday = () =>
|
||||
setParams({ ...withoutKeys(params, ['pubkey', 'kind', 'identifier', 'relays']), month: monthParamValue(today), day: localDateKey(today) });
|
||||
|
||||
const selectDate = (key: string) => setParams({ ...params, day: key });
|
||||
const openEvent = (event: ParsedCalendarEvent) =>
|
||||
setParams({ ...params, pubkey: event.event.pubkey, kind: String(event.kind), identifier: event.id });
|
||||
const closeEvent = () => setParams(withoutKeys(params, ['pubkey', 'kind', 'identifier', 'relays']));
|
||||
const closeDay = () => setParams(withoutKeys(params, ['day', 'pubkey', 'kind', 'identifier', 'relays']));
|
||||
|
||||
const monthLabel = monthAnchor.toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
|
||||
const filterCount = activeFilterCount(filters, Boolean(resolvedAuthor));
|
||||
const showingEmptyState =
|
||||
!eventsQuery.isLoading && !eventsQuery.isError && (eventsQuery.data?.length ?? 0) === 0;
|
||||
const showingFilteredEmpty =
|
||||
!eventsQuery.isLoading &&
|
||||
!eventsQuery.isError &&
|
||||
(eventsQuery.data?.length ?? 0) > 0 &&
|
||||
(searchedEvents?.length ?? 0) === 0;
|
||||
|
||||
const monthNav = (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="icon" className="size-7" onClick={() => shiftMonth(-1)} aria-label="Previous month">
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<span className="min-w-28 text-center text-[13px] font-medium">{monthLabel}</span>
|
||||
<Button variant="ghost" size="icon" className="size-7" onClick={() => shiftMonth(1)} aria-label="Next month">
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="ml-1 h-7 px-2 text-xs" onClick={goToToday}>
|
||||
Today
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const grid = eventsQuery.isError ? (
|
||||
<QueryErrorState onRetry={() => eventsQuery.refetch()} />
|
||||
) : (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{(showingEmptyState || showingFilteredEmpty) && (
|
||||
<p className="shrink-0 border-b border-border bg-muted/40 px-3 py-1.5 text-center text-xs text-muted-foreground">
|
||||
{showingFilteredEmpty ? 'No events match your filters.' : 'No calendar events found on your relays for this month.'}
|
||||
</p>
|
||||
)}
|
||||
<MonthGrid
|
||||
monthAnchor={monthAnchor}
|
||||
today={today}
|
||||
selectedDate={selectedDateKey}
|
||||
eventsByDay={eventsByDay}
|
||||
isLoading={eventsQuery.isLoading}
|
||||
onSelectDate={selectDate}
|
||||
onShiftMonth={shiftMonth}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const detailPane = detailQuery.isLoading ? (
|
||||
<div className="space-y-4 p-5">
|
||||
<Skeleton className="h-6 w-2/3" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
</div>
|
||||
) : detailQuery.data ? (
|
||||
<EventDetail event={detailQuery.data} />
|
||||
) : (
|
||||
<EmptyState title="Event not found" hint="None of your relays returned this calendar event." />
|
||||
);
|
||||
|
||||
const agendaPane = params.pubkey ? (
|
||||
detailPane
|
||||
) : (
|
||||
<DayAgenda date={selectedDate} events={selectedDayEvents} isLoading={eventsQuery.isLoading} onOpenEvent={openEvent} />
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
const level = params.pubkey ? 'detail' : selectedDateKey ? 'agenda' : 'grid';
|
||||
return (
|
||||
<AppLayout>
|
||||
<AppToolbar>
|
||||
{level === 'grid' ? (
|
||||
monthNav
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={level === 'detail' ? closeEvent : closeDay}
|
||||
className="-ml-1 flex items-center gap-1 rounded px-1 py-0.5 text-[13px] font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
||||
>
|
||||
<ChevronLeft className="size-4" aria-hidden />
|
||||
{level === 'detail' ? 'Day' : 'Calendar'}
|
||||
</button>
|
||||
)}
|
||||
{level === 'grid' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative ml-auto size-7"
|
||||
onClick={() => setMobileFiltersOpen(true)}
|
||||
aria-label={`Filters${filterCount > 0 ? ` (${filterCount} active)` : ''}`}
|
||||
>
|
||||
<SlidersHorizontal className="size-4" />
|
||||
{filterCount > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex size-3.5 items-center justify-center rounded-full bg-primary text-[9px] font-semibold text-primary-foreground">
|
||||
{filterCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</AppToolbar>
|
||||
<AppBody className={level === 'grid' ? 'overflow-hidden' : undefined}>
|
||||
{level === 'grid' ? grid : agendaPane}
|
||||
</AppBody>
|
||||
|
||||
<Sheet open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}>
|
||||
<SheetContent side="bottom" className="max-h-[80vh] overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Filters</SheetTitle>
|
||||
<SheetDescription>Narrow down which calendar events are shown.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<CalendarFilters filters={filters} onChange={setFilters} authorError={authorError} resolvedAuthor={resolvedAuthor} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<AppToolbar>
|
||||
{monthNav}
|
||||
<span className="ml-auto text-[11px] text-muted-foreground" aria-live="polite">
|
||||
{eventsQuery.isFetching && !eventsQuery.isLoading ? 'Refreshing…' : null}
|
||||
</span>
|
||||
</AppToolbar>
|
||||
<AppSplit>
|
||||
<AppSidebar className="p-0">
|
||||
<CalendarFilters filters={filters} onChange={setFilters} authorError={authorError} resolvedAuthor={resolvedAuthor} />
|
||||
</AppSidebar>
|
||||
<div className="min-h-0 flex-1">{grid}</div>
|
||||
<aside className="os-scroll w-72 shrink-0 overflow-y-auto border-l border-border">
|
||||
{params.pubkey && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeEvent}
|
||||
className="flex items-center gap-1 border-b border-border px-2 py-1.5 text-[12px] font-medium text-muted-foreground hover:text-foreground focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring"
|
||||
>
|
||||
<ChevronLeft className="size-3.5" aria-hidden />
|
||||
Back to day
|
||||
</button>
|
||||
)}
|
||||
{agendaPane}
|
||||
</aside>
|
||||
</AppSplit>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function dateFromKey(key: string): Date | undefined {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(key);
|
||||
if (!match) return undefined;
|
||||
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
||||
}
|
||||
|
||||
function QueryErrorState({ onRetry }: { onRetry: () => void }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<AlertCircle className="size-8 text-destructive" aria-hidden />
|
||||
<p className="text-sm font-medium">Couldn't load calendar events</p>
|
||||
<p className="max-w-xs text-sm text-muted-foreground">
|
||||
None of your relays responded in time. Check your connection and try again.
|
||||
</p>
|
||||
<Button size="sm" onClick={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
src/apps/calendar/monthGridRange.ts
Normal file
15
src/apps/calendar/monthGridRange.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* The inclusive/exclusive date range the grid actually renders for
|
||||
* `monthAnchor`, including the leading/trailing days of adjacent months that
|
||||
* complete the first and last week rows. Exported so the data layer can
|
||||
* request exactly what's on screen — no more, no less.
|
||||
*/
|
||||
export function monthGridRange(monthAnchor: Date): { start: Date; end: Date } {
|
||||
const year = monthAnchor.getFullYear();
|
||||
const month = monthAnchor.getMonth();
|
||||
const firstOfMonth = new Date(year, month, 1);
|
||||
const lastOfMonth = new Date(year, month + 1, 0);
|
||||
const start = new Date(year, month, 1 - firstOfMonth.getDay());
|
||||
const end = new Date(year, month, lastOfMonth.getDate() + (6 - lastOfMonth.getDay()) + 1);
|
||||
return { start, end };
|
||||
}
|
||||
159
src/hooks/useCalendarEvents.ts
Normal file
159
src/hooks/useCalendarEvents.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
import {
|
||||
type CalendarEventKind,
|
||||
type ParsedCalendarEvent,
|
||||
DATE_BASED_KIND,
|
||||
TIME_BASED_KIND,
|
||||
dayGranularityRange,
|
||||
dedupeLatestCalendarEvents,
|
||||
eventDateKeys,
|
||||
isCalendarEventKind,
|
||||
parseCalendarEvent,
|
||||
} from '@/lib/calendarEvents';
|
||||
|
||||
export type CalendarEventTypeFilter = 'all' | 'date' | 'time';
|
||||
|
||||
export interface CalendarQueryFilters {
|
||||
eventType: CalendarEventTypeFilter;
|
||||
/** Hex pubkeys. Empty/undefined means no author constraint. */
|
||||
authors?: string[];
|
||||
}
|
||||
|
||||
function kindsForFilter(eventType: CalendarEventTypeFilter): CalendarEventKind[] {
|
||||
if (eventType === 'date') return [DATE_BASED_KIND];
|
||||
if (eventType === 'time') return [TIME_BASED_KIND];
|
||||
return [DATE_BASED_KIND, TIME_BASED_KIND];
|
||||
}
|
||||
|
||||
const QUERY_TIMEOUT_MS = 8000;
|
||||
/** Per-kind cap. Generous enough for a padded month window without being unbounded. */
|
||||
const EVENT_LIMIT = 400;
|
||||
|
||||
/**
|
||||
* Fetches NIP-52 calendar events overlapping `[rangeStart, rangeEnd)` (a
|
||||
* padded window around the viewed month, wide enough to cover multi-day
|
||||
* events and the leading/trailing adjacent-month days the grid shows).
|
||||
*
|
||||
* Time-based events (kind 31923) carry a day-granularity `D` tag, which is a
|
||||
* single-letter — and therefore relay-indexable — tag, so the range is sent
|
||||
* as a `#D` constraint. Date-based events (kind 31922) have no indexable
|
||||
* date field in NIP-52; those are bounded only by `limit` and then filtered
|
||||
* client-side against the range. This is a known limitation of the NIP
|
||||
* itself, not of this query.
|
||||
*
|
||||
* Free-text search is intentionally not a parameter here: it's applied
|
||||
* client-side by the caller so that typing in a search box never triggers a
|
||||
* refetch.
|
||||
*/
|
||||
export function useCalendarEvents(rangeStart: Date, rangeEnd: Date, filters: CalendarQueryFilters) {
|
||||
const { nostr } = useNostr();
|
||||
const kinds = kindsForFilter(filters.eventType);
|
||||
const authors = filters.authors && filters.authors.length > 0 ? filters.authors : undefined;
|
||||
|
||||
const queryKey = [
|
||||
'nostr',
|
||||
'calendar-events',
|
||||
kinds.join(','),
|
||||
authors?.join(',') ?? '',
|
||||
rangeStart.getTime(),
|
||||
rangeEnd.getTime(),
|
||||
] as const;
|
||||
|
||||
const query = useQuery<ParsedCalendarEvent[]>({
|
||||
queryKey,
|
||||
queryFn: async ({ signal }) => {
|
||||
const nostrFilters: NostrFilter[] = [];
|
||||
if (kinds.includes(TIME_BASED_KIND)) {
|
||||
nostrFilters.push({
|
||||
kinds: [TIME_BASED_KIND],
|
||||
...(authors ? { authors } : {}),
|
||||
'#D': dayGranularityRange(rangeStart, rangeEnd),
|
||||
limit: EVENT_LIMIT,
|
||||
});
|
||||
}
|
||||
if (kinds.includes(DATE_BASED_KIND)) {
|
||||
nostrFilters.push({
|
||||
kinds: [DATE_BASED_KIND],
|
||||
...(authors ? { authors } : {}),
|
||||
limit: EVENT_LIMIT,
|
||||
});
|
||||
}
|
||||
|
||||
const events = await nostr.query(nostrFilters, {
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(QUERY_TIMEOUT_MS)]),
|
||||
});
|
||||
|
||||
const parsed = events
|
||||
.filter((event): event is NostrEvent & { kind: CalendarEventKind } => isCalendarEventKind(event.kind))
|
||||
.map(parseCalendarEvent)
|
||||
.filter((event): event is ParsedCalendarEvent => event !== null);
|
||||
|
||||
const deduped = dedupeLatestCalendarEvents(parsed);
|
||||
|
||||
// Relay-side #D narrows time-based events but can't bound date-based
|
||||
// ones, so every event is re-checked against the exact window here.
|
||||
return deduped.filter((event) => {
|
||||
return event.start.getTime() < rangeEnd.getTime() && event.end.getTime() > rangeStart.getTime();
|
||||
});
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/** Free-text match against title/summary, applied client-side to a bounded result set. */
|
||||
export function useSearchedCalendarEvents(events: ParsedCalendarEvent[] | undefined, search: string) {
|
||||
return useMemo(() => {
|
||||
if (!events) return events;
|
||||
const term = search.trim().toLowerCase();
|
||||
if (!term) return events;
|
||||
return events.filter(
|
||||
(event) => event.title.toLowerCase().includes(term) || event.summary?.toLowerCase().includes(term),
|
||||
);
|
||||
}, [events, search]);
|
||||
}
|
||||
|
||||
/** Maps each calendar-day key to the events occurring on it, sorted by start time. */
|
||||
export function useEventsByDay(events: ParsedCalendarEvent[] | undefined) {
|
||||
return useMemo(() => {
|
||||
const byDay = new Map<string, ParsedCalendarEvent[]>();
|
||||
for (const event of events ?? []) {
|
||||
for (const key of eventDateKeys(event)) {
|
||||
const list = byDay.get(key);
|
||||
if (list) list.push(event);
|
||||
else byDay.set(key, [event]);
|
||||
}
|
||||
}
|
||||
for (const list of byDay.values()) {
|
||||
list.sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||
}
|
||||
return byDay;
|
||||
}, [events]);
|
||||
}
|
||||
|
||||
/** Fetches one calendar event by its addressable coordinates, e.g. for a deep link. */
|
||||
export function useCalendarEvent(
|
||||
pubkey: string | undefined,
|
||||
kind: number | undefined,
|
||||
identifier: string | undefined,
|
||||
relays: string[] | undefined,
|
||||
) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery<ParsedCalendarEvent | null>({
|
||||
queryKey: ['nostr', 'calendar-event', pubkey ?? '', kind ?? 0, identifier ?? '', relays?.join(',') ?? ''],
|
||||
enabled: Boolean(pubkey && identifier !== undefined && kind !== undefined && isCalendarEventKind(kind)),
|
||||
queryFn: async ({ signal }) => {
|
||||
const [event] = await nostr.query(
|
||||
[{ kinds: [kind!], authors: [pubkey!], '#d': [identifier!], limit: 1 }],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(QUERY_TIMEOUT_MS)]), relays },
|
||||
);
|
||||
return event ? parseCalendarEvent(event) : null;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
223
src/lib/calendarEvents.test.ts
Normal file
223
src/lib/calendarEvents.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import {
|
||||
DATE_BASED_KIND,
|
||||
TIME_BASED_KIND,
|
||||
calendarEventKey,
|
||||
dayGranularity,
|
||||
dayGranularityRange,
|
||||
dedupeLatestCalendarEvents,
|
||||
eventDateKeys,
|
||||
formatEventTimeRange,
|
||||
isValidCalendarEvent,
|
||||
parseCalendarEvent,
|
||||
} from './calendarEvents';
|
||||
|
||||
function makeEvent(overrides: Partial<NostrEvent> & { tags: string[][] }): NostrEvent {
|
||||
return {
|
||||
id: 'id',
|
||||
pubkey: 'pubkey',
|
||||
created_at: 1_700_000_000,
|
||||
kind: DATE_BASED_KIND,
|
||||
content: '',
|
||||
sig: 'sig',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('parseCalendarEvent', () => {
|
||||
it('parses a valid date-based event', () => {
|
||||
const event = makeEvent({
|
||||
kind: DATE_BASED_KIND,
|
||||
tags: [
|
||||
['d', 'abc'],
|
||||
['title', 'Company retreat'],
|
||||
['start', '2026-06-01'],
|
||||
['end', '2026-06-03'],
|
||||
],
|
||||
});
|
||||
|
||||
const parsed = parseCalendarEvent(event);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.allDay).toBe(true);
|
||||
expect(parsed?.title).toBe('Company retreat');
|
||||
expect(parsed?.start.toISOString()).toBe('2026-06-01T00:00:00.000Z');
|
||||
expect(parsed?.end.toISOString()).toBe('2026-06-03T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('parses a valid time-based event', () => {
|
||||
const event = makeEvent({
|
||||
kind: TIME_BASED_KIND,
|
||||
tags: [
|
||||
['d', 'xyz'],
|
||||
['title', 'Standup'],
|
||||
['start', '1700000000'],
|
||||
['end', '1700003600'],
|
||||
],
|
||||
});
|
||||
|
||||
const parsed = parseCalendarEvent(event);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.allDay).toBe(false);
|
||||
expect(parsed?.start.getTime()).toBe(1_700_000_000_000);
|
||||
expect(parsed?.end.getTime()).toBe(1_700_003_600_000);
|
||||
});
|
||||
|
||||
it('falls back to the deprecated name tag when title is missing', () => {
|
||||
const event = makeEvent({
|
||||
tags: [
|
||||
['d', 'abc'],
|
||||
['name', 'Legacy title'],
|
||||
['start', '2026-06-01'],
|
||||
],
|
||||
});
|
||||
expect(parseCalendarEvent(event)?.title).toBe('Legacy title');
|
||||
});
|
||||
|
||||
it('defaults a same-day end for date-based events with no end tag', () => {
|
||||
const event = makeEvent({
|
||||
tags: [
|
||||
['d', 'abc'],
|
||||
['title', 'One day'],
|
||||
['start', '2026-06-01'],
|
||||
],
|
||||
});
|
||||
const parsed = parseCalendarEvent(event);
|
||||
expect(parsed?.end.toISOString()).toBe('2026-06-02T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('ignores an end tag that is not after start', () => {
|
||||
const event = makeEvent({
|
||||
kind: TIME_BASED_KIND,
|
||||
tags: [
|
||||
['d', 'abc'],
|
||||
['title', 'Bad end'],
|
||||
['start', '1700000000'],
|
||||
['end', '1699999999'],
|
||||
],
|
||||
});
|
||||
const parsed = parseCalendarEvent(event);
|
||||
expect(parsed?.end.getTime()).toBe(parsed?.start.getTime());
|
||||
});
|
||||
|
||||
it('rejects events missing required tags', () => {
|
||||
expect(parseCalendarEvent(makeEvent({ tags: [['title', 'No id']] }))).toBeNull();
|
||||
expect(parseCalendarEvent(makeEvent({ tags: [['d', 'abc']] }))).toBeNull();
|
||||
expect(parseCalendarEvent(makeEvent({ tags: [['d', 'abc'], ['title', 'No start']] }))).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a malformed start date', () => {
|
||||
const event = makeEvent({ tags: [['d', 'abc'], ['title', 'Bad date'], ['start', 'not-a-date']] });
|
||||
expect(parseCalendarEvent(event)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects non-calendar kinds', () => {
|
||||
const event = makeEvent({ kind: 1, tags: [['d', 'abc'], ['title', 'Note'], ['start', '2026-06-01']] });
|
||||
expect(parseCalendarEvent(event)).toBeNull();
|
||||
});
|
||||
|
||||
it('isValidCalendarEvent mirrors parseCalendarEvent success', () => {
|
||||
const valid = makeEvent({ tags: [['d', 'abc'], ['title', 'Ok'], ['start', '2026-06-01']] });
|
||||
const invalid = makeEvent({ tags: [['title', 'No id']] });
|
||||
expect(isValidCalendarEvent(valid)).toBe(true);
|
||||
expect(isValidCalendarEvent(invalid)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('eventDateKeys', () => {
|
||||
it('covers each day of a multi-day all-day event', () => {
|
||||
const parsed = parseCalendarEvent(
|
||||
makeEvent({ tags: [['d', 'a'], ['title', 'Trip'], ['start', '2026-06-01'], ['end', '2026-06-04']] }),
|
||||
)!;
|
||||
expect(eventDateKeys(parsed)).toEqual(['2026-06-01', '2026-06-02', '2026-06-03']);
|
||||
});
|
||||
|
||||
it('produces a single key for an instantaneous time-based event', () => {
|
||||
const parsed = parseCalendarEvent(
|
||||
makeEvent({ kind: TIME_BASED_KIND, tags: [['d', 'a'], ['title', 'Ping'], ['start', '1700000000']] }),
|
||||
)!;
|
||||
expect(eventDateKeys(parsed)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dayGranularity / dayGranularityRange', () => {
|
||||
it('computes floor(unix_seconds / 86400)', () => {
|
||||
expect(dayGranularity(new Date(1_700_000_000_000))).toBe(String(Math.floor(1_700_000_000 / 86400)));
|
||||
});
|
||||
|
||||
it('produces one value per day in the range', () => {
|
||||
const from = new Date('2026-06-01T00:00:00Z');
|
||||
const to = new Date('2026-06-04T00:00:00Z');
|
||||
expect(dayGranularityRange(from, to)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dedupeLatestCalendarEvents', () => {
|
||||
it('keeps only the newest revision per (kind, pubkey, d)', () => {
|
||||
const older = parseCalendarEvent(
|
||||
makeEvent({ created_at: 100, tags: [['d', 'a'], ['title', 'Old'], ['start', '2026-06-01']] }),
|
||||
)!;
|
||||
const newer = parseCalendarEvent(
|
||||
makeEvent({ created_at: 200, tags: [['d', 'a'], ['title', 'New'], ['start', '2026-06-01']] }),
|
||||
)!;
|
||||
const result = dedupeLatestCalendarEvents([older, newer]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].title).toBe('New');
|
||||
});
|
||||
|
||||
it('keeps events from different authors with the same d tag separate', () => {
|
||||
const a = parseCalendarEvent(
|
||||
makeEvent({ pubkey: 'alice', tags: [['d', 'a'], ['title', 'Alice event'], ['start', '2026-06-01']] }),
|
||||
)!;
|
||||
const b = parseCalendarEvent(
|
||||
makeEvent({ pubkey: 'bob', tags: [['d', 'a'], ['title', 'Bob event'], ['start', '2026-06-01']] }),
|
||||
)!;
|
||||
expect(dedupeLatestCalendarEvents([a, b])).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendarEventKey', () => {
|
||||
it('combines kind, pubkey and d tag', () => {
|
||||
const parsed = parseCalendarEvent(
|
||||
makeEvent({ pubkey: 'alice', kind: DATE_BASED_KIND, tags: [['d', 'a'], ['title', 'T'], ['start', '2026-06-01']] }),
|
||||
)!;
|
||||
expect(calendarEventKey(parsed)).toBe(`${DATE_BASED_KIND}:alice:a`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatEventTimeRange', () => {
|
||||
it('formats a single all-day event without a range', () => {
|
||||
const parsed = parseCalendarEvent(
|
||||
makeEvent({ tags: [['d', 'a'], ['title', 'T'], ['start', '2026-06-01']] }),
|
||||
)!;
|
||||
expect(formatEventTimeRange(parsed)).not.toContain('–');
|
||||
});
|
||||
|
||||
it('formats a multi-day all-day event as a range', () => {
|
||||
const parsed = parseCalendarEvent(
|
||||
makeEvent({ tags: [['d', 'a'], ['title', 'T'], ['start', '2026-06-01'], ['end', '2026-06-04']] }),
|
||||
)!;
|
||||
expect(formatEventTimeRange(parsed)).toContain('–');
|
||||
});
|
||||
|
||||
it('treats a time-based event ending exactly at local midnight as ending on the start day, not the next', () => {
|
||||
// `end` is exclusive, so this instant belongs to the start day, same as eventDateKeys() would attribute it.
|
||||
const start = new Date(2026, 5, 1, 23, 0, 0);
|
||||
const end = new Date(2026, 5, 2, 0, 0, 0);
|
||||
const parsed = parseCalendarEvent(
|
||||
makeEvent({
|
||||
kind: TIME_BASED_KIND,
|
||||
tags: [
|
||||
['d', 'a'],
|
||||
['title', 'T'],
|
||||
['start', String(Math.floor(start.getTime() / 1000))],
|
||||
['end', String(Math.floor(end.getTime() / 1000))],
|
||||
],
|
||||
}),
|
||||
)!;
|
||||
|
||||
const startDate = start.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
const expected = `${startDate}, ${start.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })} – ${end.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}`;
|
||||
expect(formatEventTimeRange(parsed)).toBe(expected);
|
||||
});
|
||||
});
|
||||
242
src/lib/calendarEvents.ts
Normal file
242
src/lib/calendarEvents.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { sanitizeUrl, tagValue, tagValues } from '@/lib/nostrUtils';
|
||||
|
||||
/** NIP-52 calendar event kinds: date-based (all-day) and time-based. */
|
||||
export const DATE_BASED_KIND = 31922;
|
||||
export const TIME_BASED_KIND = 31923;
|
||||
export const CALENDAR_EVENT_KINDS = [DATE_BASED_KIND, TIME_BASED_KIND] as const;
|
||||
export type CalendarEventKind = (typeof CALENDAR_EVENT_KINDS)[number];
|
||||
|
||||
export function isCalendarEventKind(kind: number): kind is CalendarEventKind {
|
||||
return kind === DATE_BASED_KIND || kind === TIME_BASED_KIND;
|
||||
}
|
||||
|
||||
export interface CalendarParticipant {
|
||||
pubkey: string;
|
||||
relay?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface ParsedCalendarEvent {
|
||||
event: NostrEvent;
|
||||
kind: CalendarEventKind;
|
||||
/** The `d` tag identifier. */
|
||||
id: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
description: string;
|
||||
/** Sanitized image URL, if any. */
|
||||
image?: string;
|
||||
locations: string[];
|
||||
geohash?: string;
|
||||
participants: CalendarParticipant[];
|
||||
hashtags: string[];
|
||||
/** Sanitized reference URLs. */
|
||||
references: string[];
|
||||
allDay: boolean;
|
||||
/** Inclusive start instant. */
|
||||
start: Date;
|
||||
/** Exclusive end instant. */
|
||||
end: Date;
|
||||
startTzid?: string;
|
||||
endTzid?: string;
|
||||
}
|
||||
|
||||
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function parseDateOnly(value: string): Date | undefined {
|
||||
if (!DATE_ONLY_RE.test(value)) return undefined;
|
||||
const date = new Date(`${value}T00:00:00Z`);
|
||||
return Number.isNaN(date.getTime()) ? undefined : date;
|
||||
}
|
||||
|
||||
function parseUnixSeconds(value: string): Date | undefined {
|
||||
if (!/^\d+$/.test(value)) return undefined;
|
||||
const date = new Date(Number(value) * 1000);
|
||||
return Number.isNaN(date.getTime()) ? undefined : date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and normalizes a raw event into a `ParsedCalendarEvent`, or
|
||||
* returns `null` when required NIP-52 tags are missing or malformed. Callers
|
||||
* should filter query results through this rather than trusting `kind`
|
||||
* membership alone — relay data is unauthenticated for shape.
|
||||
*/
|
||||
export function parseCalendarEvent(event: NostrEvent): ParsedCalendarEvent | null {
|
||||
if (!isCalendarEventKind(event.kind)) return null;
|
||||
|
||||
const id = tagValue(event, 'd');
|
||||
if (!id) return null;
|
||||
|
||||
// `title` is required; the deprecated `name` tag is only a fallback.
|
||||
const title = tagValue(event, 'title')?.trim() || tagValue(event, 'name')?.trim();
|
||||
if (!title) return null;
|
||||
|
||||
const startRaw = tagValue(event, 'start');
|
||||
if (!startRaw) return null;
|
||||
|
||||
const allDay = event.kind === DATE_BASED_KIND;
|
||||
const start = allDay ? parseDateOnly(startRaw) : parseUnixSeconds(startRaw);
|
||||
if (!start) return null;
|
||||
|
||||
const endRaw = tagValue(event, 'end');
|
||||
const parsedEnd = endRaw ? (allDay ? parseDateOnly(endRaw) : parseUnixSeconds(endRaw)) : undefined;
|
||||
// A malformed or non-later `end` falls back to the spec default rather
|
||||
// than rejecting the whole event: same-day for date-based, instantaneous
|
||||
// for time-based.
|
||||
const end =
|
||||
parsedEnd && parsedEnd.getTime() > start.getTime()
|
||||
? parsedEnd
|
||||
: new Date(start.getTime() + (allDay ? ONE_DAY_MS : 0));
|
||||
|
||||
const participants: CalendarParticipant[] = event.tags
|
||||
.filter((tag): tag is [string, string, ...string[]] => tag[0] === 'p' && Boolean(tag[1]))
|
||||
.map(([, pubkey, relay, role]) => ({ pubkey, relay: relay || undefined, role: role || undefined }));
|
||||
|
||||
return {
|
||||
event,
|
||||
kind: event.kind,
|
||||
id,
|
||||
title,
|
||||
summary: tagValue(event, 'summary')?.trim() || undefined,
|
||||
description: event.content ?? '',
|
||||
image: sanitizeUrl(tagValue(event, 'image')),
|
||||
locations: tagValues(event, 'location'),
|
||||
geohash: tagValue(event, 'g') || undefined,
|
||||
participants,
|
||||
hashtags: tagValues(event, 't'),
|
||||
references: tagValues(event, 'r')
|
||||
.map((url) => sanitizeUrl(url))
|
||||
.filter((url): url is string => Boolean(url)),
|
||||
allDay,
|
||||
start,
|
||||
end,
|
||||
startTzid: tagValue(event, 'start_tzid') || undefined,
|
||||
endTzid: tagValue(event, 'end_tzid') || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Cheap membership + required-tag check, for filtering query results before the full parse. */
|
||||
export function isValidCalendarEvent(event: NostrEvent): boolean {
|
||||
return parseCalendarEvent(event) !== null;
|
||||
}
|
||||
|
||||
function dateKey(year: number, monthIndex: number, day: number): string {
|
||||
return `${year}-${String(monthIndex + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** `YYYY-MM-DD` for a Date's *local* calendar day — how the grid keys its cells. */
|
||||
export function localDateKey(date: Date): string {
|
||||
return dateKey(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
}
|
||||
|
||||
/** `YYYY-MM-DD` for a Date's *UTC* calendar day — how all-day event tags are keyed. */
|
||||
export function utcDateKey(date: Date): string {
|
||||
return dateKey(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
||||
}
|
||||
|
||||
/** Safety cap on iterated days so a malformed far-future `end` can't hang the grid. */
|
||||
const MAX_SPAN_DAYS = 3660;
|
||||
|
||||
/**
|
||||
* Every calendar-day key (`YYYY-MM-DD`) this event touches. Date-based events
|
||||
* are timezone-agnostic, so their keys come from the UTC calendar day of the
|
||||
* tag values directly. Time-based events are instants, so their keys come
|
||||
* from the *viewer's local* calendar day — matching the grid, which is
|
||||
* itself a view of the viewer's local calendar.
|
||||
*/
|
||||
export function eventDateKeys(parsed: ParsedCalendarEvent): string[] {
|
||||
const keys: string[] = [];
|
||||
if (parsed.allDay) {
|
||||
const cursor = new Date(parsed.start);
|
||||
for (let i = 0; i < MAX_SPAN_DAYS && cursor.getTime() < parsed.end.getTime(); i++) {
|
||||
keys.push(utcDateKey(cursor));
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
} else {
|
||||
const lastInstant = parsed.end.getTime() > parsed.start.getTime() ? parsed.end.getTime() - 1 : parsed.start.getTime();
|
||||
const cursor = new Date(parsed.start);
|
||||
cursor.setHours(0, 0, 0, 0);
|
||||
const last = new Date(lastInstant);
|
||||
last.setHours(0, 0, 0, 0);
|
||||
for (let i = 0; i < MAX_SPAN_DAYS && cursor.getTime() <= last.getTime(); i++) {
|
||||
keys.push(localDateKey(cursor));
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** Day-granularity `D` tag value per NIP-52: `floor(unix_seconds / 86400)`. */
|
||||
export function dayGranularity(date: Date): string {
|
||||
return String(Math.floor(date.getTime() / (ONE_DAY_MS)));
|
||||
}
|
||||
|
||||
/**
|
||||
* `#D` values covering `[from, to)`, for a relay-side time-based query. Capped
|
||||
* so a caller can't accidentally request an unbounded tag list.
|
||||
*/
|
||||
export function dayGranularityRange(from: Date, to: Date): string[] {
|
||||
const values: string[] = [];
|
||||
const cursor = new Date(from);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
for (let i = 0; i < MAX_SPAN_DAYS && cursor.getTime() < to.getTime(); i++) {
|
||||
values.push(dayGranularity(cursor));
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/** Stable identity for an addressable event, for dedup and React keys. */
|
||||
export function calendarEventKey(parsed: ParsedCalendarEvent): string {
|
||||
return `${parsed.kind}:${parsed.event.pubkey}:${parsed.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Addressable events: keep only the newest revision per (kind, pubkey, d).
|
||||
* Relays across the pool can hand back stale copies of an edited event.
|
||||
*/
|
||||
export function dedupeLatestCalendarEvents(events: ParsedCalendarEvent[]): ParsedCalendarEvent[] {
|
||||
const latest = new Map<string, ParsedCalendarEvent>();
|
||||
for (const parsed of events) {
|
||||
const key = calendarEventKey(parsed);
|
||||
const current = latest.get(key);
|
||||
if (!current || parsed.event.created_at > current.event.created_at) latest.set(key, parsed);
|
||||
}
|
||||
return [...latest.values()];
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function formatAllDayDate(date: Date): string {
|
||||
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' });
|
||||
}
|
||||
|
||||
/** Human-readable date/time range for the detail view and agenda rows. */
|
||||
export function formatEventTimeRange(parsed: ParsedCalendarEvent): string {
|
||||
if (parsed.allDay) {
|
||||
const lastDay = new Date(parsed.end.getTime() - ONE_DAY_MS);
|
||||
if (utcDateKey(parsed.start) === utcDateKey(lastDay)) {
|
||||
return formatAllDayDate(parsed.start);
|
||||
}
|
||||
return `${formatAllDayDate(parsed.start)} – ${formatAllDayDate(lastDay)}`;
|
||||
}
|
||||
|
||||
const startDate = parsed.start.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
if (parsed.start.getTime() === parsed.end.getTime()) {
|
||||
return `${startDate} at ${formatTime(parsed.start)}`;
|
||||
}
|
||||
// `end` is exclusive, so an event ending exactly at midnight belongs to
|
||||
// the *previous* instant's day — the same day `eventDateKeys()` puts it
|
||||
// on — not the day `end` technically ticks over into.
|
||||
const lastInstant = new Date(parsed.end.getTime() - 1);
|
||||
const sameDay = localDateKey(parsed.start) === localDateKey(lastInstant);
|
||||
if (sameDay) {
|
||||
return `${startDate}, ${formatTime(parsed.start)} – ${formatTime(parsed.end)}`;
|
||||
}
|
||||
const endDate = lastInstant.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
return `${startDate} ${formatTime(parsed.start)} – ${endDate} ${formatTime(parsed.end)}`;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { lazy } from 'react';
|
||||
import { Activity, Bookmark, BookOpen, FileText, Info, Link2, Radio, Rss, Settings, Sparkles, User } from 'lucide-react';
|
||||
import { Activity, Bookmark, BookOpen, CalendarDays, FileText, Info, Link2, Radio, Rss, Settings, Sparkles, User } from 'lucide-react';
|
||||
import type { AppDefinition } from './types';
|
||||
|
||||
/**
|
||||
@@ -79,6 +79,16 @@ export const APPS: AppDefinition[] = [
|
||||
defaultSize: { width: 780, height: 720 },
|
||||
minSize: { width: 360, height: 320 },
|
||||
},
|
||||
{
|
||||
id: 'calendar',
|
||||
title: 'Calendar',
|
||||
description: 'Nostr calendar events, browsed by month',
|
||||
icon: CalendarDays,
|
||||
category: 'social',
|
||||
component: lazy(() => import('@/apps/calendar')),
|
||||
defaultSize: { width: 900, height: 700 },
|
||||
minSize: { width: 420, height: 420 },
|
||||
},
|
||||
{
|
||||
id: 'spells',
|
||||
title: 'Spells',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { nip19 } from 'nostr-tools';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { OsShell } from '@/components/os/OsShell';
|
||||
import NotFound from './NotFound';
|
||||
import { isCalendarEventKind } from '@/lib/calendarEvents';
|
||||
import { encodeRelayHints } from '@/lib/nostrUtils';
|
||||
import type { AppParams } from '@/os/types';
|
||||
|
||||
@@ -42,7 +43,7 @@ export function NIP19Page() {
|
||||
break;
|
||||
case 'naddr':
|
||||
boot = {
|
||||
appId: 'articles',
|
||||
appId: isCalendarEventKind(decoded.data.kind) ? 'calendar' : 'articles',
|
||||
params: withHints(
|
||||
{
|
||||
pubkey: decoded.data.pubkey,
|
||||
|
||||
Reference in New Issue
Block a user