blob: 6ffdcf2a4fbb7e259311b6e8ef4404ce56b34519 [file]
<script lang="ts">
import type { CalendarEvent, Calendars, EventDraft, Filters, SessionInfo, ViewName } from "./lib/types";
import { VIEWS } from "./lib/types";
import { api, ApiError } from "./lib/api";
import { rangeForView, startOfDay, step } from "./lib/dates";
import { applyFilters, defaultFilters, projectsInPlay, sortEvents } from "./lib/filters";
import { canCreateAnything, canEdit, draftFromEvent, newDraft } from "./lib/drafts";
import type { DisplayZone } from "./lib/timezone";
import {
LOCAL,
UTC,
fromWall,
normaliseZone,
nowInZone,
toWall,
uniqueZones,
} from "./lib/timezone";
import { appPath, withoutBase } from "./lib/base";
import Header from "./components/Header.svelte";
import FilterPanel from "./components/FilterPanel.svelte";
import MonthView from "./components/MonthView.svelte";
import WeekView from "./components/WeekView.svelte";
import YearView from "./components/YearView.svelte";
import AgendaView from "./components/AgendaView.svelte";
import EventDialog from "./components/EventDialog.svelte";
import HelpPage from "./components/HelpPage.svelte";
import ApiDocs from "./components/ApiDocs.svelte";
import ImportDialog from "./components/ImportDialog.svelte";
const VIEW_STORAGE_KEY = "asf-calendar-view";
const ZONE_STORAGE_KEY = "asf-calendar-zone";
const ALT_ZONE_STORAGE_KEY = "asf-calendar-alt-zone";
const COMPARE_ZONES_STORAGE_KEY = "asf-calendar-compare-zones";
/** More than three extra clocks and the week grid has no room left for days. */
const MAX_COMPARE_ZONES = 3;
const SHORTLINK_PATTERN = /^\/e\/([A-Za-z0-9]+)\/?$/;
const HELP_PATH = "/help";
const DOCS_PATH = "/docs";
/** The current browser path with the deployment's mount point removed. */
function routePath(): string | null {
return withoutBase(globalThis.location?.pathname ?? "/");
}
function stored(key: string): string | null {
try {
return globalThis.localStorage?.getItem(key) ?? null;
} catch {
return null;
}
}
function remember(key: string, value: string): void {
try {
globalThis.localStorage?.setItem(key, value);
} catch {
// Private browsing, or storage turned off. Not worth complaining about.
}
}
function initialView(): ViewName {
const saved = stored(VIEW_STORAGE_KEY);
return (VIEWS as readonly string[]).includes(saved ?? "") ? (saved as ViewName) : "month";
}
function initialZone(): DisplayZone {
return normaliseZone(stored(ZONE_STORAGE_KEY), LOCAL);
}
/** The zone the picker is parked on. UTC until the visitor picks another. */
function initialAlternateZone(): DisplayZone {
const saved = normaliseZone(stored(ALT_ZONE_STORAGE_KEY), UTC);
return saved === LOCAL ? UTC : saved;
}
function initialCompareZones(): DisplayZone[] {
const saved = stored(COMPARE_ZONES_STORAGE_KEY);
if (!saved) return [];
return uniqueZones(
saved
.split(",")
.map((name) => normaliseZone(name, ""))
.filter((name): name is DisplayZone => Boolean(name)),
).slice(0, MAX_COMPARE_ZONES);
}
/** True once the visitor has expressed a preference of their own. */
function hasChosenZone(): boolean {
return Boolean(stored(ZONE_STORAGE_KEY));
}
let view = $state<ViewName>(initialView());
let zone = $state<DisplayZone>(initialZone());
let alternateZone = $state<DisplayZone>(initialAlternateZone());
let compareZones = $state<DisplayZone[]>(initialCompareZones());
// The cursor is a wall date: its local getters read as the display zone.
let cursor = $state<Date>(startOfDay(nowInZone(initialZone())));
let helpOpen = $state<boolean>(routePath() === HELP_PATH);
let docsOpen = $state<boolean>(routePath() === DOCS_PATH);
let events = $state<CalendarEvent[]>([]);
let session = $state<SessionInfo | null>(null);
let calendars = $state<Calendars | null>(null);
let filters = $state<Filters>(defaultFilters(null));
let loading = $state(false);
let saving = $state(false);
let loadError = $state<string | null>(null);
let dialogError = $state<string | null>(null);
let showFilters = $state(true);
let selected = $state<CalendarEvent | null>(null);
let draft = $state<EventDraft | null>(null);
let importing = $state(false);
let range = $derived(rangeForView(view, cursor));
let knownProjects = $derived(projectsInPlay(events, calendars));
let visible = $derived(sortEvents(applyFilters(events, filters, knownProjects), filters.sort, zone));
let title = $derived(calendars?.title ?? "ASF Community Calendar");
let editable = $derived(selected ? canEdit(selected, calendars) : false);
let feedUrl = $derived(
api.feedUrl({
start: fromWall(range.start, zone),
end: fromWall(range.end, zone),
categories: [...filters.categories],
visibility: filters.visibility,
q: filters.search || undefined,
}),
);
// ---- loading ------------------------------------------------------------
async function loadIdentity() {
try {
const [who, cals] = await Promise.all([api.session(), api.calendars()]);
session = who;
calendars = cals;
// Everything the user belongs to is on by default.
filters = defaultFilters(cals);
// The deployment can say which clock to open on; the visitor's own
// choice, once made, always wins.
if (!hasChosenZone()) zone = normaliseZone(cals.default_display_zone, zone);
} catch (error) {
loadError = error instanceof ApiError ? error.message : String(error);
}
}
let loadToken = 0;
async function loadEvents(from: Date, to: Date) {
const token = ++loadToken;
loading = true;
try {
const found = await api.events({ start: from, end: to });
// A slow earlier request must not overwrite a newer result.
if (token === loadToken) {
events = found;
loadError = null;
}
} catch (error) {
if (token === loadToken) loadError = error instanceof ApiError ? error.message : String(error);
} finally {
if (token === loadToken) loading = false;
}
}
$effect(() => {
// The grid works in wall time; the API works in instants.
const from = fromWall(range.start, zone);
const to = fromWall(range.end, zone);
void loadEvents(from, to);
});
$effect(() => {
remember(VIEW_STORAGE_KEY, view);
});
$effect(() => {
remember(ZONE_STORAGE_KEY, zone);
});
$effect(() => {
remember(ALT_ZONE_STORAGE_KEY, alternateZone);
});
$effect(() => {
remember(COMPARE_ZONES_STORAGE_KEY, compareZones.join(","));
});
// ---- shortlink routing --------------------------------------------------
async function openFromLocation() {
const path = routePath();
if (path === null) return; // Not a URL this app is mounted on.
helpOpen = path === HELP_PATH;
docsOpen = path === DOCS_PATH;
const match = SHORTLINK_PATTERN.exec(path);
if (!match) return;
try {
const event = await api.byShortlink(match[1]);
selected = event;
cursor = startOfDay(toWall(new Date(event.start), zone));
} catch (error) {
loadError =
error instanceof ApiError && error.needsLogin
? "That event is not public. Log in to see whether you have access."
: "That shortlink does not point at an event you can see.";
}
}
/** Pushes a route, in app terms: navigate("/help") -> /calendar/help. */
function navigate(path: string) {
if (!globalThis.history) return;
const target = appPath(path);
if (globalThis.location.pathname !== target) globalThis.history.pushState({}, "", target);
}
function pushShortlink(event: CalendarEvent | null) {
if (!globalThis.location) return;
if (!event) {
navigate("/");
return;
}
// The backend builds shortlink_url with the mount point already in it, so
// push its path as-is rather than prefixing it a second time.
const path = new URL(event.shortlink_url, globalThis.location.href).pathname;
if (globalThis.location.pathname !== path) globalThis.history.pushState({}, "", path);
}
$effect(() => {
void openFromLocation();
const onPop = () => void openFromLocation();
globalThis.addEventListener?.("popstate", onPop);
return () => globalThis.removeEventListener?.("popstate", onPop);
});
void loadIdentity();
// ---- actions ------------------------------------------------------------
function selectEvent(event: CalendarEvent) {
selected = event;
draft = null;
dialogError = null;
pushShortlink(event);
}
function closeDialog() {
selected = null;
draft = null;
dialogError = null;
pushShortlink(null);
}
function startCreate(when: Date = nowInZone(zone)) {
selected = null;
dialogError = null;
helpOpen = false;
docsOpen = false;
// The form is filled in from the wall clock the user is looking at.
draft = newDraft(fromWall(when, zone), calendars);
}
function startImport() {
selected = null;
draft = null;
dialogError = null;
helpOpen = false;
docsOpen = false;
importing = true;
}
async function finishImport(count: number) {
importing = false;
// The imported events may be anywhere in time, so reload the window rather
// than trying to splice them in.
await loadEvents(fromWall(range.start, zone), fromWall(range.end, zone));
loadError = count === 0 ? "Nothing was imported." : null;
}
function startEdit() {
if (selected) draft = draftFromEvent(selected);
}
async function save(next: EventDraft) {
saving = true;
dialogError = null;
try {
const saved = selected ? await api.update(selected.id, next) : await api.create(next);
events = [...events.filter((event) => event.id !== saved.id), saved];
selected = saved;
draft = null;
pushShortlink(saved);
} catch (error) {
dialogError = error instanceof ApiError ? error.message : String(error);
} finally {
saving = false;
}
}
async function remove() {
if (!selected) return;
if (!globalThis.confirm?.(`Delete "${selected.title}"? This cannot be undone.`)) return;
saving = true;
dialogError = null;
try {
await api.remove(selected.id);
events = events.filter((event) => event.id !== selected!.id);
closeDialog();
} catch (error) {
dialogError = error instanceof ApiError ? error.message : String(error);
} finally {
saving = false;
}
}
function goToDay(day: Date) {
cursor = startOfDay(day);
view = "day";
}
function goToMonth(month: Date) {
cursor = startOfDay(month);
view = "month";
}
/**
* Switching the display zone keeps the date you are looking at. You asked to
* see March 10th in UTC, not "whatever March 10th local becomes in UTC".
*/
function setZone(next: DisplayZone) {
zone = next;
// A clock cannot usefully be compared against itself.
compareZones = uniqueZones(compareZones, next);
}
function setCompareZones(next: DisplayZone[]) {
compareZones = uniqueZones(next, zone).slice(0, MAX_COMPARE_ZONES);
}
function toggleHelp() {
helpOpen = !helpOpen;
if (helpOpen) docsOpen = false;
navigate(helpOpen ? HELP_PATH : "/");
}
function toggleDocs() {
docsOpen = !docsOpen;
if (docsOpen) helpOpen = false;
navigate(docsOpen ? DOCS_PATH : "/");
}
</script>
<div class="layout">
<Header
{title}
{view}
{cursor}
{session}
{loading}
canCreate={canCreateAnything(calendars)}
{zone}
{alternateZone}
{helpOpen}
{docsOpen}
onview={(next) => {
view = next;
helpOpen = false;
docsOpen = false;
}}
onstep={(direction) => (cursor = step(view, cursor, direction))}
ontoday={() => (cursor = startOfDay(nowInZone(zone)))}
oncreate={() => startCreate(cursor)}
onimport={startImport}
ontogglefilters={() => (showFilters = !showFilters)}
onzone={setZone}
onalternatezone={(next) => (alternateZone = next)}
onhelp={toggleHelp}
ondocs={toggleDocs}
/>
{#if loadError}
<p class="banner" role="alert">
{loadError}
<button type="button" class="btn" onclick={() => (loadError = null)}>Dismiss</button>
</p>
{/if}
<div class="body">
{#if helpOpen}
<HelpPage {calendars} {session} {zone} {compareZones} onclose={toggleHelp} />
{:else if docsOpen}
<ApiDocs onclose={toggleDocs} />
{:else}
{#if showFilters}
<FilterPanel
{filters}
{calendars}
projects={knownProjects}
{view}
{feedUrl}
{zone}
{compareZones}
maxCompareZones={MAX_COMPARE_ZONES}
onchange={(next) => (filters = next)}
oncomparezones={setCompareZones}
/>
{/if}
<main>
{#if view === "month"}
<MonthView {cursor} events={visible} {zone} onselect={selectEvent} onpickday={goToDay} />
{:else if view === "week"}
<WeekView
{cursor}
events={visible}
{zone}
{compareZones}
onselect={selectEvent}
onpickday={goToDay}
/>
{:else if view === "day"}
<WeekView
{cursor}
events={visible}
{zone}
{compareZones}
days={1}
onselect={selectEvent}
onpickday={goToDay}
/>
{:else if view === "year"}
<YearView {cursor} events={visible} {zone} onpickday={goToDay} onpickmonth={goToMonth} />
{:else}
<AgendaView events={visible} {zone} {compareZones} onselect={selectEvent} />
{/if}
</main>
{/if}
</div>
</div>
{#if importing}
<ImportDialog
{calendars}
{zone}
onclose={() => (importing = false)}
onimported={finishImport}
/>
{/if}
{#if selected || draft}
<EventDialog
event={selected}
{draft}
{calendars}
{saving}
error={dialogError}
{editable}
{zone}
{compareZones}
onclose={closeDialog}
onedit={startEdit}
onsave={save}
ondelete={remove}
/>
{/if}
<style>
.layout {
display: flex;
flex-direction: column;
height: 100%;
}
.body {
display: flex;
flex: 1;
min-height: 0;
}
main {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.banner {
display: flex;
align-items: center;
gap: 0.7rem;
margin: 0;
padding: 0.5rem 0.9rem;
background: hsl(38 92% 92%);
border-bottom: 1px solid hsl(38 60% 70%);
color: hsl(28 70% 25%);
}
@media (prefers-color-scheme: dark) {
.banner {
background: hsl(38 40% 20%);
border-color: hsl(38 40% 35%);
color: hsl(38 80% 82%);
}
}
@media (max-width: 60rem) {
.body {
position: relative;
}
}
</style>