diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..069f0d8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.next +.git +*.md +term-2-unit.pdf +.env* +.claude diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b072522 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM node:20-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --ignore-scripts + +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..2d69862 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,58 @@ +pipeline { + agent any + + environment { + REGISTRY = 'registry.dwarrington.com' + IMAGE_NAME = 'cabrits' + IMAGE_TAG = "${env.BUILD_NUMBER}" + CREDENTIALS_ID = 'registry-creds' + SUBDIR = '' + COMPOSE_FILE = 'docker-compose.yml' + SERVICE_NAME = 'cabrits' + } + + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + stage('Build & Push') { + steps { + script { + docker.withRegistry("https://${REGISTRY}", CREDENTIALS_ID) { + def buildArgs = (!env.SUBDIR || env.SUBDIR.trim() == '') + ? "-f Dockerfile ." + : "-f ${env.SUBDIR}/Dockerfile ${env.SUBDIR}" + + def img = docker.build("${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}", buildArgs) + img.push() + img.push('latest') + } + } + } + } + + stage('Deploy') { + steps { + withCredentials([usernamePassword( + credentialsId: CREDENTIALS_ID, + usernameVariable: 'DOCKER_USER', + passwordVariable: 'DOCKER_PASS' + )]) { + sh ''' + export PATH=$PATH:/usr/libexec/docker/cli-plugins:/usr/bin:/usr/local/bin + + echo "$DOCKER_PASS" | docker login ${REGISTRY} -u "$DOCKER_USER" --password-stdin + + sed -i "/${SERVICE_NAME}:/,/image:/s|image:.*|image: ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}|" ${COMPOSE_FILE} + + docker compose -f ${COMPOSE_FILE} pull ${SERVICE_NAME} + docker compose -f ${COMPOSE_FILE} up -d --force-recreate --remove-orphans + ''' + } + } + } + } +} diff --git a/app/globals.css b/app/globals.css index a2dc41e..4a4f9fc 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,26 +1,183 @@ @import "tailwindcss"; +@import "katex/dist/katex.min.css"; :root { - --background: #ffffff; - --foreground: #171717; + --background: #f4f8ff; + --foreground: #10223f; + --muted: #4f6588; + --border: #cbdaef; + --surface: #FFFFFF; + --surface-raised: #FFFFFF; + + --unit-1: #2563eb; + --unit-1-light: #e8f1ff; + --unit-1-dark: #1d4ed8; + + --unit-2: #0ea5a4; + --unit-2-light: #e6fffb; + --unit-2-dark: #0f766e; + + --unit-3: #f97316; + --unit-3-light: #fff1e7; + --unit-3-dark: #c2410c; + + --unit-4: #e11d48; + --unit-4-light: #ffe8ef; + --unit-4-dark: #be123c; + + --correct: #16a34a; + --correct-light: #dcfce7; + --incorrect: #dc2626; + --incorrect-light: #fee2e2; + --hint: #d97706; + --hint-light: #ffedd5; + + --shadow-sm: 0 1px 2px rgb(16 34 63 / 0.06), 0 2px 8px rgb(37 99 235 / 0.04); + --shadow-md: 0 8px 18px rgb(16 34 63 / 0.09), 0 2px 8px rgb(37 99 235 / 0.08); + --shadow-lg: 0 16px 30px rgb(16 34 63 / 0.11), 0 6px 20px rgb(37 99 235 / 0.1); + --shadow-xl: 0 24px 44px rgb(16 34 63 / 0.14), 0 12px 28px rgb(37 99 235 / 0.12); + --shadow-glow: 0 0 24px rgb(37 99 235 / 0.18); } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); + --color-muted: var(--muted); + --color-border: var(--border); + --color-surface: var(--surface); + --color-surface-raised: var(--surface-raised); + + --color-unit-1: var(--unit-1); + --color-unit-1-light: var(--unit-1-light); + --color-unit-1-dark: var(--unit-1-dark); + --color-unit-2: var(--unit-2); + --color-unit-2-light: var(--unit-2-light); + --color-unit-2-dark: var(--unit-2-dark); + --color-unit-3: var(--unit-3); + --color-unit-3-light: var(--unit-3-light); + --color-unit-3-dark: var(--unit-3-dark); + --color-unit-4: var(--unit-4); + --color-unit-4-light: var(--unit-4-light); + --color-unit-4-dark: var(--unit-4-dark); + + --color-correct: var(--correct); + --color-correct-light: var(--correct-light); + --color-incorrect: var(--incorrect); + --color-incorrect-light: var(--incorrect-light); + --color-hint: var(--hint); + --color-hint-light: var(--hint-light); + + --font-sans: "Nunito", "Trebuchet MS", "Segoe UI", sans-serif; + --font-display: "Avenir Next", "Poppins", "Trebuchet MS", sans-serif; + --font-mono: "JetBrains Mono", "Fira Code", "Consolas", monospace; } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } +html { + scroll-behavior: smooth; } body { - background: var(--background); + background: + radial-gradient(circle at 15% 0%, color-mix(in srgb, var(--unit-1) 9%, transparent), transparent 46%), + radial-gradient(circle at 90% 8%, color-mix(in srgb, var(--unit-3) 9%, transparent), transparent 34%), + radial-gradient(circle at 85% 88%, color-mix(in srgb, var(--unit-2) 7%, transparent), transparent 40%), + linear-gradient(180deg, #f7fbff 0%, var(--background) 100%); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + font-family: var(--font-sans); + text-wrap: pretty; +} + +h1, +h2, +h3, +h4 { + font-family: var(--font-display); + letter-spacing: -0.02em; +} + +::selection { + background: color-mix(in srgb, var(--unit-1) 20%, transparent); +} + +/* Subtle gradient background pattern */ +.hero-gradient { + background: + radial-gradient(ellipse 100% 70% at 55% -15%, color-mix(in srgb, var(--unit-1) 14%, transparent), transparent), + radial-gradient(ellipse 70% 40% at 95% 45%, color-mix(in srgb, var(--unit-3) 12%, transparent), transparent), + radial-gradient(ellipse 70% 50% at 10% 75%, color-mix(in srgb, var(--unit-2) 10%, transparent), transparent), + linear-gradient(180deg, rgb(255 255 255 / 0.94), rgb(255 255 255 / 0.82)); +} + +/* Dot pattern overlay */ +.dot-pattern { + background-image: + linear-gradient(to right, rgb(203 218 239 / 0.45) 1px, transparent 1px), + linear-gradient(to bottom, rgb(203 218 239 / 0.45) 1px, transparent 1px); + background-size: 22px 22px; +} + +@keyframes float-card { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-3px); + } +} + +.float-card { + animation: float-card 3.2s ease-in-out infinite; +} + +.playground-bg { + background: linear-gradient(180deg, #2f65e8 0%, #2a5fdc 100%); +} + +.playground-frame { + border-left: 4px solid #1e4fc6; + border-right: 4px solid #1e4fc6; + background: #ffffff; + box-shadow: 0 16px 40px rgb(15 35 86 / 0.22); +} + +.section-ribbon { + border: 2px solid #1f4fbe; + background: linear-gradient(180deg, #2f65e8 0%, #2051c3 100%); + color: white; +} + +.game-thumb-grid { + background-image: + linear-gradient(90deg, rgb(255 255 255 / 0.22) 1px, transparent 1px), + linear-gradient(0deg, rgb(255 255 255 / 0.22) 1px, transparent 1px); + background-size: 14px 14px; +} + +/* Smooth scrollbar */ +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--muted); +} + +/* Focus ring utility */ +.focus-ring { + outline: 2px solid transparent; + outline-offset: 2px; +} + +.focus-ring:focus-visible { + outline-color: var(--unit-1); } diff --git a/app/layout.tsx b/app/layout.tsx index f7fa87e..d4f8262 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,20 +1,10 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Cabrits Math Lab", + description: + "Build confidence in mathematics with interactive lessons and practice for secondary school students.", }; export default function RootLayout({ @@ -24,9 +14,7 @@ export default function RootLayout({ }>) { return ( - + {children} diff --git a/app/lessons/layout.tsx b/app/lessons/layout.tsx new file mode 100644 index 0000000..3bd86c8 --- /dev/null +++ b/app/lessons/layout.tsx @@ -0,0 +1,28 @@ +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"; + +export default function LessonsLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+
+ +
+
+ +
+
+ {children} +
+
+
+
+ ); +} diff --git a/app/lessons/page.tsx b/app/lessons/page.tsx new file mode 100644 index 0000000..15f8008 --- /dev/null +++ b/app/lessons/page.tsx @@ -0,0 +1,42 @@ +import Link from "next/link"; +import { curriculum } from "@/lib/curriculum"; +import { Card } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; + +export default function LessonsOverview() { + return ( +
+

All Topics

+

Form 1, Term 2 — Select a topic to explore

+ + {curriculum.map((unit) => ( +
+
+ Unit {unit.number} +

{unit.title}

+ {unit.weeks} +
+
+ {unit.topics.map((topic) => ( + + +

{topic.title}

+

{topic.description}

+
+ Week {topic.week} + + Explore + + + + +
+
+ + ))} +
+
+ ))} +
+ ); +} diff --git a/app/lessons/unit-1-fractions/add-subtract/page.tsx b/app/lessons/unit-1-fractions/add-subtract/page.tsx new file mode 100644 index 0000000..01f2b02 --- /dev/null +++ b/app/lessons/unit-1-fractions/add-subtract/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Breadcrumbs } from "@/components/layout/breadcrumbs"; +import { FractionOperationExplorer } from "@/components/explorers/fraction-operation-explorer"; + +export default function AddSubtractPage() { + return ( +
+ + +

Add and Subtract Fractions

+ + +
+ ); +} diff --git a/app/lessons/unit-1-fractions/divide/page.tsx b/app/lessons/unit-1-fractions/divide/page.tsx new file mode 100644 index 0000000..836d530 --- /dev/null +++ b/app/lessons/unit-1-fractions/divide/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Breadcrumbs } from "@/components/layout/breadcrumbs"; +import { FractionOperationExplorer } from "@/components/explorers/fraction-operation-explorer"; + +export default function DividePage() { + return ( +
+ + +

Divide Fractions

+ + +
+ ); +} diff --git a/app/lessons/unit-1-fractions/fraction-of-quantity/page.tsx b/app/lessons/unit-1-fractions/fraction-of-quantity/page.tsx new file mode 100644 index 0000000..9617d87 --- /dev/null +++ b/app/lessons/unit-1-fractions/fraction-of-quantity/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Breadcrumbs } from "@/components/layout/breadcrumbs"; +import { FractionQuantityExplorer } from "@/components/explorers/fraction-quantity-explorer"; + +export default function FractionOfQuantityPage() { + return ( +
+ + +

Fraction of a Quantity

+ + +
+ ); +} diff --git a/app/lessons/unit-1-fractions/mixed-operations/page.tsx b/app/lessons/unit-1-fractions/mixed-operations/page.tsx new file mode 100644 index 0000000..0bb9d59 --- /dev/null +++ b/app/lessons/unit-1-fractions/mixed-operations/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Breadcrumbs } from "@/components/layout/breadcrumbs"; +import { BODMASExplorer } from "@/components/explorers/bodmas-explorer"; + +export default function MixedOperationsPage() { + return ( +
+ + +

Mixed Operations (BODMAS)

+ + +
+ ); +} diff --git a/app/lessons/unit-1-fractions/multiply/page.tsx b/app/lessons/unit-1-fractions/multiply/page.tsx new file mode 100644 index 0000000..9e49ac2 --- /dev/null +++ b/app/lessons/unit-1-fractions/multiply/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Breadcrumbs } from "@/components/layout/breadcrumbs"; +import { FractionOperationExplorer } from "@/components/explorers/fraction-operation-explorer"; + +export default function MultiplyPage() { + return ( +
+ + +

Multiply Fractions

+ + +
+ ); +} diff --git a/app/lessons/unit-1-fractions/page.tsx b/app/lessons/unit-1-fractions/page.tsx new file mode 100644 index 0000000..70d32cc --- /dev/null +++ b/app/lessons/unit-1-fractions/page.tsx @@ -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 Unit1Overview() { + const unit = curriculum[0]; + + return ( +
+ +
+ Unit 1 — {unit.weeks} +

{unit.title}

+

{unit.description}

+
+
+ {unit.topics.map((topic, i) => ( + + +
+ + {i + 1} + + Week {topic.week} +
+

{topic.title}

+

{topic.description}

+
+ + ))} +
+
+ ); +} diff --git a/app/lessons/unit-1-fractions/whole-from-fractions/page.tsx b/app/lessons/unit-1-fractions/whole-from-fractions/page.tsx new file mode 100644 index 0000000..7d363b8 --- /dev/null +++ b/app/lessons/unit-1-fractions/whole-from-fractions/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Breadcrumbs } from "@/components/layout/breadcrumbs"; +import { FractionQuantityExplorer } from "@/components/explorers/fraction-quantity-explorer"; + +export default function WholeFromFractionsPage() { + return ( +
+ + +

Calculate the Whole from Fractions

+ + +
+ ); +} diff --git a/app/lessons/unit-2-decimals/approximate/page.tsx b/app/lessons/unit-2-decimals/approximate/page.tsx new file mode 100644 index 0000000..46b815a --- /dev/null +++ b/app/lessons/unit-2-decimals/approximate/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Breadcrumbs } from "@/components/layout/breadcrumbs"; +import { RoundingExplorer } from "@/components/explorers/rounding-explorer"; + +export default function ApproximatePage() { + return ( +
+ + +

Approximate Decimals

+ + +
+ ); +} diff --git a/app/lessons/unit-2-decimals/compare-order/page.tsx b/app/lessons/unit-2-decimals/compare-order/page.tsx new file mode 100644 index 0000000..c6496cc --- /dev/null +++ b/app/lessons/unit-2-decimals/compare-order/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Breadcrumbs } from "@/components/layout/breadcrumbs"; +import { DecimalOrderExplorer } from "@/components/explorers/decimal-order-explorer"; + +export default function CompareOrderPage() { + return ( +
+ + +

Compare and Order Decimals

+ + +
+ ); +} diff --git a/app/lessons/unit-2-decimals/page.tsx b/app/lessons/unit-2-decimals/page.tsx new file mode 100644 index 0000000..174b144 --- /dev/null +++ b/app/lessons/unit-2-decimals/page.tsx @@ -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 Unit2Overview() { + const unit = curriculum[1]; + + return ( +
+ +
+ Unit 2 — {unit.weeks} +

{unit.title}

+

{unit.description}

+
+
+ {unit.topics.map((topic, i) => ( + + +
+ + {i + 7} + + Week {topic.week} +
+

{topic.title}

+

{topic.description}

+
+ + ))} +
+
+ ); +} diff --git a/app/lessons/unit-2-decimals/standard-form/page.tsx b/app/lessons/unit-2-decimals/standard-form/page.tsx new file mode 100644 index 0000000..7982f73 --- /dev/null +++ b/app/lessons/unit-2-decimals/standard-form/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Breadcrumbs } from "@/components/layout/breadcrumbs"; +import { StandardFormExplorer } from "@/components/explorers/standard-form-explorer"; + +export default function StandardFormPage() { + return ( +
+ + +

Standard Form (Scientific Notation)

+ + +
+ ); +} diff --git a/app/lessons/unit-3-decimal-operations/add-subtract/page.tsx b/app/lessons/unit-3-decimal-operations/add-subtract/page.tsx new file mode 100644 index 0000000..2f71631 --- /dev/null +++ b/app/lessons/unit-3-decimal-operations/add-subtract/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Breadcrumbs } from "@/components/layout/breadcrumbs"; +import { DecimalArithmeticExplorer } from "@/components/explorers/decimal-arithmetic-explorer"; + +export default function AddSubtractPage() { + return ( +
+ + +

Add and Subtract Decimals

+ + +
+ ); +} diff --git a/app/lessons/unit-3-decimal-operations/convert/page.tsx b/app/lessons/unit-3-decimal-operations/convert/page.tsx new file mode 100644 index 0000000..82d9b38 --- /dev/null +++ b/app/lessons/unit-3-decimal-operations/convert/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Breadcrumbs } from "@/components/layout/breadcrumbs"; +import { ConversionExplorer } from "@/components/explorers/conversion-explorer"; + +export default function ConvertPage() { + return ( +
+ + +

Convert Decimals and Fractions

+ + +
+ ); +} diff --git a/app/lessons/unit-3-decimal-operations/multiply-divide/page.tsx b/app/lessons/unit-3-decimal-operations/multiply-divide/page.tsx new file mode 100644 index 0000000..47b4172 --- /dev/null +++ b/app/lessons/unit-3-decimal-operations/multiply-divide/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Breadcrumbs } from "@/components/layout/breadcrumbs"; +import { DecimalArithmeticExplorer } from "@/components/explorers/decimal-arithmetic-explorer"; + +export default function MultiplyDividePage() { + return ( +
+ + +

Multiply and Divide Decimals

+ + +
+ ); +} diff --git a/app/lessons/unit-3-decimal-operations/page.tsx b/app/lessons/unit-3-decimal-operations/page.tsx new file mode 100644 index 0000000..c0d5c36 --- /dev/null +++ b/app/lessons/unit-3-decimal-operations/page.tsx @@ -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 Unit3Overview() { + const unit = curriculum[2]; + + return ( +
+ +
+ Unit 3 — {unit.weeks} +

{unit.title}

+

{unit.description}

+
+
+ {unit.topics.map((topic, i) => ( + + +
+ + {i + 10} + + Week {topic.week} +
+

{topic.title}

+

{topic.description}

+
+ + ))} +
+
+ ); +} diff --git a/app/lessons/unit-4-ratio-proportion/define-ratio/page.tsx b/app/lessons/unit-4-ratio-proportion/define-ratio/page.tsx new file mode 100644 index 0000000..b9a2f9c --- /dev/null +++ b/app/lessons/unit-4-ratio-proportion/define-ratio/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Breadcrumbs } from "@/components/layout/breadcrumbs"; +import { RatioExplorer } from "@/components/explorers/ratio-explorer"; + +export default function DefineRatioPage() { + return ( +
+ + +

Define a Ratio

+ + +
+ ); +} diff --git a/app/lessons/unit-4-ratio-proportion/divide-in-ratio/page.tsx b/app/lessons/unit-4-ratio-proportion/divide-in-ratio/page.tsx new file mode 100644 index 0000000..4070ed0 --- /dev/null +++ b/app/lessons/unit-4-ratio-proportion/divide-in-ratio/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Breadcrumbs } from "@/components/layout/breadcrumbs"; +import { RatioExplorer } from "@/components/explorers/ratio-explorer"; + +export default function DivideInRatioPage() { + return ( +
+ + +

Divide a Quantity in a Given Ratio

+ + +
+ ); +} diff --git a/app/lessons/unit-4-ratio-proportion/fractions-and-ratios/page.tsx b/app/lessons/unit-4-ratio-proportion/fractions-and-ratios/page.tsx new file mode 100644 index 0000000..69c7d43 --- /dev/null +++ b/app/lessons/unit-4-ratio-proportion/fractions-and-ratios/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Breadcrumbs } from "@/components/layout/breadcrumbs"; +import { RatioExplorer } from "@/components/explorers/ratio-explorer"; + +export default function FractionsAndRatiosPage() { + return ( +
+ + +

Fractions and Ratios

+ + +
+ ); +} diff --git a/app/lessons/unit-4-ratio-proportion/page.tsx b/app/lessons/unit-4-ratio-proportion/page.tsx new file mode 100644 index 0000000..c4d543f --- /dev/null +++ b/app/lessons/unit-4-ratio-proportion/page.tsx @@ -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 Unit4Overview() { + const unit = curriculum[3]; + + return ( +
+ +
+ Unit 4 — {unit.weeks} +

{unit.title}

+

{unit.description}

+
+
+ {unit.topics.map((topic, i) => ( + + +
+ + {String.fromCharCode(97 + i)} + + Week {topic.week} +
+

{topic.title}

+

{topic.description}

+
+ + ))} +
+
+ ); +} diff --git a/app/lessons/unit-4-ratio-proportion/simplify-ratios/page.tsx b/app/lessons/unit-4-ratio-proportion/simplify-ratios/page.tsx new file mode 100644 index 0000000..bb8fc56 --- /dev/null +++ b/app/lessons/unit-4-ratio-proportion/simplify-ratios/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Breadcrumbs } from "@/components/layout/breadcrumbs"; +import { RatioExplorer } from "@/components/explorers/ratio-explorer"; + +export default function SimplifyRatiosPage() { + return ( +
+ + +

Simplify Ratios

+ + +
+ ); +} diff --git a/app/lessons/unit-4-ratio-proportion/word-problems/page.tsx b/app/lessons/unit-4-ratio-proportion/word-problems/page.tsx new file mode 100644 index 0000000..8d581a7 --- /dev/null +++ b/app/lessons/unit-4-ratio-proportion/word-problems/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Breadcrumbs } from "@/components/layout/breadcrumbs"; +import { RatioExplorer } from "@/components/explorers/ratio-explorer"; + +export default function WordProblemsPage() { + return ( +
+ + +

Proportional Parts Word Problems

+ + +
+ ); +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..0706160 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,37 @@ +import Link from "next/link"; +import { Header } from "@/components/layout/header"; +import { Footer } from "@/components/layout/footer"; + +export default function NotFound() { + return ( +
+
+
+
+
+

+ Page not found +

+

+ + 404 + +

+

+ This page doesn't exist or has been moved. +

+ + + + + Back to Home + +
+
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index 295f8fd..5ba59db 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,65 +1,133 @@ -import Image from "next/image"; +import Link from "next/link"; +import { Header } from "@/components/layout/header"; +import { Footer } from "@/components/layout/footer"; +import { curriculum } from "@/lib/curriculum"; + +const unitStyles = { + "unit-1": { + tile: "from-[#4e95ff] to-[#1f63ea]", + unitCard: "border-[#8cb2ff] bg-[#eaf2ff]", + chip: "bg-[#2059cc]", + }, + "unit-2": { + tile: "from-[#29c6c5] to-[#0f8d8c]", + unitCard: "border-[#89e5dd] bg-[#eafffb]", + chip: "bg-[#0f8d8c]", + }, + "unit-3": { + tile: "from-[#ffab55] to-[#e97617]", + unitCard: "border-[#ffd2a3] bg-[#fff4e8]", + chip: "bg-[#d26a17]", + }, + "unit-4": { + tile: "from-[#ff5f85] to-[#d11b56]", + unitCard: "border-[#ffb2c7] bg-[#fff0f4]", + chip: "bg-[#c6174e]", + }, +}; + +const topicTiles = [ + "from-[#4e95ff] via-[#357fe9] to-[#1f63ea]", + "from-[#2ac3be] via-[#1da8a3] to-[#0f8d8c]", + "from-[#ffb067] via-[#f2913a] to-[#d86e16]", + "from-[#ff6f98] via-[#ef3d72] to-[#cb1c54]", + "from-[#7d95ff] via-[#6078f2] to-[#3f56d9]", + "from-[#4ccf7b] via-[#2fb45f] to-[#1f944b]", + "from-[#ff8f8f] via-[#f26b6b] to-[#d94545]", + "from-[#5eb7ff] via-[#3f99ef] to-[#2a7fda]", +]; export default function Home() { + const allTopics = curriculum.flatMap((unit) => + unit.topics.map((topic) => ({ + unit, + topic, + })), + ); + return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. +

+
+
+
+

+ Interactive maths practice for secondary school students

-
-
- - Vercel logomark - Deploy Now - - - Documentation - -
+

+ Math Topics Students Love to Explore +

+

+ Explore each topic with visual examples, short explanations, and guided lessons. + Pick a unit, jump into a topic, and start solving. +

+
+ + Browse Lessons + + + Start Practice + +
+ + +
+

+ Unit Worlds +

+
+ {curriculum.map((unit) => ( + +
+

{unit.title}

+ + Unit {unit.number} + +
+

{unit.description}

+

{unit.weeks}

+ + ))} +
+
+ +
+

+ Topic Explorer +

+
+ {allTopics.map(({ unit, topic }, index) => ( + +
+ + Week {topic.week} + + + {topic.shortTitle} + +
+

+ {topic.title} +

+ + ))} +
+
+
); } diff --git a/app/practice/page.tsx b/app/practice/page.tsx new file mode 100644 index 0000000..8364b14 --- /dev/null +++ b/app/practice/page.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useState } from "react"; +import { Card } from "@/components/ui/card"; +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 type { Difficulty, MathProblem } from "@/lib/problems/types"; + +import { generateFractionAddSubtract, generateFractionMultiply, generateFractionDivide, generateFractionOfQuantity, generateWholeFromFraction } 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"; + +interface TopicGenerator { + unitSlug: string; + topicSlug: string; + label: string; + generator: (difficulty: Difficulty) => MathProblem; + unitColor: "unit-1" | "unit-2" | "unit-3" | "unit-4"; +} + +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 = { + "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", + }, +}; + +export default function PracticePage() { + const [selectedTopic, setSelectedTopic] = useState(null); + const [filterUnit, setFilterUnit] = useState(null); + + const filtered = filterUnit + ? TOPIC_GENERATORS.filter((t) => t.unitColor === filterUnit) + : TOPIC_GENERATORS; + + return ( +
+
+
+
+

Practice

+

+ Pick a topic and build your confidence with fresh questions each round. +

+ + {/* Unit filters */} +
+ + {(["unit-1", "unit-2", "unit-3", "unit-4"] as const).map((uc) => ( + + ))} +
+ + {/* Topic grid */} + {!selectedTopic && ( +
+ {filtered.map((topic) => ( + setSelectedTopic(topic)} + > +
+ {topic.label} + + {unitColors[topic.unitColor].label} + +
+
+ ))} +
+ )} + + {/* Practice area */} + {selectedTopic && ( +
+ + + +
+ )} +
+
+
+
+ ); +} diff --git a/components/explorers/bodmas-explorer.tsx b/components/explorers/bodmas-explorer.tsx new file mode 100644 index 0000000..4fe9340 --- /dev/null +++ b/components/explorers/bodmas-explorer.tsx @@ -0,0 +1,338 @@ +"use client"; + +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"; + +interface FractionVal { + num: number; + den: number; +} + +interface Expression { + fractions: FractionVal[]; + operators: string[]; +} + +interface Step { + label: string; + math: string; + highlightIndex: number | null; // which operator is being evaluated +} + +const OP_PRIORITY: Record = { + "×": 2, + "÷": 2, + "+": 1, + "−": 1, +}; + +function performOp(a: FractionVal, b: FractionVal, op: string): FractionVal { + let result: [number, number]; + switch (op) { + case "+": + result = add(a.num, a.den, b.num, b.den); + break; + case "−": + result = subtract(a.num, a.den, b.num, b.den); + break; + case "×": + result = multiply(a.num, a.den, b.num, b.den); + break; + case "÷": + result = divide(a.num, a.den, b.num, b.den); + break; + default: + result = [0, 1]; + } + return { num: result[0], den: result[1] }; +} + +function expressionToKatex(fracs: FractionVal[], ops: string[], highlightIdx: number | null): string { + const parts: string[] = []; + fracs.forEach((f, i) => { + parts.push(toKatex(f.num, f.den)); + if (i < ops.length) { + if (i === highlightIdx) { + parts.push(`\\;\\boxed{${ops[i]}}\\;`); + } else { + parts.push(`\\;${ops[i]}\\;`); + } + } + }); + return parts.join(""); +} + +function buildBodmasSteps(expr: Expression): Step[] { + const steps: Step[] = []; + let fracs = [...expr.fractions]; + let ops = [...expr.operators]; + + // Step 0: Show the expression + steps.push({ + label: "Start with the expression", + math: expressionToKatex(fracs, ops, null), + highlightIndex: null, + }); + + while (ops.length > 0) { + // Find highest priority operator (leftmost if tie) + let maxPri = 0; + let opIdx = 0; + ops.forEach((op, i) => { + if (OP_PRIORITY[op] > maxPri) { + maxPri = OP_PRIORITY[op]; + opIdx = i; + } + }); + + // Highlight the operation to perform + steps.push({ + label: `BODMAS: do ${ops[opIdx]} first (${OP_PRIORITY[ops[opIdx]] === 2 ? "multiplication/division" : "addition/subtraction"})`, + math: expressionToKatex(fracs, ops, opIdx), + highlightIndex: opIdx, + }); + + // Perform the operation + const result = performOp(fracs[opIdx], fracs[opIdx + 1], ops[opIdx]); + const [sn, sd] = simplify(result.num, result.den); + + const newFracs = [...fracs]; + newFracs.splice(opIdx, 2, { num: sn, den: sd }); + const newOps = [...ops]; + newOps.splice(opIdx, 1); + + steps.push({ + label: `${fracs[opIdx].num}/${fracs[opIdx].den} ${ops[opIdx]} ${fracs[opIdx + 1].num}/${fracs[opIdx + 1].den} = ${sn}/${sd}`, + math: expressionToKatex(newFracs, newOps, null), + highlightIndex: null, + }); + + fracs = newFracs; + ops = newOps; + } + + return steps; +} + +const PRESETS: { label: string; fracs: FractionVal[]; ops: string[] }[] = [ + { + label: "½ + ¼ × ⅗", + fracs: [ + { num: 1, den: 2 }, + { num: 1, den: 4 }, + { num: 3, den: 5 }, + ], + ops: ["+", "×"], + }, + { + label: "⅔ × ¾ − ½", + fracs: [ + { num: 2, den: 3 }, + { num: 3, den: 4 }, + { num: 1, den: 2 }, + ], + ops: ["×", "−"], + }, + { + label: "⅗ ÷ ¼ + ⅔", + fracs: [ + { num: 3, den: 5 }, + { num: 1, den: 4 }, + { num: 2, den: 3 }, + ], + ops: ["÷", "+"], + }, +]; + +export function BODMASExplorer() { + const [fracs, setFracs] = useState([ + { num: 1, den: 2 }, + { num: 1, den: 4 }, + { num: 3, den: 5 }, + ]); + const [ops, setOps] = useState(["+", "×"]); + const [error, setError] = useState(""); + + const [steps, setSteps] = useState(null); + const [currentStep, setCurrentStep] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + + const done = steps ? currentStep >= steps.length - 1 : false; + + function updateFrac(idx: number, field: "num" | "den", value: string) { + setFracs((prev) => { + const next = [...prev]; + next[idx] = { ...next[idx], [field]: parseInt(value) || 0 }; + return next; + }); + } + + function handleGo() { + setError(""); + if (fracs.some((f) => f.den === 0)) { + setError("Denominators cannot be zero."); + return; + } + if (fracs.some((f) => Math.abs(f.num) > 99 || Math.abs(f.den) > 99)) { + setError("Keep numbers under 100."); + return; + } + try { + const s = buildBodmasSteps({ fractions: fracs, operators: ops }); + setSteps(s); + setCurrentStep(0); + setIsPlaying(false); + } catch { + setError("Could not compute. Check your inputs."); + } + } + + function loadPreset(idx: number) { + const p = PRESETS[idx]; + setFracs([...p.fracs]); + setOps([...p.ops]); + reset(); + } + + function stepForward() { + if (!steps || currentStep >= steps.length - 1) return; + setCurrentStep((s) => s + 1); + if (currentStep + 1 >= steps.length - 1) setIsPlaying(false); + } + + function stepBack() { + if (currentStep <= 0) return; + setCurrentStep((s) => s - 1); + } + + function togglePlay() { + setIsPlaying((p) => !p); + } + + function reset() { + setSteps(null); + setCurrentStep(0); + setIsPlaying(false); + } + + const step = steps ? steps[currentStep] : null; + + const opChoices = ["+", "−", "×", "÷"]; + + return ( +
+ {/* Presets */} +
+ {PRESETS.map((p, i) => ( + + ))} +
+ + {/* Input */} + +

