You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
597 lines
20 KiB
597 lines
20 KiB
import { ExtendedKind, isNip52CalendarCardKind } from '@/constants' |
|
import { replaceableEventDedupeKey } from '@/lib/event' |
|
import { generateBech32IdFromATag, tagNameEquals } from '@/lib/tag' |
|
import { Event } from 'nostr-tools' |
|
|
|
/** NIP-52 collaborative calendar (addressable kind). */ |
|
export const NIP52_CALENDAR_KIND = 31924 |
|
|
|
export type Nip52CalendarRTag = { value: string; isHttpUrl: boolean } |
|
|
|
export type Nip52CalendarInclusionRow = { coordinate: string; naddr: string } |
|
|
|
export type Nip52CalendarTagExtras = { |
|
locations: string[] |
|
rTags: Nip52CalendarRTag[] |
|
dayGranularities: string[] |
|
calendarInclusions: Nip52CalendarInclusionRow[] |
|
unknownTags: string[][] |
|
} |
|
|
|
const NIP52_CALENDAR_EVENT_KNOWN_TAG_NAMES = new Set([ |
|
'd', |
|
'D', |
|
'title', |
|
'summary', |
|
'image', |
|
'location', |
|
'g', |
|
'p', |
|
't', |
|
'r', |
|
'a', |
|
'start', |
|
'end', |
|
'start_tzid', |
|
'end_tzid', |
|
'name', |
|
/** App attribution; rendered under the title in calendar UI, not in “other tags”. */ |
|
'client' |
|
]) |
|
|
|
/** Parsed NIP-52 calendar event tags not fully covered by {@link getCalendarEventMeta}. */ |
|
export function getNip52CalendarEventTagExtras(event: Event): Nip52CalendarTagExtras { |
|
const locations = event.tags |
|
.filter(tagNameEquals('location')) |
|
.map((t) => t[1]?.trim()) |
|
.filter((x): x is string => !!x) |
|
const rTags: Nip52CalendarRTag[] = event.tags |
|
.filter(tagNameEquals('r')) |
|
.map((t) => { |
|
const v = t[1]?.trim() ?? '' |
|
return { value: v, isHttpUrl: /^https?:\/\//i.test(v) } |
|
}) |
|
.filter((e) => e.value.length > 0) |
|
const dayGranularities = event.tags |
|
.filter((t) => t[0] === 'D') |
|
.map((t) => t[1]?.trim()) |
|
.filter((x): x is string => !!x) |
|
const calendarInclusions: Nip52CalendarInclusionRow[] = [] |
|
for (const t of event.tags.filter(tagNameEquals('a'))) { |
|
const coord = t[1]?.trim() |
|
if (!coord) continue |
|
const kindStr = coord.split(':')[0] ?? '' |
|
const kind = parseInt(kindStr, 10) |
|
if (!Number.isFinite(kind) || kind !== NIP52_CALENDAR_KIND) continue |
|
const naddr = generateBech32IdFromATag(t) |
|
if (!naddr) continue |
|
calendarInclusions.push({ coordinate: coord, naddr }) |
|
} |
|
const unknownTags = event.tags.filter((tag) => { |
|
const n = tag[0] |
|
if (n == null || n === '') return false |
|
return !NIP52_CALENDAR_EVENT_KNOWN_TAG_NAMES.has(n) |
|
}) |
|
return { locations, rTags, dayGranularities, calendarInclusions, unknownTags } |
|
} |
|
|
|
export interface CalendarEventMeta { |
|
title: string |
|
summary: string |
|
image: string |
|
/** Time-based: Unix seconds. Date-based: undefined. */ |
|
start: number | undefined |
|
/** Time-based: Unix seconds. Date-based: undefined. */ |
|
end: number | undefined |
|
/** Date-based: YYYY-MM-DD. Time-based: undefined. */ |
|
startDate: string |
|
/** Date-based: YYYY-MM-DD (exclusive end). Time-based: undefined. */ |
|
endDate: string |
|
isDateBased: boolean |
|
/** First `r` tag with an http(s) URL (join / registration link). */ |
|
joinUrl: string |
|
/** Same as {@link joinUrl}; every http(s) `r` value. */ |
|
rUrl: string |
|
rUrls: string[] |
|
/** All `location` tag values (NIP-52 allows repeated). */ |
|
locations: string[] |
|
/** First location, for compact UI. */ |
|
location: string |
|
/** `d` tag (replaceable identifier). */ |
|
d: string |
|
/** `g` tag (geohash). */ |
|
geo: string |
|
/** `start_tzid` (IANA zone). */ |
|
startTzid: string |
|
/** `end_tzid` (IANA zone). */ |
|
endTzid: string |
|
topics: string[] |
|
} |
|
|
|
export function getCalendarEventMeta(event: Event): CalendarEventMeta { |
|
const rawTitle = event.tags.find(tagNameEquals('title'))?.[1]?.trim() ?? '' |
|
const nameFallback = event.tags.find(tagNameEquals('name'))?.[1]?.trim() ?? '' |
|
const title = rawTitle || nameFallback |
|
const summary = event.tags.find(tagNameEquals('summary'))?.[1] ?? '' |
|
const image = event.tags.find(tagNameEquals('image'))?.[1] ?? '' |
|
const startStr = event.tags.find(tagNameEquals('start'))?.[1] |
|
const endStr = event.tags.find(tagNameEquals('end'))?.[1] |
|
const locations = event.tags |
|
.filter(tagNameEquals('location')) |
|
.map((t) => t[1]?.trim()) |
|
.filter((x): x is string => !!x) |
|
const location = locations[0] ?? '' |
|
const d = event.tags.find(tagNameEquals('d'))?.[1] ?? '' |
|
const geo = event.tags.find(tagNameEquals('g'))?.[1] ?? '' |
|
const startTzid = event.tags.find(tagNameEquals('start_tzid'))?.[1] ?? '' |
|
const endTzid = event.tags.find(tagNameEquals('end_tzid'))?.[1] ?? '' |
|
const rUrls = event.tags |
|
.filter(tagNameEquals('r')) |
|
.map((t) => t[1]?.trim()) |
|
.filter((u): u is string => !!u && (u.startsWith('http://') || u.startsWith('https://'))) |
|
const rUrl = rUrls[0] ?? '' |
|
const joinUrl = rUrl |
|
const topics = event.tags.filter(tagNameEquals('t')).map((t) => t[1]?.trim()).filter(Boolean) |
|
const isDateBased = event.kind === ExtendedKind.CALENDAR_EVENT_DATE |
|
if (isDateBased) { |
|
return { |
|
title, |
|
summary, |
|
image, |
|
start: undefined, |
|
end: undefined, |
|
startDate: startStr ?? '', |
|
endDate: endStr ?? '', |
|
isDateBased: true, |
|
joinUrl, |
|
rUrl, |
|
rUrls, |
|
locations, |
|
location, |
|
d, |
|
geo, |
|
startTzid, |
|
endTzid, |
|
topics |
|
} |
|
} |
|
const start = startStr ? parseInt(startStr, 10) : undefined |
|
const end = endStr ? parseInt(endStr, 10) : undefined |
|
return { |
|
title, |
|
summary, |
|
image, |
|
start, |
|
end, |
|
startDate: '', |
|
endDate: '', |
|
isDateBased: false, |
|
joinUrl, |
|
rUrl, |
|
rUrls, |
|
locations, |
|
location, |
|
d, |
|
geo, |
|
startTzid, |
|
endTzid, |
|
topics |
|
} |
|
} |
|
|
|
/** |
|
* Drop leading/trailing lines that are only `#word` tokens when every word matches a NIP-52 |
|
* `t` tag (already shown as topic chips). Typical duplicate: body ends with `#run #walk` mirroring `t` tags. |
|
*/ |
|
export function stripCalendarEventRedundantTopicHashtagLines( |
|
content: string, |
|
topics: readonly string[] |
|
): string { |
|
const topicSet = new Set( |
|
topics.map((x) => x.trim().toLowerCase()).filter((x): x is string => x.length > 0) |
|
) |
|
if (topicSet.size === 0) return content |
|
|
|
const lines = content.split('\n') |
|
|
|
const isHashtagOnlyLine = (line: string): boolean => { |
|
const t = line.trim() |
|
if (!t) return false |
|
const parts = t.split(/\s+/).filter(Boolean) |
|
return parts.every((p) => /^#[a-zA-Z0-9_]+$/.test(p)) |
|
} |
|
|
|
const tagsFromHashtagOnlyLine = (line: string): string[] => |
|
line |
|
.trim() |
|
.split(/\s+/) |
|
.filter(Boolean) |
|
.map((p) => p.slice(1).toLowerCase()) |
|
|
|
let start = 0 |
|
let end = lines.length |
|
|
|
while (start < end) { |
|
const line = lines[start] |
|
if (line.trim() === '') { |
|
start++ |
|
continue |
|
} |
|
if (!isHashtagOnlyLine(line)) break |
|
const tags = tagsFromHashtagOnlyLine(line) |
|
if (!tags.every((tag) => topicSet.has(tag))) break |
|
start++ |
|
} |
|
|
|
while (end > start) { |
|
const line = lines[end - 1] |
|
if (line.trim() === '') { |
|
end-- |
|
continue |
|
} |
|
if (!isHashtagOnlyLine(line)) break |
|
const tags = tagsFromHashtagOnlyLine(line) |
|
if (!tags.every((tag) => topicSet.has(tag))) break |
|
end-- |
|
} |
|
|
|
return lines.slice(start, end).join('\n').trimEnd() |
|
} |
|
|
|
const CALENDAR_DISPLAY_LOCALE = 'en-US' |
|
|
|
/** Safe fallback when `Date` is invalid — avoids `Intl.DateTimeFormat#formatToParts` throwing. */ |
|
const INVALID_DATE_PARTS: Record<Intl.DateTimeFormatPartTypes, string> = new Proxy( |
|
{} as Record<Intl.DateTimeFormatPartTypes, string>, |
|
{ |
|
get(_target, prop: string | symbol) { |
|
if (typeof prop !== 'string') return '\u2014' |
|
if (prop === 'literal') return '' |
|
if (prop === 'dayPeriod' || prop === 'timeZoneName') return '' |
|
return '\u2014' |
|
} |
|
} |
|
) |
|
|
|
function readFormatParts( |
|
d: Date, |
|
opts: Intl.DateTimeFormatOptions |
|
): Record<Intl.DateTimeFormatPartTypes, string> { |
|
if (!Number.isFinite(d.getTime())) return INVALID_DATE_PARTS |
|
const out: Partial<Record<Intl.DateTimeFormatPartTypes, string>> = {} |
|
for (const p of new Intl.DateTimeFormat(CALENDAR_DISPLAY_LOCALE, opts).formatToParts(d)) { |
|
if (p.type !== 'literal') out[p.type] = p.value |
|
} |
|
return out as Record<Intl.DateTimeFormatPartTypes, string> |
|
} |
|
|
|
/** |
|
* Single instant: explicit English month + day + year + 12-hour clock + short timezone |
|
* (e.g. `May 13, 2025 10:30 am EST`) in the viewer's local zone — avoids DD/MM vs MM/DD ambiguity. |
|
*/ |
|
export function formatCalendarTime(ts: number): string { |
|
if (!Number.isFinite(ts)) return '\u2014' |
|
const ms = ts * 1000 |
|
if (!Number.isFinite(ms)) return '\u2014' |
|
const d = new Date(ms) |
|
if (!Number.isFinite(d.getTime())) return '\u2014' |
|
const p = readFormatParts(d, { |
|
month: 'long', |
|
day: 'numeric', |
|
year: 'numeric', |
|
hour: 'numeric', |
|
minute: '2-digit', |
|
hour12: true, |
|
timeZoneName: 'short' |
|
}) |
|
const ap = (p.dayPeriod ?? '').toLowerCase() |
|
const tz = p.timeZoneName ?? '' |
|
return `${p.month} ${p.day}, ${p.year} ${p.hour}:${p.minute} ${ap} ${tz}`.trim() |
|
} |
|
|
|
/** `start` / `end` Unix seconds; omits end time if invalid or not after start. Same calendar day → one date line. */ |
|
export function formatCalendarTimeRange(start: number, end: number | undefined): string { |
|
const startLine = formatCalendarTime(start) |
|
if (end == null || Number.isNaN(end) || end <= start) return startLine |
|
|
|
const a = new Date(start * 1000) |
|
const b = new Date(end * 1000) |
|
const sameLocalDay = |
|
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() |
|
|
|
if (!sameLocalDay) { |
|
return `${formatCalendarTime(start)} – ${formatCalendarTime(end)}` |
|
} |
|
|
|
const dateOnly = readFormatParts(a, { |
|
month: 'long', |
|
day: 'numeric', |
|
year: 'numeric' |
|
}) |
|
const dateStr = `${dateOnly.month} ${dateOnly.day}, ${dateOnly.year}` |
|
|
|
const pStart = readFormatParts(a, { |
|
hour: 'numeric', |
|
minute: '2-digit', |
|
hour12: true, |
|
timeZoneName: 'short' |
|
}) |
|
const pEnd = readFormatParts(b, { |
|
hour: 'numeric', |
|
minute: '2-digit', |
|
hour12: true |
|
}) |
|
const apS = (pStart.dayPeriod ?? '').toLowerCase() |
|
const apE = (pEnd.dayPeriod ?? '').toLowerCase() |
|
const tz = pStart.timeZoneName ?? '' |
|
return `${dateStr} · ${pStart.hour}:${pStart.minute} ${apS} – ${pEnd.hour}:${pEnd.minute} ${apE} ${tz}`.trim() |
|
} |
|
|
|
/** Format a YYYY-MM-DD date string for display (English long month, unambiguous). */ |
|
export function formatCalendarDate(dateStr: string): string { |
|
if (!dateStr) return '' |
|
const d = new Date(dateStr + 'T12:00:00') |
|
if (!Number.isFinite(d.getTime())) return '' |
|
const p = readFormatParts(d, { month: 'long', day: 'numeric', year: 'numeric' }) |
|
return `${p.month} ${p.day}, ${p.year}` |
|
} |
|
|
|
/** Inclusive start and exclusive end (NIP-52); omits end when same as start. */ |
|
export function formatCalendarDateRange(startDate: string, endDate: string): string { |
|
if (!startDate?.trim() && !endDate?.trim()) return '' |
|
if (!startDate?.trim()) return formatCalendarDate(endDate) |
|
const a = formatCalendarDate(startDate) |
|
if (!endDate?.trim() || endDate === startDate) return a |
|
return `${a} – ${formatCalendarDate(endDate)}` |
|
} |
|
|
|
/** Seconds per day for NIP-52 `D` tags: `floor(unix_seconds / 86400)`. */ |
|
const NIP52_SECONDS_PER_DAY = 86400 |
|
|
|
function nip52DayIndexToUtcCalendarParts(dayIndex: number): { month: string; day: string; year: string } { |
|
const ms = dayIndex * NIP52_SECONDS_PER_DAY * 1000 |
|
if (!Number.isFinite(ms)) { |
|
return { month: '\u2014', day: '\u2014', year: '\u2014' } |
|
} |
|
const d = new Date(ms) |
|
if (!Number.isFinite(d.getTime())) { |
|
return { month: '\u2014', day: '\u2014', year: '\u2014' } |
|
} |
|
const parts = new Intl.DateTimeFormat(CALENDAR_DISPLAY_LOCALE, { |
|
month: 'long', |
|
day: 'numeric', |
|
year: 'numeric', |
|
timeZone: 'UTC' |
|
}).formatToParts(d) |
|
const m: Partial<Record<Intl.DateTimeFormatPartTypes, string>> = {} |
|
for (const p of parts) { |
|
if (p.type !== 'literal') m[p.type] = p.value |
|
} |
|
return { |
|
month: m.month ?? '', |
|
day: m.day ?? '', |
|
year: m.year ?? '' |
|
} |
|
} |
|
|
|
/** |
|
* Human-readable summary of NIP-52 `D` (day-granularity) tags: each value is a UTC calendar day index |
|
* from the Unix epoch; publishers repeat `D` for every day a timed event touches so relays can index |
|
* and filter by day. Ranges of consecutive indices are collapsed (e.g. “May 23–25, 2026”). |
|
*/ |
|
export function summarizeNip52DayGranularityTags(dayStrings: readonly string[]): string { |
|
const indices = Array.from( |
|
new Set( |
|
dayStrings |
|
.map((s) => String(s).trim()) |
|
.filter((s) => /^-?\d+$/.test(s)) |
|
.map((s) => parseInt(s, 10)) |
|
.filter((n) => Number.isFinite(n)) |
|
) |
|
).sort((a, b) => a - b) |
|
if (indices.length === 0) return '' |
|
|
|
const ranges: Array<{ start: number; end: number }> = [] |
|
for (const n of indices) { |
|
const last = ranges[ranges.length - 1] |
|
if (last && n === last.end + 1) last.end = n |
|
else ranges.push({ start: n, end: n }) |
|
} |
|
|
|
return ranges |
|
.map(({ start, end }) => { |
|
if (start === end) { |
|
const p = nip52DayIndexToUtcCalendarParts(start) |
|
return `${p.month} ${p.day}, ${p.year}` |
|
} |
|
const a = nip52DayIndexToUtcCalendarParts(start) |
|
const b = nip52DayIndexToUtcCalendarParts(end) |
|
if (a.month === b.month && a.year === b.year) { |
|
return `${a.month} ${a.day}–${b.day}, ${a.year}` |
|
} |
|
if (a.year === b.year) { |
|
return `${a.month} ${a.day} – ${b.month} ${b.day}, ${a.year}` |
|
} |
|
return `${a.month} ${a.day}, ${a.year} – ${b.month} ${b.day}, ${b.year}` |
|
}) |
|
.join(' · ') |
|
} |
|
|
|
/** True for NIP-52 calendar note kinds **31922** / **31923** only (via {@link isNip52CalendarCardKind}). */ |
|
export function isCalendarEventKind(kind: number): boolean { |
|
return isNip52CalendarCardKind(kind) |
|
} |
|
|
|
/** Local midnight at start of `YYYY-MM-DD`; invalid pattern → null. */ |
|
export function parseCalendarYmdToLocalStartMs(ymd: string): number | null { |
|
const t = ymd?.trim() |
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(t)) return null |
|
const [y, mo, d] = t.split('-').map(Number) |
|
if (!y || mo < 1 || mo > 12 || d < 1 || d > 31) return null |
|
const ms = new Date(y, mo - 1, d, 0, 0, 0, 0).getTime() |
|
return Number.isNaN(ms) ? null : ms |
|
} |
|
|
|
/** |
|
* Half-open window [startMs, endExclusiveMs) for overlap with a week |
|
* [weekStartMs, weekEndExclusiveMs). Date-based uses NIP-52 exclusive `end` date. |
|
*/ |
|
export function getCalendarOccurrenceWindowMs( |
|
event: Event |
|
): { startMs: number; endExclusiveMs: number } | null { |
|
const m = getCalendarEventMeta(event) |
|
if (m.isDateBased) { |
|
const s = m.startDate ? parseCalendarYmdToLocalStartMs(m.startDate) : null |
|
if (s == null) return null |
|
if (m.endDate?.trim()) { |
|
if (m.endDate === m.startDate) { |
|
return { startMs: s, endExclusiveMs: s + 86400000 } |
|
} |
|
const e = parseCalendarYmdToLocalStartMs(m.endDate) |
|
return { startMs: s, endExclusiveMs: e != null ? e : s + 86400000 } |
|
} |
|
return { startMs: s, endExclusiveMs: s + 86400000 } |
|
} |
|
if (m.start == null || Number.isNaN(m.start)) return null |
|
const startMs = m.start * 1000 |
|
const endExclusiveMs = |
|
m.end != null && !Number.isNaN(m.end) && m.end > m.start ? m.end * 1000 : startMs + 3600000 |
|
return { startMs, endExclusiveMs } |
|
} |
|
|
|
export function calendarOccurrenceOverlapsRange( |
|
event: Event, |
|
rangeStartMs: number, |
|
rangeEndExclusiveMs: number |
|
): boolean { |
|
const w = getCalendarOccurrenceWindowMs(event) |
|
if (!w) return false |
|
return w.startMs < rangeEndExclusiveMs && w.endExclusiveMs > rangeStartMs |
|
} |
|
|
|
/** |
|
* Deduplicate by replaceable coordinate; when several revisions exist, prefer one whose occurrence **overlaps** |
|
* `[rangeStartMs, rangeEndExclusiveMs)` with a parseable window, then newest `created_at`. Avoids global calendar |
|
* REQs replacing a good local row with a newer revision that does not apply to the visible range. |
|
*/ |
|
export function dedupeCalendarEventsPreferringOccurrenceRange( |
|
events: Event[], |
|
rangeStartMs: number, |
|
rangeEndExclusiveMs: number |
|
): Event[] { |
|
const byKey = new Map<string, Event[]>() |
|
for (const e of events) { |
|
const k = replaceableEventDedupeKey(e) |
|
const list = byKey.get(k) |
|
if (list) list.push(e) |
|
else byKey.set(k, [e]) |
|
} |
|
const out: Event[] = [] |
|
for (const variants of byKey.values()) { |
|
const inRange = variants.filter( |
|
(e) => |
|
getCalendarOccurrenceWindowMs(e) != null && |
|
calendarOccurrenceOverlapsRange(e, rangeStartMs, rangeEndExclusiveMs) |
|
) |
|
const pool = inRange.length > 0 ? inRange : variants |
|
out.push(pool.reduce((best, e) => (e.created_at > best.created_at ? e : best))) |
|
} |
|
return out |
|
} |
|
|
|
/** 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( |
|
weekOffset: number, |
|
anchor: Date = new Date() |
|
): { weekStartMs: number; weekEndExclusiveMs: number } { |
|
const d = new Date(anchor) |
|
d.setHours(0, 0, 0, 0) |
|
const day = d.getDay() |
|
const diffFromMonday = day === 0 ? -6 : 1 - day |
|
const monday = new Date(d) |
|
monday.setDate(d.getDate() + diffFromMonday + weekOffset * 7) |
|
monday.setHours(0, 0, 0, 0) |
|
const end = new Date(monday) |
|
end.setDate(monday.getDate() + 7) |
|
return { weekStartMs: monday.getTime(), weekEndExclusiveMs: end.getTime() } |
|
} |
|
|
|
function toYmdLocal(d: Date): string { |
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` |
|
} |
|
|
|
/** Compact week banner for sidebar (en-US month names). */ |
|
export function formatSidebarWeekLabel(weekStartMs: number, weekEndExclusiveMs: number): string { |
|
if (!Number.isFinite(weekStartMs) || !Number.isFinite(weekEndExclusiveMs)) return '' |
|
const start = new Date(weekStartMs) |
|
const last = new Date(weekEndExclusiveMs) |
|
if (!Number.isFinite(start.getTime()) || !Number.isFinite(last.getTime())) return '' |
|
last.setDate(last.getDate() - 1) |
|
const y1 = start.getFullYear() |
|
const y2 = last.getFullYear() |
|
const m1 = start.getMonth() |
|
const m2 = last.getMonth() |
|
const d1 = start.getDate() |
|
const d2 = last.getDate() |
|
if (y1 === y2 && m1 === m2 && d1 === d2) { |
|
return formatCalendarDate(toYmdLocal(start)) |
|
} |
|
const p1 = readFormatParts(start, { month: 'short', day: 'numeric', year: y1 !== y2 ? 'numeric' : undefined }) |
|
const p2 = readFormatParts(last, { month: 'short', day: 'numeric', year: 'numeric' }) |
|
const left = |
|
y1 !== y2 |
|
? `${p1.month} ${p1.day}, ${p1.year}` |
|
: m1 === m2 |
|
? `${p1.month} ${p1.day}` |
|
: `${p1.month} ${p1.day}` |
|
const right = `${p2.month} ${p2.day}, ${p2.year}` |
|
return `${left} – ${right}` |
|
} |
|
|
|
/** One-line schedule hint for narrow sidebar rows (en-US, includes TZ for timed events). */ |
|
export function formatCalendarSidebarRow(event: Event): string { |
|
const m = getCalendarEventMeta(event) |
|
if (m.isDateBased) { |
|
if (!m.startDate) return '' |
|
const a = formatCalendarDate(m.startDate) |
|
if (m.endDate?.trim() && m.endDate !== m.startDate) { |
|
return `${a} – ${formatCalendarDate(m.endDate)}` |
|
} |
|
return a |
|
} |
|
if (m.start == null || Number.isNaN(m.start) || !Number.isFinite(m.start)) return '' |
|
const d = new Date(m.start * 1000) |
|
if (!Number.isFinite(d.getTime())) return '' |
|
const p = readFormatParts(d, { |
|
month: 'short', |
|
day: 'numeric', |
|
hour: 'numeric', |
|
minute: '2-digit', |
|
hour12: true, |
|
timeZoneName: 'short' |
|
}) |
|
const ap = (p.dayPeriod ?? '').toLowerCase() |
|
const base = `${p.month} ${p.day} · ${p.hour}:${p.minute} ${ap} ${p.timeZoneName ?? ''}`.trim() |
|
if (m.end != null && !Number.isNaN(m.end) && Number.isFinite(m.end) && m.end > m.start) { |
|
const d2 = new Date(m.end * 1000) |
|
if (!Number.isFinite(d2.getTime())) return base |
|
const p2 = readFormatParts(d2, { |
|
hour: 'numeric', |
|
minute: '2-digit', |
|
hour12: true |
|
}) |
|
const ap2 = (p2.dayPeriod ?? '').toLowerCase() |
|
return `${base} – ${p2.hour}:${p2.minute} ${ap2}` |
|
} |
|
return base |
|
}
|
|
|