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:
mroxso
2026-09-06 22:39:42 +02:00
committed by GitHub
parent b94753ec68
commit 2f4967071e
12 changed files with 1552 additions and 2 deletions

View 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} />;
}

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

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

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

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

View 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 };
}

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

View 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
View 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)}`;
}

View File

@@ -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',

View File

@@ -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,