Browse Source

calendar implemented

imwald
Silberengel 1 month ago
parent
commit
84f7313662
  1. 35
      src/PageManager.tsx
  2. 17
      src/components/Sidebar/SidebarCalendarWeekWidget.tsx
  3. 12
      src/i18n/locales/cs.ts
  4. 12
      src/i18n/locales/de.ts
  5. 12
      src/i18n/locales/en.ts
  6. 12
      src/i18n/locales/es.ts
  7. 12
      src/i18n/locales/fr.ts
  8. 12
      src/i18n/locales/nl.ts
  9. 12
      src/i18n/locales/pl.ts
  10. 12
      src/i18n/locales/ru.ts
  11. 12
      src/i18n/locales/tr.ts
  12. 12
      src/i18n/locales/zh.ts
  13. 24
      src/lib/calendar-day-panel-cache.ts
  14. 10
      src/lib/calendar-event.ts
  15. 5
      src/lib/document-meta.ts
  16. 2
      src/lib/tag.ts
  17. 527
      src/pages/primary/CalendarPrimaryPage.tsx
  18. 101
      src/pages/secondary/CalendarDayEventsPage/index.tsx
  19. 3
      src/routes.tsx
  20. 6
      src/services/client-query.service.ts

35
src/PageManager.tsx

