integers added

This commit is contained in:
2026-03-26 08:50:17 -04:00
parent 1764adf0a5
commit 221997f2d9
17 changed files with 1520 additions and 64 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(cd \"C:/Users/earl_/source/repos/CabritsMath/cabrits-math\" && npx next build 2>&1 | tail -80)"
]
}
}

View File

@@ -25,6 +25,10 @@
--unit-4-light: #ffe8ef; --unit-4-light: #ffe8ef;
--unit-4-dark: #be123c; --unit-4-dark: #be123c;
--unit-5: #7c3aed;
--unit-5-light: #f0e7ff;
--unit-5-dark: #5b21b6;
--correct: #16a34a; --correct: #16a34a;
--correct-light: #dcfce7; --correct-light: #dcfce7;
--incorrect: #dc2626; --incorrect: #dc2626;
@@ -59,6 +63,9 @@
--color-unit-4: var(--unit-4); --color-unit-4: var(--unit-4);
--color-unit-4-light: var(--unit-4-light); --color-unit-4-light: var(--unit-4-light);
--color-unit-4-dark: var(--unit-4-dark); --color-unit-4-dark: var(--unit-4-dark);
--color-unit-5: var(--unit-5);
--color-unit-5-light: var(--unit-5-light);
--color-unit-5-dark: var(--unit-5-dark);
--color-correct: var(--correct); --color-correct: var(--correct);
--color-correct-light: var(--correct-light); --color-correct-light: var(--correct-light);

View File