+ Enter a BODMAS expression with fractions +

+
+ {fracs.map((f, i) => ( +
+
+ updateFrac(i, "num", e.target.value)} + className="w-12 rounded-lg border-2 border-border bg-surface px-1.5 py-1 text-center text-base font-bold outline-none focus:border-unit-1" + aria-label={`Numerator ${i + 1}`} + /> +
+ updateFrac(i, "den", e.target.value)} + className="w-12 rounded-lg border-2 border-border bg-surface px-1.5 py-1 text-center text-base font-bold outline-none focus:border-unit-1" + aria-label={`Denominator ${i + 1}`} + /> +
+ {i < ops.length && ( + + )} +
+ ))} + +
+ {error &&

{error}

} + + + {/* Display */} + + {!step ? ( +

+ Enter an expression above and click Go +

+ ) : ( + <> +

{step.label}

+ + + )} +
+ + {/* Controls */} + + 0} + /> + + + {/* Result */} + + {!done || !steps ? ( +

Result will appear here when steps are complete

+ ) : ( + <> +

Final Answer

+
+ +
+ + )} +
+
+ ); +} diff --git a/components/explorers/conversion-explorer.tsx b/components/explorers/conversion-explorer.tsx new file mode 100644 index 0000000..4923a65 --- /dev/null +++ b/components/explorers/conversion-explorer.tsx @@ -0,0 +1,345 @@ +"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"; +import { simplify, toKatex, gcd } from "@/lib/math/fractions"; + +type ConvertMode = "decToFrac" | "fracToDec"; + +interface Step { + label: string; + math: string; + gridFilled?: number; // out of 100 for decimal grid + barFilled?: number; + barTotal?: number; +} + +function buildDecToFracSteps(decimal: string): Step[] { + const steps: Step[] = []; + const val = parseFloat(decimal); + + steps.push({ + label: `Convert ${decimal} to a fraction`, + math: `${decimal} = \\;?`, + gridFilled: Math.round(val * 100), + }); + + // Count decimal places + const parts = decimal.split("."); + const decPlaces = parts[1]?.length || 0; + const denominator = Math.pow(10, decPlaces); + const numerator = Math.round(val * denominator); + + steps.push({ + label: `${decPlaces} decimal place${decPlaces !== 1 ? "s" : ""} → denominator is ${denominator}`, + math: `${decimal} = ${toKatex(numerator, denominator)}`, + gridFilled: Math.round(val * 100), + barFilled: numerator, + barTotal: denominator, + }); + + // Simplify + const [sn, sd] = simplify(numerator, denominator); + if (sn !== numerator || sd !== denominator) { + const g = gcd(numerator, denominator); + steps.push({ + label: `Simplify by dividing by GCD = ${g}`, + math: `${toKatex(numerator, denominator)} = ${toKatex(sn, sd)}`, + gridFilled: Math.round(val * 100), + barFilled: sn, + barTotal: sd, + }); + } + + steps.push({ + label: `Result: ${decimal} = ${sn}/${sd}`, + math: `${decimal} = ${toKatex(sn, sd)}`, + gridFilled: Math.round(val * 100), + barFilled: sn, + barTotal: sd, + }); + + return steps; +} + +function buildFracToDecSteps(num: number, den: number): Step[] { + const steps: Step[] = []; + + steps.push({ + label: `Convert ${num}/${den} to a decimal`, + math: `${toKatex(num, den)} = \\;?`, + barFilled: num, + barTotal: den, + }); + + // Step: Perform the division + const result = num / den; + const resultStr = Number.isInteger(result) ? result.toString() : result.toFixed(6).replace(/0+$/, ""); + + steps.push({ + label: `Divide numerator by denominator`, + math: `${num} \\div ${den} = ${resultStr}`, + barFilled: num, + barTotal: den, + gridFilled: Math.min(100, Math.round(result * 100)), + }); + + // Check for terminating vs repeating + let tempDen = den; + const [, simpDen] = simplify(num, den); + tempDen = simpDen; + // Remove factors of 2 and 5 + while (tempDen % 2 === 0) tempDen /= 2; + while (tempDen % 5 === 0) tempDen /= 5; + const isTerminating = tempDen === 1; + + steps.push({ + label: isTerminating + ? `This is a terminating decimal` + : `This is a recurring decimal`, + math: `${toKatex(num, den)} = ${resultStr}${!isTerminating ? "..." : ""}`, + barFilled: num, + barTotal: den, + gridFilled: Math.min(100, Math.round(result * 100)), + }); + + return steps; +} + +function DecimalGrid({ filled }: { filled: number }) { + return ( +
+ {Array.from({ length: 100 }, (_, i) => ( +
+ ))} +
+ ); +} + +function FractionBarSmall({ filled, total }: { filled: number; total: number }) { + const segments = Math.min(total, 20); + const fillCount = Math.min(filled, segments); + return ( +
+ {Array.from({ length: segments }, (_, i) => ( +
+ ))} +
+ ); +} + +export function ConversionExplorer() { + const [mode, setMode] = useState("decToFrac"); + const [decInput, setDecInput] = useState("0.75"); + const [numInput, setNumInput] = useState("3"); + const [denInput, setDenInput] = useState("8"); + const [error, setError] = useState(""); + + const [steps, setSteps] = useState(null); + const [currentStep, setCurrentStep] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + + const done = steps ? currentStep >= steps.length - 1 : false; + + function handleGo() { + setError(""); + try { + let s: Step[]; + if (mode === "decToFrac") { + const val = parseFloat(decInput); + if (isNaN(val) || val < 0 || val >= 10) { + setError("Enter a valid decimal between 0 and 10."); + return; + } + s = buildDecToFracSteps(decInput.trim()); + } else { + const n = parseInt(numInput); + const d = parseInt(denInput); + if (isNaN(n) || isNaN(d) || d === 0) { + setError("Enter a valid fraction (denominator ≠ 0)."); + return; + } + s = buildFracToDecSteps(n, d); + } + setSteps(s); + setCurrentStep(0); + setIsPlaying(false); + } catch { + setError("Could not compute. 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 reset = useCallback(() => { + setSteps(null); + setCurrentStep(0); + setIsPlaying(false); + }, []); + + const step = steps ? steps[currentStep] : null; + + return ( +
+ {/* Mode tabs */} +
+ + +
+ + {/* Input */} + +

+ {mode === "decToFrac" ? "Enter a decimal" : "Enter a fraction"} +

+
+ {mode === "decToFrac" ? ( + setDecInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleGo()} + placeholder="e.g. 0.75" + autoComplete="off" + className="max-w-[180px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-3" + /> + ) : ( +
+ setNumInput(e.target.value)} + className="w-14 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-3" + aria-label="Numerator" + /> +
+ setDenInput(e.target.value)} + className="w-14 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-3" + aria-label="Denominator" + /> +
+ )} + +
+ {error &&

{error}

} + + + {/* Display */} + + {!step ? ( +

+ Enter a value above and click Convert +

+ ) : ( + <> +

{step.label}

+ +
+ {step.gridFilled !== undefined && ( +
+ + {step.gridFilled}/100 +
+ )} + {step.barFilled !== undefined && step.barTotal !== undefined && ( +
+ + + {step.barFilled}/{step.barTotal} + +
+ )} +
+ + )} +
+ + {/* Controls */} + + 0} + /> + + + {/* Result */} + + {!done || !steps ? ( +

Result will appear here when steps are complete

+ ) : ( + <> +

Answer

+
+ +
+ + )} +
+
+ ); +} diff --git a/components/explorers/decimal-arithmetic-explorer.tsx b/components/explorers/decimal-arithmetic-explorer.tsx new file mode 100644 index 0000000..2efcc82 --- /dev/null +++ b/components/explorers/decimal-arithmetic-explorer.tsx @@ -0,0 +1,403 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { Card } from "@/components/ui/card"; +import { StepControls } from "./step-controls"; + +type DecOp = "add" | "subtract" | "multiply" | "divide"; + +interface Step { + label: string; + columns: ColumnDisplay[]; + carry?: string; + resultRow?: string; +} + +interface ColumnDisplay { + rows: string[]; + highlight?: number; // which row is highlighted + separator?: boolean; // line above this row +} + +function padAndAlign(a: string, b: string): { aStr: string; bStr: string; dotPos: number } { + const aParts = a.split("."); + const bParts = b.split("."); + const aInt = aParts[0] || "0"; + const bInt = bParts[0] || "0"; + const aDec = aParts[1] || ""; + const bDec = bParts[1] || ""; + + const maxInt = Math.max(aInt.length, bInt.length); + const maxDec = Math.max(aDec.length, bDec.length); + + const aAligned = aInt.padStart(maxInt, " ") + (maxDec > 0 ? "." + aDec.padEnd(maxDec, "0") : ""); + const bAligned = bInt.padStart(maxInt, " ") + (maxDec > 0 ? "." + bDec.padEnd(maxDec, "0") : ""); + + return { aStr: aAligned, bStr: bAligned, dotPos: maxInt }; +} + +function buildAddSubSteps(a: number, b: number, op: DecOp): Step[] { + const steps: Step[] = []; + const aStr = a.toString(); + const bStr = b.toString(); + const { aStr: aAligned, bStr: bAligned } = padAndAlign(aStr, bStr); + + const opSymbol = op === "add" ? "+" : "−"; + const result = op === "add" ? a + b : a - b; + const resultStr = parseFloat(result.toFixed(10)).toString(); + + // Step 0: Show the problem + steps.push({ + label: `${aStr} ${opSymbol} ${bStr}`, + columns: [], + }); + + // Step 1: Align decimal points + steps.push({ + label: "Align the decimal points", + columns: [ + { rows: [aAligned, `${opSymbol} ${bAligned}`] }, + ], + }); + + // Step 2: Add trailing zeros + steps.push({ + label: "Fill in zeros as placeholders", + columns: [ + { rows: [aAligned, `${opSymbol} ${bAligned}`], separator: true }, + ], + }); + + // Step 3: Compute + const { aStr: aFinal, bStr: bFinal } = padAndAlign(aStr, bStr); + const resultAligned = padAndAlign(resultStr, aStr).aStr; + + steps.push({ + label: `Compute column by column`, + columns: [ + { rows: [aFinal, `${opSymbol} ${bFinal}`, resultAligned], separator: true, highlight: 2 }, + ], + }); + + // Step 4: Result + steps.push({ + label: `${aStr} ${opSymbol} ${bStr} = ${resultStr}`, + columns: [ + { rows: [aFinal, `${opSymbol} ${bFinal}`, resultAligned], separator: true, highlight: 2 }, + ], + resultRow: resultStr, + }); + + return steps; +} + +function buildMultiplySteps(a: number, b: number): Step[] { + const steps: Step[] = []; + const aStr = a.toString(); + const bStr = b.toString(); + const aDec = (aStr.split(".")[1] || "").length; + const bDec = (bStr.split(".")[1] || "").length; + const totalDec = aDec + bDec; + + // Remove decimals for integer multiplication + const aInt = Math.round(a * Math.pow(10, aDec)); + const bInt = Math.round(b * Math.pow(10, bDec)); + const result = a * b; + const resultStr = parseFloat(result.toFixed(10)).toString(); + + steps.push({ + label: `${aStr} × ${bStr}`, + columns: [], + }); + + if (totalDec > 0) { + steps.push({ + label: `Count decimal places: ${aDec} + ${bDec} = ${totalDec}`, + columns: [ + { rows: [`${aStr} → ${aDec} d.p.`, `${bStr} → ${bDec} d.p.`, `Total: ${totalDec} d.p.`] }, + ], + }); + + steps.push({ + label: `Multiply as whole numbers: ${aInt} × ${bInt}`, + columns: [ + { rows: [`${aInt}`, `× ${bInt}`, `${aInt * bInt}`], separator: true, highlight: 2 }, + ], + }); + + steps.push({ + label: `Place decimal point ${totalDec} place${totalDec !== 1 ? "s" : ""} from the right`, + columns: [ + { rows: [`${aInt * bInt}`, `→ ${resultStr}`], highlight: 1 }, + ], + resultRow: resultStr, + }); + } else { + const intResult = a * b; + steps.push({ + label: `Multiply: ${aStr} × ${bStr} = ${intResult}`, + columns: [ + { rows: [`${aStr}`, `× ${bStr}`, `${intResult}`], separator: true, highlight: 2 }, + ], + resultRow: intResult.toString(), + }); + } + + return steps; +} + +function buildDivideSteps(a: number, b: number): Step[] { + const steps: Step[] = []; + const aStr = a.toString(); + const bStr = b.toString(); + const result = a / b; + const resultStr = parseFloat(result.toFixed(10)).toString(); + + steps.push({ + label: `${aStr} ÷ ${bStr}`, + columns: [], + }); + + // Make divisor a whole number + const bDec = (bStr.split(".")[1] || "").length; + if (bDec > 0) { + const factor = Math.pow(10, bDec); + const newA = parseFloat((a * factor).toFixed(10)); + const newB = parseFloat((b * factor).toFixed(10)); + steps.push({ + label: `Make divisor whole: multiply both by ${factor}`, + columns: [ + { rows: [`${aStr} × ${factor} = ${newA}`, `${bStr} × ${factor} = ${newB}`] }, + ], + }); + steps.push({ + label: `Now divide: ${newA} ÷ ${newB}`, + columns: [ + { rows: [`${newA} ÷ ${newB}`, `= ${resultStr}`], highlight: 1 }, + ], + }); + } else { + steps.push({ + label: `Divide: ${aStr} ÷ ${bStr}`, + columns: [ + { rows: [`${aStr} ÷ ${bStr}`, `= ${resultStr}`], highlight: 1 }, + ], + }); + } + + steps.push({ + label: `${aStr} ÷ ${bStr} = ${resultStr}`, + columns: [ + { rows: [`${aStr} ÷ ${bStr} = ${resultStr}`] }, + ], + resultRow: resultStr, + }); + + return steps; +} + +function ColumnArithmetic({ + columns, +}: { + columns: ColumnDisplay[]; +}) { + if (columns.length === 0) return null; + + return ( +
+ {columns.map((col, ci) => + col.rows.map((row, ri) => ( +
+ {col.separator && ri === col.rows.length - 1 && ( +
+ )} +
+ {row} +
+
+ )), + )} +
+ ); +} + +export function DecimalArithmeticExplorer() { + const [op, setOp] = useState("add"); + const [aInput, setAInput] = useState("12.45"); + const [bInput, setBInput] = useState("3.7"); + const [error, setError] = useState(""); + + const [steps, setSteps] = useState(null); + const [currentStep, setCurrentStep] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + + const done = steps ? currentStep >= steps.length - 1 : false; + + function handleGo() { + setError(""); + const a = parseFloat(aInput); + const b = parseFloat(bInput); + if (isNaN(a) || isNaN(b)) { + setError("Enter valid decimal numbers."); + return; + } + if (op === "divide" && b === 0) { + setError("Cannot divide by zero."); + return; + } + try { + let s: Step[]; + if (op === "add" || op === "subtract") { + s = buildAddSubSteps(a, b, op); + } else if (op === "multiply") { + s = buildMultiplySteps(a, b); + } else { + s = 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; + + const opLabels: Record = { + add: "Add (+)", + subtract: "Subtract (−)", + multiply: "Multiply (×)", + divide: "Divide (÷)", + }; + + return ( +
+ {/* Operation tabs */} +
+ {(["add", "subtract", "multiply", "divide"] as DecOp[]).map((o) => ( + + ))} +
+ + {/* Input */} + +

+ Enter two decimal numbers +

+
+ setAInput(e.target.value)} + placeholder="e.g. 12.45" + autoComplete="off" + className="max-w-[140px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-3" + aria-label="First number" + /> + + {{ add: "+", subtract: "−", multiply: "×", divide: "÷" }[op]} + + setBInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleGo()} + placeholder="e.g. 3.7" + autoComplete="off" + className="max-w-[140px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-3" + aria-label="Second number" + /> + +
+ {error &&

{error}

} +
+ + {/* Display */} + + {!step ? ( +

+ Enter numbers above and click Go +

+ ) : ( + <> +

{step.label}

+ + + )} +
+ + {/* Controls */} + + 0} + /> + + + {/* Result */} + + {!done || !step?.resultRow ? ( +

Result will appear here when steps are complete

+ ) : ( + <> +

Answer

+

+ {step.resultRow} +

+ + )} +
+
+ ); +} diff --git a/components/explorers/decimal-order-explorer.tsx b/components/explorers/decimal-order-explorer.tsx new file mode 100644 index 0000000..5549b0f --- /dev/null +++ b/components/explorers/decimal-order-explorer.tsx @@ -0,0 +1,311 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { Card } from "@/components/ui/card"; +import { StepControls } from "./step-controls"; + +type SortDirection = "ascending" | "descending"; + +interface Step { + label: string; + values: { value: number; str: string; sorted: boolean; comparing: boolean }[]; + comparingPair?: [number, number]; +} + +function padDecimal(s: string, maxInt: number, maxDec: number): string { + const parts = s.split("."); + const intPart = (parts[0] || "0").padStart(maxInt, "\u00A0"); // nbsp padding + const decPart = (parts[1] || "").padEnd(maxDec, "0"); + return maxDec > 0 ? `${intPart}.${decPart}` : intPart; +} + +function buildSortSteps(values: number[], direction: SortDirection): Step[] { + const steps: Step[] = []; + const strs = values.map((v) => v.toString()); + + // Find max integer and decimal lengths for alignment + const maxInt = Math.max(...strs.map((s) => s.split(".")[0].length)); + const maxDec = Math.max(...strs.map((s) => (s.split(".")[1] || "").length)); + + // Step 0: Show unsorted + steps.push({ + label: `Sort ${values.length} decimals in ${direction} order`, + values: values.map((v, i) => ({ + value: v, + str: padDecimal(strs[i], maxInt, maxDec), + sorted: false, + comparing: false, + })), + }); + + // Step 1: Show aligned place values + steps.push({ + label: "Align decimal points to compare place values", + values: values.map((v, i) => ({ + value: v, + str: padDecimal(strs[i], maxInt, maxDec), + sorted: false, + comparing: false, + })), + }); + + // Bubble sort with steps + const arr = [...values]; + const arrStrs = [...strs]; + const cmp = direction === "ascending" ? (a: number, b: number) => a > b : (a: number, b: number) => a < b; + + for (let i = 0; i < arr.length - 1; i++) { + for (let j = 0; j < arr.length - 1 - i; j++) { + // Show comparison + steps.push({ + label: `Compare ${arrStrs[j]} and ${arrStrs[j + 1]}`, + values: arr.map((v, k) => ({ + value: v, + str: padDecimal(arrStrs[k], maxInt, maxDec), + sorted: k >= arr.length - i, + comparing: k === j || k === j + 1, + })), + comparingPair: [j, j + 1], + }); + + if (cmp(arr[j], arr[j + 1])) { + // Swap + [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]; + [arrStrs[j], arrStrs[j + 1]] = [arrStrs[j + 1], arrStrs[j]]; + + steps.push({ + label: `Swap: ${arrStrs[j]} ${direction === "ascending" ? "<" : ">"} ${arrStrs[j + 1]}`, + values: arr.map((v, k) => ({ + value: v, + str: padDecimal(arrStrs[k], maxInt, maxDec), + sorted: k >= arr.length - i, + comparing: k === j || k === j + 1, + })), + }); + } + } + } + + // Final step: Show sorted + steps.push({ + label: `Sorted (${direction}): ${arrStrs.join(direction === "ascending" ? " < " : " > ")}`, + values: arr.map((v, k) => ({ + value: v, + str: padDecimal(arrStrs[k], maxInt, maxDec), + sorted: true, + comparing: false, + })), + }); + + return steps; +} + +function NumberLine({ values, min, max }: { values: { value: number; sorted: boolean; comparing: boolean }[]; min: number; max: number }) { + const range = max - min || 1; + return ( +
+
+ {values.map((v, i) => { + const pct = ((v.value - min) / range) * 100; + return ( +
+ {i + 1} +
+ ); + })} +
+
+ {min} + {max} +
+
+ ); +} + +export function DecimalOrderExplorer() { + const [input, setInput] = useState("3.14, 3.1, 3.141, 3.04, 3.4"); + const [direction, setDirection] = useState("ascending"); + const [error, setError] = useState(""); + + const [steps, setSteps] = useState(null); + const [currentStep, setCurrentStep] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + + const done = steps ? currentStep >= steps.length - 1 : false; + + function handleGo() { + setError(""); + const parts = input + .split(/[,\s]+/) + .map((s) => s.trim()) + .filter(Boolean); + if (parts.length < 2) { + setError("Enter at least 2 decimals separated by commas."); + return; + } + if (parts.length > 6) { + setError("Maximum 6 decimals."); + return; + } + const values = parts.map(Number); + if (values.some(isNaN)) { + setError("All values must be valid numbers."); + return; + } + try { + const s = buildSortSteps(values, direction); + setSteps(s); + setCurrentStep(0); + setIsPlaying(false); + } catch { + setError("Could not process. 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 reset = useCallback(() => { + setSteps(null); + setCurrentStep(0); + setIsPlaying(false); + }, []); + + const step = steps ? steps[currentStep] : null; + + return ( +
+ {/* Direction tabs */} +
+ {(["ascending", "descending"] as SortDirection[]).map((d) => ( + + ))} +
+ + {/* Input */} + +

+ Enter 2-6 decimal numbers (comma separated) +

+
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleGo()} + placeholder="e.g. 3.14, 3.1, 3.141" + autoComplete="off" + className="max-w-[320px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-2" + /> + +
+ {error &&

{error}

} +
+ + {/* Display */} + + {!step ? ( +

+ Enter decimals above and click Sort +

+ ) : ( + <> +

{step.label}

+ {/* Place value columns */} +
+ {step.values.map((v, i) => ( +
+ {v.str} +
+ ))} +
+ {/* Number line */} + v.value)) - 0.1} + max={Math.max(...step.values.map((v) => v.value)) + 0.1} + /> + + )} +
+ + {/* Controls */} + + 0} + /> + + + {/* Result */} + + {!done || !steps ? ( +

Result will appear here when steps are complete

+ ) : ( + <> +

+ Sorted ({direction}) +

+

+ {step!.values.map((v) => v.value).join(direction === "ascending" ? " < " : " > ")} +

+ + )} +
+
+ ); +} diff --git a/components/explorers/fraction-operation-explorer.tsx b/components/explorers/fraction-operation-explorer.tsx new file mode 100644 index 0000000..4c71fad --- /dev/null +++ b/components/explorers/fraction-operation-explorer.tsx @@ -0,0 +1,389 @@ +"use client"; + +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 { MathDisplay } from "@/components/math/math-display"; + +type Operation = "add" | "subtract" | "multiply" | "divide"; + +interface Step { + label: string; + math: string; + barA: { filled: number; total: number }; + barB: { filled: number; total: number }; + barResult?: { filled: number; total: number }; +} + +function buildSteps( + n1: number, d1: number, + n2: number, d2: number, + op: Operation, +): Step[] { + const steps: Step[] = []; + + // Step 0: Show the original fractions + const opSymbol = { add: "+", subtract: "-", multiply: "\\times", divide: "\\div" }[op]; + steps.push({ + label: "Start with the two fractions", + math: `${toKatex(n1, d1)} ${opSymbol} ${toKatex(n2, d2)}`, + barA: { filled: n1, total: d1 }, + barB: { filled: n2, total: d2 }, + }); + + if (op === "add" || op === "subtract") { + // Step 1: Find LCD + const lcd = lcm(d1, d2); + const mult1 = lcd / d1; + const mult2 = lcd / d2; + const cn1 = n1 * mult1; + const cn2 = n2 * mult2; + + if (d1 !== d2) { + steps.push({ + label: `Find the LCD of ${d1} and ${d2}`, + math: `\\text{LCD}(${d1}, ${d2}) = ${lcd}`, + barA: { filled: n1, total: d1 }, + barB: { filled: n2, total: d2 }, + }); + + // Step 2: Convert both fractions + steps.push({ + label: "Convert to equivalent fractions with the LCD", + math: `${toKatex(n1, d1)} = ${toKatex(cn1, lcd)} \\quad ${toKatex(n2, d2)} = ${toKatex(cn2, lcd)}`, + barA: { filled: cn1, total: lcd }, + barB: { filled: cn2, total: lcd }, + }); + } + + // Step 3: Perform the operation + const resultNum = op === "add" ? cn1 + cn2 : cn1 - cn2; + steps.push({ + label: op === "add" ? "Add the numerators" : "Subtract the numerators", + math: `${toKatex(cn1, lcd)} ${opSymbol} ${toKatex(cn2, lcd)} = ${toKatex(resultNum, lcd)}`, + barA: { filled: cn1, total: lcd }, + barB: { filled: cn2, total: lcd }, + barResult: { filled: Math.abs(resultNum), total: lcd }, + }); + + // Step 4: Simplify if needed + const [sn, sd] = simplify(resultNum, lcd); + if (Math.abs(sn) !== Math.abs(resultNum) || sd !== lcd) { + const g = gcd(Math.abs(resultNum), lcd); + steps.push({ + label: `Simplify by dividing by GCD = ${g}`, + math: `${toKatex(resultNum, lcd)} = ${toKatex(sn, sd)}`, + barA: { filled: cn1, total: lcd }, + barB: { filled: cn2, total: lcd }, + barResult: { filled: Math.abs(sn), total: sd }, + }); + } + } else if (op === "multiply") { + // Step 1: Multiply numerators and denominators + const rn = n1 * n2; + const rd = d1 * d2; + steps.push({ + label: "Multiply numerators and denominators", + math: `\\frac{${n1} \\times ${n2}}{${d1} \\times ${d2}} = ${toKatex(rn, rd)}`, + barA: { filled: n1, total: d1 }, + barB: { filled: n2, total: d2 }, + barResult: { filled: rn, total: rd }, + }); + + // Step 2: Simplify + const [sn, sd] = simplify(rn, rd); + if (sn !== rn || sd !== rd) { + const g = gcd(Math.abs(rn), rd); + steps.push({ + label: `Simplify by dividing by GCD = ${g}`, + math: `${toKatex(rn, rd)} = ${toKatex(sn, sd)}`, + barA: { filled: n1, total: d1 }, + barB: { filled: n2, total: d2 }, + barResult: { filled: Math.abs(sn), total: sd }, + }); + } + } else { + // divide: invert and multiply + steps.push({ + label: "Invert the second fraction (reciprocal)", + math: `${toKatex(n1, d1)} \\times ${toKatex(d2, n2)}`, + barA: { filled: n1, total: d1 }, + barB: { filled: d2, total: n2 }, + }); + + const rn = n1 * d2; + const rd = d1 * n2; + steps.push({ + label: "Multiply numerators and denominators", + math: `\\frac{${n1} \\times ${d2}}{${d1} \\times ${n2}} = ${toKatex(rn, rd)}`, + barA: { filled: n1, total: d1 }, + barB: { filled: d2, total: n2 }, + barResult: { filled: rn, total: rd }, + }); + + const [sn, sd] = simplify(rn, rd); + if (sn !== rn || sd !== rd) { + const g = gcd(Math.abs(rn), rd); + steps.push({ + label: `Simplify by dividing by GCD = ${g}`, + math: `${toKatex(rn, rd)} = ${toKatex(sn, sd)}`, + barA: { filled: n1, total: d1 }, + barB: { filled: d2, total: n2 }, + barResult: { filled: Math.abs(sn), total: sd }, + }); + } + } + + return steps; +} + +function FractionBar({ + filled, + total, + color, + label, +}: { + filled: number; + total: number; + color: string; + label?: string; +}) { + const maxSegments = Math.min(total, 24); + const fillCount = Math.min(filled, maxSegments); + + return ( +
+ {label && {label}} +
+ {Array.from({ length: maxSegments }, (_, i) => ( +
+ ))} +
+ + {filled}/{total} + +
+ ); +} + +export function FractionOperationExplorer() { + const [op, setOp] = useState("add"); + const [n1, setN1] = useState("1"); + const [d1, setD1] = useState("3"); + const [n2, setN2] = useState("1"); + const [d2, setD2] = useState("4"); + const [error, setError] = useState(""); + + const [steps, setSteps] = useState(null); + const [currentStep, setCurrentStep] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + + const done = steps ? currentStep >= steps.length - 1 : false; + + function handleGo() { + setError(""); + const nums = [parseInt(n1), parseInt(d1), parseInt(n2), parseInt(d2)]; + if (nums.some(isNaN) || nums.some((n) => n === 0)) { + setError("Enter valid non-zero numbers."); + return; + } + if (nums[1] < 0 || nums[3] < 0) { + setError("Denominators must be positive."); + return; + } + if (nums.some((n) => Math.abs(n) > 99)) { + setError("Keep numbers under 100."); + return; + } + try { + const s = buildSteps(nums[0], nums[1], nums[2], nums[3], 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 opLabels: Record = { + add: "Add (+)", + subtract: "Subtract (−)", + multiply: "Multiply (×)", + divide: "Divide (÷)", + }; + + return ( +
+ {/* Operation tabs */} +
+ {(["add", "subtract", "multiply", "divide"] as Operation[]).map((o) => ( + + ))} +
+ + {/* Input Card */} + +

+ Enter two fractions +

+
+
+
+ setN1(e.target.value)} + className="w-14 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-1" + aria-label="Numerator 1" + /> +
+ setD1(e.target.value)} + className="w-14 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-1" + aria-label="Denominator 1" + /> +
+
+ + {{ add: "+", subtract: "−", multiply: "×", divide: "÷" }[op]} + +
+
+ setN2(e.target.value)} + className="w-14 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-1" + aria-label="Numerator 2" + /> +
+ setD2(e.target.value)} + className="w-14 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-1" + aria-label="Denominator 2" + /> +
+
+ +
+ {error &&

{error}

} + + + {/* Display Card */} + + {!step ? ( +

+ Enter fractions above and click Go +

+ ) : ( + <> +

{step.label}

+ +
+ + + {step.barResult && ( + + )} +
+ + )} +
+ + {/* Controls */} + + 0} + /> + + + {/* Result */} + + {!done || !steps ? ( +

Result will appear here when steps are complete

+ ) : ( + <> +

Answer

+
+ +
+ + )} +
+
+ ); +} diff --git a/components/explorers/fraction-quantity-explorer.tsx b/components/explorers/fraction-quantity-explorer.tsx new file mode 100644 index 0000000..b2e80b9 --- /dev/null +++ b/components/explorers/fraction-quantity-explorer.tsx @@ -0,0 +1,305 @@ +"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"; +import { toKatex } from "@/lib/math/fractions"; + +type FQMode = "fractionOf" | "findWhole"; + +interface Step { + label: string; + math: string; + barFilled: number; // 0–1 proportion + barLabel?: string; +} + +function buildFractionOfSteps(num: number, den: number, quantity: number): Step[] { + const steps: Step[] = []; + + steps.push({ + label: `Find ${toKatexStr(num, den)} of ${quantity}`, + math: `${toKatex(num, den)} \\text{ of } ${quantity}`, + barFilled: 0, + }); + + // Step 1: Divide quantity by denominator + const onePart = quantity / den; + steps.push({ + label: `Divide ${quantity} by ${den} to find one part`, + math: `${quantity} \\div ${den} = ${fmt(onePart)}`, + barFilled: 1 / den, + barLabel: fmt(onePart), + }); + + // Step 2: Multiply by numerator + const result = onePart * num; + steps.push({ + label: `Multiply one part by ${num}`, + math: `${fmt(onePart)} \\times ${num} = ${fmt(result)}`, + barFilled: num / den, + barLabel: fmt(result), + }); + + steps.push({ + label: `Answer: ${toKatexStr(num, den)} of ${quantity} = ${fmt(result)}`, + math: `${toKatex(num, den)} \\times ${quantity} = ${fmt(result)}`, + barFilled: num / den, + barLabel: fmt(result), + }); + + return steps; +} + +function buildFindWholeSteps(num: number, den: number, part: number): Step[] { + const steps: Step[] = []; + + steps.push({ + label: `If ${toKatexStr(num, den)} of the whole = ${part}, find the whole`, + math: `${toKatex(num, den)} \\text{ of ? } = ${part}`, + barFilled: num / den, + barLabel: `${part}`, + }); + + // Step 1: Find one part + const onePart = part / num; + steps.push({ + label: `Divide ${part} by ${num} to find one ${den}th`, + math: `${part} \\div ${num} = ${fmt(onePart)}`, + barFilled: 1 / den, + barLabel: fmt(onePart), + }); + + // Step 2: Multiply by denominator + const whole = onePart * den; + steps.push({ + label: `Multiply by ${den} to find the whole`, + math: `${fmt(onePart)} \\times ${den} = ${fmt(whole)}`, + barFilled: 1, + barLabel: fmt(whole), + }); + + steps.push({ + label: `The whole is ${fmt(whole)}`, + math: `\\text{Whole} = ${fmt(whole)}`, + barFilled: 1, + barLabel: fmt(whole), + }); + + return steps; +} + +function toKatexStr(n: number, d: number): string { + return `${n}/${d}`; +} + +function fmt(n: number): string { + return Number.isInteger(n) ? n.toString() : n.toFixed(2); +} + +function QuantityBar({ filled, label }: { filled: number; label?: string }) { + const pct = Math.max(0, Math.min(1, filled)) * 100; + return ( +
+
+
+ {label && pct > 15 ? label : ""} +
+
+ {label && pct <= 15 && ( +

{label}

+ )} +
+ 0 + {Math.round(pct)}% +
+
+ ); +} + +export function FractionQuantityExplorer() { + const [mode, setMode] = useState("fractionOf"); + const [num, setNum] = useState("3"); + const [den, setDen] = useState("4"); + const [quantity, setQuantity] = useState("80"); + const [error, setError] = useState(""); + + const [steps, setSteps] = useState(null); + const [currentStep, setCurrentStep] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + + const done = steps ? currentStep >= steps.length - 1 : false; + + function handleGo() { + setError(""); + const n = parseInt(num); + const d = parseInt(den); + const q = parseFloat(quantity); + if (isNaN(n) || isNaN(d) || d === 0) { + setError("Enter a valid fraction (denominator ≠ 0)."); + return; + } + if (isNaN(q) || q <= 0) { + setError("Enter a valid positive quantity."); + return; + } + try { + const s = + mode === "fractionOf" + ? buildFractionOfSteps(n, d, q) + : buildFindWholeSteps(n, d, q); + 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 ( +
+ {/* Mode tabs */} +
+ + +
+ + {/* Input */} + +

+ {mode === "fractionOf" ? "Enter a fraction and quantity" : "Enter a fraction and the part value"} +

+
+
+ setNum(e.target.value)} + className="w-14 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-1" + aria-label="Numerator" + /> +
+ setDen(e.target.value)} + className="w-14 rounded-lg border-2 border-border bg-surface px-2 py-1.5 text-center text-lg font-bold outline-none focus:border-unit-1" + aria-label="Denominator" + /> +
+ + {mode === "fractionOf" ? "of" : "= "} + + setQuantity(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleGo()} + className="w-24 rounded-lg border-2 border-border bg-surface px-3 py-2.5 text-center text-lg font-bold outline-none focus:border-unit-1" + aria-label={mode === "fractionOf" ? "Quantity" : "Part value"} + /> + +
+ {error &&

{error}

} + + + {/* Display */} + + {!step ? ( +

+ Enter values above and click Go +

+ ) : ( + <> +

{step.label}

+ + + + )} +
+ + {/* Controls */} + + 0} + /> + + + {/* Result */} + + {!done || !steps ? ( +

Result will appear here when steps are complete

+ ) : ( + <> +

Answer

+
+ +
+ + )} +
+
+ ); +} diff --git a/components/explorers/ratio-explorer.tsx b/components/explorers/ratio-explorer.tsx new file mode 100644 index 0000000..e0717f2 --- /dev/null +++ b/components/explorers/ratio-explorer.tsx @@ -0,0 +1,401 @@ +"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"; +import { simplifyRatio } from "@/lib/math/ratios"; +import { gcd } from "@/lib/math/fractions"; + +type RatioMode = "divide" | "simplify" | "equivalent"; + +interface Step { + label: string; + math: string; + barSegments: { value: number; filled: boolean; label?: string }[]; +} + +const COLORS = ["var(--unit-4)", "var(--unit-1)", "var(--unit-3)", "var(--hint)"]; + +function buildDivideSteps(total: number, parts: number[]): Step[] { + const steps: Step[] = []; + const sum = parts.reduce((a, b) => a + b, 0); + + // Step 0: Show the ratio and total + steps.push({ + label: `Divide ${total} in the ratio ${parts.join(" : ")}`, + math: `${total} \\div (${parts.join(" : ")})`, + barSegments: parts.map((p) => ({ value: p, filled: false })), + }); + + // Step 1: Sum the parts + steps.push({ + label: `Add the ratio parts: ${parts.join(" + ")} = ${sum}`, + math: `${parts.join(" + ")} = ${sum} \\text{ total parts}`, + barSegments: parts.map((p) => ({ value: p, filled: false })), + }); + + // Step 2: Find one part + const onePart = total / sum; + steps.push({ + label: `Find one part: ${total} ÷ ${sum} = ${onePart}`, + math: `\\text{One part} = ${total} \\div ${sum} = ${Number.isInteger(onePart) ? onePart : onePart.toFixed(2)}`, + barSegments: parts.map((p) => ({ value: p, filled: false })), + }); + + // Steps 3+: Calculate each share + const shares: number[] = []; + parts.forEach((p, i) => { + const share = p * onePart; + shares.push(share); + steps.push({ + label: `Part ${i + 1}: ${p} × ${Number.isInteger(onePart) ? onePart : onePart.toFixed(2)} = ${Number.isInteger(share) ? share : share.toFixed(2)}`, + math: `${p} \\times ${Number.isInteger(onePart) ? onePart : onePart.toFixed(2)} = ${Number.isInteger(share) ? share : share.toFixed(2)}`, + barSegments: parts.map((pp, j) => ({ + value: pp, + filled: j <= i, + label: j <= i ? `${Number.isInteger(shares[j]) ? shares[j] : shares[j].toFixed(2)}` : undefined, + })), + }); + }); + + // Final step: Show all shares + const shareStrs = shares.map((s) => (Number.isInteger(s) ? s.toString() : s.toFixed(2))); + steps.push({ + label: `Result: ${shareStrs.join(", ")}`, + math: `${total} = ${shareStrs.join(" + ")}`, + barSegments: parts.map((pp, j) => ({ + value: pp, + filled: true, + label: shareStrs[j], + })), + }); + + return steps; +} + +function buildSimplifySteps(parts: number[]): Step[] { + const steps: Step[] = []; + + steps.push({ + label: `Simplify the ratio ${parts.join(" : ")}`, + math: parts.join(" : "), + barSegments: parts.map((p) => ({ value: p, filled: true })), + }); + + // Find GCD + let g = parts[0]; + for (let i = 1; i < parts.length; i++) g = gcd(g, parts[i]); + + if (g === 1) { + steps.push({ + label: "The ratio is already in its simplest form (GCD = 1)", + math: `\\text{GCD} = 1`, + barSegments: parts.map((p) => ({ value: p, filled: true })), + }); + } else { + steps.push({ + label: `Find the GCD of all parts: ${g}`, + math: `\\text{GCD}(${parts.join(", ")}) = ${g}`, + barSegments: parts.map((p) => ({ value: p, filled: true })), + }); + + const simplified = simplifyRatio(parts); + steps.push({ + label: `Divide each part by ${g}`, + math: parts.map((p) => `${p} \\div ${g} = ${p / g}`).join(", \\quad "), + barSegments: simplified.map((p) => ({ value: p, filled: true })), + }); + + steps.push({ + label: `Simplified ratio: ${simplified.join(" : ")}`, + math: `${parts.join(" : ")} = ${simplified.join(" : ")}`, + barSegments: simplified.map((p) => ({ value: p, filled: true })), + }); + } + + return steps; +} + +function buildEquivalentSteps(parts: number[], multiplier: number): Step[] { + const steps: Step[] = []; + + steps.push({ + label: `Find an equivalent ratio: ${parts.join(" : ")} × ${multiplier}`, + math: `(${parts.join(" : ")}) \\times ${multiplier}`, + barSegments: parts.map((p) => ({ value: p, filled: true })), + }); + + const result = parts.map((p) => p * multiplier); + steps.push({ + label: `Multiply each part by ${multiplier}`, + math: parts.map((p) => `${p} \\times ${multiplier} = ${p * multiplier}`).join(", \\quad "), + barSegments: result.map((p) => ({ value: p, filled: true })), + }); + + steps.push({ + label: `Equivalent ratio: ${result.join(" : ")}`, + math: `${parts.join(" : ")} = ${result.join(" : ")}`, + barSegments: result.map((p) => ({ value: p, filled: true })), + }); + + return steps; +} + +function RatioBar({ + segments, +}: { + segments: { value: number; filled: boolean; label?: string }[]; +}) { + const total = segments.reduce((s, seg) => s + seg.value, 0); + if (total === 0) return null; + + return ( +
+
+ {segments.map((seg, i) => ( +
+ {seg.label || seg.value} +
+ ))} +
+
+ {segments.map((seg, i) => ( +
+ {seg.label && `(${seg.value} parts)`} +
+ ))} +
+
+ ); +} + +export function RatioExplorer() { + const [mode, setMode] = useState("divide"); + const [partA, setPartA] = useState("2"); + const [partB, setPartB] = useState("3"); + const [partC, setPartC] = useState(""); + const [total, setTotal] = useState("60"); + const [multiplier, setMultiplier] = useState("4"); + const [error, setError] = useState(""); + + const [steps, setSteps] = useState(null); + const [currentStep, setCurrentStep] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + + const done = steps ? currentStep >= steps.length - 1 : false; + + function getParts(): number[] { + const parts = [parseInt(partA), parseInt(partB)]; + if (partC.trim()) parts.push(parseInt(partC)); + return parts; + } + + function handleGo() { + setError(""); + const parts = getParts(); + if (parts.some(isNaN) || parts.some((p) => p <= 0)) { + setError("Enter valid positive ratio parts."); + return; + } + + try { + let s: Step[]; + if (mode === "divide") { + const t = parseFloat(total); + if (isNaN(t) || t <= 0) { + setError("Enter a valid positive total."); + return; + } + s = buildDivideSteps(t, parts); + } else if (mode === "simplify") { + s = buildSimplifySteps(parts); + } else { + const m = parseInt(multiplier); + if (isNaN(m) || m <= 0) { + setError("Enter a valid positive multiplier."); + return; + } + s = buildEquivalentSteps(parts, m); + } + 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 modeLabels: Record = { + divide: "Divide in Ratio", + simplify: "Simplify Ratio", + equivalent: "Equivalent Ratio", + }; + + return ( +
+ {/* Mode tabs */} +
+ {(["divide", "simplify", "equivalent"] as RatioMode[]).map((m) => ( + + ))} +
+ + {/* Input */} + +

+ {mode === "divide" ? "Enter ratio parts and total" : mode === "simplify" ? "Enter ratio parts" : "Enter ratio parts and multiplier"} +

+
+ setPartA(e.target.value)} + className="w-16 rounded-lg border-2 border-border bg-surface px-2 py-2.5 text-center text-lg font-bold outline-none focus:border-unit-4" + aria-label="Part A" + /> + : + setPartB(e.target.value)} + className="w-16 rounded-lg border-2 border-border bg-surface px-2 py-2.5 text-center text-lg font-bold outline-none focus:border-unit-4" + aria-label="Part B" + /> + : + setPartC(e.target.value)} + placeholder="?" + className="w-16 rounded-lg border-2 border-border bg-surface px-2 py-2.5 text-center text-lg font-bold outline-none focus:border-unit-4" + aria-label="Part C (optional)" + /> + + {mode === "divide" && ( + <> + Total: + setTotal(e.target.value)} + className="w-20 rounded-lg border-2 border-border bg-surface px-2 py-2.5 text-center text-lg font-bold outline-none focus:border-unit-4" + aria-label="Total" + /> + + )} + {mode === "equivalent" && ( + <> + × + setMultiplier(e.target.value)} + className="w-16 rounded-lg border-2 border-border bg-surface px-2 py-2.5 text-center text-lg font-bold outline-none focus:border-unit-4" + aria-label="Multiplier" + /> + + )} + + +
+ {error &&

{error}

} +
+ + {/* Display */} + + {!step ? ( +

+ Enter values above and click Go +

+ ) : ( + <> +

{step.label}

+ + + + )} +
+ + {/* Controls */} + + 0} + /> + + + {/* Result */} + + {!done || !steps ? ( +

Result will appear here when steps are complete

+ ) : ( + <> +

Answer

+

+ {steps[steps.length - 1].label.replace("Result: ", "").replace("Simplified ratio: ", "").replace("Equivalent ratio: ", "")} +

+ + )} +
+
+ ); +} diff --git a/components/explorers/rounding-explorer.tsx b/components/explorers/rounding-explorer.tsx new file mode 100644 index 0000000..165b06d --- /dev/null +++ b/components/explorers/rounding-explorer.tsx @@ -0,0 +1,559 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { Card } from "@/components/ui/card"; +import { StepControls } from "./step-controls"; + +type RoundTarget = "whole" | "1dp" | "2dp" | "1sf" | "2sf" | "3sf"; + +interface Step { + label: string; + digits: DigitInfo[]; + resultText?: string; +} + +interface DigitInfo { + char: string; + state: "normal" | "target" | "decider" | "dim" | "significant"; +} + +function getDigits(value: string): string[] { + return value.split(""); +} + +// ── Decimal place rounding steps ───────────────────────────────────────────── +function buildDPSteps(rawValue: string, target: "whole" | "1dp" | "2dp"): Step[] { + const value = parseFloat(rawValue); + const digits = getDigits(rawValue); + const dotIndex = digits.indexOf("."); + const steps: Step[] = []; + + function makeDigits( + targetIdx: number | null, + deciderIdx: number | null, + ): DigitInfo[] { + return digits.map((ch, i) => ({ + char: ch, + state: + ch === "." + ? "normal" + : i === targetIdx + ? "target" + : i === deciderIdx + ? "decider" + : "normal", + })); + } + + let targetDigitIdx: number; + let roundedValue: number; + let targetLabel: string; + + if (target === "whole") { + targetDigitIdx = dotIndex === -1 ? digits.length - 1 : dotIndex - 1; + roundedValue = Math.round(value); + targetLabel = "ones (whole number)"; + } else if (target === "1dp") { + targetDigitIdx = dotIndex + 1; + roundedValue = Math.round(value * 10) / 10; + targetLabel = "1 decimal place (tenths)"; + } else { + targetDigitIdx = dotIndex + 2; + roundedValue = Math.round(value * 100) / 100; + targetLabel = "2 decimal places (hundredths)"; + } + + // Compute decider index (skip over decimal point if needed) + const nextIdx = targetDigitIdx + 1; + const deciderIdx = + nextIdx < digits.length && digits[nextIdx] === "." + ? nextIdx + 1 + : nextIdx; + + // Step 0: Show the number + steps.push({ + label: `Rounding ${rawValue} to ${targetLabel}`, + digits: makeDigits(null, null), + }); + + // Step 1: Identify target + steps.push({ + label: `Identify the target digit (${targetLabel})`, + digits: makeDigits(targetDigitIdx, null), + }); + + // Step 2: Identify decider + if (deciderIdx < digits.length) { + steps.push({ + label: "Look at the digit to the right (the decider)", + digits: makeDigits(targetDigitIdx, deciderIdx), + }); + + const deciderDigit = parseInt(digits[deciderIdx]); + const roundsUp = deciderDigit >= 5; + steps.push({ + label: roundsUp + ? `Decider is ${deciderDigit} (≥ 5) → round UP` + : `Decider is ${deciderDigit} (< 5) → round DOWN`, + digits: makeDigits(targetDigitIdx, deciderIdx), + }); + } else { + steps.push({ + label: "No digit to the right — no rounding needed", + digits: makeDigits(targetDigitIdx, null), + }); + } + + // Step 3: Result + const resultStr = + target === "whole" + ? roundedValue.toString() + : target === "1dp" + ? roundedValue.toFixed(1) + : roundedValue.toFixed(2); + + const resultDigits = getDigits(resultStr); + steps.push({ + label: `Result: ${resultStr}`, + digits: resultDigits.map((ch) => ({ + char: ch, + state: "normal" as const, + })), + resultText: resultStr, + }); + + return steps; +} + +// ── Significant figures rounding steps ─────────────────────────────────────── +function buildSFSteps(rawValue: string, sfCount: number): Step[] { + const value = parseFloat(rawValue); + const digits = getDigits(rawValue); + const steps: Step[] = []; + + // Step 0: Show the number + steps.push({ + label: `Rounding ${rawValue} to ${sfCount} significant figure${sfCount > 1 ? "s" : ""}`, + digits: digits.map((ch) => ({ char: ch, state: "normal" as const })), + }); + + // Step 1: Identify which digits are significant + // Rule: significant figures start at the first non-zero digit + const sigDigitIndices: number[] = []; + let foundFirstNonZero = false; + for (let i = 0; i < digits.length; i++) { + if (digits[i] === ".") continue; + if (!foundFirstNonZero && digits[i] === "0") continue; + foundFirstNonZero = true; + sigDigitIndices.push(i); + } + + // Build display: highlight all significant digits found + const sigSet = new Set(sigDigitIndices); + steps.push({ + label: `Identify significant figures — start from the first non-zero digit`, + digits: digits.map((ch, i) => ({ + char: ch, + state: + ch === "." + ? "normal" + : sigSet.has(i) + ? "significant" + : "dim", + })), + }); + + // Count and label the sig figs we need + const sigLabel = sigDigitIndices.slice(0, sfCount).map((idx, n) => { + return `${ordinal(n + 1)} s.f. = ${digits[idx]}`; + }); + const targetIdx = sigDigitIndices[sfCount - 1]; // the last sig fig we keep + + if (targetIdx === undefined) { + // Not enough significant figures in the number + steps.push({ + label: `The number only has ${sigDigitIndices.length} significant figure${sigDigitIndices.length !== 1 ? "s" : ""} — already rounded`, + digits: digits.map((ch, i) => ({ + char: ch, + state: + ch === "." + ? "normal" + : sigSet.has(i) + ? "significant" + : "dim", + })), + resultText: rawValue, + }); + return steps; + } + + // Step 2: Show which sig figs we're keeping + const keepSet = new Set(sigDigitIndices.slice(0, sfCount)); + steps.push({ + label: `Keep ${sfCount} sig fig${sfCount > 1 ? "s" : ""}: ${sigLabel.join(", ")}`, + digits: digits.map((ch, i) => ({ + char: ch, + state: + ch === "." + ? "normal" + : keepSet.has(i) + ? "target" + : sigSet.has(i) + ? "normal" + : "dim", + })), + }); + + // Step 3: Identify the decider + const deciderCandidateIdx = sigDigitIndices[sfCount]; // next sig fig after the ones we keep + // But the decider could also be after a decimal point + let deciderIdx: number | null = null; + if (deciderCandidateIdx !== undefined) { + deciderIdx = deciderCandidateIdx; + } else { + // Look for any digit after the target + for (let i = targetIdx + 1; i < digits.length; i++) { + if (digits[i] !== ".") { + deciderIdx = i; + break; + } + } + } + + if (deciderIdx !== null && deciderIdx < digits.length) { + steps.push({ + label: `Look at the next digit: ${digits[deciderIdx]} (the decider)`, + digits: digits.map((ch, i) => ({ + char: ch, + state: + ch === "." + ? "normal" + : i === deciderIdx + ? "decider" + : keepSet.has(i) + ? "target" + : "dim", + })), + }); + + // Step 4: Round decision + const deciderDigit = parseInt(digits[deciderIdx]); + const roundsUp = deciderDigit >= 5; + steps.push({ + label: roundsUp + ? `Decider is ${deciderDigit} (≥ 5) → round UP the last kept digit` + : `Decider is ${deciderDigit} (< 5) → round DOWN (keep it the same)`, + digits: digits.map((ch, i) => ({ + char: ch, + state: + ch === "." + ? "normal" + : i === deciderIdx + ? "decider" + : keepSet.has(i) + ? "target" + : "dim", + })), + }); + } else { + steps.push({ + label: "No digit to the right — no rounding needed", + digits: digits.map((ch, i) => ({ + char: ch, + state: + ch === "." + ? "normal" + : keepSet.has(i) + ? "target" + : "dim", + })), + }); + } + + // Step 5: Compute and show result + let roundedValue: number; + if (value === 0) { + roundedValue = 0; + } else { + const magnitude = Math.floor(Math.log10(Math.abs(value))); + const factor = Math.pow(10, sfCount - 1 - magnitude); + roundedValue = Math.round(value * factor) / factor; + } + + // Format result with correct sig figs (preserve trailing zeros) + const resultStr = formatSigFigs(roundedValue, sfCount); + const resultDigits = getDigits(resultStr); + + // Step: explain what happens to remaining digits + const dotIdx = rawValue.indexOf("."); + const hasIntegerPart = dotIdx === -1 || dotIdx > 0; + if (hasIntegerPart && sigDigitIndices.length > sfCount) { + // Some trailing digits in the integer part get replaced with zeros + steps.push({ + label: "Replace remaining digits with zeros (to keep the place value)", + digits: resultDigits.map((ch) => ({ + char: ch, + state: "normal" as const, + })), + }); + } + + steps.push({ + label: `Result: ${resultStr} (${sfCount} significant figure${sfCount > 1 ? "s" : ""})`, + digits: resultDigits.map((ch) => ({ + char: ch, + state: "normal" as const, + })), + resultText: resultStr, + }); + + return steps; +} + +function formatSigFigs(value: number, sfCount: number): string { + if (value === 0) return "0"; + + const magnitude = Math.floor(Math.log10(Math.abs(value))); + + if (magnitude >= sfCount - 1) { + // Integer result — no decimal point needed + return Math.round(value / Math.pow(10, magnitude - sfCount + 1)) + * Math.pow(10, magnitude - sfCount + 1) + ""; + } + + // Decimal result — need to show enough places + const decimalPlaces = sfCount - 1 - magnitude; + return value.toFixed(decimalPlaces); +} + +function ordinal(n: number): string { + if (n === 1) return "1st"; + if (n === 2) return "2nd"; + if (n === 3) return "3rd"; + return `${n}th`; +} + +// ── Main step builder ──────────────────────────────────────────────────────── +function buildSteps(rawValue: string, target: RoundTarget): Step[] { + if (target === "whole" || target === "1dp" || target === "2dp") { + return buildDPSteps(rawValue, target); + } + const sfCount = target === "1sf" ? 1 : target === "2sf" ? 2 : 3; + return buildSFSteps(rawValue, sfCount); +} + +// ── Digit box component ───────────────────────────────────────────────────── +function DigitBox({ char, state }: DigitInfo) { + if (char === ".") { + return ( +
+
+
+ ); + } + + const styles: Record = { + normal: "border-border bg-surface text-foreground", + target: "border-unit-2 bg-unit-2-light text-foreground ring-2 ring-unit-2/30", + decider: "border-hint bg-hint-light text-foreground ring-2 ring-hint/30", + significant: "border-unit-4 bg-unit-4-light text-foreground ring-2 ring-unit-4/30", + dim: "border-border/40 bg-background text-muted/30", + }; + + return ( +
+ {char} +
+ ); +} + +// ── Explorer component ────────────────────────────────────────────────────── +export function RoundingExplorer() { + const [input, setInput] = useState("3.4567"); + const [target, setTarget] = useState("1dp"); + const [error, setError] = useState(""); + + const [steps, setSteps] = useState(null); + const [currentStep, setCurrentStep] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + + const done = steps ? currentStep >= steps.length - 1 : false; + + function handleGo() { + setError(""); + const trimmed = input.trim(); + const val = parseFloat(trimmed); + if (isNaN(val)) { + setError("Enter a valid number."); + return; + } + if (val < 0) { + setError("Enter a positive number."); + return; + } + if (trimmed.length > 16) { + setError("Number too long."); + return; + } + if (!trimmed.includes(".") && (target === "1dp" || target === "2dp")) { + setError("Enter a decimal number for decimal place rounding."); + return; + } + try { + const s = buildSteps(trimmed, target); + setSteps(s); + setCurrentStep(0); + setIsPlaying(false); + } catch { + setError("Could not process. 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 reset = useCallback(() => { + setSteps(null); + setCurrentStep(0); + setIsPlaying(false); + }, []); + + const step = steps ? steps[currentStep] : null; + + const targetLabels: Record = { + whole: "Whole Number", + "1dp": "1 d.p.", + "2dp": "2 d.p.", + "1sf": "1 s.f.", + "2sf": "2 s.f.", + "3sf": "3 s.f.", + }; + + return ( +
+ {/* Target tabs */} +
+ {(["whole", "1dp", "2dp", "1sf", "2sf", "3sf"] as RoundTarget[]).map((t) => ( + + ))} +
+ + {/* Input */} + +