@ -87,6 +87,7 @@ const SearchPageLazy = lazy(() => import('./pages/primary/SearchPage'))
const FollowsLatestPageLazy = lazy(() => import('./pages/primary/FollowsLatestPage')) const FollowsLatestPageLazy = lazy(() => import('./pages/primary/FollowsLatestPage'))
const RssPageLazy = lazy(() => import('./pages/primary/RssPage')) const RssPageLazy = lazy(() => import('./pages/primary/RssPage'))
const SettingsPrimaryPageLazy = lazy(() => import('./pages/primary/SettingsPrimaryPage')) const SettingsPrimaryPageLazy = lazy(() => import('./pages/primary/SettingsPrimaryPage'))
const CalendarPrimaryPageLazy = lazy(() => import('./pages/primary/CalendarPrimaryPage'))
/** Lazy chrome: Sidebar / bottom bar / dialogs import hooks from PageManager — must not be sync-imported here. */ /** Lazy chrome: Sidebar / bottom bar / dialogs import hooks from PageManager — must not be sync-imported here. */
const SidebarLazy = lazy(() => import('@/components/Sidebar')) const SidebarLazy = lazy(() => import('@/components/Sidebar'))
@ -129,7 +130,8 @@ const PRIMARY_PAGE_REF_MAP = {
'follows-latest': createRef<TPageRef>(), 'follows-latest': createRef<TPageRef>(),
rss: createRef<TPageRef>(), rss: createRef<TPageRef>(),
settings: createRef<TPageRef>(), settings: createRef<TPageRef>(),
spells: createRef<TPageRef>() spells: createRef<TPageRef>(),
calendar: createRef<TPageRef>()
} }
// Lazy function to create PRIMARY_PAGE_MAP to avoid circular dependency // Lazy function to create PRIMARY_PAGE_MAP to avoid circular dependency
@ -184,6 +186,11 @@ const getPrimaryPageMap = () => ({
<Suspense fallback={primaryPageLazyFallback}> <Suspense fallback={primaryPageLazyFallback}>
<SpellsPageLazy ref={PRIMARY_PAGE_REF_MAP.spells} /> <SpellsPageLazy ref={PRIMARY_PAGE_REF_MAP.spells} />
</Suspense> </Suspense>
),
calendar: (
<Suspense fallback={primaryPageLazyFallback}>
<CalendarPrimaryPageLazy ref={PRIMARY_PAGE_REF_MAP.calendar} />
</Suspense>
) )
}) })
@ -269,7 +276,8 @@ function buildNoteUrl(noteId: string, currentPage: TPrimaryPageName | null): str
'spells', 'spells',
'rss', 'rss',
'explore', 'explore',
'follows-latest' 'follows-latest',
'calendar'
] ]
if (currentPage && contextualPages.includes(currentPage)) { if (currentPage && contextualPages.includes(currentPage)) {
@ -292,7 +300,8 @@ function buildRssArticleUrl(
'spells', 'spells',
'rss', 'rss',
'explore', 'explore',
'follows-latest' 'follows-latest',
'calendar'
] ]
let path = let path =
currentPage && contextualPages.includes(currentPage) currentPage && contextualPages.includes(currentPage)
@ -409,7 +418,7 @@ function extractValidNoteId(raw: string): string | null {
function parseNoteUrl(url: string): { noteId: string; context?: string } | null { function parseNoteUrl(url: string): { noteId: string; context?: string } | null {
// Match patterns like /discussions/notes/{noteId} or /notes/{noteId} // Match patterns like /discussions/notes/{noteId} or /notes/{noteId}
const contextualMatch = url.match( const contextualMatch = url.match(
/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\/(.+)$/ /\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/notes\/(.+)$/
) )
if (contextualMatch) { if (contextualMatch) {
const noteId = extractValidNoteId(contextualMatch[2]) const noteId = extractValidNoteId(contextualMatch[2])
@ -1227,7 +1236,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const pathname = window.location.pathname const pathname = window.location.pathname
// Check if this is a note URL - handle both /notes/{id} and /{context}/notes/{id} // Check if this is a note URL - handle both /notes/{id} and /{context}/notes/{id}
const contextualNoteMatch = pathname.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\/(.+)$/) const contextualNoteMatch = pathname.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/notes\/(.+)$/)
const standardNoteMatch = pathname.match(/\/notes\/(.+)$/) const standardNoteMatch = pathname.match(/\/notes\/(.+)$/)
const noteUrlMatch = contextualNoteMatch || standardNoteMatch const noteUrlMatch = contextualNoteMatch || standardNoteMatch
@ -1290,7 +1299,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// RSS article in side panel: /{context}/rss-item/{key} or /rss-item/{key} // RSS article in side panel: /{context}/rss-item/{key} or /rss-item/{key}
const contextualRssMatch = pathname.match( const contextualRssMatch = pathname.match(
/^\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/rss-item\/([^/?#]+)/ /^\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/rss-item\/([^/?#]+)/
) )
const standardRssMatch = pathname.match(/^\/rss-item\/([^/?#]+)/) const standardRssMatch = pathname.match(/^\/rss-item\/([^/?#]+)/)
const rssArticleKey = contextualRssMatch?.[2] ?? standardRssMatch?.[1] const rssArticleKey = contextualRssMatch?.[2] ?? standardRssMatch?.[1]
@ -1415,7 +1424,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// Check if pathname matches a primary page name // Check if pathname matches a primary page name
// First, check if it's a contextual note URL (e.g., /discussions/notes/...) // First, check if it's a contextual note URL (e.g., /discussions/notes/...)
const contextualNoteMatch = pathname.match( const contextualNoteMatch = pathname.match(
/^\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\// /^\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/notes\//
) )
if (contextualNoteMatch) { if (contextualNoteMatch) {
const pageContext = contextualNoteMatch[1] const pageContext = contextualNoteMatch[1]
@ -1485,7 +1494,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const urlToCheck = state?.url || window.location.pathname const urlToCheck = state?.url || window.location.pathname
// Check if it's a note URL (we'll update drawer after stack is synced) // Check if it's a note URL (we'll update drawer after stack is synced)
const noteUrlMatch = urlToCheck.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\/(.+)$/) || const noteUrlMatch = urlToCheck.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/notes\/(.+)$/) ||
urlToCheck.match(/\/notes\/(.+)$/) urlToCheck.match(/\/notes\/(.+)$/)
const noteIdToShow = noteUrlMatch ? noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0] : null const noteIdToShow = noteUrlMatch ? noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0] : null
@ -1507,7 +1516,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
/* keep pathname */ /* keep pathname */
} }
const ctxRssPop = rssPathSync.match( const ctxRssPop = rssPathSync.match(
/^\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/rss-item\/([^/?#]+)/ /^\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/rss-item\/([^/?#]+)/
) )
if (ctxRssPop) { if (ctxRssPop) {
const resolvedPop = noteContextToPrimaryEntry(ctxRssPop[1]) const resolvedPop = noteContextToPrimaryEntry(ctxRssPop[1])
@ -1542,7 +1551,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
if (topItemUrl) { if (topItemUrl) {
const topNoteUrlMatch = const topNoteUrlMatch =
topItemUrl.match( topItemUrl.match(
/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\/(.+)$/ /\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/notes\/(.+)$/
) || topItemUrl.match(/\/notes\/(.+)$/) ) || topItemUrl.match(/\/notes\/(.+)$/)
if (topNoteUrlMatch) { if (topNoteUrlMatch) {
const topNoteId = topNoteUrlMatch[topNoteUrlMatch.length - 1] const topNoteId = topNoteUrlMatch[topNoteUrlMatch.length - 1]
@ -1638,7 +1647,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
} }
// Check if navigating to a note URL (supports both /notes/{id} and /{context}/notes/{id}) // Check if navigating to a note URL (supports both /notes/{id} and /{context}/notes/{id})
const noteUrlMatch = state.url.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\/(.+)$/) || const noteUrlMatch = state.url.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/notes\/(.+)$/) ||
state.url.match(/\/notes\/(.+)$/) state.url.match(/\/notes\/(.+)$/)
if (noteUrlMatch) { if (noteUrlMatch) {
const noteId = noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0] const noteId = noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0]
@ -1695,7 +1704,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// Extract noteId from top item's URL or from state.url // Extract noteId from top item's URL or from state.url
const topItemUrl = newStack[newStack.length - 1]?.url || state?.url const topItemUrl = newStack[newStack.length - 1]?.url || state?.url
if (topItemUrl) { if (topItemUrl) {
const topNoteUrlMatch = topItemUrl.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\/(.+)$/) || const topNoteUrlMatch = topItemUrl.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/notes\/(.+)$/) ||
topItemUrl.match(/\/notes\/(.+)$/) topItemUrl.match(/\/notes\/(.+)$/)
if (topNoteUrlMatch) { if (topNoteUrlMatch) {
const topNoteId = topNoteUrlMatch[topNoteUrlMatch.length - 1].split('?')[0].split('#')[0] const topNoteId = topNoteUrlMatch[topNoteUrlMatch.length - 1].split('?')[0].split('#')[0]
@ -2374,7 +2383,7 @@ function cloneSecondaryRouteElement(
/** Hex id segment from /notes/{id} or /{context}/notes/{id} (query/hash stripped). */ /** Hex id segment from /notes/{id} or /{context}/notes/{id} (query/hash stripped). */
function noteHexIdFromSecondaryNoteUrl(url: string): string | null { function noteHexIdFromSecondaryNoteUrl(url: string): string | null {
const contextual = url.match( const contextual = url.match(
/\/(?:discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\/(.+)$/ /\/(?:discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/notes\/(.+)$/
) )
const standard = url.match(/\/notes\/(.+)$/) const standard = url.match(/\/notes\/(.+)$/)
const m = contextual || standard const m = contextual || standard

17
src/components/Sidebar/SidebarCalendarWeekWidget.tsx

@ -9,6 +9,7 @@ import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadRelaysWithHttp } fro
import { replaceableEventDedupeKey } from '@/lib/event' import { replaceableEventDedupeKey } from '@/lib/event'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useSmartNoteNavigation } from '@/PageManager' import { useSmartNoteNavigation } from '@/PageManager'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useFollowListOptional } from '@/providers/follow-list-context' import { useFollowListOptional } from '@/providers/follow-list-context'
@ -17,7 +18,7 @@ import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants' import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants'
import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds' import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds'
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react' import { CalendarDays, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
import { type Event } from 'nostr-tools' import { type Event } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -51,6 +52,7 @@ export default function SidebarCalendarWeekWidget() {
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const followList = useFollowListOptional() const followList = useFollowListOptional()
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigation()
const { navigate: navigatePrimary } = usePrimaryPage()
const [weekOffset, setWeekOffset] = useState(0) const [weekOffset, setWeekOffset] = useState(0)
const [rawEvents, setRawEvents] = useState<Event[]>([]) const [rawEvents, setRawEvents] = useState<Event[]>([])
@ -206,7 +208,7 @@ export default function SidebarCalendarWeekWidget() {
return ( return (
<div className="max-xl:hidden w-full min-w-0 rounded-lg border border-border/60 bg-card/40 px-2 py-2 shadow-sm"> <div className="max-xl:hidden w-full min-w-0 rounded-lg border border-border/60 bg-card/40 px-2 py-2 shadow-sm">
<div className="mb-1.5 flex items-center justify-between gap-1"> <div className="mb-1.5 flex items-center justify-between gap-0.5">
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@ -223,6 +225,17 @@ export default function SidebarCalendarWeekWidget() {
> >
{weekLabel} {weekLabel}
</span> </span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0"
aria-label={t('sidebarCalendarOpenMonthView')}
title={t('sidebarCalendarOpenMonthView')}
onClick={() => navigatePrimary('calendar', { weekOffset })}
>
<CalendarDays className="size-4" />
</Button>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"

12
src/i18n/locales/cs.ts

@ -760,6 +760,18 @@ export default {
sidebarCalendarEmptyWeek: "No calendar events this week.", sidebarCalendarEmptyWeek: "No calendar events this week.",
sidebarCalendarLoading: "Loading…", sidebarCalendarLoading: "Loading…",
sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.", sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.",
sidebarCalendarOpenMonthView: "Open month view",
calendarPageTitle: "Calendar",
calendarPagePrevMonth: "Previous month",
calendarPageNextMonth: "Next month",
calendarPageThisWeek: "This week",
calendarPageGridLabel: "Month calendar",
calendarPageUntitledEvent: "Event",
calendarPageMoreEventsAria: "Show {{count}} more events for this day",
calendarPageDayEventCount: "{{count}} events",
calendarDayPanelEmpty:
"No cached events for this day. Close the panel, open the month calendar again, then tap “+N”.",
calendarDayPanelInvalidDate: "Invalid date.",
"Looking for more events…": "Looking for more events…", "Looking for more events…": "Looking for more events…",
"Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.", "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.",
"Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)", "Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)",

12
src/i18n/locales/de.ts

@ -760,6 +760,18 @@ export default {
sidebarCalendarEmptyWeek: "Keine Kalender-Termine in dieser Woche.", sidebarCalendarEmptyWeek: "Keine Kalender-Termine in dieser Woche.",
sidebarCalendarLoading: "Laden…", sidebarCalendarLoading: "Laden…",
sidebarCalendarNoRelays: "Lese-Relays in den Einstellungen eintragen, um Kalender-Termine zu laden.", sidebarCalendarNoRelays: "Lese-Relays in den Einstellungen eintragen, um Kalender-Termine zu laden.",
sidebarCalendarOpenMonthView: "Monatsansicht öffnen",
calendarPageTitle: "Kalender",
calendarPagePrevMonth: "Vorheriger Monat",
calendarPageNextMonth: "Nächster Monat",
calendarPageThisWeek: "Diese Woche",
calendarPageGridLabel: "Monatskalender",
calendarPageUntitledEvent: "Termin",
calendarPageMoreEventsAria: "{{count}} weitere Termine an diesem Tag anzeigen",
calendarPageDayEventCount: "{{count}} Termine",
calendarDayPanelEmpty:
"Keine zwischengespeicherten Termine. Panel schließen, Kalender erneut öffnen und „+N“ tippen.",
calendarDayPanelInvalidDate: "Ungültiges Datum.",
"Looking for more events…": "Looking for more events…", "Looking for more events…": "Looking for more events…",
"Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Die Relays haben keine Ereignisse für diesen Feed geliefert. Sie können offline sein, langsam antworten oder diese Notizen nicht indexieren.", "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Die Relays haben keine Ereignisse für diesen Feed geliefert. Sie können offline sein, langsam antworten oder diese Notizen nicht indexieren.",
"Per-relay timeline results ({{count}} connections)": "Ergebnis je Relay ({{count}} Verbindungen)", "Per-relay timeline results ({{count}} connections)": "Ergebnis je Relay ({{count}} Verbindungen)",

12
src/i18n/locales/en.ts

@ -764,6 +764,18 @@ export default {
sidebarCalendarEmptyWeek: "No calendar events this week.", sidebarCalendarEmptyWeek: "No calendar events this week.",
sidebarCalendarLoading: "Loading…", sidebarCalendarLoading: "Loading…",
sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.", sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.",
sidebarCalendarOpenMonthView: "Open month view",
calendarPageTitle: "Calendar",
calendarPagePrevMonth: "Previous month",
calendarPageNextMonth: "Next month",
calendarPageThisWeek: "This week",
calendarPageGridLabel: "Month calendar",
calendarPageUntitledEvent: "Event",
calendarPageMoreEventsAria: "Show {{count}} more events for this day",
calendarPageDayEventCount: "{{count}} events",
calendarDayPanelEmpty:
"No cached events for this day. Close the panel, open the month calendar again, then tap “+N”.",
calendarDayPanelInvalidDate: "Invalid date.",
"Looking for more events…": "Looking for more events…", "Looking for more events…": "Looking for more events…",
"Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.", "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.",
"Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)", "Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)",

12
src/i18n/locales/es.ts

@ -760,6 +760,18 @@ export default {
sidebarCalendarEmptyWeek: "No calendar events this week.", sidebarCalendarEmptyWeek: "No calendar events this week.",
sidebarCalendarLoading: "Loading…", sidebarCalendarLoading: "Loading…",
sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.", sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.",
sidebarCalendarOpenMonthView: "Open month view",
calendarPageTitle: "Calendar",
calendarPagePrevMonth: "Previous month",
calendarPageNextMonth: "Next month",
calendarPageThisWeek: "This week",
calendarPageGridLabel: "Month calendar",
calendarPageUntitledEvent: "Event",
calendarPageMoreEventsAria: "Show {{count}} more events for this day",
calendarPageDayEventCount: "{{count}} events",
calendarDayPanelEmpty:
"No cached events for this day. Close the panel, open the month calendar again, then tap “+N”.",
calendarDayPanelInvalidDate: "Invalid date.",
"Looking for more events…": "Looking for more events…", "Looking for more events…": "Looking for more events…",
"Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.", "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.",
"Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)", "Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)",

12
src/i18n/locales/fr.ts

@ -760,6 +760,18 @@ export default {
sidebarCalendarEmptyWeek: "No calendar events this week.", sidebarCalendarEmptyWeek: "No calendar events this week.",
sidebarCalendarLoading: "Loading…", sidebarCalendarLoading: "Loading…",
sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.", sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.",
sidebarCalendarOpenMonthView: "Open month view",
calendarPageTitle: "Calendar",
calendarPagePrevMonth: "Previous month",
calendarPageNextMonth: "Next month",
calendarPageThisWeek: "This week",
calendarPageGridLabel: "Month calendar",
calendarPageUntitledEvent: "Event",
calendarPageMoreEventsAria: "Show {{count}} more events for this day",
calendarPageDayEventCount: "{{count}} events",
calendarDayPanelEmpty:
"No cached events for this day. Close the panel, open the month calendar again, then tap “+N”.",
calendarDayPanelInvalidDate: "Invalid date.",
"Looking for more events…": "Looking for more events…", "Looking for more events…": "Looking for more events…",
"Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.", "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.",
"Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)", "Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)",

12
src/i18n/locales/nl.ts

@ -760,6 +760,18 @@ export default {
sidebarCalendarEmptyWeek: "No calendar events this week.", sidebarCalendarEmptyWeek: "No calendar events this week.",
sidebarCalendarLoading: "Loading…", sidebarCalendarLoading: "Loading…",
sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.", sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.",
sidebarCalendarOpenMonthView: "Open month view",
calendarPageTitle: "Calendar",
calendarPagePrevMonth: "Previous month",
calendarPageNextMonth: "Next month",
calendarPageThisWeek: "This week",
calendarPageGridLabel: "Month calendar",
calendarPageUntitledEvent: "Event",
calendarPageMoreEventsAria: "Show {{count}} more events for this day",
calendarPageDayEventCount: "{{count}} events",
calendarDayPanelEmpty:
"No cached events for this day. Close the panel, open the month calendar again, then tap “+N”.",
calendarDayPanelInvalidDate: "Invalid date.",
"Looking for more events…": "Looking for more events…", "Looking for more events…": "Looking for more events…",
"Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.", "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.",
"Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)", "Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)",

12
src/i18n/locales/pl.ts

@ -760,6 +760,18 @@ export default {
sidebarCalendarEmptyWeek: "No calendar events this week.", sidebarCalendarEmptyWeek: "No calendar events this week.",
sidebarCalendarLoading: "Loading…", sidebarCalendarLoading: "Loading…",
sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.", sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.",
sidebarCalendarOpenMonthView: "Open month view",
calendarPageTitle: "Calendar",
calendarPagePrevMonth: "Previous month",
calendarPageNextMonth: "Next month",
calendarPageThisWeek: "This week",
calendarPageGridLabel: "Month calendar",
calendarPageUntitledEvent: "Event",
calendarPageMoreEventsAria: "Show {{count}} more events for this day",
calendarPageDayEventCount: "{{count}} events",
calendarDayPanelEmpty:
"No cached events for this day. Close the panel, open the month calendar again, then tap “+N”.",
calendarDayPanelInvalidDate: "Invalid date.",
"Looking for more events…": "Looking for more events…", "Looking for more events…": "Looking for more events…",
"Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.", "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.",
"Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)", "Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)",

12
src/i18n/locales/ru.ts

@ -760,6 +760,18 @@ export default {
sidebarCalendarEmptyWeek: "No calendar events this week.", sidebarCalendarEmptyWeek: "No calendar events this week.",
sidebarCalendarLoading: "Loading…", sidebarCalendarLoading: "Loading…",
sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.", sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.",
sidebarCalendarOpenMonthView: "Open month view",
calendarPageTitle: "Calendar",
calendarPagePrevMonth: "Previous month",
calendarPageNextMonth: "Next month",
calendarPageThisWeek: "This week",
calendarPageGridLabel: "Month calendar",
calendarPageUntitledEvent: "Event",
calendarPageMoreEventsAria: "Show {{count}} more events for this day",
calendarPageDayEventCount: "{{count}} events",
calendarDayPanelEmpty:
"No cached events for this day. Close the panel, open the month calendar again, then tap “+N”.",
calendarDayPanelInvalidDate: "Invalid date.",
"Looking for more events…": "Looking for more events…", "Looking for more events…": "Looking for more events…",
"Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.", "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.",
"Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)", "Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)",

12
src/i18n/locales/tr.ts

@ -760,6 +760,18 @@ export default {
sidebarCalendarEmptyWeek: "No calendar events this week.", sidebarCalendarEmptyWeek: "No calendar events this week.",
sidebarCalendarLoading: "Loading…", sidebarCalendarLoading: "Loading…",
sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.", sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.",
sidebarCalendarOpenMonthView: "Open month view",
calendarPageTitle: "Calendar",
calendarPagePrevMonth: "Previous month",
calendarPageNextMonth: "Next month",
calendarPageThisWeek: "This week",
calendarPageGridLabel: "Month calendar",
calendarPageUntitledEvent: "Event",
calendarPageMoreEventsAria: "Show {{count}} more events for this day",
calendarPageDayEventCount: "{{count}} events",
calendarDayPanelEmpty:
"No cached events for this day. Close the panel, open the month calendar again, then tap “+N”.",
calendarDayPanelInvalidDate: "Invalid date.",
"Looking for more events…": "Looking for more events…", "Looking for more events…": "Looking for more events…",
"Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.", "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.",
"Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)", "Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)",

12
src/i18n/locales/zh.ts

@ -760,6 +760,18 @@ export default {
sidebarCalendarEmptyWeek: "No calendar events this week.", sidebarCalendarEmptyWeek: "No calendar events this week.",
sidebarCalendarLoading: "Loading…", sidebarCalendarLoading: "Loading…",
sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.", sidebarCalendarNoRelays: "Add read relays in settings to load calendar events.",
sidebarCalendarOpenMonthView: "Open month view",
calendarPageTitle: "Calendar",
calendarPagePrevMonth: "Previous month",
calendarPageNextMonth: "Next month",
calendarPageThisWeek: "This week",
calendarPageGridLabel: "Month calendar",
calendarPageUntitledEvent: "Event",
calendarPageMoreEventsAria: "Show {{count}} more events for this day",
calendarPageDayEventCount: "{{count}} events",
calendarDayPanelEmpty:
"No cached events for this day. Close the panel, open the month calendar again, then tap “+N”.",
calendarDayPanelInvalidDate: "Invalid date.",
"Looking for more events…": "Looking for more events…", "Looking for more events…": "Looking for more events…",
"Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.", "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.": "Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.",
"Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)", "Per-relay timeline results ({{count}} connections)": "Per-relay timeline results ({{count}} connections)",

24
src/lib/calendar-day-panel-cache.ts

@ -0,0 +1,24 @@
import type { Event } from 'nostr-tools'
const KEY_PREFIX = 'jumble:calendarDayPanel:'
/** Persist calendar events for a day so the secondary panel can load them without a giant URL. */
export function setCalendarDayPanelEvents(ymd: string, events: Event[]): void {
try {
sessionStorage.setItem(KEY_PREFIX + ymd, JSON.stringify(events))
} catch {
/* quota or private mode */
}
}
export function readCalendarDayPanelEvents(ymd: string): Event[] | null {
try {
const raw = sessionStorage.getItem(KEY_PREFIX + ymd)
if (!raw) return null
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return null
return parsed.filter((x): x is Event => x && typeof x === 'object' && typeof (x as Event).id === 'string')
} catch {
return null
}
}

10
src/lib/calendar-event.ts

@ -201,6 +201,16 @@ export function calendarOccurrenceOverlapsRange(
} }
/** Monday 00:00 local through the following Monday 00:00 (exclusive), shifted by `weekOffset` weeks from the anchor week. */ /** Monday 00:00 local through the following Monday 00:00 (exclusive), shifted by `weekOffset` weeks from the anchor week. */
/** Local midnight on the 1st through midnight on the 1st of the following month (exclusive). */
export function getLocalMonthRangeMs(
year: number,
monthIndex: number
): { startMs: number; endExclusiveMs: number } {
const start = new Date(year, monthIndex, 1, 0, 0, 0, 0)
const end = new Date(year, monthIndex + 1, 1, 0, 0, 0, 0)
return { startMs: start.getTime(), endExclusiveMs: end.getTime() }
}
export function getLocalMondayWeekBounds( export function getLocalMondayWeekBounds(
weekOffset: number, weekOffset: number,
anchor: Date = new Date() anchor: Date = new Date()

5
src/lib/document-meta.ts

@ -76,7 +76,8 @@ const PRIMARY_PAGE_LABEL: Record<string, string> = {
'follows-latest': 'Latest follows', 'follows-latest': 'Latest follows',
rss: 'RSS', rss: 'RSS',
settings: 'Settings', settings: 'Settings',
spells: 'Spells' spells: 'Spells',
calendar: 'Calendar'
} }
function relayHostnameFromPath(pathname: string): string | null { function relayHostnameFromPath(pathname: string): string | null {
@ -104,7 +105,7 @@ export function isNoteDetailPathname(pathname: string): boolean {
const path = pathname.split('?')[0].split('#')[0] const path = pathname.split('?')[0].split('#')[0]
return ( return (
/\/notes\/[^/?#]+/.test(path) || /\/notes\/[^/?#]+/.test(path) ||
/\/(?:discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\/[^/?#]+/.test( /\/(?:discussions|search|profile|home|feed|spells|explore|rss|follows-latest|calendar)\/notes\/[^/?#]+/.test(
path path
) )
) )

2
src/lib/tag.ts

@ -313,7 +313,7 @@ export function getPubkeysFromPTags(tags: string[][]) {
new Set( new Set(
tags tags
.filter(tagNameEquals('p')) .filter(tagNameEquals('p'))
.map(([, pubkey]) => pubkey) .map(([, pubkey]) => (pubkey ? pubkey.trim().toLowerCase() : ''))
.filter((pubkey) => !!pubkey && isValidPubkey(pubkey)) .filter((pubkey) => !!pubkey && isValidPubkey(pubkey))
.reverse() .reverse()
) )

527
src/pages/primary/CalendarPrimaryPage.tsx

@ -0,0 +1,527 @@
import PrimaryPageLayout, { type TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout'
import {
calendarOccurrenceOverlapsRange,
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 { RefreshButton } from '@/components/RefreshButton'
import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react'
import { type Event } 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 dedupeCalendarEvents(events: Event[]): Event[] {
const map = new Map<string, Event>()
for (const e of events) {
const k = replaceableEventDedupeKey(e)
const prev = map.get(k)
if (!prev || e.created_at > prev.created_at) map.set(k, e)
}
return [...map.values()]
}
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<TPageRef, CalendarPrimaryPageProps>(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 layoutRef = useRef<TPrimaryPageLayoutRef>(null)
const [refreshKey, setRefreshKey] = useState(0)
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<Event[]>([])
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<string>()
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
setLoading(true)
void (async () => {
try {
const { rangeStartMs, rangeEndExclusiveMs } = paddedMonthRange
const fromIdb = await indexedDb.getCalendarEventsForOccurrenceWindow(
rangeStartMs,
rangeEndExclusiveMs,
MONTH_IDB_MAX_SCAN
)
if (!relayUrls.length) {
if (cancelled) return
const fromSession = client.getSessionEventsMatchingSearch(
'',
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents(dedupeCalendarEvents([...fromIdb, ...fromSession]))
lateMergeTimer = window.setTimeout(() => {
lateMergeTimer = null
if (cancelled) return
const later = client.getSessionEventsMatchingSearch(
'',
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later, ...fromIdb]))
}, 2500)
return
}
const batch = await client.fetchEvents(
relayUrls,
{
kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME],
limit: FETCH_LIMIT
},
{
cache: true,
globalTimeout: 22_000,
eoseTimeout: 3500,
firstRelayResultGraceMs: false
}
)
if (cancelled) return
const fromFollowing: Event[] = []
if (followAuthorsKey) {
const authorList = followAuthorsKey.split('|').filter(Boolean).slice(0, FOLLOWING_CALENDAR_AUTHORS_CAP)
for (let i = 0; i < authorList.length; i += FOLLOWING_CALENDAR_AUTHORS_CHUNK) {
const authors = authorList.slice(i, i + FOLLOWING_CALENDAR_AUTHORS_CHUNK)
const chunk = await client.fetchEvents(
relayUrls,
{
kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME],
authors,
limit: FOLLOWING_CALENDAR_CHUNK_LIMIT
},
{
cache: true,
globalTimeout: 16_000,
eoseTimeout: 2800,
firstRelayResultGraceMs: false
}
)
if (cancelled) return
fromFollowing.push(...chunk)
}
}
const fromSession = client.getSessionEventsMatchingSearch(
'',
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents(dedupeCalendarEvents([...batch, ...fromFollowing, ...fromSession, ...fromIdb]))
lateMergeTimer = window.setTimeout(() => {
lateMergeTimer = null
if (cancelled) return
const later = client.getSessionEventsMatchingSearch(
'',
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later]))
}, 2500)
} catch {
if (!cancelled) setRawEvents([])
} finally {
if (!cancelled) 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 (
<PrimaryPageLayout
ref={layoutRef}
pageName="calendar"
titlebar={<CalendarPageTitlebar onRefresh={() => setRefreshKey((k) => k + 1)} />}
displayScrollToTopButton
>
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3 p-3 md:p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-1">
<Button type="button" variant="outline" size="icon" className="size-9" onClick={goPrevMonth} aria-label={t('calendarPagePrevMonth')}>
<ChevronLeft className="size-4" />
</Button>
<Button type="button" variant="outline" size="icon" className="size-9" onClick={goNextMonth} aria-label={t('calendarPageNextMonth')}>
<ChevronRight className="size-4" />
</Button>
</div>
<h2 className="text-center text-lg font-semibold tracking-tight text-foreground md:text-xl">{monthTitle}</h2>
<Button type="button" variant="secondary" size="sm" className="shrink-0" onClick={jumpToThisWeek}>
{t('calendarPageThisWeek')}
</Button>
</div>
{loading && rawEvents.length === 0 ? (
<p className="text-center text-sm text-muted-foreground">{t('sidebarCalendarLoading')}</p>
) : null}
{isSmallScreen ? (
<div className="flex min-w-0 flex-col gap-2" aria-label={t('calendarPageGridLabel')}>
{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 (
<div
key={`m-${viewYear}-${viewMonth}-${day}`}
className={cn(
'flex min-w-0 flex-col gap-1 rounded-lg border border-border bg-card p-3',
inWeek && 'bg-primary/10 ring-1 ring-inset ring-primary/35'
)}
>
<div className="flex items-baseline justify-between gap-2 border-b border-border/50 pb-1">
<span className="text-sm font-semibold text-foreground">
{weekdayShort} · {day}
</span>
{list.length > 0 ? (
<span className="text-xs tabular-nums text-muted-foreground">
{t('calendarPageDayEventCount', { count: list.length })}
</span>
) : null}
</div>
<ul className="min-w-0 space-y-0.5">
{list.slice(0, 4).map((ev) => {
const meta = getCalendarEventMeta(ev)
const title = meta.title?.trim() || t('calendarPageUntitledEvent')
return (
<li key={replaceableEventDedupeKey(ev)} className="min-w-0">
<button
type="button"
onClick={() => navigateToNote(toNote(ev), ev)}
className={cn(
'w-full truncate rounded-md px-2 py-1.5 text-left text-xs font-medium leading-snug text-primary',
'hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
)}
>
{title}
</button>
</li>
)
})}
</ul>
{excess > 0 ? (
<Button
type="button"
variant="secondary"
size="sm"
className="h-9 w-full shrink-0 text-xs font-semibold"
aria-label={t('calendarPageMoreEventsAria', { count: excess })}
onClick={() => openDayEventsPanel(day)}
>
+{excess}
</Button>
) : null}
</div>
)
})}
</div>
) : (
<div
className="grid min-w-0 gap-px rounded-lg border border-border bg-border"
style={{ gridTemplateColumns: 'repeat(7, minmax(0, 1fr))' }}
role="grid"
aria-label={t('calendarPageGridLabel')}
>
{weekdayLabels.map((label) => (
<div
key={label}
className="bg-muted/80 px-1 py-2 text-center text-[10px] font-semibold uppercase tracking-wide text-muted-foreground md:text-xs"
role="columnheader"
>
{label}
</div>
))}
{gridCells.map((cell, idx) => {
if (cell.day == null) {
return <div key={`pad-${idx}`} className="min-h-[72px] bg-muted/20 md:min-h-[96px]" />
}
const day = cell.day
const list = eventsForDay(day)
const inWeek = isDayInHighlightWeek(day)
const excess = list.length > 4 ? list.length - 4 : 0
return (
<div
key={`${viewYear}-${viewMonth}-${day}`}
className={cn(
'flex min-h-[72px] min-w-0 flex-col gap-0.5 bg-card p-1 md:min-h-[96px] md:p-1.5',
inWeek && 'bg-primary/10 ring-1 ring-inset ring-primary/35'
)}
role="gridcell"
>
<span className="text-[11px] font-semibold tabular-nums text-foreground md:text-xs">{day}</span>
<ul className="min-w-0 flex-1 space-y-0.5 overflow-hidden">
{list.slice(0, 4).map((ev) => {
const meta = getCalendarEventMeta(ev)
const title = meta.title?.trim() || t('calendarPageUntitledEvent')
return (
<li key={replaceableEventDedupeKey(ev)} className="min-w-0">
<button
type="button"
onClick={() => navigateToNote(toNote(ev), ev)}
className={cn(
'w-full truncate rounded px-0.5 py-px text-left text-[9px] font-medium leading-tight text-primary underline-offset-2',
'hover:bg-muted/80 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:text-[10px]'
)}
title={title}
>
{title}
</button>
</li>
)
})}
</ul>
{excess > 0 ? (
<Button
type="button"
variant="secondary"
size="sm"
className="mt-0.5 h-7 w-full shrink-0 px-1 text-[10px] font-semibold md:h-8 md:text-[11px]"
aria-label={t('calendarPageMoreEventsAria', { count: excess })}
onClick={() => openDayEventsPanel(day)}
>
+{excess}
</Button>
) : null}
</div>
)
})}
</div>
)}
</div>
</PrimaryPageLayout>
)
})
function CalendarPageTitlebar({ onRefresh }: { onRefresh: () => void }) {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center justify-between gap-2 pr-1">
<div className="flex items-center gap-2 pl-3">
<CalendarDays className="size-5 shrink-0" aria-hidden />
<div className="app-chrome-title">{t('calendarPageTitle')}</div>
</div>
<RefreshButton onClick={onRefresh} />
</div>
)
}
CalendarPrimaryPage.displayName = 'CalendarPrimaryPage'
export default CalendarPrimaryPage

101
src/pages/secondary/CalendarDayEventsPage/index.tsx

@ -0,0 +1,101 @@
import { getCalendarEventMeta, getCalendarOccurrenceWindowMs } from '@/lib/calendar-event'
import { readCalendarDayPanelEvents } from '@/lib/calendar-day-panel-cache'
import { replaceableEventDedupeKey } from '@/lib/event'
import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { useSmartNoteNavigation } from '@/PageManager'
import { TPageRef } from '@/types'
import { type Event } from 'nostr-tools'
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
const YMD_RE = /^\d{4}-\d{2}-\d{2}$/
const CalendarDayEventsPage = forwardRef<TPageRef, { ymd: string; index?: number }>(function CalendarDayEventsPage(
{ ymd, index },
ref
) {
const { t, i18n } = useTranslation()
const { navigateToNote } = useSmartNoteNavigation()
const [events, setEvents] = useState<Event[]>([])
const validYmd = typeof ymd === 'string' && YMD_RE.test(ymd)
useEffect(() => {
if (!validYmd) {
setEvents([])
return
}
setEvents(readCalendarDayPanelEvents(ymd) ?? [])
}, [ymd, validYmd])
const title = useMemo(() => {
if (!validYmd) return t('calendarPageTitle')
const [y, m, d] = ymd.split('-').map(Number)
const dt = new Date(y, (m ?? 1) - 1, d ?? 1, 12, 0, 0, 0)
return new Intl.DateTimeFormat(i18n.language, {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric'
}).format(dt)
}, [ymd, validYmd, i18n.language])
const sorted = useMemo(() => {
return [...events].sort((a, b) => {
const wa = getCalendarOccurrenceWindowMs(a)?.startMs ?? a.created_at * 1000
const wb = getCalendarOccurrenceWindowMs(b)?.startMs ?? b.created_at * 1000
return wa - wb
})
}, [events])
useImperativeHandle(ref, () => ({
scrollToTop: (behavior?: ScrollBehavior) => {
window.scrollTo({ top: 0, behavior: behavior ?? 'smooth' })
}
}))
if (!validYmd) {
return (
<SecondaryPageLayout ref={ref} index={index} title={t('calendarPageTitle')}>
<p className="px-4 py-6 text-sm text-muted-foreground">{t('calendarDayPanelInvalidDate')}</p>
</SecondaryPageLayout>
)
}
return (
<SecondaryPageLayout ref={ref} index={index} title={title} displayScrollToTopButton>
<div className="min-w-0 px-3 py-3 md:px-4">
{sorted.length === 0 ? (
<p className="text-sm text-muted-foreground leading-relaxed">{t('calendarDayPanelEmpty')}</p>
) : (
<ul className="min-w-0 space-y-1">
{sorted.map((ev) => {
const meta = getCalendarEventMeta(ev)
const label = meta.title?.trim() || t('calendarPageUntitledEvent')
return (
<li key={replaceableEventDedupeKey(ev)}>
<Button
type="button"
variant="ghost"
className={cn(
'h-auto min-h-10 w-full justify-start whitespace-normal px-3 py-2 text-left text-sm font-medium'
)}
onClick={() => navigateToNote(toNote(ev), ev)}
>
{label}
</Button>
</li>
)
})}
</ul>
)}
</div>
</SecondaryPageLayout>
)
})
CalendarDayEventsPage.displayName = 'CalendarDayEventsPage'
export default CalendarDayEventsPage

3
src/routes.tsx

@ -36,6 +36,7 @@ const SettingsPageLazy = lazy(() => import('./pages/secondary/SettingsPage'))
const WalletPageLazy = lazy(() => import('./pages/secondary/WalletPage')) const WalletPageLazy = lazy(() => import('./pages/secondary/WalletPage'))
const FollowPacksRedirectLazy = lazy(() => import('./pages/secondary/FollowPacksRedirect')) const FollowPacksRedirectLazy = lazy(() => import('./pages/secondary/FollowPacksRedirect'))
const RssArticlePageLazy = lazy(() => import('./pages/secondary/RssArticlePage')) const RssArticlePageLazy = lazy(() => import('./pages/secondary/RssArticlePage'))
const CalendarDayEventsPageLazy = lazy(() => import('./pages/secondary/CalendarDayEventsPage'))
const routeSuspenseFallback = null const routeSuspenseFallback = null
@ -59,6 +60,8 @@ const ROUTES = [
{ path: '/feed/notes/:id', element: SR(NotePageLazy) }, { path: '/feed/notes/:id', element: SR(NotePageLazy) },
{ path: '/spells/notes/:id', element: SR(NotePageLazy) }, { path: '/spells/notes/:id', element: SR(NotePageLazy) },
{ path: '/rss/notes/:id', element: SR(NotePageLazy) }, { path: '/rss/notes/:id', element: SR(NotePageLazy) },
{ path: '/calendar/notes/:id', element: SR(NotePageLazy) },
{ path: '/calendar/day/:ymd', element: SR(CalendarDayEventsPageLazy) },
{ path: '/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, { path: '/rss-item/:articleKey', element: SR(RssArticlePageLazy) },
{ path: '/rss/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, { path: '/rss/rss-item/:articleKey', element: SR(RssArticlePageLazy) },
{ path: '/feed/rss-item/:articleKey', element: SR(RssArticlePageLazy) }, { path: '/feed/rss-item/:articleKey', element: SR(RssArticlePageLazy) },

6
src/services/client-query.service.ts

@ -842,6 +842,12 @@ export class QueryService {
const socialKindBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) const socialKindBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u))
const stripped = relays.filter((url) => !socialKindBlockedSet.has(normalizeUrl(url) || url)) const stripped = relays.filter((url) => !socialKindBlockedSet.has(normalizeUrl(url) || url))
relays = relaysAfterSocialKindBlockedStrip(originalDedupedRelays, stripped) relays = relaysAfterSocialKindBlockedStrip(originalDedupedRelays, stripped)
if (relays.length === 0) {
const fallback = [...FAST_READ_RELAY_URLS].filter(
(url) => !socialKindBlockedSet.has(normalizeUrl(url) || url)
)
relays = fallback.length > 0 ? fallback : [...FAST_READ_RELAY_URLS]
}
} }
if (relayFiltersUseCapitalLetterTagKeys(filters)) { if (relayFiltersUseCapitalLetterTagKeys(filters)) {
relays = relayUrlsStripExtendedTagReqBlocked(relays) relays = relayUrlsStripExtendedTagReqBlocked(relays)

Loading…
Cancel
Save