From 2f4967071e5692178fb2ad96f54cf4a0f9a79f8f Mon Sep 17 00:00:00 2001 From: mroxso <24775431+mroxso@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:39:42 +0200 Subject: [PATCH] feat: Calendar app for NIP-52 date-based and time-based events (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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 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 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 Co-authored-by: Claude Sonnet 5 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- src/apps/calendar/CalendarFilters.tsx | 127 +++++++++++ src/apps/calendar/DayAgenda.tsx | 92 ++++++++ src/apps/calendar/EventDetail.tsx | 126 +++++++++++ src/apps/calendar/MonthGrid.tsx | 222 +++++++++++++++++++ src/apps/calendar/filterState.ts | 28 +++ src/apps/calendar/index.tsx | 305 ++++++++++++++++++++++++++ src/apps/calendar/monthGridRange.ts | 15 ++ src/hooks/useCalendarEvents.ts | 159 ++++++++++++++ src/lib/calendarEvents.test.ts | 223 +++++++++++++++++++ src/lib/calendarEvents.ts | 242 ++++++++++++++++++++ src/os/registry.ts | 12 +- src/pages/NIP19Page.tsx | 3 +- 12 files changed, 1552 insertions(+), 2 deletions(-) create mode 100644 src/apps/calendar/CalendarFilters.tsx create mode 100644 src/apps/calendar/DayAgenda.tsx create mode 100644 src/apps/calendar/EventDetail.tsx create mode 100644 src/apps/calendar/MonthGrid.tsx create mode 100644 src/apps/calendar/filterState.ts create mode 100644 src/apps/calendar/index.tsx create mode 100644 src/apps/calendar/monthGridRange.ts create mode 100644 src/hooks/useCalendarEvents.ts create mode 100644 src/lib/calendarEvents.test.ts create mode 100644 src/lib/calendarEvents.ts diff --git a/src/apps/calendar/CalendarFilters.tsx b/src/apps/calendar/CalendarFilters.tsx new file mode 100644 index 0000000..edf9db7 --- /dev/null +++ b/src/apps/calendar/CalendarFilters.tsx @@ -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 = { + 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 = (key: K, value: CalendarFilterState[K]) => + onChange({ ...filters, [key]: value }); + + return ( +
+
+

Filters

+ {count > 0 && ( + + )} +
+ + {count > 0 && ( +
+ {filters.eventType !== 'all' && ( + set('eventType', 'all')} /> + )} + {resolvedAuthor && set('authorInput', '')} />} + {filters.search.trim() && ( + set('search', '')} /> + )} +
+ )} + +
+ + value && set('eventType', value as CalendarEventTypeFilter)} + className="flex w-full" + > + + All + + + All-day + + + Timed + + +
+ +
+ + set('authorInput', event.target.value)} + aria-invalid={authorError || undefined} + className="h-8 text-xs" + /> + {authorError &&

Not a valid npub, nprofile, or hex pubkey.

} +
+ +
+ + set('search', event.target.value)} + className="h-8 text-xs" + /> +
+
+ ); +} + +function FilterChip({ label, onClear }: { label: string; onClear: () => void }) { + return ( + + {label} + + + ); +} + +function AuthorFilterChip({ pubkey, onClear }: { pubkey: string; onClear: () => void }) { + const author = useAuthor(pubkey); + return ; +} diff --git a/src/apps/calendar/DayAgenda.tsx b/src/apps/calendar/DayAgenda.tsx new file mode 100644 index 0000000..7444e14 --- /dev/null +++ b/src/apps/calendar/DayAgenda.tsx @@ -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 ( + + ); + } + + const heading = date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }); + + if (isLoading) { + return ( +
+ + {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+ ); + } + + return ( +
+

{heading}

+ {events.length === 0 ? ( +

No events on this day.

+ ) : ( +
    + {events.map((event) => ( +
  • + onOpenEvent(event)} /> +
  • + ))} +
+ )} +
+ ); +} + +function AgendaRow({ event, onSelect }: { event: ParsedCalendarEvent; onSelect: () => void }) { + return ( + + ); +} diff --git a/src/apps/calendar/EventDetail.tsx b/src/apps/calendar/EventDetail.tsx new file mode 100644 index 0000000..9fe0ee2 --- /dev/null +++ b/src/apps/calendar/EventDetail.tsx @@ -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 ( +
+
+ {event.allDay ? : } + {formatEventTimeRange(event)} +
+

{event.title}

+ {event.summary &&

{event.summary}

} + +
+ +
+ +
+ + {event.hashtags.map((tag) => ( + + #{tag} + + ))} +
+ + {event.image && ( + + )} + + {event.locations.length > 0 && ( +
+ {event.locations.map((location, index) => { + const url = sanitizeUrl(location); + return ( +
+ + {url ? ( + + {location} + + ) : ( + {location} + )} +
+ ); + })} +
+ )} + + {event.description.trim() && ( +

{event.description}

+ )} + + {event.references.length > 0 && ( +
+

Links

+ {event.references.map((url) => ( + + + {url} + + ))} +
+ )} + + {event.participants.length > 0 && ( +
+

Participants

+ {event.participants.map((participant) => ( + + ))} +
+ )} +
+ ); +} + +function CopyEventLink({ event }: { event: ParsedCalendarEvent }) { + const hints = useRelayHints(); + const { toast } = useToast(); + + return ( + + ); +} diff --git a/src/apps/calendar/MonthGrid.tsx b/src/apps/calendar/MonthGrid.tsx new file mode 100644 index 0000000..7cd162a --- /dev/null +++ b/src/apps/calendar/MonthGrid.tsx @@ -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; + 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()); + + 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 = { + 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 ( +
+
+ {WEEKDAY_LABELS.map((label) => ( +
+ {label} +
+ ))} +
+
+ {isLoading + ? Array.from({ length: weeks.length * 7 }).map((_, index) => ( +
+ +
+ )) + : weeks.map((week, weekIndex) => ( +
+ {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 ( + + ); + })} +
+ ))} +
+
+ ); +} + +/** 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 ` + {monthLabel} + + + + ); + + const grid = eventsQuery.isError ? ( + eventsQuery.refetch()} /> + ) : ( +
+ {(showingEmptyState || showingFilteredEmpty) && ( +

+ {showingFilteredEmpty ? 'No events match your filters.' : 'No calendar events found on your relays for this month.'} +

+ )} + +
+ ); + + const detailPane = detailQuery.isLoading ? ( +
+ + + +
+ ) : detailQuery.data ? ( + + ) : ( + + ); + + const agendaPane = params.pubkey ? ( + detailPane + ) : ( + + ); + + if (isMobile) { + const level = params.pubkey ? 'detail' : selectedDateKey ? 'agenda' : 'grid'; + return ( + + + {level === 'grid' ? ( + monthNav + ) : ( + + )} + {level === 'grid' && ( + + )} + + + {level === 'grid' ? grid : agendaPane} + + + + + + Filters + Narrow down which calendar events are shown. + + + + + + ); + } + + return ( + + + {monthNav} + + {eventsQuery.isFetching && !eventsQuery.isLoading ? 'Refreshing…' : null} + + + + + + +
{grid}
+ +
+
+ ); +} + +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 ( +
+ +

Couldn't load calendar events

+

+ None of your relays responded in time. Check your connection and try again. +

+ +
+ ); +} diff --git a/src/apps/calendar/monthGridRange.ts b/src/apps/calendar/monthGridRange.ts new file mode 100644 index 0000000..46bf565 --- /dev/null +++ b/src/apps/calendar/monthGridRange.ts @@ -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 }; +} diff --git a/src/hooks/useCalendarEvents.ts b/src/hooks/useCalendarEvents.ts new file mode 100644 index 0000000..0b38cd2 --- /dev/null +++ b/src/hooks/useCalendarEvents.ts @@ -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({ + 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(); + 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({ + 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, + }); +} diff --git a/src/lib/calendarEvents.test.ts b/src/lib/calendarEvents.test.ts new file mode 100644 index 0000000..b6955b2 --- /dev/null +++ b/src/lib/calendarEvents.test.ts @@ -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 & { 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); + }); +}); diff --git a/src/lib/calendarEvents.ts b/src/lib/calendarEvents.ts new file mode 100644 index 0000000..5093486 --- /dev/null +++ b/src/lib/calendarEvents.ts @@ -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(); + 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)}`; +} diff --git a/src/os/registry.ts b/src/os/registry.ts index 2379edb..688345c 100644 --- a/src/os/registry.ts +++ b/src/os/registry.ts @@ -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', diff --git a/src/pages/NIP19Page.tsx b/src/pages/NIP19Page.tsx index 02525bd..ad0eb2f 100644 --- a/src/pages/NIP19Page.tsx +++ b/src/pages/NIP19Page.tsx @@ -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,