From f58b470396a6bd3008930eb25adfcd3322861fbf Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Wed, 17 Jun 2026 16:46:38 -0500 Subject: [PATCH] Store progress in a level including anything drawn on web canvas, be able to save and download drawings from profile page --- src/app/api/progress/route.ts | 3 +- src/app/globals.css | 11 ++ src/app/learn/[level]/[slug]/page.tsx | 55 +++++--- src/components/LessonRunner.tsx | 194 ++++++++++++++++++-------- src/components/NavLinks.tsx | 4 +- src/lib/curriculum.ts | 39 ++++-- src/lib/db.ts | 24 ++++ src/lib/progress.ts | 25 ++++ 8 files changed, 268 insertions(+), 87 deletions(-) diff --git a/src/app/api/progress/route.ts b/src/app/api/progress/route.ts index f07a921..74cbe4f 100644 --- a/src/app/api/progress/route.ts +++ b/src/app/api/progress/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { getCurrentUser } from "@/lib/session"; -import { markStepComplete, awardBadge } from "@/lib/progress"; +import { markStepComplete, awardBadge, recordCompletion } from "@/lib/progress"; import { getLesson } from "@/lib/curriculum"; export async function POST(req: Request) { @@ -28,6 +28,7 @@ export async function POST(req: Request) { // Only award the badge that actually belongs to this lesson. if (lesson && lesson.badgeKey === body.badgeKey) { awardBadge(user.id, body.badgeKey); + recordCompletion(user.id, level, sublevel, body.badgeKey); awarded = true; } } diff --git a/src/app/globals.css b/src/app/globals.css index 80dbb55..1f10d7d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -43,6 +43,17 @@ html, body { } .guide-reveal { animation: guideReveal 0.45s ease-out; } +/* Grip/pressure demo: the shading scribble "draws in" then fades and loops */ +@keyframes shadeDraw { + 0% { stroke-dashoffset: 440; opacity: 0; } + 12% { opacity: 1; } + 55% { stroke-dashoffset: 0; opacity: 1; } + 88% { stroke-dashoffset: 0; opacity: 1; } + 100% { stroke-dashoffset: 0; opacity: 0; } +} +.shade-scribble { stroke-dasharray: 440; animation: shadeDraw 3s ease-in-out infinite; } +@media (prefers-reduced-motion: reduce) { .shade-scribble { animation: none; stroke-dashoffset: 0; } } + h1, h2, h3 { line-height: 1.15; margin: 0 0 0.4em; } h1 { font-size: clamp(1.9rem, 6vw, 3rem); } h2 { font-size: clamp(1.4rem, 4.5vw, 2rem); } diff --git a/src/app/learn/[level]/[slug]/page.tsx b/src/app/learn/[level]/[slug]/page.tsx index 03d63f1..5fe28a9 100644 --- a/src/app/learn/[level]/[slug]/page.tsx +++ b/src/app/learn/[level]/[slug]/page.tsx @@ -1,7 +1,8 @@ import { notFound, redirect } from "next/navigation"; import SiteNav from "@/components/SiteNav"; import LessonRunner from "@/components/LessonRunner"; -import { getLessonBySlug, cumulativeSvg } from "@/lib/curriculum"; +import ShadingRunner from "@/components/ShadingRunner"; +import { getLessonBySlug, getLevel, cumulativeSvg } from "@/lib/curriculum"; import { getCurrentUser } from "@/lib/session"; import { getCompletedSteps, hasBadge } from "@/lib/progress"; @@ -30,6 +31,8 @@ export default async function LessonPage({ const completed = user ? getCompletedSteps(user.id, lesson.level, lesson.sublevel) : []; const alreadyEarned = user ? hasBadge(user.id, lesson.badgeKey) : false; + const levelName = getLevel(lesson.level)?.name ?? ""; + const isShade = lesson.mode === "shade"; const steps = lesson.steps.map((s) => ({ n: s.n, @@ -37,30 +40,44 @@ export default async function LessonPage({ instruction: s.instruction, tip: s.tip ?? "", kind: s.kind ?? "", - guideSvg: cumulativeSvg(lesson, s.n), + // Draw lessons stack their fragments; shade lessons use each step's full guide. + guideSvg: isShade ? s.svg : cumulativeSvg(lesson, s.n), })); + const meta = { + level: lesson.level, + levelName, + sublevel: lesson.sublevel, + title: lesson.title, + emoji: lesson.emoji, + intro: lesson.intro, + badgeKey: lesson.badgeKey, + badgeName: lesson.badgeName, + }; + return ( <>
- + {isShade ? ( + + ) : ( + + )}
); diff --git a/src/components/LessonRunner.tsx b/src/components/LessonRunner.tsx index 516d79c..53ca56b 100644 --- a/src/components/LessonRunner.tsx +++ b/src/components/LessonRunner.tsx @@ -2,13 +2,20 @@ import { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { badgeSvgMarkup, badgeFullSvg } from "./BadgeArt"; +import { + loadProgressDrawing, + saveProgressDrawing, + saveFinalDrawing, + exportCanvasToDataURL, + fileToDataURL, +} from "./drawingPersistence"; interface StepData { n: number; title: string; instruction: string; tip: string; - kind: string; // optional teaching tag ("" = derive from position) + kind: string; guideSvg: string; } interface Meta { @@ -22,7 +29,7 @@ interface Meta { badgeName: string; } -const COLORS = ["#2b2440", "#4f7cff", "#34c6c6", "#58c777", "#ffc843", "#ff7eb6", "#e85a5a"]; +const COLORS = ["#2b2440", "#4f7cff", "#34c6c6", "#58c777", "#ffc843", "#ff7eb6", "#e85a5a", "#ffffff", "#c4c8cc"]; const SIZES = [4, 8, 16]; const KIND_META: Record = { @@ -45,7 +52,7 @@ interface Point { interface Stroke { color: string; size: number; - points: Point[]; // normalized 0..1 + points: Point[]; } export default function LessonRunner({ @@ -70,19 +77,24 @@ export default function LessonRunner({ const [finished, setFinished] = useState(alreadyEarned); const [earned, setEarned] = useState(alreadyEarned); - // Replay ("watch it drawn") + narration const [replaying, setReplaying] = useState(false); const [replayK, setReplayK] = useState(1); const [narrate, setNarrate] = useState(false); + // Completion drawing + paper upload + const [finalImage, setFinalImage] = useState(null); + const [uploadMsg, setUploadMsg] = useState(""); + const uploadRef = useRef(null); + const step = steps[idx]; const kindInfo = kindForStep(step.kind, idx, steps.length); - // ---------------- Canvas drawing ---------------- const canvasRef = useRef(null); const stageRef = useRef(null); const strokesRef = useRef([]); const drawingRef = useRef(null); + const baseImgRef = useRef(null); // restored saved drawing + const saveTimer = useRef | undefined>(undefined); const [color, setColor] = useState(COLORS[0]); const [size, setSize] = useState(SIZES[1]); const [, force] = useState(0); @@ -92,14 +104,11 @@ export default function LessonRunner({ if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; - const w = canvas.width; - const h = canvas.height; - ctx.clearRect(0, 0, w, h); + ctx.clearRect(0, 0, canvas.width, canvas.height); + if (baseImgRef.current) ctx.drawImage(baseImgRef.current, 0, 0, canvas.width, canvas.height); ctx.lineCap = "round"; ctx.lineJoin = "round"; - for (const s of strokesRef.current) { - drawStroke(ctx, s, w, h); - } + for (const s of strokesRef.current) drawStroke(ctx, s, canvas.width, canvas.height); }, []); const resize = useCallback(() => { @@ -119,6 +128,40 @@ export default function LessonRunner({ return () => window.removeEventListener("resize", resize); }, [resize]); + // Restore the saved in-progress drawing + useEffect(() => { + if (!loggedIn || alreadyEarned) return; + let alive = true; + loadProgressDrawing(meta.level, meta.sublevel).then((img) => { + if (!img || !alive) return; + const im = new Image(); + im.onload = () => { + baseImgRef.current = im; + redraw(); + }; + im.src = img; + }); + return () => { + alive = false; + }; + }, [loggedIn, alreadyEarned, meta.level, meta.sublevel, redraw]); + + function hasContent(): boolean { + return strokesRef.current.length > 0 || !!baseImgRef.current; + } + + function flushSave() { + if (!loggedIn) return; + const c = canvasRef.current; + if (!c || !hasContent()) return; + saveProgressDrawing(meta.level, meta.sublevel, exportCanvasToDataURL(c)); + } + function scheduleSave() { + if (!loggedIn) return; + clearTimeout(saveTimer.current); + saveTimer.current = setTimeout(flushSave, 1200); + } + function pointFromEvent(e: React.PointerEvent): Point { const canvas = canvasRef.current!; const rect = canvas.getBoundingClientRect(); @@ -141,29 +184,31 @@ export default function LessonRunner({ function onPointerUp() { drawingRef.current = null; force((n) => n + 1); + scheduleSave(); } function clearCanvas() { strokesRef.current = []; + baseImgRef.current = null; redraw(); force((n) => n + 1); + if (loggedIn) saveProgressDrawing(meta.level, meta.sublevel, exportCanvasToDataURL(canvasRef.current!)); } function undo() { strokesRef.current.pop(); redraw(); force((n) => n + 1); + scheduleSave(); } - // ---------------- "Watch it drawn" replay ---------------- function watch() { if (replaying) return; setReplayK(1); setReplaying(true); } - useEffect(() => { if (!replaying) return; - const target = idx + 1; // reveal steps 1..current + const target = idx + 1; if (replayK < target) { const t = setTimeout(() => setReplayK((k) => k + 1), 850); return () => clearTimeout(t); @@ -172,7 +217,6 @@ export default function LessonRunner({ return () => clearTimeout(t); }, [replaying, replayK, idx]); - // ---------------- Narration (on-device, optional) ---------------- const speak = useCallback((text: string) => { try { const synth = window.speechSynthesis; @@ -183,28 +227,24 @@ export default function LessonRunner({ u.pitch = 1.05; synth.speak(u); } catch { - /* speech is best-effort */ + /* best-effort */ } }, []); - useEffect(() => { - if (narrate && !finished) { - speak(`Step ${step.n}. ${step.title}. ${step.instruction}`); - } + if (narrate && !finished) speak(`Step ${step.n}. ${step.title}. ${step.instruction}`); // eslint-disable-next-line react-hooks/exhaustive-deps }, [idx, narrate, finished]); - - useEffect(() => { - return () => { + useEffect( + () => () => { try { window.speechSynthesis?.cancel(); } catch { /* ignore */ } - }; - }, []); + }, + [] + ); - // ---------------- Step navigation + progress ---------------- async function saveStep(stepN: number, badgeKey?: string) { if (!loggedIn) return; try { @@ -214,19 +254,18 @@ export default function LessonRunner({ body: JSON.stringify({ level: meta.level, sublevel: meta.sublevel, step: stepN, badgeKey }), }); } catch { - /* progress is best-effort */ + /* best-effort */ } } function next() { saveStep(step.n); - if (idx < lastIndex) { - setIdx(idx + 1); - } else { - finish(); - } + flushSave(); + if (idx < lastIndex) setIdx(idx + 1); + else finish(); } function prev() { + flushSave(); if (idx > 0) setIdx(idx - 1); } @@ -237,11 +276,35 @@ export default function LessonRunner({ /* ignore */ } await saveStep(step.n, meta.badgeKey); + const c = canvasRef.current; + if (c && hasContent()) { + const data = exportCanvasToDataURL(c); + setFinalImage(data); + if (loggedIn) await saveFinalDrawing(meta.level, meta.sublevel, "canvas", data); + } setEarned(loggedIn); setFinished(true); } - // ---------------- Sharing ---------------- + async function onUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + if (!loggedIn) { + setUploadMsg("Log in to save your drawing to your gallery."); + return; + } + setUploadMsg("Uploading…"); + try { + const data = await fileToDataURL(file); + const ok = await saveFinalDrawing(meta.level, meta.sublevel, "upload", data); + setUploadMsg(ok ? "Saved to your gallery! πŸ–ΌοΈ" : "Couldn't save that file."); + } catch { + setUploadMsg("Couldn't read that image."); + } finally { + if (uploadRef.current) uploadRef.current.value = ""; + } + } + async function rasterizeBadge(): Promise { const svg = badgeFullSvg(meta.badgeName, meta.emoji); const blob = new Blob([svg], { type: "image/svg+xml" }); @@ -258,7 +321,6 @@ export default function LessonRunner({ URL.revokeObjectURL(url); } } - async function shareBadge() { const blob = await rasterizeBadge(); if (!blob) return; @@ -266,21 +328,19 @@ export default function LessonRunner({ const nav = navigator as Navigator & { canShare?: (d: ShareData) => boolean }; if (nav.share && nav.canShare && nav.canShare({ files: [file] })) { try { - await nav.share({ files: [file], title: "My DrawIt badge!", text: `I earned the ${meta.badgeName} badge on DrawIt! 🐟` }); + await nav.share({ files: [file], title: "My DrawIt badge!", text: `I earned the ${meta.badgeName} badge on DrawIt!` }); return; } catch { - /* fall through to download */ + /* fall through */ } } downloadBlob(blob, "drawit-badge.png"); } - async function downloadBadge() { const blob = await rasterizeBadge(); if (blob) downloadBlob(blob, "drawit-badge.png"); } - // ---------------- Render ---------------- if (finished) { return (
@@ -289,7 +349,7 @@ export default function LessonRunner({

You finished {meta.title} and earned the {meta.badgeName} badge.

-
+
@@ -304,15 +364,44 @@ export default function LessonRunner({ ) : (
Nice drawing! Create an account or log in to - earn the badge and download your printable award. + earn the badge and keep your drawings.
)} -
+ {finalImage && ( + + )} + +
+ Drew it on paper? +

+ Take a photo and add it to your gallery. +

+ + + {uploadMsg &&

{uploadMsg}

} +
+ +
+ {loggedIn && πŸ–ΌοΈ My drawings}
)} - {/* Progress dots */}
{steps.map((s, i) => (
- {/* Stage */}
- {/* Watch + narration */}
-
- {/* Tools */}
{COLORS.map((c) => (
+ {loggedIn &&

Your drawing is saved automatically.

}
- {/* Instruction */}
diff --git a/src/components/NavLinks.tsx b/src/components/NavLinks.tsx index e008e69..00bebf7 100644 --- a/src/components/NavLinks.tsx +++ b/src/components/NavLinks.tsx @@ -34,7 +34,9 @@ export default function NavLinks({ Report a problem - {user.username} + + πŸ–ΌοΈ {user.username} + ) : ( diff --git a/src/lib/curriculum.ts b/src/lib/curriculum.ts index 353c61d..8dfb1b0 100644 --- a/src/lib/curriculum.ts +++ b/src/lib/curriculum.ts @@ -1,7 +1,7 @@ /** * The DrawIt curriculum. Levels describe the learner; each level has numbered sub-levels (lessons). - * Each lesson is ordered steps built from simple shapes. SVG fragments use a 400x300 viewBox and - * STACK: rendering steps 1..n draws the whole picture (powers the slideshow and the lesson guide). + * Draw lessons: SVG fragments STACK (steps 1..n = full picture). Shade lessons (mode "shade"): + * each step's svg is a full guide (light source + zone hints), not cumulative. */ export interface LevelMeta { key: string; name: string; emoji: string; blurb: string; } @@ -20,7 +20,7 @@ export type StepKind = "construct" | "outline" | "color" | "shade" | "detail"; export interface LessonStep { n: number; title: string; instruction: string; tip?: string; kind?: StepKind; svg: string; } -export interface Lesson { level: string; sublevel: number; slug: string; title: string; subject: string; emoji: string; intro: string; badgeKey: string; badgeName: string; steps: LessonStep[]; } +export interface Lesson { level: string; sublevel: number; slug: string; title: string; subject: string; emoji: string; intro: string; badgeKey: string; badgeName: string; mode?: "draw" | "shade"; steps: LessonStep[]; } const FISH_STEPS: LessonStep[] = [ @@ -41,7 +41,7 @@ const FISH_STEPS: LessonStep[] = [ svg: `` }, ]; -export const FISH_LESSON: Lesson = { level: "early-beginner", sublevel: 1, slug: "fish", title: "Draw a Fish", subject: "a happy fish", emoji: "🐟", intro: "We'll build a friendly fish from a few simple shapes. Six easy steps!", badgeKey: "early-beginner-1-fish", badgeName: "Fish Friend", steps: FISH_STEPS }; +export const FISH_LESSON: Lesson = { level: "early-beginner", sublevel: 1, slug: "fish", title: "Draw a Fish", subject: "a happy fish", emoji: "🐟", intro: "We'll build a friendly fish from a few simple shapes. Six easy steps!", badgeKey: "early-beginner-1-fish", badgeName: "Fish Friend", mode: "draw", steps: FISH_STEPS }; const PANDA_STEPS: LessonStep[] = [ { n: 1, kind: "construct", title: "The head", instruction: "Draw one big round face.", @@ -61,7 +61,7 @@ const PANDA_STEPS: LessonStep[] = [ svg: `` }, ]; -export const PANDA_LESSON: Lesson = { level: "early-beginner", sublevel: 2, slug: "panda", title: "Draw a Panda", subject: "a cuddly panda", emoji: "🐼", intro: "A panda is just circles and ovals. Let's make a cuddly friend!", badgeKey: "early-beginner-2-panda", badgeName: "Panda Pal", steps: PANDA_STEPS }; +export const PANDA_LESSON: Lesson = { level: "early-beginner", sublevel: 2, slug: "panda", title: "Draw a Panda", subject: "a cuddly panda", emoji: "🐼", intro: "A panda is just circles and ovals. Let's make a cuddly friend!", badgeKey: "early-beginner-2-panda", badgeName: "Panda Pal", mode: "draw", steps: PANDA_STEPS }; const FLOWER_STEPS: LessonStep[] = [ { n: 1, kind: "construct", title: "The middle", instruction: "Draw a circle in the center of the page.", @@ -83,7 +83,7 @@ const FLOWER_STEPS: LessonStep[] = [ svg: `` }, ]; -export const FLOWER_LESSON: Lesson = { level: "early-beginner", sublevel: 3, slug: "flower", title: "Draw a Flower", subject: "a cheerful flower", emoji: "🌸", intro: "A circle in the middle and petals all around β€” then a stem and leaves.", badgeKey: "early-beginner-3-flower", badgeName: "Flower Power", steps: FLOWER_STEPS }; +export const FLOWER_LESSON: Lesson = { level: "early-beginner", sublevel: 3, slug: "flower", title: "Draw a Flower", subject: "a cheerful flower", emoji: "🌸", intro: "A circle in the middle and petals all around β€” then a stem and leaves.", badgeKey: "early-beginner-3-flower", badgeName: "Flower Power", mode: "draw", steps: FLOWER_STEPS }; const UNICORN_STEPS: LessonStep[] = [ { n: 1, kind: "construct", title: "The head", instruction: "Draw a rounded head that's a little narrower at the chin.", @@ -103,7 +103,7 @@ const UNICORN_STEPS: LessonStep[] = [ svg: `` }, ]; -export const UNICORN_LESSON: Lesson = { level: "early-beginner", sublevel: 4, slug: "unicorn", title: "Draw a Unicorn", subject: "a magical unicorn", emoji: "πŸ¦„", intro: "A rounded head, a sparkly horn, and a rainbow mane. Let's make magic!", badgeKey: "early-beginner-4-unicorn", badgeName: "Unicorn Magic", steps: UNICORN_STEPS }; +export const UNICORN_LESSON: Lesson = { level: "early-beginner", sublevel: 4, slug: "unicorn", title: "Draw a Unicorn", subject: "a magical unicorn", emoji: "πŸ¦„", intro: "A rounded head, a sparkly horn, and a rainbow mane. Let's make magic!", badgeKey: "early-beginner-4-unicorn", badgeName: "Unicorn Magic", mode: "draw", steps: UNICORN_STEPS }; const TREE_STEPS: LessonStep[] = [ { n: 1, kind: "construct", title: "The trunk", instruction: "Draw a trunk that's a little wider at the bottom.", @@ -125,7 +125,7 @@ const TREE_STEPS: LessonStep[] = [ svg: `` }, ]; -export const TREE_LESSON: Lesson = { level: "early-beginner", sublevel: 5, slug: "tree", title: "Draw a Tree", subject: "a leafy tree", emoji: "🌳", intro: "A trunk, some branches, and big clumps of leaves make a leafy tree.", badgeKey: "early-beginner-5-tree", badgeName: "Tree Top", steps: TREE_STEPS }; +export const TREE_LESSON: Lesson = { level: "early-beginner", sublevel: 5, slug: "tree", title: "Draw a Tree", subject: "a leafy tree", emoji: "🌳", intro: "A trunk, some branches, and big clumps of leaves make a leafy tree.", badgeKey: "early-beginner-5-tree", badgeName: "Tree Top", mode: "draw", steps: TREE_STEPS }; const DINO_STEPS: LessonStep[] = [ { n: 1, kind: "construct", title: "The body", instruction: "Draw a big rounded body.", @@ -149,9 +149,28 @@ const DINO_STEPS: LessonStep[] = [ svg: `` }, ]; -export const DINO_LESSON: Lesson = { level: "early-beginner", sublevel: 6, slug: "dinosaur", title: "Draw a Dinosaur", subject: "a friendly dinosaur", emoji: "πŸ¦•", intro: "A round body, a long neck, a small head, and a swooshy tail!", badgeKey: "early-beginner-6-dinosaur", badgeName: "Dino Master", steps: DINO_STEPS }; +export const DINO_LESSON: Lesson = { level: "early-beginner", sublevel: 6, slug: "dinosaur", title: "Draw a Dinosaur", subject: "a friendly dinosaur", emoji: "πŸ¦•", intro: "A round body, a long neck, a small head, and a swooshy tail!", badgeKey: "early-beginner-6-dinosaur", badgeName: "Dino Master", mode: "draw", steps: DINO_STEPS }; -export const LESSONS: Lesson[] = [FISH_LESSON, PANDA_LESSON, FLOWER_LESSON, UNICORN_LESSON, TREE_LESSON, DINO_LESSON]; +const SHADE_STEPS: LessonStep[] = [ + { n: 1, kind: "shade", title: "How to hold your pencil", instruction: "Watch the demo above. Holding near the tip and pressing hard makes DARK shades; holding high up and pressing soft makes LIGHT shades.", + tip: "Try it in the air first!", + svg: `` }, + { n: 2, kind: "shade", title: "Find the light", instruction: "The sun is up and to the left. The side of the ball facing the sun stays the brightest. That bright spot is the highlight.", + svg: `` }, + { n: 3, kind: "shade", title: "Soft shade all over", instruction: "Pick a light tone and, with soft gentle passes, shade the whole ball β€” but leave the bright highlight spot white.", + tip: "Hold high and go easy.", + svg: `` }, + { n: 4, kind: "shade", title: "Build up the shadow", instruction: "Pick a darker tone and press a little harder on the side AWAY from the sun. Go over it a few times to build it up slowly.", + tip: "More passes = darker.", + svg: `` }, + { n: 5, kind: "shade", title: "Cast shadow & finish", instruction: "Shade the ground just under the ball on the shadow side. Keep that highlight bright and clean. You did it!", + tip: "πŸŒ—", + svg: `` }, +]; + +export const SHADE_BALL_LESSON: Lesson = { level: "learner", sublevel: 1, slug: "shade-ball", title: "Light & Shadow: Shade a Ball", subject: "a shaded ball", emoji: "\u{1F317}", intro: "Time to make a flat circle look like a round ball β€” using light and shadow! First, learn how to hold your pencil.", badgeKey: "learner-1-shading", badgeName: "Shading Star", mode: "shade", steps: SHADE_STEPS }; + +export const LESSONS: Lesson[] = [FISH_LESSON, PANDA_LESSON, FLOWER_LESSON, UNICORN_LESSON, TREE_LESSON, DINO_LESSON, SHADE_BALL_LESSON]; export function getLesson(level: string, sublevel: number): Lesson | undefined { return LESSONS.find((l) => l.level === level && l.sublevel === sublevel); } export function getLessonBySlug(slug: string): Lesson | undefined { return LESSONS.find((l) => l.slug === slug); } diff --git a/src/lib/db.ts b/src/lib/db.ts index f6ce669..9d9aa07 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -99,10 +99,34 @@ function migrate(db: Database.Database) { created_at TEXT NOT NULL DEFAULT (datetime('now')) ); + CREATE TABLE IF NOT EXISTS drawings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + level TEXT NOT NULL, + sublevel INTEGER NOT NULL, + status TEXT NOT NULL, -- 'progress' | 'final' + kind TEXT NOT NULL, -- 'canvas' | 'upload' + image TEXT NOT NULL, -- data URL (PNG/JPEG) + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_tokens_token ON tokens(token); CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); CREATE INDEX IF NOT EXISTS idx_progress_user ON progress(user_id); CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status); + CREATE TABLE IF NOT EXISTS completions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + level TEXT NOT NULL, + sublevel INTEGER NOT NULL, + badge_key TEXT NOT NULL, + completed_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_drawings_user ON drawings(user_id); + CREATE INDEX IF NOT EXISTS idx_drawings_progress ON drawings(user_id, level, sublevel, status); + CREATE INDEX IF NOT EXISTS idx_completions_user ON completions(user_id); `); } diff --git a/src/lib/progress.ts b/src/lib/progress.ts index 926e2ce..eae950b 100644 --- a/src/lib/progress.ts +++ b/src/lib/progress.ts @@ -52,3 +52,28 @@ export function listBadges(userId: number): { badge_key: string; awarded_at: str .prepare("SELECT badge_key, awarded_at FROM badges WHERE user_id = ? ORDER BY awarded_at") .all(userId) as { badge_key: string; awarded_at: string }[]; } + +/** Records one row per lesson completion, so the profile can show every date completed. */ +export function recordCompletion(userId: number, level: string, sublevel: number, badgeKey: string): void { + getDb() + .prepare( + `INSERT INTO completions (user_id, level, sublevel, badge_key) VALUES (?, ?, ?, ?)` + ) + .run(userId, level, sublevel, badgeKey); +} + +export interface CompletionRow { + badge_key: string; + level: string; + sublevel: number; + completed_at: string; +} + +export function listCompletions(userId: number): CompletionRow[] { + return getDb() + .prepare( + `SELECT badge_key, level, sublevel, completed_at FROM completions + WHERE user_id = ? ORDER BY completed_at DESC` + ) + .all(userId) as CompletionRow[]; +}