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:
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user