Files
Calendar_Frontend/lib/filter.ts
T
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

110 lines
4.1 KiB
TypeScript

import { berlinDay, formatMonth, monthKey, nextOccurrence } from "@/lib/dates";
import type { CalendarEvent } from "@/lib/types";
/**
* Filtering, sorting and grouping for the admin list. All client-side: the
* Angular app re-fetched from the server every time the filter changed, even
* though the data did not.
*/
export type Range = "future" | "past" | "all";
export type Sort = "start_asc" | "start_desc" | "created_asc" | "created_desc";
export const RANGES: readonly Range[] = ["future", "past", "all"];
export const SORTS: readonly Sort[] = ["start_asc", "start_desc", "created_asc", "created_desc"];
export interface ListFilter {
calendarIds: readonly number[];
range: Range;
query: string;
}
export const isRecurring = (event: CalendarEvent): boolean => event.repeatFrequency !== "";
/**
* When the event next happens, for sorting and grouping. A repeating event's
* original start can be decades back (a birthday is stored with the year of
* birth); what matters in the list is the next time it comes round.
*/
export const effectiveStart = (event: CalendarEvent, now: Date): Date =>
isRecurring(event) ? nextOccurrence(event.start, event.repeatFrequency, now) : event.start;
/**
* Repeating events always count as current - under "future" and "all", never
* under "past". This replaces the Angular rule "calendar 5 ignores the
* filter", which keyed on the calendar rather than on the thing that actually
* makes an event timeless.
*
* "future" compares Berlin days, not instants, so an event that ended earlier
* today is still listed until midnight.
*/
export const matchesRange = (event: CalendarEvent, range: Range, now: Date): boolean => {
if (range === "all") {
return true;
}
if (isRecurring(event)) {
return range === "future";
}
const upcoming = berlinDay(event.end) >= berlinDay(now);
return range === "future" ? upcoming : !upcoming;
};
const normalise = (s: string): string => s.toLocaleLowerCase("de-DE").normalize("NFC");
/** Case-insensitive substring match on title and location; every word must match. */
export const matchesQuery = (event: CalendarEvent, query: string): boolean => {
const words = normalise(query).split(/\s+/).filter(Boolean);
if (words.length === 0) {
return true;
}
const haystack = normalise(`${event.name} ${event.location}`);
return words.every((w) => haystack.includes(w));
};
export const filterEvents = (events: readonly CalendarEvent[], filter: ListFilter, now: Date): CalendarEvent[] =>
events.filter(
(e) => filter.calendarIds.includes(e.calendarId) && matchesRange(e, filter.range, now) && matchesQuery(e, filter.query),
);
const byName = (a: CalendarEvent, b: CalendarEvent): number => a.name.localeCompare(b.name, "de-DE");
/**
* A stable, total order - ties fall back to the title and then the id. The
* Angular comparator returned only 1 or -1, never 0, which is not a valid
* comparator and let equal events swap places between renders.
*/
export const sortEvents = (events: readonly CalendarEvent[], sort: Sort, now: Date): CalendarEvent[] => {
const key = (e: CalendarEvent): number =>
sort.startsWith("created") ? (e.created?.getTime() ?? 0) : effectiveStart(e, now).getTime();
const direction = sort.endsWith("desc") ? -1 : 1;
return [...events].sort((a, b) => (key(a) - key(b)) * direction || byName(a, b) || a.eventId - b.eventId);
};
export interface MonthGroup {
/** "2026-10" */
key: string;
/** "Oktober 2026" */
label: string;
events: CalendarEvent[];
}
/**
* Consecutive runs of events in the same month (by effective start), in the
* order given. Meant for a list sorted by start; with another order, a month
* can legitimately appear more than once.
*/
export const groupByMonth = (events: readonly CalendarEvent[], now: Date): MonthGroup[] => {
const groups: MonthGroup[] = [];
for (const event of events) {
const start = effectiveStart(event, now);
const key = monthKey(start);
const last = groups.at(-1);
if (last && last.key === key) {
last.events.push(event);
} else {
groups.push({ key, label: formatMonth(start), events: [event] });
}
}
return groups;
};