mirror of
https://github.com/layer-systems/website.git
synced 2026-09-13 22:41:46 +02:00
* 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>
243 lines
9.0 KiB
TypeScript
243 lines
9.0 KiB
TypeScript
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)}`;
|
||
}
|