From ac9bc4e93771129fddb9f9f8a24c167ebdaf2f3b Mon Sep 17 00:00:00 2001 From: "Michael A. Coughlin" Date: Thu, 21 May 2026 08:05:43 -0400 Subject: [PATCH 1/7] Fix migration ordering and local schema compatibility Renamed add-rejection-reason-to-submissions to run after the user_submissions table is created. Made performance index migration safe when the codex schema does not exist locally. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 ++- ...0_add-rejection-reason-to-submissions.cjs} | 0 .../1762262413053_add-performance-indexes.cjs | 24 ++++++++++++------- 3 files changed, 18 insertions(+), 9 deletions(-) rename migrations/{1734058800000_add-rejection-reason-to-submissions.cjs => 1747064560000_add-rejection-reason-to-submissions.cjs} (100%) diff --git a/.gitignore b/.gitignore index c1f7b0c..5bde1f3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules .env* !.env.example .next -certificates \ No newline at end of file +certificates +launchClaude.bat diff --git a/migrations/1734058800000_add-rejection-reason-to-submissions.cjs b/migrations/1747064560000_add-rejection-reason-to-submissions.cjs similarity index 100% rename from migrations/1734058800000_add-rejection-reason-to-submissions.cjs rename to migrations/1747064560000_add-rejection-reason-to-submissions.cjs diff --git a/migrations/1762262413053_add-performance-indexes.cjs b/migrations/1762262413053_add-performance-indexes.cjs index f34c02b..baaa97e 100644 --- a/migrations/1762262413053_add-performance-indexes.cjs +++ b/migrations/1762262413053_add-performance-indexes.cjs @@ -11,11 +11,15 @@ exports.shorthands = undefined; exports.up = (pgm) => { console.log("[MIGRATION_LOG] Starting migration: add_performance_indexes UP"); - // Add index on target_entry_id for entry_references table (in codex schema) - // This optimizes queries that join on target_entry_id (e.g., finding entries that reference a given entry) + // Add index on target_entry_id for entry_references table (in codex schema, if it exists) pgm.sql(` - CREATE INDEX IF NOT EXISTS idx_entry_references_target_entry_id - ON codex.entry_references(target_entry_id); + DO $$ + BEGIN + IF EXISTS (SELECT FROM pg_tables WHERE schemaname = 'codex' AND tablename = 'entry_references') THEN + CREATE INDEX IF NOT EXISTS idx_entry_references_target_entry_id + ON codex.entry_references(target_entry_id); + END IF; + END $$; `); // Also create in public schema if it exists there @@ -29,11 +33,15 @@ exports.up = (pgm) => { END $$; `); - // Add index on tag_id for entry_tags table (in codex schema) - // This optimizes queries that join on tag_id (e.g., finding all entries with a specific tag) + // Add index on tag_id for entry_tags table (in codex schema, if it exists) pgm.sql(` - CREATE INDEX IF NOT EXISTS idx_entry_tags_tag_id - ON codex.entry_tags(tag_id); + DO $$ + BEGIN + IF EXISTS (SELECT FROM pg_tables WHERE schemaname = 'codex' AND tablename = 'entry_tags') THEN + CREATE INDEX IF NOT EXISTS idx_entry_tags_tag_id + ON codex.entry_tags(tag_id); + END IF; + END $$; `); // Also create in public schema if it exists there From 80f4dfb6d3076e02566e9f0c1b1cf7d4f8609fa3 Mon Sep 17 00:00:00 2001 From: "Michael A. Coughlin" Date: Thu, 21 May 2026 08:05:54 -0400 Subject: [PATCH 2/7] Add CSV import script for seeding local data Reads exicon.csv and lexicon.csv, extracts tags from entry text, and inserts into the entries and entry_tags tables. Added as npm run seed. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 3 +- scripts/import-csv.ts | 212 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 scripts/import-csv.ts diff --git a/package.json b/package.json index 498e559..e324656 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "db:migrate": "tsx scripts/db-migrate.ts up", "db:migrate:up": "tsx scripts/db-migrate.ts up", "db:migrate:down": "tsx scripts/db-migrate.ts down", - "db:migrate:redo": "tsx scripts/db-migrate.ts redo" + "db:migrate:redo": "tsx scripts/db-migrate.ts redo", + "seed": "tsx scripts/import-csv.ts" }, "dependencies": { "@genkit-ai/googleai": "^1.8.0", diff --git a/scripts/import-csv.ts b/scripts/import-csv.ts new file mode 100644 index 0000000..882e42a --- /dev/null +++ b/scripts/import-csv.ts @@ -0,0 +1,212 @@ +import fs from "node:fs"; +import path from "node:path"; +import dotenv from "dotenv"; +import pg from "pg"; + +dotenv.config(); + +const { Client } = pg; + +const KNOWN_TAGS = [ + // Multi-word tags first so they match before their component words + "Full Body", + "Warm-Up", + "Arms", + "Legs", + "Core", + "Cardio", + "Partner", + "Coupon", + "Music", + "Mosey", + "Static", + "Strength", + "AMRAP", + "EMOM", + "Reps", + "Timed", + "Distance", + "Routine", + "Run", + "Mary", +]; + +function parseCSV(text: string): string[][] { + const rows: string[][] = []; + let i = 0; + const n = text.length; + + while (i < n) { + const row: string[] = []; + + while (i < n && text[i] !== "\n" && text[i] !== "\r") { + let field = ""; + if (text[i] === '"') { + i++; + while (i < n) { + if (text[i] === '"') { + if (text[i + 1] === '"') { + field += '"'; + i += 2; + } else { + i++; + break; + } + } else { + field += text[i++]; + } + } + } else { + while (i < n && text[i] !== "," && text[i] !== "\n" && text[i] !== "\r") { + field += text[i++]; + } + } + row.push(field); + if (text[i] === ",") i++; + } + + if (text[i] === "\r") i++; + if (text[i] === "\n") i++; + + if (row.length > 0 && !(row.length === 1 && row[0] === "")) { + rows.push(row); + } + } + + return rows; +} + +function slugify(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} + +function stripTitlePrefix(title: string, text: string): string { + if (text.startsWith(title)) { + return text.slice(title.length).trimStart(); + } + return text; +} + +function extractTagsFromEnd(text: string): { definition: string; tags: string[] } { + const found: string[] = []; + let remaining = text.trimEnd(); + + let changed = true; + while (changed) { + changed = false; + for (const tag of KNOWN_TAGS) { + if (remaining.endsWith(tag)) { + found.unshift(tag); + remaining = remaining.slice(0, -tag.length).trimEnd(); + changed = true; + } + } + } + + return { definition: remaining, tags: found }; +} + +async function main() { + const client = new Client({ connectionString: process.env.DATABASE_URL }); + await client.connect(); + + try { + const tagsResult = await client.query<{ id: string; name: string }>( + "SELECT id, name FROM tags", + ); + const tagMap = new Map(); + for (const row of tagsResult.rows) { + tagMap.set(row.name, row.id); + } + + let inserted = 0; + let failed = 0; + + // --- Exicon --- + const exiconText = fs.readFileSync( + path.resolve(process.cwd(), "exicon.csv"), + "utf-8", + ); + const exiconRows = parseCSV(exiconText).slice(1); + + for (const row of exiconRows) { + const [title, tagsCol, rawText] = row; + if (!title?.trim() || !rawText?.trim()) continue; + + const id = slugify(title.trim()); + const withoutTitle = stripTitlePrefix(title.trim(), rawText.trim()); + const { definition, tags: endTags } = extractTagsFromEnd(withoutTitle); + + const csvTags = tagsCol + ? tagsCol.split(/[,\s]+/).filter((t) => t.length > 0) + : []; + const allTagNames = [...new Set([...endTags, ...csvTags])]; + + try { + await client.query( + `INSERT INTO entries (id, title, definition, type, aliases) + VALUES ($1, $2, $3, 'exicon', '[]') + ON CONFLICT (id) DO NOTHING`, + [id, title.trim(), definition], + ); + + for (const tagName of allTagNames) { + const tagId = tagMap.get(tagName); + if (tagId) { + await client.query( + `INSERT INTO entry_tags (entry_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + [id, tagId], + ); + } + } + inserted++; + } catch (err) { + console.error(`Failed to insert exicon "${title}":`, err); + failed++; + } + } + + console.log(`Exicon: inserted ${inserted}, failed ${failed}`); + inserted = 0; + failed = 0; + + // --- Lexicon --- + const lexiconText = fs.readFileSync( + path.resolve(process.cwd(), "lexicon.csv"), + "utf-8", + ); + const lexiconRows = parseCSV(lexiconText).slice(1); + + for (const row of lexiconRows) { + const [title, text] = row; + if (!title?.trim() || !text?.trim()) continue; + + const id = `lex-${slugify(title.trim())}`; + + try { + await client.query( + `INSERT INTO entries (id, title, definition, type, aliases) + VALUES ($1, $2, $3, 'lexicon', '[]') + ON CONFLICT (id) DO NOTHING`, + [id, title.trim(), text.trim()], + ); + inserted++; + } catch (err) { + console.error(`Failed to insert lexicon "${title}":`, err); + failed++; + } + } + + console.log(`Lexicon: inserted ${inserted}, failed ${failed}`); + } finally { + await client.end(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); From 4272844c0f8a28ad04d7cd18d0b179b624b145ea Mon Sep 17 00:00:00 2001 From: "Michael A. Coughlin" Date: Thu, 21 May 2026 08:06:00 -0400 Subject: [PATCH 3/7] Fix mobile nav menu not closing on selection and accessibility warning Made Header a client component with controlled Sheet state so clicking a nav link closes the drawer. Added a visually hidden SheetTitle to satisfy the Radix Dialog accessibility requirement. Co-Authored-By: Claude Sonnet 4.6 --- src/components/layout/Header.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 03123ab..0cb30a3 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,6 +1,10 @@ +"use client"; + +import { useState } from "react"; import Link from "next/link"; import { Button } from "@/components/ui/button"; -import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; +import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import { Menu, Flame } from "lucide-react"; const navItems = [ @@ -12,6 +16,8 @@ const navItems = [ ]; export function Header() { + const [open, setOpen] = useState(false); + return (
@@ -33,7 +39,7 @@ export function Header() {
- + + + Navigation Menu +