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:
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<string, { dayShort: string; times: string[] }>();
|
||||
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<string>();
|
||||
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<string, ScheduleGroup[]> {
|
||||
const map = new Map<string, ScheduleGroup[]>();
|
||||
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<string, ScheduleGroup[]>();
|
||||
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<string, string>;
|
||||
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 (
|
||||
<div className="mt-8 grid grid-cols-1 gap-3 px-4 sm:grid-cols-2 lg:grid-cols-3 sm:px-6 lg:px-8 xl:px-6">
|
||||
{groups.map((group) => {
|
||||
const dotColor = typeDots[group.type] ?? "bg-white/30";
|
||||
<div className="mt-8 space-y-3 px-4 sm:px-6 lg:px-8 xl:px-6 max-w-4xl mx-auto">
|
||||
{Array.from(byTrainer.entries()).map(([trainer, trainerGroups]) => {
|
||||
const byType = groupByType(trainerGroups);
|
||||
const totalGroups = trainerGroups.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${group.trainer}||${group.type}||${group.location ?? ""}`}
|
||||
className={`rounded-2xl border overflow-hidden transition-colors ${
|
||||
group.hasSlots
|
||||
? "border-emerald-500/25 bg-white dark:border-emerald-500/15 dark:bg-[#0a0a0a]"
|
||||
: group.recruiting
|
||||
? "border-sky-500/25 bg-white dark:border-sky-500/15 dark:bg-[#0a0a0a]"
|
||||
: "border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a]"
|
||||
}`}
|
||||
key={trainer}
|
||||
className="rounded-xl border border-neutral-200 bg-white overflow-hidden dark:border-white/[0.06] dark:bg-[#0a0a0a]"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={`px-5 py-4 border-b ${
|
||||
group.hasSlots
|
||||
? "border-emerald-500/15 bg-emerald-500/5 dark:border-emerald-500/10 dark:bg-emerald-500/[0.03]"
|
||||
: group.recruiting
|
||||
? "border-sky-500/15 bg-sky-500/5 dark:border-sky-500/10 dark:bg-sky-500/[0.03]"
|
||||
: "border-neutral-100 bg-neutral-50 dark:border-white/[0.04] dark:bg-white/[0.02]"
|
||||
}`}>
|
||||
{/* Type + badges */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setFilterType(filterType === group.type ? null : group.type)}
|
||||
className={`flex items-center gap-2 active:opacity-60 ${
|
||||
filterType === group.type ? "opacity-100" : ""
|
||||
}`}
|
||||
>
|
||||
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${dotColor}`} />
|
||||
<span className="text-base font-semibold text-neutral-900 dark:text-white/90">
|
||||
{group.type}
|
||||
</span>
|
||||
</button>
|
||||
{group.level && (
|
||||
<span className="rounded-full bg-rose-500/15 border border-rose-500/25 px-2 py-0.5 text-[10px] font-semibold text-rose-600 dark:text-rose-400">
|
||||
{group.level}
|
||||
</span>
|
||||
)}
|
||||
{group.hasSlots && (
|
||||
<span className="rounded-full bg-emerald-500/15 border border-emerald-500/25 px-2 py-0.5 text-[10px] font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
есть места
|
||||
</span>
|
||||
)}
|
||||
{group.recruiting && (
|
||||
<span className="rounded-full bg-sky-500/15 border border-sky-500/25 px-2 py-0.5 text-[10px] font-semibold text-sky-600 dark:text-sky-400">
|
||||
набор
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Trainer header */}
|
||||
<button
|
||||
onClick={() => setFilterTrainer(filterTrainer === trainer ? null : trainer)}
|
||||
className={`flex items-center gap-2 w-full px-4 py-2.5 text-left transition-colors cursor-pointer ${
|
||||
filterTrainer === trainer
|
||||
? "bg-gold/10 dark:bg-gold/5"
|
||||
: "bg-neutral-50 dark:bg-white/[0.02]"
|
||||
}`}
|
||||
>
|
||||
<User size={14} className={filterTrainer === trainer ? "text-gold" : "text-neutral-400 dark:text-white/40"} />
|
||||
<span className={`text-sm font-semibold ${
|
||||
filterTrainer === trainer ? "text-gold" : "text-neutral-800 dark:text-white/80"
|
||||
}`}>
|
||||
{trainer}
|
||||
</span>
|
||||
<span className="ml-auto text-[10px] text-neutral-400 dark:text-white/25">
|
||||
{totalGroups === 1 ? "1 группа" : `${totalGroups} групп${totalGroups < 5 ? "ы" : ""}`}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Trainer */}
|
||||
<button
|
||||
onClick={() => setFilterTrainer(filterTrainer === group.trainer ? null : group.trainer)}
|
||||
className={`mt-2 flex items-center gap-1.5 text-sm active:opacity-60 ${
|
||||
filterTrainer === group.trainer
|
||||
? "text-gold underline underline-offset-2"
|
||||
: "text-neutral-500 dark:text-white/50"
|
||||
}`}
|
||||
>
|
||||
<User size={13} className="shrink-0" />
|
||||
{group.trainer}
|
||||
</button>
|
||||
{/* Type → Groups */}
|
||||
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
|
||||
{byType.map(({ type, groups: typeGroups }) => {
|
||||
const dotColor = typeDots[type] ?? "bg-white/30";
|
||||
|
||||
{/* Location badge — only in "all" mode */}
|
||||
{showLocation && group.location && (
|
||||
<div className="mt-2 flex items-center gap-1 text-[11px] text-neutral-400 dark:text-white/30">
|
||||
<MapPin size={10} className="shrink-0" />
|
||||
{group.location}
|
||||
{group.locationAddress && (
|
||||
<span className="text-neutral-300 dark:text-white/15"> · {shortAddress(group.locationAddress)}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<div key={type} className="px-4 py-2.5">
|
||||
{/* Class type row */}
|
||||
<button
|
||||
onClick={() => setFilterType(filterType === type ? null : type)}
|
||||
className="flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${dotColor}`} />
|
||||
<span className="text-sm font-medium text-neutral-800 dark:text-white/80">
|
||||
{type}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Schedule slots */}
|
||||
<div className="px-5 py-3.5">
|
||||
<div className="flex items-center gap-1.5 mb-2.5 text-[11px] font-medium text-neutral-400 dark:text-white/25 uppercase tracking-wider">
|
||||
<CalendarDays size={12} />
|
||||
Расписание
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{group.slots.map((slot, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-3 rounded-lg bg-neutral-50 px-3 py-2 dark:bg-white/[0.03]"
|
||||
>
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-gold/10 text-[10px] font-bold text-gold-dark dark:text-gold-light">
|
||||
{slot.dayShort}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 text-sm text-neutral-600 dark:text-white/60">
|
||||
<Clock size={12} className="shrink-0 text-neutral-400 dark:text-white/30" />
|
||||
<span className="font-medium tabular-nums">{slot.time}</span>
|
||||
{/* Group rows under this type */}
|
||||
<div className="mt-1.5 space-y-1 pl-3.5">
|
||||
{typeGroups.map((group, gi) => {
|
||||
const merged = mergeSlotsByDay(group.slots);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={gi}
|
||||
className="flex items-center gap-2 flex-wrap"
|
||||
>
|
||||
{/* Datetimes */}
|
||||
<div className="flex items-center gap-0.5 flex-wrap">
|
||||
{merged.map((m, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 text-xs">
|
||||
{i > 0 && <span className="text-neutral-300 dark:text-white/15 mx-0.5">·</span>}
|
||||
<span className="rounded bg-gold/10 px-1.5 py-0.5 text-[10px] font-bold text-gold-dark dark:text-gold">
|
||||
{m.days.join(", ")}
|
||||
</span>
|
||||
<span className="font-medium tabular-nums text-neutral-500 dark:text-white/45">
|
||||
{m.times.join(", ")}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Badges */}
|
||||
{group.level && (
|
||||
<span className="rounded-full bg-rose-500/15 border border-rose-500/25 px-2 py-px text-[10px] font-semibold text-rose-600 dark:text-rose-400">
|
||||
{group.level}
|
||||
</span>
|
||||
)}
|
||||
{group.hasSlots && (
|
||||
<span className="rounded-full bg-emerald-500/15 border border-emerald-500/25 px-2 py-px text-[10px] font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
есть места
|
||||
</span>
|
||||
)}
|
||||
{group.recruiting && (
|
||||
<span className="rounded-full bg-sky-500/15 border border-sky-500/25 px-2 py-px text-[10px] font-semibold text-sky-600 dark:text-sky-400">
|
||||
набор
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Location */}
|
||||
{showLocation && group.location && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-neutral-400 dark:text-white/25">
|
||||
<MapPin size={9} />
|
||||
{group.location}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Book button */}
|
||||
{onBook && (
|
||||
<button
|
||||
onClick={() => onBook(`${group.type}, ${group.trainer}, ${group.slots.map(s => s.dayShort).join("/")} ${group.slots[0]?.time ?? ""}`)}
|
||||
className="ml-auto rounded-lg bg-gold/10 border border-gold/20 px-3 py-1 text-[11px] font-semibold text-gold hover:bg-gold/20 transition-colors cursor-pointer shrink-0"
|
||||
>
|
||||
Записаться
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{onBook && (
|
||||
<button
|
||||
onClick={() => onBook(`${group.type}, ${group.trainer}, ${group.slots.map(s => s.dayShort).join("/")} ${group.slots[0]?.time ?? ""}`)}
|
||||
className="w-full mt-3 rounded-xl bg-gold/15 border border-gold/25 py-2 text-xs font-semibold text-gold-dark dark:text-gold hover:bg-gold/25 transition-colors cursor-pointer"
|
||||
>
|
||||
Записаться
|
||||
</button>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -87,6 +87,7 @@ export interface ScheduleClassWithLocation {
|
||||
level?: string;
|
||||
hasSlots?: boolean;
|
||||
recruiting?: boolean;
|
||||
groupId?: string;
|
||||
locationName?: string;
|
||||
locationAddress?: string;
|
||||
}
|
||||
|
||||
124
src/lib/db.ts
124
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 ---
|
||||
|
||||
@@ -55,6 +55,7 @@ export interface ScheduleClass {
|
||||
level?: string;
|
||||
hasSlots?: boolean;
|
||||
recruiting?: boolean;
|
||||
groupId?: string;
|
||||
}
|
||||
|
||||
export interface ScheduleDay {
|
||||
|
||||
Reference in New Issue
Block a user