@@ -7,7 +7,7 @@ export default function LessonsOverview() {
return ( return (
<div> <div>
<h1 className="mb-2 text-3xl font-bold tracking-tight">All Topics</h1> <h1 className="mb-2 text-3xl font-bold tracking-tight">All Topics</h1>
<p className="mb-10 text-muted">Form 1, Term 2 &mdash; Select a topic to explore</p> <p className="mb-10 text-muted">Form 1 &mdash; Select a topic to explore</p>
{curriculum.map((unit) => ( {curriculum.map((unit) => (
<section key={unit.slug} className="mb-12"> <section key={unit.slug} className="mb-12">

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
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 Unit5Overview() {
const unit = curriculum[4];
return (
<div>
<Breadcrumbs
items={[
{ label: "Lessons", href: "/lessons" },
{ label: `Unit 5: ${unit.title}` },
]}
/>
<div className="mb-10">
<Badge variant="unit-5" className="mb-3">Unit 5 &mdash; {unit.weeks}</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-5" 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-5-light text-xs font-bold text-unit-5-dark shadow-[var(--shadow-sm)]">
{i + 1}
</span>
<span className="text-xs text-muted">Week {topic.week}</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

@@ -24,6 +24,11 @@ const unitStyles = {
unitCard: "border-[#ffb2c7] bg-[#fff0f4]", unitCard: "border-[#ffb2c7] bg-[#fff0f4]",
chip: "bg-[#c6174e]", chip: "bg-[#c6174e]",
}, },
"unit-5": {
tile: "from-[#a78bfa] to-[#6d28d9]",
unitCard: "border-[#c4b5fd] bg-[#f5f0ff]",
chip: "bg-[#6d28d9]",
},
}; };
const topicTiles = [ const topicTiles = [

View File

@@ -0,0 +1,593 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
type Mode = "number-line" | "rules";
interface Step {
label: string;
math: string;
numberLineHighlight?: {
start: number;
end: number;
direction: "left" | "right";
moves: number;
};
}
function buildAddSubtractSteps(a: number, b: number, op: "add" | "subtract"): Step[] {
const steps: Step[] = [];
const opSymbol = op === "add" ? "+" : "-";
const expression = `${a} ${opSymbol} ${b < 0 ? `(${b})` : b}`;
// Step 0: Show the original expression
steps.push({
label: "Start with the expression",
math: expression,
numberLineHighlight: { start: a, end: a, direction: "right", moves: 0 },
});
if (op === "add") {
// Adding
steps.push({
label: `Place your finger on ${a} on the number line`,
math: `\\text{Start at } ${a}`,
numberLineHighlight: { start: a, end: a, direction: "right", moves: 0 },
});
if (b >= 0) {
steps.push({
label: `Adding a positive number: move ${b} steps to the RIGHT`,
math: `${a} + ${b} \\longrightarrow \\text{move right } ${b}`,
numberLineHighlight: { start: a, end: a + b, direction: "right", moves: b },
});
} else {
steps.push({
label: `Adding a negative number: move ${Math.abs(b)} steps to the LEFT`,
math: `${a} + (${b}) \\longrightarrow \\text{move left } ${Math.abs(b)}`,
numberLineHighlight: { start: a, end: a + b, direction: "left", moves: Math.abs(b) },
});
}
const result = a + b;
steps.push({
label: `You land on ${result}`,
math: `${expression} = ${result}`,
numberLineHighlight: { start: a, end: result, direction: b >= 0 ? "right" : "left", moves: Math.abs(b) },
});
// Sign rule explanation
if (a >= 0 && b >= 0) {
steps.push({
label: "Rule: Positive + Positive = Positive",
math: `(+) + (+) = (+) \\quad \\Rightarrow \\quad ${expression} = ${result}`,
numberLineHighlight: { start: a, end: result, direction: "right", moves: Math.abs(b) },
});
} else if (a < 0 && b < 0) {
steps.push({
label: "Rule: Negative + Negative = Negative (add magnitudes, keep negative sign)",
math: `(-) + (-) = (-) \\quad \\Rightarrow \\quad ${expression} = ${result}`,
numberLineHighlight: { start: a, end: result, direction: "left", moves: Math.abs(b) },
});
} else {
steps.push({
label: "Rule: Different signs? Subtract the smaller from the larger, keep the sign of the larger",
math: `|${Math.abs(a)}| - |${Math.abs(b)}| = ${Math.abs(Math.abs(a) - Math.abs(b))} \\quad \\Rightarrow \\quad ${expression} = ${result}`,
numberLineHighlight: { start: a, end: result, direction: result >= a ? "right" : "left", moves: Math.abs(b) },
});
}
} else {
// Subtracting
steps.push({
label: `Place your finger on ${a} on the number line`,
math: `\\text{Start at } ${a}`,
numberLineHighlight: { start: a, end: a, direction: "right", moves: 0 },
});
if (b >= 0) {
steps.push({
label: `Subtracting a positive number: move ${b} steps to the LEFT`,
math: `${a} - ${b} \\longrightarrow \\text{move left } ${b}`,
numberLineHighlight: { start: a, end: a - b, direction: "left", moves: b },
});
} else {
steps.push({
label: `Subtracting a negative number is the same as ADDING a positive! Move ${Math.abs(b)} steps RIGHT`,
math: `${a} - (${b}) = ${a} + ${Math.abs(b)} \\longrightarrow \\text{move right } ${Math.abs(b)}`,
numberLineHighlight: { start: a, end: a - b, direction: "right", moves: Math.abs(b) },
});
}
const result = a - b;
steps.push({
label: `You land on ${result}`,
math: `${expression} = ${result}`,
numberLineHighlight: { start: a, end: result, direction: result >= a ? "right" : "left", moves: Math.abs(b) },
});
// Two like/unlike signs rule
if (b >= 0) {
steps.push({
label: "Two unlike signs (+ -) becomes negative: subtract means move left",
math: `+(-)\\text{ becomes } - \\quad \\Rightarrow \\quad ${expression} = ${result}`,
numberLineHighlight: { start: a, end: result, direction: "left", moves: Math.abs(b) },
});
} else {
steps.push({
label: "Two like signs (- -) becomes positive: subtracting a negative means move right!",
math: `-(-) \\text{ becomes } + \\quad \\Rightarrow \\quad ${expression} = ${result}`,
numberLineHighlight: { start: a, end: result, direction: "right", moves: Math.abs(b) },
});
}
}
return steps;
}
function NumberLine({
highlight,
animate,
}: {
highlight?: Step["numberLineHighlight"];
animate: boolean;
}) {
const minVal = -15;
const maxVal = 15;
const range = maxVal - minVal;
const start = highlight?.start ?? 0;
const end = highlight?.end ?? 0;
const startX = ((start - minVal) / range) * 100;
const endX = ((end - minVal) / range) * 100;
const leftX = Math.min(startX, endX);
const width = Math.abs(endX - startX);
return (
<div className="mx-auto w-full max-w-2xl select-none">
<div className="relative h-24 px-4">
{/* Arrow path highlight */}
{highlight && highlight.moves > 0 && (
<div
className="absolute top-5 h-3 rounded-full transition-all duration-700"
style={{
left: `calc(${leftX}% + 16px)`,
width: `${width}%`,
backgroundColor: highlight.direction === "right"
? "rgba(124, 58, 237, 0.25)"
: "rgba(220, 38, 38, 0.25)",
}}
/>
)}
{/* Direction arrow */}
{highlight && highlight.moves > 0 && (
<div
className="absolute top-1 text-xs font-bold transition-all duration-500"
style={{
left: `calc(${(startX + endX) / 2}% + 16px)`,
transform: "translateX(-50%)",
color: highlight.direction === "right" ? "var(--unit-5)" : "var(--incorrect)",
}}
>
{highlight.direction === "right" ? "→ right →" : "← left ←"}
<div className="text-center text-[10px] font-semibold opacity-70">
{highlight.moves} step{highlight.moves !== 1 ? "s" : ""}
</div>
</div>
)}
{/* Number line */}
<div className="absolute bottom-8 left-4 right-4">
<div className="h-0.5 bg-foreground/40" />
{/* Tick marks */}
{Array.from({ length: range + 1 }, (_, i) => {
const val = minVal + i;
const x = (i / range) * 100;
const isZero = val === 0;
const isHighlighted = highlight && (val === start || val === end);
const isInRange = highlight && highlight.moves > 0 &&
val >= Math.min(start, end) && val <= Math.max(start, end);
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 ${
isHighlighted
? "bg-unit-5 h-4"
: isZero
? "bg-foreground"
: isInRange
? "bg-unit-5/50"
: "bg-foreground/30"
}`}
/>
{(val % 5 === 0 || isHighlighted) && (
<span
className={`mt-1 block text-center text-[10px] transition-all duration-300 ${
isHighlighted
? "text-xs font-extrabold text-unit-5"
: isZero
? "font-bold text-foreground"
: "text-muted/70"
}`}
>
{val}
</span>
)}
</div>
);
})}
</div>
{/* Start marker */}
{highlight && (
<div
className="absolute bottom-16 -translate-x-1/2 transition-all duration-500"
style={{ left: `calc(${startX}% + 16px)` }}
>
<div className="flex flex-col items-center">
<span className="rounded-full bg-unit-5 px-2 py-0.5 text-[10px] font-bold text-white shadow-md">
Start
</span>
<div className="h-3 w-0.5 bg-unit-5" />
</div>
</div>
)}
{/* End marker */}
{highlight && highlight.moves > 0 && animate && (
<div
className="absolute bottom-16 -translate-x-1/2 transition-all duration-700"
style={{ left: `calc(${endX}% + 16px)` }}
>
<div className="flex flex-col items-center">
<span className="rounded-full bg-correct px-2 py-0.5 text-[10px] font-bold text-white shadow-md">
End: {end}
</span>
<div className="h-3 w-0.5 bg-correct" />
</div>
</div>
)}
</div>
{/* Arrows on edges */}
<div className="flex justify-between px-2 text-xs text-muted/50">
<span> negative</span>
<span>positive </span>
</div>
</div>
);
}
function SignRuleCard({ rule, active }: { rule: string; active: boolean }) {
return (
<div
className={`rounded-xl border-2 px-3 py-2 text-center text-sm font-semibold transition-all duration-300 ${
active
? "border-unit-5 bg-unit-5-light text-unit-5-dark scale-105 shadow-md"
: "border-border/40 bg-surface text-muted"
}`}
>
{rule}
</div>
);
}
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)];
const a = Math.floor(Math.random() * 21) - 10;
const b = Math.floor(Math.random() * 21) - 10;
const answer = op === "+" ? a + b : a - b;
return { a, b, op, answer };
}
function checkAnswer() {
const parsed = parseInt(userAnswer);
if (isNaN(parsed)) return;
const isCorrect = parsed === problem.answer;
setFeedback(isCorrect ? "correct" : "incorrect");
setScore((prev) => ({
correct: prev.correct + (isCorrect ? 1 : 0),
total: prev.total + 1,
}));
}
function nextProblem() {
setProblem(generateProblem());
setUserAnswer("");
setFeedback(null);
}
const displayB = problem.b < 0 ? `(${problem.b})` : `${problem.b}`;
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>
<div className="flex flex-wrap items-center justify-center gap-3">
<span className="text-2xl font-extrabold">
{problem.a} {problem.op} {displayB} =
</span>
<input
type="number"
value={userAnswer}
onChange={(e) => {
setUserAnswer(e.target.value);
setFeedback(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (feedback !== null) nextProblem();
else checkAnswer();
}
}}
className={`w-20 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-5"
}`}
placeholder="?"
aria-label="Your answer"
/>
{feedback === null ? (
<button
onClick={checkAnswer}
className="rounded-lg bg-unit-5 px-5 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-5-dark"
>
Check
</button>
) : (
<button
onClick={nextProblem}
className="rounded-lg bg-foreground px-5 py-2.5 text-sm font-bold text-background transition-colors hover:bg-foreground/80"
>
Next
</button>
)}
</div>
{feedback === "correct" && (
<p className="text-center text-sm font-bold text-correct">Correct! Well done!</p>
)}
{feedback === "incorrect" && (
<p className="text-center text-sm font-bold text-incorrect">
Not quite. The answer is {problem.answer}. Try the next one!
</p>
)}
</Card>
);
}
export function IntegerAddSubtractExplorer() {
const [mode, setMode] = useState<Mode>("number-line");
const [op, setOp] = useState<"add" | "subtract">("add");
const [inputA, setInputA] = useState("-3");
const [inputB, setInputB] = 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;
function handleGo() {
setError("");
const a = parseInt(inputA);
const b = parseInt(inputB);
if (isNaN(a) || isNaN(b)) {
setError("Enter valid integers.");
return;
}
if (Math.abs(a) > 15 || Math.abs(b) > 15) {
setError("Keep numbers between -15 and 15 for the number line.");
return;
}
const result = op === "add" ? a + b : a - b;
if (Math.abs(result) > 15) {
setError("Result goes off the number line. Try smaller numbers.");
return;
}
try {
const s = buildAddSubtractSteps(a, b, op);
setSteps(s);
setCurrentStep(0);
setIsPlaying(false);
} catch {
setError("Could not compute. Check your inputs.");
}
}
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 reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
const step = steps ? steps[currentStep] : null;
const addRules = [
{ rule: "(+) + (+) = (+)", active: step?.label.includes("Positive + Positive") ?? false },
{ rule: "(-) + (-) = (-)", active: step?.label.includes("Negative + Negative") ?? false },
{ rule: "Different signs? Subtract, keep sign of larger", active: step?.label.includes("Different signs") ?? false },
];
const subRules = [
{ rule: "+() becomes ", active: step?.label.includes("unlike signs") ?? false },
{ rule: "() becomes +", active: step?.label.includes("like signs") ?? false },
];
return (
<div className="space-y-4">
{/* Mode toggle */}
<div className="flex flex-wrap gap-2">
{(["number-line", "rules"] as Mode[]).map((m) => (
<button
key={m}
onClick={() => {
setMode(m);
reset();
}}
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" : "Sign Rules"}
</button>
))}
</div>
{/* Operation tabs */}
<div className="flex flex-wrap gap-2">
{(["add", "subtract"] as const).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-5-dark bg-unit-5-dark text-white"
: "border-unit-5/30 text-unit-5-dark hover:bg-unit-5-light"
}`}
>
{o === "add" ? "Add (+)" : "Subtract ()"}
</button>
))}
</div>
{/* Input Card */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-5">
Enter two integers
</p>
<div className="flex flex-wrap items-center gap-3">
<input
type="number"
value={inputA}
onChange={(e) => setInputA(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">
{op === "add" ? "+" : ""}
</span>
<input
type="number"
value={inputB}
onChange={(e) => setInputB(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>
{/* Sign Rules Reference */}
{mode === "rules" && (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{(op === "add" ? addRules : subRules).map((r) => (
<SignRuleCard key={r.rule} rule={r.rule} active={r.active} />
))}
</div>
)}
{/* Display Card with Number Line */}
<Card className="flex min-h-[280px] flex-col items-center justify-center gap-4 p-6">
{!step ? (
<p className="text-muted/50">
Enter integers above and click <strong>Go</strong>
</p>
) : (
<>
<p className="text-sm font-medium text-muted">{step.label}</p>
<MathDisplay math={step.math} className="text-2xl" />
{mode === "number-line" && (
<NumberLine
highlight={step.numberLineHighlight}
animate={currentStep > 0}
/>
)}
</>
)}
</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>
{/* Result */}
<Card className="flex min-h-[80px] flex-col items-center justify-center gap-1.5 text-center">
{!done || !steps ? (
<p className="text-sm text-muted/40">Result will appear here when steps are complete</p>
) : (
<>
<p className="text-xs uppercase tracking-wider text-muted">Answer</p>
<div className="text-3xl font-extrabold max-sm:text-2xl">
<MathDisplay math={steps[steps.length - 1].math} />
</div>
</>
)}
</Card>
{/* Quick Practice */}
<QuickPractice />
</div>
);
}

View File

@@ -0,0 +1,708 @@
"use client";
import { useState, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { StepControls } from "./step-controls";
import { MathDisplay } from "@/components/math/math-display";
type Tab = "multiply" | "divide" | "thermometer";
interface Step {
label: string;
math: string;
signVisual?: { signA: "+" | "-"; signB: "+" | "-"; resultSign: "+" | "-" };
thermometer?: { startTemp: number; change: number; endTemp: number };
}
function getSign(n: number): "+" | "-" {
return n >= 0 ? "+" : "-";
}
function buildMultiplySteps(a: number, b: number): Step[] {
const steps: Step[] = [];
const result = a * b;
const signA = getSign(a);
const signB = getSign(b);
const signR = getSign(result);
steps.push({
label: "Start with the multiplication",
math: `${a < 0 ? `(${a})` : a} \\times ${b < 0 ? `(${b})` : b}`,
});
// Step: Multiply the absolute values
steps.push({
label: `First, multiply the absolute values: ${Math.abs(a)} × ${Math.abs(b)}`,
math: `|${a}| \\times |${b}| = ${Math.abs(a)} \\times ${Math.abs(b)} = ${Math.abs(result)}`,
});
// Step: Determine the sign
const sameSign = (a >= 0 && b >= 0) || (a < 0 && b < 0);
if (sameSign) {
steps.push({
label: `Same signs (${signA} × ${signB}): the result is POSITIVE`,
math: `(${signA}) \\times (${signB}) = (+)`,
signVisual: { signA, signB, resultSign: "+" },
});
} else {
steps.push({
label: `Different signs (${signA} × ${signB}): the result is NEGATIVE`,
math: `(${signA}) \\times (${signB}) = (-)`,
signVisual: { signA, signB, resultSign: "-" },
});
}
// Step: Combine
steps.push({
label: "Combine the magnitude and sign",
math: `${a < 0 ? `(${a})` : a} \\times ${b < 0 ? `(${b})` : b} = ${result}`,
signVisual: { signA, signB, resultSign: signR },
});
// Story time for negative × negative
if (a < 0 && b < 0) {
steps.push({
label: "Why does negative × negative = positive? Think of it like a double-negative in English!",
math: `\\text{\"Do NOT not eat!\"} = \\text{\"Eat!\"} \\quad (-) \\times (-) = (+)`,
signVisual: { signA: "-", signB: "-", resultSign: "+" },
});
}
return steps;
}
function buildDivideSteps(a: number, b: number): Step[] {
const steps: Step[] = [];
const result = a / b;
const signA = getSign(a);
const signB = getSign(b);
const signR = getSign(result);
const isWhole = Number.isInteger(result);
const displayResult = isWhole ? result.toString() : result.toFixed(2);
steps.push({
label: "Start with the division",
math: `${a < 0 ? `(${a})` : a} \\div ${b < 0 ? `(${b})` : b}`,
});
steps.push({
label: `First, divide the absolute values: ${Math.abs(a)} ÷ ${Math.abs(b)}`,
math: `|${a}| \\div |${b}| = ${Math.abs(a)} \\div ${Math.abs(b)} = ${isWhole ? Math.abs(result) : Math.abs(result).toFixed(2)}`,
});
const sameSign = (a >= 0 && b >= 0) || (a < 0 && b < 0);
if (sameSign) {
steps.push({
label: `Same signs (${signA} ÷ ${signB}): the result is POSITIVE`,
math: `(${signA}) \\div (${signB}) = (+)`,
signVisual: { signA, signB, resultSign: "+" },
});
} else {
steps.push({
label: `Different signs (${signA} ÷ ${signB}): the result is NEGATIVE`,
math: `(${signA}) \\div (${signB}) = (-)`,
signVisual: { signA, signB, resultSign: "-" },
});
}
steps.push({
label: "Combine the magnitude and sign",
math: `${a < 0 ? `(${a})` : a} \\div ${b < 0 ? `(${b})` : b} = ${displayResult}`,
signVisual: { signA, signB, resultSign: signR },
});
return steps;
}
function buildThermometerSteps(startTemp: number, change: number): Step[] {
const steps: Step[] = [];
const endTemp = startTemp + change;
const direction = change >= 0 ? "rose" : "dropped";
const absChange = Math.abs(change);
steps.push({
label: "Read the starting temperature on the thermometer",
math: `\\text{Starting temperature: } ${startTemp}°C`,
thermometer: { startTemp, change: 0, endTemp: startTemp },
});
steps.push({
label: `The temperature ${direction} by ${absChange}°C`,
math: change >= 0
? `${startTemp}°C + ${absChange}°C`
: `${startTemp}°C - ${absChange}°C`,
thermometer: { startTemp, change: 0, endTemp: startTemp },
});
steps.push({
label: change >= 0 ? "Rising temperature means we ADD" : "Dropping temperature means we SUBTRACT",
math: `${startTemp} + (${change}) = ${endTemp}`,
thermometer: { startTemp, change, endTemp },
});
steps.push({
label: `The new temperature is ${endTemp}°C`,
math: `\\text{New temperature: } ${endTemp}°C`,
thermometer: { startTemp, change, endTemp },
});
return steps;
}
function SignRuleGrid({ visual, operation }: { visual?: Step["signVisual"]; operation: "×" | "÷" }) {
const rules = [
{ a: "+", b: "+", result: "+", example: operation === "×" ? "3 × 2 = 6" : "6 ÷ 2 = 3" },
{ a: "-", b: "-", result: "+", example: operation === "×" ? "(-3) × (-2) = 6" : "(-6) ÷ (-2) = 3" },
{ a: "+", b: "-", result: "-", example: operation === "×" ? "3 × (-2) = -6" : "6 ÷ (-2) = -3" },
{ a: "-", b: "+", result: "-", example: operation === "×" ? "(-3) × 2 = -6" : "(-6) ÷ 2 = -3" },
];
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{rules.map((r) => {
const isActive = visual && visual.signA === r.a && visual.signB === r.b;
return (
<div
key={`${r.a}${r.b}`}
className={`rounded-xl border-2 p-3 text-center transition-all duration-300 ${
isActive
? "border-unit-5 bg-unit-5-light shadow-md scale-105"
: "border-border/40 bg-surface"
}`}
>
<p className={`text-lg font-extrabold ${isActive ? "text-unit-5-dark" : "text-foreground"}`}>
({r.a}) {operation} ({r.b}) = ({r.result})
</p>
<p className={`mt-1 text-xs ${isActive ? "text-unit-5-dark/70" : "text-muted"}`}>
{r.example}
</p>
</div>
);
})}
</div>
);
}
function Thermometer({ data }: { data?: Step["thermometer"] }) {
const minTemp = -20;
const maxTemp = 30;
const range = maxTemp - minTemp;
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 = [];
for (let t = minTemp; t <= maxTemp; t += 5) {
ticks.push(t);
}
return (
<div className="mx-auto flex items-end gap-6">
{/* Thermometer */}
<div className="relative flex flex-col items-center">
<div className="relative h-64 w-8 overflow-hidden rounded-t-full border-2 border-foreground/30 bg-gradient-to-b from-red-50 to-blue-50">
{/* Mercury fill */}
<div
className="absolute bottom-0 left-0 right-0 rounded-t-sm transition-all duration-700"
style={{
height: `${Math.max(0, Math.min(100, fillY))}%`,
background: endTemp >= 0
? `linear-gradient(to top, #dc2626, #f97316)`
: `linear-gradient(to top, #2563eb, #60a5fa)`,
}}
/>
{/* Tick marks */}
{ticks.map((t) => {
const y = ((t - minTemp) / range) * 100;
return (
<div
key={t}
className="absolute left-0 right-0 flex items-center"
style={{ bottom: `${y}%`, transform: "translateY(50%)" }}
>
<div className={`h-px flex-1 ${t === 0 ? "bg-foreground" : "bg-foreground/20"}`} />
</div>
);
})}
</div>
{/* Bulb */}
<div className="relative -mt-1 flex h-12 w-12 items-center justify-center rounded-full border-2 border-foreground/30 bg-red-500">
<span className="text-[8px] font-bold text-white">°C</span>
</div>
</div>
{/* Scale labels */}
<div className="relative h-64">
{ticks.map((t) => {
const y = ((t - minTemp) / range) * 100;
const isStart = t === startTemp;
const isEnd = data && data.change !== 0 && t === endTemp;
return (
<div
key={t}
className="absolute flex items-center gap-1"
style={{ bottom: `${y}%`, transform: "translateY(50%)" }}
>
<span
className={`text-xs transition-all duration-300 ${
isEnd
? "font-extrabold text-correct text-sm"
: isStart
? "font-bold text-unit-5"
: t === 0
? "font-semibold text-foreground"
: "text-muted/60"
}`}
>
{t}°C
</span>
{isStart && (
<span className="rounded bg-unit-5 px-1.5 py-0.5 text-[9px] font-bold text-white">
START
</span>
)}
{isEnd && (
<span className="rounded bg-correct px-1.5 py-0.5 text-[9px] font-bold text-white">
END
</span>
)}
</div>
);
})}
</div>
</div>
);
}
function ThermometerPractice() {
const scenarios = [
{ city: "London", startTemp: -3, change: 7, question: "The temperature rose by 7°C. What is the new temperature?" },
{ city: "Moscow", startTemp: -12, change: -5, question: "The temperature dropped by 5°C. What is the new temperature?" },
{ city: "Kingston", startTemp: 28, change: -10, question: "The temperature dropped by 10°C. What is the new temperature?" },
{ city: "Ottawa", startTemp: -8, change: 15, question: "The temperature rose by 15°C. What is the new temperature?" },
{ city: "Reykjavik", startTemp: -1, change: -6, question: "The temperature dropped by 6°C. What is the new temperature?" },
];
const [scenarioIdx, setScenarioIdx] = useState(0);
const [userAnswer, setUserAnswer] = useState("");
const [feedback, setFeedback] = useState<"correct" | "incorrect" | null>(null);
const scenario = scenarios[scenarioIdx];
const correctAnswer = scenario.startTemp + scenario.change;
function check() {
const parsed = parseInt(userAnswer);
if (isNaN(parsed)) return;
setFeedback(parsed === correctAnswer ? "correct" : "incorrect");
}
function next() {
setScenarioIdx((i) => (i + 1) % scenarios.length);
setUserAnswer("");
setFeedback(null);
}
return (
<Card className="space-y-4">
<p className="text-xs font-bold uppercase tracking-wider text-unit-5">
Real-World Challenge
</p>
<div className="rounded-xl border-2 border-unit-5/20 bg-unit-5-light/50 p-4">
<p className="text-sm font-bold text-unit-5-dark">
In {scenario.city}, the temperature is {scenario.startTemp}°C.
</p>
<p className="mt-1 text-sm text-unit-5-dark/80">{scenario.question}</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<input
type="number"
value={userAnswer}
onChange={(e) => {
setUserAnswer(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-lg 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-5"
}`}
placeholder="°C"
aria-label="Temperature answer"
/>
<span className="text-sm font-semibold text-muted">°C</span>
{feedback === null ? (
<button
onClick={check}
className="rounded-lg bg-unit-5 px-5 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-5-dark"
>
Check
</button>
) : (
<button
onClick={next}
className="rounded-lg bg-foreground px-5 py-2.5 text-sm font-bold text-background transition-colors hover:bg-foreground/80"
>
Next City
</button>
)}
</div>
{feedback === "correct" && (
<p className="text-sm font-bold text-correct">
Correct! {scenario.startTemp} + ({scenario.change}) = {correctAnswer}°C
</p>
)}
{feedback === "incorrect" && (
<p className="text-sm font-bold text-incorrect">
Not quite. {scenario.startTemp} + ({scenario.change}) = {correctAnswer}°C
</p>
)}
</Card>
);
}
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)];
if (op === "×") {
const a = Math.floor(Math.random() * 25) - 12;
const b = Math.floor(Math.random() * 25) - 12;
return { a, b, op, answer: a * b };
} else {
// Generate division with clean integer results
const b = Math.floor(Math.random() * 11) - 5;
const nonZeroB = b === 0 ? 3 : b;
const answer = Math.floor(Math.random() * 21) - 10;
const a = answer * nonZeroB;
return { a, b: nonZeroB, op, answer };
}
}
function check() {
const parsed = parseInt(userAnswer);
if (isNaN(parsed)) return;
const isCorrect = parsed === problem.answer;
setFeedback(isCorrect ? "correct" : "incorrect");
setScore((prev) => ({
correct: prev.correct + (isCorrect ? 1 : 0),
total: prev.total + 1,
}));
}
function next() {
setProblem(generateProblem());
setUserAnswer("");
setFeedback(null);
}
const displayA = problem.a < 0 ? `(${problem.a})` : `${problem.a}`;
const displayB = problem.b < 0 ? `(${problem.b})` : `${problem.b}`;
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>
<div className="flex flex-wrap items-center justify-center gap-3">
<span className="text-2xl font-extrabold">
{displayA} {problem.op} {displayB} =
</span>
<input
type="number"
value={userAnswer}
onChange={(e) => {
setUserAnswer(e.target.value);
setFeedback(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (feedback !== null) next();
else check();
}
}}
className={`w-20 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-5"
}`}
placeholder="?"
aria-label="Your answer"
/>
{feedback === null ? (
<button onClick={check} className="rounded-lg bg-unit-5 px-5 py-2.5 text-sm font-bold text-white transition-colors hover:bg-unit-5-dark">
Check
</button>
) : (
<button onClick={next} className="rounded-lg bg-foreground px-5 py-2.5 text-sm font-bold text-background transition-colors 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 {problem.answer}.
</p>
)}
</Card>
);
}
export function IntegerMultiplyDivideExplorer() {
const [tab, setTab] = useState<Tab>("multiply");
const [inputA, setInputA] = useState("-3");
const [inputB, setInputB] = useState("5");
const [startTemp, setStartTemp] = useState("-3");
const [tempChange, setTempChange] = useState("7");
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;
function handleGo() {
setError("");
if (tab === "thermometer") {
const st = parseInt(startTemp);
const ch = parseInt(tempChange);
if (isNaN(st) || isNaN(ch)) {
setError("Enter valid integers.");
return;
}
if (Math.abs(st) > 20 || Math.abs(ch) > 30) {
setError("Keep temperatures reasonable (-20 to 30).");
return;
}
const s = buildThermometerSteps(st, ch);
setSteps(s);
setCurrentStep(0);
setIsPlaying(false);
return;
}
const a = parseInt(inputA);
const b = parseInt(inputB);
if (isNaN(a) || isNaN(b)) {
setError("Enter valid integers.");
return;
}
if (tab === "divide" && b === 0) {
setError("Cannot divide by zero!");
return;
}
if (Math.abs(a) > 99 || Math.abs(b) > 99) {
setError("Keep numbers under 100.");
return;
}
if (tab === "divide" && !Number.isInteger(a / b)) {
setError("Choose numbers that divide evenly for now.");
return;
}
try {
const s = tab === "multiply" ? buildMultiplySteps(a, b) : buildDivideSteps(a, b);
setSteps(s);
setCurrentStep(0);
setIsPlaying(false);
} catch {
setError("Could not compute. Check your inputs.");
}
}
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 reset = useCallback(() => {
setSteps(null);
setCurrentStep(0);
setIsPlaying(false);
}, []);
const step = steps ? steps[currentStep] : null;
return (
<div className="space-y-4">
{/* Tab selector */}
<div className="flex flex-wrap gap-2">
{(["multiply", "divide", "thermometer"] as Tab[]).map((t) => (
<button
key={t}
onClick={() => {
setTab(t);
reset();
}}
className={`rounded-full border-2 px-4 py-2 text-sm font-semibold transition-colors ${
tab === t
? "border-unit-5 bg-unit-5 text-white"
: "border-unit-5/40 text-unit-5 hover:bg-unit-5-light"
}`}
>
{t === "multiply"
? "Multiply (×)"
: t === "divide"
? "Divide (÷)"
: "Thermometer"}
</button>
))}
</div>
{/* Input Card */}
<Card>
<p className="mb-3 text-xs font-bold uppercase tracking-wider text-unit-5">
{tab === "thermometer" ? "Enter temperature scenario" : "Enter two integers"}
</p>
{tab === "thermometer" ? (
<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-col">
<label className="mb-1 text-xs font-semibold text-muted">Start temp (°C)</label>
<input
type="number"
value={startTemp}
onChange={(e) => setStartTemp(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="Starting temperature"
/>
</div>
<div className="flex flex-col">
<label className="mb-1 text-xs font-semibold text-muted">Change (°C)</label>
<input
type="number"
value={tempChange}
onChange={(e) => setTempChange(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="Temperature change"
/>
</div>
<button
onClick={handleGo}
className="mt-5 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>
) : (
<div className="flex flex-wrap items-center gap-3">
<input
type="number"
value={inputA}
onChange={(e) => setInputA(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">
{tab === "multiply" ? "×" : "÷"}
</span>
<input
type="number"
value={inputB}
onChange={(e) => setInputB(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>
{/* Sign Rules Grid (for multiply/divide) */}
{tab !== "thermometer" && (
<SignRuleGrid
visual={step?.signVisual}
operation={tab === "multiply" ? "×" : "÷"}
/>
)}
{/* Display Card */}
<Card className="flex min-h-[300px] 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-sm font-medium text-muted">{step.label}</p>
<MathDisplay math={step.math} className="text-2xl" />
{step.thermometer && <Thermometer data={step.thermometer} />}
</>
)}
</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>
{/* Result */}
<Card className="flex min-h-[80px] flex-col items-center justify-center gap-1.5 text-center">
{!done || !steps ? (
<p className="text-sm text-muted/40">Result will appear here when steps are complete</p>
) : (
<>
<p className="text-xs uppercase tracking-wider text-muted">Answer</p>
<div className="text-3xl font-extrabold max-sm:text-2xl">
<MathDisplay math={steps[steps.length - 1].math} />
</div>
</>
)}
</Card>
{/* Practice sections */}
{tab === "thermometer" ? <ThermometerPractice /> : <MultiplyDividePractice />}
</div>
);
}

View File

@@ -11,6 +11,7 @@ const unitColorMap = {
"unit-2": "text-unit-2-dark", "unit-2": "text-unit-2-dark",
"unit-3": "text-unit-3-dark", "unit-3": "text-unit-3-dark",
"unit-4": "text-unit-4-dark", "unit-4": "text-unit-4-dark",
"unit-5": "text-unit-5-dark",
}; };
const unitDotColor = { const unitDotColor = {
@@ -18,6 +19,7 @@ const unitDotColor = {
"unit-2": "bg-unit-2", "unit-2": "bg-unit-2",
"unit-3": "bg-unit-3", "unit-3": "bg-unit-3",
"unit-4": "bg-unit-4", "unit-4": "bg-unit-4",
"unit-5": "bg-unit-5",
}; };
export function MobileNav() { export function MobileNav() {

View File

@@ -31,6 +31,12 @@ const unitColorMap = {
heading: "text-unit-4-dark", heading: "text-unit-4-dark",
hoverBg: "hover:bg-unit-4-light/50", hoverBg: "hover:bg-unit-4-light/50",
}, },
"unit-5": {
active: "bg-unit-5-light text-unit-5-dark border-unit-5/20",
dot: "bg-unit-5",
heading: "text-unit-5-dark",
hoverBg: "hover:bg-unit-5-light/50",
},
}; };
export function Sidebar() { export function Sidebar() {

View File

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

View File

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

View File

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

View File

@@ -7,12 +7,12 @@ export interface Topic {
} }
export interface Unit { export interface Unit {
number: 1 | 2 | 3 | 4; number: 1 | 2 | 3 | 4 | 5;
slug: string; slug: string;
title: string; title: string;
description: string; description: string;
weeks: string; weeks: string;
color: "unit-1" | "unit-2" | "unit-3" | "unit-4"; color: "unit-1" | "unit-2" | "unit-3" | "unit-4" | "unit-5";
topics: Topic[]; topics: Topic[];
} }
@@ -176,6 +176,30 @@ export const curriculum: Unit[] = [
}, },
], ],
}, },
{
number: 5,
slug: "unit-5-integers",
title: "Integers",
description: "Add, subtract, multiply, divide integers and apply to real-world problems",
weeks: "Weeks 2-4",
color: "unit-5",
topics: [
{
slug: "add-subtract",
title: "Add and Subtract Integers",
shortTitle: "Add & Subtract",
week: 2,
description: "Use number lines and sign rules to add and subtract positive and negative numbers",
},
{
slug: "multiply-divide",
title: "Multiply and Divide Integers",
shortTitle: "Multiply & Divide",
week: 3,
description: "Sign rules for multiplication and division with real-world thermometer problems",
},
],
},
]; ];
export function getUnit(slug: string): Unit | undefined { export function getUnit(slug: string): Unit | undefined {
@@ -187,12 +211,13 @@ export function getTopic(unitSlug: string, topicSlug: string): Topic | undefined
return unit?.topics.find((t) => t.slug === topicSlug); return unit?.topics.find((t) => t.slug === topicSlug);
} }
export function getUnitColor(unitNumber: 1 | 2 | 3 | 4): string { export function getUnitColor(unitNumber: 1 | 2 | 3 | 4 | 5): string {
const colors = { const colors = {
1: "unit-1", 1: "unit-1",
2: "unit-2", 2: "unit-2",
3: "unit-3", 3: "unit-3",
4: "unit-4", 4: "unit-4",
5: "unit-5",
}; };
return colors[unitNumber]; return colors[unitNumber];
} }

123
package-lock.json generated
View File

@@ -11,9 +11,9 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.34.3", "framer-motion": "^12.34.3",
"katex": "^0.16.33", "katex": "^0.16.33",
"next": "16.1.6", "next": "^16.2.1",
"react": "19.2.3", "react": "^19.2.3",
"react-dom": "19.2.3", "react-dom": "^19.2.3",
"tailwind-merge": "^3.5.0" "tailwind-merge": "^3.5.0"
}, },
"devDependencies": { "devDependencies": {
@@ -975,9 +975,10 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "16.1.6", "version": "16.2.1",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.1.tgz",
"integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==" "integrity": "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==",
"license": "MIT"
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
"version": "16.1.6", "version": "16.1.6",
@@ -989,12 +990,13 @@
} }
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "16.1.6", "version": "16.2.1",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.1.tgz",
"integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", "integrity": "sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@@ -1004,12 +1006,13 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "16.1.6", "version": "16.2.1",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.1.tgz",
"integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", "integrity": "sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@@ -1019,12 +1022,13 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.1.6", "version": "16.2.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.1.tgz",
"integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", "integrity": "sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -1034,12 +1038,13 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "16.1.6", "version": "16.2.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.1.tgz",
"integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", "integrity": "sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -1049,12 +1054,13 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "16.1.6", "version": "16.2.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.1.tgz",
"integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", "integrity": "sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -1064,12 +1070,13 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "16.1.6", "version": "16.2.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.1.tgz",
"integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", "integrity": "sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -1079,12 +1086,13 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.1.6", "version": "16.2.1",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.1.tgz",
"integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", "integrity": "sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -1094,12 +1102,13 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "16.1.6", "version": "16.2.1",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.1.tgz",
"integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", "integrity": "sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -3395,10 +3404,11 @@
} }
}, },
"node_modules/flatted": { "node_modules/flatted": {
"version": "3.3.3", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true "dev": true,
"license": "ISC"
}, },
"node_modules/for-each": { "node_modules/for-each": {
"version": "0.3.5", "version": "0.3.5",
@@ -4737,13 +4747,14 @@
"dev": true "dev": true
}, },
"node_modules/next": { "node_modules/next": {
"version": "16.1.6", "version": "16.2.1",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", "resolved": "https://registry.npmjs.org/next/-/next-16.2.1.tgz",
"integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", "integrity": "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==",
"license": "MIT",
"dependencies": { "dependencies": {
"@next/env": "16.1.6", "@next/env": "16.2.1",
"@swc/helpers": "0.5.15", "@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.8.3", "baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579", "caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31", "postcss": "8.4.31",
"styled-jsx": "5.1.6" "styled-jsx": "5.1.6"
@@ -4755,15 +4766,15 @@
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-arm64": "16.2.1",
"@next/swc-darwin-x64": "16.1.6", "@next/swc-darwin-x64": "16.2.1",
"@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-gnu": "16.2.1",
"@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-arm64-musl": "16.2.1",
"@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-gnu": "16.2.1",
"@next/swc-linux-x64-musl": "16.1.6", "@next/swc-linux-x64-musl": "16.2.1",
"@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-arm64-msvc": "16.2.1",
"@next/swc-win32-x64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.2.1",
"sharp": "^0.34.4" "sharp": "^0.34.5"
}, },
"peerDependencies": { "peerDependencies": {
"@opentelemetry/api": "^1.1.0", "@opentelemetry/api": "^1.1.0",
@@ -5060,10 +5071,11 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.1", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
}, },
@@ -5161,6 +5173,7 @@
"version": "19.2.3", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -5169,6 +5182,7 @@
"version": "19.2.3", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -5819,10 +5833,11 @@
} }
}, },
"node_modules/tinyglobby/node_modules/picomatch": { "node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },

View File

@@ -12,9 +12,9 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.34.3", "framer-motion": "^12.34.3",
"katex": "^0.16.33", "katex": "^0.16.33",
"next": "16.1.6", "next": "^16.2.1",
"react": "19.2.3", "react": "^19.2.3",
"react-dom": "19.2.3", "react-dom": "^19.2.3",
"tailwind-merge": "^3.5.0" "tailwind-merge": "^3.5.0"
}, },
"devDependencies": { "devDependencies": {