form 1 topics added

This commit is contained in:
2026-07-05 11:12:45 -04:00
parent a614532810
commit a23fa53772
58 changed files with 7089 additions and 475 deletions

View File

@@ -33,6 +33,14 @@
--unit-6-light: #eef2ff;
--unit-6-dark: #3730a3;
--unit-7: #16a34a;
--unit-7-light: #dcfce7;
--unit-7-dark: #15803d;
--unit-8: #e11d48;
--unit-8-light: #ffe4e9;
--unit-8-dark: #be123c;
--correct: #16a34a;
--correct-light: #dcfce7;
--incorrect: #dc2626;
@@ -73,6 +81,12 @@
--color-unit-6: var(--unit-6);
--color-unit-6-light: var(--unit-6-light);
--color-unit-6-dark: var(--unit-6-dark);
--color-unit-7: var(--unit-7);
--color-unit-7-light: var(--unit-7-light);
--color-unit-7-dark: var(--unit-7-dark);
--color-unit-8: var(--unit-8);
--color-unit-8-light: var(--unit-8-light);
--color-unit-8-dark: var(--unit-8-dark);
--color-correct: var(--correct);
--color-correct-light: var(--correct-light);

View File

@@ -1,7 +1,6 @@
import { Header } from "@/components/layout/header";
import { Footer } from "@/components/layout/footer";
import { Sidebar } from "@/components/layout/sidebar";
import { MobileNav } from "@/components/layout/mobile-nav";
import { LessonsShell } from "@/components/layout/lessons-shell";
export default function LessonsLayout({
children,
@@ -11,17 +10,7 @@ export default function LessonsLayout({
return (
<div className="playground-bg flex min-h-screen flex-col">
<Header />
<div className="playground-frame relative mx-auto flex w-full max-w-6xl flex-1">
<Sidebar />
<main className="flex-1 bg-white">
<div className="relative p-4 lg:hidden">
<MobileNav />
</div>
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
{children}
</div>
</main>
</div>
<LessonsShell>{children}</LessonsShell>
<Footer />
</div>
);

View File

@@ -0,0 +1,22 @@
"use client";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
import { NegativeFractionsExplorer } from "@/components/explorers/negative-fractions-explorer";
export default function NegativeFractionsPage() {
return (
<div className="space-y-8">
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: "Unit 5: Integers", href: "/lessons/unit-5-integers" },
{ label: "Negative Fractions" },
]}
/>
<h1 className="text-3xl font-bold tracking-tight">Negative Fractions</h1>
<NegativeFractionsExplorer />
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
import { IntegerOrderCompareExplorer } from "@/components/explorers/integer-order-compare-explorer";
export default function IntegerOrderComparePage() {
return (
<div className="space-y-8">
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: "Unit 5: Integers", href: "/lessons/unit-5-integers" },
{ label: "Order & Compare" },
]}
/>
<h1 className="text-3xl font-bold tracking-tight">Order and Compare Integers</h1>
<IntegerOrderCompareExplorer />
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
import { PlaceValueExplorer } from "@/components/explorers/place-value-explorer";
export default function PlaceValuePage() {
return (
<div className="space-y-8">
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: "Unit 6: Number System", href: "/lessons/unit-6-number-system" },
{ label: "Place Value" },
]}
/>
<h1 className="text-3xl font-bold tracking-tight">Denary Place Value (Base 10)</h1>
<PlaceValueExplorer />
</div>
);
}

View File

@@ -0,0 +1,40 @@
import Link from "next/link";
import { curriculum } from "@/lib/curriculum";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
export default function Unit7Overview() {
const unit = curriculum[6];
return (
<div>
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: `Unit 7: ${unit.title}` },
]}
/>
<div className="mb-10">
<Badge variant="unit-7" className="mb-3">Unit 7</Badge>
<h1 className="mb-2 text-3xl font-bold tracking-tight">{unit.title}</h1>
<p className="text-muted leading-relaxed">{unit.description}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{unit.topics.map((topic, i) => (
<Link key={topic.slug} href={`/lessons/${unit.slug}/${topic.slug}`}>
<Card accent="unit-7" hover className="group h-full">
<div className="mb-2 flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-unit-7-light text-xs font-bold text-unit-7-dark shadow-[var(--shadow-sm)]">
{i + 1}
</span>
</div>
<h3 className="mb-1 font-semibold">{topic.title}</h3>
<p className="text-sm leading-relaxed text-muted">{topic.description}</p>
</Card>
</Link>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
import { PlotPointsExplorer } from "@/components/explorers/plot-points-explorer";
export default function PlotPointsPage() {
return (
<div className="space-y-8">
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: "Unit 7: Coordinates", href: "/lessons/unit-7-coordinates" },
{ label: "Plot Points" },
]}
/>
<h1 className="text-3xl font-bold tracking-tight">Plot Points on the Cartesian Plane</h1>
<PlotPointsExplorer />
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
import { ReadCoordinatesExplorer } from "@/components/explorers/read-coordinates-explorer";
export default function ReadCoordinatesPage() {
return (
<div className="space-y-8">
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: "Unit 7: Coordinates", href: "/lessons/unit-7-coordinates" },
{ label: "Read Coordinates" },
]}
/>
<h1 className="text-3xl font-bold tracking-tight">State Coordinates of Points</h1>
<ReadCoordinatesExplorer />
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
import { ExponentsExplorer } from "@/components/explorers/exponents-explorer";
export default function ExponentsPage() {
return (
<div className="space-y-8">
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: "Unit 8: Number Properties", href: "/lessons/unit-8-number-properties" },
{ label: "Exponents" },
]}
/>
<h1 className="text-3xl font-bold tracking-tight">Powers and Exponents</h1>
<ExponentsExplorer />
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
import { IdentityInverseExplorer } from "@/components/explorers/identity-inverse-explorer";
export default function IdentityInversePage() {
return (
<div className="space-y-8">
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: "Unit 8: Number Properties", href: "/lessons/unit-8-number-properties" },
{ label: "Identity & Inverse" },
]}
/>
<h1 className="text-3xl font-bold tracking-tight">Identity and Inverse</h1>
<IdentityInverseExplorer />
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
import { NumberLawsExplorer } from "@/components/explorers/number-laws-explorer";
export default function NumberLawsPage() {
return (
<div className="space-y-8">
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: "Unit 8: Number Properties", href: "/lessons/unit-8-number-properties" },
{ label: "Number Laws" },
]}
/>
<h1 className="text-3xl font-bold tracking-tight">Commutative, Associative and Distributive Laws</h1>
<NumberLawsExplorer />
</div>
);
}

View File

@@ -0,0 +1,40 @@
import Link from "next/link";
import { curriculum } from "@/lib/curriculum";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
export default function Unit8Overview() {
const unit = curriculum[7];
return (
<div>
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: `Unit 8: ${unit.title}` },
]}
/>
<div className="mb-10">
<Badge variant="unit-8" className="mb-3">Unit 8</Badge>
<h1 className="mb-2 text-3xl font-bold tracking-tight">{unit.title}</h1>
<p className="text-muted leading-relaxed">{unit.description}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{unit.topics.map((topic, i) => (
<Link key={topic.slug} href={`/lessons/${unit.slug}/${topic.slug}`}>
<Card accent="unit-8" hover className="group h-full">
<div className="mb-2 flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-unit-8-light text-xs font-bold text-unit-8-dark shadow-[var(--shadow-sm)]">
{i + 1}
</span>
</div>
<h3 className="mb-1 font-semibold">{topic.title}</h3>
<p className="text-sm leading-relaxed text-muted">{topic.description}</p>
</Card>
</Link>
))}
</div>
</div>
);
}

View File

