2026-09-14 21:01:53 +00:00
|
|
|
|
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"
|
2026-09-18 18:08:00 +00:00
|
|
|
|
import {PROTECTED} from "@app/rooms"
|
2026-09-14 21:01:53 +00:00
|
|
|
|
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<CalendarView>({
|
|
|
|
|
|
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<string, TrustedEvent[]>()
|
|
|
|
|
|
|
|
|
|
|
|
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<string, TrustedEvent[]>,
|
|
|
|
|
|
): CalendarBar[] => {
|
|
|
|
|
|
const windowStart = days[0]
|
|
|
|
|
|
const windowEnd = days[days.length - 1]
|
|
|
|
|
|
const seen = new Set<string>()
|
|
|
|
|
|
const spans: Omit<CalendarBar, "lane">[] = []
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-09-18 18:08:00 +00:00
|
|
|
|
const h = tagValue(tagSpec("h"), event.tags)
|
2026-09-14 21:01:53 +00:00
|
|
|
|
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) {
|
2026-09-18 18:08:00 +00:00
|
|
|
|
tags.push(["h", h])
|
2026-09-14 21:01:53 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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])
|
|
|
|
|
|
}
|