feat: add groupId support and redesign schedule GroupView hierarchy

- Add groupId field to ScheduleClass for admin-defined group identity
- Add versioned DB migration system (replaces initTables) to prevent data loss
- Redesign GroupView: Trainer → Class Type → Group → Datetimes hierarchy
- Group datetimes by day, merge days with identical time sets
- Auto-assign groupIds to legacy schedule entries in admin
- Add mc_registrations CRUD to db.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 19:55:34 +03:00
parent 84b0bc4d60
commit f5e80c792a
6 changed files with 358 additions and 139 deletions

View File

@@ -211,12 +211,16 @@ function ClassBlock({
}
// ---------- Edit Modal ----------
/** Check if two classes are the same "group" (same trainer + type + time) */
/** Same group = same trainer + type */
/** Same group = matching groupId, or fallback to trainer + type for legacy data */
function isSameGroup(a: ScheduleClass, b: ScheduleClass): boolean {
if (a.groupId && b.groupId) return a.groupId === b.groupId;
return a.trainer === b.trainer && a.type === b.type;
}
function generateGroupId(): string {
return `g_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
}
function ClassModal({
cls,
trainers,
@@ -565,6 +569,83 @@ function CalendarGrid({
cls: ScheduleClass;
} | null>(null);
// Auto-assign groupId to legacy classes that don't have one
useEffect(() => {
const needsMigration = location.days.some((d) =>
d.classes.some((c) => !c.groupId)
);
if (!needsMigration) return;
// Collect all legacy classes per trainer+type key
const buckets = new Map<string, { dayIdx: number; clsIdx: number; time: string }[]>();
location.days.forEach((d, di) => {
d.classes.forEach((c, ci) => {
if (c.groupId) return;
const key = `${c.trainer}|${c.type}`;
const bucket = buckets.get(key);
if (bucket) bucket.push({ dayIdx: di, clsIdx: ci, time: c.time });
else buckets.set(key, [{ dayIdx: di, clsIdx: ci, time: c.time }]);
});
});
// For each bucket, figure out how many distinct groups there are.
// If any day has N entries with same trainer+type, there are at least N groups.
// Assign groups by matching closest times across days.
const assignedIds = new Map<string, string>(); // "dayIdx:clsIdx" -> groupId
for (const entries of buckets.values()) {
// Count max entries per day
const perDay = new Map<number, typeof entries>();
for (const e of entries) {
const arr = perDay.get(e.dayIdx);
if (arr) arr.push(e);
else perDay.set(e.dayIdx, [e]);
}
const maxPerDay = Math.max(...[...perDay.values()].map((a) => a.length));
if (maxPerDay <= 1) {
// Simple: one group
const gid = generateGroupId();
for (const e of entries) assignedIds.set(`${e.dayIdx}:${e.clsIdx}`, gid);
} else {
// Find the day with most entries — those define the seed groups
const busiestDay = [...perDay.entries()].sort((a, b) => b[1].length - a[1].length)[0];
const seeds = busiestDay[1].map((e) => ({
gid: generateGroupId(),
time: timeToMinutes(e.time.split("")[0]?.trim() || ""),
entry: e,
}));
// Assign seeds
for (const s of seeds) assignedIds.set(`${s.entry.dayIdx}:${s.entry.clsIdx}`, s.gid);
// Assign remaining entries to closest seed by start time
for (const e of entries) {
const k = `${e.dayIdx}:${e.clsIdx}`;
if (assignedIds.has(k)) continue;
const eMin = timeToMinutes(e.time.split("")[0]?.trim() || "");
let bestSeed = seeds[0];
let bestDiff = Infinity;
for (const s of seeds) {
const diff = Math.abs(eMin - s.time);
if (diff < bestDiff) { bestDiff = diff; bestSeed = s; }
}
assignedIds.set(k, bestSeed.gid);
}
}
}
// Apply groupIds
const migratedDays = location.days.map((d, di) => ({
...d,
classes: d.classes.map((c, ci) => {
if (c.groupId) return c;
return { ...c, groupId: assignedIds.get(`${di}:${ci}`) ?? generateGroupId() };
}),
}));
onChange({ ...location, days: migratedDays });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Compute group-based colors for calendar blocks
const sortedDaysForColors = sortDaysByWeekday(location.days);
const groupColors = useMemo(
@@ -749,6 +830,7 @@ function CalendarGrid({
time: `${startTime}${endTime}`,
trainer: "",
type: "",
groupId: generateGroupId(),
},
});
}