From f5e80c792a5e5a2d6dde86cf14c56cff1a6de224 Mon Sep 17 00:00:00 2001 From: "diana.dolgolyova" Date: Sun, 15 Mar 2026 19:55:34 +0300 Subject: [PATCH] feat: add groupId support and redesign schedule GroupView hierarchy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 7 + src/app/admin/schedule/page.tsx | 86 +++++- .../sections/schedule/GroupView.tsx | 278 +++++++++++------- src/components/sections/schedule/constants.ts | 1 + src/lib/db.ts | 124 +++++--- src/types/content.ts | 1 + 6 files changed, 358 insertions(+), 139 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 28af5ae..79c0ae7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,6 +78,13 @@ src/ - Covers all 31 TS/TSX files + 4 CSS files - Update the index when adding/removing/renaming files or exports +## Database Migrations +- **Never drop/recreate the database** — admin data (photos, edits, registrations) lives there +- Schema changes go through versioned migrations in `src/lib/db.ts` (`migrations` array) +- Add a new entry with the next version number; never modify existing migrations +- Migrations run automatically on server start via `runMigrations()` and are tracked in the `_migrations` table +- Use `CREATE TABLE IF NOT EXISTS` and column-existence checks (`PRAGMA table_info`) for safety + ## Git - Remote: Gitea at `git.dolgolyov-family.by` - User: diana.dolgolyova diff --git a/src/app/admin/schedule/page.tsx b/src/app/admin/schedule/page.tsx index 2c56c11..389e86b 100644 --- a/src/app/admin/schedule/page.tsx +++ b/src/app/admin/schedule/page.tsx @@ -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(); + 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(); // "dayIdx:clsIdx" -> groupId + + for (const entries of buckets.values()) { + // Count max entries per day + const perDay = new Map(); + 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(), }, }); } diff --git a/src/components/sections/schedule/GroupView.tsx b/src/components/sections/schedule/GroupView.tsx index 6154959..4a460a3 100644 --- a/src/components/sections/schedule/GroupView.tsx +++ b/src/components/sections/schedule/GroupView.tsx @@ -1,6 +1,6 @@ "use client"; -import { User, Clock, CalendarDays, MapPin } from "lucide-react"; +import { User, MapPin } from "lucide-react"; import { shortAddress } from "./constants"; import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants"; @@ -20,9 +20,12 @@ function buildGroups(days: ScheduleDayMerged[]): ScheduleGroup[] { for (const day of days) { for (const cls of day.classes as ScheduleClassWithLocation[]) { - // Include location in key so same trainer+type at different locations = separate groups + // Use groupId if available, otherwise fall back to trainer+type+location const locPart = cls.locationName ?? ""; - const key = `${cls.trainer}||${cls.type}||${locPart}`; + const key = cls.groupId + ? `${cls.groupId}||${locPart}` + : `${cls.trainer}||${cls.type}||${locPart}`; + const existing = map.get(key); if (existing) { existing.slots.push({ day: day.day, dayShort: day.dayShort, time: cls.time }); @@ -47,6 +50,70 @@ function buildGroups(days: ScheduleDayMerged[]): ScheduleGroup[] { return Array.from(map.values()); } +/** Group slots by day, then merge days that share identical time sets */ +function mergeSlotsByDay(slots: { day: string; dayShort: string; time: string }[]): { days: string[]; times: string[] }[] { + // Step 1: collect times per day + const dayMap = new Map(); + const dayOrder: string[] = []; + for (const s of slots) { + const existing = dayMap.get(s.day); + if (existing) { + if (!existing.times.includes(s.time)) existing.times.push(s.time); + } else { + dayMap.set(s.day, { dayShort: s.dayShort, times: [s.time] }); + dayOrder.push(s.day); + } + } + // Sort times within each day + for (const entry of dayMap.values()) entry.times.sort(); + + // Step 2: merge days with identical time sets + const result: { days: string[]; times: string[] }[] = []; + const used = new Set(); + for (const day of dayOrder) { + if (used.has(day)) continue; + const entry = dayMap.get(day)!; + const timeKey = entry.times.join("|"); + const days = [entry.dayShort]; + used.add(day); + for (const other of dayOrder) { + if (used.has(other)) continue; + const o = dayMap.get(other)!; + if (o.times.join("|") === timeKey) { days.push(o.dayShort); used.add(other); } + } + result.push({ days, times: entry.times }); + } + return result; +} + +/** Group schedule groups by trainer for compact display */ +function groupByTrainer(groups: ScheduleGroup[]): Map { + const map = new Map(); + for (const g of groups) { + const existing = map.get(g.trainer); + if (existing) existing.push(g); + else map.set(g.trainer, [g]); + } + return map; +} + +/** Within a trainer's groups, cluster by class type preserving order */ +function groupByType(groups: ScheduleGroup[]): { type: string; groups: ScheduleGroup[] }[] { + const result: { type: string; groups: ScheduleGroup[] }[] = []; + const map = new Map(); + for (const g of groups) { + const existing = map.get(g.type); + if (existing) { + existing.push(g); + } else { + const arr = [g]; + map.set(g.type, arr); + result.push({ type: g.type, groups: arr }); + } + } + return result; +} + interface GroupViewProps { typeDots: Record; filteredDays: ScheduleDayMerged[]; @@ -69,6 +136,7 @@ export function GroupView({ onBook, }: GroupViewProps) { const groups = buildGroups(filteredDays); + const byTrainer = groupByTrainer(groups); if (groups.length === 0) { return ( @@ -79,114 +147,120 @@ export function GroupView({ } return ( -
- {groups.map((group) => { - const dotColor = typeDots[group.type] ?? "bg-white/30"; +
+ {Array.from(byTrainer.entries()).map(([trainer, trainerGroups]) => { + const byType = groupByType(trainerGroups); + const totalGroups = trainerGroups.length; return (
- {/* Header */} -
- {/* Type + badges */} -
- - {group.level && ( - - {group.level} - - )} - {group.hasSlots && ( - - есть места - - )} - {group.recruiting && ( - - набор - - )} -
+ {/* Trainer header */} + - {/* Trainer */} - + {/* Type → Groups */} +
+ {byType.map(({ type, groups: typeGroups }) => { + const dotColor = typeDots[type] ?? "bg-white/30"; - {/* Location badge — only in "all" mode */} - {showLocation && group.location && ( -
- - {group.location} - {group.locationAddress && ( - · {shortAddress(group.locationAddress)} - )} -
- )} -
+ return ( +
+ {/* Class type row */} + - {/* Schedule slots */} -
-
- - Расписание -
-
- {group.slots.map((slot, i) => ( -
- - {slot.dayShort} - -
- - {slot.time} + {/* Group rows under this type */} +
+ {typeGroups.map((group, gi) => { + const merged = mergeSlotsByDay(group.slots); + + return ( +
+ {/* Datetimes */} +
+ {merged.map((m, i) => ( + + {i > 0 && ·} + + {m.days.join(", ")} + + + {m.times.join(", ")} + + + ))} +
+ + {/* Badges */} + {group.level && ( + + {group.level} + + )} + {group.hasSlots && ( + + есть места + + )} + {group.recruiting && ( + + набор + + )} + + {/* Location */} + {showLocation && group.location && ( + + + {group.location} + + )} + + {/* Book button */} + {onBook && ( + + )} +
+ ); + })}
- ))} -
- {onBook && ( - - )} + ); + })}
); diff --git a/src/components/sections/schedule/constants.ts b/src/components/sections/schedule/constants.ts index f31d737..4e586e3 100644 --- a/src/components/sections/schedule/constants.ts +++ b/src/components/sections/schedule/constants.ts @@ -87,6 +87,7 @@ export interface ScheduleClassWithLocation { level?: string; hasSlots?: boolean; recruiting?: boolean; + groupId?: string; locationName?: string; locationAddress?: string; } diff --git a/src/lib/db.ts b/src/lib/db.ts index 27216d2..e6f2c18 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -13,50 +13,104 @@ function getDb(): Database.Database { _db = new Database(DB_PATH); _db.pragma("journal_mode = WAL"); _db.pragma("foreign_keys = ON"); - initTables(_db); + runMigrations(_db); } return _db; } -function initTables(db: Database.Database) { +// --- Migrations --- +// Each migration has a unique version number and runs exactly once. +// Add new migrations to the end of the array. Never modify existing ones. + +interface Migration { + version: number; + name: string; + up: (db: Database.Database) => void; +} + +const migrations: Migration[] = [ + { + version: 1, + name: "create_sections_and_team_members", + up: (db) => { + db.exec(` + CREATE TABLE IF NOT EXISTS sections ( + key TEXT PRIMARY KEY, + data TEXT NOT NULL, + updated_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS team_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + role TEXT NOT NULL, + image TEXT NOT NULL, + instagram TEXT, + description TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + `); + }, + }, + { + version: 2, + name: "add_team_bio_columns", + up: (db) => { + const cols = db.prepare("PRAGMA table_info(team_members)").all() as { name: string }[]; + const colNames = new Set(cols.map((c) => c.name)); + if (!colNames.has("experience")) db.exec("ALTER TABLE team_members ADD COLUMN experience TEXT"); + if (!colNames.has("victories")) db.exec("ALTER TABLE team_members ADD COLUMN victories TEXT"); + if (!colNames.has("education")) db.exec("ALTER TABLE team_members ADD COLUMN education TEXT"); + }, + }, + { + version: 3, + name: "create_mc_registrations", + up: (db) => { + db.exec(` + CREATE TABLE IF NOT EXISTS mc_registrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + master_class_title TEXT NOT NULL, + name TEXT NOT NULL, + instagram TEXT NOT NULL, + telegram TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + `); + }, + }, +]; + +function runMigrations(db: Database.Database) { + // Create migrations tracking table db.exec(` - CREATE TABLE IF NOT EXISTS sections ( - key TEXT PRIMARY KEY, - data TEXT NOT NULL, - updated_at TEXT DEFAULT (datetime('now')) - ); - - CREATE TABLE IF NOT EXISTS mc_registrations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - master_class_title TEXT NOT NULL, + CREATE TABLE IF NOT EXISTS _migrations ( + version INTEGER PRIMARY KEY, name TEXT NOT NULL, - instagram TEXT NOT NULL, - telegram TEXT, - created_at TEXT DEFAULT (datetime('now')) - ); - - CREATE TABLE IF NOT EXISTS team_members ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - role TEXT NOT NULL, - image TEXT NOT NULL, - instagram TEXT, - description TEXT, - experience TEXT, - victories TEXT, - education TEXT, - sort_order INTEGER NOT NULL DEFAULT 0, - created_at TEXT DEFAULT (datetime('now')), - updated_at TEXT DEFAULT (datetime('now')) + applied_at TEXT DEFAULT (datetime('now')) ); `); - // Migrate: add bio columns if missing - const cols = db.prepare("PRAGMA table_info(team_members)").all() as { name: string }[]; - const colNames = new Set(cols.map((c) => c.name)); - if (!colNames.has("experience")) db.exec("ALTER TABLE team_members ADD COLUMN experience TEXT"); - if (!colNames.has("victories")) db.exec("ALTER TABLE team_members ADD COLUMN victories TEXT"); - if (!colNames.has("education")) db.exec("ALTER TABLE team_members ADD COLUMN education TEXT"); + const applied = new Set( + (db.prepare("SELECT version FROM _migrations").all() as { version: number }[]) + .map((r) => r.version) + ); + + const pending = migrations.filter((m) => !applied.has(m.version)); + if (pending.length === 0) return; + + const insertMigration = db.prepare( + "INSERT INTO _migrations (version, name) VALUES (?, ?)" + ); + + const tx = db.transaction(() => { + for (const m of pending) { + m.up(db); + insertMigration.run(m.version, m.name); + } + }); + tx(); } // --- Sections --- diff --git a/src/types/content.ts b/src/types/content.ts index da8cce0..611d6ca 100644 --- a/src/types/content.ts +++ b/src/types/content.ts @@ -55,6 +55,7 @@ export interface ScheduleClass { level?: string; hasSlots?: boolean; recruiting?: boolean; + groupId?: string; } export interface ScheduleDay {