Files
Calendar_Frontend/lib/url-state.ts
PaddyandClaude Opus 5.5 0cda40f85e Rewrite the calendar frontend in Next.js
Replaces the Angular 18 app with Next.js 16 / React 19 / Tailwind 4 / shadcn,
the same stack and design system as nachklang-feedback, planned in
docs/rewrite-plan.md.

- Admin: all calendars in one mobile-friendly list (month groups, calendar
  chips, Kommend/Vergangen/Alle, search, sort, filters in the URL), an edit
  sheet for create/edit/duplicate/delete, publish confirmation, and an
  "Abonnieren" dialog with subscription links for every calendar.
- Public landing page: subscribe links from env instead of hardcoded, plus
  the next public events, server-rendered and revalidated every 5 minutes.
- All times handled explicitly as Europe/Berlin; whole-day events stored as
  00:00-23:59. Fixes carried over from the Angular app: the move dialog
  leaking a cancelled calendar change, saves that looked saved when they
  failed, in-place Date mutation, and duplicated calendar-id mappings.
- Same shared-session auth as before (proxy.ts gate, /admin/me, 401 vs 403).
- 112 Vitest tests over lib/; docs in docs/ (architecture, features,
  gotchas, API wish list, deployment).

Needs the API branch feature/calendar-ical-escaping-subscriptions deployed
first (GET /calendar/events/subscriptions).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:15:59 +02:00

112 lines
3.7 KiB
TypeScript

import { calendarById, calendarBySlug, DEFAULT_VISIBLE_CALENDAR_IDS } from "@/lib/calendars";
import { RANGES, SORTS, type Range, type Sort } from "@/lib/filter";
/**
* The admin list's view state, kept in the query string so a link reproduces
* a view: `?cal=public,choir&range=past&sort=start_desc&q=probe`.
*
* Defaults are left out of the URL, so the plain `/admin` link means "the
* default view". Anything unrecognised falls back to its default instead of
* failing - these URLs get pasted into chats and bookmarked.
*
* `cal=` with an empty value is kept as "no calendars selected"; only a
* missing `cal` means the default selection.
*
* The default sort depends on the range: looking back ("Vergangen") starts
* with the most recent event, everything else with the earliest. A sort that
* matches its range's default stays out of the URL, so switching ranges
* carries the right default along; a sort someone picked is kept.
*/
export interface ListState {
calendarIds: number[];
range: Range;
sort: Sort;
query: string;
}
export const DEFAULT_LIST_STATE: ListState = {
calendarIds: [...DEFAULT_VISIBLE_CALENDAR_IDS],
range: "future",
sort: "start_asc",
query: "",
};
export const defaultSortFor = (range: Range): Sort => (range === "past" ? "start_desc" : "start_asc");
/**
* The state after switching to `range`: a sort that was only the old range's
* default becomes the new range's default; one that someone picked stays.
*/
export const withRange = (state: ListState, range: Range): ListState => ({
...state,
range,
sort: state.sort === defaultSortFor(state.range) ? defaultSortFor(range) : state.sort,
});
const sameIds = (a: readonly number[], b: readonly number[]): boolean => {
const sortedB = [...b].sort();
return a.length === b.length && [...a].sort().every((id, i) => id === sortedB[i]);
};
export const parseListState = (params: URLSearchParams): ListState => {
const cal = params.get("cal");
const calendarIds =
cal === null
? [...DEFAULT_LIST_STATE.calendarIds]
: [
...new Set(
cal
.split(",")
.map((slug) => calendarBySlug(slug.trim())?.id)
.filter((id): id is number => id !== undefined),
),
];
const rawRange = params.get("range");
const rawSort = params.get("sort");
const range = RANGES.includes(rawRange as Range) ? (rawRange as Range) : DEFAULT_LIST_STATE.range;
return {
calendarIds,
range,
sort: SORTS.includes(rawSort as Sort) ? (rawSort as Sort) : defaultSortFor(range),
query: params.get("q") ?? "",
};
};
export const serializeListState = (state: ListState): URLSearchParams => {
const params = new URLSearchParams();
if (!sameIds(state.calendarIds, DEFAULT_LIST_STATE.calendarIds)) {
params.set(
"cal",
state.calendarIds
.map((id) => calendarById(id)?.slug)
.filter(Boolean)
.join(","),
);
}
if (state.range !== DEFAULT_LIST_STATE.range) {
params.set("range", state.range);
}
if (state.sort !== defaultSortFor(state.range)) {
params.set("sort", state.sort);
}
if (state.query.trim() !== "") {
params.set("q", state.query);
}
return params;
};
/**
* The query string for the address bar, without the leading "?". Commas stay
* literal (`cal=public,choir`) because these URLs are meant to be read and
* shared; URLSearchParams would write `%2C`. A comma inside the search text
* is escaped by hand first, so it cannot be mistaken for a separator - and
* the parser only splits `cal` anyway.
*/
export const toQueryString = (state: ListState): string =>
serializeListState(state)
.toString()
.replace(/(^|&)cal=([^&]*)/, (_, lead: string, value: string) => `${lead}cal=${value.replace(/%2C/gi, ",")}`);