+ Enter a number +

+
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleGo()} + placeholder="e.g. 3.4567 or 0.00456" + maxLength={18} + autoComplete="off" + className="max-w-[240px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-2" + /> + +
+ {error &&

{error}

} +
+ + {/* Display */} + + {!step ? ( +

+ Enter a number above and click Round +

+ ) : ( + <> +

{step.label}

+
+ {step.digits.map((d, i) => ( + + ))} +
+ {/* Legend */} +
+ + + Target digit + + + + Decider digit + + + + Significant + + + + Not significant + +
+ + )} +
+ + {/* Controls */} + + 0} + /> + + + {/* Result */} + + {!done || !step?.resultText ? ( +

Result will appear here when steps are complete

+ ) : ( + <> +

Rounded Value

+

+ {step.resultText} +

+ + )} +
+
+ ); +} diff --git a/components/explorers/standard-form-explorer.tsx b/components/explorers/standard-form-explorer.tsx new file mode 100644 index 0000000..cbacb77 --- /dev/null +++ b/components/explorers/standard-form-explorer.tsx @@ -0,0 +1,445 @@ +"use client"; + +import { useState, useCallback, useRef, useEffect } from "react"; +import { Card } from "@/components/ui/card"; +import { StepControls } from "./step-controls"; + +type Mode = "toSF" | "toOrd"; + +interface DisplayState { + digits: string[]; + decimalPos: number; + initPos: number; + targetPos: number; + origPower: number; +} + +function parseOrdinary(raw: string): { digits: string[]; decimalPos: number } { + const s = raw.trim().replace(/[\s,]/g, ""); + if (s.startsWith("-")) throw new Error("Please enter a positive number."); + if (!/^\d*\.?\d+$/.test(s) || s === "") + throw new Error("Invalid number. Try something like 7438 or 0.0055"); + if (s.length > 16) throw new Error("Number too long (max 16 digits)."); + + const dot = s.indexOf("."); + if (dot === -1) { + return { digits: s.split(""), decimalPos: s.length }; + } + return { + digits: (s.slice(0, dot) + s.slice(dot + 1)).split(""), + decimalPos: dot, + }; +} + +function firstNonZero(digits: string[]): number { + return digits.findIndex((d) => d !== "0"); +} + +function sfTargetPos(digits: string[]): number { + const i = firstNonZero(digits); + return i === -1 ? 1 : i + 1; +} + +function buildCoeff(sigDigs: string[]): string { + if (sigDigs.length === 0) return "0"; + if (sigDigs.length === 1) return sigDigs[0] + ".0"; + const rest = sigDigs.slice(1).join("").replace(/0+$/, "") || "0"; + return sigDigs[0] + "." + rest; +} + +function buildOrdinary(digits: string[], pos: number): string { + let s: string; + if (pos <= 0) { + s = "0." + "0".repeat(-pos) + digits.join(""); + } else if (pos >= digits.length) { + s = digits.join("") + "0".repeat(pos - digits.length); + } else { + s = digits.slice(0, pos).join("") + "." + digits.slice(pos).join(""); + } + s = s.replace(/^0+(?=[1-9])/, ""); + if (s.startsWith(".")) s = "0" + s; + if (s.includes(".")) s = s.replace(/\.?0+$/, ""); + return s || "0"; +} + +export function StandardFormExplorer() { + const [mode, setMode] = useState("toSF"); + const [ordinaryInput, setOrdinaryInput] = useState(""); + const [coeffInput, setCoeffInput] = useState(""); + const [powerInput, setPowerInput] = useState(""); + const [error, setError] = useState(""); + + const [display, setDisplay] = useState(null); + const [currentPos, setCurrentPos] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + const [done, setDone] = useState(false); + + const numberRowRef = useRef(null); + const dotRef = useRef(null); + const [dotLeft, setDotLeft] = useState(0); + const [dotAnimate, setDotAnimate] = useState(false); + const [powerPop, setPowerPop] = useState(false); + + const totalSteps = display ? Math.abs(display.targetPos - display.initPos) : 0; + const currentStep = display ? Math.abs(currentPos - display.initPos) : 0; + + const currentPower = display + ? mode === "toSF" + ? display.initPos - currentPos + : display.origPower - (currentPos - display.initPos) + : 0; + + // Position decimal dot + const placeDot = useCallback( + (animate: boolean) => { + if (!numberRowRef.current || !display) return; + const boxes = numberRowRef.current.querySelectorAll("[data-digit-box]"); + if (!boxes.length) return; + + let leftPx: number; + const dp = currentPos; + if (dp <= 0) { + leftPx = boxes[0].offsetLeft - 6; + } else if (dp >= boxes.length) { + const last = boxes[boxes.length - 1]; + leftPx = last.offsetLeft + last.offsetWidth - 4; + } else { + const prev = boxes[dp - 1]; + const next = boxes[dp]; + leftPx = Math.round((prev.offsetLeft + prev.offsetWidth + next.offsetLeft) / 2); + } + + setDotAnimate(animate); + setDotLeft(leftPx); + }, + [currentPos, display], + ); + + useEffect(() => { + if (display) { + requestAnimationFrame(() => { + requestAnimationFrame(() => placeDot(true)); + }); + } + }, [currentPos, display, placeDot]); + + // Initial placement without animation + useEffect(() => { + if (display) { + requestAnimationFrame(() => { + requestAnimationFrame(() => placeDot(false)); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [display?.digits.join("")]); + + function initDisplay(digits: string[], start: number, target: number, origPower: number) { + setIsPlaying(false); + setDone(start === target); + setDisplay({ digits, decimalPos: start, initPos: start, targetPos: target, origPower }); + setCurrentPos(start); + setError(""); + } + + function handleConvertToSF() { + setError(""); + try { + const { digits, decimalPos } = parseOrdinary(ordinaryInput); + const target = sfTargetPos(digits); + initDisplay(digits, decimalPos, target, 0); + } catch (e) { + setError((e as Error).message); + } + } + + function handleConvertToOrd() { + setError(""); + try { + if (!coeffInput.trim()) throw new Error("Enter the coefficient A."); + const n = parseInt(powerInput, 10); + if (isNaN(n)) throw new Error("Enter the power n as a whole number."); + if (Math.abs(n) > 12) throw new Error("Power too large for display (max ±12)."); + if (n === 0) throw new Error("Power is 0 — the number is already in ordinary form."); + + const { digits } = parseOrdinary(coeffInput); + const A = parseFloat(coeffInput); + if (isNaN(A) || A < 1 || A >= 10) + throw new Error(`Coefficient must satisfy 1 ≤ A < 10 (got ${coeffInput}).`); + + const decimalPos = 1; + const expandedDigits = [...digits]; + + let startPos: number; + let targetPos: number; + if (n > 0) { + targetPos = decimalPos + n; + startPos = decimalPos; + while (expandedDigits.length < targetPos) expandedDigits.push("0"); + } else { + const absN = Math.abs(n); + const zeros = Array(absN).fill("0"); + expandedDigits.unshift(...zeros); + startPos = decimalPos + absN; + targetPos = startPos + n; + } + + initDisplay(expandedDigits, startPos, targetPos, n); + } catch (e) { + setError((e as Error).message); + } + } + + const stepForward = useCallback(() => { + if (!display || currentPos === display.targetPos) return; + const dir = display.targetPos > display.initPos ? 1 : -1; + const nextPos = currentPos + dir; + setCurrentPos(nextPos); + setPowerPop(true); + setTimeout(() => setPowerPop(false), 300); + if (nextPos === display.targetPos) { + setDone(true); + setIsPlaying(false); + } + }, [display, currentPos]); + + const stepBack = useCallback(() => { + if (!display || currentPos === display.initPos) return; + if (done) setDone(false); + const dir = display.targetPos > display.initPos ? -1 : 1; + setCurrentPos((p) => p + dir); + setPowerPop(true); + setTimeout(() => setPowerPop(false), 300); + }, [display, currentPos, done]); + + const togglePlay = useCallback(() => { + setIsPlaying((p) => !p); + }, []); + + const reset = useCallback(() => { + setIsPlaying(false); + setDisplay(null); + setDone(false); + setError(""); + }, []); + + // Direction label + const remaining = display ? display.targetPos - currentPos : 0; + const dirText = + !display || done || remaining === 0 + ? null + : remaining < 0 + ? `← Moving left · ${Math.abs(remaining)} step${Math.abs(remaining) !== 1 ? "s" : ""} remaining` + : `→ Moving right · ${remaining} step${remaining !== 1 ? "s" : ""} remaining`; + + // Result + const fz = display ? firstNonZero(display.digits) : 0; + + return ( +
+ {/* Mode Tabs */} +
+ + +
+ + {/* Input Card */} + +

+ {mode === "toSF" ? "Enter an ordinary number" : "Enter a number in standard form A × 10ⁿ"} +

+ {mode === "toSF" ? ( +
+ setOrdinaryInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleConvertToSF()} + placeholder="e.g. 7438 or 0.0055" + maxLength={18} + autoComplete="off" + className="max-w-[220px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-2" + /> + +
+ ) : ( +
+ setCoeffInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleConvertToOrd()} + placeholder="A e.g. 3.6" + maxLength={12} + autoComplete="off" + className="max-w-[140px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-2" + /> + + × 10n where n = + + setPowerInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleConvertToOrd()} + placeholder="e.g. 4 or −3" + autoComplete="off" + className="max-w-[110px] rounded-lg border-2 border-border bg-surface px-3.5 py-2.5 text-lg font-medium outline-none transition-colors focus:border-unit-2" + /> + +
+ )} + {error &&

{error}

} +
+ + {/* Display Card */} + + {!display ? ( +

+ Enter a number above and click Convert +

+ ) : ( + <> + {/* Step info */} +

+ {totalSteps === 0 + ? "Number is already in standard form position — power = 0" + : `Step ${currentStep} of ${totalSteps}`} +

+ + {/* Digit row */} +
+ {display.digits.map((ch, i) => { + const isLeadingZero = i < fz && fz !== -1; + const isHighlight = i === display.targetPos - 1; + return ( +
+ {ch} +
+ ); + })} + {/* Decimal dot */} +
= display.digits.length ? 0 : 1, + }} + /> +
+ + {/* Direction label */} + {dirText && ( +

+ {dirText} +

+ )} + + {/* Power counter */} +
+ × + 10 + + {currentPower} + +
+ + )} + + + {/* Controls */} + + + + + {/* Result Card */} + + {!display || !done ? ( +

Result will appear here when steps are complete

+ ) : mode === "toSF" ? ( + <> +

Standard Form

+

+ + {buildCoeff(display.digits.slice(fz === -1 ? 0 : fz))} + + × + 10 + {display.initPos - display.targetPos} +

+ + ) : ( + <> +

Ordinary Form

+

+ {buildOrdinary(display.digits, display.targetPos)} +

+ + )} +
+
+ ); +} diff --git a/components/explorers/step-controls.tsx b/components/explorers/step-controls.tsx new file mode 100644 index 0000000..3a30e87 --- /dev/null +++ b/components/explorers/step-controls.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useEffect, useCallback, useRef } from "react"; + +interface StepControlsProps { + currentStep: number; + totalSteps: number; + isPlaying: boolean; + onStepForward: () => void; + onStepBack: () => void; + onTogglePlay: () => void; + onReset: () => void; + canStepForward: boolean; + canStepBack: boolean; + playIntervalMs?: number; +} + +export function StepControls({ + currentStep, + totalSteps, + isPlaying, + onStepForward, + onStepBack, + onTogglePlay, + onReset, + canStepForward, + canStepBack, + playIntervalMs = 950, +}: StepControlsProps) { + const timerRef = useRef | null>(null); + + // Auto-play timer + useEffect(() => { + if (isPlaying && canStepForward) { + timerRef.current = setInterval(() => { + onStepForward(); + }, playIntervalMs); + } + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, [isPlaying, canStepForward, onStepForward, playIntervalMs]); + + // Keyboard shortcuts + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement).tagName; + if (["INPUT", "TEXTAREA", "SELECT"].includes(tag)) return; + + if (e.key === "ArrowRight" || e.key === " ") { + e.preventDefault(); + if (canStepForward) onStepForward(); + } + if (e.key === "ArrowLeft") { + e.preventDefault(); + if (canStepBack) onStepBack(); + } + if (e.key.toLowerCase() === "p") onTogglePlay(); + if (e.key.toLowerCase() === "r") onReset(); + }, + [canStepForward, canStepBack, onStepForward, onStepBack, onTogglePlay, onReset], + ); + + useEffect(() => { + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [handleKeyDown]); + + const progress = totalSteps > 0 ? (currentStep / totalSteps) * 100 : 0; + + return ( +
+ {/* Progress bar */} + {totalSteps > 0 && ( +
+
+ Step {currentStep} of {totalSteps} + {Math.round(progress)}% +
+
+
+
+
+ )} + + {/* Controls */} +
+ + + + +
+ + {/* Keyboard hint */} +

+ Space{" / "} + {" "} + next{" · "} + {" "} + back{" · "} + P{" "} + play{" · "} + R{" "} + reset +

+
+ ); +} diff --git a/components/layout/breadcrumbs.tsx b/components/layout/breadcrumbs.tsx new file mode 100644 index 0000000..642d8b3 --- /dev/null +++ b/components/layout/breadcrumbs.tsx @@ -0,0 +1,38 @@ +import Link from "next/link"; + +interface BreadcrumbItem { + label: string; + href?: string; +} + +interface BreadcrumbsProps { + items: BreadcrumbItem[]; +} + +export function Breadcrumbs({ items }: BreadcrumbsProps) { + return ( + + ); +} diff --git a/components/layout/footer.tsx b/components/layout/footer.tsx new file mode 100644 index 0000000..9bba16c --- /dev/null +++ b/components/layout/footer.tsx @@ -0,0 +1,32 @@ +import Link from "next/link"; + +export function Footer() { + return ( +
+
+
+
+
+ C +
+
+

Cabrits Mathematics

+

Learn it. Try it. Master it.

+
+
+ +

+ Portsmouth Secondary School +

+
+
+
+ ); +} diff --git a/components/layout/header.tsx b/components/layout/header.tsx new file mode 100644 index 0000000..5feb1d3 --- /dev/null +++ b/components/layout/header.tsx @@ -0,0 +1,42 @@ +import Link from "next/link"; + +const navItems = [ + { href: "/", label: "Home", className: "bg-[#e0483d] hover:bg-[#cb3a30]" }, + { href: "/lessons", label: "Lessons", className: "bg-[#f08b24] hover:bg-[#d97706]" }, + { href: "/practice", label: "Practice", className: "bg-[#1faa59] hover:bg-[#168845]" }, +]; + +export function Header() { + return ( +
+
+
+ +
+ C +
+
+

Cabrits

+

Math Playground

+
+ +
+ Ages 12-16 + Form 1 Term 2 +
+
+ +
+
+ ); +} diff --git a/components/layout/mobile-nav.tsx b/components/layout/mobile-nav.tsx new file mode 100644 index 0000000..404c50b --- /dev/null +++ b/components/layout/mobile-nav.tsx @@ -0,0 +1,89 @@ +"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", +}; + +const unitDotColor = { + "unit-1": "bg-unit-1", + "unit-2": "bg-unit-2", + "unit-3": "bg-unit-3", + "unit-4": "bg-unit-4", +}; + +export function MobileNav() { + const pathname = usePathname(); + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + + {isOpen && ( +
+ 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" + > + + + + All Topics + +
+ {curriculum.map((unit) => ( +
+

+ + Unit {unit.number}: {unit.title} +

+
+ {unit.topics.map((topic) => { + const href = `/lessons/${unit.slug}/${topic.slug}`; + return ( + 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} + + ); + })} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx new file mode 100644 index 0000000..99fa031 --- /dev/null +++ b/components/layout/sidebar.tsx @@ -0,0 +1,142 @@ +"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": { + active: "bg-unit-1-light text-unit-1-dark border-unit-1/20", + dot: "bg-unit-1", + heading: "text-unit-1-dark", + hoverBg: "hover:bg-unit-1-light/50", + }, + "unit-2": { + active: "bg-unit-2-light text-unit-2-dark border-unit-2/20", + dot: "bg-unit-2", + heading: "text-unit-2-dark", + hoverBg: "hover:bg-unit-2-light/50", + }, + "unit-3": { + active: "bg-unit-3-light text-unit-3-dark border-unit-3/20", + dot: "bg-unit-3", + heading: "text-unit-3-dark", + hoverBg: "hover:bg-unit-3-light/50", + }, + "unit-4": { + active: "bg-unit-4-light text-unit-4-dark border-unit-4/20", + dot: "bg-unit-4", + heading: "text-unit-4-dark", + hoverBg: "hover:bg-unit-4-light/50", + }, +}; + +export function Sidebar() { + const pathname = usePathname(); + const [openUnits, setOpenUnits] = useState>(() => { + const open = new Set(); + for (const unit of curriculum) { + if (pathname.includes(unit.slug)) { + open.add(unit.number); + } + } + if (open.size === 0) open.add(1); + return open; + }); + + function toggleUnit(num: number) { + setOpenUnits((prev) => { + const next = new Set(prev); + if (next.has(num)) next.delete(num); + else next.add(num); + return next; + }); + } + + return ( + + ); +} diff --git a/components/math/math-display.tsx b/components/math/math-display.tsx new file mode 100644 index 0000000..a023a32 --- /dev/null +++ b/components/math/math-display.tsx @@ -0,0 +1,36 @@ +"use client"; + +import katex from "katex"; + +interface MathDisplayProps { + math: string; + className?: string; +} + +export function MathDisplay({ math, className }: MathDisplayProps) { + const html = katex.renderToString(math, { + displayMode: true, + throwOnError: false, + }); + + return ( +
+ ); +} + +export function MathInline({ math, className }: MathDisplayProps) { + const html = katex.renderToString(math, { + displayMode: false, + throwOnError: false, + }); + + return ( + + ); +} diff --git a/components/practice/fraction-input.tsx b/components/practice/fraction-input.tsx new file mode 100644 index 0000000..841f67a --- /dev/null +++ b/components/practice/fraction-input.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +interface FractionInputProps { + numerator: string; + denominator: string; + onNumeratorChange: (value: string) => void; + onDenominatorChange: (value: string) => void; + disabled?: boolean; + className?: string; +} + +export function FractionInput({ + numerator, + denominator, + onNumeratorChange, + onDenominatorChange, + disabled = false, + className, +}: FractionInputProps) { + return ( +
+ onNumeratorChange(e.target.value)} + 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" + placeholder="?" + aria-label="Numerator" + /> +
+ onDenominatorChange(e.target.value)} + 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" + placeholder="?" + aria-label="Denominator" + /> +
+ ); +} + +interface DecimalInputProps { + value: string; + onChange: (value: string) => void; + disabled?: boolean; + className?: string; +} + +export function DecimalInput({ value, onChange, disabled, className }: DecimalInputProps) { + return ( + onChange(e.target.value)} + 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", + className, + )} + placeholder="?" + aria-label="Answer" + /> + ); +} + +interface RatioInputProps { + parts: string[]; + onChange: (index: number, value: string) => void; + disabled?: boolean; + className?: string; +} + +export function RatioInput({ parts, onChange, disabled, className }: RatioInputProps) { + return ( +
+ {parts.map((p, i) => ( + + {i > 0 && :} + onChange(i, e.target.value)} + disabled={disabled} + className="w-14 rounded-lg border border-border bg-surface px-2 py-2 text-center text-lg font-bold focus:border-unit-4 focus:outline-none" + placeholder="?" + aria-label={`Part ${i + 1}`} + /> + + ))} +
+ ); +} diff --git a/components/practice/practice-section.tsx b/components/practice/practice-section.tsx new file mode 100644 index 0000000..0950ea8 --- /dev/null +++ b/components/practice/practice-section.tsx @@ -0,0 +1,308 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import type { MathProblem, Difficulty } from "@/lib/problems/types"; +import { checkFractionAnswer, checkDecimalAnswer, checkRatioAnswer, checkIntegerAnswer, type AnswerResult } from "@/lib/math/validation"; +import { FractionInput, DecimalInput, RatioInput } from "./fraction-input"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import katex from "katex"; + +interface PracticeSectionProps { + title: string; + generator: (difficulty: Difficulty) => MathProblem; + unitColor?: "unit-1" | "unit-2" | "unit-3" | "unit-4"; +} + +type UnitColor = NonNullable; + +const activeDifficultyStyle: Record = { + "unit-1": "bg-unit-1 text-white", + "unit-2": "bg-unit-2 text-white", + "unit-3": "bg-unit-3 text-white", + "unit-4": "bg-unit-4 text-white", +}; + +export function PracticeSection({ title, generator, unitColor = "unit-1" }: PracticeSectionProps) { + const [difficulty, setDifficulty] = useState(1); + const [problem, setProblem] = useState(() => generator(1)); + const [userAnswer, setUserAnswer] = useState>({}); + const [result, setResult] = useState(null); + const [showHint, setShowHint] = useState(false); + const [hintIndex, setHintIndex] = useState(0); + const [showSolution, setShowSolution] = useState(false); + const [score, setScore] = useState({ correct: 0, total: 0 }); + + const generateNew = useCallback((diff: Difficulty) => { + setProblem(generator(diff)); + setUserAnswer({}); + setResult(null); + setShowHint(false); + setHintIndex(0); + setShowSolution(false); + }, [generator]); + + function checkAnswer() { + const answer = problem.answer; + let res: AnswerResult; + + switch (answer.kind) { + case "fraction": { + const num = parseInt(userAnswer.numerator || "0"); + const den = parseInt(userAnswer.denominator || "0"); + if (isNaN(num) || isNaN(den)) { + res = { correct: false, message: "Enter valid numbers" }; + } else { + res = checkFractionAnswer(num, den, answer.numerator, answer.denominator); + } + break; + } + case "decimal": { + const val = parseFloat(userAnswer.value || ""); + if (isNaN(val)) { + res = { correct: false, message: "Enter a valid number" }; + } else { + res = checkDecimalAnswer(val, answer.value); + } + break; + } + case "integer": { + const val = parseInt(userAnswer.value || ""); + if (isNaN(val)) { + res = { correct: false, message: "Enter a valid number" }; + } else { + res = checkIntegerAnswer(val, answer.value); + } + break; + } + case "ratio": { + const parts = (userAnswer.ratio || "").split(":").map((p) => parseInt(p.trim())); + if (parts.some(isNaN)) { + res = { correct: false, message: "Enter valid ratio parts" }; + } else { + res = checkRatioAnswer(parts, answer.parts); + } + break; + } + case "standardForm": { + const coeff = parseFloat(userAnswer.coefficient || ""); + const exp = parseInt(userAnswer.exponent || ""); + if (isNaN(coeff) || isNaN(exp)) { + res = { correct: false, message: "Enter valid numbers" }; + } else if ( + Math.abs(coeff - answer.coefficient) < 0.01 && + exp === answer.exponent + ) { + res = { correct: true, simplified: true }; + } else { + res = { correct: false, message: "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 })); + } + } + + function nextHint() { + if (hintIndex < problem.hints.length - 1) { + setHintIndex((i) => i + 1); + } + setShowHint(true); + } + + const promptHtml = katex.renderToString(problem.prompt, { throwOnError: false, displayMode: true }); + + return ( + +
+

{title}

+
+ + {score.correct}/{score.total} + +
+ {([1, 2, 3] as Difficulty[]).map((d) => ( + + ))} +
+
+
+ + {/* Problem */} +
+ + {/* Input */} +
+ {problem.answer.kind === "fraction" && ( + setUserAnswer((a) => ({ ...a, numerator: v }))} + onDenominatorChange={(v) => setUserAnswer((a) => ({ ...a, denominator: v }))} + disabled={result?.correct === true} + /> + )} + + {(problem.answer.kind === "decimal" || problem.answer.kind === "integer") && ( + setUserAnswer((a) => ({ ...a, value: v }))} + disabled={result?.correct === true} + /> + )} + + {problem.answer.kind === "ratio" && (() => { + const ratioParts = (problem.answer as { kind: "ratio"; parts: number[] }).parts; + return ( + "") + } + onChange={(i, v) => { + const current = userAnswer.ratioParts + ? JSON.parse(userAnswer.ratioParts) + : ratioParts.map(() => ""); + current[i] = v; + setUserAnswer((a) => ({ + ...a, + ratioParts: JSON.stringify(current), + ratio: current.join(":"), + })); + }} + disabled={result?.correct === true} + /> + ); + })()} + + {problem.answer.kind === "standardForm" && ( +
+ setUserAnswer((a) => ({ ...a, coefficient: e.target.value }))} + disabled={result?.correct === true} + className="w-16 rounded-lg border border-border bg-surface px-2 py-2 text-center font-bold focus:border-unit-2 focus:outline-none" + placeholder="?" + aria-label="Coefficient" + /> + x 10 + setUserAnswer((a) => ({ ...a, exponent: e.target.value }))} + disabled={result?.correct === true} + className="w-12 -translate-y-2 rounded-lg border border-border bg-surface px-2 py-1 text-center text-sm font-bold focus:border-unit-2 focus:outline-none" + placeholder="?" + aria-label="Exponent" + /> +
+ )} +
+ + {/* Actions */} +
+ {!result?.correct && ( + <> + + + + + )} + {result?.correct && ( + + )} +
+ + {/* Feedback */} + + {result && ( + + {result.correct + ? result.simplified + ? "Correct!" + : "Correct, but can you simplify further?" + : result.message} + + )} + + + {/* Hint */} + + {showHint && ( + + {problem.hints[hintIndex]} + + )} + + + {/* Solution */} + + {showSolution && ( + +

Solution

+ {problem.steps.map((step, i) => ( +
+ {i + 1}. + {step.explanation} +
+ ))} +
+ )} +
+ + ); +} diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..94bd4fc --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,31 @@ +import { cn } from "@/lib/utils"; +import { type HTMLAttributes } from "react"; + +type BadgeVariant = "default" | "unit-1" | "unit-2" | "unit-3" | "unit-4"; + +interface BadgeProps extends HTMLAttributes { + variant?: BadgeVariant; +} + +const variantStyles: Record = { + default: "border border-border/70 bg-background text-muted", + "unit-1": "border border-unit-1/20 bg-unit-1-light text-unit-1-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-4": "border border-unit-4/20 bg-unit-4-light text-unit-4-dark", +}; + +export function Badge({ variant = "default", className, children, ...props }: BadgeProps) { + return ( + + {children} + + ); +} diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..3b798ac --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,51 @@ +import { cn } from "@/lib/utils"; +import { type ButtonHTMLAttributes } from "react"; + +type ButtonVariant = "primary" | "secondary" | "ghost" | "unit-1" | "unit-2" | "unit-3" | "unit-4"; +type ButtonSize = "sm" | "md" | "lg"; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; +} + +const variantStyles: Record = { + primary: "bg-foreground text-background shadow-[var(--shadow-sm)] hover:bg-foreground/90 hover:shadow-[var(--shadow-lg)]", + secondary: "border-2 border-border/70 bg-surface text-foreground shadow-[var(--shadow-sm)] hover:bg-background hover:shadow-[var(--shadow-md)]", + ghost: "text-foreground hover:bg-border/30", + "unit-1": "bg-unit-1 text-white shadow-[var(--shadow-sm)] hover:bg-unit-1-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-4": "bg-unit-4 text-white shadow-[var(--shadow-sm)] hover:bg-unit-4-dark hover:shadow-[var(--shadow-lg)]", +}; + +const sizeStyles: Record = { + sm: "px-4 py-2 text-sm", + md: "px-5.5 py-2.5 text-base", + lg: "px-8 py-3.5 text-lg", +}; + +export function Button({ + variant = "primary", + size = "md", + className, + children, + ...props +}: ButtonProps) { + return ( + + ); +} diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..c7ae94a --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,76 @@ +import { cn } from "@/lib/utils"; +import { type HTMLAttributes } from "react"; + +interface CardProps extends HTMLAttributes { + accent?: "unit-1" | "unit-2" | "unit-3" | "unit-4"; + hover?: boolean; +} + +const accentStyles = { + "unit-1": "border-l-4 border-l-unit-1", + "unit-2": "border-l-4 border-l-unit-2", + "unit-3": "border-l-4 border-l-unit-3", + "unit-4": "border-l-4 border-l-unit-4", +}; + +export function Card({ + accent, + hover = false, + className, + children, + ...props +}: CardProps) { + return ( +
+ {children} +
+ ); +} + +export function CardHeader({ + className, + children, + ...props +}: HTMLAttributes) { + return ( +
+ {children} +
+ ); +} + +export function CardTitle({ + className, + children, + ...props +}: HTMLAttributes) { + return ( +

+ {children} +

+ ); +} + +export function CardDescription({ + className, + children, + ...props +}: HTMLAttributes) { + return ( +

+ {children} +

+ ); +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b77bb8e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + cabrits: + build: + context: . + dockerfile: Dockerfile + image: registry.dwarrington.com/cabrits:${IMAGE_TAG} + container_name: cabrits + restart: always + ports: + - "5030:3000" + networks: + - caddy_network + +networks: + caddy_network: + external: true diff --git a/lib/curriculum.ts b/lib/curriculum.ts new file mode 100644 index 0000000..f23e888 --- /dev/null +++ b/lib/curriculum.ts @@ -0,0 +1,198 @@ +export interface Topic { + slug: string; + title: string; + shortTitle: string; + week: number; + description: string; +} + +export interface Unit { + number: 1 | 2 | 3 | 4; + slug: string; + title: string; + description: string; + weeks: string; + color: "unit-1" | "unit-2" | "unit-3" | "unit-4"; + topics: Topic[]; +} + +export const curriculum: Unit[] = [ + { + number: 1, + slug: "unit-1-fractions", + title: "Fractions", + description: "Add, subtract, multiply, divide fractions and apply to quantities", + weeks: "Weeks 1-3", + color: "unit-1", + topics: [ + { + slug: "add-subtract", + title: "Add and Subtract Fractions", + shortTitle: "Add & Subtract", + week: 1, + description: "Common and uncommon denominators, Butterfly Method", + }, + { + slug: "multiply", + title: "Multiply Fractions", + shortTitle: "Multiply", + week: 1, + description: "Multiply numerators and denominators, simplify", + }, + { + slug: "divide", + title: "Divide Fractions", + shortTitle: "Divide", + week: 1, + description: "Invert and multiply (reciprocal method)", + }, + { + slug: "mixed-operations", + title: "Mixed Operations (BODMAS)", + shortTitle: "BODMAS", + week: 2, + description: "Order of operations with fractions", + }, + { + slug: "fraction-of-quantity", + title: "Fraction of a Quantity", + shortTitle: "Of a Quantity", + week: 3, + description: "Calculate a fraction of a given amount", + }, + { + slug: "whole-from-fractions", + title: "Calculate the Whole from Fractions", + shortTitle: "Find the Whole", + week: 3, + description: "Find the whole when given a part and its fraction", + }, + ], + }, + { + number: 2, + slug: "unit-2-decimals", + title: "Decimals", + description: "Compare, order, round decimals and express in standard form", + weeks: "Weeks 4-5", + color: "unit-2", + topics: [ + { + slug: "compare-order", + title: "Compare and Order Decimals", + shortTitle: "Compare & Order", + week: 4, + description: "Place value, ascending, descending, greater/less than", + }, + { + slug: "approximate", + title: "Approximate Decimals", + shortTitle: "Approximation", + week: 4, + description: "Round to whole numbers, decimal places, significant figures", + }, + { + slug: "standard-form", + title: "Standard Form (Scientific Notation)", + shortTitle: "Standard Form", + week: 5, + description: "Express numbers as a × 10^n", + }, + ], + }, + { + number: 3, + slug: "unit-3-decimal-operations", + title: "Decimal Operations", + description: "Convert, add, subtract, multiply and divide decimals", + weeks: "Weeks 6-7", + color: "unit-3", + topics: [ + { + slug: "convert", + title: "Convert Decimals and Fractions", + shortTitle: "Convert", + week: 6, + description: "Decimals to fractions and fractions to decimals", + }, + { + slug: "add-subtract", + title: "Add and Subtract Decimals", + shortTitle: "Add & Subtract", + week: 6, + description: "Align decimal points, insert zeros as placeholders", + }, + { + slug: "multiply-divide", + title: "Multiply and Divide Decimals", + shortTitle: "Multiply & Divide", + week: 7, + description: "By powers of 10, whole numbers, and decimals", + }, + ], + }, + { + number: 4, + slug: "unit-4-ratio-proportion", + title: "Ratio & Proportion", + description: "Define, simplify, and apply ratios to solve problems", + weeks: "Weeks 8-9", + color: "unit-4", + topics: [ + { + slug: "define-ratio", + title: "Define a Ratio", + shortTitle: "Define Ratio", + week: 8, + description: "Relationship between two or more quantities", + }, + { + slug: "fractions-and-ratios", + title: "Fractions and Ratios", + shortTitle: "Fractions & Ratios", + week: 8, + description: "Understand the relationship between fractions and ratios", + }, + { + slug: "simplify-ratios", + title: "Simplify Ratios", + shortTitle: "Simplify", + week: 8, + description: "Write ratios in their simplest form", + }, + { + slug: "divide-in-ratio", + title: "Divide a Quantity in a Given Ratio", + shortTitle: "Divide in Ratio", + week: 9, + description: "Share amounts using ratios", + }, + { + slug: "word-problems", + title: "Proportional Parts Word Problems", + shortTitle: "Word Problems", + week: 9, + description: "Solve real-world problems with ratios", + }, + ], + }, +]; + +export function getUnit(slug: string): Unit | undefined { + return curriculum.find((u) => u.slug === slug); +} + +export function getTopic(unitSlug: string, topicSlug: string): Topic | undefined { + const unit = getUnit(unitSlug); + return unit?.topics.find((t) => t.slug === topicSlug); +} + +export function getUnitColor(unitNumber: 1 | 2 | 3 | 4): string { + const colors = { + 1: "unit-1", + 2: "unit-2", + 3: "unit-3", + 4: "unit-4", + }; + return colors[unitNumber]; +} diff --git a/lib/math/decimals.ts b/lib/math/decimals.ts new file mode 100644 index 0000000..6592323 --- /dev/null +++ b/lib/math/decimals.ts @@ -0,0 +1,65 @@ +export function roundToWholeNumber(value: number): number { + return Math.round(value); +} + +export function roundToDecimalPlaces(value: number, places: number): number { + const factor = Math.pow(10, places); + return Math.round(value * factor) / factor; +} + +export function roundToSignificantFigures(value: number, sigFigs: number): number { + if (value === 0) return 0; + const magnitude = Math.floor(Math.log10(Math.abs(value))); + const factor = Math.pow(10, sigFigs - 1 - magnitude); + return Math.round(value * factor) / factor; +} + +export function toStandardForm(value: number): { coefficient: number; exponent: number } { + if (value === 0) return { coefficient: 0, exponent: 0 }; + const exponent = Math.floor(Math.log10(Math.abs(value))); + const coefficient = value / Math.pow(10, exponent); + return { + coefficient: roundToDecimalPlaces(coefficient, 10), + exponent, + }; +} + +export function fromStandardForm(coefficient: number, exponent: number): number { + return coefficient * Math.pow(10, exponent); +} + +export function compareDecimals(a: number, b: number): -1 | 0 | 1 { + if (a > b) return 1; + if (a < b) return -1; + return 0; +} + +export function getPlaceValue(value: number): { digit: number; place: string }[] { + const places = [ + "thousands", "hundreds", "tens", "units", "tenths", "hundredths", "thousandths", + ]; + const str = Math.abs(value).toFixed(3); + const [intPart, decPart] = str.split("."); + const paddedInt = intPart.padStart(4, "0"); + const result: { digit: number; place: string }[] = []; + + for (let i = 0; i < 4; i++) { + result.push({ digit: parseInt(paddedInt[i]), place: places[i] }); + } + for (let i = 0; i < 3; i++) { + result.push({ digit: parseInt(decPart[i]), place: places[4 + i] }); + } + return result; +} + +export function multiplyByPowerOf10(value: number, power: number): number { + return roundToDecimalPlaces(value * Math.pow(10, power), 10); +} + +export function divideByPowerOf10(value: number, power: number): number { + return roundToDecimalPlaces(value / Math.pow(10, power), 10); +} + +export function standardFormToKatex(coefficient: number, exponent: number): string { + return `${coefficient} \\times 10^{${exponent}}`; +} diff --git a/lib/math/fractions.ts b/lib/math/fractions.ts new file mode 100644 index 0000000..4c8cc2c --- /dev/null +++ b/lib/math/fractions.ts @@ -0,0 +1,107 @@ +export function gcd(a: number, b: number): number { + a = Math.abs(a); + b = Math.abs(b); + while (b) { + [a, b] = [b, a % b]; + } + return a; +} + +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]; +} + +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); +} + +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); +} + +export function multiply( + n1: number, d1: number, + n2: number, d2: number, +): [number, number] { + return simplify(n1 * n2, d1 * d2); +} + +export function divide( + n1: number, d1: number, + n2: number, d2: number, +): [number, number] { + return simplify(n1 * d2, d1 * n2); +} + +export function toDecimal(num: number, den: number): number { + return num / den; +} + +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); +} + +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; +} + +export function fractionOfQuantity(num: number, den: number, quantity: number): number { + return (num / den) * quantity; +} + +export function wholeFromFraction(num: number, den: number, part: number): number { + return (part * den) / num; +} + +export function isProper(num: number, den: number): boolean { + return Math.abs(num) < Math.abs(den); +} + +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)]; +} + +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]; +} + +export function isSimplified(num: number, den: number): boolean { + return gcd(Math.abs(num), Math.abs(den)) === 1; +} + +export function toKatex(num: number, den: number): string { + if (den === 1) return `${num}`; + return `\\frac{${num}}{${den}}`; +} diff --git a/lib/math/ratios.ts b/lib/math/ratios.ts new file mode 100644 index 0000000..004b63e --- /dev/null +++ b/lib/math/ratios.ts @@ -0,0 +1,35 @@ +import { gcd } from "./fractions"; + +export function simplifyRatio(parts: number[]): number[] { + if (parts.length === 0) return []; + let g = parts[0]; + for (let i = 1; i < parts.length; i++) { + g = gcd(g, parts[i]); + } + return parts.map((p) => p / g); +} + +export function divideInRatio(total: number, parts: number[]): number[] { + const sum = parts.reduce((a, b) => a + b, 0); + const onePart = total / sum; + return parts.map((p) => p * onePart); +} + +export function ratioToFraction(a: number, b: number): [number, number] { + return [a, a + b]; +} + +export function areEquivalent(ratio1: number[], ratio2: number[]): boolean { + if (ratio1.length !== ratio2.length) return false; + const s1 = simplifyRatio(ratio1); + const s2 = simplifyRatio(ratio2); + return s1.every((v, i) => v === s2[i]); +} + +export function totalParts(parts: number[]): number { + return parts.reduce((a, b) => a + b, 0); +} + +export function ratioToKatex(parts: number[]): string { + return parts.join(":"); +} diff --git a/lib/math/validation.ts b/lib/math/validation.ts new file mode 100644 index 0000000..df868f3 --- /dev/null +++ b/lib/math/validation.ts @@ -0,0 +1,94 @@ +import { gcd } from "./fractions"; +import { simplifyRatio } from "./ratios"; + +export type AnswerResult = + | { correct: true; simplified: boolean } + | { correct: false; message: string }; + +export function checkFractionAnswer( + userNum: number, + userDen: number, + expectedNum: number, + expectedDen: number, + requireSimplified: boolean = true, +): AnswerResult { + if (userDen === 0) { + return { correct: false, message: "Denominator cannot be zero" }; + } + + // Check mathematical equivalence + const isEquivalent = userNum * expectedDen === expectedNum * userDen; + + if (!isEquivalent) { + return { correct: false, message: "That's not quite right. Try again!" }; + } + + const isSimplified = gcd(Math.abs(userNum), Math.abs(userDen)) === 1; + + if (requireSimplified && !isSimplified) { + return { correct: true, simplified: false }; + } + + return { correct: true, simplified: true }; +} + +export function checkDecimalAnswer( + userValue: number, + expectedValue: number, + tolerance: number = 0.001, +): AnswerResult { + if (Math.abs(userValue - expectedValue) <= tolerance) { + return { correct: true, simplified: true }; + } + return { correct: false, message: "That's not quite right. Try again!" }; +} + +export function checkRatioAnswer( + userParts: number[], + expectedParts: number[], + requireSimplified: boolean = true, +): AnswerResult { + if (userParts.length !== expectedParts.length) { + return { correct: false, message: "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!" }; + } + + if (requireSimplified) { + const isAlreadySimplified = userParts.every((v, i) => v === userSimplified[i]); + return { correct: true, simplified: isAlreadySimplified }; + } + + return { correct: true, simplified: true }; +} + +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!" }; +} + +export function checkOrderingAnswer( + userOrder: number[], + expectedOrder: number[], +): AnswerResult { + if (userOrder.length !== expectedOrder.length) { + return { correct: false, message: "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!" }; +} diff --git a/lib/problems/generators/decimal-problems.ts b/lib/problems/generators/decimal-problems.ts new file mode 100644 index 0000000..91e3c53 --- /dev/null +++ b/lib/problems/generators/decimal-problems.ts @@ -0,0 +1,287 @@ +import type { MathProblem, Difficulty } from "../types"; +import { randomInt, randomChoice, shuffle } from "@/lib/utils"; +import { roundToDecimalPlaces, roundToSignificantFigures, toStandardForm, standardFormToKatex } from "@/lib/math/decimals"; + +let counter = 0; +function nextId() { + return `dp-${++counter}-${Date.now()}`; +} + +export function generateDecimalCompareOrder(difficulty: Difficulty): MathProblem { + const count = difficulty === 1 ? 3 : difficulty === 2 ? 4 : 5; + const values: number[] = []; + + for (let i = 0; i < count; i++) { + const intPart = randomInt(0, difficulty === 1 ? 2 : 10); + const decPlaces = randomInt(1, difficulty === 1 ? 2 : 3); + const decPart = randomInt(1, Math.pow(10, decPlaces) - 1); + const val = intPart + decPart / Math.pow(10, decPlaces); + values.push(Math.round(val * 1000) / 1000); + } + + const sorted = [...values].sort((a, b) => a - b); + const shuffled = shuffle(values); + + return { + id: nextId(), + prompt: `\\text{Order from smallest to largest: } ${shuffled.join(", ")}`, + answer: { kind: "decimal", value: 0 }, + hints: [ + "Compare the whole number part first", + "If the whole parts are equal, compare tenths, then hundredths", + "Writing them with the same number of decimal places can help", + ], + steps: sorted.map((v, i) => ({ + explanation: `Position ${i + 1}: ${v}`, + })), + }; +} + +export function generateDecimalRounding(difficulty: Difficulty): MathProblem { + const value = randomInt(100, 99999) / (difficulty === 1 ? 100 : 1000); + const roundType = randomChoice( + difficulty === 1 + ? ["whole", "1dp"] as const + : difficulty === 2 + ? ["1dp", "2dp", "whole"] as const + : ["1sf", "2sf", "3sf"] as const, + ); + + let answer: number; + let instruction: string; + + switch (roundType) { + case "whole": + answer = Math.round(value); + instruction = "the nearest whole number"; + break; + case "1dp": + answer = roundToDecimalPlaces(value, 1); + instruction = "1 decimal place"; + break; + case "2dp": + answer = roundToDecimalPlaces(value, 2); + instruction = "2 decimal places"; + break; + case "1sf": + answer = roundToSignificantFigures(value, 1); + instruction = "1 significant figure"; + break; + case "2sf": + answer = roundToSignificantFigures(value, 2); + instruction = "2 significant figures"; + break; + case "3sf": + answer = roundToSignificantFigures(value, 3); + instruction = "3 significant figures"; + break; + } + + return { + id: nextId(), + prompt: `\\text{Round } ${value} \\text{ to ${instruction}}`, + answer: { kind: "decimal", value: answer }, + hints: [ + "Look at the digit to the right of where you're rounding", + "If it's 5 or more, round up. If it's less than 5, round down", + ], + steps: [ + { explanation: `Rounding ${value} to ${instruction}` }, + { explanation: `Answer: ${answer}` }, + ], + }; +} + +export function generateStandardForm(difficulty: Difficulty): MathProblem { + let value: number; + if (difficulty === 1) { + value = randomChoice([7438, 1578, 62, 93000, 5200, 340]); + } else if (difficulty === 2) { + value = randomChoice([0.086, 0.748, 0.0055, 0.34, 0.007]); + } else { + value = randomChoice([12436.3, 0.00092, 504000, 0.0000067, 83100]); + } + + const sf = toStandardForm(value); + + return { + id: nextId(), + prompt: `\\text{Express } ${value} \\text{ in standard form}`, + answer: { kind: "standardForm", coefficient: sf.coefficient, exponent: sf.exponent }, + hints: [ + "Move the decimal point so there's one non-zero digit before it", + "Count how many places you moved the decimal", + value >= 1 + ? "Number is ≥ 1, so the power is positive" + : "Number is < 1, so the power is negative", + ], + steps: [ + { explanation: `Move the decimal point to get a number between 1 and 10` }, + { explanation: `Coefficient: ${sf.coefficient}` }, + { explanation: `The decimal moved ${Math.abs(sf.exponent)} places, power = ${sf.exponent}` }, + { explanation: "Answer", math: standardFormToKatex(sf.coefficient, sf.exponent) }, + ], + }; +} + +export function generateDecimalAddSubtract(difficulty: Difficulty): MathProblem { + const operation = randomChoice(["add", "subtract"] as const); + const op = operation === "add" ? "+" : "-"; + const places = difficulty === 1 ? 1 : difficulty === 2 ? 2 : 3; + + const a = randomInt(100, 9999) / Math.pow(10, places); + const b = randomInt(100, 9999) / Math.pow(10, places); + const [first, second] = operation === "subtract" ? [Math.max(a, b), Math.min(a, b)] : [a, b]; + + const answer = roundToDecimalPlaces( + operation === "add" ? first + second : first - second, + places, + ); + + return { + id: nextId(), + prompt: `${first} ${op} ${second}`, + answer: { kind: "decimal", value: answer }, + hints: [ + "Line up the decimal points", + "Add zeros to fill empty places", + operation === "add" ? "Add column by column from right to left" : "Subtract column by column, borrow if needed", + ], + steps: [ + { explanation: `Line up: ${first} ${op} ${second}` }, + { explanation: `= ${answer}` }, + ], + }; +} + +export function generateDecimalMultiplyDivide(difficulty: Difficulty): MathProblem { + const operation = randomChoice(["multiply", "divide"] as const); + + if (difficulty === 1) { + // By powers of 10 + const power = randomChoice([10, 100, 1000]); + const base = randomInt(1, 999) / 100; + + if (operation === "multiply") { + const answer = roundToDecimalPlaces(base * power, 10); + return { + id: nextId(), + prompt: `${base} \\times ${power}`, + answer: { kind: "decimal", value: answer }, + hints: [ + `When multiplying by ${power}, move decimal ${Math.log10(power)} place(s) right`, + ], + steps: [ + { explanation: `Move decimal point ${Math.log10(power)} place(s) to the right` }, + { explanation: `= ${answer}` }, + ], + }; + } else { + const answer = roundToDecimalPlaces(base / power, 10); + return { + id: nextId(), + prompt: `${base} \\div ${power}`, + answer: { kind: "decimal", value: answer }, + hints: [ + `When dividing by ${power}, move decimal ${Math.log10(power)} place(s) left`, + ], + steps: [ + { explanation: `Move decimal point ${Math.log10(power)} place(s) to the left` }, + { explanation: `= ${answer}` }, + ], + }; + } + } + + // By whole numbers or decimals + const a = randomInt(10, 999) / 10; + const b = difficulty === 2 ? randomInt(2, 12) : randomInt(10, 99) / 10; + + if (operation === "multiply") { + const answer = roundToDecimalPlaces(a * b, 4); + return { + id: nextId(), + prompt: `${a} \\times ${b}`, + answer: { kind: "decimal", value: answer }, + hints: [ + "Multiply as if there are no decimal points", + "Count total decimal places in both numbers", + "Place the decimal point in your answer", + ], + steps: [ + { explanation: `${a} × ${b}` }, + { explanation: `= ${answer}` }, + ], + }; + } else { + const answer = roundToDecimalPlaces(a / b, 4); + return { + id: nextId(), + prompt: `${a} \\div ${b}`, + answer: { kind: "decimal", value: answer }, + hints: [ + "Place decimal point in answer directly above the one in the dividend", + "Divide as normal", + ], + steps: [ + { explanation: `${a} ÷ ${b}` }, + { explanation: `= ${answer}` }, + ], + }; + } +} + +export function generateDecimalConversion(difficulty: Difficulty): MathProblem { + const direction = randomChoice(["toFraction", "toDecimal"] as const); + + if (direction === "toFraction") { + const decimal = randomChoice( + difficulty === 1 + ? [0.5, 0.25, 0.75, 0.1, 0.2, 0.4] + : [0.125, 0.375, 0.625, 0.875, 0.15, 0.35, 0.45], + ); + const den = decimal === 0.5 ? 2 : decimal === 0.25 || decimal === 0.75 ? 4 : [0.125, 0.375, 0.625, 0.875].includes(decimal) ? 8 : 100; + const num = Math.round(decimal * den); + const g = gcdSimple(num, den); + + return { + id: nextId(), + prompt: `\\text{Convert } ${decimal} \\text{ to a fraction}`, + answer: { kind: "fraction", numerator: num / g, denominator: den / g }, + hints: [ + `Write as ${num}/${den}`, + "Simplify by dividing numerator and denominator by their GCD", + ], + steps: [ + { explanation: `${decimal} = ${num}/${den}` }, + { explanation: `Simplified: ${num / g}/${den / g}` }, + ], + }; + } else { + const den = randomChoice(difficulty === 1 ? [2, 4, 5, 10] : [3, 8, 20, 25, 40, 50]); + const num = randomInt(1, den - 1); + const answer = roundToDecimalPlaces(num / den, 6); + + return { + id: nextId(), + prompt: `\\text{Convert } \\frac{${num}}{${den}} \\text{ to a decimal}`, + answer: { kind: "decimal", value: answer }, + hints: [ + `Divide ${num} by ${den}`, + "Or find an equivalent fraction with denominator 10, 100, etc.", + ], + steps: [ + { explanation: `${num} ÷ ${den} = ${answer}` }, + ], + }; + } +} + +function gcdSimple(a: number, b: number): number { + a = Math.abs(a); + b = Math.abs(b); + while (b) { + [a, b] = [b, a % b]; + } + return a; +} diff --git a/lib/problems/generators/fraction-problems.ts b/lib/problems/generators/fraction-problems.ts new file mode 100644 index 0000000..bd38ef6 --- /dev/null +++ b/lib/problems/generators/fraction-problems.ts @@ -0,0 +1,163 @@ +import type { MathProblem, Difficulty } from "../types"; +import { randomInt, randomChoice } from "@/lib/utils"; +import * as frac from "@/lib/math/fractions"; + +let counter = 0; +function nextId() { + return `fp-${++counter}-${Date.now()}`; +} + +export function generateFractionAddSubtract(difficulty: Difficulty): MathProblem { + const operation = randomChoice(["add", "subtract"] as const); + const op = operation === "add" ? "+" : "-"; + + let n1: number, d1: number, n2: number, d2: number; + + if (difficulty === 1) { + // Common denominators + d1 = randomChoice([2, 3, 4, 5, 6, 8, 10, 12]); + d2 = d1; + n1 = randomInt(1, d1 - 1); + n2 = operation === "subtract" ? randomInt(1, n1) : randomInt(1, d1 - n1); + } else if (difficulty === 2) { + // One denominator is a multiple of the other + d1 = randomChoice([2, 3, 4, 5, 6]); + const multiplier = randomChoice([2, 3]); + d2 = d1 * multiplier; + n1 = randomInt(1, d1 - 1); + n2 = randomInt(1, d2 - 1); + } else { + // Unlike denominators + d1 = randomChoice([3, 4, 5, 7, 8, 9]); + d2 = randomChoice([3, 4, 5, 7, 8, 9].filter((d) => d !== d1)); + n1 = randomInt(1, d1 - 1); + n2 = randomInt(1, d2 - 1); + } + + const [ansNum, ansDen] = operation === "add" + ? frac.add(n1, d1, n2, d2) + : frac.subtract(n1, d1, n2, d2); + + const prompt = `\\frac{${n1}}{${d1}} ${op} \\frac{${n2}}{${d2}}`; + + const commonDen = frac.lcm(d1, d2); + const steps = d1 === d2 + ? [ + { explanation: "Denominators are the same, so just " + (operation === "add" ? "add" : "subtract") + " the numerators" }, + { explanation: `${n1} ${op} ${n2} = ${operation === "add" ? n1 + n2 : n1 - n2}`, math: `\\frac{${operation === "add" ? n1 + n2 : n1 - n2}}{${d1}}` }, + ] + : [ + { explanation: `Find the common denominator: LCM(${d1}, ${d2}) = ${commonDen}` }, + { explanation: `Convert: ${n1}×${commonDen / d1}/${d1}×${commonDen / d1} and ${n2}×${commonDen / d2}/${d2}×${commonDen / d2}` }, + { explanation: `${op === "+" ? "Add" : "Subtract"} numerators`, math: `\\frac{${ansNum}}{${ansDen}}` }, + ]; + + return { + id: nextId(), + prompt, + answer: { kind: "fraction", numerator: ansNum, denominator: ansDen }, + hints: [ + d1 === d2 ? "The denominators are already the same!" : "Find a common denominator first", + d1 !== d2 ? `Try using the Butterfly Method: cross multiply` : `Just ${operation} the numerators`, + ], + steps, + }; +} + +export function generateFractionMultiply(difficulty: Difficulty): MathProblem { + const denChoices = difficulty === 1 ? [2, 3, 4, 5] : difficulty === 2 ? [2, 3, 4, 5, 6, 7, 8] : [3, 5, 7, 9, 11, 12, 15]; + const d1 = randomChoice(denChoices); + const d2 = randomChoice(denChoices); + const n1 = randomInt(1, d1 - 1); + const n2 = randomInt(1, d2 - 1); + + const [ansNum, ansDen] = frac.multiply(n1, d1, n2, d2); + + return { + id: nextId(), + prompt: `\\frac{${n1}}{${d1}} \\times \\frac{${n2}}{${d2}}`, + answer: { kind: "fraction", numerator: ansNum, denominator: ansDen }, + hints: [ + "Multiply numerator × numerator", + "Multiply denominator × denominator", + "Simplify if possible", + ], + steps: [ + { explanation: `Multiply numerators: ${n1} × ${n2} = ${n1 * n2}` }, + { explanation: `Multiply denominators: ${d1} × ${d2} = ${d1 * d2}` }, + { explanation: "Simplify", math: `\\frac{${ansNum}}{${ansDen}}` }, + ], + }; +} + +export function generateFractionDivide(difficulty: Difficulty): MathProblem { + const denChoices = difficulty === 1 ? [2, 3, 4, 5] : difficulty === 2 ? [2, 3, 4, 5, 6, 7, 8] : [3, 5, 7, 9, 11, 14, 15]; + const d1 = randomChoice(denChoices); + const d2 = randomChoice(denChoices); + const n1 = randomInt(1, d1 - 1); + const n2 = randomInt(1, d2 - 1); + + const [ansNum, ansDen] = frac.divide(n1, d1, n2, d2); + + return { + id: nextId(), + prompt: `\\frac{${n1}}{${d1}} \\div \\frac{${n2}}{${d2}}`, + answer: { kind: "fraction", numerator: ansNum, denominator: ansDen }, + hints: [ + "Keep the first fraction the same", + "Flip the second fraction (reciprocal)", + "Then multiply", + ], + steps: [ + { explanation: `Keep ${n1}/${d1}, flip ${n2}/${d2} to ${d2}/${n2}` }, + { explanation: `Multiply: ${n1}×${d2} / ${d1}×${n2}` }, + { explanation: "Simplify", math: `\\frac{${ansNum}}{${ansDen}}` }, + ], + }; +} + +export function generateFractionOfQuantity(difficulty: Difficulty): MathProblem { + const denChoices = difficulty === 1 ? [2, 4, 5, 10] : difficulty === 2 ? [3, 4, 5, 6, 8] : [7, 8, 9, 12, 15]; + const den = randomChoice(denChoices); + const num = randomInt(1, den - 1); + const multiplier = randomChoice(difficulty === 1 ? [2, 4, 5, 10] : [3, 6, 7, 8, 9, 12]); + const quantity = den * multiplier; + const answer = num * multiplier; + + return { + id: nextId(), + prompt: `\\frac{${num}}{${den}} \\text{ of } ${quantity}`, + answer: { kind: "integer", value: answer }, + hints: [ + `Divide ${quantity} by ${den} first`, + `Then multiply by ${num}`, + ], + steps: [ + { explanation: `Divide the quantity by the denominator: ${quantity} ÷ ${den} = ${quantity / den}` }, + { explanation: `Multiply by the numerator: ${quantity / den} × ${num} = ${answer}` }, + ], + }; +} + +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); + const num = randomInt(1, den - 1); + const whole = den * randomChoice(difficulty === 1 ? [2, 3, 4, 5] : [3, 5, 6, 7, 8, 10]); + const part = (num / den) * whole; + + return { + id: nextId(), + prompt: `\\frac{${num}}{${den}} \\text{ of a number is } ${part}\\text{. Find the number.}`, + answer: { kind: "integer", value: whole }, + hints: [ + `If ${num}/${den} of the number is ${part}...`, + `Find 1/${den} first by dividing ${part} by ${num}`, + `Then multiply by ${den} to get the whole`, + ], + steps: [ + { explanation: `1/${den} of the number = ${part} ÷ ${num} = ${part / num}` }, + { explanation: `The whole number = ${part / num} × ${den} = ${whole}` }, + ], + }; +} diff --git a/lib/problems/generators/ratio-problems.ts b/lib/problems/generators/ratio-problems.ts new file mode 100644 index 0000000..69e5dac --- /dev/null +++ b/lib/problems/generators/ratio-problems.ts @@ -0,0 +1,110 @@ +import type { MathProblem, Difficulty } from "../types"; +import { randomInt, randomChoice } from "@/lib/utils"; +import { simplifyRatio, divideInRatio } from "@/lib/math/ratios"; + +let counter = 0; +function nextId() { + return `rp-${++counter}-${Date.now()}`; +} + +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; + const b = randomInt(1, difficulty === 1 ? 5 : 10) * gcd; + + const simplified = simplifyRatio([a, b]); + + return { + id: nextId(), + prompt: `\\text{Simplify the ratio } ${a}:${b}`, + answer: { kind: "ratio", parts: simplified }, + hints: [ + `Find the GCD of ${a} and ${b}`, + `The GCD is ${gcd}`, + `Divide both parts by ${gcd}`, + ], + steps: [ + { explanation: `GCD of ${a} and ${b} is ${gcd}` }, + { explanation: `${a} ÷ ${gcd} = ${simplified[0]}, ${b} ÷ ${gcd} = ${simplified[1]}` }, + { explanation: `Simplified: ${simplified.join(":")}` }, + ], + }; +} + +export function generateDivideInRatio(difficulty: Difficulty): MathProblem { + const numParts = difficulty === 3 ? 3 : 2; + const parts: number[] = []; + for (let i = 0; i < numParts; i++) { + parts.push(randomInt(1, difficulty === 1 ? 5 : 9)); + } + + const sum = parts.reduce((a, b) => a + b, 0); + const multiplier = randomChoice(difficulty === 1 ? [2, 3, 4, 5] : [3, 4, 5, 6, 7, 8]); + const total = sum * multiplier; + + const values = divideInRatio(total, parts); + const intValues = values.map(Math.round); + + const names = ["first share", "second share", "third share"]; + + return { + id: nextId(), + prompt: `\\text{Share } ${total} \\text{ in the ratio } ${parts.join(":")}`, + answer: { kind: "ratio", parts: intValues }, + hints: [ + `Total parts: ${parts.join(" + ")} = ${sum}`, + `One part = ${total} ÷ ${sum} = ${multiplier}`, + `Multiply each ratio number by ${multiplier}`, + ], + steps: [ + { explanation: `Total parts = ${parts.join(" + ")} = ${sum}` }, + { explanation: `One part = ${total} ÷ ${sum} = ${multiplier}` }, + ...parts.map((p, i) => ({ + explanation: `${names[i]}: ${p} × ${multiplier} = ${intValues[i]}`, + })), + ], + }; +} + +export function generateRatioWordProblem(difficulty: Difficulty): MathProblem { + const a = randomInt(2, 7); + const b = randomInt(2, 7); + const diff = Math.abs(a - b); + + if (diff === 0) { + return generateDivideInRatio(difficulty); + } + + const contexts = [ + { item: "sweets", nameA: "Josh", nameB: "Nathan" }, + { item: "marbles", nameA: "Amy", nameB: "Ben" }, + { item: "stickers", nameA: "Karen", nameB: "Natasha" }, + ]; + const ctx = randomChoice(contexts); + + const onePart = randomChoice(difficulty === 1 ? [5, 10, 12] : [6, 8, 9, 15, 20]); + const extraAmount = diff * onePart; + const totalParts = a + b; + const total = totalParts * onePart; + + const bigger = a > b ? ctx.nameA : ctx.nameB; + const biggerRatio = Math.max(a, b); + const smallerRatio = Math.min(a, b); + + return { + id: nextId(), + prompt: `\\text{${ctx.item.charAt(0).toUpperCase() + ctx.item.slice(1)} were shared between ${ctx.nameA} and ${ctx.nameB} in the ratio ${a}:${b}. If ${bigger} received ${extraAmount} more ${ctx.item}, find the total shared.}`, + answer: { kind: "integer", value: total }, + hints: [ + `The difference in ratio parts is ${biggerRatio} - ${smallerRatio} = ${diff}`, + `${diff} parts = ${extraAmount}, so 1 part = ${onePart}`, + `Total parts = ${a} + ${b} = ${totalParts}`, + ], + steps: [ + { explanation: `Difference in parts: ${biggerRatio} - ${smallerRatio} = ${diff}` }, + { explanation: `${diff} parts = ${extraAmount}, so 1 part = ${extraAmount} ÷ ${diff} = ${onePart}` }, + { explanation: `Total parts: ${a} + ${b} = ${totalParts}` }, + { explanation: `Total ${ctx.item}: ${totalParts} × ${onePart} = ${total}` }, + ], + }; +} diff --git a/lib/problems/types.ts b/lib/problems/types.ts new file mode 100644 index 0000000..d65a9e7 --- /dev/null +++ b/lib/problems/types.ts @@ -0,0 +1,21 @@ +export type Difficulty = 1 | 2 | 3; + +export type MathAnswer = + | { kind: "fraction"; numerator: number; denominator: number } + | { kind: "decimal"; value: number } + | { kind: "integer"; value: number } + | { kind: "ratio"; parts: number[] } + | { kind: "standardForm"; coefficient: number; exponent: number }; + +export interface SolutionStep { + explanation: string; + math?: string; +} + +export interface MathProblem { + id: string; + prompt: string; + answer: MathAnswer; + hints: string[]; + steps: SolutionStep[]; +} diff --git a/lib/utils.ts b/lib/utils.ts new file mode 100644 index 0000000..d741864 --- /dev/null +++ b/lib/utils.ts @@ -0,0 +1,23 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export function randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +export function randomChoice(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +export function shuffle(arr: T[]): T[] { + const result = [...arr]; + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; +} diff --git a/next.config.ts b/next.config.ts index e9ffa30..68a6c64 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: "standalone", }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 2d56f44..908cd38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,17 @@ "name": "cabrits", "version": "0.1.0", "dependencies": { + "clsx": "^2.1.1", + "framer-motion": "^12.34.3", + "katex": "^0.16.33", "next": "16.1.6", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "tailwind-merge": "^3.5.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/katex": "^0.16.8", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -1445,6 +1450,12 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "dev": true + }, "node_modules/@types/node": { "version": "20.19.35", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", @@ -2436,6 +2447,14 @@ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2454,6 +2473,14 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "engines": { + "node": ">= 12" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3388,6 +3415,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/framer-motion": { + "version": "12.34.3", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.3.tgz", + "integrity": "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==", + "dependencies": { + "motion-dom": "^12.34.3", + "motion-utils": "^12.29.2", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -4219,6 +4272,21 @@ "node": ">=4.0" } }, + "node_modules/katex": { + "version": "0.16.33", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.33.tgz", + "integrity": "sha512-q3N5u+1sY9Bu7T4nlXoiRBXWfwSefNGoKeOwekV+gw0cAXQlz2Ww6BLcmBxVDeXBMUDQv6fK5bcNaJLxob3ZQA==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4611,6 +4679,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/motion-dom": { + "version": "12.34.3", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz", + "integrity": "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==", + "dependencies": { + "motion-utils": "^12.29.2" + } + }, + "node_modules/motion-utils": { + "version": "12.29.2", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz", + "integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5676,6 +5757,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", diff --git a/package.json b/package.json index bc52b82..8cb521f 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,17 @@ "lint": "eslint" }, "dependencies": { + "clsx": "^2.1.1", + "framer-motion": "^12.34.3", + "katex": "^0.16.33", "next": "16.1.6", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "tailwind-merge": "^3.5.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/katex": "^0.16.8", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/standard-form-explorer.html b/standard-form-explorer.html new file mode 100644 index 0000000..9d17ab1 --- /dev/null +++ b/standard-form-explorer.html @@ -0,0 +1,836 @@ + + + + + + Standard Form Explorer + + + + + +
+

🔢 Standard Form Explorer

+
+ + +
+
+ + +
+ + +
+
Enter an ordinary number
+
+ + +
+ +
+ + + + + +
+
Enter a number above and click Convert
+ + + + + + + + +
+ + +
+ + + + +
+ + +
+
Result will appear here when steps are complete
+ + +
+ + +
+ Keyboard: Space or next step  ·  + back  ·  P play/pause  ·  R reset +
+ +
+ + + + + diff --git a/term-2-unit.pdf b/term-2-unit.pdf new file mode 100644 index 0000000..15f3d65 Binary files /dev/null and b/term-2-unit.pdf differ diff --git a/www.mathplayground.com_.png b/www.mathplayground.com_.png new file mode 100644 index 0000000..952f7f0 Binary files /dev/null and b/www.mathplayground.com_.png differ