From 9e9c38f9b287827099b7123791c825e294349b0c Mon Sep 17 00:00:00 2001 From: Coracle-Bot Date: Mon, 14 Sep 2026 21:01:53 +0000 Subject: [PATCH] Redesign the calendar with month, week and agenda views (#531) --- e2e/USER_STORIES.md | 2 +- e2e/specs/community.spec.ts | 25 +- src/app/calendar.ts | 297 ++++++++++++++++++ src/app/components/CalendarAgenda.svelte | 156 +++++++++ src/app/components/CalendarDay.svelte | 57 ++++ src/app/components/CalendarEventChip.svelte | 44 +++ src/app/components/CalendarEventCreate.svelte | 9 +- .../components/CalendarEventDiscussion.svelte | 78 +++++ src/app/components/CalendarEventForm.svelte | 17 +- src/app/components/CalendarEventHeader.svelte | 5 +- src/app/components/CalendarEventPeople.svelte | 46 +++ src/app/components/CalendarMonth.svelte | 123 ++++++++ src/app/components/CalendarRsvp.svelte | 104 ++++++ src/app/components/CalendarWeek.svelte | 134 ++++++++ src/app/feeds.ts | 3 + .../spaces/[relay]/calendar/+page.svelte | 234 +++++++++----- .../[relay]/calendar/[address]/+page.svelte | 132 ++++---- 17 files changed, 1310 insertions(+), 156 deletions(-) create mode 100644 src/app/calendar.ts create mode 100644 src/app/components/CalendarAgenda.svelte create mode 100644 src/app/components/CalendarDay.svelte create mode 100644 src/app/components/CalendarEventChip.svelte create mode 100644 src/app/components/CalendarEventDiscussion.svelte create mode 100644 src/app/components/CalendarEventPeople.svelte create mode 100644 src/app/components/CalendarMonth.svelte create mode 100644 src/app/components/CalendarRsvp.svelte create mode 100644 src/app/components/CalendarWeek.svelte diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md index b5260fed..b5a6fc0f 100644 --- a/e2e/USER_STORIES.md +++ b/e2e/USER_STORIES.md @@ -753,7 +753,7 @@ Acceptance: - The create form requires a title and a start/end time. - Submitting closes the modal and lists the event under its date on the Calendar page. -- The calendar opens scrolled to today or the next upcoming event. +- Switching to the Agenda view scrolls to today or the next upcoming event. ### US-047 — Manage your own calendar event diff --git a/e2e/specs/community.spec.ts b/e2e/specs/community.spec.ts index da073e03..7560369d 100644 --- a/e2e/specs/community.spec.ts +++ b/e2e/specs/community.spec.ts @@ -109,6 +109,9 @@ test("US-046 create and browse a calendar event", async ({seed, as}) => { const {url} = scenario.space("space") const page = await as(users.alice, `${spacePath(url)}/calendar`) + // This story is about the scrollable list spanning past and future events, not the default view + await page.getByRole("button", {name: "Agenda", exact: true}).click() + const cards = page.getByRole("link").filter({hasText: "Posted by"}) const card = (title: string) => page.getByRole("link").filter({hasText: title}) @@ -204,8 +207,9 @@ test("US-047 manage your own calendar event", async ({seed, as}) => { await openCard(page.getByRole("link").filter({hasText: "Harvest Supper"}), "Harvest Supper") - // The event has no replies, so the page carries exactly one feature card. - const eventCard = page.locator(".card.z-feature") + // The hero card (date, header, meta, actions) is always the first feature card on the page; + // the About tab renders its own feature card below it for the description. + const eventCard = page.locator(".card.z-feature").first() await expect(page.getByRole("heading", {name: "Harvest Supper", exact: true})).toBeVisible() await expect(eventCard).toContainText(await longDate(page, at(-2, DAY))) @@ -773,18 +777,20 @@ test("US-052 comment on and react to community posts", async ({seed, as}) => { await openCard(bob.getByRole("link").filter({hasText: "Autumn Fair"}), "Autumn Fair") - const eventCard = bob.locator(".card.z-feature").filter({hasText: "Stalls, cider"}) + // The hero card carries reactions; the description sits in its own card under the About tab. + const heroCard = bob.locator(".card.z-feature").first() - await expect(eventCard).toBeVisible() + await expect(bob.getByText("Stalls, cider and a tug of war.")).toBeVisible() - await pickParty(bob, emojiButton(eventCard)) + await pickParty(bob, emojiButton(heroCard)) - const bobsPill = eventCard.getByRole("button", {name: PARTY}) + const bobsPill = heroCard.getByRole("button", {name: PARTY}) await expect(bobsPill).toHaveCount(1) await expect(bobsPill).toHaveClass(/button-primary/) await expect(bobsPill).toHaveAttribute("data-tip", "Bob Barker reacted") + await bob.getByRole("button", {name: /^Discussion/}).click() await bob.getByRole("button", {name: "Leave comment"}).click() const reply = bob.locator("form").filter({has: bob.locator(".note-editor")}) @@ -798,12 +804,15 @@ test("US-052 comment on and react to community posts", async ({seed, as}) => { await openCard(alice.getByRole("link").filter({hasText: "Autumn Fair"}), "Autumn Fair") - const alicesView = alice.locator(".card.z-feature").filter({hasText: "Stalls, cider"}) + const alicesHeroCard = alice.locator(".card.z-feature").first() - await expect(alicesView.getByRole("button", {name: PARTY})).toHaveAttribute( + await expect(alicesHeroCard.getByRole("button", {name: PARTY})).toHaveAttribute( "data-tip", "Bob Barker reacted", ) + + await alice.getByRole("button", {name: /^Discussion/}).click() + await expect(alice.getByText("Is there parking at The Green?")).toBeVisible() await alice.getByRole("button", {name: "Leave comment"}).click() diff --git a/src/app/calendar.ts b/src/app/calendar.ts new file mode 100644 index 00000000..1a3fd832 --- /dev/null +++ b/src/app/calendar.ts @@ -0,0 +1,297 @@ +import { + HOUR, + LOCALE, + dateToSeconds, + int, + pushToMapKey, + secondsToDate, + sortBy, + uniqBy, +} from "@welshman/lib" +import {EVENT_RSVP, getAddress, makeEvent, tagSpec, tagValue} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {TimeEvent} from "@welshman/domain" +import {synced} from "@welshman/store" +import {deletes, reader, relays, thunks} from "@app/core" +import {deriveEvents} from "@app/repository" +import {PROTECTED, ROOM} from "@app/rooms" +import {kv} from "@app/storage" + +// Views + +export type CalendarView = "agenda" | "week" | "month" + +// People tend to stick with one way of reading a calendar, so open it the way they left it +export const calendarView = synced({ + key: "calendarView", + defaultValue: "month", + storage: kv, +}) + +// Dates + +const startOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()) + +export const addDays = (date: Date, days: number) => { + const result = startOfDay(date) + + result.setDate(result.getDate() + days) + + return result +} + +export const isSameDay = (a: Date, b: Date) => + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + +const startOfWeek = (date: Date) => addDays(date, -date.getDay()) + +export const isToday = (date: Date) => isSameDay(date, new Date()) + +export const isCurrentMonth = (date: Date) => { + const today = new Date() + + return date.getFullYear() === today.getFullYear() && date.getMonth() === today.getMonth() +} + +export const isCurrentWeek = (date: Date) => isSameDay(startOfWeek(date), startOfWeek(new Date())) + +// Six rows so the grid's height doesn't change from month to month +export const getMonthDays = (date: Date) => { + const start = startOfWeek(new Date(date.getFullYear(), date.getMonth(), 1)) + + return Array.from({length: 42}, (_, index) => addDays(start, index)) +} + +export const getWeekDays = (date: Date) => { + const start = startOfWeek(date) + + return Array.from({length: 7}, (_, index) => addDays(start, index)) +} + +export const addMonths = (date: Date, months: number) => + new Date(date.getFullYear(), date.getMonth() + months, 1) + +export const makeDayKey = (date: Date) => + `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` + +export const formatMonth = (date: Date) => + Intl.DateTimeFormat(LOCALE, {month: "long", year: "numeric"}).format(date) + +export const formatWeekday = (date: Date) => + Intl.DateTimeFormat(LOCALE, {weekday: "short"}).format(date) + +export const formatDay = (date: Date) => + Intl.DateTimeFormat(LOCALE, {weekday: "long", month: "long", day: "numeric"}).format(date) + +export const formatWeekRange = (date: Date) => { + const days = getWeekDays(date) + const first = Intl.DateTimeFormat(LOCALE, {month: "short", day: "numeric"}).format(days[0]) + const last = Intl.DateTimeFormat(LOCALE, { + month: "short", + day: "numeric", + year: "numeric", + }).format(days[6]) + + return `${first} – ${last}` +} + +// A day picked out of a grid carries no time of day, so start it at a neutral hour +export const makeDayRange = (day: Date) => { + const start = dateToSeconds(new Date(day.getFullYear(), day.getMonth(), day.getDate(), 12)) + + return {start, end: start + int(1, HOUR)} +} + +// Calendar events + +export const getEventStart = (event: TrustedEvent) => reader(TimeEvent)(event).start() + +// NIP-52 settled on `title`, but events from before that still carry `name`. +export const getEventTitle = (event: TrustedEvent) => + reader(TimeEvent)(event).title() ?? tagValue(tagSpec("name"), event.tags) + +// An event covering several days is only introduced on the first of them; the rest continue it. +export const startsOnDay = (event: TrustedEvent, day: Date) => { + const start = getEventStart(event) + + return Boolean(start && isSameDay(secondsToDate(start), day)) +} + +// Multi-day events show up on each day they cover, in start order within each day. A malformed +// end date could span an unbounded number of days, so stop after a year. +export const groupEventsByDay = (events: TrustedEvent[]) => { + const result = new Map() + + for (const event of events) { + const timeEvent = reader(TimeEvent)(event) + const start = timeEvent.start() + + if (start) { + const lastDay = startOfDay(secondsToDate(timeEvent.end() ?? start)) + + let day = startOfDay(secondsToDate(start)) + + for (let index = 0; day <= lastDay && index < 366; index++) { + pushToMapKey(result, makeDayKey(day), event) + day = addDays(day, 1) + } + } + } + + for (const [key, dayEvents] of result) { + result.set( + key, + sortBy(event => getEventStart(event) ?? 0, dayEvents), + ) + } + + return result +} + +// Whether an event spans more than one calendar day — see layoutMultiDayBars. +export const isMultiDayEvent = (event: TrustedEvent) => { + const timeEvent = reader(TimeEvent)(event) + const start = timeEvent.start() + + if (!start) { + return false + } + + return !isSameDay(secondsToDate(start), secondsToDate(timeEvent.end() ?? start)) +} + +export const getSingleDayEvents = (events: TrustedEvent[]) => + events.filter(event => !isMultiDayEvent(event)) + +export type CalendarBar = { + event: TrustedEvent + startCol: number + span: number + lane: number + continuesBefore: boolean + continuesAfter: boolean +} + +const daysApart = (a: Date, b: Date) => + Math.round((a.getTime() - b.getTime()) / (24 * 60 * 60 * 1000)) + +// Lays multi-day events spanning `days` out as bars instead of a chip per day, packing +// overlapping ones into as few vertical lanes as possible. +export const layoutMultiDayBars = ( + days: Date[], + eventsByDay: Map, +): CalendarBar[] => { + const windowStart = days[0] + const windowEnd = days[days.length - 1] + const seen = new Set() + const spans: Omit[] = [] + + for (const day of days) { + for (const event of eventsByDay.get(makeDayKey(day)) ?? []) { + if (seen.has(event.id) || !isMultiDayEvent(event)) { + continue + } + + seen.add(event.id) + + const timeEvent = reader(TimeEvent)(event) + const start = timeEvent.start()! + const eventStart = startOfDay(secondsToDate(start)) + const eventEnd = startOfDay(secondsToDate(timeEvent.end() ?? start)) + const startCol = Math.max(0, daysApart(eventStart, windowStart)) + const endCol = Math.min(days.length - 1, daysApart(eventEnd, windowStart)) + + spans.push({ + event, + startCol, + span: endCol - startCol + 1, + continuesBefore: eventStart < windowStart, + continuesAfter: eventEnd > windowEnd, + }) + } + } + + const laneEnds: number[] = [] + const bars: CalendarBar[] = [] + + for (const span of sortBy(s => s.startCol, spans)) { + let lane = laneEnds.findIndex(end => end < span.startCol) + + if (lane === -1) { + lane = laneEnds.length + } + + laneEnds[lane] = span.startCol + span.span - 1 + bars.push({...span, lane}) + } + + return bars +} + +// RSVPs (NIP-52) + +export enum RsvpStatus { + Accepted = "accepted", + Declined = "declined", + Tentative = "tentative", +} + +export const makeRsvpFilter = (event: TrustedEvent) => ({ + kinds: [EVENT_RSVP], + "#a": [getAddress(event)], +}) + +export const deriveRsvps = (event: TrustedEvent) => deriveEvents([makeRsvpFilter(event)]) + +export const getRsvpStatus = (rsvp: TrustedEvent) => tagValue(tagSpec("status"), rsvp.tags) + +// An RSVP replaces the sender's previous one, but a relay can still be holding both, so the +// newest per person is the one that counts. +export const getRsvpsByStatus = (rsvps: TrustedEvent[]) => { + const latest = uniqBy( + rsvp => rsvp.pubkey, + sortBy(rsvp => -rsvp.created_at, rsvps), + ) + + const byStatus = (status: RsvpStatus) => + latest.filter(rsvp => getRsvpStatus(rsvp) === status).map(rsvp => rsvp.pubkey) + + return { + latest, + accepted: byStatus(RsvpStatus.Accepted), + tentative: byStatus(RsvpStatus.Tentative), + declined: byStatus(RsvpStatus.Declined), + } +} + +// An RSVP is addressable, so identifying it by its target makes a new one supersede the old. +export const publishRsvp = async (url: string, event: TrustedEvent, status: RsvpStatus) => { + const address = getAddress(event) + const h = tagValue(tagSpec(ROOM), event.tags) + const tags = [ + ["a", address, url], + ["e", event.id, url], + ["d", address], + ["status", status], + ["p", event.pubkey], + ] + + if (await relays.get().hasNip(url, 70)) { + tags.push(PROTECTED) + } + + if (h) { + tags.push([ROOM, h]) + } + + return thunks.get().publish({relays: [url], event: makeEvent(EVENT_RSVP, {tags})}) +} + +export const retractRsvp = async (url: string, rsvp: TrustedEvent) => { + const protect = await relays.get().hasNip(url, 70) + const command = await deletes.get().deleteEvent(rsvp, writer => writer.setProtected(protect)) + + return command.publishToRelays([url]) +} diff --git a/src/app/components/CalendarAgenda.svelte b/src/app/components/CalendarAgenda.svelte new file mode 100644 index 00000000..2df74a41 --- /dev/null +++ b/src/app/components/CalendarAgenda.svelte @@ -0,0 +1,156 @@ + + +
+ {#each items as { event, dateDisplay, isFirstFutureEvent } (event.id)} +
+ {#if isFirstFutureEvent} +
+
+

Today

+
+
+ {/if} + {#if dateDisplay} + {dateDisplay} + {/if} + +
+ {/each} + {#if loading} +

+ +

+ {:else if items.length === 0} +

No events found.

+ {:else} +

That's all!

+ {/if} +
diff --git a/src/app/components/CalendarDay.svelte b/src/app/components/CalendarDay.svelte new file mode 100644 index 00000000..aafc07d9 --- /dev/null +++ b/src/app/components/CalendarDay.svelte @@ -0,0 +1,57 @@ + + + + + + {formatDay(date)} + + {dayEvents.length === 1 ? "1 event" : `${dayEvents.length} events`} + + + {#each dayEvents as event (event.id)} + + {/each} + + + + + + diff --git a/src/app/components/CalendarEventChip.svelte b/src/app/components/CalendarEventChip.svelte new file mode 100644 index 00000000..3558c6cd --- /dev/null +++ b/src/app/components/CalendarEventChip.svelte @@ -0,0 +1,44 @@ + + + + {#if continuesBefore} + → + {:else if start} + {formatTimestampAsTime(start)} + {/if} + {getEventTitle(event)} + {#if continuesAfter} + → + {/if} + diff --git a/src/app/components/CalendarEventCreate.svelte b/src/app/components/CalendarEventCreate.svelte index 9ffe8f33..0f7ba123 100644 --- a/src/app/components/CalendarEventCreate.svelte +++ b/src/app/components/CalendarEventCreate.svelte @@ -3,17 +3,22 @@ import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import CalendarEventForm from "@app/components/CalendarEventForm.svelte" + import {makeDayRange} from "@app/calendar" type Props = { url: string h?: string shareToChat?: boolean + date?: Date } - const {url, h, shareToChat = false}: Props = $props() + const {url, h, shareToChat = false, date}: Props = $props() + + // Creating from a day in the grid starts the event on that day + const range = date ? makeDayRange(date) : undefined - + {#snippet header()} Create an Event diff --git a/src/app/components/CalendarEventDiscussion.svelte b/src/app/components/CalendarEventDiscussion.svelte new file mode 100644 index 00000000..6c85b84c --- /dev/null +++ b/src/app/components/CalendarEventDiscussion.svelte @@ -0,0 +1,78 @@ + + +
+ {#if ordered.length === 0 && !showReply} +
+

No comments yet — start the conversation.

+ +
+ {:else} + {#if !showAll && ordered.length > 4} +
+ +
+ {/if} + {#each ordered.slice(0, showAll ? undefined : 4) as reply (reply.id)} + +
+ + +
+
+ {/each} + {#if showReply} + + {:else} +
+ +
+ {/if} + {/if} +
diff --git a/src/app/components/CalendarEventForm.svelte b/src/app/components/CalendarEventForm.svelte index 3354fe68..7904eb8a 100644 --- a/src/app/components/CalendarEventForm.svelte +++ b/src/app/components/CalendarEventForm.svelte @@ -39,9 +39,19 @@ shareToChat?: boolean header: Snippet initialValues?: Values + defaultStart?: number + defaultEnd?: number } - let {url, h, shareToChat = false, header, initialValues}: Props = $props() + let { + url, + h, + shareToChat = false, + header, + initialValues, + defaultStart, + defaultEnd, + }: Props = $props() const draftKey = new DraftKey(`calendar:${url}:${h ?? ""}`) @@ -129,8 +139,9 @@ const d = $state(initialValues?.d ?? randomId()) let title = $state(initialValues?.title ?? "") let location = $state(initialValues?.location ?? "") - let start: number | undefined = $state(initialValues?.start) - let end: number | undefined = $state(initialValues?.end) + // A day chosen before the form opened is more current than whatever the draft was left on + let start: number | undefined = $state(defaultStart ?? initialValues?.start) + let end: number | undefined = $state(defaultEnd ?? initialValues?.end) let content = $state(initialValues?.content ?? "") const onChange = (json: object) => { diff --git a/src/app/components/CalendarEventHeader.svelte b/src/app/components/CalendarEventHeader.svelte index ce61ee43..dd812cb1 100644 --- a/src/app/components/CalendarEventHeader.svelte +++ b/src/app/components/CalendarEventHeader.svelte @@ -1,11 +1,11 @@ diff --git a/src/app/components/CalendarEventPeople.svelte b/src/app/components/CalendarEventPeople.svelte new file mode 100644 index 00000000..e25a1f39 --- /dev/null +++ b/src/app/components/CalendarEventPeople.svelte @@ -0,0 +1,46 @@ + + +
+
+

Host

+
+ +
+
+ {#each sections as section (section.label)} +
+

{section.label} · {section.pubkeys.length}

+ {#each section.pubkeys as pubkey (pubkey)} +
+ +
+ {/each} +
+ {:else} +

+ No one has responded yet — be the first. +

+ {/each} +
diff --git a/src/app/components/CalendarMonth.svelte b/src/app/components/CalendarMonth.svelte new file mode 100644 index 00000000..5225a169 --- /dev/null +++ b/src/app/components/CalendarMonth.svelte @@ -0,0 +1,123 @@ + + +
+
+ {#each weeks[0] as day (day.getTime())} +
{formatWeekday(day)}
+ {/each} +
+ {#each weeks as week, weekIndex (week[0].getTime())} + {@const bars = weekBars[weekIndex]} +
+ {#each week as day, col (day.getTime())} + + {/each} + {#each bars as bar (bar.event.id)} +
+ +
+ {/each} + {#each week as day, col (day.getTime())} + {@const dayEvents = getSingleDayEvents(eventsByDay.get(makeDayKey(day)) ?? [])} +
+ {#each dayEvents.slice(0, 3) as event (event.id)} + + {/each} + {#if dayEvents.length > 3} + + {/if} +
+ {/each} +
+ {/each} +
diff --git a/src/app/components/CalendarRsvp.svelte b/src/app/components/CalendarRsvp.svelte new file mode 100644 index 00000000..573f67a4 --- /dev/null +++ b/src/app/components/CalendarRsvp.svelte @@ -0,0 +1,104 @@ + + +
+
+ Are you going? +
+ + + +
+ {#if loading} + + {/if} +
+ {#if attending.length > 0} + {@const summary = [ + `${people.accepted.length} going`, + people.tentative.length > 0 ? `${people.tentative.length} maybe` : undefined, + people.declined.length > 0 ? `${people.declined.length} can't go` : undefined, + ] + .filter(Boolean) + .join(" · ")} + + {#if onShowPeople} + + {:else} +
+ + {summary} +
+ {/if} + {/if} +
diff --git a/src/app/components/CalendarWeek.svelte b/src/app/components/CalendarWeek.svelte new file mode 100644 index 00000000..ae3ff28d --- /dev/null +++ b/src/app/components/CalendarWeek.svelte @@ -0,0 +1,134 @@ + + + +
+
+ {#each days as day (day.getTime())} + {@const dayEvents = eventsByDay.get(makeDayKey(day)) ?? []} +
+ + {#each dayEvents as event (event.id)} + + {:else} + — + {/each} +
+ {/each} +
+ +
diff --git a/src/app/feeds.ts b/src/app/feeds.ts index 7134e97e..94a24ef8 100644 --- a/src/app/feeds.ts +++ b/src/app/feeds.ts @@ -789,6 +789,9 @@ export const makeCalendarFeed = ({ events, loadOlder, loadNewer, + // The month and week views jump to arbitrary ranges rather than scrolling through them, and + // wait on the request so they can show progress for the range on screen + load: loadTimeframe, cleanup: () => { controller.abort() unsubscribers.forEach(call) diff --git a/src/routes/spaces/[relay]/calendar/+page.svelte b/src/routes/spaces/[relay]/calendar/+page.svelte index e26f7a6a..533c63b5 100644 --- a/src/routes/spaces/[relay]/calendar/+page.svelte +++ b/src/routes/spaces/[relay]/calendar/+page.svelte @@ -1,24 +1,39 @@ + + {#snippet leading()} @@ -129,6 +164,25 @@ Calendar {/snippet} {#snippet action()} +
+ + + {#if !isNarrow} + + {/if} +
+ {rangeLabel} + + + + + {#if rangeLoading} + + {/if} + + + {/if} + {#if view === "agenda"} + + {:else if view === "week"} + + {:else} + + {/if} + diff --git a/src/routes/spaces/[relay]/calendar/[address]/+page.svelte b/src/routes/spaces/[relay]/calendar/[address]/+page.svelte index c2854c57..5b920dc9 100644 --- a/src/routes/spaces/[relay]/calendar/[address]/+page.svelte +++ b/src/routes/spaces/[relay]/calendar/[address]/+page.svelte @@ -1,27 +1,30 @@ + + @@ -73,47 +86,54 @@ {#if $event} -
-
- -
- - -
-
-
- -
-
-
+
+ +
+ + +
- {#if !showAll && $replies.length > 4} -
- + +
+ + + +
+ {#if tab === "about"} +
+ {#if $event.content.trim()} + + {:else} +

+ The host hasn't added a description. +

+ {/if}
- {/if} - {#each sortBy(e => e.created_at, $replies).slice(0, showAll ? undefined : 4) as reply (reply.id)} - -
- - -
-
- {/each} - {#if showReply} - + {:else if tab === "discussion"} + {:else} -
- -
+ {/if} {:else}