Files
website/src/hooks/useCalendarEvents.ts
mroxso 2f4967071e 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>
2026-09-06 22:39:42 +02:00

160 lines
5.6 KiB
TypeScript

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,
});
}