import storage from '@/services/local-storage.service' import PrimaryPageLayout, { type TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout' import { calendarOccurrenceOverlapsRange, dedupeCalendarEventsPreferringOccurrenceRange, getCalendarEventMeta, getLocalMondayWeekBounds, getLocalMonthRangeMs } from '@/lib/calendar-event' import { replaceableEventDedupeKey } from '@/lib/event' import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { setCalendarDayPanelEvents } from '@/lib/calendar-day-panel-cache' import { toNote } from '@/lib/link' import { cn } from '@/lib/utils' import { useSecondaryPage } from '@/contexts/secondary-page-context' import { useSmartNoteNavigation } from '@/PageManager' import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFollowListOptional } from '@/providers/follow-list-context' import { useNostr } from '@/providers/NostrProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import client from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants' import { TPageRef } from '@/types' import { CalendarEventCoverImage } from '@/components/CalendarEventCoverImage' import { RefreshButton } from '@/components/RefreshButton' import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react' import { type Event as NostrEvent } from 'nostr-tools' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' const FETCH_LIMIT = 1200 const FOLLOWING_CALENDAR_AUTHORS_CAP = 200 const FOLLOWING_CALENDAR_AUTHORS_CHUNK = 80 const FOLLOWING_CALENDAR_CHUNK_LIMIT = 350 const SESSION_CALENDAR_MERGE_CAP = 5000 const SIDEBAR_CALENDAR_MAX_RELAYS = 24 const MONTH_IDB_MAX_SCAN = 12_000 const PAD_DAYS = 7 export type CalendarPrimaryPageProps = { /** Week offset from the current local week (same as sidebar widget). */ weekOffset?: number } function mondayFirstOffsetFromMonthStart(year: number, monthIndex: number): number { const first = new Date(year, monthIndex, 1, 0, 0, 0, 0) const dow = first.getDay() return dow === 0 ? 6 : dow - 1 } function daysInMonth(year: number, monthIndex: number): number { return new Date(year, monthIndex + 1, 0).getDate() } function ymdForLocalDay(year: number, monthIndex: number, day: number): string { return `${year}-${String(monthIndex + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}` } const CalendarPrimaryPage = forwardRef(function CalendarPrimaryPage( { weekOffset: weekOffsetProp = 0 }, ref ) { const { t, i18n } = useTranslation() const { relayList, pubkey } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const followList = useFollowListOptional() const { navigateToNote } = useSmartNoteNavigation() const { push } = useSecondaryPage() const { isSmallScreen } = useScreenSize() const [panelMode, setPanelMode] = useState<'single' | 'double'>(() => storage.getPanelMode()) const layoutRef = useRef(null) const [refreshKey, setRefreshKey] = useState(0) useEffect(() => { const onPanelMode = (ev: Event) => { const d = (ev as CustomEvent<{ mode: 'single' | 'double' }>).detail?.mode if (d === 'single' || d === 'double') setPanelMode(d) } window.addEventListener('panelModeChanged', onPanelMode) return () => window.removeEventListener('panelModeChanged', onPanelMode) }, []) /** Month grid is unreadable in the narrow primary column of double-pane; use the same vertical layout as mobile. */ const useVerticalMonthCalendar = isSmallScreen || panelMode === 'double' const [activeWeekOffset, setActiveWeekOffset] = useState(weekOffsetProp) useEffect(() => { setActiveWeekOffset(weekOffsetProp) }, [weekOffsetProp]) const highlightBounds = useMemo( () => getLocalMondayWeekBounds(activeWeekOffset), [activeWeekOffset] ) const anchorMonday = useMemo(() => new Date(highlightBounds.weekStartMs), [highlightBounds.weekStartMs]) const [viewYear, setViewYear] = useState(() => anchorMonday.getFullYear()) const [viewMonth, setViewMonth] = useState(() => anchorMonday.getMonth()) useEffect(() => { const d = new Date(highlightBounds.weekStartMs) setViewYear(d.getFullYear()) setViewMonth(d.getMonth()) }, [highlightBounds.weekStartMs]) const [rawEvents, setRawEvents] = useState([]) const [loading, setLoading] = useState(false) const relayUrls = useMemo(() => { const base = getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, userReadRelaysWithHttp(relayList), { userWriteRelays: relayList?.write ?? [], applySocialKindBlockedFilter: false } ) return appendCuratedReadOnlyRelays(base, blockedRelays).slice(0, SIDEBAR_CALENDAR_MAX_RELAYS) }, [favoriteRelays, blockedRelays, relayList]) const relayKey = useMemo(() => [...relayUrls].sort().join('|'), [relayUrls]) const followAuthorsKey = useMemo(() => { const raw = followList?.followings ?? [] if (!raw.length && !pubkey) return '' const set = new Set() for (const p of raw) { const k = p?.trim().toLowerCase() if (k) set.add(k) } if (pubkey) set.add(pubkey.toLowerCase()) return [...set].sort().join('|') }, [followList?.followings, pubkey]) const paddedMonthRange = useMemo(() => { const { startMs, endExclusiveMs } = getLocalMonthRangeMs(viewYear, viewMonth) const pad = PAD_DAYS * 86_400_000 return { rangeStartMs: startMs - pad, rangeEndExclusiveMs: endExclusiveMs + pad } }, [viewYear, viewMonth]) useEffect(() => { let cancelled = false let lateMergeTimer: number | null = null const { rangeStartMs, rangeEndExclusiveMs } = paddedMonthRange /** Same-tick paint from in-memory session (no await) — IDB + relays merge in the async block below. */ const fromSessionSync = client.getSessionEventsMatchingSearch( '', SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) const sessionOnly = dedupeCalendarEventsPreferringOccurrenceRange( fromSessionSync, rangeStartMs, rangeEndExclusiveMs ) setRawEvents(sessionOnly) setLoading(false) const scheduleLateSessionMerge = (mergeWithIdb: NostrEvent[]) => { lateMergeTimer = window.setTimeout(() => { lateMergeTimer = null if (cancelled) return const later = client.getSessionEventsMatchingSearch( '', SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) setRawEvents((prev) => dedupeCalendarEventsPreferringOccurrenceRange( [...prev, ...later, ...mergeWithIdb], rangeStartMs, rangeEndExclusiveMs ) ) }, 2500) } void (async () => { try { const idbP = Promise.all([ indexedDb.getCalendarEventsForOccurrenceWindow( rangeStartMs, rangeEndExclusiveMs, MONTH_IDB_MAX_SCAN ), indexedDb.getArchivedCalendarEventsOverlappingWindow( rangeStartMs, rangeEndExclusiveMs, 55_000, 2500 ) ]) .then(([fromIdb, fromArchive]) => dedupeCalendarEventsPreferringOccurrenceRange( [...fromIdb, ...fromArchive], rangeStartMs, rangeEndExclusiveMs ) ) .catch((): NostrEvent[] => []) void idbP.then((localBaseline) => { if (cancelled) return const s2 = client.getSessionEventsMatchingSearch( '', SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) setRawEvents( dedupeCalendarEventsPreferringOccurrenceRange( [...localBaseline, ...s2], rangeStartMs, rangeEndExclusiveMs ) ) }) if (!relayUrls.length) { void idbP.then((lb) => { if (!cancelled) scheduleLateSessionMerge(lb) }) return } const mainFetchOpts = { cache: true as const, globalTimeout: 22_000, eoseTimeout: 3500, firstRelayResultGraceMs: false as const } const chunkFetchOpts = { cache: true as const, globalTimeout: 12_000, eoseTimeout: 2200, firstRelayResultGraceMs: false as const } const authorList = followAuthorsKey ? followAuthorsKey.split('|').filter(Boolean).slice(0, FOLLOWING_CALENDAR_AUTHORS_CAP) : [] const authorChunks: string[][] = [] for (let i = 0; i < authorList.length; i += FOLLOWING_CALENDAR_AUTHORS_CHUNK) { authorChunks.push(authorList.slice(i, i + FOLLOWING_CALENDAR_AUTHORS_CHUNK)) } const mainReq = client.fetchEvents( relayUrls, { kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], limit: FETCH_LIMIT }, mainFetchOpts ) const chunkReqs = authorChunks.map((authors) => client.fetchEvents( relayUrls, { kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], authors, limit: FOLLOWING_CALENDAR_CHUNK_LIMIT }, chunkFetchOpts ) ) const relayMergedP = Promise.all([mainReq, ...chunkReqs]) .then((merged) => { const batch = merged[0] ?? [] const fromFollowing: NostrEvent[] = [] for (let i = 1; i < merged.length; i++) { fromFollowing.push(...(merged[i] ?? [])) } return { batch, fromFollowing } }) .catch(() => ({ batch: [] as NostrEvent[], fromFollowing: [] as NostrEvent[] })) const [{ batch, fromFollowing }, localBaseline] = await Promise.all([relayMergedP, idbP]) if (cancelled) return const fromSession = client.getSessionEventsMatchingSearch( '', SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) setRawEvents( dedupeCalendarEventsPreferringOccurrenceRange( [...batch, ...fromFollowing, ...fromSession, ...localBaseline], rangeStartMs, rangeEndExclusiveMs ) ) lateMergeTimer = window.setTimeout(() => { lateMergeTimer = null if (cancelled) return const later = client.getSessionEventsMatchingSearch( '', SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) setRawEvents((prev) => dedupeCalendarEventsPreferringOccurrenceRange( [...prev, ...later, ...localBaseline], rangeStartMs, rangeEndExclusiveMs ) ) }, 2500) } catch { if (!cancelled) { try { const rs = rangeStartMs const re = rangeEndExclusiveMs const [idb, arc] = await Promise.all([ indexedDb.getCalendarEventsForOccurrenceWindow(rs, re, MONTH_IDB_MAX_SCAN), indexedDb.getArchivedCalendarEventsOverlappingWindow(rs, re, 55_000, 2500) ]) const salvage = dedupeCalendarEventsPreferringOccurrenceRange([...idb, ...arc], rs, re) const fromSession = client.getSessionEventsMatchingSearch( '', SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) setRawEvents(dedupeCalendarEventsPreferringOccurrenceRange([...salvage, ...fromSession], rs, re)) } catch { setRawEvents([]) } setLoading(false) } } })() return () => { cancelled = true if (lateMergeTimer != null) window.clearTimeout(lateMergeTimer) } }, [relayKey, followAuthorsKey, paddedMonthRange, relayUrls.length, refreshKey]) const weekdayLabels = useMemo(() => { const fmt = new Intl.DateTimeFormat(i18n.language, { weekday: 'short' }) const base = new Date(2024, 0, 1) while (base.getDay() !== 1) base.setDate(base.getDate() + 1) return Array.from({ length: 7 }, (_, i) => { const d = new Date(base) d.setDate(base.getDate() + i) return fmt.format(d) }) }, [i18n.language]) const monthTitle = useMemo(() => { const d = new Date(viewYear, viewMonth, 1) return new Intl.DateTimeFormat(i18n.language, { month: 'long', year: 'numeric' }).format(d) }, [viewYear, viewMonth, i18n.language]) const gridCells = useMemo(() => { const offset = mondayFirstOffsetFromMonthStart(viewYear, viewMonth) const dim = daysInMonth(viewYear, viewMonth) const total = offset + dim const rows = Math.ceil(total / 7) const cells: { day: number | null; inMonth: boolean }[] = [] for (let i = 0; i < rows * 7; i++) { const dayNum = i - offset + 1 if (dayNum < 1 || dayNum > dim) cells.push({ day: null, inMonth: false }) else cells.push({ day: dayNum, inMonth: true }) } return cells }, [viewYear, viewMonth]) const eventsForDay = useCallback( (day: number) => { const dayStart = new Date(viewYear, viewMonth, day, 0, 0, 0, 0).getTime() const dayEnd = dayStart + 86_400_000 return rawEvents.filter((ev) => calendarOccurrenceOverlapsRange(ev, dayStart, dayEnd)) }, [rawEvents, viewYear, viewMonth] ) const isDayInHighlightWeek = useCallback( (day: number) => { const dayStart = new Date(viewYear, viewMonth, day, 0, 0, 0, 0).getTime() const dayEnd = dayStart + 86_400_000 return ( dayStart < highlightBounds.weekEndExclusiveMs && dayEnd > highlightBounds.weekStartMs ) }, [viewYear, viewMonth, highlightBounds] ) const openDayEventsPanel = useCallback( (day: number) => { const list = eventsForDay(day) const ymd = ymdForLocalDay(viewYear, viewMonth, day) setCalendarDayPanelEvents(ymd, list) push(`/calendar/day/${ymd}`) }, [eventsForDay, viewYear, viewMonth, push] ) const goPrevMonth = () => { setViewMonth((m) => { if (m === 0) { setViewYear((y) => y - 1) return 11 } return m - 1 }) } const goNextMonth = () => { setViewMonth((m) => { if (m === 11) { setViewYear((y) => y + 1) return 0 } return m + 1 }) } const jumpToThisWeek = () => { setActiveWeekOffset(0) const { weekStartMs } = getLocalMondayWeekBounds(0) const d = new Date(weekStartMs) setViewYear(d.getFullYear()) setViewMonth(d.getMonth()) } useImperativeHandle( ref, () => ({ scrollToTop: (behavior?: ScrollBehavior) => layoutRef.current?.scrollToTop(behavior), refresh: () => setRefreshKey((k) => k + 1) }), [] ) return ( setRefreshKey((k) => k + 1)} />} displayScrollToTopButton >

{monthTitle}

{loading && rawEvents.length === 0 ? (

{t('sidebarCalendarLoading')}

) : null} {useVerticalMonthCalendar ? (
{Array.from({ length: daysInMonth(viewYear, viewMonth) }, (_, idx) => { const day = idx + 1 const list = eventsForDay(day) const inWeek = isDayInHighlightWeek(day) const weekdayShort = new Intl.DateTimeFormat(i18n.language, { weekday: 'short' }).format( new Date(viewYear, viewMonth, day, 12, 0, 0, 0) ) const excess = list.length > 4 ? list.length - 4 : 0 return (
{weekdayShort} · {day} {list.length > 0 ? ( {t('calendarPageDayEventCount', { count: list.length })} ) : null}
    {list.slice(0, 4).map((ev) => { const meta = getCalendarEventMeta(ev) const title = meta.title?.trim() || t('calendarPageUntitledEvent') return (
  • ) })}
{excess > 0 ? ( ) : null}
) })}
) : (
{weekdayLabels.map((label) => (
{label}
))} {gridCells.map((cell, idx) => { if (cell.day == null) { return
} const day = cell.day const list = eventsForDay(day) const inWeek = isDayInHighlightWeek(day) const excess = list.length > 4 ? list.length - 4 : 0 return (
{day}
    {list.slice(0, 4).map((ev) => { const meta = getCalendarEventMeta(ev) const title = meta.title?.trim() || t('calendarPageUntitledEvent') return (
  • ) })}
{excess > 0 ? ( ) : null}
) })}
)}
) }) function CalendarPageTitlebar({ onRefresh }: { onRefresh: () => void }) { const { t } = useTranslation() return (
{t('calendarPageTitle')}
) } CalendarPrimaryPage.displayName = 'CalendarPrimaryPage' export default CalendarPrimaryPage