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} 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("h"), 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(["h", 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]) }