feat(classes): weekly recurring schedules with lazy session materialization

class_schedules rules generate concrete session rows for a rolling 28-day
horizon — no cron; materialize runs idempotently on dashboard and public
reads via a partial unique index on (schedule_id, starts_at). Rule delete
stops future sessions; existing sessions keep bookings and overrides.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-18 17:31:17 +02:00
1 parent 7b9080eb1c
commit ad60167a31
15 files changed
+1029 -7

No files matched your search

@@ -446,6 +446,19 @@ const dictionaries = {
"dashboard.class.queuePosition": "v pořadí",
"dashboard.class.showRoster": "Účastníci",
"dashboard.class.hideRoster": "Skrýt",
"dashboard.class.recurring": "Opakující se lekce",
"dashboard.class.recurringHint": "Týdenní pravidla automaticky vytvářejí lekce na příštích 28 dní. Smazání pravidla nechává již vytvořené lekce nedotčené.",
"dashboard.class.weekday": "Den v týdnu",
"dashboard.class.startsLocal": "Čas",
"dashboard.class.addSchedule": "Přidat pravidlo",
"dashboard.class.noSchedules": "Zatím žádná opakující se pravidla.",
"dashboard.class.dow.0": "Neděle",
"dashboard.class.dow.1": "Pondělí",
"dashboard.class.dow.2": "Úterý",
"dashboard.class.dow.3": "Středa",
"dashboard.class.dow.4": "Čtvrtek",
"dashboard.class.dow.5": "Pátek",
"dashboard.class.dow.6": "Sobota",
"dashboard.waitlistPosition": "Pořadí v čekací listině",
"dashboard.booking.promote": "Potvrdit z čekací listiny",
"dashboard.booking.promoteFull": "Lekce je plná. Přesto potvrdit rezervaci?",
@@ -1052,6 +1065,19 @@ const dictionaries = {
"dashboard.class.queuePosition": "in queue",
"dashboard.class.showRoster": "Roster",
"dashboard.class.hideRoster": "Hide",
"dashboard.class.recurring": "Recurring classes",
"dashboard.class.recurringHint": "Weekly rules automatically create sessions for the next 28 days. Deleting a rule leaves existing sessions untouched.",
"dashboard.class.weekday": "Weekday",
"dashboard.class.startsLocal": "Time",
"dashboard.class.addSchedule": "Add rule",
"dashboard.class.noSchedules": "No recurring rules yet.",
"dashboard.class.dow.0": "Sunday",
"dashboard.class.dow.1": "Monday",
"dashboard.class.dow.2": "Tuesday",
"dashboard.class.dow.3": "Wednesday",
"dashboard.class.dow.4": "Thursday",
"dashboard.class.dow.5": "Friday",
"dashboard.class.dow.6": "Saturday",
"dashboard.waitlistPosition": "Waitlist position",
"dashboard.booking.promote": "Promote from waitlist",
"dashboard.booking.promoteFull": "The session is full. Confirm the booking anyway?",
@@ -3,7 +3,7 @@ import { apiClient } from "../../lib/api-client";
import { Input } from "../../components/ui/input";
import { Select } from "../../components/ui/select";
import { getInitials } from "../../components/dashboard/types";
import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon, PencilIcon, ChevronDownIcon } from "../../components/dashboard/icons";
import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon, PencilIcon, ChevronDownIcon, CalendarDaysIcon } from "../../components/dashboard/icons";
import { DashboardLayout, useDashboardData } from "./layout";
function ZonesPage() {
@@ -30,6 +30,11 @@ function ZonesPage() {
const [newSessionLocationId, setNewSessionLocationId] = createSignal("");
const [newSessionStartsAt, setNewSessionStartsAt] = createSignal("");
const [sessionSaving, setSessionSaving] = createSignal(false);
const [newSchedTemplateId, setNewSchedTemplateId] = createSignal("");
const [newSchedDayOfWeek, setNewSchedDayOfWeek] = createSignal("3");
const [newSchedStartsLocal, setNewSchedStartsLocal] = createSignal("");
const [newSchedLocationId, setNewSchedLocationId] = createSignal("");
const [schedSaving, setSchedSaving] = createSignal(false);
const [expandedSessionId, setExpandedSessionId] = createSignal<string | null>(null);
const [promoteBusy, setPromoteBusy] = createSignal<string | null>(null);
// Booking IDs whose promote attempt hit a full session — awaiting the
@@ -131,6 +136,24 @@ function ZonesPage() {
},
);
const [classSchedules, { refetch: refetchSchedules }] = createResource(
() => [data.token(), activeTab()] as const,
async ([bearer, tab]) => {
if (!bearer || tab !== "classes") return [];
if (bearer.startsWith("demo.")) {
return [
{ id: "sch1", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationName: cs() ? "Hlavní sál" : "Main Hall", dayOfWeek: 3, startsLocal: "10:00:00", active: true },
{ id: "sch2", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationName: cs() ? "Hlavní sál" : "Main Hall", dayOfWeek: 6, startsLocal: "09:30:00", active: true },
];
}
const response = await (apiClient as any).GET("/v1/catalog/class-schedules", authHeaders());
return (response.data as any[]) ?? [];
},
);
const weekdayName = (dow: number) =>
data.i18n.t(`dashboard.class.dow.${dow}`) as string;
const [roster, { refetch: refetchRoster }] = createResource(
() => [data.token(), expandedSessionId()] as const,
async ([bearer, sessionId]) => {
@@ -357,6 +380,42 @@ function ZonesPage() {
}
};
const handleAddSchedule = async () => {
const bearer = data.token();
if (!newSchedTemplateId() || !newSchedStartsLocal()) return;
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode."); return; }
setSchedSaving(true);
try {
await (apiClient as any).POST("/v1/catalog/class-schedules", {
...authHeaders(),
body: {
templateId: newSchedTemplateId(),
dayOfWeek: parseInt(newSchedDayOfWeek(), 10),
startsLocal: newSchedStartsLocal(),
...(newSchedLocationId() ? { locationId: newSchedLocationId() } : {}),
},
});
setNewSchedTemplateId(""); setNewSchedStartsLocal(""); setNewSchedLocationId("");
void refetchSchedules();
void refetchSessions();
} catch {
data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed.");
} finally {
setSchedSaving(false);
}
};
const handleDeleteSchedule = async (id: string) => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) { data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode."); return; }
try {
await (apiClient as any).DELETE(`/v1/catalog/class-schedules/${id}`, authHeaders());
void refetchSchedules();
} catch {
data.setDemoNotice(cs() ? "Smazání selhalo." : "Delete failed.");
}
};
const typeLabel = (type: string) => {
const map: Record<string, string> = { room: data.i18n.t("dashboard.zone.rooms"), private: data.i18n.t("dashboard.zone.private"), hall: data.i18n.t("dashboard.zone.hall") };
return map[type] || type;
@@ -607,6 +666,72 @@ function ZonesPage() {
</Show>
</div>
<div class="surface-card overflow-hidden">
<div class="p-6 border-b border-border">
<h3 class="text-lg font-display font-semibold text-ink">{data.i18n.t("dashboard.class.recurring")}</h3>
<p class="text-sm text-ink-muted mt-1">{data.i18n.t("dashboard.class.recurringHint")}</p>
</div>
<div class="p-6 border-b border-border bg-canvas-subtle/50">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
<Select
label={data.i18n.t("dashboard.class.template")}
value={newSchedTemplateId()}
onChange={(v) => setNewSchedTemplateId(v)}
options={[
{ value: "", label: "—" },
...(classTemplates() ?? []).map((t: any) => ({ value: t.id, label: t.title })),
]}
/>
<Select
label={data.i18n.t("dashboard.class.weekday")}
value={newSchedDayOfWeek()}
onChange={(v) => setNewSchedDayOfWeek(v)}
options={[1, 2, 3, 4, 5, 6, 0].map((d) => ({ value: String(d), label: weekdayName(d) }))}
/>
<Input type="time" label={data.i18n.t("dashboard.class.startsLocal")} value={newSchedStartsLocal()} onInput={(e) => setNewSchedStartsLocal(e.currentTarget.value)} />
<Select
label={data.i18n.t("dashboard.class.location")}
value={newSchedLocationId()}
onChange={(v) => setNewSchedLocationId(v)}
options={[
{ value: "", label: data.i18n.t("dashboard.class.noLocation") },
...resolvedLocations().map((l: any) => ({ value: l.id, label: l.name })),
]}
/>
<div class="flex items-end">
<button onClick={() => void handleAddSchedule()} disabled={schedSaving() || !newSchedTemplateId() || !newSchedStartsLocal()} class="btn-primary text-sm w-full sm:w-auto disabled:opacity-60">
{schedSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.class.addSchedule")}
</button>
</div>
</div>
</div>
<Show when={(classSchedules() ?? []).length > 0} fallback={
<div class="p-10 text-center">
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.class.noSchedules")}</p>
</div>
}>
<div class="divide-y divide-border/60">
<For each={classSchedules() ?? []}>
{(sch: any) => (
<div class="flex items-center justify-between p-4 px-6">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-xl bg-accent-subtle/50 flex items-center justify-center text-accent"><CalendarDaysIcon /></div>
<div>
<p class="font-medium text-ink">{sch.templateTitle}</p>
<p class="text-sm text-ink-muted">
{`${weekdayName(sch.dayOfWeek)} ${String(sch.startsLocal).slice(0, 5)}`}
{sch.locationName ? ` • ${sch.locationName}` : ""}
</p>
</div>
</div>
<button onClick={() => void handleDeleteSchedule(sch.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" aria-label={cs() ? "Smazat" : "Delete"}><Trash2Icon /></button>
</div>
)}
</For>
</div>
</Show>
</div>
<div class="surface-card overflow-hidden">
<div class="p-6 border-b border-border">
<h3 class="text-lg font-display font-semibold text-ink">{data.i18n.t("dashboard.class.sessions")}</h3>