@@ -6,7 +6,7 @@ export default function NotFound() {
return (
<div className="playground-bg flex min-h-screen flex-col">
<Header />
<main className="playground-frame hero-gradient relative mx-auto flex w-full max-w-6xl flex-1 items-center justify-center">
<main className="playground-frame hero-gradient relative mx-auto flex w-full max-w-7xl flex-1 items-center justify-center">
<div className="dot-pattern absolute inset-0 opacity-25" />
<div className="relative text-center">
<p className="mb-2 text-sm font-semibold uppercase tracking-widest text-muted">

View File

@@ -35,6 +35,16 @@ const unitStyles = {
unitCard: "border-[#a5b4fc] bg-[#eef2ff]",
chip: "bg-[#3730a3]",
},
"unit-7": {
tile: "from-[#4ade80] to-[#15803d]",
unitCard: "border-[#86efac] bg-[#f0fdf4]",
chip: "bg-[#15803d]",
},
"unit-8": {
tile: "from-[#fb7185] to-[#be123c]",
unitCard: "border-[#fda4af] bg-[#fff1f2]",
chip: "bg-[#be123c]",
},
};
const topicTiles = [
@@ -59,7 +69,7 @@ export default function Home() {
return (
<div className="playground-bg flex min-h-screen flex-col">
<Header />
<main className="playground-frame mx-auto w-full max-w-6xl flex-1 px-3 pb-10 sm:px-5">
<main className="playground-frame mx-auto w-full max-w-7xl flex-1 px-3 pb-10 sm:px-5">
<section className="mt-4 rounded-md border-2 border-[#1f50bf] bg-[#f1f6ff] p-4 sm:p-6">
<p className="text-center text-sm font-bold text-[#26438b]">
Interactive maths practice for secondary school students

View File

@@ -1,167 +1,338 @@
"use client";
import { useState } from "react";
import { Card } from "@/components/ui/card";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Header } from "@/components/layout/header";
import { Footer } from "@/components/layout/footer";
import { PracticeSection } from "@/components/practice/practice-section";
import { useCollapsibleSidebar, SidebarBackdrop, SidebarToggle, sidebarAsideClass, sidebarNavClass } from "@/components/layout/collapsible-sidebar";
import { curriculum, type Unit, type Topic } from "@/lib/curriculum";
import { cn } from "@/lib/utils";
import type { Difficulty, MathProblem } from "@/lib/problems/types";
import { generateFractionAddSubtract, generateFractionMultiply, generateFractionDivide, generateFractionOfQuantity, generateWholeFromFraction } from "@/lib/problems/generators/fraction-problems";
import { generateFractionAddSubtract, generateFractionMultiply, generateFractionDivide, generateFractionOfQuantity, generateWholeFromFraction, generateFractionBodmas, generateSignedFraction } from "@/lib/problems/generators/fraction-problems";
import { generateDecimalCompareOrder, generateDecimalRounding, generateStandardForm, generateDecimalAddSubtract, generateDecimalMultiplyDivide, generateDecimalConversion } from "@/lib/problems/generators/decimal-problems";
import { generateSimplifyRatio, generateDivideInRatio, generateRatioWordProblem } from "@/lib/problems/generators/ratio-problems";
import { generateSimplifyRatio, generateDivideInRatio, generateRatioWordProblem, generateDefineRatio, generateFractionsAndRatios } from "@/lib/problems/generators/ratio-problems";
import { generateIntegerAddSubtractMixed, generateIntegerMultiplyDivide, generateIntegerOrderCompare } from "@/lib/problems/generators/integer-problems";
import { generateDigitValue, generateBinaryToDenary, generateQuaternaryToDenary } from "@/lib/problems/generators/number-system-problems";
import { generateIdentityInverse, generateExponentEvaluate, generateDistributive } from "@/lib/problems/generators/number-properties-problems";
interface TopicGenerator {
unitSlug: string;
topicSlug: string;
label: string;
generator: (difficulty: Difficulty) => MathProblem;
unitColor: "unit-1" | "unit-2" | "unit-3" | "unit-4";
}
type UnitColor = Unit["color"];
type PracticeUnitColor = Exclude<UnitColor, "unit-7">;
type Generator = (difficulty: Difficulty) => MathProblem;
type UnitColor = TopicGenerator["unitColor"];
const TOPIC_GENERATORS: TopicGenerator[] = [
{ unitSlug: "unit-1-fractions", topicSlug: "add-subtract", label: "Add & Subtract Fractions", generator: generateFractionAddSubtract, unitColor: "unit-1" },
{ unitSlug: "unit-1-fractions", topicSlug: "multiply", label: "Multiply Fractions", generator: generateFractionMultiply, unitColor: "unit-1" },
{ unitSlug: "unit-1-fractions", topicSlug: "divide", label: "Divide Fractions", generator: generateFractionDivide, unitColor: "unit-1" },
{ unitSlug: "unit-1-fractions", topicSlug: "fraction-of-quantity", label: "Fraction of a Quantity", generator: generateFractionOfQuantity, unitColor: "unit-1" },
{ unitSlug: "unit-1-fractions", topicSlug: "whole-from-fractions", label: "Find the Whole", generator: generateWholeFromFraction, unitColor: "unit-1" },
{ unitSlug: "unit-2-decimals", topicSlug: "compare-order", label: "Compare & Order Decimals", generator: generateDecimalCompareOrder, unitColor: "unit-2" },
{ unitSlug: "unit-2-decimals", topicSlug: "approximate", label: "Approximate Decimals", generator: generateDecimalRounding, unitColor: "unit-2" },
{ unitSlug: "unit-2-decimals", topicSlug: "standard-form", label: "Standard Form", generator: generateStandardForm, unitColor: "unit-2" },
{ unitSlug: "unit-3-decimal-operations", topicSlug: "convert", label: "Convert Decimals & Fractions", generator: generateDecimalConversion, unitColor: "unit-3" },
{ unitSlug: "unit-3-decimal-operations", topicSlug: "add-subtract", label: "Add & Subtract Decimals", generator: generateDecimalAddSubtract, unitColor: "unit-3" },
{ unitSlug: "unit-3-decimal-operations", topicSlug: "multiply-divide", label: "Multiply & Divide Decimals", generator: generateDecimalMultiplyDivide, unitColor: "unit-3" },
{ unitSlug: "unit-4-ratio-proportion", topicSlug: "simplify-ratios", label: "Simplify Ratios", generator: generateSimplifyRatio, unitColor: "unit-4" },
{ unitSlug: "unit-4-ratio-proportion", topicSlug: "divide-in-ratio", label: "Divide in a Ratio", generator: generateDivideInRatio, unitColor: "unit-4" },
{ unitSlug: "unit-4-ratio-proportion", topicSlug: "word-problems", label: "Ratio Word Problems", generator: generateRatioWordProblem, unitColor: "unit-4" },
];
const unitColors: Record<UnitColor, { activeFilter: string; inactiveFilter: string; label: string }> = {
"unit-1": {
activeFilter: "border-unit-1 bg-unit-1 text-white shadow-[var(--shadow-sm)]",
inactiveFilter: "border-unit-1/40 text-unit-1-dark hover:bg-unit-1-light",
label: "Fractions",
},
"unit-2": {
activeFilter: "border-unit-2 bg-unit-2 text-white shadow-[var(--shadow-sm)]",
inactiveFilter: "border-unit-2/40 text-unit-2-dark hover:bg-unit-2-light",
label: "Decimals",
},
"unit-3": {
activeFilter: "border-unit-3 bg-unit-3 text-white shadow-[var(--shadow-sm)]",
inactiveFilter: "border-unit-3/40 text-unit-3-dark hover:bg-unit-3-light",
label: "Decimal Operations",
},
"unit-4": {
activeFilter: "border-unit-4 bg-unit-4 text-white shadow-[var(--shadow-sm)]",
inactiveFilter: "border-unit-4/40 text-unit-4-dark hover:bg-unit-4-light",
label: "Ratio & Proportion",
},
/**
* Practice for each curriculum topic. "lesson-only" topics (the coordinate grid)
* need the interactive visual, so they link to their lesson instead of a
* fill-in-the-answer generator. Keyed by `${unitSlug}/${topicSlug}`.
*/
const PRACTICE_BY_TOPIC: Record<string, Generator | "lesson-only"> = {
"unit-1-fractions/add-subtract": generateFractionAddSubtract,
"unit-1-fractions/multiply": generateFractionMultiply,
"unit-1-fractions/divide": generateFractionDivide,
"unit-1-fractions/mixed-operations": generateFractionBodmas,
"unit-1-fractions/fraction-of-quantity": generateFractionOfQuantity,
"unit-1-fractions/whole-from-fractions": generateWholeFromFraction,
"unit-2-decimals/compare-order": generateDecimalCompareOrder,
"unit-2-decimals/approximate": generateDecimalRounding,
"unit-2-decimals/standard-form": generateStandardForm,
"unit-3-decimal-operations/convert": generateDecimalConversion,
"unit-3-decimal-operations/add-subtract": generateDecimalAddSubtract,
"unit-3-decimal-operations/multiply-divide": generateDecimalMultiplyDivide,
"unit-4-ratio-proportion/define-ratio": generateDefineRatio,
"unit-4-ratio-proportion/fractions-and-ratios": generateFractionsAndRatios,
"unit-4-ratio-proportion/simplify-ratios": generateSimplifyRatio,
"unit-4-ratio-proportion/divide-in-ratio": generateDivideInRatio,
"unit-4-ratio-proportion/word-problems": generateRatioWordProblem,
"unit-5-integers/order-compare": generateIntegerOrderCompare,
"unit-5-integers/add-subtract": generateIntegerAddSubtractMixed,
"unit-5-integers/multiply-divide": generateIntegerMultiplyDivide,
"unit-5-integers/negative-fractions": generateSignedFraction,
"unit-6-number-system/place-value": generateDigitValue,
"unit-6-number-system/binary": generateBinaryToDenary,
"unit-6-number-system/quaternary": generateQuaternaryToDenary,
"unit-7-coordinates/plot-points": "lesson-only",
"unit-7-coordinates/read-coordinates": "lesson-only",
"unit-8-number-properties/identity-inverse": generateIdentityInverse,
"unit-8-number-properties/number-laws": generateDistributive,
"unit-8-number-properties/exponents": generateExponentEvaluate,
};
export default function PracticePage() {
const [selectedTopic, setSelectedTopic] = useState<TopicGenerator | null>(null);
const [filterUnit, setFilterUnit] = useState<UnitColor | null>(null);
function practiceFor(unit: Unit, topic: Topic): Generator | "lesson-only" {
return PRACTICE_BY_TOPIC[`${unit.slug}/${topic.slug}`] ?? "lesson-only";
}
const filtered = filterUnit
? TOPIC_GENERATORS.filter((t) => t.unitColor === filterUnit)
: TOPIC_GENERATORS;
interface UnitStyle {
heading: string;
dot: string;
accent: string;
iconBg: string;
hover: string;
activeItem: string;
headingHover: string;
}
const UNIT_STYLE: Record<UnitColor, UnitStyle> = {
"unit-1": { heading: "text-unit-1-dark", dot: "bg-unit-1", accent: "border-l-unit-1", iconBg: "bg-unit-1-light text-unit-1-dark", hover: "hover:border-unit-1/40", activeItem: "bg-unit-1-light text-unit-1-dark border-unit-1/20", headingHover: "hover:bg-unit-1-light/50" },
"unit-2": { heading: "text-unit-2-dark", dot: "bg-unit-2", accent: "border-l-unit-2", iconBg: "bg-unit-2-light text-unit-2-dark", hover: "hover:border-unit-2/40", activeItem: "bg-unit-2-light text-unit-2-dark border-unit-2/20", headingHover: "hover:bg-unit-2-light/50" },
"unit-3": { heading: "text-unit-3-dark", dot: "bg-unit-3", accent: "border-l-unit-3", iconBg: "bg-unit-3-light text-unit-3-dark", hover: "hover:border-unit-3/40", activeItem: "bg-unit-3-light text-unit-3-dark border-unit-3/20", headingHover: "hover:bg-unit-3-light/50" },
"unit-4": { heading: "text-unit-4-dark", dot: "bg-unit-4", accent: "border-l-unit-4", iconBg: "bg-unit-4-light text-unit-4-dark", hover: "hover:border-unit-4/40", activeItem: "bg-unit-4-light text-unit-4-dark border-unit-4/20", headingHover: "hover:bg-unit-4-light/50" },
"unit-5": { heading: "text-unit-5-dark", dot: "bg-unit-5", accent: "border-l-unit-5", iconBg: "bg-unit-5-light text-unit-5-dark", hover: "hover:border-unit-5/40", activeItem: "bg-unit-5-light text-unit-5-dark border-unit-5/20", headingHover: "hover:bg-unit-5-light/50" },
"unit-6": { heading: "text-unit-6-dark", dot: "bg-unit-6", accent: "border-l-unit-6", iconBg: "bg-unit-6-light text-unit-6-dark", hover: "hover:border-unit-6/40", activeItem: "bg-unit-6-light text-unit-6-dark border-unit-6/20", headingHover: "hover:bg-unit-6-light/50" },
"unit-7": { heading: "text-unit-7-dark", dot: "bg-unit-7", accent: "border-l-unit-7", iconBg: "bg-unit-7-light text-unit-7-dark", hover: "hover:border-unit-7/40", activeItem: "bg-unit-7-light text-unit-7-dark border-unit-7/20", headingHover: "hover:bg-unit-7-light/50" },
"unit-8": { heading: "text-unit-8-dark", dot: "bg-unit-8", accent: "border-l-unit-8", iconBg: "bg-unit-8-light text-unit-8-dark", hover: "hover:border-unit-8/40", activeItem: "bg-unit-8-light text-unit-8-dark border-unit-8/20", headingHover: "hover:bg-unit-8-light/50" },
};
interface Selection {
unit: Unit;
topic: Topic;
}
function PlayIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
<path d="M8 5v14l11-7z" />
</svg>
);
}
// ── Sidebar (mirrors the lessons sidebar) ────────────────────────────────────
function PracticeSidebar({
selected,
onSelect,
onAllTopics,
mobileOpen,
desktopCollapsed,
}: {
selected: Selection | null;
onSelect: (s: Selection) => void;
onAllTopics: () => void;
mobileOpen: boolean;
desktopCollapsed: boolean;
}) {
const [open, setOpen] = useState<Set<number>>(() => new Set([curriculum[0].number]));
function toggle(num: number) {
setOpen((prev) => {
const next = new Set(prev);
if (next.has(num)) next.delete(num);
else next.add(num);
return next;
});
}
return (
<aside className={sidebarAsideClass(mobileOpen, desktopCollapsed)}>
<nav className={sidebarNavClass}>
<button
onClick={onAllTopics}
className={cn(
"mb-4 flex w-full items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition-all duration-200",
selected === null
? "bg-foreground text-background shadow-[var(--shadow-sm)]"
: "text-muted hover:bg-background hover:text-foreground",
)}
>
All Topics
</button>
<div className="mb-3 h-px bg-border/60" />
{curriculum.map((unit) => {
const style = UNIT_STYLE[unit.color];
const isOpen = open.has(unit.number);
return (
<div key={unit.slug} className="mb-1">
<button
onClick={() => toggle(unit.number)}
className={cn(
"flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm font-semibold transition-all duration-200",
style.heading,
style.headingHover,
)}
>
<span className="truncate">Unit {unit.number}: {unit.title}</span>
<svg
className={cn("h-4 w-4 shrink-0 transition-transform duration-200", isOpen && "rotate-90")}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{isOpen && (
<div className="ml-2 mt-0.5 space-y-0.5 border-l-2 border-border/40 pl-2">
{unit.topics.map((topic) => {
const isActive = selected?.unit.slug === unit.slug && selected?.topic.slug === topic.slug;
return (
<button
key={topic.slug}
onClick={() => onSelect({ unit, topic })}
className={cn(
"flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm transition-all duration-200",
isActive
? cn(style.activeItem, "font-medium shadow-[var(--shadow-sm)]")
: "text-muted hover:text-foreground",
)}
>
<span className={cn("h-1.5 w-1.5 shrink-0 rounded-full transition-colors", isActive ? style.dot : "bg-border")} />
<span className="truncate">{topic.shortTitle}</span>
</button>
);
})}
</div>
)}
</div>
);
})}
</nav>
</aside>
);
}
// ── Topic browser (main area, all topics) ────────────────────────────────────
function TopicBrowser({ onSelect }: { onSelect: (s: Selection) => void }) {
return (
<div className="space-y-9">
{curriculum.map((unit) => {
const style = UNIT_STYLE[unit.color];
return (
<section key={unit.slug}>
<div className="mb-3 flex items-center gap-2.5">
<span className={cn("h-3.5 w-3.5 rounded-full", style.dot)} />
<h2 className={cn("text-sm font-extrabold uppercase tracking-wider", style.heading)}>
Unit {unit.number}: {unit.title}
</h2>
<span className="rounded-full bg-border/50 px-2 py-0.5 text-[0.7rem] font-bold text-muted">
{unit.topics.length}
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{unit.topics.map((topic) => (
<button
key={topic.slug}
onClick={() => onSelect({ unit, topic })}
className={cn(
"group flex items-center gap-3 rounded-2xl border-2 border-l-4 border-border/60 bg-surface p-4 text-left shadow-[var(--shadow-sm)] transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[var(--shadow-md)]",
style.accent,
style.hover,
)}
>
<span className={cn("flex h-9 w-9 shrink-0 items-center justify-center rounded-xl", style.iconBg)}>
<PlayIcon className="h-4 w-4" />
</span>
<span className="flex-1 font-bold leading-tight">{topic.title}</span>
<svg
className="h-4 w-4 shrink-0 text-muted/40 transition-all duration-200 group-hover:translate-x-0.5 group-hover:text-muted"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
))}
</div>
</section>
);
})}
</div>
);
}
// ── Panel for topics that are practised in the interactive lesson ────────────
function LessonOnlyPanel({ unit, topic }: { unit: Unit; topic: Topic }) {
return (
<div className="rounded-3xl border-2 border-border/60 bg-surface p-8 text-center shadow-[var(--shadow-sm)]">
<div className={cn("mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl", UNIT_STYLE[unit.color].iconBg)}>
<PlayIcon className="h-5 w-5" />
</div>
<h2 className="mb-2 text-xl font-bold">{topic.title}</h2>
<p className="mx-auto mb-6 max-w-md text-sm text-muted">
This topic uses the interactive coordinate grid, so it&apos;s best practised right in the lesson.
</p>
<Link href={`/lessons/${unit.slug}/${topic.slug}`}>
<Button variant="primary" size="md">Open the interactive lesson </Button>
</Link>
</div>
);
}
// ── Page ─────────────────────────────────────────────────────────────────────
export default function PracticePage() {
const [selected, setSelected] = useState<Selection | null>(null);
const { mobileOpen, desktopCollapsed, toggle, closeMobile } = useCollapsibleSidebar();
function select(s: Selection) {
setSelected(s);
closeMobile();
}
const generator = selected ? practiceFor(selected.unit, selected.topic) : null;
return (
<div className="playground-bg flex min-h-screen flex-col">
<Header />
<main className="playground-frame mx-auto w-full max-w-6xl flex-1 bg-white">
<div className="playground-frame relative mx-auto flex w-full max-w-7xl flex-1">
<SidebarBackdrop show={mobileOpen} onClick={closeMobile} />
<PracticeSidebar
selected={selected}
onSelect={select}
onAllTopics={() => {
setSelected(null);
closeMobile();
}}
mobileOpen={mobileOpen}
desktopCollapsed={desktopCollapsed}
/>
<main className="min-w-0 flex-1 bg-white">
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
<SidebarToggle onClick={toggle} />
{!selected ? (
<>
<h1 className="mb-2 text-3xl font-extrabold tracking-tight">Practice</h1>
<p className="mb-8 text-muted">
Pick a topic and build your confidence with fresh questions each round.
</p>
{/* Unit filters */}
<div className="mb-6 flex flex-wrap gap-2">
<button
onClick={() => setFilterUnit(null)}
className={`rounded-full border-2 px-4 py-1.5 text-sm font-extrabold transition-all duration-200 ${
filterUnit === null
? "border-foreground bg-foreground text-background shadow-[var(--shadow-sm)]"
: "border-border text-muted hover:text-foreground"
}`}
>
All Topics
</button>
{(["unit-1", "unit-2", "unit-3", "unit-4"] as const).map((uc) => (
<button
key={uc}
onClick={() => setFilterUnit(filterUnit === uc ? null : uc)}
className={`rounded-full border-2 px-4 py-1.5 text-sm font-extrabold transition-all duration-200 ${
filterUnit === uc
? unitColors[uc].activeFilter
: unitColors[uc].inactiveFilter
}`}
>
{unitColors[uc].label}
</button>
))}
</div>
{/* Topic grid */}
{!selectedTopic && (
<div className="grid gap-4 sm:grid-cols-2">
{filtered.map((topic) => (
<Card
key={`${topic.unitSlug}-${topic.topicSlug}`}
hover
className="cursor-pointer"
accent={topic.unitColor}
tabIndex={0}
role="button"
onClick={() => setSelectedTopic(topic)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelectedTopic(topic);
}
}}
>
<div className="flex items-center justify-between">
<span className="font-bold">{topic.label}</span>
<Badge variant={topic.unitColor}>
{unitColors[topic.unitColor].label}
</Badge>
</div>
</Card>
))}
</div>
)}
{/* Practice area */}
{selectedTopic && (
<div>
<Button
variant="secondary"
size="sm"
onClick={() => setSelectedTopic(null)}
className="mb-6"
>
<TopicBrowser onSelect={select} />
</>
) : (
<>
<Button variant="secondary" size="sm" onClick={() => setSelected(null)} className="mb-6">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to Topics
All Topics
</Button>
{generator === "lesson-only" || generator === null ? (
<LessonOnlyPanel unit={selected.unit} topic={selected.topic} />
) : (
<PracticeSection
title={`Practice: ${selectedTopic.label}`}
generator={selectedTopic.generator}
unitColor={selectedTopic.unitColor}
key={`${selected.unit.slug}/${selected.topic.slug}`}
title={`Practice: ${selected.topic.title}`}
generator={generator}
unitColor={selected.unit.color as PracticeUnitColor}
/>
</div>
)}
</>
)}
</div>
</main>
</div>
<Footer />
</div>
);

View File

@@ -4,7 +4,7 @@ import { useState } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
import { toKatex, simplify, add, subtract, multiply, divide } from "@/lib/math/fractions";
import { toKatex, Fraction } from "@/lib/math/fractions";
interface FractionVal {
num: number;
@@ -30,24 +30,26 @@ const OP_PRIORITY: Record<string, number> = {
};
function performOp(a: FractionVal, b: FractionVal, op: string): FractionVal {
let result: [number, number];
const fa = new Fraction(a.num, a.den);
const fb = new Fraction(b.num, b.den);
let result: Fraction;
switch (op) {
case "+":
result = add(a.num, a.den, b.num, b.den);
result = fa.plus(fb);
break;
case "":
result = subtract(a.num, a.den, b.num, b.den);
result = fa.minus(fb);
break;
case "×":
result = multiply(a.num, a.den, b.num, b.den);
result = fa.times(fb);
break;
case "÷":
result = divide(a.num, a.den, b.num, b.den);
result = fa.dividedBy(fb);
break;
default:
result = [0, 1];
result = new Fraction(0, 1);
}
return { num: result[0], den: result[1] };
return { num: result.n, den: result.d };
}
function expressionToKatex(fracs: FractionVal[], ops: string[], highlightIdx: number | null): string {
@@ -97,7 +99,7 @@ function buildBodmasSteps(expr: Expression): Step[] {
// Perform the operation
const result = performOp(fracs[opIdx], fracs[opIdx + 1], ops[opIdx]);
const [sn, sd] = simplify(result.num, result.den);
const { n: sn, d: sd } = new Fraction(result.num, result.den).simplified();
const newFracs = [...fracs];
newFracs.splice(opIdx, 2, { num: sn, den: sd });

View File

@@ -0,0 +1,181 @@
"use client";
import { useId } from "react";
export interface GridPoint {
x: number;
y: number;
label?: string;
tone?: "solid" | "target" | "wrong" | "muted";
}
export interface GridSegment {
from: { x: number; y: number };
to: { x: number; y: number };
}
export interface GridArrow {
from: { x: number; y: number };
to: { x: number; y: number };
dashed?: boolean;
}
interface CartesianGridProps {
points?: GridPoint[];
segments?: GridSegment[];
arrows?: GridArrow[];
/** Draws dashed guide lines from this point to the axes. `axis` picks which (default both). */
highlight?: { x: number; y: number; axis?: "x" | "y" | "both" };
onGridClick?: (x: number, y: number) => void;
}
const MIN = -10;
const MAX = 10;
const UNIT = 20;
const MARGIN = 20;
const SIZE = MARGIN * 2 + (MAX - MIN) * UNIT; // 440
const sx = (x: number) => MARGIN + (x - MIN) * UNIT;
const sy = (y: number) => MARGIN + (MAX - y) * UNIT;
const toneColor: Record<NonNullable<GridPoint["tone"]>, string> = {
solid: "var(--unit-7)",
target: "var(--correct)",
wrong: "var(--incorrect)",
muted: "var(--muted)",
};
export function CartesianGrid({
points = [],
segments = [],
arrows = [],
highlight,
onGridClick,
}: CartesianGridProps) {
const arrowHeadId = useId();
const axisArrowId = useId();
const ticks: number[] = [];
for (let v = MIN; v <= MAX; v++) ticks.push(v);
const labelTicks = ticks.filter((v) => v !== 0 && v % 2 === 0);
function handleClick(e: React.MouseEvent<SVGSVGElement>) {
if (!onGridClick) return;
const rect = e.currentTarget.getBoundingClientRect();
const px = ((e.clientX - rect.left) / rect.width) * SIZE;
const py = ((e.clientY - rect.top) / rect.height) * SIZE;
const x = Math.round((px - MARGIN) / UNIT + MIN);
const y = Math.round(MAX - (py - MARGIN) / UNIT);
if (x < MIN || x > MAX || y < MIN || y > MAX) return;
onGridClick(x, y);
}
return (
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
className={`h-auto w-full max-w-md ${onGridClick ? "cursor-crosshair" : ""}`}
onClick={handleClick}
role="img"
aria-label="Cartesian coordinate grid"
>
<defs>
<marker id={arrowHeadId} markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto">
<path d="M0 0 L8 4 L0 8 z" fill="var(--unit-7-dark)" />
</marker>
<marker id={axisArrowId} markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto">
<path d="M0 0 L9 4.5 L0 9 z" fill="var(--foreground)" />
</marker>
</defs>
{/* Light gridlines */}
{ticks.map((v) => (
<g key={`grid-${v}`}>
<line x1={sx(v)} y1={sy(MIN)} x2={sx(v)} y2={sy(MAX)} stroke="var(--border)" strokeWidth={1} />
<line x1={sx(MIN)} y1={sy(v)} x2={sx(MAX)} y2={sy(v)} stroke="var(--border)" strokeWidth={1} />
</g>
))}
{/* Axes */}
<line x1={12} y1={sy(0)} x2={SIZE - 8} y2={sy(0)} stroke="var(--foreground)" strokeWidth={2} markerEnd={`url(#${axisArrowId})`} />
<line x1={sx(0)} y1={SIZE - 8} x2={sx(0)} y2={12} stroke="var(--foreground)" strokeWidth={2} markerEnd={`url(#${axisArrowId})`} />
<text x={SIZE - 12} y={sy(0) - 8} fontSize={14} fontWeight={700} fill="var(--foreground)" textAnchor="end">x</text>
<text x={sx(0) + 10} y={18} fontSize={14} fontWeight={700} fill="var(--foreground)">y</text>
{/* Axis labels */}
{labelTicks.map((v) => (
<text key={`xl-${v}`} x={sx(v)} y={sy(0) + 15} fontSize={10} fill="var(--muted)" textAnchor="middle">
{v}
</text>
))}
{labelTicks.map((v) => (
<text key={`yl-${v}`} x={sx(0) - 7} y={sy(v) + 3.5} fontSize={10} fill="var(--muted)" textAnchor="end">
{v}
</text>
))}
<text x={sx(0) - 7} y={sy(0) + 15} fontSize={10} fill="var(--muted)" textAnchor="end">0</text>
{/* Guide lines */}
{highlight && (
<g>
{(highlight.axis ?? "both") !== "y" && (
<line x1={sx(highlight.x)} y1={sy(highlight.y)} x2={sx(highlight.x)} y2={sy(0)} stroke="var(--unit-7)" strokeWidth={1.5} strokeDasharray="4 3" opacity={0.8} />
)}
{(highlight.axis ?? "both") !== "x" && (
<line x1={sx(highlight.x)} y1={sy(highlight.y)} x2={sx(0)} y2={sy(highlight.y)} stroke="var(--unit-7)" strokeWidth={1.5} strokeDasharray="4 3" opacity={0.8} />
)}
</g>
)}
{/* Segments */}
{segments.map((s, i) => (
<line
key={`seg-${i}`}
x1={sx(s.from.x)}
y1={sy(s.from.y)}
x2={sx(s.to.x)}
y2={sy(s.to.y)}
stroke="var(--unit-7)"
strokeWidth={2.5}
strokeLinecap="round"
/>
))}
{/* Movement arrows */}
{arrows.map((a, i) => (
<line
key={`arr-${i}`}
x1={sx(a.from.x)}
y1={sy(a.from.y)}
x2={sx(a.to.x)}
y2={sy(a.to.y)}
stroke="var(--unit-7-dark)"
strokeWidth={2.5}
strokeLinecap="round"
strokeDasharray={a.dashed ? "5 4" : undefined}
markerEnd={`url(#${arrowHeadId})`}
/>
))}
{/* Points */}
{points.map((p, i) => {
const color = toneColor[p.tone ?? "solid"];
return (
<g key={`pt-${i}`}>
<circle cx={sx(p.x)} cy={sy(p.y)} r={5} fill={color} stroke="white" strokeWidth={1.5} />
{p.label && (
<text
x={sx(p.x) + 8}
y={sy(p.y) - 8}
fontSize={12}
fontWeight={700}
fill={color}
>
{p.label}
</text>
)}
</g>
);
})}
</svg>
);
}

View File

@@ -4,7 +4,7 @@ import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
import { simplify, toKatex, gcd } from "@/lib/math/fractions";
import { toKatex, gcd, Fraction } from "@/lib/math/fractions";
import { parseStrictDecimal, parseStrictInt } from "@/lib/math/validation";
type ConvertMode = "decToFrac" | "fracToDec";
@@ -42,7 +42,7 @@ function buildDecToFracSteps(decimal: string): Step[] {
});
// Simplify
const [sn, sd] = simplify(numerator, denominator);
const { n: sn, d: sd } = new Fraction(numerator, denominator).simplified();
if (sn !== numerator || sd !== denominator) {
const g = gcd(numerator, denominator);
steps.push({
@@ -89,7 +89,7 @@ function buildFracToDecSteps(num: number, den: number): Step[] {
// Check for terminating vs repeating
let tempDen = den;
const [, simpDen] = simplify(num, den);
const { d: simpDen } = new Fraction(num, den).simplified();
tempDen = simpDen;
// Remove factors of 2 and 5
while (tempDen % 2 === 0) tempDen /= 2;

View File

@@ -0,0 +1,372 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { ClientOnly } from "@/components/ui/client-only";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
type Mode = "expand" | "index";
type IndexOp = "multiply" | "divide";
interface Step {
label: string;
math: string;
}
function repeat(base: number, count: number): string {
return Array.from({ length: count }, () => `${base}`).join(" \\times ");
}
function buildExpandSteps(base: number, exp: number): Step[] {
if (exp === 0) {
return [
{ label: "The power (index) is 0.", math: `${base}^{0}` },
{ label: "Any number raised to the power 0 is equal to 1.", math: `${base}^{0} = 1` },
];
}
if (exp === 1) {
return [
{ label: "The power (index) is 1.", math: `${base}^{1}` },
{ label: "Any number raised to the power 1 is itself.", math: `${base}^{1} = ${base}` },
];
}
const steps: Step[] = [
{
label: `The base is ${base} and the power is ${exp}: multiply the base by itself ${exp} times.`,
math: `${base}^{${exp}}`,
},
];
let running = base;
for (let k = 2; k <= exp; k++) {
running *= base;
steps.push({
label: `Multiply on another ${base}.`,
math: `${repeat(base, k)} = ${running}`,
});
}
steps.push({ label: "So the power is worked out.", math: `${base}^{${exp}} = ${running}` });
return steps;
}
function buildIndexSteps(base: number, m: number, n: number, op: IndexOp): Step[] {
if (op === "multiply") {
const total = m + n;
const value = Math.pow(base, total);
const steps: Step[] = [
{ label: "Multiplying powers of the SAME base.", math: `${base}^{${m}} \\times ${base}^{${n}}` },
{ label: "Write each power out in full.", math: `(${repeat(base, m)}) \\times (${repeat(base, n)})` },
{ label: `Count the factors: ${m} + ${n} = ${total}. When multiplying, ADD the powers.`, math: `${base}^{${m}} \\times ${base}^{${n}} = ${base}^{${m}+${n}} = ${base}^{${total}}` },
];
if (value <= 1e7) steps.push({ label: "Work out the value.", math: `${base}^{${total}} = ${value}` });
return steps;
}
// divide, m >= n
const diff = m - n;
const value = Math.pow(base, diff);
const steps: Step[] = [
{ label: "Dividing powers of the SAME base.", math: `\\frac{${base}^{${m}}}{${base}^{${n}}}` },
{ label: "Write each power out in full.", math: `\\frac{${repeat(base, m)}}{${repeat(base, n)}}` },
{ label: `Cancel the ${n} matching factor${n === 1 ? "" : "s"} from top and bottom.`, math: `${base}^{${m}} \\div ${base}^{${n}} = ${base}^{${m}-${n}} = ${base}^{${diff}}` },
];
if (value <= 1e7) steps.push({ label: "Work out the value.", math: `${base}^{${diff}} = ${value}` });
return steps;
}
// ── Quick practice ───────────────────────────────────────────────────────────
interface PracticeQ {
prompt: string;
answer: number;
}
function randInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
const SUPERSCRIPT: Record<string, string> = {
"0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴",
"5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹",
};
function sup(n: number): string {
return String(n).split("").map((c) => SUPERSCRIPT[c] ?? c).join("");
}
function makeQuestion(): PracticeQ {
const roll = Math.floor(Math.random() * 5);
if (roll === 0) {
const base = randInt(2, 7);
const exp = randInt(2, 3);
return { prompt: `What is ${base}${sup(exp)}?`, answer: Math.pow(base, exp) };
}
if (roll === 1) {
const base = randInt(2, 9);
return { prompt: `What is ${base}${sup(0)}?`, answer: 1 };
}
if (roll === 2) {
const base = randInt(2, 4);
const a = randInt(2, 3);
const b = 1;
return { prompt: `What is ${base}${sup(a)} ${base}${sup(b)}?`, answer: Math.pow(base, a) - Math.pow(base, b) };
}
if (roll === 3) {
const base = randInt(2, 5);
const m = randInt(3, 6);
const n = randInt(1, m - 1);
return { prompt: `Simplify ${base}${sup(m)} ÷ ${base}${sup(n)}. The answer is ${base} to what power?`, answer: m - n };
}
const base = randInt(2, 6);
const m = randInt(1, 4);
const n = randInt(1, 4);
return { prompt: `Simplify ${base}${sup(m)} × ${base}${sup(n)}. The answer is ${base} to what power?`, answer: m + n };
}
function QuickPractice() {
const [q, setQ] = useState<PracticeQ>(() => makeQuestion());
const [val, setVal] = useState("");
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
const [score, setScore] = useState({ correct: 0, total: 0 });
function check() {
const parsed = parseInt(val, 10);
if (isNaN(parsed)) return;
const correct = parsed === q.answer;
setFeedback(correct ? "correct" : "incorrect");
setScore((s) => ({ correct: s.correct + (correct ? 1 : 0), total: s.total + 1 }));
}
function next() {
setQ(makeQuestion());
setVal("");
setFeedback(null);
}
return (
<Card className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs font-bold uppercase tracking-wider text-unit-8">Quick Practice</p>
<span className="rounded-full bg-unit-8-light px-3 py-1 text-xs font-bold text-unit-8-dark">
{score.correct}/{score.total}
</span>
</div>
<p className="text-center text-lg font-bold">{q.prompt}</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<input
type="number"
value={val}
onChange={(e) => {
setVal(e.target.value);
setFeedback(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (feedback !== null) next();
else check();
}
}}
className={`w-24 rounded-lg border-2 px-3 py-2 text-center text-xl font-bold outline-none transition-colors ${
feedback === "correct"
? "border-correct bg-correct-light"
: feedback === "incorrect"
? "border-incorrect bg-incorrect-light"
: "border-border focus:border-unit-8"
}`}
placeholder="?"
aria-label="Your answer"
/>
{feedback === null ? (
<button onClick={check} className="rounded-lg bg-unit-8 px-5 py-2.5 text-sm font-bold text-white hover:bg-unit-8-dark">
Check
</button>
) : (
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2.5 text-sm font-bold text-background hover:bg-foreground/80">
Next
</button>
)}
</div>
{feedback === "correct" && <p className="text-center text-sm font-bold text-correct">Correct!</p>}
{feedback === "incorrect" && (
<p className="text-center text-sm font-bold text-incorrect">Not quite the answer is {q.answer}.</p>
)}
</Card>
);
}
// ── Main explorer ────────────────────────────────────────────────────────────
export function ExponentsExplorer() {
const [mode, setMode] = useState<Mode>("expand");
const [indexOp, setIndexOp] = useState<IndexOp>("multiply");
const [base, setBase] = useState("2");
const [power, setPower] = useState("4");
const [m, setM] = useState("4");
const [n, setN] = useState("3");
const [error, setError] = useState("");
const [steps, setSteps] = useState<Step[] | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const done = steps ? currentStep >= steps.length - 1 : false;
const reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
function switchMode(md: Mode) {
setMode(md);
setError("");
reset();
}
function handleGo() {
setError("");
const b = parseInt(base, 10);
if (isNaN(b) || b < 2 || b > 12) return setError("Use a base between 2 and 12.");
if (mode === "expand") {
const e = parseInt(power, 10);
if (isNaN(e) || e < 0 || e > 6) return setError("Use a power between 0 and 6.");
if (Math.pow(b, e) > 1_000_000) return setError("That gets too big. Try a smaller base or power.");
setSteps(buildExpandSteps(b, e));
} else {
const mm = parseInt(m, 10);
const nn = parseInt(n, 10);
if (isNaN(mm) || isNaN(nn) || mm < 1 || nn < 1 || mm > 8 || nn > 8) return setError("Use powers between 1 and 8.");
if (indexOp === "divide" && mm < nn) return setError("For division, the top power must be at least the bottom power.");
setSteps(buildIndexSteps(b, mm, nn, indexOp));
}
setCurrentStep(0);
setIsPlaying(false);
}
const stepForward = useCallback(() => {
if (!steps || currentStep >= steps.length - 1) return;
setCurrentStep((s) => s + 1);
if (currentStep + 1 >= steps.length - 1) setIsPlaying(false);
}, [steps, currentStep]);
const stepBack = useCallback(() => {
if (currentStep <= 0) return;
setCurrentStep((s) => s - 1);
}, [currentStep]);
const togglePlay = useCallback(() => setIsPlaying((p) => !p), []);
const step = steps ? steps[currentStep] : null;
return (
<div className="space-y-4">
{/* Mode toggle */}
<div className="flex flex-wrap gap-2">
{(["expand", "index"] as Mode[]).map((md) => (
<button
key={md}
onClick={() => switchMode(md)}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === md
? "border-unit-8 bg-unit-8 text-white"
: "border-unit-8/40 text-unit-8 hover:bg-unit-8-light"
}`}
>
{md === "expand" ? "Expand a Power" : "Index Laws"}
</button>
))}
</div>
{/* Index op tabs */}
{mode === "index" && (
<div className="flex flex-wrap gap-2">
{(["multiply", "divide"] as IndexOp[]).map((o) => (
<button
key={o}
onClick={() => {
setIndexOp(o);
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
indexOp === o
? "border-unit-8-dark bg-unit-8-dark text-white"
: "border-unit-8/30 text-unit-8-dark hover:bg-unit-8-light"
}`}
>
{o === "multiply" ? "Multiply (add powers)" : "Divide (subtract powers)"}
</button>
))}
</div>
)}
{/* Input card */}
<Card>
{mode === "expand" ? (
<>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-8">Enter a base and a power</p>
<div className="flex flex-wrap items-center gap-3">
<input type="number" value={base} onChange={(e) => setBase(e.target.value)} className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-8" aria-label="Base" />
<span className="text-sm font-bold text-muted">to the power</span>
<input type="number" value={power} onChange={(e) => setPower(e.target.value)} className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-8" aria-label="Power" />
<button onClick={handleGo} className="rounded-lg bg-unit-8 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-8-dark">
Go
</button>
</div>
</>
) : (
<>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-8">
Same base, two powers {indexOp === "multiply" ? "to multiply" : "to divide"}
</p>
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm font-bold text-muted">base</span>
<input type="number" value={base} onChange={(e) => setBase(e.target.value)} className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-8" aria-label="Base" />
<span className="text-sm font-bold text-muted">powers</span>
<input type="number" value={m} onChange={(e) => setM(e.target.value)} className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-8" aria-label="First power" />
<span className="text-lg font-bold text-muted">{indexOp === "multiply" ? "×" : "÷"}</span>
<input type="number" value={n} onChange={(e) => setN(e.target.value)} className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-8" aria-label="Second power" />
<button onClick={handleGo} className="rounded-lg bg-unit-8 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-8-dark">
Go
</button>
</div>
</>
)}
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display card */}
<Card className="flex min-h-[220px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
Enter values above and click <strong>Go</strong>
</p>
) : (
<>
<p className="text-center text-sm font-medium text-muted">{step.label}</p>
<MathDisplay math={step.math} className="text-2xl" />
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={steps?.length ?? 0}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!steps && !done}
canStepBack={!!steps && currentStep > 0}
/>
</Card>
{/* Quick practice */}
<ClientOnly fallback={<Card className="min-h-[220px]" />}>
<QuickPractice />
</ClientOnly>
</div>
);
}

View File

@@ -3,7 +3,7 @@
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
import { gcd, lcm, simplify, toKatex } from "@/lib/math/fractions";
import { gcd, lcm, toKatex, Fraction } from "@/lib/math/fractions";
import { MathDisplay } from "@/components/math/math-display";
type Operation = "add" | "subtract" | "multiply" | "divide";
@@ -68,7 +68,7 @@ function buildSteps(
});
// Step 4: Simplify if needed
const [sn, sd] = simplify(resultNum, lcd);
const { n: sn, d: sd } = new Fraction(resultNum, lcd).simplified();
if (Math.abs(sn) !== Math.abs(resultNum) || sd !== lcd) {
const g = gcd(Math.abs(resultNum), lcd);
steps.push({
@@ -92,7 +92,7 @@ function buildSteps(
});
// Step 2: Simplify
const [sn, sd] = simplify(rn, rd);
const { n: sn, d: sd } = new Fraction(rn, rd).simplified();
if (sn !== rn || sd !== rd) {
const g = gcd(Math.abs(rn), rd);
steps.push({
@@ -122,7 +122,7 @@ function buildSteps(
barResult: { filled: rn, total: rd },
});
const [sn, sd] = simplify(rn, rd);
const { n: sn, d: sd } = new Fraction(rn, rd).simplified();
if (sn !== rn || sd !== rd) {
const g = gcd(Math.abs(rn), rd);
steps.push({

View File

@@ -0,0 +1,331 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { ClientOnly } from "@/components/ui/client-only";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
type Mode = "identity" | "inverse" | "zero";
type Op = "add" | "multiply";
interface Step {
label: string;
math: string;
danger?: boolean;
}
function buildIdentitySteps(n: number, op: Op): Step[] {
if (op === "add") {
return [
{ label: "The identity for addition is 0 — it leaves a number unchanged.", math: `${n} + 0` },
{ label: "Adding 0 changes nothing.", math: `${n} + 0 = ${n}` },
{ label: "So 0 is the identity element for addition.", math: `n + 0 = n` },
];
}
return [
{ label: "The identity for multiplication is 1 — it leaves a number unchanged.", math: `${n} \\times 1` },
{ label: "Multiplying by 1 changes nothing.", math: `${n} \\times 1 = ${n}` },
{ label: "So 1 is the identity element for multiplication.", math: `n \\times 1 = n` },
];
}
function buildInverseSteps(n: number, op: Op): Step[] {
if (op === "add") {
const inv = -n;
const invText = inv < 0 ? `(${inv})` : `${inv}`;
return [
{ label: "The additive inverse is the number that brings you back to the identity, 0.", math: `${n} + \\square = 0` },
{ label: `Flip the sign of ${n} to get its additive inverse.`, math: `\\text{inverse of } ${n} = ${inv}` },
{ label: "Check: adding a number and its inverse gives 0.", math: `${n} + ${invText} = 0` },
];
}
// multiplicative inverse (reciprocal)
const absN = Math.abs(n);
const recip = n < 0 ? `-\\frac{1}{${absN}}` : `\\frac{1}{${absN}}`;
const recipParen = n < 0 ? `\\left(-\\frac{1}{${absN}}\\right)` : `\\frac{1}{${absN}}`;
return [
{ label: "The multiplicative inverse (reciprocal) brings you back to the identity, 1.", math: `${n} \\times \\square = 1` },
{ label: `Write ${n} as a fraction and flip it. The sign stays in front.`, math: `\\text{inverse of } ${n} = ${recip}` },
{ label: "Check: a number times its reciprocal gives 1.", math: `${n} \\times ${recipParen} = 1` },
];
}
function buildZeroSteps(n: number): Step[] {
return [
{ label: "Multiplying any number by zero always gives zero.", math: `${n} \\times 0 = 0` },
{ label: "Zero shared into any number of groups is still zero.", math: `0 \\div ${n === 0 ? 5 : n} = 0` },
{
label: "But you can never divide BY zero — there is no answer. It is undefined.",
math: `${n} \\div 0 = \\text{undefined}`,
danger: true,
},
];
}
// ── Quick practice ───────────────────────────────────────────────────────────
interface PracticeQ {
prompt: string;
answer: number;
}
function randInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function makeQuestion(): PracticeQ {
const roll = Math.floor(Math.random() * 5);
if (roll === 0) {
const n = randInt(-12, 12) || 3;
return { prompt: `What is the additive inverse of ${n}?`, answer: -n };
}
if (roll === 1) {
const n = randInt(1, 20);
return { prompt: `What is ${n} × 0?`, answer: 0 };
}
if (roll === 2) {
return { prompt: `What number is the identity for addition?`, answer: 0 };
}
if (roll === 3) {
return { prompt: `What number is the identity for multiplication?`, answer: 1 };
}
const n = randInt(2, 12);
return { prompt: `The multiplicative inverse of ${n} is 1 over what number?`, answer: n };
}
function QuickPractice() {
const [q, setQ] = useState<PracticeQ>(() => makeQuestion());
const [val, setVal] = useState("");
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
const [score, setScore] = useState({ correct: 0, total: 0 });
function check() {
const parsed = parseInt(val, 10);
if (isNaN(parsed)) return;
const correct = parsed === q.answer;
setFeedback(correct ? "correct" : "incorrect");
setScore((s) => ({ correct: s.correct + (correct ? 1 : 0), total: s.total + 1 }));
}
function next() {
setQ(makeQuestion());
setVal("");
setFeedback(null);
}
return (
<Card className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs font-bold uppercase tracking-wider text-unit-8">Quick Practice</p>
<span className="rounded-full bg-unit-8-light px-3 py-1 text-xs font-bold text-unit-8-dark">
{score.correct}/{score.total}
</span>
</div>
<p className="text-center text-lg font-bold">{q.prompt}</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<input
type="number"
value={val}
onChange={(e) => {
setVal(e.target.value);
setFeedback(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (feedback !== null) next();
else check();
}
}}
className={`w-24 rounded-lg border-2 px-3 py-2 text-center text-xl font-bold outline-none transition-colors ${
feedback === "correct"
? "border-correct bg-correct-light"
: feedback === "incorrect"
? "border-incorrect bg-incorrect-light"
: "border-border focus:border-unit-8"
}`}
placeholder="?"
aria-label="Your answer"
/>
{feedback === null ? (
<button onClick={check} className="rounded-lg bg-unit-8 px-5 py-2.5 text-sm font-bold text-white hover:bg-unit-8-dark">
Check
</button>
) : (
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2.5 text-sm font-bold text-background hover:bg-foreground/80">
Next
</button>
)}
</div>
{feedback === "correct" && <p className="text-center text-sm font-bold text-correct">Correct!</p>}
{feedback === "incorrect" && (
<p className="text-center text-sm font-bold text-incorrect">Not quite the answer is {q.answer}.</p>
)}
</Card>
);
}
// ── Main explorer ────────────────────────────────────────────────────────────
export function IdentityInverseExplorer() {
const [mode, setMode] = useState<Mode>("identity");
const [op, setOp] = useState<Op>("add");
const [input, setInput] = useState("3");
const [error, setError] = useState("");
const [steps, setSteps] = useState<Step[] | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const done = steps ? currentStep >= steps.length - 1 : false;
const reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
function switchMode(m: Mode) {
setMode(m);
setError("");
reset();
}
function handleGo() {
setError("");
const n = parseInt(input, 10);
if (isNaN(n)) return setError("Enter a valid whole number.");
if (Math.abs(n) > 100) return setError("Keep the number between -100 and 100.");
if (mode === "inverse" && op === "multiply" && n === 0) {
return setError("Zero has no multiplicative inverse — try another number.");
}
if (mode === "identity") setSteps(buildIdentitySteps(n, op));
else if (mode === "inverse") setSteps(buildInverseSteps(n, op));
else setSteps(buildZeroSteps(n));
setCurrentStep(0);
setIsPlaying(false);
}
const stepForward = useCallback(() => {
if (!steps || currentStep >= steps.length - 1) return;
setCurrentStep((s) => s + 1);
if (currentStep + 1 >= steps.length - 1) setIsPlaying(false);
}, [steps, currentStep]);
const stepBack = useCallback(() => {
if (currentStep <= 0) return;
setCurrentStep((s) => s - 1);
}, [currentStep]);
const togglePlay = useCallback(() => setIsPlaying((p) => !p), []);
const step = steps ? steps[currentStep] : null;
const showOp = mode !== "zero";
return (
<div className="space-y-4">
{/* Mode toggle */}
<div className="flex flex-wrap gap-2">
{(["identity", "inverse", "zero"] as Mode[]).map((m) => (
<button
key={m}
onClick={() => switchMode(m)}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === m
? "border-unit-8 bg-unit-8 text-white"
: "border-unit-8/40 text-unit-8 hover:bg-unit-8-light"
}`}
>
{m === "identity" ? "Identity" : m === "inverse" ? "Inverse" : "Zero Facts"}
</button>
))}
</div>
{/* Operation tabs */}
{showOp && (
<div className="flex flex-wrap gap-2">
{(["add", "multiply"] as Op[]).map((o) => (
<button
key={o}
onClick={() => {
setOp(o);
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
op === o
? "border-unit-8-dark bg-unit-8-dark text-white"
: "border-unit-8/30 text-unit-8-dark hover:bg-unit-8-light"
}`}
>
{o === "add" ? "Addition" : "Multiplication"}
</button>
))}
</div>
)}
{/* Input card */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-8">
{mode === "identity"
? "See how the identity leaves a number unchanged"
: mode === "inverse"
? "Find the inverse of a number"
: "Explore what happens with zero"}
</p>
<div className="flex flex-wrap items-center gap-3">
<input
type="number"
value={input}
onChange={(e) => setInput(e.target.value)}
className="w-24 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-8"
aria-label="Number"
/>
<button
onClick={handleGo}
className="rounded-lg bg-unit-8 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-8-dark"
>
Go
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display card */}
<Card className="flex min-h-[220px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
Enter a number above and click <strong>Go</strong>
</p>
) : (
<>
<p className="text-center text-sm font-medium text-muted">{step.label}</p>
<MathDisplay
math={step.math}
className={`text-3xl ${step.danger ? "text-incorrect" : ""}`}
/>
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={steps?.length ?? 0}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!steps && !done}
canStepBack={!!steps && currentStep > 0}
/>
</Card>
{/* Quick practice */}
<ClientOnly fallback={<Card className="min-h-[220px]" />}>
<QuickPractice />
</ClientOnly>
</div>
);
}

View File

@@ -2,6 +2,7 @@
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { ClientOnly } from "@/components/ui/client-only";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
@@ -282,12 +283,6 @@ function SignRuleCard({ rule, active }: { rule: string; active: boolean }) {
);
}
function QuickPractice() {
const [problem, setProblem] = useState(() => generateProblem());
const [userAnswer, setUserAnswer] = useState("");
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
const [score, setScore] = useState({ correct: 0, total: 0 });
function generateProblem() {
const ops = ["+", "-"] as const;
const op = ops[Math.floor(Math.random() * ops.length)];
@@ -297,6 +292,12 @@ function QuickPractice() {
return { a, b, op, answer };
}
function QuickPractice() {
const [problem, setProblem] = useState(() => generateProblem());
const [userAnswer, setUserAnswer] = useState("");
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
const [score, setScore] = useState({ correct: 0, total: 0 });
function checkAnswer() {
const parsed = parseInt(userAnswer);
if (isNaN(parsed)) return;
@@ -587,7 +588,9 @@ export function IntegerAddSubtractExplorer() {
</Card>
{/* Quick Practice */}
<ClientOnly fallback={<Card className="min-h-[220px]" />}>
<QuickPractice />
</ClientOnly>
</div>
);
}

View File

@@ -2,6 +2,7 @@
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { ClientOnly } from "@/components/ui/client-only";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
@@ -191,8 +192,6 @@ function Thermometer({ data }: { data?: Step["thermometer"] }) {
const startTemp = data?.startTemp ?? 0;
const endTemp = data?.endTemp ?? 0;
const startY = ((startTemp - minTemp) / range) * 100;
const endY = ((endTemp - minTemp) / range) * 100;
const fillY = ((endTemp - minTemp) / range) * 100;
const ticks = [];
@@ -372,12 +371,6 @@ function ThermometerPractice() {
);
}
function MultiplyDividePractice() {
const [problem, setProblem] = useState(() => generateProblem());
const [userAnswer, setUserAnswer] = useState("");
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
const [score, setScore] = useState({ correct: 0, total: 0 });
function generateProblem() {
const ops = ["×", "÷"] as const;
const op = ops[Math.floor(Math.random() * ops.length)];
@@ -396,6 +389,12 @@ function MultiplyDividePractice() {
}
}
function MultiplyDividePractice() {
const [problem, setProblem] = useState(() => generateProblem());
const [userAnswer, setUserAnswer] = useState("");
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
const [score, setScore] = useState({ correct: 0, total: 0 });
function check() {
const parsed = parseInt(userAnswer);
if (isNaN(parsed)) return;
@@ -702,7 +701,9 @@ export function IntegerMultiplyDivideExplorer() {
</Card>
{/* Practice sections */}
<ClientOnly fallback={<Card className="min-h-[220px]" />}>
{tab === "thermometer" ? <ThermometerPractice /> : <MultiplyDividePractice />}
</ClientOnly>
</div>
);
}

View File

@@ -0,0 +1,795 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { ClientOnly } from "@/components/ui/client-only";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
import { shuffle } from "@/lib/utils";
type Mode = "number-line" | "order" | "compare";
type Direction = "asc" | "desc";
type Tone = "start" | "highlight" | "muted";
const MIN_VAL = -20;
const MAX_VAL = 20;
const RANGE = MAX_VAL - MIN_VAL;
interface Marker {
value: number;
label?: string;
tone?: Tone;
}
interface OrderChips {
pool: number[];
ordered: number[];
total: number;
}
interface Step {
label: string;
math: string;
markers: Marker[];
order?: OrderChips;
}
// Real-world contexts for the "Number Line" mode
const CONTEXTS = {
number: {
emoji: "🔢",
label: "Number",
describe: (n: number) =>
n === 0
? "0 is neither positive nor negative — it separates the two sides."
: `${n} is ${Math.abs(n)} step${Math.abs(n) === 1 ? "" : "s"} to the ${n < 0 ? "LEFT" : "RIGHT"} of zero.`,
},
thermometer: {
emoji: "🌡️",
label: "Temperature",
describe: (n: number) =>
n === 0
? "0 °C is freezing point — the boundary between warm and cold."
: `${n} °C means ${Math.abs(n)} degree${Math.abs(n) === 1 ? "" : "s"} ${n < 0 ? "BELOW" : "ABOVE"} freezing.`,
},
money: {
emoji: "💰",
label: "Money",
describe: (n: number) =>
n === 0
? "$0 means you have nothing and owe nothing."
: n < 0
? `$${Math.abs(n)} means you OWE $${Math.abs(n)} (an overdraft).`
: `+$${n} means you HAVE $${n} in the bank.`,
},
sea: {
emoji: "🌊",
label: "Sea level",
describe: (n: number) =>
n === 0
? "0 m is exactly at sea level."
: `${n} m means ${Math.abs(n)} metre${Math.abs(n) === 1 ? "" : "s"} ${n < 0 ? "BELOW" : "ABOVE"} sea level.`,
},
} as const;
type ContextKey = keyof typeof CONTEXTS;
function buildNumberLineSteps(n: number, ctx: ContextKey): Step[] {
const context = CONTEXTS[ctx];
const typeWord = n === 0 ? "zero" : n < 0 ? "a NEGATIVE integer" : "a POSITIVE integer";
return [
{
label: "Zero sits in the middle. Negative numbers are to its LEFT, positive numbers to its RIGHT.",
math: `\\ldots -3,\\ -2,\\ -1,\\ 0,\\ 1,\\ 2,\\ 3 \\ldots`,
markers: [{ value: 0, label: "0", tone: "start" }],
},
{
label: `Find ${n} on the number line and mark it.`,
math: `\\text{Mark } ${n}`,
markers: [
{ value: 0, tone: "muted" },
{ value: n, label: `${n}`, tone: "highlight" },
],
},
{
label: `${context.emoji} ${context.describe(n)}`,
math: `${n}`,
markers: [{ value: n, label: `${n}`, tone: "start" }],
},
{
label: `So ${n} is ${typeWord}.`,
math: n === 0 ? `0` : `${n} \\text{ is } ${n < 0 ? "<" : ">"} 0`,
markers: [{ value: n, label: `${n}`, tone: "start" }],
},
];
}
function buildOrderSteps(values: number[], direction: Direction): Step[] {
const sorted = [...values].sort((a, b) => (direction === "asc" ? a - b : b - a));
const sizeWord = direction === "asc" ? "smallest" : "largest";
const posWord = direction === "asc" ? "furthest LEFT" : "furthest RIGHT";
const total = values.length;
const steps: Step[] = [
{
label: "Here are the numbers we need to put in order.",
math: `\\{\\, ${values.join(",\\ ")} \\,\\}`,
markers: values.map((v) => ({ value: v, tone: "muted" as Tone })),
order: { pool: [...values], ordered: [], total },
},
{
label: "Place every number on the number line. Smaller numbers sit further left.",
math: `\\text{left = smaller} \\qquad \\text{right = larger}`,
markers: values.map((v) => ({ value: v, tone: "highlight" as Tone })),
order: { pool: [...values], ordered: [], total },
},
];
const pool = [...values];
const ordered: number[] = [];
for (let i = 0; i < sorted.length; i++) {
const pick = sorted[i];
pool.splice(pool.indexOf(pick), 1);
ordered.push(pick);
steps.push({
label: `The ${sizeWord} number still to place is the one ${posWord}: ${pick}.`,
math: `${ordered.join(direction === "asc" ? " < " : " > ")}${pool.length ? "\\ \\ldots" : ""}`,
markers: [
...ordered.map((v) => ({ value: v, tone: "start" as Tone })),
...pool.map((v) => ({ value: v, tone: "muted" as Tone })),
],
order: { pool: [...pool], ordered: [...ordered], total },
});
}
steps.push({
label:
direction === "asc"
? "Done! In ascending order: least → greatest."
: "Done! In descending order: greatest → least.",
math: `${sorted.join(direction === "asc" ? " < " : " > ")}`,
markers: sorted.map((v) => ({ value: v, tone: "start" as Tone })),
order: { pool: [], ordered: [...sorted], total },
});
return steps;
}
function buildCompareSteps(a: number, b: number): Step[] {
const symbol = a < b ? "<" : a > b ? ">" : "=";
const steps: Step[] = [
{
label: `Put both numbers on the number line.`,
math: `${a} \\;\\square\\; ${b}`,
markers: [
{ value: a, label: `${a}`, tone: "highlight" },
{ value: b, label: `${b}`, tone: "highlight" },
],
},
];
if (symbol === "=") {
steps.push({
label: "Both numbers land on the same spot, so they are equal.",
math: `${a} = ${b}`,
markers: [{ value: a, label: `${a}`, tone: "start" }],
});
return steps;
}
const larger = a > b ? a : b;
const smaller = a > b ? b : a;
steps.push(
{
label: `The number further RIGHT is always larger. ${larger} is to the right of ${smaller}.`,
math: `${larger} \\text{ is larger}`,
markers: [
{ value: smaller, label: `${smaller}`, tone: "muted" },
{ value: larger, label: `${larger}`, tone: "start" },
],
},
{
label: `The open mouth of the sign always points to the LARGER number.`,
math: `${a} ${symbol} ${b}`,
markers: [
{ value: a, label: `${a}`, tone: a === larger ? "start" : "muted" },
{ value: b, label: `${b}`, tone: b === larger ? "start" : "muted" },
],
},
);
return steps;
}
function NumberLine({ markers }: { markers: Marker[] }) {
return (
<div className="mx-auto w-full max-w-2xl select-none">
<div className="relative h-28 px-4">
{/* Markers above the line */}
{markers.map((m, i) => {
const x = ((m.value - MIN_VAL) / RANGE) * 100;
const tone = m.tone ?? "highlight";
const bg =
tone === "start" ? "bg-unit-5" : tone === "muted" ? "bg-muted/70" : "bg-correct";
return (
<div
key={`${m.value}-${i}`}
className="absolute bottom-14 -translate-x-1/2 transition-all duration-500"
style={{ left: `calc(${x}% + 16px)` }}
>
<div className="flex flex-col items-center">
{m.label !== undefined && (
<span
className={`rounded-full ${bg} px-2 py-0.5 text-[10px] font-bold text-white shadow-md`}
>
{m.label}
</span>
)}
<div className={`h-3 w-0.5 ${bg}`} />
</div>
</div>
);
})}
{/* Number line */}
<div className="absolute bottom-8 left-4 right-4">
<div className="h-0.5 bg-foreground/40" />
{Array.from({ length: RANGE + 1 }, (_, i) => {
const val = MIN_VAL + i;
const x = (i / RANGE) * 100;
const isZero = val === 0;
const marked = markers.some((m) => m.value === val);
return (
<div key={val} className="absolute -translate-x-1/2" style={{ left: `${x}%` }}>
<div
className={`h-3 w-0.5 -translate-y-1.5 transition-colors duration-300 ${
marked
? "h-4 bg-unit-5"
: isZero
? "bg-foreground"
: "bg-foreground/30"
}`}
/>
{(val % 5 === 0 || marked) && (
<span
className={`mt-1 block text-center text-[10px] transition-all duration-300 ${
marked
? "font-extrabold text-unit-5"
: isZero
? "font-bold text-foreground"
: "text-muted/70"
}`}
>
{val}
</span>
)}
</div>
);
})}
</div>
{/* Edge labels */}
<div className="absolute bottom-0 left-2 right-2 flex justify-between text-xs text-muted/50">
<span> negative</span>
<span>positive </span>
</div>
</div>
</div>
);
}
function OrderChipsView({ order, direction }: { order: OrderChips; direction: Direction }) {
const emptySlots = order.total - order.ordered.length;
return (
<div className="w-full space-y-3">
<div>
<p className="mb-1.5 text-xs font-bold uppercase tracking-wider text-muted">Still to place</p>
<div className="flex min-h-9 flex-wrap gap-2">
{order.pool.length === 0 ? (
<span className="text-sm text-muted/50"> all placed </span>
) : (
order.pool.map((v, i) => (
<span
key={`${v}-${i}`}
className="rounded-lg border-2 border-border bg-surface px-3 py-1.5 text-sm font-bold text-muted"
>
{v}
</span>
))
)}
</div>
</div>
<div>
<p className="mb-1.5 text-xs font-bold uppercase tracking-wider text-unit-5">
Ordered ({direction === "asc" ? "small → large" : "large → small"})
</p>
<div className="flex flex-wrap items-center gap-2">
{order.ordered.map((v, i) => (
<span key={`${v}-${i}`} className="flex items-center gap-2">
{i > 0 && <span className="text-sm font-bold text-unit-5">{direction === "asc" ? "<" : ">"}</span>}
<span className="rounded-lg border-2 border-unit-5 bg-unit-5-light px-3 py-1.5 text-sm font-extrabold text-unit-5-dark">
{v}
</span>
</span>
))}
{Array.from({ length: emptySlots }, (_, i) => (
<span key={`empty-${i}`} className="flex items-center gap-2">
{(order.ordered.length > 0 || i > 0) && (
<span className="text-sm font-bold text-muted/40">{direction === "asc" ? "<" : ">"}</span>
)}
<span className="h-9 w-11 rounded-lg border-2 border-dashed border-border" />
</span>
))}
</div>
</div>
</div>
);
}
// ── Quick practice ───────────────────────────────────────────────────────────
const SEED_PAIRS: [number, number][] = [
[-5, 5],
[4, -16],
[-6, -11],
[0, -3],
[8, -1],
];
function randInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function makePair(): [number, number] {
if (Math.random() < 0.4) return SEED_PAIRS[Math.floor(Math.random() * SEED_PAIRS.length)];
const a = randInt(-15, 15);
let b = randInt(-15, 15);
if (a === b) b = a + 1;
return [a, b];
}
function makeOrderValues(): number[] {
const set = new Set<number>();
while (set.size < 4) set.add(randInt(-12, 12));
return shuffle([...set]);
}
function ComparePractice({ onScore }: { onScore: (correct: boolean) => void }) {
const [pair, setPair] = useState<[number, number]>(() => makePair());
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
const [a, b] = pair;
const answer = a < b ? "<" : ">";
function pick(sign: "<" | ">") {
if (feedback === "correct") return;
const correct = sign === answer;
setFeedback(correct ? "correct" : "incorrect");
if (correct) onScore(true);
else onScore(false);
}
function next() {
setPair(makePair());
setFeedback(null);
}
return (
<div className="space-y-4 text-center">
<p className="text-sm text-muted">Which sign makes it true?</p>
<div className="flex items-center justify-center gap-4 text-3xl font-extrabold">
<span>{a}</span>
<span className="inline-flex h-11 w-11 items-center justify-center rounded-xl border-2 border-dashed border-unit-5/50 text-unit-5">
?
</span>
<span>{b}</span>
</div>
<div className="flex justify-center gap-3">
{(["<", ">"] as const).map((sign) => (
<button
key={sign}
onClick={() => pick(sign)}
disabled={feedback === "correct"}
className={`h-14 w-14 rounded-xl border-2 text-2xl font-extrabold transition-colors ${
feedback === "correct" && sign === answer
? "border-correct bg-correct-light text-correct"
: feedback === "incorrect" && sign !== answer
? "border-border text-muted"
: "border-unit-5/40 text-unit-5 hover:bg-unit-5-light disabled:opacity-50"
}`}
>
{sign}
</button>
))}
</div>
{feedback === "correct" && (
<div className="space-y-2">
<p className="text-sm font-bold text-correct">Correct! {a} {answer} {b}</p>
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2 text-sm font-bold text-background hover:bg-foreground/80">
Next
</button>
</div>
)}
{feedback === "incorrect" && (
<p className="text-sm font-bold text-incorrect">Not quite remember, further right means larger. Try again.</p>
)}
</div>
);
}
function OrderPractice({ onScore }: { onScore: (correct: boolean) => void }) {
const [values, setValues] = useState<number[]>(() => makeOrderValues());
const [placed, setPlaced] = useState<number[]>([]);
const [wrong, setWrong] = useState<number | null>(null);
const [mistakes, setMistakes] = useState(0);
const [done, setDone] = useState(false);
const sorted = [...values].sort((a, b) => a - b);
const nextExpected = sorted[placed.length];
function clickChip(v: number) {
if (done || placed.includes(v)) return;
if (v === nextExpected) {
const nextPlaced = [...placed, v];
setPlaced(nextPlaced);
setWrong(null);
if (nextPlaced.length === sorted.length) {
setDone(true);
onScore(mistakes === 0);
}
} else {
setWrong(v);
setMistakes((m) => m + 1);
setTimeout(() => setWrong((w) => (w === v ? null : w)), 500);
}
}
function next() {
setValues(makeOrderValues());
setPlaced([]);
setWrong(null);
setMistakes(0);
setDone(false);
}
return (
<div className="space-y-4 text-center">
<p className="text-sm text-muted">Click the numbers in ascending order (smallest first).</p>
<div className="flex flex-wrap justify-center gap-2">
{values.map((v) => {
const isPlaced = placed.includes(v);
const isWrong = wrong === v;
return (
<button
key={v}
onClick={() => clickChip(v)}
disabled={isPlaced || done}
className={`h-12 w-14 rounded-xl border-2 text-lg font-extrabold transition-all ${
isPlaced
? "border-correct bg-correct-light text-correct"
: isWrong
? "border-incorrect bg-incorrect-light text-incorrect"
: "border-unit-5/40 text-unit-5-dark hover:bg-unit-5-light"
}`}
>
{v}
</button>
);
})}
</div>
<div className="flex items-center justify-center gap-2 text-lg font-bold text-unit-5">
{placed.map((v, i) => (
<span key={v}>
{i > 0 && <span className="mr-2 text-muted">{"<"}</span>}
{v}
</span>
))}
{placed.length < sorted.length && <span className="text-muted/40"></span>}
</div>
{done && (
<div className="space-y-2">
<p className="text-sm font-bold text-correct">
{mistakes === 0 ? "Perfect ordering!" : `Ordered — with ${mistakes} slip${mistakes === 1 ? "" : "s"}.`}
</p>
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2 text-sm font-bold text-background hover:bg-foreground/80">
Next
</button>
</div>
)}
</div>
);
}
function QuickPractice() {
const [kind, setKind] = useState<"compare" | "order">("compare");
const [score, setScore] = useState({ correct: 0, total: 0 });
const record = useCallback((correct: boolean) => {
setScore((s) => ({ correct: s.correct + (correct ? 1 : 0), total: s.total + 1 }));
}, []);
return (
<Card className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex gap-1.5">
{(["compare", "order"] as const).map((k) => (
<button
key={k}
onClick={() => setKind(k)}
className={`rounded-full px-3 py-1 text-xs font-bold transition-colors ${
kind === k ? "bg-unit-5 text-white" : "text-unit-5 hover:bg-unit-5-light"
}`}
>
{k === "compare" ? "Compare" : "Order"}
</button>
))}
</div>
<span className="rounded-full bg-unit-5-light px-3 py-1 text-xs font-bold text-unit-5-dark">
{score.correct}/{score.total}
</span>
</div>
{kind === "compare" ? (
<ComparePractice key={`c-${score.total}`} onScore={record} />
) : (
<OrderPractice key={`o-${score.total}`} onScore={record} />
)}
</Card>
);
}
// ── Main explorer ────────────────────────────────────────────────────────────
export function IntegerOrderCompareExplorer() {
const [mode, setMode] = useState<Mode>("number-line");
// number-line mode
const [nlInput, setNlInput] = useState("-7");
const [context, setContext] = useState<ContextKey>("thermometer");
// order mode
const [orderInput, setOrderInput] = useState("-8, -10, -4, -3, -6, 5");
const [direction, setDirection] = useState<Direction>("asc");
// compare mode
const [cmpA, setCmpA] = useState("-5");
const [cmpB, setCmpB] = useState("5");
const [error, setError] = useState("");
const [steps, setSteps] = useState<Step[] | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const done = steps ? currentStep >= steps.length - 1 : false;
const reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
function switchMode(m: Mode) {
setMode(m);
setError("");
reset();
}
function handleGo() {
setError("");
try {
if (mode === "number-line") {
const n = parseInt(nlInput, 10);
if (isNaN(n)) return setError("Enter a valid integer.");
if (n < MIN_VAL || n > MAX_VAL) return setError(`Keep the number between ${MIN_VAL} and ${MAX_VAL}.`);
setSteps(buildNumberLineSteps(n, context));
} else if (mode === "order") {
const parts = orderInput
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
const nums = parts.map((s) => parseInt(s, 10));
if (nums.some((n) => isNaN(n))) return setError("Use whole numbers separated by commas, e.g. -8, -10, 5.");
if (nums.length < 3 || nums.length > 6) return setError("Enter between 3 and 6 numbers.");
if (nums.some((n) => n < MIN_VAL || n > MAX_VAL)) return setError(`Keep every number between ${MIN_VAL} and ${MAX_VAL}.`);
setSteps(buildOrderSteps(nums, direction));
} else {
const a = parseInt(cmpA, 10);
const b = parseInt(cmpB, 10);
if (isNaN(a) || isNaN(b)) return setError("Enter two valid integers.");
if ([a, b].some((n) => n < MIN_VAL || n > MAX_VAL)) return setError(`Keep both numbers between ${MIN_VAL} and ${MAX_VAL}.`);
setSteps(buildCompareSteps(a, b));
}
setCurrentStep(0);
setIsPlaying(false);
} catch {
setError("Could not build the steps. Check your input.");
}
}
const stepForward = useCallback(() => {
if (!steps || currentStep >= steps.length - 1) return;
setCurrentStep((s) => s + 1);
if (currentStep + 1 >= steps.length - 1) setIsPlaying(false);
}, [steps, currentStep]);
const stepBack = useCallback(() => {
if (currentStep <= 0) return;
setCurrentStep((s) => s - 1);
}, [currentStep]);
const togglePlay = useCallback(() => setIsPlaying((p) => !p), []);
const step = steps ? steps[currentStep] : null;
return (
<div className="space-y-4">
{/* Mode toggle */}
<div className="flex flex-wrap gap-2">
{(["number-line", "order", "compare"] as Mode[]).map((m) => (
<button
key={m}
onClick={() => switchMode(m)}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === m
? "border-unit-5 bg-unit-5 text-white"
: "border-unit-5/40 text-unit-5 hover:bg-unit-5-light"
}`}
>
{m === "number-line" ? "Number Line" : m === "order" ? "Order" : "Compare"}
</button>
))}
</div>
{/* Input card */}
<Card>
{mode === "number-line" && (
<>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-5">
Place an integer on the number line
</p>
<div className="mb-3 flex flex-wrap gap-2">
{(Object.keys(CONTEXTS) as ContextKey[]).map((key) => (
<button
key={key}
onClick={() => {
setContext(key);
reset();
}}
className={`rounded-full border-2 px-3 py-1.5 text-xs font-semibold transition-colors ${
context === key
? "border-unit-5-dark bg-unit-5-dark text-white"
: "border-unit-5/30 text-unit-5-dark hover:bg-unit-5-light"
}`}
>
{CONTEXTS[key].emoji} {CONTEXTS[key].label}
</button>
))}
</div>
<div className="flex flex-wrap items-center gap-3">
<input
type="number"
value={nlInput}
onChange={(e) => setNlInput(e.target.value)}
className="w-24 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-5"
aria-label="Integer"
/>
<button
onClick={handleGo}
className="rounded-lg bg-unit-5 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-5-dark"
>
Go
</button>
</div>
</>
)}
{mode === "order" && (
<>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-5">
Enter 36 integers to order
</p>
<div className="mb-3 flex flex-wrap gap-2">
{(["asc", "desc"] as Direction[]).map((d) => (
<button
key={d}
onClick={() => {
setDirection(d);
reset();
}}
className={`rounded-full border-2 px-4 py-1.5 text-xs font-semibold transition-colors ${
direction === d
? "border-unit-5-dark bg-unit-5-dark text-white"
: "border-unit-5/30 text-unit-5-dark hover:bg-unit-5-light"
}`}
>
{d === "asc" ? "Ascending (small → large)" : "Descending (large → small)"}
</button>
))}
</div>
<div className="flex flex-wrap items-center gap-3">
<input
type="text"
value={orderInput}
onChange={(e) => setOrderInput(e.target.value)}
className="min-w-[16rem] flex-1 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-5"
aria-label="Comma-separated integers"
/>
<button
onClick={handleGo}
className="rounded-lg bg-unit-5 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-5-dark"
>
Go
</button>
</div>
</>
)}
{mode === "compare" && (
<>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-5">
Compare two integers with &lt; or &gt;
</p>
<div className="flex flex-wrap items-center gap-3">
<input
type="number"
value={cmpA}
onChange={(e) => setCmpA(e.target.value)}
className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-5"
aria-label="First integer"
/>
<span className="text-xl font-bold text-muted">?</span>
<input
type="number"
value={cmpB}
onChange={(e) => setCmpB(e.target.value)}
className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-5"
aria-label="Second integer"
/>
<button
onClick={handleGo}
className="rounded-lg bg-unit-5 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-5-dark"
>
Go
</button>
</div>
</>
)}
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display card */}
<Card className="flex min-h-[280px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
Set up the numbers above and click <strong>Go</strong>
</p>
) : (
<>
<p className="text-center text-sm font-medium text-muted">{step.label}</p>
<MathDisplay math={step.math} className="text-2xl" />
<NumberLine markers={step.markers} />
{step.order && <OrderChipsView order={step.order} direction={direction} />}
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={steps?.length ?? 0}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!steps && !done}
canStepBack={!!steps && currentStep > 0}
/>
</Card>
{/* Quick practice */}
<ClientOnly fallback={<Card className="min-h-[220px]" />}>
<QuickPractice />
</ClientOnly>
</div>
);
}

View File

@@ -0,0 +1,393 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { ClientOnly } from "@/components/ui/client-only";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
import { FractionInput } from "@/components/practice/fraction-input";
import { Fraction, lcm } from "@/lib/math/fractions";
import { checkFractionAnswer } from "@/lib/math/validation";
type Op = "+" | "-" | "\\times" | "\\div";
interface Step {
label: string;
math: string;
}
const OP_LABEL: Record<Op, string> = { "+": "+", "-": "", "\\times": "×", "\\div": "÷" };
/** Wrap a negative operand in parentheses so "a + (-b)" reads clearly. */
function operand(f: Fraction): string {
return f.n < 0 ? `\\left(${f.toSignedKatex()}\\right)` : f.toSignedKatex();
}
function signWord(f: Fraction): string {
return f.n < 0 ? "negative" : "positive";
}
function buildAddSubtractSteps(a: Fraction, b: Fraction, op: "+" | "-"): Step[] {
const opSym = op;
const steps: Step[] = [
{
label: "Start with the expression.",
math: `${a.toSignedKatex()} ${opSym} ${operand(b)}`,
},
];
const lcd = lcm(a.d, b.d);
let an = a.n;
let bn = b.n;
if (a.d !== b.d) {
an = a.n * (lcd / a.d);
bn = b.n * (lcd / b.d);
steps.push({
label: `The denominators are different. Find the common denominator: the LCM of ${a.d} and ${b.d} is ${lcd}.`,
math: `\\text{LCD} = ${lcd}`,
});
steps.push({
label: "Rewrite both fractions over the common denominator (the sign travels with the numerator).",
math: `${a.toSignedKatex()} = ${new Fraction(an, lcd).toSignedKatex()} \\quad ${b.toSignedKatex()} = ${new Fraction(bn, lcd).toSignedKatex()}`,
});
}
const rawNum = op === "+" ? an + bn : an - bn;
const bnShown = bn < 0 ? `\\left(${bn}\\right)` : `${bn}`;
steps.push({
label:
op === "+"
? "Add the numerators using the integer rules, keeping the common denominator."
: "Subtract the numerators using the integer rules (subtracting a negative adds), keeping the common denominator.",
math: `\\frac{${an} ${opSym} ${bnShown}}{${lcd}} = \\frac{${rawNum}}{${lcd}}`,
});
const raw = new Fraction(rawNum, lcd);
const result = raw.simplified();
if (result.n !== raw.n || result.d !== raw.d) {
steps.push({
label: "Simplify to lowest terms.",
math: `${raw.toSignedKatex()} = ${result.toSignedKatex()}`,
});
}
steps.push({
label: `The answer is ${result.n < 0 ? "negative" : "positive"}.`,
math: `${a.toSignedKatex()} ${opSym} ${operand(b)} = ${result.toSignedKatex()}`,
});
return steps;
}
function buildMultiplyDivideSteps(a: Fraction, b: Fraction, op: "\\times" | "\\div"): Step[] {
const steps: Step[] = [
{
label: "Start with the expression.",
math: `${a.toSignedKatex()} ${op} ${operand(b)}`,
},
];
let working = b;
if (op === "\\div") {
working = new Fraction(b.d, b.n); // reciprocal keeps the sign
steps.push({
label: "Dividing by a fraction is the same as multiplying by its reciprocal — keep, change, flip.",
math: `${a.toSignedKatex()} \\times ${operand(working)}`,
});
}
const negatives = (a.n < 0 ? 1 : 0) + (working.n < 0 ? 1 : 0);
const resultNegative = negatives % 2 === 1;
steps.push({
label: `Decide the sign: ${signWord(a)} and ${signWord(working)}. ${
negatives === 1 ? "Different signs give a NEGATIVE answer." : "The signs match, so the answer is POSITIVE."
}`,
math: `(${a.n < 0 ? "-" : "+"}) \\times (${working.n < 0 ? "-" : "+"}) = (${resultNegative ? "-" : "+"})`,
});
const absNum = Math.abs(a.n) * Math.abs(working.n);
const den = a.d * working.d;
steps.push({
label: "Multiply the numerators, multiply the denominators, then apply the sign.",
math: `\\frac{${Math.abs(a.n)} \\times ${Math.abs(working.n)}}{${a.d} \\times ${working.d}} = ${new Fraction(resultNegative ? -absNum : absNum, den).toSignedKatex()}`,
});
const raw = new Fraction(resultNegative ? -absNum : absNum, den);
const result = raw.simplified();
if (result.n !== raw.n || result.d !== raw.d) {
steps.push({
label: "Simplify to lowest terms.",
math: `${raw.toSignedKatex()} = ${result.toSignedKatex()}`,
});
}
steps.push({
label: "Final answer.",
math: `${a.toSignedKatex()} ${op} ${operand(b)} = ${result.toSignedKatex()}`,
});
return steps;
}
// ── Quick practice ───────────────────────────────────────────────────────────
function randInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
/** A proper-fraction numerator with a random sign: magnitude 1..den-1. */
function properSigned(den: number): number {
const mag = randInt(1, Math.max(1, den - 1));
return Math.random() < 0.5 ? -mag : mag;
}
interface PracticeProblem {
a: Fraction;
b: Fraction;
op: Op;
answer: Fraction;
}
function makeProblem(): PracticeProblem {
const op = (["+", "-", "\\times", "\\div"] as Op[])[randInt(0, 3)];
const d1 = randInt(2, 6);
const d2 = randInt(2, 6);
let n1 = properSigned(d1);
let n2 = properSigned(d2);
// This is the negative-fractions topic — make sure at least one operand is negative.
if (n1 > 0 && n2 > 0) {
if (Math.random() < 0.5) n1 = -n1;
else n2 = -n2;
}
const a = new Fraction(n1, d1);
const b = new Fraction(n2, d2);
let answer: Fraction;
if (op === "+") answer = a.plus(b).simplified();
else if (op === "-") answer = a.minus(b).simplified();
else if (op === "\\times") answer = a.times(b).simplified();
else answer = a.dividedBy(b).simplified();
return { a, b, op, answer };
}
function QuickPractice() {
const [problem, setProblem] = useState<PracticeProblem>(() => makeProblem());
const [num, setNum] = useState("");
const [den, setDen] = useState("");
const [status, setStatus] = useState<"solved" | "unsimplified" | "wrong" | null>(null);
const [score, setScore] = useState({ correct: 0, total: 0 });
const [missed, setMissed] = useState(false);
const [scored, setScored] = useState(false);
function check() {
const n = parseInt(num, 10);
const d = parseInt(den, 10);
if (isNaN(n) || isNaN(d)) return;
const res = checkFractionAnswer(n, d, problem.answer.n, problem.answer.d);
setStatus(res.status);
if (res.status === "wrong") {
setMissed(true);
} else if (res.status === "solved" && !scored) {
setScored(true);
setScore((s) => ({ correct: s.correct + (missed ? 0 : 1), total: s.total + 1 }));
}
}
function next() {
setProblem(makeProblem());
setNum("");
setDen("");
setStatus(null);
setMissed(false);
setScored(false);
}
return (
<Card className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs font-bold uppercase tracking-wider text-unit-5">Quick Practice</p>
<span className="rounded-full bg-unit-5-light px-3 py-1 text-xs font-bold text-unit-5-dark">
{score.correct}/{score.total}
</span>
</div>
<p className="text-center text-sm text-muted">Work it out and give your answer in its simplest form.</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<MathDisplay
math={`${problem.a.toSignedKatex()} ${problem.op} ${problem.b.n < 0 ? `\\left(${problem.b.toSignedKatex()}\\right)` : problem.b.toSignedKatex()} =`}
className="text-2xl"
/>
<FractionInput
numerator={num}
denominator={den}
onNumeratorChange={setNum}
onDenominatorChange={setDen}
onSubmit={status === "solved" ? next : check}
disabled={status === "solved"}
/>
{status === "solved" ? (
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2.5 text-sm font-bold text-background hover:bg-foreground/80">
Next
</button>
) : (
<button onClick={check} className="rounded-lg bg-unit-5 px-5 py-2.5 text-sm font-bold text-white hover:bg-unit-5-dark">
Check
</button>
)}
</div>
{status === "solved" && <p className="text-center text-sm font-bold text-correct">Correct!</p>}
{status === "unsimplified" && (
<p className="text-center text-sm font-bold text-hint">Right value now write it in its simplest form.</p>
)}
{status === "wrong" && (
<p className="text-center text-sm font-bold text-incorrect">Not quite. Watch the signs, then try again.</p>
)}
</Card>
);
}
// ── Main explorer ────────────────────────────────────────────────────────────
export function NegativeFractionsExplorer() {
const [op, setOp] = useState<Op>("+");
const [n1, setN1] = useState("-1");
const [d1, setD1] = useState("2");
const [n2, setN2] = useState("3");
const [d2, setD2] = useState("4");
const [error, setError] = useState("");
const [steps, setSteps] = useState<Step[] | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const done = steps ? currentStep >= steps.length - 1 : false;
const reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
function handleGo() {
setError("");
const pn1 = parseInt(n1, 10);
const pd1 = parseInt(d1, 10);
const pn2 = parseInt(n2, 10);
const pd2 = parseInt(d2, 10);
if ([pn1, pd1, pn2, pd2].some((v) => isNaN(v))) return setError("Fill in every box with a whole number.");
if (pd1 === 0 || pd2 === 0) return setError("A denominator cannot be zero.");
if ([pn1, pd1, pn2, pd2].some((v) => Math.abs(v) > 20)) return setError("Keep the numbers between -20 and 20.");
if (op === "\\div" && pn2 === 0) return setError("You cannot divide by zero.");
const a = new Fraction(pn1, pd1);
const b = new Fraction(pn2, pd2);
setSteps(
op === "+" || op === "-"
? buildAddSubtractSteps(a, b, op)
: buildMultiplyDivideSteps(a, b, op),
);
setCurrentStep(0);
setIsPlaying(false);
}
const stepForward = useCallback(() => {
if (!steps || currentStep >= steps.length - 1) return;
setCurrentStep((s) => s + 1);
if (currentStep + 1 >= steps.length - 1) setIsPlaying(false);
}, [steps, currentStep]);
const stepBack = useCallback(() => {
if (currentStep <= 0) return;
setCurrentStep((s) => s - 1);
}, [currentStep]);
const togglePlay = useCallback(() => setIsPlaying((p) => !p), []);
const step = steps ? steps[currentStep] : null;
const fieldClass =
"w-16 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-5";
return (
<div className="space-y-4">
{/* Operation selector */}
<div className="flex flex-wrap gap-2">
{(["+", "-", "\\times", "\\div"] as Op[]).map((o) => (
<button
key={o}
onClick={() => {
setOp(o);
reset();
}}
className={`h-11 w-14 rounded-full border-2 text-xl font-bold transition-colors ${
op === o
? "border-unit-5 bg-unit-5 text-white"
: "border-unit-5/40 text-unit-5 hover:bg-unit-5-light"
}`}
aria-label={OP_LABEL[o]}
>
{OP_LABEL[o]}
</button>
))}
</div>
{/* Input card */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-5">
Enter two fractions (numerators can be negative)
</p>
<div className="flex flex-wrap items-center gap-4">
<div className="inline-flex flex-col items-center gap-0.5">
<input type="number" value={n1} onChange={(e) => setN1(e.target.value)} className={fieldClass} aria-label="First numerator" />
<div className="h-0.5 w-16 bg-foreground" />
<input type="number" value={d1} onChange={(e) => setD1(e.target.value)} className={fieldClass} aria-label="First denominator" />
</div>
<span className="text-2xl font-bold text-muted">{OP_LABEL[op]}</span>
<div className="inline-flex flex-col items-center gap-0.5">
<input type="number" value={n2} onChange={(e) => setN2(e.target.value)} className={fieldClass} aria-label="Second numerator" />
<div className="h-0.5 w-16 bg-foreground" />
<input type="number" value={d2} onChange={(e) => setD2(e.target.value)} className={fieldClass} aria-label="Second denominator" />
</div>
<button
onClick={handleGo}
className="rounded-lg bg-unit-5 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-5-dark"
>
Go
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display card */}
<Card className="flex min-h-[220px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
Enter two fractions above and click <strong>Go</strong>
</p>
) : (
<>
<p className="text-center text-sm font-medium text-muted">{step.label}</p>
<MathDisplay math={step.math} className="text-2xl" />
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={steps?.length ?? 0}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!steps && !done}
canStepBack={!!steps && currentStep > 0}
/>
</Card>
{/* Quick practice */}
<ClientOnly fallback={<Card className="min-h-[220px]" />}>
<QuickPractice />
</ClientOnly>
</div>
);
}

View File

@@ -0,0 +1,356 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { ClientOnly } from "@/components/ui/client-only";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
type Mode = "commutative" | "associative" | "distributive";
type Op = "+" | "-" | "\\times" | "\\div";
interface Step {
label: string;
math: string;
match?: boolean; // colour the result green (holds) / red (fails)
}
const OP_META: Record<Op, { apply: (a: number, b: number) => number; commutative: boolean; associative: boolean; name: string }> = {
"+": { apply: (a, b) => a + b, commutative: true, associative: true, name: "Addition" },
"-": { apply: (a, b) => a - b, commutative: false, associative: false, name: "Subtraction" },
"\\times": { apply: (a, b) => a * b, commutative: true, associative: true, name: "Multiplication" },
"\\div": { apply: (a, b) => a / b, commutative: false, associative: false, name: "Division" },
};
function fmt(x: number): string {
if (Number.isInteger(x)) return `${x}`;
return `${parseFloat(x.toFixed(3))}`;
}
function buildCommutativeSteps(a: number, b: number, op: Op): Step[] {
const meta = OP_META[op];
const r1 = meta.apply(a, b);
const r2 = meta.apply(b, a);
const same = r1 === r2;
return [
{
label: "Commutative law: does the ORDER of the numbers change the answer?",
math: `${a} ${op} ${b} \\quad \\text{vs} \\quad ${b} ${op} ${a}`,
},
{ label: `Work out the first order.`, math: `${a} ${op} ${b} = ${fmt(r1)}` },
{ label: `Now swap the order.`, math: `${b} ${op} ${a} = ${fmt(r2)}` },
meta.commutative
? {
label: `Same answer! ${meta.name} IS commutative — order does not matter.`,
math: `a ${op} b = b ${op} a`,
match: true,
}
: {
label: same
? `They match here only because the numbers are special. In general, ${meta.name.toLowerCase()} is NOT commutative.`
: `Different answers! ${meta.name} is NOT commutative — order matters.`,
math: `a ${op} b \\neq b ${op} a`,
match: false,
},
];
}
function buildAssociativeSteps(a: number, b: number, c: number, op: Op): Step[] {
const meta = OP_META[op];
const left = meta.apply(meta.apply(a, b), c);
const right = meta.apply(a, meta.apply(b, c));
return [
{
label: "Associative law: does where we put the BRACKETS change the answer?",
math: `(${a} ${op} ${b}) ${op} ${c} \\quad \\text{vs} \\quad ${a} ${op} (${b} ${op} ${c})`,
},
{ label: "Do the brackets on the LEFT first.", math: `(${a} ${op} ${b}) ${op} ${c} = ${fmt(meta.apply(a, b))} ${op} ${c} = ${fmt(left)}` },
{ label: "Now do the brackets on the RIGHT first.", math: `${a} ${op} (${b} ${op} ${c}) = ${a} ${op} ${fmt(meta.apply(b, c))} = ${fmt(right)}` },
meta.associative
? { label: `Same answer! ${meta.name} IS associative — the grouping does not matter.`, math: `(a ${op} b) ${op} c = a ${op} (b ${op} c)`, match: true }
: { label: `Different answers! ${meta.name} is NOT associative — the grouping matters.`, math: `(a ${op} b) ${op} c \\neq a ${op} (b ${op} c)`, match: false },
];
}
function buildDistributiveSteps(a: number, b: number, c: number): Step[] {
const inside = b + c;
const answer = a * inside;
return [
{ label: "Distributive law: a number outside a bracket multiplies EVERYTHING inside.", math: `${a} \\times (${b} + ${c})` },
{
label: `Multiply the ${a} by each number inside the bracket separately.`,
math: `${a} \\times ${b} \\; + \\; ${a} \\times ${c}`,
},
{ label: "Working the direct way: add inside the bracket first, then multiply.", math: `${a} \\times (${b} + ${c}) = ${a} \\times ${inside} = ${answer}` },
{ label: "Working the expanded way gives the same answer.", math: `${a * b} + ${a * c} = ${answer}`, match: true },
];
}
// ── Quick practice ───────────────────────────────────────────────────────────
interface PracticeQ {
prompt: string;
answer: number;
}
function randInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function makeQuestion(): PracticeQ {
const roll = Math.floor(Math.random() * 4);
if (roll === 0) {
const a = randInt(2, 9), b = randInt(1, 9), c = randInt(1, 9);
return { prompt: `Fill the blank: ${a} × (${b} + ${c}) = ${a} × ${b} + ${a} ×`, answer: c };
}
if (roll === 1) {
const a = randInt(3, 12), b = randInt(1, a - 1);
return { prompt: `Subtraction is not commutative. What is ${a} ${b}?`, answer: a - b };
}
if (roll === 2) {
const a = randInt(2, 8), b = randInt(1, 6), c = randInt(1, 6);
return { prompt: `Work out ${a} × (${b} + ${c})`, answer: a * (b + c) };
}
const a = randInt(2, 9), b = randInt(2, 9);
return { prompt: `Multiplication is commutative, so ${a} × ${b} equals ${b} × ▢. What is ▢?`, answer: a };
}
function QuickPractice() {
const [q, setQ] = useState<PracticeQ>(() => makeQuestion());
const [val, setVal] = useState("");
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
const [score, setScore] = useState({ correct: 0, total: 0 });
function check() {
const parsed = parseInt(val, 10);
if (isNaN(parsed)) return;
const correct = parsed === q.answer;
setFeedback(correct ? "correct" : "incorrect");
setScore((s) => ({ correct: s.correct + (correct ? 1 : 0), total: s.total + 1 }));
}
function next() {
setQ(makeQuestion());
setVal("");
setFeedback(null);
}
return (
<Card className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs font-bold uppercase tracking-wider text-unit-8">Quick Practice</p>
<span className="rounded-full bg-unit-8-light px-3 py-1 text-xs font-bold text-unit-8-dark">
{score.correct}/{score.total}
</span>
</div>
<p className="text-center text-lg font-bold">{q.prompt}</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<input
type="number"
value={val}
onChange={(e) => {
setVal(e.target.value);
setFeedback(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (feedback !== null) next();
else check();
}
}}
className={`w-24 rounded-lg border-2 px-3 py-2 text-center text-xl font-bold outline-none transition-colors ${
feedback === "correct"
? "border-correct bg-correct-light"
: feedback === "incorrect"
? "border-incorrect bg-incorrect-light"
: "border-border focus:border-unit-8"
}`}
placeholder="?"
aria-label="Your answer"
/>
{feedback === null ? (
<button onClick={check} className="rounded-lg bg-unit-8 px-5 py-2.5 text-sm font-bold text-white hover:bg-unit-8-dark">
Check
</button>
) : (
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2.5 text-sm font-bold text-background hover:bg-foreground/80">
Next
</button>
)}
</div>
{feedback === "correct" && <p className="text-center text-sm font-bold text-correct">Correct!</p>}
{feedback === "incorrect" && (
<p className="text-center text-sm font-bold text-incorrect">Not quite the answer is {q.answer}.</p>
)}
</Card>
);
}
// ── Main explorer ────────────────────────────────────────────────────────────
export function NumberLawsExplorer() {
const [mode, setMode] = useState<Mode>("commutative");
const [op, setOp] = useState<Op>("+");
const [a, setA] = useState("8");
const [b, setB] = useState("3");
const [c, setC] = useState("2");
const [error, setError] = useState("");
const [steps, setSteps] = useState<Step[] | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const done = steps ? currentStep >= steps.length - 1 : false;
const reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
function switchMode(m: Mode) {
setMode(m);
setError("");
// distributive is fixed to + inside; keep whatever op for the others
reset();
}
const opChoices: Op[] = mode === "associative" ? ["+", "\\times", "-"] : ["+", "-", "\\times", "\\div"];
function handleGo() {
setError("");
const na = parseInt(a, 10);
const nb = parseInt(b, 10);
const nc = parseInt(c, 10);
if (isNaN(na) || isNaN(nb) || (mode !== "commutative" && isNaN(nc))) {
return setError("Enter whole numbers in every box.");
}
const vals = mode === "commutative" ? [na, nb] : [na, nb, nc];
if (vals.some((v) => Math.abs(v) > 50)) return setError("Keep the numbers between -50 and 50.");
if ((op === "\\div") && (nb === 0 || (mode === "associative" && nc === 0))) {
return setError("Cannot divide by zero — change the numbers.");
}
if (mode === "commutative") setSteps(buildCommutativeSteps(na, nb, op));
else if (mode === "associative") setSteps(buildAssociativeSteps(na, nb, nc, op));
else setSteps(buildDistributiveSteps(na, nb, nc));
setCurrentStep(0);
setIsPlaying(false);
}
const stepForward = useCallback(() => {
if (!steps || currentStep >= steps.length - 1) return;
setCurrentStep((s) => s + 1);
if (currentStep + 1 >= steps.length - 1) setIsPlaying(false);
}, [steps, currentStep]);
const stepBack = useCallback(() => {
if (currentStep <= 0) return;
setCurrentStep((s) => s - 1);
}, [currentStep]);
const togglePlay = useCallback(() => setIsPlaying((p) => !p), []);
const step = steps ? steps[currentStep] : null;
const showThree = mode !== "commutative";
const showOp = mode !== "distributive";
const opLabel: Record<Op, string> = { "+": "+", "-": "", "\\times": "×", "\\div": "÷" };
return (
<div className="space-y-4">
{/* Mode toggle */}
<div className="flex flex-wrap gap-2">
{(["commutative", "associative", "distributive"] as Mode[]).map((m) => (
<button
key={m}
onClick={() => switchMode(m)}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold capitalize transition-colors ${
mode === m
? "border-unit-8 bg-unit-8 text-white"
: "border-unit-8/40 text-unit-8 hover:bg-unit-8-light"
}`}
>
{m}
</button>
))}
</div>
{/* Operation tabs */}
{showOp && (
<div className="flex flex-wrap gap-2">
{opChoices.map((o) => (
<button
key={o}
onClick={() => {
setOp(o);
reset();
}}
className={`h-10 w-12 rounded-full border-2 text-lg font-bold transition-colors ${
op === o
? "border-unit-8-dark bg-unit-8-dark text-white"
: "border-unit-8/30 text-unit-8-dark hover:bg-unit-8-light"
}`}
>
{opLabel[o]}
</button>
))}
</div>
)}
{/* Input card */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-8">
{mode === "distributive" ? "Enter a, b and c for a × (b + c)" : showThree ? "Enter three numbers" : "Enter two numbers"}
</p>
<div className="flex flex-wrap items-center gap-3">
<input type="number" value={a} onChange={(e) => setA(e.target.value)} className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-8" aria-label="a" />
<input type="number" value={b} onChange={(e) => setB(e.target.value)} className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-8" aria-label="b" />
{showThree && (
<input type="number" value={c} onChange={(e) => setC(e.target.value)} className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-8" aria-label="c" />
)}
<button onClick={handleGo} className="rounded-lg bg-unit-8 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-8-dark">
Go
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display card */}
<Card className="flex min-h-[220px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
Enter numbers above and click <strong>Go</strong>
</p>
) : (
<>
<p className="text-center text-sm font-medium text-muted">{step.label}</p>
<MathDisplay
math={step.math}
className={`text-2xl ${step.match === true ? "text-correct" : step.match === false ? "text-incorrect" : ""}`}
/>
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={steps?.length ?? 0}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!steps && !done}
canStepBack={!!steps && currentStep > 0}
/>
</Card>
{/* Quick practice */}
<ClientOnly fallback={<Card className="min-h-[220px]" />}>
<QuickPractice />
</ClientOnly>
</div>
);
}

View File

@@ -0,0 +1,351 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { ClientOnly } from "@/components/ui/client-only";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
interface Column {
exponent: number;
placeValue: number;
digit: string;
contrib: number;
}
interface Step {
label: string;
math: string;
activeCol: number | null;
revealed: number; // how many columns show their contribution
}
const PLACE_NAMES = ["ones", "tens", "hundreds", "thousands", "ten thousands", "hundred thousands"];
function columnsFor(n: number): Column[] {
const str = `${n}`;
const len = str.length;
return str.split("").map((d, i) => {
const exponent = len - 1 - i;
return {
exponent,
placeValue: Math.pow(10, exponent),
digit: d,
contrib: parseInt(d, 10) * Math.pow(10, exponent),
};
});
}
function buildSteps(n: number): Step[] {
const cols = columnsFor(n);
const len = cols.length;
const steps: Step[] = [
{
label: "In the denary (base 10) system, each digit has a place value that is a power of 10.",
math: `${n}`,
activeCol: null,
revealed: 0,
},
];
cols.forEach((c, i) => {
steps.push({
label: `The ${PLACE_NAMES[c.exponent]} digit is ${c.digit}. Its place value is 10^${c.exponent} = ${c.placeValue.toLocaleString("en-US")}.`,
math: `${c.digit} \\times 10^{${c.exponent}} = ${c.contrib.toLocaleString("en-US")}`,
activeCol: i,
revealed: i + 1,
});
});
const expanded = cols.map((c) => `${c.digit} \\times 10^{${c.exponent}}`).join(" + ");
steps.push({
label: "Put it all together — this is the number in expanded form.",
math: `${n} = ${expanded}`,
activeCol: null,
revealed: len,
});
const sumParts = cols.map((c) => c.contrib.toLocaleString("en-US")).join(" + ");
steps.push({
label: "Add every part back up and you return to the original number.",
math: `${sumParts} = ${n.toLocaleString("en-US")}`,
activeCol: null,
revealed: len,
});
return steps;
}
function PlaceValueChart({ columns, step }: { columns: Column[]; step: Step }) {
return (
<div className="overflow-x-auto rounded-xl border-2 border-unit-6/30 bg-unit-6-light/40 p-3">
<table className="mx-auto border-collapse font-mono">
<tbody>
<tr>
{columns.map((c, i) => (
<td
key={`p-${i}`}
className={`min-w-[64px] border-2 border-unit-6/40 px-3 py-2 text-center text-sm font-bold text-unit-6-dark ${
step.activeCol === i ? "bg-unit-6 text-white" : "bg-unit-6-light"
}`}
>
10<sup>{c.exponent}</sup>
</td>
))}
</tr>
<tr>
{columns.map((c, i) => (
<td
key={`v-${i}`}
className={`min-w-[64px] border-2 border-unit-6/40 px-3 py-2 text-center text-sm font-bold text-unit-6 ${
step.activeCol === i ? "bg-unit-6-light" : "bg-surface"
}`}
>
{c.placeValue.toLocaleString("en-US")}
</td>
))}
</tr>
<tr>
{columns.map((c, i) => (
<td
key={`d-${i}`}
className={`min-w-[64px] border-2 border-unit-6/40 px-3 py-2 text-center text-2xl font-extrabold ${
step.activeCol === i ? "bg-unit-6-light text-unit-6-dark" : "bg-surface text-foreground"
}`}
>
{c.digit}
</td>
))}
</tr>
<tr>
{columns.map((c, i) => (
<td
key={`c-${i}`}
className={`min-w-[64px] border-2 border-unit-6/40 px-3 py-2 text-center text-sm font-bold ${
i < step.revealed ? "bg-correct-light text-correct" : "bg-[#fafbfc] text-muted/50"
}`}
>
{i < step.revealed ? c.contrib.toLocaleString("en-US") : "_"}
</td>
))}
</tr>
</tbody>
</table>
</div>
);
}
// ── Quick practice ───────────────────────────────────────────────────────────
interface PracticeQ {
prompt: string;
answer: number;
}
function randInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function makeQuestion(): PracticeQ {
if (Math.random() < 0.5) {
// value of a digit
const n = randInt(1000, 99999);
const cols = columnsFor(n);
const nonZero = cols.filter((c) => c.digit !== "0");
const pick = nonZero[Math.floor(Math.random() * nonZero.length)];
return {
prompt: `In ${n.toLocaleString("en-US")}, what is the VALUE of the digit ${pick.digit}?`,
answer: pick.contrib,
};
}
// exponent of a named column
const exp = randInt(0, 5);
return {
prompt: `Which power of 10 is the ${PLACE_NAMES[exp]} column? Enter the exponent.`,
answer: exp,
};
}
function QuickPractice() {
const [q, setQ] = useState<PracticeQ>(() => makeQuestion());
const [val, setVal] = useState("");
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
const [score, setScore] = useState({ correct: 0, total: 0 });
function check() {
const parsed = parseInt(val, 10);
if (isNaN(parsed)) return;
const correct = parsed === q.answer;
setFeedback(correct ? "correct" : "incorrect");
setScore((s) => ({ correct: s.correct + (correct ? 1 : 0), total: s.total + 1 }));
}
function next() {
setQ(makeQuestion());
setVal("");
setFeedback(null);
}
return (
<Card className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs font-bold uppercase tracking-wider text-unit-6">Quick Practice</p>
<span className="rounded-full bg-unit-6-light px-3 py-1 text-xs font-bold text-unit-6-dark">
{score.correct}/{score.total}
</span>
</div>
<p className="text-center text-lg font-bold">{q.prompt}</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<input
type="number"
value={val}
onChange={(e) => {
setVal(e.target.value);
setFeedback(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (feedback !== null) next();
else check();
}
}}
className={`w-32 rounded-lg border-2 px-3 py-2 text-center text-xl font-bold outline-none transition-colors ${
feedback === "correct"
? "border-correct bg-correct-light"
: feedback === "incorrect"
? "border-incorrect bg-incorrect-light"
: "border-border focus:border-unit-6"
}`}
placeholder="?"
aria-label="Your answer"
/>
{feedback === null ? (
<button onClick={check} className="rounded-lg bg-unit-6 px-5 py-2.5 text-sm font-bold text-white hover:bg-unit-6-dark">
Check
</button>
) : (
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2.5 text-sm font-bold text-background hover:bg-foreground/80">
Next
</button>
)}
</div>
{feedback === "correct" && <p className="text-center text-sm font-bold text-correct">Correct!</p>}
{feedback === "incorrect" && (
<p className="text-center text-sm font-bold text-incorrect">Not quite the answer is {q.answer.toLocaleString("en-US")}.</p>
)}
</Card>
);
}
// ── Main explorer ────────────────────────────────────────────────────────────
export function PlaceValueExplorer() {
const [input, setInput] = useState("983275");
const [error, setError] = useState("");
const [columns, setColumns] = useState<Column[] | null>(null);
const [steps, setSteps] = useState<Step[] | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const done = steps ? currentStep >= steps.length - 1 : false;
const reset = useCallback(() => {
setSteps(null);
setColumns(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
function handleGo() {
setError("");
const cleaned = input.replace(/[,\s]/g, "");
const n = parseInt(cleaned, 10);
if (isNaN(n) || !/^\d+$/.test(cleaned)) return setError("Enter a whole number (digits only).");
if (n < 1 || n > 999999) return setError("Use a whole number between 1 and 999,999.");
setColumns(columnsFor(n));
setSteps(buildSteps(n));
setCurrentStep(0);
setIsPlaying(false);
}
const stepForward = useCallback(() => {
if (!steps || currentStep >= steps.length - 1) return;
setCurrentStep((s) => s + 1);
if (currentStep + 1 >= steps.length - 1) setIsPlaying(false);
}, [steps, currentStep]);
const stepBack = useCallback(() => {
if (currentStep <= 0) return;
setCurrentStep((s) => s - 1);
}, [currentStep]);
const togglePlay = useCallback(() => setIsPlaying((p) => !p), []);
const step = steps ? steps[currentStep] : null;
return (
<div className="space-y-4">
{/* Input card */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-6">
Enter a whole number (up to 999,999)
</p>
<div className="flex flex-wrap items-center gap-3">
<input
type="text"
inputMode="numeric"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleGo();
}}
className="w-44 rounded-lg border-2 border-unit-6 bg-surface px-3 py-2 text-center font-mono text-2xl font-bold text-unit-6 outline-none focus:border-unit-6-dark"
aria-label="Number input"
/>
<button
onClick={handleGo}
className="rounded-lg bg-unit-6 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-6-dark"
>
Break Down
</button>
</div>
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display card */}
<Card className="flex min-h-[280px] flex-col items-center justify-center gap-4 p-6">
{!step || !columns ? (
<p className="text-muted/50">
Enter a number above and click <strong>Break Down</strong>
</p>
) : (
<>
<p className="text-center text-sm font-medium text-muted">{step.label}</p>
<MathDisplay math={step.math} className="text-xl" />
<PlaceValueChart columns={columns} step={step} />
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={steps?.length ?? 0}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!steps && !done}
canStepBack={!!steps && currentStep > 0}
/>
</Card>
{/* Quick practice */}
<ClientOnly fallback={<Card className="min-h-[220px]" />}>
<QuickPractice />
</ClientOnly>
</div>
);
}

View File

@@ -0,0 +1,418 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { ClientOnly } from "@/components/ui/client-only";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
import { CartesianGrid, type GridPoint, type GridSegment, type GridArrow } from "./cartesian-grid";
type Mode = "plot" | "shape";
interface Step {
label: string;
math: string;
points: GridPoint[];
arrows?: GridArrow[];
segments?: GridSegment[];
highlight?: { x: number; y: number };
}
// ── Plot a single point ──────────────────────────────────────────────────────
function buildPlotSteps(x: number, y: number): Step[] {
const steps: Step[] = [
{
label: "Start at the origin (0, 0) — where the two axes cross.",
math: `(0,\\ 0)`,
points: [{ x: 0, y: 0, label: "start", tone: "muted" }],
},
];
// Move along x
if (x === 0) {
steps.push({
label: "The x-coordinate is 0, so stay on the y-axis — don't move left or right.",
math: `x = 0`,
points: [{ x: 0, y: 0, tone: "muted" }],
});
} else {
steps.push({
label: `Move ALONG the x-axis: ${Math.abs(x)} to the ${x > 0 ? "RIGHT" : "LEFT"}.`,
math: `x = ${x}`,
points: [{ x: 0, y: 0, tone: "muted" }],
arrows: [{ from: { x: 0, y: 0 }, to: { x, y: 0 } }],
});
}
// Move along y
if (y === 0) {
steps.push({
label: "The y-coordinate is 0, so stay on the x-axis — don't move up or down.",
math: `y = 0`,
points: [{ x, y: 0, tone: "muted" }],
arrows: x === 0 ? [] : [{ from: { x: 0, y: 0 }, to: { x, y: 0 } }],
});
} else {
steps.push({
label: `Now move parallel to the y-axis: ${Math.abs(y)} ${y > 0 ? "UP" : "DOWN"}.`,
math: `y = ${y}`,
points: [{ x, y: 0, tone: "muted" }],
arrows: [
...(x === 0 ? [] : [{ from: { x: 0, y: 0 }, to: { x, y: 0 } }]),
{ from: { x, y: 0 }, to: { x, y } },
],
highlight: { x, y },
});
}
steps.push({
label: `Mark the point (${x}, ${y}). Remember: the x-coordinate always comes first!`,
math: `(${x},\\ ${y})`,
points: [{ x, y, label: `(${x}, ${y})`, tone: "solid" }],
highlight: { x, y },
});
return steps;
}
// ── Shape reveal presets ─────────────────────────────────────────────────────
interface Shape {
name: string;
points: { label: string; x: number; y: number }[];
}
const SHAPES: Record<string, Shape> = {
mystery: {
name: "Mystery Shape",
points: [
{ label: "A", x: -4, y: -8 },
{ label: "B", x: -3, y: -9 },
{ label: "C", x: -2, y: -8 },
{ label: "D", x: -2, y: 0 },
{ label: "E", x: -5, y: 0 },
{ label: "F", x: -6, y: 4 },
{ label: "G", x: -2, y: 5 },
{ label: "H", x: 2, y: 4 },
{ label: "I", x: 5, y: 0 },
],
},
square: {
name: "Square",
points: [
{ label: "A", x: 2, y: 2 },
{ label: "B", x: 7, y: 2 },
{ label: "C", x: 7, y: 7 },
{ label: "D", x: 2, y: 7 },
],
},
house: {
name: "House",
points: [
{ label: "A", x: -5, y: -4 },
{ label: "B", x: 5, y: -4 },
{ label: "C", x: 5, y: 3 },
{ label: "D", x: 0, y: 7 },
{ label: "E", x: -5, y: 3 },
],
},
};
function buildShapeSteps(shape: Shape): Step[] {
const pts = shape.points;
const steps: Step[] = [];
for (let i = 0; i < pts.length; i++) {
const plotted = pts.slice(0, i + 1);
const segments: GridSegment[] = [];
for (let j = 1; j <= i; j++) {
segments.push({ from: pts[j - 1], to: pts[j] });
}
const p = pts[i];
steps.push({
label:
i === 0
? `Plot the first point ${p.label} at (${p.x}, ${p.y}).`
: `Plot ${p.label} at (${p.x}, ${p.y}) and join it to ${pts[i - 1].label}.`,
math: `${p.label} = (${p.x},\\ ${p.y})`,
points: plotted.map((q) => ({ x: q.x, y: q.y, label: q.label, tone: "solid" as const })),
segments,
highlight: { x: p.x, y: p.y },
});
}
// Closing segment
const allSegments: GridSegment[] = [];
for (let j = 1; j < pts.length; j++) allSegments.push({ from: pts[j - 1], to: pts[j] });
allSegments.push({ from: pts[pts.length - 1], to: pts[0] });
steps.push({
label: `Finally, join ${pts[pts.length - 1].label} back to ${pts[0].label} to close the shape. What is it?`,
math: `${pts[pts.length - 1].label} \\to ${pts[0].label}`,
points: pts.map((q) => ({ x: q.x, y: q.y, label: q.label, tone: "solid" as const })),
segments: allSegments,
});
return steps;
}
// ── Quick practice: click the point ──────────────────────────────────────────
function randInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function ClickPractice() {
const [target, setTarget] = useState(() => ({ x: randInt(-8, 8), y: randInt(-8, 8) }));
const [clicked, setClicked] = useState<{ x: number; y: number } | null>(null);
const [score, setScore] = useState({ correct: 0, total: 0 });
const isCorrect = clicked && clicked.x === target.x && clicked.y === target.y;
function handleClick(x: number, y: number) {
if (clicked) return;
const correct = x === target.x && y === target.y;
setClicked({ x, y });
setScore((s) => ({ correct: s.correct + (correct ? 1 : 0), total: s.total + 1 }));
}
function next() {
setTarget({ x: randInt(-8, 8), y: randInt(-8, 8) });
setClicked(null);
}
const points: GridPoint[] = [];
if (clicked) {
if (isCorrect) {
points.push({ x: target.x, y: target.y, label: `(${target.x}, ${target.y})`, tone: "target" });
} else {
points.push({ x: clicked.x, y: clicked.y, label: "you", tone: "wrong" });
points.push({ x: target.x, y: target.y, label: `(${target.x}, ${target.y})`, tone: "target" });
}
}
return (
<Card className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs font-bold uppercase tracking-wider text-unit-7">Quick Practice</p>
<span className="rounded-full bg-unit-7-light px-3 py-1 text-xs font-bold text-unit-7-dark">
{score.correct}/{score.total}
</span>
</div>
<p className="text-center text-lg font-bold">
Click where <span className="text-unit-7-dark">({target.x}, {target.y})</span> belongs
</p>
<div className="mx-auto max-w-md">
<CartesianGrid points={points} highlight={isCorrect ? target : undefined} onGridClick={clicked ? undefined : handleClick} />
</div>
{clicked && (
<div className="space-y-2 text-center">
{isCorrect ? (
<p className="text-sm font-bold text-correct">Spot on! That is ({target.x}, {target.y}).</p>
) : (
<p className="text-sm font-bold text-incorrect">
You clicked ({clicked.x}, {clicked.y}). The target ({target.x}, {target.y}) is shown in green.
</p>
)}
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2 text-sm font-bold text-background hover:bg-foreground/80">
Next
</button>
</div>
)}
</Card>
);
}
// ── Main explorer ────────────────────────────────────────────────────────────
export function PlotPointsExplorer() {
const [mode, setMode] = useState<Mode>("plot");
const [inputX, setInputX] = useState("4");
const [inputY, setInputY] = useState("3");
const [shapeKey, setShapeKey] = useState<keyof typeof SHAPES>("mystery");
const [error, setError] = useState("");
const [steps, setSteps] = useState<Step[] | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const done = steps ? currentStep >= steps.length - 1 : false;
const reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
function switchMode(m: Mode) {
setMode(m);
setError("");
reset();
}
function handlePlot() {
setError("");
const x = parseInt(inputX, 10);
const y = parseInt(inputY, 10);
if (isNaN(x) || isNaN(y)) return setError("Enter whole numbers for x and y.");
if ([x, y].some((v) => v < -10 || v > 10)) return setError("Keep both coordinates between -10 and 10.");
setSteps(buildPlotSteps(x, y));
setCurrentStep(0);
setIsPlaying(false);
}
function handleShape() {
setError("");
setSteps(buildShapeSteps(SHAPES[shapeKey]));
setCurrentStep(0);
setIsPlaying(false);
}
const stepForward = useCallback(() => {
if (!steps || currentStep >= steps.length - 1) return;
setCurrentStep((s) => s + 1);
if (currentStep + 1 >= steps.length - 1) setIsPlaying(false);
}, [steps, currentStep]);
const stepBack = useCallback(() => {
if (currentStep <= 0) return;
setCurrentStep((s) => s - 1);
}, [currentStep]);
const togglePlay = useCallback(() => setIsPlaying((p) => !p), []);
const step = steps ? steps[currentStep] : null;
return (
<div className="space-y-4">
{/* Mode toggle */}
<div className="flex flex-wrap gap-2">
{(["plot", "shape"] as Mode[]).map((m) => (
<button
key={m}
onClick={() => switchMode(m)}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
mode === m
? "border-unit-7 bg-unit-7 text-white"
: "border-unit-7/40 text-unit-7 hover:bg-unit-7-light"
}`}
>
{m === "plot" ? "Plot a Point" : "Shape Reveal"}
</button>
))}
</div>
{/* Input card */}
<Card>
{mode === "plot" ? (
<>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-7">
Enter the coordinates to plot
</p>
<div className="flex flex-wrap items-center gap-3">
<span className="text-lg font-bold text-muted">(</span>
<input
type="number"
value={inputX}
onChange={(e) => setInputX(e.target.value)}
className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-7"
aria-label="x coordinate"
/>
<span className="text-lg font-bold text-muted">,</span>
<input
type="number"
value={inputY}
onChange={(e) => setInputY(e.target.value)}
className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-7"
aria-label="y coordinate"
/>
<span className="text-lg font-bold text-muted">)</span>
<button
onClick={handlePlot}
className="rounded-lg bg-unit-7 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-7-dark"
>
Plot
</button>
</div>
</>
) : (
<>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-7">
Pick a shape, then step through the points to reveal it
</p>
<div className="flex flex-wrap items-center gap-3">
<select
value={shapeKey}
onChange={(e) => {
setShapeKey(e.target.value as keyof typeof SHAPES);
reset();
}}
className="rounded-lg border-2 border-border bg-surface px-3 py-2 text-sm font-bold outline-none focus:border-unit-7"
aria-label="Choose a shape"
>
{Object.entries(SHAPES).map(([key, s]) => (
<option key={key} value={key}>
{s.name}
</option>
))}
</select>
<button
onClick={handleShape}
className="rounded-lg bg-unit-7 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-7-dark"
>
Start
</button>
</div>
</>
)}
{error && <p className="mt-2 text-sm text-incorrect">{error}</p>}
</Card>
{/* Display card */}
<Card className="flex min-h-[320px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
{mode === "plot" ? (
<>Enter coordinates above and click <strong>Plot</strong></>
) : (
<>Pick a shape and click <strong>Start</strong></>
)}
</p>
) : (
<>
<p className="text-center text-sm font-medium text-muted">{step.label}</p>
<MathDisplay math={step.math} className="text-2xl" />
<div className="w-full max-w-md">
<CartesianGrid
points={step.points}
segments={step.segments}
arrows={step.arrows}
highlight={step.highlight}
/>
</div>
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={steps?.length ?? 0}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!steps && !done}
canStepBack={!!steps && currentStep > 0}
/>
</Card>
{/* Quick practice */}
<ClientOnly fallback={<Card className="min-h-[220px]" />}>
<ClickPractice />
</ClientOnly>
</div>
);
}

View File

@@ -0,0 +1,258 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { ClientOnly } from "@/components/ui/client-only";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
import { CartesianGrid, type GridPoint } from "./cartesian-grid";
interface Step {
label: string;
math: string;
points: GridPoint[];
highlight?: { x: number; y: number };
guide?: "x" | "y" | "both";
}
function randInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function quadrantName(x: number, y: number): string {
if (x === 0 && y === 0) return "the origin";
if (x === 0) return "on the y-axis";
if (y === 0) return "on the x-axis";
if (x > 0 && y > 0) return "the top-right quadrant";
if (x < 0 && y > 0) return "the top-left quadrant";
if (x < 0 && y < 0) return "the bottom-left quadrant";
return "the bottom-right quadrant";
}
function buildReadSteps(x: number, y: number): Step[] {
const point: GridPoint = { x, y, tone: "solid" };
return [
{
label: "Here is a point. Let's read off its coordinates.",
math: `(?,\\ ?)`,
points: [point],
},
{
label: `Trace straight ${y >= 0 ? "DOWN" : "UP"} to the x-axis. It lands on ${x}.`,
math: `x = ${x}`,
points: [point],
highlight: { x, y },
guide: "x",
},
{
label: `Now trace straight across to the y-axis. It lands on ${y}.`,
math: `y = ${y}`,
points: [point],
highlight: { x, y },
guide: "both",
},
{
label: `The coordinates are (${x}, ${y}) — x first, then y. This point is in ${quadrantName(x, y)}.`,
math: `(${x},\\ ${y})`,
points: [{ x, y, label: `(${x}, ${y})`, tone: "solid" }],
highlight: { x, y },
guide: "both",
},
];
}
// ── Quick practice: read the coordinates ─────────────────────────────────────
function ReadPractice() {
const [target, setTarget] = useState(() => ({ x: randInt(-9, 9), y: randInt(-9, 9) }));
const [xVal, setXVal] = useState("");
const [yVal, setYVal] = useState("");
const [feedback, setFeedback] = useState<"correct" | "swapped" | "incorrect" | null>(null);
const [score, setScore] = useState({ correct: 0, total: 0 });
function check() {
const gx = parseInt(xVal, 10);
const gy = parseInt(yVal, 10);
if (isNaN(gx) || isNaN(gy)) return;
if (gx === target.x && gy === target.y) {
setFeedback("correct");
setScore((s) => ({ correct: s.correct + 1, total: s.total + 1 }));
} else if (gx === target.y && gy === target.x && target.x !== target.y) {
setFeedback("swapped");
setScore((s) => ({ correct: s.correct, total: s.total + 1 }));
} else {
setFeedback("incorrect");
setScore((s) => ({ correct: s.correct, total: s.total + 1 }));
}
}
function next() {
setTarget({ x: randInt(-9, 9), y: randInt(-9, 9) });
setXVal("");
setYVal("");
setFeedback(null);
}
return (
<Card className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs font-bold uppercase tracking-wider text-unit-7">Quick Practice</p>
<span className="rounded-full bg-unit-7-light px-3 py-1 text-xs font-bold text-unit-7-dark">
{score.correct}/{score.total}
</span>
</div>
<p className="text-center text-lg font-bold">What are the coordinates of this point?</p>
<div className="mx-auto max-w-md">
<CartesianGrid points={[{ x: target.x, y: target.y, tone: "solid" }]} highlight={feedback ? target : undefined} />
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
<span className="text-lg font-bold text-muted">(</span>
<input
type="number"
value={xVal}
onChange={(e) => {
setXVal(e.target.value);
setFeedback(null);
}}
disabled={feedback === "correct"}
className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-7"
placeholder="x"
aria-label="x coordinate"
/>
<span className="text-lg font-bold text-muted">,</span>
<input
type="number"
value={yVal}
onChange={(e) => {
setYVal(e.target.value);
setFeedback(null);
}}
disabled={feedback === "correct"}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (feedback === "correct") next();
else check();
}
}}
className="w-20 rounded-lg border-2 border-border bg-surface px-3 py-2 text-center text-lg font-bold outline-none focus:border-unit-7"
placeholder="y"
aria-label="y coordinate"
/>
<span className="text-lg font-bold text-muted">)</span>
{feedback === "correct" ? (
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2.5 text-sm font-bold text-background hover:bg-foreground/80">
Next
</button>
) : (
<button onClick={check} className="rounded-lg bg-unit-7 px-5 py-2.5 text-sm font-bold text-white hover:bg-unit-7-dark">
Check
</button>
)}
</div>
{feedback === "correct" && <p className="text-center text-sm font-bold text-correct">Correct! ({target.x}, {target.y})</p>}
{feedback === "swapped" && (
<p className="text-center text-sm font-bold text-incorrect">You swapped them the x-coordinate comes first! Try again.</p>
)}
{feedback === "incorrect" && (
<p className="text-center text-sm font-bold text-incorrect">Not quite. Trace down to the x-axis, then across to the y-axis.</p>
)}
</Card>
);
}
// ── Main explorer ────────────────────────────────────────────────────────────
export function ReadCoordinatesExplorer() {
const [point, setPoint] = useState(() => ({ x: 3, y: 2 }));
const [steps, setSteps] = useState<Step[] | null>(() => buildReadSteps(3, 2));
const [currentStep, setCurrentStep] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const done = steps ? currentStep >= steps.length - 1 : false;
const reset = useCallback(() => {
setCurrentStep(0);
setIsPlaying(false);
}, []);
function newPoint() {
let x = randInt(-9, 9);
const y = randInt(-9, 9);
if (x === point.x && y === point.y) x = -x || 1;
setPoint({ x, y });
setSteps(buildReadSteps(x, y));
setCurrentStep(0);
setIsPlaying(false);
}
const stepForward = useCallback(() => {
if (!steps || currentStep >= steps.length - 1) return;
setCurrentStep((s) => s + 1);
if (currentStep + 1 >= steps.length - 1) setIsPlaying(false);
}, [steps, currentStep]);
const stepBack = useCallback(() => {
if (currentStep <= 0) return;
setCurrentStep((s) => s - 1);
}, [currentStep]);
const togglePlay = useCallback(() => setIsPlaying((p) => !p), []);
const step = steps ? steps[currentStep] : null;
return (
<div className="space-y-4">
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-7">
Read the coordinates of the point
</p>
<button
onClick={newPoint}
className="rounded-lg bg-unit-7 px-6 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-7-dark"
>
New Point
</button>
</Card>
{/* Display card */}
<Card className="flex min-h-[320px] flex-col items-center justify-center gap-4 p-6">
{step && (
<>
<p className="text-center text-sm font-medium text-muted">{step.label}</p>
<MathDisplay math={step.math} className="text-2xl" />
<div className="w-full max-w-md">
<CartesianGrid
points={step.points}
highlight={
step.guide && step.highlight
? { ...step.highlight, axis: step.guide === "x" ? "x" : "both" }
: undefined
}
/>
</div>
</>
)}
</Card>
{/* Controls */}
<Card className="p-4">
<StepControls
currentStep={currentStep + 1}
totalSteps={steps?.length ?? 0}
isPlaying={isPlaying}
onStepForward={stepForward}
onStepBack={stepBack}
onTogglePlay={togglePlay}
onReset={reset}
canStepForward={!!steps && !done}
canStepBack={!!steps && currentStep > 0}
/>
</Card>
{/* Quick practice */}
<ClientOnly fallback={<Card className="min-h-[220px]" />}>
<ReadPractice />
</ClientOnly>
</div>
);
}

View File

@@ -208,6 +208,78 @@ const art: Record<string, ReactNode> = {
<text x="50" y="44" fontSize="11" textAnchor="middle" dominantBaseline="middle" opacity="0.8" {...glyphText}>base 4</text>
</>
),
// Unit 5 — Integers (Order & Compare)
"unit-5-integers/order-compare": (
<>
<path d="M14 44 H86 M24 44 v-6 M44 44 v-6 M64 44 v-6 M84 44 v-6" stroke="white" strokeWidth="2.5" strokeLinecap="round" fill="none" />
<circle cx="30" cy="44" r="4" fill="white" opacity="0.6" />
<circle cx="70" cy="44" r="4" fill="white" />
<text x="50" y="24" fontSize="22" textAnchor="middle" dominantBaseline="middle" {...glyphText}>&lt;</text>
</>
),
"unit-5-integers/negative-fractions": (
<>
<text x="34" y="24" fontSize="22" textAnchor="middle" dominantBaseline="middle" {...glyphText}>&minus;</text>
<text x="52" y="18" fontSize="15" textAnchor="middle" dominantBaseline="middle" {...glyphText}>3</text>
<rect x="44" y="26" width="18" height="3" rx="1.5" fill="white" />
<text x="52" y="40" fontSize="15" textAnchor="middle" dominantBaseline="middle" {...glyphText}>4</text>
</>
),
// Unit 6 — Number System (Place Value)
"unit-6-number-system/place-value": (
<>
<text x="30" y="24" fontSize="18" textAnchor="middle" dominantBaseline="middle" {...glyphText}>3</text>
<text x="50" y="24" fontSize="18" textAnchor="middle" dominantBaseline="middle" {...glyphText}>8</text>
<text x="70" y="24" fontSize="18" textAnchor="middle" dominantBaseline="middle" {...glyphText}>5</text>
<line x1="18" y1="33" x2="82" y2="33" stroke="white" strokeWidth="1.5" opacity="0.5" />
<text x="30" y="46" fontSize="10" textAnchor="middle" dominantBaseline="middle" opacity="0.85" {...glyphText}>10&#178;</text>
<text x="50" y="46" fontSize="10" textAnchor="middle" dominantBaseline="middle" opacity="0.85" {...glyphText}>10&#185;</text>
<text x="70" y="46" fontSize="10" textAnchor="middle" dominantBaseline="middle" opacity="0.85" {...glyphText}>10&#8304;</text>
</>
),
// Unit 7 — Coordinates
"unit-7-coordinates/plot-points": (
<>
<path d="M20 12 V60 M35 12 V60 M50 12 V60 M65 12 V60 M80 12 V60 M20 12 H80 M20 24 H80 M20 36 H80 M20 48 H80 M20 60 H80" stroke="white" strokeWidth="1" opacity="0.25" fill="none" />
<path d="M50 12 V60 M20 36 H80" stroke="white" strokeWidth="2" opacity="0.9" fill="none" />
<path d="M65 24 V36 M50 24 H65" stroke="white" strokeWidth="1.5" strokeDasharray="3 2" opacity="0.75" fill="none" />
<circle cx="65" cy="24" r="4.5" fill="white" />
</>
),
"unit-7-coordinates/read-coordinates": (
<>
<path d="M50 14 V62 M20 38 H80" stroke="white" strokeWidth="2" opacity="0.9" fill="none" />
<path d="M64 26 V38 M50 26 H64" stroke="white" strokeWidth="1.5" strokeDasharray="3 2" opacity="0.6" fill="none" />
<circle cx="64" cy="26" r="4" fill="white" />
<text x="64" y="17" fontSize="11" textAnchor="middle" dominantBaseline="middle" {...glyphText}>(3, 2)</text>
</>
),
// Unit 8 — Number Properties
"unit-8-number-properties/identity-inverse": (
<>
<text x="50" y="24" fontSize="16" textAnchor="middle" dominantBaseline="middle" {...glyphText}>n + 0 = n</text>
<text x="50" y="46" fontSize="12" textAnchor="middle" dominantBaseline="middle" opacity="0.8" {...glyphText}>3 + (&minus;3) = 0</text>
</>
),
"unit-8-number-properties/number-laws": (
<>
<text x="50" y="24" fontSize="15" textAnchor="middle" dominantBaseline="middle" {...glyphText}>a + b = b + a</text>
<path d="M36 34 C42 44, 58 44, 64 34" stroke="white" strokeWidth="2" fill="none" strokeLinecap="round" />
<path d="M64 34 l-4 1 M64 34 l-1 -4" stroke="white" strokeWidth="2" fill="none" strokeLinecap="round" />
</>
),
"unit-8-number-properties/exponents": (
<>
<text x="34" y="34" fontSize="30" textAnchor="middle" dominantBaseline="middle" {...glyphText}>2</text>
<text x="50" y="22" fontSize="16" textAnchor="middle" dominantBaseline="middle" {...glyphText}>4</text>
<text x="73" y="34" fontSize="18" textAnchor="middle" dominantBaseline="middle" {...glyphText}>= 16</text>
</>
),
};
const fallback: ReactNode = (

View File

@@ -0,0 +1,74 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
/**
* Shared state + styling for a sidebar that collapses in place on large screens
* and opens as a slide-in drawer on small ones. One toggle drives both, choosing
* the right behaviour by media query at click time. Defaults: open on desktop
* (CSS `lg:` classes), closed on mobile (state) — so SSR and hydration agree.
*/
export function useCollapsibleSidebar() {
const [mobileOpen, setMobileOpen] = useState(false);
const [desktopCollapsed, setDesktopCollapsed] = useState(false);
function toggle() {
if (typeof window !== "undefined" && window.matchMedia("(min-width: 1024px)").matches) {
setDesktopCollapsed((c) => !c);
} else {
setMobileOpen((o) => !o);
}
}
return {
mobileOpen,
desktopCollapsed,
toggle,
closeMobile: () => setMobileOpen(false),
};
}
export function sidebarAsideClass(mobileOpen: boolean, desktopCollapsed: boolean) {
return cn(
"z-40 w-64 shrink-0 border-r border-border/60 bg-surface",
// Small screens: a fixed slide-in drawer sitting below the header.
"fixed bottom-0 left-0 top-[4.125rem] transition-transform duration-200",
mobileOpen ? "translate-x-0 shadow-[var(--shadow-lg)]" : "-translate-x-full",
// Large screens: part of the layout flow, collapsible in place.
"lg:static lg:z-auto lg:translate-x-0 lg:shadow-none lg:transition-none",
desktopCollapsed ? "lg:hidden" : "lg:block",
);
}
export const sidebarNavClass =
"h-full overflow-y-auto p-4 lg:sticky lg:top-[4.125rem] lg:h-[calc(100vh-4.125rem)]";
export function SidebarBackdrop({ show, onClick }: { show: boolean; onClick: () => void }) {
if (!show) return null;
return (
<div
className="fixed inset-0 z-30 bg-foreground/30 lg:hidden"
onClick={onClick}
aria-hidden="true"
/>
);
}
export function SidebarToggle({ onClick, className }: { onClick: () => void; className?: string }) {
return (
<button
onClick={onClick}
aria-label="Toggle topics menu"
className={cn(
"mb-5 inline-flex items-center gap-2 rounded-xl border border-border/60 bg-surface px-3 py-2 text-sm font-bold text-muted shadow-[var(--shadow-sm)] transition-colors hover:text-foreground",
className,
)}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
Topics
</button>
);
}

View File

@@ -9,7 +9,7 @@ const navItems = [
export function Header() {
return (
<header className="sticky top-0 z-50 border-b-4 border-[#1c4ab7] bg-[#2f65e8] text-white">
<div className="mx-auto max-w-6xl playground-frame border-b-0 border-t-0 bg-[#2f65e8] shadow-none">
<div className="mx-auto max-w-7xl playground-frame border-b-0 border-t-0 bg-[#2f65e8] shadow-none">
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<Link href="/" className="group flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-[#ffd043] text-[#10308a] shadow-md">

View File

@@ -0,0 +1,22 @@
"use client";
import type { ReactNode } from "react";
import { Sidebar } from "./sidebar";
import { useCollapsibleSidebar, SidebarBackdrop, SidebarToggle } from "./collapsible-sidebar";
export function LessonsShell({ children }: { children: ReactNode }) {
const { mobileOpen, desktopCollapsed, toggle, closeMobile } = useCollapsibleSidebar();
return (
<div className="playground-frame relative mx-auto flex w-full max-w-7xl flex-1">
<SidebarBackdrop show={mobileOpen} onClick={closeMobile} />
<Sidebar mobileOpen={mobileOpen} desktopCollapsed={desktopCollapsed} onNavigate={closeMobile} />
<main className="min-w-0 flex-1 bg-white">
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
<SidebarToggle onClick={toggle} />
{children}
</div>
</main>
</div>
);
}

View File

@@ -1,93 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { curriculum } from "@/lib/curriculum";
import { cn } from "@/lib/utils";
import { useState } from "react";
const unitColorMap = {
"unit-1": "text-unit-1-dark",
"unit-2": "text-unit-2-dark",
"unit-3": "text-unit-3-dark",
"unit-4": "text-unit-4-dark",
"unit-5": "text-unit-5-dark",
"unit-6": "text-unit-6-dark",
};
const unitDotColor = {
"unit-1": "bg-unit-1",
"unit-2": "bg-unit-2",
"unit-3": "bg-unit-3",
"unit-4": "bg-unit-4",
"unit-5": "bg-unit-5",
"unit-6": "bg-unit-6",
};
export function MobileNav() {
const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
return (
<div className="lg:hidden">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 rounded-xl border border-border/60 bg-surface px-3.5 py-2 text-sm font-medium shadow-[var(--shadow-sm)] transition-all hover:shadow-[var(--shadow-md)]"
aria-label="Toggle navigation"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
{isOpen ? (
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
)}
</svg>
Topics
</button>
{isOpen && (
<div className="absolute left-0 right-0 top-full z-40 max-h-[70vh] overflow-y-auto border-b border-border/60 bg-surface p-4 shadow-[var(--shadow-lg)]">
<Link
href="/lessons"
onClick={() => setIsOpen(false)}
className="mb-4 flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium text-muted transition-colors hover:bg-background hover:text-foreground"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
All Topics
</Link>
<div className="mb-3 h-px bg-border/60" />
{curriculum.map((unit) => (
<div key={unit.slug} className="mb-4">
<p className={cn("mb-1.5 flex items-center gap-2 px-3 text-xs font-bold uppercase tracking-wider", unitColorMap[unit.color])}>
<span className={cn("h-2 w-2 rounded-full", unitDotColor[unit.color])} />
Unit {unit.number}: {unit.title}
</p>
<div className="space-y-0.5">
{unit.topics.map((topic) => {
const href = `/lessons/${unit.slug}/${topic.slug}`;
return (
<Link
key={topic.slug}
href={href}
onClick={() => setIsOpen(false)}
className={cn(
"block rounded-lg px-3 py-1.5 text-sm transition-colors",
pathname === href
? "bg-foreground text-background font-medium"
: "text-muted hover:bg-background hover:text-foreground",
)}
>
{topic.shortTitle}
</Link>
);
})}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
import { curriculum } from "@/lib/curriculum";
import { cn } from "@/lib/utils";
import { useState } from "react";
import { sidebarAsideClass, sidebarNavClass } from "./collapsible-sidebar";
const unitColorMap = {
"unit-1": {
@@ -43,9 +44,29 @@ const unitColorMap = {
heading: "text-unit-6-dark",
hoverBg: "hover:bg-unit-6-light/50",
},
"unit-7": {
active: "bg-unit-7-light text-unit-7-dark border-unit-7/20",
dot: "bg-unit-7",
heading: "text-unit-7-dark",
hoverBg: "hover:bg-unit-7-light/50",
},
"unit-8": {
active: "bg-unit-8-light text-unit-8-dark border-unit-8/20",
dot: "bg-unit-8",
heading: "text-unit-8-dark",
hoverBg: "hover:bg-unit-8-light/50",
},
};
export function Sidebar() {
export function Sidebar({
mobileOpen = false,
desktopCollapsed = false,
onNavigate,
}: {
mobileOpen?: boolean;
desktopCollapsed?: boolean;
onNavigate?: () => void;
}) {
const pathname = usePathname();
const [openUnits, setOpenUnits] = useState<Set<number>>(() => {
const open = new Set<number>();
@@ -68,10 +89,11 @@ export function Sidebar() {
}
return (
<aside className="hidden w-64 shrink-0 border-r border-border/60 bg-surface lg:block">
<nav className="sticky top-[4.125rem] h-[calc(100vh-4.125rem)] overflow-y-auto p-4">
<aside className={sidebarAsideClass(mobileOpen, desktopCollapsed)}>
<nav className={sidebarNavClass}>
<Link
href="/lessons"
onClick={onNavigate}
className={cn(
"mb-4 flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition-all duration-200",
pathname === "/lessons"
@@ -79,9 +101,6 @@ export function Sidebar() {
: "text-muted hover:bg-background hover:text-foreground",
)}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
All Topics
</Link>
@@ -126,6 +145,7 @@ export function Sidebar() {
<Link
key={topic.slug}
href={href}
onClick={onNavigate}
className={cn(
"flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm transition-all duration-200",
isActive

View File

@@ -1,12 +1,20 @@
"use client";
import { useRef, type KeyboardEvent } from "react";
import { cn } from "@/lib/utils";
/** Keep only digits and a single leading minus sign, so junk like "1-2" can't be typed. */
function sanitizeSigned(value: string): string {
const cleaned = value.replace(/[^0-9-]/g, "");
return cleaned.replace(/(?!^)-/g, ""); // drop any minus that isn't at the start
}
interface FractionInputProps {
numerator: string;
denominator: string;
onNumeratorChange: (value: string) => void;
onDenominatorChange: (value: string) => void;
onSubmit?: () => void;
disabled?: boolean;
className?: string;
}
@@ -16,31 +24,50 @@ export function FractionInput({
denominator,
onNumeratorChange,
onDenominatorChange,
onSubmit,
disabled = false,
className,
}: FractionInputProps) {
const denominatorRef = useRef<HTMLInputElement>(null);
const inputClass =
"w-16 rounded-lg border border-border bg-surface px-2 py-1.5 text-center text-lg font-bold focus:border-unit-1 focus:outline-none";
return (
<div className={cn("inline-flex flex-col items-center gap-0.5", className)}>
<input
type="text"
inputMode="numeric"
pattern="[0-9-]*"
value={numerator}
onChange={(e) => onNumeratorChange(e.target.value)}
onChange={(e) => onNumeratorChange(sanitizeSigned(e.target.value))}
onKeyDown={(e) => {
// "/" is how pupils think of a fraction — jump to the denominator.
if (e.key === "/" || e.key === "Enter") {
if (e.key === "/") {
e.preventDefault();
denominatorRef.current?.focus();
} else {
onSubmit?.();
}
}
}}
disabled={disabled}
className="w-16 rounded-lg border border-border bg-surface px-2 py-1.5 text-center text-lg font-bold focus:border-unit-1 focus:outline-none"
className={inputClass}
placeholder="?"
aria-label="Numerator"
/>
<div className="h-0.5 w-16 bg-foreground" />
<input
ref={denominatorRef}
type="text"
inputMode="numeric"
pattern="[0-9-]*"
value={denominator}
onChange={(e) => onDenominatorChange(e.target.value)}
onChange={(e) => onDenominatorChange(sanitizeSigned(e.target.value))}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") onSubmit?.();
}}
disabled={disabled}
className="w-16 rounded-lg border border-border bg-surface px-2 py-1.5 text-center text-lg font-bold focus:border-unit-1 focus:outline-none"
className={inputClass}
placeholder="?"
aria-label="Denominator"
/>
@@ -51,17 +78,21 @@ export function FractionInput({
interface DecimalInputProps {
value: string;
onChange: (value: string) => void;
onSubmit?: () => void;
disabled?: boolean;
className?: string;
}
export function DecimalInput({ value, onChange, disabled, className }: DecimalInputProps) {
export function DecimalInput({ value, onChange, onSubmit, disabled, className }: DecimalInputProps) {
return (
<input
type="text"
inputMode="decimal"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onSubmit?.();
}}
disabled={disabled}
className={cn(
"w-28 rounded-lg border border-border bg-surface px-3 py-2 text-center text-lg font-bold focus:border-unit-2 focus:outline-none",

View File

@@ -11,6 +11,8 @@ import {
parseStrictDecimal,
parseStrictSignedDecimal,
parseStrictSignedInt,
wrong,
SOLVED,
type AnswerResult,
} from "@/lib/math/validation";
import { FractionInput, DecimalInput, RatioInput } from "./fraction-input";
@@ -22,7 +24,7 @@ import katex from "katex";
interface PracticeSectionProps {
title: string;
generator: (difficulty: Difficulty) => MathProblem;
unitColor?: "unit-1" | "unit-2" | "unit-3" | "unit-4";
unitColor?: "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5" | "unit-6" | "unit-8";
}
type UnitColor = NonNullable<PracticeSectionProps["unitColor"]>;
@@ -32,6 +34,9 @@ const activeDifficultyStyle: Record<UnitColor, string> = {
"unit-2": "bg-unit-2 text-white",
"unit-3": "bg-unit-3 text-white",
"unit-4": "bg-unit-4 text-white",
"unit-5": "bg-unit-5 text-white",
"unit-6": "bg-unit-6 text-white",
"unit-8": "bg-unit-8 text-white",
};
const RECENT_PROBLEM_LIMIT = 8;
@@ -54,7 +59,11 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
const [hintIndex, setHintIndex] = useState(0);
const [showSolution, setShowSolution] = useState(false);
const [score, setScore] = useState({ correct: 0, total: 0 });
const isFullyCorrect = result?.correct === true && result.simplified === true;
const isFullyCorrect = result?.status === "solved";
// Per-problem scoring guards: a problem is counted at most once, when solved,
// and only counts as "correct" if solved with no earlier wrong attempt or peek.
const missedCurrentRef = useRef(false);
const scoredCurrentRef = useRef(false);
const generateUniqueProblem = useCallback((diff: Difficulty): MathProblem => {
const recentProblemKeys = recentProblemKeysRef.current ?? [getProblemKey(problem)];
@@ -86,6 +95,8 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
setShowHint(false);
setHintIndex(0);
setShowSolution(false);
missedCurrentRef.current = false;
scoredCurrentRef.current = false;
}, [generateUniqueProblem]);
function checkAnswer() {
@@ -97,7 +108,7 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
const num = parseStrictSignedInt(userAnswer.numerator || "");
const den = parseStrictSignedInt(userAnswer.denominator || "");
if (num === null || den === null) {
res = { correct: false, message: "Enter valid numbers" };
res = wrong("Enter valid numbers");
} else {
res = checkFractionAnswer(num, den, answer.numerator, answer.denominator);
}
@@ -106,7 +117,7 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
case "decimal": {
const val = parseStrictSignedDecimal(userAnswer.value || "");
if (val === null) {
res = { correct: false, message: "Enter a valid number" };
res = wrong("Enter a valid number");
} else {
res = checkDecimalAnswer(val, answer.value);
}
@@ -115,7 +126,7 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
case "integer": {
const val = parseStrictSignedInt(userAnswer.value || "");
if (val === null) {
res = { correct: false, message: "Enter a valid number" };
res = wrong("Enter a valid number");
} else {
res = checkIntegerAnswer(val, answer.value);
}
@@ -124,7 +135,7 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
case "ratio": {
const parts = (userAnswer.ratio || "").split(":").map((p) => parseInt(p.trim()));
if (parts.some(isNaN)) {
res = { correct: false, message: "Enter valid ratio parts" };
res = wrong("Enter valid ratio parts");
} else {
res = checkRatioAnswer(parts, answer.parts);
}
@@ -134,27 +145,44 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
const coeff = parseStrictDecimal(userAnswer.coefficient || "");
const exp = parseStrictSignedInt(userAnswer.exponent || "");
if (coeff === null || exp === null) {
res = { correct: false, message: "Enter valid numbers" };
res = wrong("Enter valid numbers");
} else if (
Math.abs(coeff - answer.coefficient) < 0.01 &&
exp === answer.exponent
) {
res = { correct: true, simplified: true };
res = SOLVED;
} else {
res = { correct: false, message: "That's not quite right. Try again!" };
res = wrong("That's not quite right. Try again!");
}
break;
}
}
setResult(res);
if (res.correct) {
setScore((s) => ({ correct: s.correct + 1, total: s.total + 1 }));
} else {
setScore((s) => ({ ...s, total: s.total + 1 }));
// Score each problem exactly once, when it is solved. A wrong attempt (or an
// "unsimplified" nudge that never gets finished) never scores on its own; it
// just marks the current problem as missed so the eventual solve counts as
// incorrect. This makes the score "problems solved / first-time right" and
// makes double-counting impossible.
if (res.status === "wrong") {
missedCurrentRef.current = true;
} else if (res.status === "solved" && !scoredCurrentRef.current) {
scoredCurrentRef.current = true;
const gotFirstTime = !missedCurrentRef.current;
setScore((s) => ({
correct: s.correct + (gotFirstTime ? 1 : 0),
total: s.total + 1,
}));
}
}
// Enter key: check the answer, or move to the next problem once it's solved.
function submit() {
if (isFullyCorrect) generateNew(difficulty);
else checkAnswer();
}
function nextHint() {
if (!showHint) {
setShowHint(true);
@@ -210,6 +238,7 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
denominator={userAnswer.denominator || ""}
onNumeratorChange={(v) => setUserAnswer((a) => ({ ...a, numerator: v }))}
onDenominatorChange={(v) => setUserAnswer((a) => ({ ...a, denominator: v }))}
onSubmit={submit}
disabled={isFullyCorrect}
/>
)}
@@ -218,6 +247,7 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
<DecimalInput
value={userAnswer.value || ""}
onChange={(v) => setUserAnswer((a) => ({ ...a, value: v }))}
onSubmit={submit}
disabled={isFullyCorrect}
/>
)}
@@ -284,7 +314,14 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
<Button variant="secondary" size="sm" onClick={nextHint}>
Hint
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowSolution(true)}>
<Button
variant="ghost"
size="sm"
onClick={() => {
missedCurrentRef.current = true;
setShowSolution(true);
}}
>
Show Solution
</Button>
</>
@@ -304,17 +341,17 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className={`rounded-xl px-4 py-3 text-center text-sm font-medium ${
result.correct
? result.simplified
result.status === "solved"
? "bg-correct-light text-correct"
: "bg-hint-light text-hint"
: result.status === "unsimplified"
? "bg-hint-light text-hint"
: "bg-incorrect-light text-incorrect"
}`}
>
{result.correct
? result.simplified
{result.status === "solved"
? "Correct!"
: "Correct, but can you simplify further?"
: result.status === "unsimplified"
? "Correct, but can you simplify further?"
: result.message}
</motion.div>
)}

View File

@@ -1,7 +1,7 @@
import { cn } from "@/lib/utils";
import { type HTMLAttributes } from "react";
type BadgeVariant = "default" | "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5" | "unit-6";
type BadgeVariant = "default" | "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5" | "unit-6" | "unit-7" | "unit-8";
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
variant?: BadgeVariant;
@@ -15,6 +15,8 @@ const variantStyles: Record<BadgeVariant, string> = {
"unit-4": "border border-unit-4/20 bg-unit-4-light text-unit-4-dark",
"unit-5": "border border-unit-5/20 bg-unit-5-light text-unit-5-dark",
"unit-6": "border border-unit-6/20 bg-unit-6-light text-unit-6-dark",
"unit-7": "border border-unit-7/20 bg-unit-7-light text-unit-7-dark",
"unit-8": "border border-unit-8/20 bg-unit-8-light text-unit-8-dark",
};
export function Badge({ variant = "default", className, children, ...props }: BadgeProps) {

View File

@@ -1,7 +1,7 @@
import { cn } from "@/lib/utils";
import { type ButtonHTMLAttributes } from "react";
type ButtonVariant = "primary" | "secondary" | "ghost" | "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5";
type ButtonVariant = "primary" | "secondary" | "ghost" | "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5" | "unit-6" | "unit-7" | "unit-8";
type ButtonSize = "sm" | "md" | "lg";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
@@ -18,6 +18,9 @@ const variantStyles: Record<ButtonVariant, string> = {
"unit-3": "bg-unit-3 text-white shadow-[var(--shadow-sm)] hover:bg-unit-3-dark hover:shadow-[var(--shadow-lg)]",
"unit-4": "bg-unit-4 text-white shadow-[var(--shadow-sm)] hover:bg-unit-4-dark hover:shadow-[var(--shadow-lg)]",
"unit-5": "bg-unit-5 text-white shadow-[var(--shadow-sm)] hover:bg-unit-5-dark hover:shadow-[var(--shadow-lg)]",
"unit-6": "bg-unit-6 text-white shadow-[var(--shadow-sm)] hover:bg-unit-6-dark hover:shadow-[var(--shadow-lg)]",
"unit-7": "bg-unit-7 text-white shadow-[var(--shadow-sm)] hover:bg-unit-7-dark hover:shadow-[var(--shadow-lg)]",
"unit-8": "bg-unit-8 text-white shadow-[var(--shadow-sm)] hover:bg-unit-8-dark hover:shadow-[var(--shadow-lg)]",
};
const sizeStyles: Record<ButtonSize, string> = {

View File

@@ -2,7 +2,7 @@ import { cn } from "@/lib/utils";
import { type HTMLAttributes } from "react";
interface CardProps extends HTMLAttributes<HTMLDivElement> {
accent?: "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5" | "unit-6";
accent?: "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5" | "unit-6" | "unit-7" | "unit-8";
hover?: boolean;
}
@@ -13,6 +13,8 @@ const accentStyles = {
"unit-4": "border-l-4 border-l-unit-4",
"unit-5": "border-l-4 border-l-unit-5",
"unit-6": "border-l-4 border-l-unit-6",
"unit-7": "border-l-4 border-l-unit-7",
"unit-8": "border-l-4 border-l-unit-8",
};
export function Card({

View File

@@ -0,0 +1,29 @@
"use client";
import { useSyncExternalStore, type ReactNode } from "react";
const emptySubscribe = () => () => {};
/**
* Renders its children only after hydration on the client. Use this to wrap UI
* whose initial render is non-deterministic (e.g. seeded with Math.random()),
* which would otherwise cause a server/client hydration mismatch. The
* `fallback` is shown during SSR and the first (hydrating) client render.
*
* Uses useSyncExternalStore rather than a mount effect so there is no
* setState-in-effect and the server/client snapshots stay explicit.
*/
export function ClientOnly({
children,
fallback = null,
}: {
children: ReactNode;
fallback?: ReactNode;
}) {
const isServer = useSyncExternalStore(
emptySubscribe,
() => false,
() => true,
);
return <>{isServer ? fallback : children}</>;
}

View File

@@ -6,11 +6,11 @@ export interface Topic {
}
export interface Unit {
number: 1 | 2 | 3 | 4 | 5 | 6;
number: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
slug: string;
title: string;
description: string;
color: "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5" | "unit-6";
color: "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5" | "unit-6" | "unit-7" | "unit-8";
topics: Topic[];
}
@@ -160,6 +160,12 @@ export const curriculum: Unit[] = [
description: "Add, subtract, multiply, divide integers and apply to real-world problems",
color: "unit-5",
topics: [
{
slug: "order-compare",
title: "Order and Compare Integers",
shortTitle: "Order & Compare",
description: "Place integers on a number line, order them, and compare with < and >",
},
{
slug: "add-subtract",
title: "Add and Subtract Integers",
@@ -172,6 +178,12 @@ export const curriculum: Unit[] = [
shortTitle: "Multiply & Divide",
description: "Sign rules for multiplication and division with real-world thermometer problems",
},
{
slug: "negative-fractions",
title: "Negative Fractions",
shortTitle: "Negative Fractions",
description: "Apply the sign rules to add, subtract, multiply and divide fractions with negative signs",
},
],
},
{
@@ -181,6 +193,12 @@ export const curriculum: Unit[] = [
description: "Explore how numbers can be written in different bases, beyond the everyday denary system",
color: "unit-6",
topics: [
{
slug: "place-value",
title: "Denary Place Value (Base 10)",
shortTitle: "Place Value",
description: "Powers of 10 and writing numbers in expanded form",
},
{
slug: "binary",
title: "Binary Numbers",
@@ -195,6 +213,54 @@ export const curriculum: Unit[] = [
},
],
},
{
number: 7,
slug: "unit-7-coordinates",
title: "Coordinates",
description: "Plot points and read coordinates on the Cartesian plane",
color: "unit-7",
topics: [
{
slug: "plot-points",
title: "Plot Points on the Cartesian Plane",
shortTitle: "Plot Points",
description: "Use x and y coordinates to place points and reveal hidden shapes",
},
{
slug: "read-coordinates",
title: "State Coordinates of Points",
shortTitle: "Read Coordinates",
description: "Read off the (x, y) coordinates of points in all four quadrants",
},
],
},
{
number: 8,
slug: "unit-8-number-properties",
title: "Number Properties",
description: "Identity and inverse, the laws of arithmetic, and powers of numbers",
color: "unit-8",
topics: [
{
slug: "identity-inverse",
title: "Identity and Inverse",
shortTitle: "Identity & Inverse",
description: "Identity for addition and multiplication, inverses, and the zero facts",
},
{
slug: "number-laws",
title: "Commutative, Associative and Distributive Laws",
shortTitle: "Number Laws",
description: "Which operations can be swapped, regrouped, and expanded",
},
{
slug: "exponents",
title: "Powers and Exponents",
shortTitle: "Exponents",
description: "Base and power, expanding powers, and the index laws",
},
],
},
];
export function getUnit(slug: string): Unit | undefined {
@@ -206,7 +272,7 @@ export function getTopic(unitSlug: string, topicSlug: string): Topic | undefined
return unit?.topics.find((t) => t.slug === topicSlug);
}
export function getUnitColor(unitNumber: 1 | 2 | 3 | 4 | 5 | 6): string {
export function getUnitColor(unitNumber: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8): string {
const colors = {
1: "unit-1",
2: "unit-2",
@@ -214,6 +280,8 @@ export function getUnitColor(unitNumber: 1 | 2 | 3 | 4 | 5 | 6): string {
4: "unit-4",
5: "unit-5",
6: "unit-6",
7: "unit-7",
8: "unit-8",
};
return colors[unitNumber];
}

View File

@@ -0,0 +1,94 @@
import { describe, it, expect } from "vitest";
import { gcd, lcm, Fraction } from "./fractions";
const f = (n: number, d: number) => new Fraction(n, d);
describe("gcd / lcm", () => {
it("computes gcd", () => {
expect(gcd(12, 8)).toBe(4);
expect(gcd(7, 3)).toBe(1);
expect(gcd(0, 5)).toBe(5);
expect(gcd(-12, 8)).toBe(4);
});
it("computes lcm", () => {
expect(lcm(4, 6)).toBe(12);
expect(lcm(3, 5)).toBe(15);
});
});
describe("Fraction sign + simplify", () => {
it("keeps the sign on the numerator", () => {
expect([f(2, -4).n, f(2, -4).d]).toEqual([-2, 4]);
expect([f(-2, -4).n, f(-2, -4).d]).toEqual([2, 4]);
});
it("reduces to lowest terms", () => {
const check = (n: number, d: number, en: number, ed: number) => {
const s = f(n, d).simplified();
expect([s.n, s.d]).toEqual([en, ed]);
};
check(5, 10, 1, 2);
check(8, 12, 2, 3);
check(3, 1, 3, 1);
check(2, -4, -1, 2);
check(-2, -4, 1, 2);
check(0, 5, 0, 1);
});
it("reports whether it is already simplified", () => {
expect(f(1, 2).isSimplified).toBe(true);
expect(f(5, 10).isSimplified).toBe(false);
});
});
describe("Fraction arithmetic (raw, then .simplified())", () => {
const s = (fr: Fraction) => [fr.simplified().n, fr.simplified().d];
it("adds", () => {
expect(s(f(1, 2).plus(f(1, 2)))).toEqual([1, 1]);
expect(s(f(2, 10).plus(f(3, 10)))).toEqual([1, 2]);
expect(s(f(1, 3).plus(f(1, 4)))).toEqual([7, 12]);
});
it("subtracts", () => {
expect(s(f(7, 8).minus(f(6, 8)))).toEqual([1, 8]);
expect(s(f(1, 2).minus(f(1, 2)))).toEqual([0, 1]);
});
it("multiplies", () => {
expect(s(f(2, 3).times(f(3, 4)))).toEqual([1, 2]);
expect(s(f(1, 2).times(f(1, 2)))).toEqual([1, 4]);
});
it("divides (invert and multiply)", () => {
expect(s(f(1, 2).dividedBy(f(1, 4)))).toEqual([2, 1]);
expect(s(f(3, 4).dividedBy(f(1, 2)))).toEqual([3, 2]);
});
it("handles negative fractions with the sign rules", () => {
expect(s(f(-1, 2).plus(f(3, 4)))).toEqual([1, 4]); // -1/2 + 3/4
expect(s(f(1, 2).minus(f(-3, 4)))).toEqual([5, 4]); // subtracting a negative adds
expect(s(f(-2, 3).times(f(1, 4)))).toEqual([-1, 6]); // different signs -> negative
expect(s(f(-2, 3).times(f(-3, 5)))).toEqual([2, 5]); // same signs -> positive
expect(s(f(-1, 2).dividedBy(f(1, 4)))).toEqual([-2, 1]); // -1/2 ÷ 1/4
});
});
describe("Fraction presentation + equality", () => {
it("renders KaTeX, collapsing whole numbers", () => {
expect(f(1, 2).toKatex()).toBe("\\frac{1}{2}");
expect(f(3, 1).toKatex()).toBe("3");
});
it("renders signed KaTeX with the sign in front", () => {
expect(f(-3, 4).toSignedKatex()).toBe("-\\frac{3}{4}");
expect(f(3, 4).toSignedKatex()).toBe("\\frac{3}{4}");
expect(f(-5, 1).toSignedKatex()).toBe("-5");
});
it("compares by value, not written form", () => {
expect(f(5, 10).equals(f(1, 2))).toBe(true);
expect(f(1, 2).equals(f(1, 3))).toBe(false);
});
});

View File

@@ -11,97 +11,100 @@ export function lcm(a: number, b: number): number {
return Math.abs(a * b) / gcd(a, b);
}
export function simplify(num: number, den: number): [number, number] {
if (den === 0) return [num, den];
const g = gcd(Math.abs(num), Math.abs(den));
const sign = den < 0 ? -1 : 1;
return [(num / g) * sign, (den / g) * sign];
/**
* An immutable fraction. Sign is kept on the numerator; arithmetic returns a new
* Fraction and does NOT auto-simplify — call `.simplified()` when you want lowest
* terms (this lets teaching views show the un-reduced working). Prefer this over
* passing `(num, den)` pairs around: positional pairs are easy to mis-order.
*/
export class Fraction {
readonly n: number;
readonly d: number;
constructor(n: number, d: number) {
// Canonical sign: keep it on the numerator. A zero denominator is tolerated
// (kept as-is) so divide-by-zero in a lesson renders rather than throwing.
if (d < 0) {
n = -n;
d = -d;
}
this.n = n;
this.d = d;
}
export function add(
n1: number, d1: number,
n2: number, d2: number,
): [number, number] {
const num = n1 * d2 + n2 * d1;
const den = d1 * d2;
return simplify(num, den);
static of(n: number, d: number): Fraction {
return new Fraction(n, d);
}
export function subtract(
n1: number, d1: number,
n2: number, d2: number,
): [number, number] {
const num = n1 * d2 - n2 * d1;
const den = d1 * d2;
return simplify(num, den);
get isZero(): boolean {
return this.n === 0;
}
export function multiply(
n1: number, d1: number,
n2: number, d2: number,
): [number, number] {
return simplify(n1 * n2, d1 * d2);
plus(o: Fraction): Fraction {
return new Fraction(this.n * o.d + o.n * this.d, this.d * o.d);
}
export function divide(
n1: number, d1: number,
n2: number, d2: number,
): [number, number] {
return simplify(n1 * d2, d1 * n2);
minus(o: Fraction): Fraction {
return new Fraction(this.n * o.d - o.n * this.d, this.d * o.d);
}
export function toDecimal(num: number, den: number): number {
return num / den;
times(o: Fraction): Fraction {
return new Fraction(this.n * o.n, this.d * o.d);
}
export function fromDecimal(decimal: number, precision: number = 6): [number, number] {
const str = decimal.toFixed(precision);
const parts = str.split(".");
if (!parts[1]) return [parseInt(parts[0]), 1];
const den = Math.pow(10, parts[1].length);
const num = Math.round(decimal * den);
return simplify(num, den);
dividedBy(o: Fraction): Fraction {
return new Fraction(this.n * o.d, this.d * o.n);
}
export function compare(
n1: number, d1: number,
n2: number, d2: number,
): -1 | 0 | 1 {
const diff = n1 * d2 - n2 * d1;
if (diff > 0) return 1;
if (diff < 0) return -1;
return 0;
simplified(): Fraction {
if (this.d === 0) return this;
if (this.n === 0) return new Fraction(0, 1);
const g = gcd(Math.abs(this.n), Math.abs(this.d));
return new Fraction(this.n / g, this.d / g);
}
export function fractionOfQuantity(num: number, den: number, quantity: number): number {
return (num / den) * quantity;
get isSimplified(): boolean {
return gcd(Math.abs(this.n), Math.abs(this.d)) === 1;
}
export function wholeFromFraction(num: number, den: number, part: number): number {
return (part * den) / num;
/** Value equality (cross-multiplication), independent of the written form. */
equals(o: Fraction): boolean {
return this.n * o.d === o.n * this.d;
}
export function isProper(num: number, den: number): boolean {
return Math.abs(num) < Math.abs(den);
toDecimal(): number {
return this.n / this.d;
}
export function toMixed(num: number, den: number): [number, number, number] {
const whole = Math.floor(Math.abs(num) / Math.abs(den));
const remainder = Math.abs(num) % Math.abs(den);
const sign = (num < 0) !== (den < 0) ? -1 : 1;
return [whole * sign, remainder, Math.abs(den)];
/** [whole, remainderNumerator, denominator] */
toMixed(): [number, number, number] {
const whole = Math.trunc(this.n / this.d);
const remainder = Math.abs(this.n % this.d);
return [whole, remainder, this.d];
}
export function fromMixed(whole: number, num: number, den: number): [number, number] {
const sign = whole < 0 ? -1 : 1;
return [sign * (Math.abs(whole) * den + num), den];
toKatex(): string {
if (this.d === 1) return `${this.n}`;
return `\\frac{${this.n}}{${this.d}}`;
}
export function isSimplified(num: number, den: number): boolean {
return gcd(Math.abs(num), Math.abs(den)) === 1;
/** Like toKatex, but writes negatives with the sign in front: -\frac{3}{4} rather than \frac{-3}{4}. */
toSignedKatex(): string {
if (this.d === 1) return `${this.n}`;
const sign = this.n < 0 ? "-" : "";
return `${sign}\\frac{${Math.abs(this.n)}}{${this.d}}`;
}
export function toKatex(num: number, den: number): string {
if (den === 1) return `${num}`;
return `\\frac{${num}}{${den}}`;
toString(): string {
return this.d === 1 ? `${this.n}` : `${this.n}/${this.d}`;
}
}
/**
* Convenience KaTeX formatter for raw (num, den) pairs. Handy in the step-by-step
* lesson views, which build up strings from loose numerators/denominators rather
* than Fraction objects. For arithmetic, use the Fraction class instead.
*/
export function toKatex(n: number, d: number): string {
return new Fraction(n, d).toKatex();
}

View File

@@ -0,0 +1,73 @@
import { describe, it, expect } from "vitest";
import {
checkFractionAnswer,
checkIntegerAnswer,
checkDecimalAnswer,
checkRatioAnswer,
checkOrderingAnswer,
} from "./validation";
describe("checkIntegerAnswer", () => {
it("solves an exact match", () => {
expect(checkIntegerAnswer(6, 6).status).toBe("solved");
});
it("rejects a wrong value", () => {
expect(checkIntegerAnswer(5, 6).status).toBe("wrong");
});
});
describe("checkFractionAnswer", () => {
it("solves a fully simplified answer", () => {
expect(checkFractionAnswer(1, 2, 1, 2).status).toBe("solved");
});
// Regression: 5/10 is the right value but unsimplified — it must NOT be treated
// as fully solved (this is what let the score double-count before the fix).
it("flags a correct but unsimplified answer", () => {
expect(checkFractionAnswer(5, 10, 1, 2).status).toBe("unsimplified");
});
it("accepts any non-zero denominator for zero", () => {
expect(checkFractionAnswer(0, 5, 0, 1).status).toBe("solved");
});
it("rejects a wrong value", () => {
expect(checkFractionAnswer(3, 4, 1, 8).status).toBe("wrong");
});
it("rejects a zero denominator", () => {
const res = checkFractionAnswer(1, 0, 1, 2);
expect(res.status).toBe("wrong");
if (res.status === "wrong") expect(res.message).toMatch(/denominator/i);
});
});
describe("checkDecimalAnswer", () => {
it("solves within tolerance", () => {
expect(checkDecimalAnswer(3.14, 3.14).status).toBe("solved");
});
it("rejects outside tolerance", () => {
expect(checkDecimalAnswer(3.2, 3.14).status).toBe("wrong");
});
});
describe("checkRatioAnswer", () => {
it("solves an already-simplified ratio", () => {
expect(checkRatioAnswer([1, 2], [1, 2]).status).toBe("solved");
});
it("flags an equivalent but unsimplified ratio", () => {
expect(checkRatioAnswer([2, 4], [1, 2]).status).toBe("unsimplified");
});
it("rejects a non-equivalent ratio", () => {
expect(checkRatioAnswer([1, 3], [1, 2]).status).toBe("wrong");
});
});
describe("checkOrderingAnswer", () => {
it("solves a correct ascending order", () => {
expect(checkOrderingAnswer([-10, -8, -6, -4, -3, 5], [-10, -8, -6, -4, -3, 5]).status).toBe("solved");
});
it("rejects a wrong order", () => {
expect(checkOrderingAnswer([5, -10], [-10, 5]).status).toBe("wrong");
});
});

View File

@@ -1,4 +1,4 @@
import { gcd } from "./fractions";
import { Fraction } from "./fractions";
import { simplifyRatio } from "./ratios";
/** Parse a strict decimal string (digits and optional single dot). Rejects scientific notation, trailing letters, etc. */
@@ -29,9 +29,25 @@ export function parseStrictSignedDecimal(input: string): number | null {
return parseFloat(s);
}
/**
* The outcome of checking one submitted answer.
* - `solved` — correct and in the required form (fully done)
* - `unsimplified` — correct value, but still needs simplifying (a nudge, not done)
* - `wrong` — incorrect; `message` explains why
*
* A single `status` field means callers branch on one thing, instead of an
* error-prone `correct`/`simplified` boolean pair.
*/
export type AnswerResult =
| { correct: true; simplified: boolean }
| { correct: false; message: string };
| { status: "solved" }
| { status: "unsimplified" }
| { status: "wrong"; message: string };
export const SOLVED: AnswerResult = { status: "solved" };
export const UNSIMPLIFIED: AnswerResult = { status: "unsimplified" };
export function wrong(message: string): AnswerResult {
return { status: "wrong", message };
}
export function checkFractionAnswer(
userNum: number,
@@ -41,28 +57,26 @@ export function checkFractionAnswer(
requireSimplified: boolean = true,
): AnswerResult {
if (userDen === 0) {
return { correct: false, message: "Denominator cannot be zero" };
return wrong("Denominator cannot be zero");
}
// Check mathematical equivalence
const isEquivalent = userNum * expectedDen === expectedNum * userDen;
const user = new Fraction(userNum, userDen);
const expected = new Fraction(expectedNum, expectedDen);
if (!isEquivalent) {
return { correct: false, message: "That's not quite right. Try again!" };
if (!user.equals(expected)) {
return wrong("That's not quite right. Try again!");
}
// Any non-zero denominator gives the same value for zero, so don't force 0/1 in the UI.
if (userNum === 0) {
return { correct: true, simplified: true };
if (user.isZero) {
return SOLVED;
}
const isSimplified = gcd(Math.abs(userNum), Math.abs(userDen)) === 1;
if (requireSimplified && !isSimplified) {
return { correct: true, simplified: false };
if (requireSimplified && !user.isSimplified) {
return UNSIMPLIFIED;
}
return { correct: true, simplified: true };
return SOLVED;
}
export function checkDecimalAnswer(
@@ -71,9 +85,9 @@ export function checkDecimalAnswer(
tolerance: number = 0.001,
): AnswerResult {
if (Math.abs(userValue - expectedValue) <= tolerance) {
return { correct: true, simplified: true };
return SOLVED;
}
return { correct: false, message: "That's not quite right. Try again!" };
return wrong("That's not quite right. Try again!");
}
export function checkRatioAnswer(
@@ -82,34 +96,30 @@ export function checkRatioAnswer(
requireSimplified: boolean = true,
): AnswerResult {
if (userParts.length !== expectedParts.length) {
return { correct: false, message: "Check the number of parts in your ratio" };
return wrong("Check the number of parts in your ratio");
}
const userSimplified = simplifyRatio(userParts);
const expectedSimplified = simplifyRatio(expectedParts);
const isEquivalent = userSimplified.every((v, i) => v === expectedSimplified[i]);
if (!isEquivalent) {
return { correct: false, message: "That's not quite right. Try again!" };
return wrong("That's not quite right. Try again!");
}
if (requireSimplified) {
const isAlreadySimplified = userParts.every((v, i) => v === userSimplified[i]);
return { correct: true, simplified: isAlreadySimplified };
return isAlreadySimplified ? SOLVED : UNSIMPLIFIED;
}
return { correct: true, simplified: true };
return SOLVED;
}
export function checkIntegerAnswer(
userValue: number,
expectedValue: number,
): AnswerResult {
if (userValue === expectedValue) {
return { correct: true, simplified: true };
}
return { correct: false, message: "That's not quite right. Try again!" };
return userValue === expectedValue ? SOLVED : wrong("That's not quite right. Try again!");
}
export function checkOrderingAnswer(
@@ -117,11 +127,8 @@ export function checkOrderingAnswer(
expectedOrder: number[],
): AnswerResult {
if (userOrder.length !== expectedOrder.length) {
return { correct: false, message: "Make sure you've ordered all the numbers" };
return wrong("Make sure you've ordered all the numbers");
}
const isCorrect = userOrder.every((v, i) => v === expectedOrder[i]);
if (isCorrect) {
return { correct: true, simplified: true };
}
return { correct: false, message: "Check your ordering. Try again!" };
return isCorrect ? SOLVED : wrong("Check your ordering. Try again!");
}

View File

@@ -1,6 +1,6 @@
import type { MathProblem, Difficulty } from "../types";
import { randomInt, randomChoice } from "@/lib/utils";
import * as frac from "@/lib/math/fractions";
import { Fraction, lcm } from "@/lib/math/fractions";
let counter = 0;
function nextId() {
@@ -35,13 +35,13 @@ export function generateFractionAddSubtract(difficulty: Difficulty): MathProblem
}
const prompt = `\\frac{${n1}}{${d1}} ${op} \\frac{${n2}}{${d2}}`;
const commonDen = frac.lcm(d1, d2);
const commonDen = lcm(d1, d2);
const convertedN1 = n1 * (commonDen / d1);
const convertedN2 = n2 * (commonDen / d2);
const rawNum = operation === "add"
? convertedN1 + convertedN2
: convertedN1 - convertedN2;
const [ansNum, ansDen] = frac.simplify(rawNum, commonDen);
const { n: ansNum, d: ansDen } = new Fraction(rawNum, commonDen).simplified();
const steps = d1 === d2
? [
@@ -100,7 +100,7 @@ export function generateFractionMultiply(difficulty: Difficulty): MathProblem {
const n1 = randomInt(1, d1 - 1);
const n2 = randomInt(1, d2 - 1);
const [ansNum, ansDen] = frac.multiply(n1, d1, n2, d2);
const { n: ansNum, d: ansDen } = Fraction.of(n1, d1).times(Fraction.of(n2, d2)).simplified();
return {
id: nextId(),
@@ -126,7 +126,7 @@ export function generateFractionDivide(difficulty: Difficulty): MathProblem {
const n1 = randomInt(1, d1 - 1);
const n2 = randomInt(1, d2 - 1);
const [ansNum, ansDen] = frac.divide(n1, d1, n2, d2);
const { n: ansNum, d: ansDen } = Fraction.of(n1, d1).dividedBy(Fraction.of(n2, d2)).simplified();
return {
id: nextId(),
@@ -168,6 +168,150 @@ export function generateFractionOfQuantity(difficulty: Difficulty): MathProblem
};
}
/** A proper-fraction numerator with a random sign, magnitude 1..den-1. */
function signedProperNum(den: number): number {
const mag = randomInt(1, Math.max(1, den - 1));
return Math.random() < 0.5 ? -mag : mag;
}
/** Two proper fractions where at least one is negative (this is the negative-fractions topic). */
function signedFractionPair(d1: number, d2: number): [Fraction, Fraction] {
let n1 = signedProperNum(d1);
let n2 = signedProperNum(d2);
if (n1 > 0 && n2 > 0) {
if (Math.random() < 0.5) n1 = -n1;
else n2 = -n2;
}
return [new Fraction(n1, d1), new Fraction(n2, d2)];
}
function signedOperand(f: Fraction): string {
return f.n < 0 ? `\\left(${f.toSignedKatex()}\\right)` : f.toSignedKatex();
}
export function generateSignedFractionAddSubtract(difficulty: Difficulty): MathProblem {
const operation = randomChoice(["+", "-"] as const);
const dens = difficulty === 1 ? [2, 3, 4] : difficulty === 2 ? [2, 3, 4, 5, 6] : [3, 4, 5, 6, 8];
const d1 = randomChoice(dens);
const d2 = randomChoice(dens);
const [a, b] = signedFractionPair(d1, d2);
const result = (operation === "+" ? a.plus(b) : a.minus(b)).simplified();
return {
id: nextId(),
prompt: `${a.toSignedKatex()} ${operation} ${signedOperand(b)}`,
answer: { kind: "fraction", numerator: result.n, denominator: result.d },
hints: [
d1 === d2 ? "The denominators already match." : "Find a common denominator first.",
"Combine the numerators using the integer sign rules (subtracting a negative adds).",
"Simplify your answer.",
],
steps: [
{
explanation: d1 === d2 ? `Keep the denominator ${d1}.` : `Use a common denominator: LCM(${d1}, ${d2}) = ${lcm(d1, d2)}.`,
},
{ explanation: `${operation === "+" ? "Add" : "Subtract"} the numerators, watching the signs.` },
{ explanation: "Simplify to lowest terms.", math: result.toSignedKatex() },
],
};
}
export function generateSignedFractionMultiplyDivide(difficulty: Difficulty): MathProblem {
const operation = randomChoice(["\\times", "\\div"] as const);
const dens = difficulty === 1 ? [2, 3, 4] : difficulty === 2 ? [2, 3, 4, 5] : [3, 4, 5, 6, 7];
const d1 = randomChoice(dens);
const d2 = randomChoice(dens);
const [a, b] = signedFractionPair(d1, d2);
const result = (operation === "\\times" ? a.times(b) : a.dividedBy(b)).simplified();
const sameSign = a.n < 0 === b.n < 0;
return {
id: nextId(),
prompt: `${a.toSignedKatex()} ${operation} ${signedOperand(b)}`,
answer: { kind: "fraction", numerator: result.n, denominator: result.d },
hints: [
operation === "\\div" ? "Keep, change, flip: multiply by the reciprocal." : "Multiply numerators and denominators.",
`The signs are ${sameSign ? "the same, so the answer is positive" : "different, so the answer is negative"}.`,
"Simplify your answer.",
],
steps: [
operation === "\\div"
? { explanation: "Multiply by the reciprocal of the second fraction (keep, change, flip)." }
: { explanation: "Multiply the numerators and multiply the denominators." },
{ explanation: `Same signs give positive, different signs give negative — here the answer is ${result.n < 0 ? "negative" : "positive"}.` },
{ explanation: "Simplify to lowest terms.", math: result.toSignedKatex() },
],
};
}
export function generateFractionBodmas(difficulty: Difficulty): MathProblem {
const dens = difficulty === 1 ? [2, 3, 4] : difficulty === 2 ? [2, 3, 4, 5] : [2, 3, 4, 5, 6];
const mk = () => {
const d = randomChoice(dens);
return new Fraction(randomInt(1, d - 1), d);
};
const f1 = mk(), f2 = mk(), f3 = mk();
const multFirst = Math.random() < 0.5;
let prompt: string;
let result: Fraction;
if (multFirst) {
const prod = f2.times(f3);
// avoid a negative answer in this (positive-fractions) unit
const op = f1.toDecimal() >= prod.toDecimal() && Math.random() < 0.5 ? "-" : "+";
result = (op === "+" ? f1.plus(prod) : f1.minus(prod)).simplified();
prompt = `${f1.toKatex()} ${op} ${f2.toKatex()} \\times ${f3.toKatex()}`;
} else {
const prod = f1.times(f2);
const op = prod.toDecimal() >= f3.toDecimal() && Math.random() < 0.5 ? "-" : "+";
result = (op === "+" ? prod.plus(f3) : prod.minus(f3)).simplified();
prompt = `${f1.toKatex()} \\times ${f2.toKatex()} ${op} ${f3.toKatex()}`;
}
return {
id: nextId(),
prompt,
answer: { kind: "fraction", numerator: result.n, denominator: result.d },
hints: [
"BODMAS: do the multiplication before the addition or subtraction.",
"Multiply the two fractions first, then combine with a common denominator.",
],
steps: [
{ explanation: "Multiply first (that's the B-O-D-M before A-S)." },
{ explanation: "Then add or subtract, using a common denominator." },
{ explanation: "Simplify.", math: result.toKatex() },
],
};
}
/** Combined signed-fraction practice covering +, -, x and / (Unit 5 topic). */
export function generateSignedFraction(difficulty: Difficulty): MathProblem {
const operation = randomChoice(["+", "-", "\\times", "\\div"] as const);
const dens = difficulty === 1 ? [2, 3, 4] : difficulty === 2 ? [2, 3, 4, 5] : [3, 4, 5, 6];
const [a, b] = signedFractionPair(randomChoice(dens), randomChoice(dens));
let result: Fraction;
if (operation === "+") result = a.plus(b);
else if (operation === "-") result = a.minus(b);
else if (operation === "\\times") result = a.times(b);
else result = a.dividedBy(b);
result = result.simplified();
return {
id: nextId(),
prompt: `${a.toSignedKatex()} ${operation} ${signedOperand(b)}`,
answer: { kind: "fraction", numerator: result.n, denominator: result.d },
hints: [
"Apply the integer sign rules as you work.",
"Give your answer in its simplest form.",
],
steps: [
{ explanation: "Work through the operation, watching the signs." },
{ explanation: "Simplify.", math: result.toSignedKatex() },
],
};
}
export function generateWholeFromFraction(difficulty: Difficulty): MathProblem {
const denChoices = difficulty === 1 ? [2, 3, 4, 5] : difficulty === 2 ? [3, 5, 6, 8] : [7, 8, 9, 12];
const den = randomChoice(denChoices);

View File

@@ -0,0 +1,72 @@
import { describe, it, expect } from "vitest";
import type { Difficulty, MathProblem } from "../types";
import { generateFractionBodmas, generateSignedFraction } from "./fraction-problems";
import { generateDefineRatio, generateFractionsAndRatios } from "./ratio-problems";
import { generateIntegerOrderCompare, generateIntegerAddSubtractMixed } from "./integer-problems";
import { generateQuaternaryToDenary } from "./number-system-problems";
function assertWellFormed(p: MathProblem) {
expect(typeof p.prompt).toBe("string");
expect(p.prompt.length).toBeGreaterThan(0);
expect(Array.isArray(p.hints)).toBe(true);
expect(Array.isArray(p.steps)).toBe(true);
const a = p.answer;
if (a.kind === "integer") {
expect(Number.isFinite(a.value)).toBe(true);
} else if (a.kind === "fraction") {
expect(Number.isInteger(a.numerator)).toBe(true);
expect(Number.isInteger(a.denominator)).toBe(true);
expect(a.denominator).not.toBe(0);
} else if (a.kind === "ratio") {
expect(a.parts.length).toBeGreaterThan(0);
a.parts.forEach((v) => expect(Number.isFinite(v)).toBe(true));
}
}
const NEW_GENERATORS: Record<string, (d: Difficulty) => MathProblem> = {
"fraction BODMAS": generateFractionBodmas,
"signed fraction": generateSignedFraction,
"define ratio": generateDefineRatio,
"fractions and ratios": generateFractionsAndRatios,
"integer order/compare": generateIntegerOrderCompare,
"integer add/subtract (mixed)": generateIntegerAddSubtractMixed,
"quaternary to denary": generateQuaternaryToDenary,
};
describe("new practice generators produce well-formed problems", () => {
for (const [name, gen] of Object.entries(NEW_GENERATORS)) {
it(name, () => {
for (const d of [1, 2, 3] as Difficulty[]) {
for (let i = 0; i < 40; i++) assertWellFormed(gen(d));
}
});
}
});
describe("quaternary answer matches its base-4 prompt", () => {
it("converts base 4 correctly", () => {
for (const d of [1, 2, 3] as Difficulty[]) {
for (let i = 0; i < 40; i++) {
const p = generateQuaternaryToDenary(d);
const digits = p.prompt.match(/^(\d+)_\{4\}/)?.[1];
expect(digits).toBeTruthy();
if (p.answer.kind === "integer") {
expect(p.answer.value).toBe(parseInt(digits!, 4));
}
}
}
});
});
describe("order/compare answer is one of the numbers asked about", () => {
it("picks a value from the prompt", () => {
for (let i = 0; i < 60; i++) {
const p = generateIntegerOrderCompare(1);
const nums = (p.prompt.match(/-?\d+/g) ?? []).map(Number);
if (p.answer.kind === "integer") {
expect(nums).toContain(p.answer.value);
}
}
});
});

View File

@@ -0,0 +1,210 @@
import type { MathProblem, Difficulty } from "../types";
import { randomInt, randomChoice } from "@/lib/utils";
let counter = 0;
function nextId() {
return `int-${++counter}-${Date.now()}`;
}
/** Wrap negatives in parentheses when they follow an operator, e.g. 5 + (-3). */
function signed(n: number): string {
return n < 0 ? `(${n})` : `${n}`;
}
export function generateIntegerAddSubtract(difficulty: Difficulty): MathProblem {
if (difficulty === 3) {
const a = randomInt(-12, 12);
const b = randomInt(-12, 12);
const c = randomInt(-12, 12);
const op1 = randomChoice(["+", "-"] as const);
const op2 = randomChoice(["+", "-"] as const);
const partial = op1 === "+" ? a + b : a - b;
const value = op2 === "+" ? partial + c : partial - c;
return {
id: nextId(),
prompt: `${a} ${op1} ${signed(b)} ${op2} ${signed(c)}`,
answer: { kind: "integer", value },
hints: [
"Work from left to right, one operation at a time.",
"Subtracting a negative is the same as adding a positive.",
`First: ${a} ${op1} ${signed(b)} = ${partial}`,
],
steps: [
{ explanation: `Work left to right. First: ${a} ${op1} ${signed(b)} = ${partial}` },
{ explanation: `Then: ${partial} ${op2} ${signed(c)} = ${value}` },
],
};
}
const range = difficulty === 1 ? 10 : 20;
const a = randomInt(-range, range);
const b = randomInt(-range, range);
const op = randomChoice(["+", "-"] as const);
const value = op === "+" ? a + b : a - b;
const rule =
op === "+"
? b < 0
? "Adding a negative means moving left — the same as subtracting."
: "Adding a positive means moving right."
: b < 0
? "Subtracting a negative is the same as adding a positive (move right)."
: "Subtracting a positive means moving left.";
return {
id: nextId(),
prompt: `${a} ${op} ${signed(b)}`,
answer: { kind: "integer", value },
hints: [
"Picture the first number on a number line.",
rule,
op === "-" && b < 0 ? "Two minus signs make a plus." : "Watch the sign of the second number.",
],
steps: [
{ explanation: `Start at ${a} on the number line.` },
{ explanation: rule },
{ explanation: `${a} ${op} ${signed(b)} = ${value}` },
],
};
}
export function generateIntegerMultiplyDivide(difficulty: Difficulty): MathProblem {
if (difficulty === 3) {
// triple product, e.g. (-2) × (-3) × (-4)
const a = randomInt(-5, 5) || 2;
const b = randomInt(-5, 5) || 3;
const c = randomInt(-5, 5) || 4;
const value = a * b * c;
const negatives = [a, b, c].filter((n) => n < 0).length;
return {
id: nextId(),
prompt: `${signed(a)} \\times ${signed(b)} \\times ${signed(c)}`,
answer: { kind: "integer", value },
hints: [
"Multiply two numbers at a time.",
"Count the negative signs: an even count gives a positive result, an odd count gives a negative result.",
`There are ${negatives} negative sign${negatives === 1 ? "" : "s"} here.`,
],
steps: [
{ explanation: `Multiply the first two: ${signed(a)} × ${signed(b)} = ${a * b}` },
{ explanation: `Then multiply by the last: ${signed(a * b)} × ${signed(c)} = ${value}` },
{ explanation: `${negatives} negative signs → ${negatives % 2 === 0 ? "positive" : "negative"} answer.` },
],
};
}
const op = randomChoice(["\\times", "\\div"] as const);
const factorRange = difficulty === 1 ? 9 : 12;
if (op === "\\times") {
const a = randomInt(-factorRange, factorRange) || 3;
const b = randomInt(-factorRange, factorRange) || 4;
const value = a * b;
const sameSign = a < 0 === b < 0;
return {
id: nextId(),
prompt: `${signed(a)} \\times ${signed(b)}`,
answer: { kind: "integer", value },
hints: [
"Multiply the numbers as normal, then decide the sign.",
"Same signs → positive. Different signs → negative.",
`These signs are ${sameSign ? "the same" : "different"}.`,
],
steps: [
{ explanation: `Multiply the sizes: ${Math.abs(a)} × ${Math.abs(b)} = ${Math.abs(value)}` },
{ explanation: `${sameSign ? "Same signs give a positive" : "Different signs give a negative"} answer.` },
{ explanation: `${signed(a)} × ${signed(b)} = ${value}` },
],
};
}
// division — build from an exact product so the answer is a whole number
const divisor = (randomInt(-factorRange, factorRange) || 3);
const quotient = (randomInt(-factorRange, factorRange) || 2);
const dividend = divisor * quotient;
const sameSign = divisor < 0 === dividend < 0;
return {
id: nextId(),
prompt: `${signed(dividend)} \\div ${signed(divisor)}`,
answer: { kind: "integer", value: quotient },
hints: [
"Divide the numbers as normal, then decide the sign.",
"Same signs → positive. Different signs → negative.",
`These signs are ${sameSign ? "the same" : "different"}.`,
],
steps: [
{ explanation: `Divide the sizes: ${Math.abs(dividend)} ÷ ${Math.abs(divisor)} = ${Math.abs(quotient)}` },
{ explanation: `${sameSign ? "Same signs give a positive" : "Different signs give a negative"} answer.` },
{ explanation: `${signed(dividend)} ÷ ${signed(divisor)} = ${quotient}` },
],
};
}
export function generateIntegerOrderCompare(difficulty: Difficulty): MathProblem {
const range = difficulty === 1 ? 10 : difficulty === 2 ? 20 : 30;
if (difficulty === 3) {
const nums: number[] = [];
while (nums.length < 3) {
const v = randomInt(-range, range);
if (!nums.includes(v)) nums.push(v);
}
const wantLargest = Math.random() < 0.5;
const value = wantLargest ? Math.max(...nums) : Math.min(...nums);
return {
id: nextId(),
prompt: `\\text{Which is the ${wantLargest ? "largest" : "smallest"}: } ${nums.join(",\\ ")}\\text{ ?}`,
answer: { kind: "integer", value },
hints: ["Picture them on a number line.", "Further right is larger; further left is smaller."],
steps: [{ explanation: `The ${wantLargest ? "largest" : "smallest"} is ${value}.` }],
};
}
const a = randomInt(-range, range);
let b = randomInt(-range, range);
if (a === b) b = a + 1;
const wantGreater = Math.random() < 0.5;
const value = wantGreater ? Math.max(a, b) : Math.min(a, b);
const dir = wantGreater ? "right" : "left";
return {
id: nextId(),
prompt: `\\text{Which is ${wantGreater ? "greater" : "smaller"}, } ${a} \\text{ or } ${b}\\text{? Enter it.}`,
answer: { kind: "integer", value },
hints: [`On a number line, the number further ${dir} wins.`, "Negatives get smaller the further left they are."],
steps: [{ explanation: `${value} is ${wantGreater ? "greater" : "smaller"}.` }],
};
}
/** Add/subtract integers, occasionally as a real-world temperature word problem. */
export function generateIntegerAddSubtractMixed(difficulty: Difficulty): MathProblem {
return Math.random() < 0.3 ? generateTemperatureProblem(difficulty) : generateIntegerAddSubtract(difficulty);
}
export function generateTemperatureProblem(difficulty: Difficulty): MathProblem {
const cities = ["London", "Moscow", "Toronto", "Oslo", "Helsinki"];
const days = ["Monday", "Tuesday", "Wednesday", "Thursday"];
const city = randomChoice(cities);
const day = randomChoice(days);
const start = randomInt(-8, 3);
const rose = Math.random() < 0.5;
const change = randomInt(2, difficulty === 1 ? 6 : 12);
const value = rose ? start + change : start - change;
const verb = rose ? "rose" : "fell";
return {
id: nextId(),
prompt: `\\begin{array}{l}\\text{In ${city} the temperature was ${start}°C on ${day}.}\\\\\\text{The next day it ${verb} by ${change}°C.}\\\\\\text{What was the temperature then?}\\end{array}`,
answer: { kind: "integer", value },
hints: [
`Start at ${start}°C.`,
`${rose ? "Rising" : "Falling"} means ${rose ? "add" : "subtract"} ${change}.`,
`${start} ${rose ? "+" : "-"} ${change} = ${value}`,
],
steps: [
{ explanation: `Start at ${start}°C.` },
{ explanation: `It ${verb}, so ${rose ? "add" : "subtract"} ${change}: ${start} ${rose ? "+" : "-"} ${change} = ${value}` },
{ explanation: `The temperature was ${value}°C.` },
],
};
}

View File

@@ -0,0 +1,136 @@
import type { MathProblem, Difficulty } from "../types";
import { randomInt, randomChoice } from "@/lib/utils";
let counter = 0;
function nextId() {
return `np-${++counter}-${Date.now()}`;
}
export function generateIdentityInverse(difficulty: Difficulty): MathProblem {
const kind = randomChoice(
difficulty === 1
? (["identity-add", "zero", "inverse-add"] as const)
: (["identity-add", "identity-mul", "zero", "inverse-add"] as const),
);
if (kind === "identity-add") {
const n = randomInt(-20, 20);
return {
id: nextId(),
prompt: `${n} + 0`,
answer: { kind: "integer", value: n },
hints: ["0 is the identity for addition.", "Adding 0 changes nothing."],
steps: [{ explanation: "Adding the identity 0 leaves the number unchanged.", math: `${n} + 0 = ${n}` }],
};
}
if (kind === "identity-mul") {
const n = randomInt(-20, 20);
return {
id: nextId(),
prompt: `${n} \\times 1`,
answer: { kind: "integer", value: n },
hints: ["1 is the identity for multiplication.", "Multiplying by 1 changes nothing."],
steps: [{ explanation: "Multiplying by the identity 1 leaves the number unchanged.", math: `${n} \\times 1 = ${n}` }],
};
}
if (kind === "zero") {
const n = randomInt(1, 30);
return {
id: nextId(),
prompt: `${n} \\times 0`,
answer: { kind: "integer", value: 0 },
hints: ["Anything multiplied by zero is zero."],
steps: [{ explanation: "Any number times zero is zero.", math: `${n} \\times 0 = 0` }],
};
}
// inverse-add: additive inverse
const n = randomInt(-20, 20) || 5;
return {
id: nextId(),
prompt: `\\text{The additive inverse of } ${n} \\text{ is?}`,
answer: { kind: "integer", value: -n },
hints: ["The additive inverse adds up to 0.", "Flip the sign."],
steps: [{ explanation: "The additive inverse flips the sign so the sum is 0.", math: `${n} + (${-n}) = 0` }],
};
}
export function generateExponentEvaluate(difficulty: Difficulty): MathProblem {
if (difficulty === 3) {
// index law: find the resulting exponent
const base = randomInt(2, 6);
const op = randomChoice(["\\times", "\\div"] as const);
if (op === "\\times") {
const m = randomInt(1, 5);
const n = randomInt(1, 5);
return {
id: nextId(),
prompt: `${base}^{${m}} \\times ${base}^{${n}} = ${base}^{x}`,
answer: { kind: "integer", value: m + n },
hints: ["Same base, multiplying → add the powers.", `${m} + ${n} = ?`],
steps: [{ explanation: "When multiplying powers of the same base, add the exponents.", math: `${base}^{${m}} \\times ${base}^{${n}} = ${base}^{${m + n}}` }],
};
}
const m = randomInt(3, 7);
const n = randomInt(1, m - 1);
return {
id: nextId(),
prompt: `${base}^{${m}} \\div ${base}^{${n}} = ${base}^{x}`,
answer: { kind: "integer", value: m - n },
hints: ["Same base, dividing → subtract the powers.", `${m} - ${n} = ?`],
steps: [{ explanation: "When dividing powers of the same base, subtract the exponents.", math: `${base}^{${m}} \\div ${base}^{${n}} = ${base}^{${m - n}}` }],
};
}
if (difficulty === 2 && Math.random() < 0.5) {
// difference of two powers, e.g. 2^3 - 2^1
const base = randomInt(2, 4);
const a = randomInt(2, 3);
const b = 1;
const value = Math.pow(base, a) - Math.pow(base, b);
return {
id: nextId(),
prompt: `${base}^{${a}} - ${base}^{${b}}`,
answer: { kind: "integer", value },
hints: [`${base}^${a} = ${Math.pow(base, a)}`, `${base}^${b} = ${Math.pow(base, b)}`],
steps: [{ explanation: "Work out each power, then subtract.", math: `${Math.pow(base, a)} - ${Math.pow(base, b)} = ${value}` }],
};
}
// evaluate a single power (includes n^0 and n^1)
const base = randomInt(2, difficulty === 1 ? 6 : 9);
const exp = difficulty === 1 ? randomChoice([0, 1, 2, 2, 3]) : randomChoice([0, 1, 2, 3]);
const value = Math.pow(base, exp);
const note = exp === 0 ? "Any number to the power 0 is 1." : exp === 1 ? "Any number to the power 1 is itself." : `Multiply ${base} by itself ${exp} times.`;
return {
id: nextId(),
prompt: `${base}^{${exp}}`,
answer: { kind: "integer", value },
hints: [note],
steps: [{ explanation: note, math: `${base}^{${exp}} = ${value}` }],
};
}
export function generateDistributive(difficulty: Difficulty): MathProblem {
const a = randomInt(2, difficulty === 1 ? 6 : 9);
const b = randomInt(1, difficulty === 1 ? 6 : 9);
const c = randomInt(1, difficulty === 1 ? 6 : 9);
const value = a * (b + c);
return {
id: nextId(),
prompt: `${a} \\times (${b} + ${c})`,
answer: { kind: "integer", value },
hints: [
"Add inside the bracket first, then multiply.",
"Or multiply the outside number by each number inside.",
`${a} × ${b} + ${a} × ${c}`,
],
steps: [
{ explanation: `Add inside the bracket: ${b} + ${c} = ${b + c}` },
{ explanation: `Multiply: ${a} × ${b + c} = ${value}` },
{ explanation: `Check by distributing: ${a * b} + ${a * c} = ${value}` },
],
};
}

View File

@@ -0,0 +1,104 @@
import type { MathProblem, Difficulty } from "../types";
import { randomInt } from "@/lib/utils";
let counter = 0;
function nextId() {
return `ns-${++counter}-${Date.now()}`;
}
export function generateBinaryToDenary(difficulty: Difficulty): MathProblem {
const bits = difficulty === 1 ? randomInt(3, 4) : difficulty === 2 ? randomInt(5, 6) : randomInt(7, 8);
// ensure a leading 1 so the length is meaningful
let binary = "1";
for (let i = 1; i < bits; i++) binary += Math.random() < 0.5 ? "0" : "1";
const len = binary.length;
let value = 0;
const parts: string[] = [];
for (let i = 0; i < len; i++) {
const exp = len - 1 - i;
const digit = parseInt(binary[i], 10);
const place = Math.pow(2, exp);
if (digit === 1) parts.push(`${place}`);
value += digit * place;
}
return {
id: nextId(),
prompt: `${binary}_{2} = ?_{10}`,
answer: { kind: "integer", value },
hints: [
"Each column is a power of 2, doubling from the right: 1, 2, 4, 8, 16 …",
"Add up the place values wherever there is a 1.",
`${parts.join(" + ")} = ${value}`,
],
steps: [
{ explanation: "Label each column with its power of 2, starting at 1 on the right." },
{ explanation: `Add the place values under each 1: ${parts.join(" + ")}` },
{ explanation: `${binary} in binary = ${value} in denary.` },
],
};
}
export function generateQuaternaryToDenary(difficulty: Difficulty): MathProblem {
const digits = difficulty === 1 ? randomInt(2, 3) : difficulty === 2 ? randomInt(3, 4) : randomInt(4, 5);
let quaternary = `${randomInt(1, 3)}`; // leading non-zero digit
for (let i = 1; i < digits; i++) quaternary += `${randomInt(0, 3)}`;
const len = quaternary.length;
let value = 0;
const parts: string[] = [];
for (let i = 0; i < len; i++) {
const exp = len - 1 - i;
const digit = parseInt(quaternary[i], 10);
const place = Math.pow(4, exp);
if (digit !== 0) parts.push(`${digit}×${place}`);
value += digit * place;
}
return {
id: nextId(),
prompt: `${quaternary}_{4} = ?_{10}`,
answer: { kind: "integer", value },
hints: [
"Each column is a power of 4, from the right: 1, 4, 16, 64 …",
"Multiply each digit by its place value and add them up.",
`${parts.join(" + ")} = ${value}`,
],
steps: [
{ explanation: "Label each column with its power of 4, starting at 1 on the right." },
{ explanation: `Add the products: ${parts.join(" + ")}` },
{ explanation: `${quaternary} in base 4 = ${value} in denary.` },
],
};
}
export function generateDigitValue(difficulty: Difficulty): MathProblem {
const digits = difficulty === 1 ? randomInt(3, 4) : difficulty === 2 ? 5 : 6;
const min = Math.pow(10, digits - 1);
const max = Math.pow(10, digits) - 1;
const n = randomInt(min, max);
const str = `${n}`;
// pick a non-zero digit position
const nonZeroPositions = str.split("").map((d, i) => ({ d, i })).filter((x) => x.d !== "0");
const pick = nonZeroPositions[randomInt(0, nonZeroPositions.length - 1)];
const exp = str.length - 1 - pick.i;
const place = Math.pow(10, exp);
const value = parseInt(pick.d, 10) * place;
return {
id: nextId(),
prompt: `\\text{In } ${n} \\text{, what is the value of the digit } ${pick.d}\\text{?}`,
answer: { kind: "integer", value },
hints: [
"Find which column the digit sits in.",
`That column is worth 10^${exp} = ${place}.`,
`${pick.d} × ${place} = ${value}`,
],
steps: [
{ explanation: `The digit ${pick.d} is in the 10^${exp} (${place}) column.` },
{ explanation: `Its value is ${pick.d} × ${place} = ${value}.` },
],
};
}

View File

@@ -7,6 +7,58 @@ function nextId() {
return `rp-${++counter}-${Date.now()}`;
}
export function generateDefineRatio(difficulty: Difficulty): MathProblem {
const contexts = [
{ a: "red", b: "blue", item: "counters in a bag" },
{ a: "boys", b: "girls", item: "in the class" },
{ a: "cats", b: "dogs", item: "at the shelter" },
{ a: "apples", b: "oranges", item: "in the basket" },
];
const ctx = randomChoice(contexts);
const g = randomChoice(difficulty === 1 ? [1, 2] : difficulty === 2 ? [2, 3, 4] : [3, 4, 5, 6]);
const x = randomInt(1, difficulty === 1 ? 6 : 8) * g;
const y = randomInt(1, difficulty === 1 ? 6 : 8) * g;
const simplified = simplifyRatio([x, y]);
return {
id: nextId(),
prompt: `\\begin{array}{l}\\text{There are ${x} ${ctx.a} and ${y} ${ctx.b} ${ctx.item}.}\\\\\\text{Write the ratio of ${ctx.a} to ${ctx.b} in its simplest form.}\\end{array}`,
answer: { kind: "ratio", parts: simplified },
hints: [
`Write it as ${x} : ${y} first.`,
"Divide both parts by their highest common factor.",
],
steps: [
{ explanation: `The ratio of ${ctx.a} to ${ctx.b} is ${x} : ${y}.` },
{ explanation: `In simplest form: ${simplified.join(" : ")}` },
],
};
}
export function generateFractionsAndRatios(difficulty: Difficulty): MathProblem {
const a = randomInt(1, difficulty === 1 ? 4 : 7);
const b = randomInt(1, difficulty === 1 ? 4 : 7);
const whole = a + b;
const which = randomChoice([0, 1] as const);
const part = which === 0 ? a : b;
const [n, d] = simplifyRatio([part, whole]);
return {
id: nextId(),
prompt: `\\begin{array}{l}\\text{Two amounts are shared in the ratio } ${a} : ${b}.\\\\\\text{What fraction of the whole is the ${which === 0 ? "first" : "second"} part?}\\end{array}`,
answer: { kind: "fraction", numerator: n, denominator: d },
hints: [
`The whole is ${a} + ${b} = ${whole} parts.`,
`The ${which === 0 ? "first" : "second"} part is ${part} out of ${whole}.`,
"Simplify the fraction.",
],
steps: [
{ explanation: `Total parts: ${a} + ${b} = ${whole}.` },
{ explanation: `Fraction = ${part}/${whole}, which simplifies to ${n}/${d}.` },
],
};
}
export function generateSimplifyRatio(difficulty: Difficulty): MathProblem {
const gcd = randomChoice(difficulty === 1 ? [2, 3, 4, 5] : difficulty === 2 ? [3, 4, 5, 6, 7] : [4, 6, 8, 9, 12]);
const a = randomInt(1, difficulty === 1 ? 5 : 10) * gcd;

View File

@@ -2,6 +2,9 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
// Pin the workspace root to this project. A stray package-lock.json in the
// parent directory otherwise makes Turbopack infer the wrong root and crash.
turbopack: { root: __dirname },
};
export default nextConfig;

1220
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,9 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"clsx": "^2.1.1",
@@ -26,6 +28,7 @@
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
"typescript": "^5",
"vitest": "^2.1.9"
}
}

319
plan.md Normal file
View File

@@ -0,0 +1,319 @@
# Implementation Plan: Term 1 Unit Plan (`unit_plan_term_1.pdf`) → Cabrits App
## 1. Context
Cabrits is an interactive mathematics teaching tool for Portsmouth Secondary School Form 1 (age 1112), used live in class by the teacher. Pages contain **interactive tools only — no lesson text** (the teacher explains verbally). Stack: Next.js 16 (App Router, `output: "standalone"`), React 19, TypeScript, Tailwind CSS v4 (`@theme inline`), KaTeX via `components/math/math-display.tsx`, custom SVG visualizations (no chart libraries), no database/auth — everything is client-side and generated on the fly.
The app currently implements the **Term 2** curriculum as Units 14 (Fractions, Decimals, Decimal Operations, Ratio & Proportion) and has already started on Term 1 material as Units 56 (Integers, Number System).
`unit_plan_term_1.pdf` (8 pages, AY 20252026, Form 1, "NUMBER" unit) specifies these topics. The website is a **resource organized by topic** — the source document's scheduling is irrelevant and must never appear in the app (no week numbers, no dates, nothing schedule-related in any UI text, slug, title, or description):
| Topic | Objectives | Content |
|-------|-----------|---------|
| Integers: define, order, compare | Define integers & negative numbers; represent on a number line; order integers (ascending/descending); compare integers with `<` / `>` | Number line, real-world contexts (temperature/thermometer, banking/overdraft, sports goal differences, time). Evaluation: order sets like `-8, -10, -4, -3, -6, 5`; fill in `<` or `>` for pairs like `-5 __ 5`, `4 __ -16`, `-6 __ -11` |
| Integers: add & subtract | Add & subtract integers, with and without number lines | Number-line movement rules; sign rules ("two like signs become +, two unlike signs become "); borrow/owe strategy. Evaluation: `5 + (-3)`, `8 - (-7)`, `-2 + 9`, `-9 - (-1)`, etc. |
| Integers: multiply & divide | Multiply & divide integers | Sign rules (` × = +` etc.); "Eat! / Do not eat!" story; thermometer word problems (e.g. "In London, temperature is 3 °C, next day it rose by 7 °C…"). Evaluation: `3 × -5`, `-2 × -9`, `-12 / 4`, `-24 / -8`, `(-2) × (-3) × (-4)` |
| Coordinates | Plot coordinates; state coordinates of points | Cartesian plane: x-axis (horizontal), y-axis (vertical), origin (0, 0). Evaluation: plot `A(-4,-8), B(-3,-9), C(-2,-8), D(-2,0), E(-5,0), F(-6,4), G(-2,5), H(2,4), I(5,0)`, join alphabetically then I→A to reveal a shape |
| Identity, inverse & zero facts | Basic operations; identity for addition (0) & multiplication (1); inverse under addition (additive inverse, e.g. inverse of 3 is 3 because `3 + (-3) = 0`); inverse under multiplication (reciprocal, e.g. inverse of 6 is 1/6 because `6 × 1/6 = 1`; inverse of 5 is 1/5); multiplication by zero → 0; division by zero → undefined | "Math Wheel" activity. Evaluation: classwork + homework on identities/inverses |
| Number laws & exponents | Commutative, associative, distributive laws; powers/exponents | Addition & multiplication are commutative/associative, subtraction & division are not; distributive law (number outside bracket multiplies every number inside). Exponents: `2⁴ = 2×2×2×2 = 16`; `a¹ = a`; `a⁰ = 1`; product rule `2⁴ × 2³ = 2⁷`; quotient rule `6⁵ / 6³ = 6²`. Evaluation quiz: `6³`, `2³ 2¹`, `5⁶ / 5⁴`, `7⁰`, `4⁴ × 4³` |
| Number systems (base 10 & base 2) | Understand & use the decimal system (base 10); understand & use the binary system (base 2) | Base-10 place value chart with powers of 10, expanded form (`983275 = 9×10⁵ + 8×10⁴ + 3×10³ + 2×10² + 7×10¹ + 5×10⁰`); binary place value with powers of 2; convert binary→denary (`111001₂ = 32+16+8+0+0+1 = 57`). Evaluation: convert `1011₂`, `111₂`, `1101₂` to denary |
## 2. Gap Analysis
**Already implemented (do NOT rebuild — leave untouched):**
| PDF topic | Existing route | Existing explorer |
|---|---|---|
| Add/subtract integers | `/lessons/unit-5-integers/add-subtract` | `components/explorers/integer-add-subtract-explorer.tsx` (number-line + sign-rules modes, step-through, inline QuickPractice) |
| Multiply/divide integers | `/lessons/unit-5-integers/multiply-divide` | `components/explorers/integer-multiply-divide-explorer.tsx` |
| Binary ↔ denary | `/lessons/unit-6-number-system/binary` | `components/explorers/binary-converter-explorer.tsx`, `denary-to-binary-explorer.tsx` |
| (bonus, not in PDF) quaternary | `/lessons/unit-6-number-system/quaternary` | `quaternary-to-denary-explorer.tsx`, `denary-to-quaternary-explorer.tsx` |
**Missing (this plan builds these):**
1. Order & compare integers (new topic in existing Unit 5)
2. Cartesian plane: plot points & read coordinates (new Unit 7)
3. Identity, inverse, zero facts (new Unit 8, topic 1)
4. Commutative/associative/distributive laws (Unit 8, topic 2)
5. Powers & exponents with index laws (Unit 8, topic 3)
6. Base-10 place value / expanded form (new topic in existing Unit 6)
## 3. Target Curriculum Structure After Implementation
- **Unit 5 Integers** (existing, violet `#7c3aed`): `order-compare` **(NEW, insert as first topic)**, `add-subtract`, `multiply-divide`
- **Unit 6 Number System** (existing, indigo `#4f46e5`): `place-value` **(NEW, insert as first topic)**, `binary`, `quaternary`
- **Unit 7 Coordinates** (NEW, green `#16a34a`): `plot-points`, `read-coordinates`
- **Unit 8 Number Properties** (NEW, rose `#e11d48`): `identity-inverse`, `number-laws`, `exponents`
New routes (7 topic pages + 2 unit overview pages):
```
/lessons/unit-5-integers/order-compare
/lessons/unit-6-number-system/place-value
/lessons/unit-7-coordinates (overview)
/lessons/unit-7-coordinates/plot-points
/lessons/unit-7-coordinates/read-coordinates
/lessons/unit-8-number-properties (overview)
/lessons/unit-8-number-properties/identity-inverse
/lessons/unit-8-number-properties/number-laws
/lessons/unit-8-number-properties/exponents
```
No existing route changes. Inserting topics at the front of a unit's `topics` array only changes the displayed numbering on overview pages (which is index-based), not URLs.
## 4. Codebase Conventions (READ THESE FILES FIRST as templates)
1. **`lib/curriculum.ts`** — single source of truth. `Unit` interface has `number: 1|2|3|4|5|6` and `color: "unit-1"|...|"unit-6"` unions that must be extended to include 7 and 8. The sidebar (`components/layout/sidebar.tsx`), mobile nav (`components/layout/mobile-nav.tsx`), lessons index (`app/lessons/page.tsx`), and landing page (`app/page.tsx`) all iterate `curriculum` automatically — but each keeps its **own hardcoded color-class map keyed by `"unit-N"`** that must gain `unit-7`/`unit-8` entries (Tailwind cannot generate class names dynamically).
2. **Topic page pattern** — see `app/lessons/unit-5-integers/add-subtract/page.tsx` (the most recent convention): a `"use client"` page containing only `<Breadcrumbs>`, an `<h1>`, and one `<XyzExplorer />` component. Breadcrumb items: `Lessons → Unit N: Title → Short topic label`.
3. **Unit overview page pattern** — see `app/lessons/unit-5-integers/page.tsx`: server component, reads `curriculum[unitIndex]`, renders a `<Badge variant="unit-N">` header and a `sm:grid-cols-2` grid of `<Card accent="unit-N" hover>` links with numbered chips (`bg-unit-N-light text-unit-N-dark`).
4. **Explorer pattern** — see `components/explorers/integer-add-subtract-explorer.tsx` (594 lines; the canonical template). Structure:
- `"use client"`, local `Step` interface `{ label: string; math: string; ...visualization payload }`
- A pure `buildXyzSteps(...)` function that returns `Step[]` narrating the worked solution
- Mode-toggle pill buttons (`rounded-full border-2 ...` with unit color classes)
- An input `<Card>` with validated number inputs and a "Go →" button; friendly error messages via a local `error` state
- A display `<Card>` showing `step.label`, `<MathDisplay math={step.math} />` (KaTeX string), and the custom SVG/div visualization that animates with the current step
- `<StepControls>` from `components/explorers/step-controls.tsx` wired via `currentStep / totalSteps / isPlaying / onStepForward / onStepBack / onTogglePlay / onReset / canStepForward / canStepBack` (note: it expects `currentStep + 1` as the 1-based display value)
- A result `<Card>` shown when steps complete
- An inline `QuickPractice` component (local to the file): random problem generator, number input, Check/Next buttons, running `score.correct/score.total` chip, Enter-key handling, correct/incorrect feedback using `border-correct bg-correct-light` / `border-incorrect bg-incorrect-light` classes
5. **Color system**`app/globals.css` defines per-unit CSS vars in `:root` (`--unit-N`, `--unit-N-light`, `--unit-N-dark`) and mirrors them in the `@theme inline` block as `--color-unit-N*` so Tailwind classes like `bg-unit-7`, `text-unit-7-dark`, `bg-unit-7-light`, `border-unit-7/40` work. Also available: `--correct`, `--incorrect` (+ `-light` variants), `--foreground`, `--muted`, `--surface`, `--border`.
6. **Practice hub**`app/practice/page.tsx` holds `TOPIC_GENERATORS` (generator functions from `lib/problems/generators/*`), a `unitColors` map (currently only units 14), and a filter-pill array `(["unit-1","unit-2","unit-3","unit-4"] as const)`. `components/practice/practice-section.tsx` renders input UI switched on `problem.answer.kind` — it already supports `"integer"` (plain number input), so all new generators should return `{ kind: "integer", value }` answers. `MathProblem` (in `lib/problems/types.ts`) requires `id`, `prompt` (may contain KaTeX-able text), `answer`, `hints: string[]`, `steps: SolutionStep[]` (`{ explanation, math? }`). `Difficulty = 1 | 2 | 3`.
7. **Landing page art**`components/home/topic-art.tsx` exports `art: Record<string, ReactNode>` keyed by `"unitSlug/topicSlug"`, each a small white-on-gradient SVG glyph inside the shared 100×75 viewBox `Frame`. Every new topic needs an entry or its tile renders without artwork.
---
## 5. Implementation Phases
Work in this order; the app must compile after every phase (`npx tsc --noEmit` and `npm run build`).
### Phase 1 — Shared infrastructure (types, colors, nav maps)
1. **`lib/curriculum.ts`**
- Extend `Unit["number"]` union to `1|2|3|4|5|6|7|8` and `Unit["color"]` union to include `"unit-7" | "unit-8"`.
- Extend `getUnitColor`'s parameter type and `colors` record with `7: "unit-7"`, `8: "unit-8"`.
- Insert into unit 5's `topics` (as the FIRST element):
```ts
{
slug: "order-compare",
title: "Order and Compare Integers",
shortTitle: "Order & Compare",
description: "Place integers on a number line, order them, and compare with < and >",
},
```
- Insert into unit 6's `topics` (as the FIRST element):
```ts
{
slug: "place-value",
title: "Denary Place Value (Base 10)",
shortTitle: "Place Value",
description: "Powers of 10 and writing numbers in expanded form",
},
```
- Append two new units after unit 6:
```ts
{
number: 7,
slug: "unit-7-coordinates",
title: "Coordinates",
description: "Plot points and read coordinates on the Cartesian plane",
color: "unit-7",
topics: [
{ slug: "plot-points", title: "Plot Points on the Cartesian Plane", shortTitle: "Plot Points", description: "Use x and y coordinates to place points and reveal hidden shapes" },
{ slug: "read-coordinates", title: "State Coordinates of Points", shortTitle: "Read Coordinates", description: "Read off the (x, y) coordinates of points in all four quadrants" },
],
},
{
number: 8,
slug: "unit-8-number-properties",
title: "Number Properties",
description: "Identity and inverse, the laws of arithmetic, and powers of numbers",
color: "unit-8",
topics: [
{ slug: "identity-inverse", title: "Identity and Inverse", shortTitle: "Identity & Inverse", description: "Identity for addition and multiplication, inverses, and the zero facts" },
{ slug: "number-laws", title: "Commutative, Associative and Distributive Laws", shortTitle: "Number Laws", description: "Which operations can be swapped, regrouped, and expanded" },
{ slug: "exponents", title: "Powers and Exponents", shortTitle: "Exponents", description: "Base and power, expanding powers, and the index laws" },
],
},
```
2. **`app/globals.css`** — add beside the existing `--unit-6` definitions in `:root`:
```css
--unit-7: #16a34a;
--unit-7-light: #dcfce7;
--unit-7-dark: #15803d;
--unit-8: #e11d48;
--unit-8-light: #ffe4e9;
--unit-8-dark: #be123c;
```
and in the `@theme inline` block (beside `--color-unit-6-dark`):
```css
--color-unit-7: var(--unit-7);
--color-unit-7-light: var(--unit-7-light);
--color-unit-7-dark: var(--unit-7-dark);
--color-unit-8: var(--unit-8);
--color-unit-8-light: var(--unit-8-light);
--color-unit-8-dark: var(--unit-8-dark);
```
3. **`components/ui/badge.tsx`** — extend `BadgeVariant` union with `"unit-7" | "unit-8"` and add to `variantStyles`:
```ts
"unit-7": "border border-unit-7/20 bg-unit-7-light text-unit-7-dark",
"unit-8": "border border-unit-8/20 bg-unit-8-light text-unit-8-dark",
```
4. **`components/ui/card.tsx`** — extend the `accent` prop union and `accentStyles` with `"unit-7": "border-l-4 border-l-unit-7"` and the unit-8 equivalent.
5. **`components/layout/sidebar.tsx`** — add `unit-7` / `unit-8` entries to `unitColorMap`, following the exact shape of the existing entries (`active`, `dot`, `heading`, `hoverBg` keys with the unit's color classes).
6. **`components/layout/mobile-nav.tsx`** — same: extend both color maps (there is a text-color map at ~line 10 and a dot/bg map at ~line 19) with unit-7/unit-8 entries.
7. **`app/page.tsx`** (landing) — add to `unitStyles`:
```ts
"unit-7": {
tile: "from-[#4ade80] to-[#15803d]",
unitCard: "border-[#86efac] bg-[#f0fdf4]",
chip: "bg-[#15803d]",
},
"unit-8": {
tile: "from-[#fb7185] to-[#be123c]",
unitCard: "border-[#fda4af] bg-[#fff1f2]",
chip: "bg-[#be123c]",
},
```
The topic tiles cycle through the `topicTiles` gradient array by index, so no per-topic change is needed there.
8. **`components/home/topic-art.tsx`** — add 7 entries to the `art` record (white SVG glyphs in the 100×75 viewBox, matching the visual weight of existing entries):
- `"unit-5-integers/order-compare"`: a horizontal number line (line + tick marks) with `<` glyph, e.g. ticks at intervals with two highlighted dots and a `<` between two numbers
- `"unit-6-number-system/place-value"`: three columns of stacked digits over small `10² 10¹ 10⁰` labels (use `<text>` with `fontSize` ~810)
- `"unit-7-coordinates/plot-points"`: a small grid (4 light `<line>`s each way), two axes drawn heavier, one bright plotted point with small crosshair lines to the axes
- `"unit-7-coordinates/read-coordinates"`: axes plus a point labelled `(3, 2)`
- `"unit-8-number-properties/identity-inverse"`: `n + 0 = n` style glyph, or two circles balancing on a seesaw with `0` at the pivot
- `"unit-8-number-properties/number-laws"`: `a + b = b + a` with a swap arrow (curved `<path>` with arrowhead)
- `"unit-8-number-properties/exponents"`: large `2` with superscript `4` and `= 16`
**Checkpoint:** `npx tsc --noEmit` passes; landing page, sidebar, and mobile nav show Units 78 with correct colors; the two new Unit 5/6 topics appear (links 404 until later phases — acceptable mid-work, but all phases must be done before merging).
### Phase 2 — Unit 5: Order & Compare Integers
**New file: `components/explorers/integer-order-compare-explorer.tsx`** — model on `integer-add-subtract-explorer.tsx`. Unit color: `unit-5` (violet). Three modes via pill toggle:
1. **"Number Line" mode** (objective 12: define/represent):
- Input: one integer from 15 to 15 (reuse the NumberLine rendering approach from the add-subtract explorer: absolute-positioned ticks over a percentage-scaled track, labels every 5 plus highlighted values).
- Step sequence: (1) show the number line with zero highlighted, label "Zero separates negative numbers (left) from positive numbers (right)"; (2) mark the entered integer with a Start-style pill marker; (3) state its type: `\text{-7 is a negative integer — it is 7 steps left of zero}` (KaTeX via `MathDisplay`).
- Below the line keep the existing convention: `← negative` / `positive →` edge labels.
- Add a context selector row of small buttons — **Thermometer 🌡, Money 💰, Sea level 🌊** — that re-labels the step text (e.g. thermometer: "7 means 7 degrees below zero"; money: "7 means owing $7"; sea level: "7 means 7 m below sea level"). This delivers the PDF's real-world contexts (temperature, banking/overdraft). A vertical thermometer variant is optional polish, not required.
2. **"Order" mode** (objective 3):
- Input: a comma-separated list of 36 integers (validate: integers, within 20…20, at least 3). Direction toggle: Ascending / Descending.
- Step sequence built by `buildOrderSteps(values, direction)`: (1) show unordered values as chips; (2) place all values as dots on the number line simultaneously; (3) one step per pick — "The smallest is the furthest LEFT on the number line: 10" — moving each chip into the ordered row; (4) final ordered list, `\text{Ascending: least} \to \text{greatest}`.
- The visualization: the shared number line with dots, plus a row of value chips that fill in as steps advance (conditionally render based on `currentStep`, same technique as the existing explorers' step-driven rendering).
3. **"Compare" mode** (objective 4):
- Input: two integers a, b. Steps: (1) plot both on the number line; (2) "Numbers to the RIGHT are always larger" with the right-hand one highlighted; (3) result `5 < 5` rendered with `MathDisplay`.
**Inline `QuickPractice`** (same Card layout/score-chip pattern as the template): alternate two question types randomly —
- *Compare*: show `a \; \square \; b` with two big `<` / `>` buttons instead of a number input (immediate check on click). Seed pairs like the PDF's evaluation: `(-5, 5)`, `(4, -16)`, `(-6, -11)`, plus randoms in 20…20.
- *Order*: show 4 shuffled integers as clickable chips; student clicks them in ascending order; each click either locks the chip (correct next pick) or flashes incorrect. Score a point when all 4 are placed without a mistake.
**New file: `app/lessons/unit-5-integers/order-compare/page.tsx`** — copy the shape of `add-subtract/page.tsx`; breadcrumbs `Lessons → Unit 5: Integers → Order & Compare`; `<h1>Order and Compare Integers</h1>`; render the new explorer.
### Phase 3 — Unit 7: Coordinates
**New file: `app/lessons/unit-7-coordinates/page.tsx`** — copy `app/lessons/unit-5-integers/page.tsx`, change `curriculum[4]` → `curriculum[6]`, `unit-5` → `unit-7` classes, badge variant `unit-7`, breadcrumb label `Unit 7: Coordinates`.
**Shared visualization — build a reusable `CartesianGrid` component** (either inside each explorer or as `components/visualizations/cartesian-grid.tsx` if `components/visualizations/` exists — check; if the repo keeps visuals inline in explorers, put it in a shared file under `components/explorers/` such as `cartesian-grid.tsx`). Spec:
- SVG `viewBox="0 0 440 440"`, responsive (`className="h-auto w-full max-w-md"`), grid from 10 to +10 both axes (21 lines each way; light `stroke="var(--border)"` gridlines, heavier axes through the origin in `var(--foreground)`), axis numeric labels every 2 (skip 0 clutter; label origin `0` once), arrowheads + `x` / `y` labels at the axis ends.
- Props: `points: { x: number; y: number; label?: string; color?: string }[]`, `segments: { from: Point; to: Point }[]` (polylines drawn so far), optional `highlight` point with **guide lines**: a dashed line from the point straight down/up to the x-axis and across to the y-axis (this is the key teaching visual for both plotting and reading), optional `onGridClick?: (x, y) => void` (attach a click handler on the SVG, invert the coordinate transform, snap to nearest integer intersection).
- Coordinate transform: `sx = 20 + (x + 10) * 20`, `sy = 20 + (10 - y) * 20`.
**New file: `components/explorers/plot-points-explorer.tsx`** — unit color `unit-7`. Two modes:
1. **"Plot a Point"**: inputs for x and y (10…10). `buildPlotSteps(x, y)`: (1) "Start at the origin (0, 0), where the axes cross"; (2) "Move ALONG the x-axis: 4 to the right" (grid shows a horizontal arrow from origin to (x, 0)); (3) "Then move UP/DOWN parallel to the y-axis: 3 up" (vertical arrow (x,0)→(x,y), dashed guides); (4) "Mark the point (4, 3) — x always comes first!". Each step's payload tells `CartesianGrid` what arrows/guides/points to draw.
2. **"Shape Reveal"** (the PDF's evaluation activity): a select of 34 preset shape datasets, the first being exactly the PDF's: `A(-4,-8), B(-3,-9), C(-2,-8), D(-2,0), E(-5,0), F(-6,4), G(-2,5), H(2,4), I(5,0)` joined A→B→…→I→A. Add 23 simpler presets (e.g. a square, a star, a house). Step-through: each step plots the next labelled point and draws the segment from the previous one; final step closes the shape (I back to A). `StepControls` drives it, so the teacher can play it like an animation.
Inline `QuickPractice`: "Click where (3, 5) is!" — random target coordinates, student clicks the grid (via `onGridClick`), snap-checked for exact match; correct → green pulse on the point, score increments; incorrect → show the clicked point in red and the target in green with guide lines.
**New file: `components/explorers/read-coordinates-explorer.tsx`** — unit color `unit-7`.
- **Explore mode**: a random (or stepped) point is shown; `buildReadSteps(x, y)`: (1) "Drop a line straight DOWN/UP to the x-axis — it lands on 4" (dashed vertical guide); (2) "Go straight ACROSS to the y-axis — it lands on 3" (dashed horizontal guide); (3) "The coordinates are (4, 3): x first, then y". Include a quadrant call-out step ("Both negative → bottom-left quadrant") since the PDF's shape spans all four quadrants.
- Inline `QuickPractice`: show a random point on the grid; two number inputs `( __ , __ )`; check both; classic error worth catching in feedback: if the student enters (y, x) swapped, say "You swapped them — x comes first!".
**New pages:** `app/lessons/unit-7-coordinates/plot-points/page.tsx` and `.../read-coordinates/page.tsx` following the standard topic-page shape (breadcrumb unit label `Unit 7: Coordinates`).
### Phase 4 — Unit 8: Number Properties
**New file: `app/lessons/unit-8-number-properties/page.tsx`** — copy the unit-overview pattern with `curriculum[7]`, `unit-8` classes.
**New file: `components/explorers/identity-inverse-explorer.tsx`** — unit color `unit-8`. Mode pills: **Identity · Inverse · Zero Facts**.
- *Identity*: input a number n; toggle Add / Multiply. Steps: `n + 0 = n` — "Adding 0 leaves the number unchanged: 0 is the identity for addition"; `n × 1 = n` — "Multiplying by 1 leaves it unchanged: 1 is the identity for multiplication". Visual: a "machine" card — number goes in, `+0`/`×1` badge in the middle, same number comes out (simple flex layout with an animated arrow; framer-motion optional, CSS transitions fine).
- *Inverse*: input n (non-zero for multiplication). Steps for addition: "The additive inverse is the number that gets you back to the identity 0" → `n + (n) = 0` (e.g. inverse of 3 is 3; inverse of 5 is 5 — flip the sign). Steps for multiplication: "The multiplicative inverse (reciprocal) gets you back to 1" → `n × \frac{1}{n} = 1`, with the PDF's note that the inverse of 5 is `\frac{1}{5}` (sign stays in front). Render fractions with KaTeX.
- *Zero Facts*: input n. Steps: `n × 0 = 0` — "Anything times zero is zero"; `n ÷ 0 = \text{undefined}` — "You cannot share among zero groups — division by zero is UNDEFINED" (style the word in `text-incorrect`). Also show `0 ÷ n = 0` for contrast.
- Inline `QuickPractice`: random prompts with integer answers — "What is the additive inverse of 8?" (answer 8), "What is 17 × 0?", "What number is the identity for multiplication?"; multiplicative-inverse questions phrased to keep integer answers ("The inverse of 6 under multiplication is 1/□ — what goes in the box?").
**New file: `components/explorers/number-laws-explorer.tsx`** — unit color `unit-8`. Mode pills: **Commutative · Associative · Distributive**.
- *Commutative*: inputs a, b; operation selector (+ × ÷). Steps compute both `a ∘ b` and `b ∘ a` side by side and conclude: for + and ×, "Same answer — addition/multiplication IS commutative ✓"; for and ÷, show the two different results and "Different answers — subtraction/division is NOT commutative ✗" (this contrast is explicitly in the PDF's evaluation questions). Visual: two mirrored expression cards with a swap arrow between them; results colored `correct`/`incorrect` by match.
- *Associative*: inputs a, b, c; operation + or × (plus a "try " demo option that shows the counterexample). Steps: evaluate `(a ∘ b) ∘ c` bracket-first, then `a ∘ (b ∘ c)`, compare.
- *Distributive*: inputs a, b, c. Steps for `a × (b + c)`: (1) show the expression; (2) "The number outside multiplies EVERY number inside" → `a×b + a×c` with curved arrows from a to each term (SVG or absolutely-positioned spans); (3) evaluate both ways to the same answer.
- Inline `QuickPractice`: e.g. "Fill in the blank: 4 × (5 + 2) = 4×5 + 4×□" (integer answer), "Does 9 4 = 4 9? Enter 9 4:" style computations.
**New file: `components/explorers/exponents-explorer.tsx`** — unit color `unit-8`. Mode pills: **Expand · Index Laws**.
- *Expand*: inputs base (212) and power (06), guard result ≤ ~1,000,000. `buildExpandSteps(base, exp)`: (1) `2^4` — "the base is 2, the power (index) is 4: multiply the base by itself 4 times"; (2) `2 × 2 × 2 × 2` shown building up factor by factor across steps with running product; (3) `= 16`. Special-case steps: power 1 → `3^1 = 3` "any number to the power 1 is itself"; power 0 → `6^0 = 1` "any number to the power 0 is 1".
- *Index Laws*: choose Multiply or Divide; inputs base b, powers m, n. Steps for multiply: (1) `b^m × b^n`; (2) expand both into factor strings side by side; (3) "count the factors: m + n of them" → `b^{m+n}`; (4) evaluated value. Divide analogously with cancellation visual (strike-through matching factors — render the expansion as individual chips and add `line-through opacity-40` to cancelled ones as steps advance) → `b^{mn}`.
- Inline `QuickPractice`: seeded directly from the PDF quiz — ``, ``, `5⁶ / 5⁴` (ask for the answer as a power's value, keep numbers small), `7⁰`, plus randoms like `a^m × a^n = a^?` (answer: the exponent, an integer).
**New pages:** `app/lessons/unit-8-number-properties/{identity-inverse,number-laws,exponents}/page.tsx` following the standard topic-page shape.
### Phase 5 — Unit 6: Denary Place Value
**New file: `components/explorers/place-value-explorer.tsx`** — unit color `unit-6` (indigo), consistent with the binary explorers it sits beside (read `binary-converter-explorer.tsx` first and mirror its table/chart styling so the base-10 and base-2 pages feel like siblings).
- Input: a whole number 1999,999.
- `buildPlaceValueSteps(n)`: (1) show the number and "each digit has a place value that is a power of 10"; (2) reveal the place-value chart header row: `10^5 = 100000 · 10^4 = 10000 · … · 10^0 = 1` (only as many columns as the number has digits); (3) one step per digit left→right: highlight the column, show `9 × 10^5 = 900000`; (4) assemble expanded form `983275 = 9×10^5 + 8×10^4 + 3×10^3 + 2×10^2 + 7×10^1 + 5×10^0` in KaTeX; (5) sum check back to the original number.
- Visual: an HTML table styled like the PDF's chart (Place Value / Digit / Product rows), current column highlighted with `bg-unit-6-light`, inside an `overflow-x-auto` wrapper for mobile.
- Inline `QuickPractice`: "In 47,382 — what is the VALUE of the digit 7?" (answer 7000); "Which power of 10 is the hundreds column? Enter the exponent" (answer 2).
**New page:** `app/lessons/unit-6-number-system/place-value/page.tsx` (breadcrumb unit label `Unit 6: Number System`).
### Phase 6 — Practice hub generators
**New file: `lib/problems/generators/integer-problems.ts`** exporting (all return `MathProblem` with `answer: { kind: "integer", value }`, difficulty-scaled ranges, unique `id` via the same scheme other generators use — check `fraction-problems.ts` for the id/random helpers and reuse its style):
- `generateIntegerAddSubtract(difficulty)` — d1: single op, values 10…10; d2: values 20…20 including double negatives like `8 (7)`; d3: three-term chains. Steps narrate the sign rules exactly as the explorer does.
- `generateIntegerMultiplyDivide(difficulty)` — d1: single product/quotient with small factors (division always exact); d2: larger values; d3: triple products like `(2) × (3) × (4)` (the PDF homework question). Steps narrate sign-counting ("two negatives → positive").
- `generateTemperatureProblem(difficulty)` — word problems modeled on the PDF: "In London, the temperature is 3 °C on Monday. The next day it rose by 7 °C. What was the temperature on Tuesday?" Vary city, start temp, rise/fall.
**New file: `lib/problems/generators/number-properties-problems.ts`** exporting:
- `generateIdentityInverse(difficulty)` — additive-inverse and zero-fact questions with integer answers.
- `generateExponentEvaluate(difficulty)` — d1: squares/cubes of 29 and `n^0`/`n^1`; d2: PDF-quiz style (`6^3`, `2^3 2^1`); d3: index-law simplifications where the answer is the resulting exponent ("`4^4 × 4^3 = 4^x` — find x").
- `generateDistributive(difficulty)` — evaluate `a × (b + c)` numerically.
**New file: `lib/problems/generators/number-system-problems.ts`** exporting:
- `generateBinaryToDenary(difficulty)` — d1: 34 bits; d2: 56 bits (the PDF's `1011₂`, `111₂`, `1101₂` range); d3: 78 bits. Steps show the power-of-2 column products and the sum.
- `generateDigitValue(difficulty)` — "value of digit d in N" questions for base 10.
**Modify `app/practice/page.tsx`:**
- Import the new generators and append `TOPIC_GENERATORS` entries for: unit-5 `order-compare`→ *(skip — comparison answers don't fit integer input; leave its practice inline in the explorer)*, unit-5 `add-subtract` (`generateIntegerAddSubtract`), unit-5 `multiply-divide` (`generateIntegerMultiplyDivide`), unit-5 temperature word problems (attach to `multiply-divide` or `add-subtract` as a second entry with label "Temperature Problems"), unit-6 `place-value` (`generateDigitValue`), unit-6 `binary` (`generateBinaryToDenary`), unit-8 all three topics.
- Extend the `TopicGenerator["unitColor"]` union to `"unit-1" | ... | "unit-8"`, add `unit-5``unit-8` entries to the `unitColors` map (labels: "Integers", "Number System", "Coordinates", "Number Properties" — include unit-7 only if a coordinates generator is added; otherwise omit unit-7 from the filter row), and extend the filter-pill `as const` array accordingly.
Coordinates practice stays inside the explorers (click-the-grid needs custom input UI that `PracticeSection` doesn't support — do NOT extend `MathAnswer` for this; it's out of scope).
### Phase 7 — Verification
1. `npx tsc --noEmit` — zero errors.
2. `npm run lint` — zero errors.
3. `npm run build` — succeeds (catches server/client component violations; every page that renders an explorer must be `"use client"` or import a `"use client"` explorer, matching the existing pattern).
4. `npm run dev` and manually visit every new route (list in §3). For each: breadcrumbs correct, unit color correct, explorer loads, "Go" produces steps, StepControls play/step/reset work, QuickPractice checks answers correctly (test at least one correct and one incorrect answer each).
5. Specifically verify: the PDF shape-reveal dataset plots correctly and closes I→A; `n ÷ 0` shows "undefined" and never computes; `7⁰ = 1`; negative coordinates plot in the correct quadrants; ordering `-8, -10, -4, -3, -6, 5` ascending yields `-10, -8, -6, -4, -3, 5`.
6. `/practice`: new topics appear, unit filter pills for the new units work, answers validate.
7. Landing page: Units 78 render with art on every topic tile (no blank tiles — confirms `topic-art.tsx` keys match `unitSlug/topicSlug` exactly).
8. Check mobile layout (narrow viewport): the Cartesian grid and place-value table must scroll or scale, not overflow the page.
## 6. Acceptance Criteria (map back to the PDF)
- [ ] Order & compare integers: explorer covers number-line placement, real-world contexts, ascending/descending ordering, and `<`/`>` comparison, including the PDF's evaluation numbers
- [ ] Integer add/subtract and multiply/divide: already live; unchanged and still passing build
- [ ] Coordinates: plot-points + read-coordinates explorers, including the PDF's AI shape activity as a preset
- [ ] Identity & inverse: explorer covers identity 0/1, additive & multiplicative inverse (with the 1/5 sign note), ×0 = 0, ÷0 undefined
- [ ] Number laws: explorer demonstrates all three laws AND the non-commutativity/non-associativity of and ÷
- [ ] Exponents: explorer covers expansion, ``, `a⁰`, product & quotient index laws; PDF quiz questions appear in QuickPractice
- [ ] Denary place value: explorer reproduces the PDF's 983275 expanded-form walkthrough for any input
- [ ] Binary system: already live (binary page)
- [ ] No week numbers, dates, or scheduling language anywhere in app UI text, slugs, titles, or descriptions
- [ ] All new topics wired into: curriculum.ts, sidebar, mobile nav, lessons index, landing page (with art), and the practice hub where applicable
- [ ] `tsc`, `lint`, and `build` all clean

BIN
unit_plan_term_1.pdf Normal file

Binary file not shown.

14
vitest.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from "vitest/config";
import { fileURLToPath } from "node:url";
export default defineConfig({
resolve: {
alias: {
"@": fileURLToPath(new URL("./", import.meta.url)),
},
},
test: {
include: ["lib/**/*.test.ts"],
environment: "node",
},
});