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, ",")}`);