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>
This commit is contained in:
138 files changed
+15942
-24010
No files matched your search
+109
@@ -0,0 +1,109 @@
|
||||
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;
|
||||
};
|
||||
Reference in new issue
Block a user