From 40536fe91a346cfc320161e05d61117e0ee6a0d3 Mon Sep 17 00:00:00 2001 From: Florin Badita-Nistor Date: Sat, 11 Jul 2026 13:13:16 +0300 Subject: [PATCH 1/2] feat: add browser learning progress explorer --- README.md | 12 ++ explorer/index.html | 136 +++++++++++++ explorer/src/app.js | 355 ++++++++++++++++++++++++++++++++++ explorer/src/graph-view.js | 217 +++++++++++++++++++++ explorer/src/profile-store.js | 182 +++++++++++++++++ explorer/src/taxonomy.js | 36 ++++ explorer/styles.css | 169 ++++++++++++++++ package.json | 4 +- scripts/serve.mjs | 40 ++++ tests/profile-store.test.mjs | 90 +++++++++ tests/taxonomy.test.mjs | 27 +++ 11 files changed, 1267 insertions(+), 1 deletion(-) create mode 100644 explorer/index.html create mode 100644 explorer/src/app.js create mode 100644 explorer/src/graph-view.js create mode 100644 explorer/src/profile-store.js create mode 100644 explorer/src/taxonomy.js create mode 100644 explorer/styles.css create mode 100644 scripts/serve.mjs create mode 100644 tests/profile-store.test.mjs create mode 100644 tests/taxonomy.test.mjs diff --git a/README.md b/README.md index 93a93575..4091c529 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,18 @@ Validate structure + referential integrity: node scripts/validate.mjs ``` +## Learning Map explorer + +This repository includes a dependency-free browser explorer that can track learning progress privately on one device. It supports multiple child profiles, `learning` and `known` states, evidence-based assessments, subject/age/search filters, and dimming or hiding concepts a child already knows. + +```bash +npm run serve +``` + +Then open [http://localhost:4173/explorer/](http://localhost:4173/explorer/). Profiles and progress are versioned and stored only in the browser's `localStorage`; the explorer makes no network requests after loading the taxonomy JSON. Use **Manage** beside the profile picker to rename or delete a profile, or reset its progress. + +The explorer is intentionally a static ES-module application with no build step. Its persistence layer lives in `explorer/src/profile-store.js`, independently of the graph renderer, so a server-backed profile adapter can replace it later without changing the taxonomy data or graph interaction. + ## License This dataset is **multi-licensed** — read this before you use or redistribute it. diff --git a/explorer/index.html b/explorer/index.html new file mode 100644 index 00000000..e570e3fc --- /dev/null +++ b/explorer/index.html @@ -0,0 +1,136 @@ + + + + + + + Marble Learning Map + + + +
+ + + + Learning Map + Marble Skill Taxonomy + + + +
+ + + + +
+
+ +
+
+
+
+ + +
+ + + + + + +
+ +
+
+ Learning overview + Loading the taxonomy… +
+
+ +
+
+ Not started + Learning + Known + Assessed +
+
+ +
+ +
+ + + +
+

Drag to move · Scroll to zoom · Select a dot to inspect

+ +
+
+ + +
+ + +
+
+
+

Local profile

+

Add a child

+
+ +
+

Profiles and learning progress stay in this browser. Nothing is uploaded.

+ +
+ + +
+ +
+
+ +
+ + + diff --git a/explorer/src/app.js b/explorer/src/app.js new file mode 100644 index 00000000..e0087563 --- /dev/null +++ b/explorer/src/app.js @@ -0,0 +1,355 @@ +import { GraphView, SUBJECT_COLORS } from "./graph-view.js"; +import { ProfileStore } from "./profile-store.js"; +import { assessmentPromptFor, loadTaxonomy } from "./taxonomy.js"; + +const elements = Object.fromEntries( + [ + "profile-select", "add-profile", "manage-profile", "search", "search-results", "subject-filter", "age-filter", "mastery-filter", + "profile-greeting", "progress-summary", "progress-bar", "graph", "zoom-in", "zoom-out", "reset-view", + "empty-state", "clear-filters", "details-empty", "details-content", "profile-dialog", "dialog-title", + "profile-name", "save-profile", "profile-danger", "danger-copy", "reset-progress", "delete-profile", "toast", + ].map((id) => [id, document.getElementById(id)]), +); + +const store = new ProfileStore(); +let taxonomy; +let graph; +let selectedId = null; +let dialogMode = "add"; +let toastTimer; + +function activeProgress() { + return store.activeProfile?.progress ?? {}; +} + +function showToast(message) { + elements.toast.textContent = message; + elements.toast.classList.add("visible"); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => elements.toast.classList.remove("visible"), 2600); +} + +function makeElement(tag, { className, text, attrs = {} } = {}) { + const element = document.createElement(tag); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + for (const [name, value] of Object.entries(attrs)) element.setAttribute(name, value); + return element; +} + +function topicButton(topic, relationship) { + const button = makeElement("button", { className: "relationship-item", attrs: { type: "button" } }); + const copy = makeElement("span"); + copy.append(makeElement("strong", { text: topic.name }), makeElement("small", { text: `${topic.subject} · ages ${topic.ageRangeStart}–${topic.ageRangeEnd}` })); + button.append(copy, makeElement("span", { className: "relationship-arrow", text: relationship === "prerequisite" ? "←" : "→" })); + button.addEventListener("click", () => selectTopic(topic.id)); + return button; +} + +function renderProfiles() { + const state = store.state; + elements["profile-select"].replaceChildren(); + if (!state.profiles.length) { + elements["profile-select"].append(new Option("No child profile", "")); + } else { + for (const profile of state.profiles) elements["profile-select"].append(new Option(profile.name, profile.id)); + elements["profile-select"].value = state.activeProfileId; + } + elements["manage-profile"].disabled = !state.activeProfileId; + renderSummary(); + renderDetails(); + applyFilters(); +} + +function renderSummary() { + if (!taxonomy) return; + const profile = store.activeProfile; + const entries = Object.values(profile?.progress ?? {}); + const mastered = entries.filter(({ status }) => status === "mastered").length; + const learning = entries.filter(({ status }) => status === "learning").length; + const assessed = entries.filter(({ assessment }) => assessment?.verified).length; + const percent = Math.round((mastered / taxonomy.topics.length) * 100); + elements["profile-greeting"].textContent = profile ? `${profile.name}'s learning map` : "Learning overview"; + elements["progress-summary"].textContent = profile + ? `${mastered} known · ${learning} learning · ${assessed} assessed · ${taxonomy.topics.length - mastered - learning} not started` + : "Create a private child profile to start tracking progress."; + elements["progress-bar"].style.width = `${percent}%`; + const track = elements["progress-bar"].parentElement; + track.setAttribute("aria-valuenow", String(percent)); + track.setAttribute("aria-valuetext", `${mastered} of ${taxonomy.topics.length} concepts known`); +} + +function filters() { + return { + query: elements.search.value.trim().toLocaleLowerCase(), + subject: elements["subject-filter"].value, + age: Number(elements["age-filter"].value) || null, + masteryMode: elements["mastery-filter"].value, + }; +} + +function renderSearchResults() { + if (!taxonomy) return; + const query = elements.search.value.trim().toLocaleLowerCase(); + const results = elements["search-results"]; + results.replaceChildren(); + if (query.length < 2 || document.activeElement !== elements.search) { + results.hidden = true; + elements.search.setAttribute("aria-expanded", "false"); + return; + } + + const matches = taxonomy.topics + .filter((topic) => `${topic.name} ${topic.domain} ${topic.description}`.toLocaleLowerCase().includes(query)) + .sort((left, right) => { + const leftStarts = left.name.toLocaleLowerCase().startsWith(query) ? 0 : 1; + const rightStarts = right.name.toLocaleLowerCase().startsWith(query) ? 0 : 1; + return leftStarts - rightStarts || right.centrality - left.centrality || left.name.localeCompare(right.name); + }) + .slice(0, 8); + + if (!matches.length) { + results.append(makeElement("p", { text: "No matching concepts" })); + } else { + for (const topic of matches) { + const button = makeElement("button", { attrs: { type: "button", role: "option" } }); + const dot = makeElement("i"); + dot.style.background = SUBJECT_COLORS[topic.subject] || "#92a3bb"; + const copy = makeElement("span"); + copy.append(makeElement("strong", { text: topic.name }), makeElement("small", { text: `${topic.subject} · ${topic.domain}` })); + button.append(dot, copy); + button.addEventListener("mousedown", (event) => event.preventDefault()); + button.addEventListener("click", () => { + results.hidden = true; + elements.search.setAttribute("aria-expanded", "false"); + selectTopic(topic.id); + }); + results.append(button); + } + } + results.hidden = false; + elements.search.setAttribute("aria-expanded", "true"); +} + +function applyFilters() { + if (!taxonomy || !graph) return; + const { query, subject, age, masteryMode } = filters(); + const progress = activeProgress(); + const visibleIds = new Set(); + for (const topic of taxonomy.topics) { + const known = progress[topic.id]?.status === "mastered"; + const searchText = `${topic.name} ${topic.description} ${topic.domain}`.toLocaleLowerCase(); + if (query && !searchText.includes(query)) continue; + if (subject && topic.subject !== subject) continue; + if (age && (age < topic.ageRangeStart || age > topic.ageRangeEnd)) continue; + if (masteryMode === "hide" && known) continue; + if (masteryMode === "only" && !known) continue; + visibleIds.add(topic.id); + } + elements["empty-state"].hidden = visibleIds.size > 0; + graph.update({ visibleIds, progress, masteryMode, selectedId }); +} + +function statusButton(label, value, current, topicId) { + const button = makeElement("button", { + className: `status-button${current === value ? " active" : ""}`, + text: label, + attrs: { type: "button", "aria-pressed": String(current === value) }, + }); + button.addEventListener("click", () => { + if (!store.activeProfile) return openProfileDialog("add"); + store.setProgress(topicId, value); + showToast(value === null ? "Progress cleared" : value === "learning" ? "Marked as learning" : "Marked as known"); + }); + return button; +} + +function sectionHeading(title, count) { + const heading = makeElement("div", { className: "section-heading" }); + heading.append(makeElement("h3", { text: title }), makeElement("span", { text: String(count) })); + return heading; +} + +function renderDetails() { + if (!taxonomy) return; + const topic = taxonomy.byId.get(selectedId); + elements["details-empty"].hidden = Boolean(topic); + elements["details-content"].hidden = !topic; + if (!topic) return; + + const profile = store.activeProfile; + const entry = profile?.progress[topic.id]; + const current = entry?.status ?? null; + const content = elements["details-content"]; + content.replaceChildren(); + + const header = makeElement("header", { className: "topic-header" }); + const kicker = makeElement("div", { className: "topic-kicker" }); + const color = SUBJECT_COLORS[topic.subject] || "#92a3bb"; + const subjectDot = makeElement("i", { className: "subject-dot" }); + subjectDot.style.background = color; + kicker.append(subjectDot, document.createTextNode(`${topic.subject} · ${topic.domain}`)); + header.append(kicker, makeElement("h1", { text: topic.name }), makeElement("p", { text: topic.description })); + const chips = makeElement("div", { className: "chips" }); + chips.append( + makeElement("span", { text: `Ages ${topic.ageRangeStart}–${topic.ageRangeEnd}` }), + makeElement("span", { text: topic.type.toLowerCase().replaceAll("_", " ") }), + ); + if (entry?.assessment?.verified) chips.append(makeElement("span", { className: "verified-chip", text: "✓ Assessed" })); + header.append(chips); + + const progressSection = makeElement("section", { className: "detail-section progress-section" }); + progressSection.append(makeElement("h2", { text: profile ? `What does ${profile.name} know?` : "Track this concept" })); + const statusControl = makeElement("div", { className: "status-control" }); + statusControl.append( + statusButton("Not started", null, current, topic.id), + statusButton("Learning", "learning", current, topic.id), + statusButton("Knows it", "mastered", current, topic.id), + ); + progressSection.append(statusControl); + if (!profile) progressSection.append(makeElement("p", { className: "inline-note", text: "Selecting a status will first create a private child profile." })); + + const assessment = makeElement("section", { className: "detail-section assessment-card" }); + assessment.append(makeElement("p", { className: "eyebrow", text: "Quick assessment" })); + assessment.append(makeElement("h2", { text: assessmentPromptFor(topic, profile?.name) })); + assessment.append(makeElement("p", { className: "assessment-help", text: "Check each piece of evidence you observed. This does not score the child; it records your judgement." })); + const checklist = makeElement("div", { className: "evidence-list" }); + const savedEvidence = new Set(entry?.assessment?.evidence ?? []); + const checkboxes = topic.evidence.map((evidence, index) => { + const label = makeElement("label"); + const input = makeElement("input", { attrs: { type: "checkbox" } }); + input.checked = savedEvidence.has(index); + label.append(input, makeElement("span", { text: evidence })); + checklist.append(label); + return input; + }); + const assessButton = makeElement("button", { + className: "button assessment-action", + text: entry?.assessment?.verified ? "Update assessment" : "Confirm assessment & mark known", + attrs: { type: "button" }, + }); + function syncAssessButton() { + const complete = checkboxes.length > 0 && checkboxes.every(({ checked }) => checked); + assessButton.disabled = !complete; + assessButton.title = complete ? "" : "Check every observed outcome to confirm this assessment"; + } + checkboxes.forEach((checkbox) => checkbox.addEventListener("change", syncAssessButton)); + syncAssessButton(); + assessButton.addEventListener("click", () => { + if (!store.activeProfile) return openProfileDialog("add"); + store.setProgress(topic.id, "mastered", { verified: true, evidence: checkboxes.map((_, index) => index) }); + showToast("Assessment saved privately in this browser"); + }); + assessment.append(checklist, assessButton); + + content.append(header, progressSection, assessment); + + const prereqs = taxonomy.prerequisites.get(topic.id).filter(({ topic: linkedTopic }) => linkedTopic); + const unlocks = taxonomy.unlocks.get(topic.id).filter(({ topic: linkedTopic }) => linkedTopic); + for (const [title, items, relationship] of [ + ["Builds on", prereqs, "prerequisite"], + ["Unlocks", unlocks, "unlock"], + ]) { + const section = makeElement("section", { className: "detail-section relationships" }); + section.append(sectionHeading(title, items.length)); + if (items.length) { + for (const { topic: linkedTopic } of items.slice(0, 12)) section.append(topicButton(linkedTopic, relationship)); + if (items.length > 12) section.append(makeElement("p", { className: "inline-note", text: `And ${items.length - 12} more connected concepts.` })); + } else { + section.append(makeElement("p", { className: "inline-note", text: relationship === "prerequisite" ? "No prerequisite is recorded." : "No direct unlock is recorded." })); + } + content.append(section); + } +} + +function selectTopic(topicId) { + selectedId = topicId; + graph.select(topicId); + renderDetails(); + if (globalThis.innerWidth < 900) document.getElementById("details").scrollIntoView({ behavior: "smooth", block: "start" }); +} + +function openProfileDialog(mode) { + dialogMode = mode; + const profile = store.activeProfile; + elements["dialog-title"].textContent = mode === "add" ? "Add a child" : "Manage profile"; + elements["profile-name"].value = mode === "manage" ? profile?.name ?? "" : ""; + elements["profile-danger"].hidden = mode !== "manage"; + elements["danger-copy"].textContent = profile ? `Reset or remove ${profile.name}'s browser-only data.` : ""; + elements["profile-dialog"].showModal(); + setTimeout(() => elements["profile-name"].focus(), 0); +} + +function clearFilters() { + elements.search.value = ""; + elements["subject-filter"].value = ""; + elements["age-filter"].value = ""; + elements["mastery-filter"].value = "dim"; + applyFilters(); +} + +function bindEvents() { + elements["profile-select"].addEventListener("change", (event) => store.setActive(event.target.value)); + elements["add-profile"].addEventListener("click", () => openProfileDialog("add")); + elements["manage-profile"].addEventListener("click", () => openProfileDialog("manage")); + elements.search.addEventListener("input", () => { applyFilters(); renderSearchResults(); }); + elements.search.addEventListener("focus", renderSearchResults); + elements.search.addEventListener("blur", () => setTimeout(renderSearchResults, 0)); + for (const element of [elements["subject-filter"], elements["age-filter"], elements["mastery-filter"]]) element.addEventListener("input", applyFilters); + elements["clear-filters"].addEventListener("click", clearFilters); + elements["zoom-in"].addEventListener("click", () => graph.zoom(1.25)); + elements["zoom-out"].addEventListener("click", () => graph.zoom(0.8)); + elements["reset-view"].addEventListener("click", () => graph.fit()); + elements["save-profile"].addEventListener("click", (event) => { + event.preventDefault(); + if (!elements["profile-name"].reportValidity()) return; + try { + if (dialogMode === "add") store.addProfile(elements["profile-name"].value); + else store.renameActive(elements["profile-name"].value); + elements["profile-dialog"].close(); + showToast(dialogMode === "add" ? "Child profile created" : "Profile updated"); + } catch (error) { + elements["profile-name"].setCustomValidity(error.message); + elements["profile-name"].reportValidity(); + elements["profile-name"].setCustomValidity(""); + } + }); + elements["reset-progress"].addEventListener("click", () => { + const profile = store.activeProfile; + if (profile && confirm(`Reset all learning progress for ${profile.name}? This cannot be undone.`)) { + store.resetActiveProgress(); + elements["profile-dialog"].close(); + showToast("Progress reset"); + } + }); + elements["delete-profile"].addEventListener("click", () => { + const profile = store.activeProfile; + if (profile && confirm(`Delete ${profile.name}'s profile and all progress from this browser?`)) { + store.deleteActive(); + elements["profile-dialog"].close(); + showToast("Profile deleted"); + } + }); + globalThis.addEventListener("storage", (event) => { + if (event.key === "marble-taxonomy:learner-profiles") store.reload(); + }); +} + +async function initialize() { + bindEvents(); + try { + taxonomy = await loadTaxonomy(); + for (const subject of taxonomy.subjects) elements["subject-filter"].append(new Option(subject, subject)); + for (let age = taxonomy.minAge; age <= taxonomy.maxAge; age += 1) elements["age-filter"].append(new Option(`Age ${age}`, String(age))); + graph = new GraphView(elements.graph, taxonomy, selectTopic); + store.subscribe(renderProfiles); + renderProfiles(); + if (!store.activeProfile) openProfileDialog("add"); + } catch (error) { + elements["progress-summary"].textContent = error.message; + elements["details-empty"].querySelector("h1").textContent = "The explorer could not start"; + elements["details-empty"].querySelector("p").textContent = "Serve the repository over HTTP with `npm run serve`, then reload this page."; + } +} + +initialize(); diff --git a/explorer/src/graph-view.js b/explorer/src/graph-view.js new file mode 100644 index 00000000..a4ae7934 --- /dev/null +++ b/explorer/src/graph-view.js @@ -0,0 +1,217 @@ +const SUBJECT_COLORS = { + Computing: "#7c6df2", + English: "#eb6f92", + History: "#d09b46", + "Learning to Learn": "#4b9ca8", + "Life Skills": "#71a064", + Mathematics: "#4e83e6", + "Personal & Social Development": "#bd68bf", + Science: "#28a982", +}; + +function hash(value) { + let result = 2166136261; + for (let index = 0; index < value.length; index += 1) { + result ^= value.charCodeAt(index); + result = Math.imul(result, 16777619); + } + return (result >>> 0) / 4294967295; +} + +export function layoutTopics(topics, subjects, minAge, maxAge) { + const subjectIndex = new Map(subjects.map((subject, index) => [subject, index])); + const width = 2400; + const laneHeight = 290; + const height = Math.max(1000, subjects.length * laneHeight); + const ageSpan = Math.max(1, maxAge - minAge); + const nodes = topics.map((topic) => { + const midpoint = (topic.ageRangeStart + topic.ageRangeEnd) / 2; + const x = 120 + ((midpoint - minAge) / ageSpan) * (width - 240) + (hash(topic.id) - 0.5) * 80; + const lane = subjectIndex.get(topic.subject) ?? 0; + const y = lane * laneHeight + laneHeight / 2 + (hash(`${topic.domain}:${topic.id}`) - 0.5) * (laneHeight - 52); + return { ...topic, x, y, radius: 3.2 + Math.min(4.8, Math.sqrt(topic.centrality || 0) * 7) }; + }); + return { nodes, byId: new Map(nodes.map((node) => [node.id, node])), width, height }; +} + +export class GraphView { + #canvas; + #context; + #layout; + #dependencies; + #visibleIds = new Set(); + #progress = {}; + #masteryMode = "dim"; + #selectedId = null; + #onSelect; + #transform = { x: 0, y: 0, scale: 1 }; + #drag = null; + #resizeObserver; + + constructor(canvas, taxonomy, onSelect) { + this.#canvas = canvas; + this.#context = canvas.getContext("2d"); + this.#layout = layoutTopics(taxonomy.topics, taxonomy.subjects, taxonomy.minAge, taxonomy.maxAge); + this.#dependencies = taxonomy.dependencies; + this.#visibleIds = new Set(taxonomy.topics.map(({ id }) => id)); + this.#onSelect = onSelect; + this.#bindEvents(); + this.#resizeObserver = new ResizeObserver(() => this.resize()); + this.#resizeObserver.observe(canvas.parentElement); + } + + #bindEvents() { + this.#canvas.addEventListener("pointerdown", (event) => { + this.#canvas.setPointerCapture(event.pointerId); + this.#drag = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, x: this.#transform.x, y: this.#transform.y }; + this.#canvas.classList.add("dragging"); + }); + this.#canvas.addEventListener("pointermove", (event) => { + if (!this.#drag || event.pointerId !== this.#drag.pointerId) return; + this.#transform.x = this.#drag.x + event.clientX - this.#drag.startX; + this.#transform.y = this.#drag.y + event.clientY - this.#drag.startY; + this.draw(); + }); + this.#canvas.addEventListener("pointerup", (event) => { + if (!this.#drag) return; + const distance = Math.hypot(event.clientX - this.#drag.startX, event.clientY - this.#drag.startY); + if (distance < 5) this.#pick(event.offsetX, event.offsetY); + this.#drag = null; + this.#canvas.classList.remove("dragging"); + }); + this.#canvas.addEventListener("wheel", (event) => { + event.preventDefault(); + this.zoom(event.deltaY < 0 ? 1.13 : 0.885, event.offsetX, event.offsetY); + }, { passive: false }); + } + + resize() { + const rect = this.#canvas.parentElement.getBoundingClientRect(); + const ratio = Math.min(2, globalThis.devicePixelRatio || 1); + this.#canvas.width = Math.round(rect.width * ratio); + this.#canvas.height = Math.round(rect.height * ratio); + this.#canvas.style.width = `${rect.width}px`; + this.#canvas.style.height = `${rect.height}px`; + this.#context.setTransform(ratio, 0, 0, ratio, 0, 0); + if (this.#transform.scale === 1 && this.#transform.x === 0) this.fit(); + else this.draw(); + } + + fit() { + const width = this.#canvas.clientWidth; + const height = this.#canvas.clientHeight; + const scale = Math.min(width / this.#layout.width, height / this.#layout.height) * 0.92; + this.#transform = { + scale, + x: (width - this.#layout.width * scale) / 2, + y: (height - this.#layout.height * scale) / 2, + }; + this.draw(); + } + + zoom(factor, centerX = this.#canvas.clientWidth / 2, centerY = this.#canvas.clientHeight / 2) { + const previous = this.#transform.scale; + const scale = Math.max(0.18, Math.min(4, previous * factor)); + const worldX = (centerX - this.#transform.x) / previous; + const worldY = (centerY - this.#transform.y) / previous; + this.#transform = { scale, x: centerX - worldX * scale, y: centerY - worldY * scale }; + this.draw(); + } + + update({ visibleIds, progress, masteryMode, selectedId }) { + this.#visibleIds = visibleIds; + this.#progress = progress; + this.#masteryMode = masteryMode; + this.#selectedId = selectedId; + this.draw(); + } + + select(topicId) { + this.#selectedId = topicId; + const node = this.#layout.byId.get(topicId); + if (node) { + const screenX = node.x * this.#transform.scale + this.#transform.x; + const screenY = node.y * this.#transform.scale + this.#transform.y; + if (screenX < 80 || screenX > this.#canvas.clientWidth - 80 || screenY < 80 || screenY > this.#canvas.clientHeight - 80) { + this.#transform.x = this.#canvas.clientWidth / 2 - node.x * this.#transform.scale; + this.#transform.y = this.#canvas.clientHeight / 2 - node.y * this.#transform.scale; + } + } + this.draw(); + } + + #pick(screenX, screenY) { + const x = (screenX - this.#transform.x) / this.#transform.scale; + const y = (screenY - this.#transform.y) / this.#transform.scale; + const maxDistance = 14 / this.#transform.scale; + let nearest = null; + let nearestDistance = maxDistance; + for (const node of this.#layout.nodes) { + if (!this.#visibleIds.has(node.id)) continue; + const distance = Math.hypot(node.x - x, node.y - y); + if (distance < nearestDistance) { + nearest = node; + nearestDistance = distance; + } + } + if (nearest) this.#onSelect(nearest.id); + } + + draw() { + const context = this.#context; + const width = this.#canvas.clientWidth; + const height = this.#canvas.clientHeight; + context.clearRect(0, 0, width, height); + context.save(); + context.translate(this.#transform.x, this.#transform.y); + context.scale(this.#transform.scale, this.#transform.scale); + + const selectedNeighbors = new Set(); + if (this.#selectedId) { + for (const edge of this.#dependencies) { + if (edge.topicId === this.#selectedId) selectedNeighbors.add(edge.prerequisiteId); + if (edge.prerequisiteId === this.#selectedId) selectedNeighbors.add(edge.topicId); + } + } + + context.lineWidth = 0.7 / Math.max(0.4, this.#transform.scale); + for (const edge of this.#dependencies) { + if (!this.#visibleIds.has(edge.topicId) || !this.#visibleIds.has(edge.prerequisiteId)) continue; + const from = this.#layout.byId.get(edge.prerequisiteId); + const to = this.#layout.byId.get(edge.topicId); + const highlighted = edge.topicId === this.#selectedId || edge.prerequisiteId === this.#selectedId; + context.strokeStyle = highlighted ? "rgba(255,255,255,.72)" : "rgba(132,152,184,.10)"; + context.lineWidth = (highlighted ? 2.1 : 0.7) / Math.max(0.45, this.#transform.scale); + context.beginPath(); + context.moveTo(from.x, from.y); + context.lineTo(to.x, to.y); + context.stroke(); + } + + for (const node of this.#layout.nodes) { + if (!this.#visibleIds.has(node.id)) continue; + const entry = this.#progress[node.id]; + const selected = node.id === this.#selectedId; + const related = selectedNeighbors.has(node.id); + const dimmed = this.#masteryMode === "dim" && entry?.status === "mastered" && !selected; + context.globalAlpha = dimmed ? 0.18 : selected || related ? 1 : 0.82; + context.fillStyle = SUBJECT_COLORS[node.subject] || "#92a3bb"; + context.beginPath(); + context.arc(node.x, node.y, selected ? node.radius + 4 : related ? node.radius + 2 : node.radius, 0, Math.PI * 2); + context.fill(); + + if (entry?.status === "learning" || entry?.status === "mastered") { + context.globalAlpha = dimmed ? 0.24 : 1; + context.strokeStyle = entry.assessment?.verified ? "#fff3a6" : entry.status === "mastered" ? "#7df0bd" : "#ffb454"; + context.lineWidth = (entry.assessment?.verified ? 3 : 2) / Math.max(0.55, this.#transform.scale); + context.beginPath(); + context.arc(node.x, node.y, node.radius + 3.5, 0, Math.PI * 2); + context.stroke(); + } + } + context.restore(); + context.globalAlpha = 1; + } +} + +export { SUBJECT_COLORS }; diff --git a/explorer/src/profile-store.js b/explorer/src/profile-store.js new file mode 100644 index 00000000..e3e4324d --- /dev/null +++ b/explorer/src/profile-store.js @@ -0,0 +1,182 @@ +export const STORAGE_KEY = "marble-taxonomy:learner-profiles"; +export const STORAGE_VERSION = 1; +export const PROGRESS_STATUSES = new Set(["learning", "mastered"]); + +function makeId() { + if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID(); + return `profile-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function cleanName(name) { + return String(name ?? "").trim().slice(0, 60); +} + +export function createEmptyState() { + return { version: STORAGE_VERSION, activeProfileId: null, profiles: [] }; +} + +function sanitizeAssessment(value) { + if (!value || typeof value !== "object" || value.verified !== true) return undefined; + return { + verified: true, + evidence: Array.isArray(value.evidence) ? value.evidence.filter(Number.isInteger) : [], + assessedAt: typeof value.assessedAt === "string" ? value.assessedAt : new Date(0).toISOString(), + }; +} + +export function sanitizeState(value) { + if (!value || typeof value !== "object" || !Array.isArray(value.profiles)) return createEmptyState(); + + const ids = new Set(); + const profiles = []; + for (const candidate of value.profiles) { + const id = typeof candidate?.id === "string" && candidate.id ? candidate.id : makeId(); + const name = cleanName(candidate?.name); + if (!name || ids.has(id)) continue; + ids.add(id); + + const progress = {}; + if (candidate.progress && typeof candidate.progress === "object") { + for (const [topicId, entry] of Object.entries(candidate.progress)) { + if (!topicId.startsWith("mt_") || !PROGRESS_STATUSES.has(entry?.status)) continue; + const assessment = sanitizeAssessment(entry.assessment); + progress[topicId] = { + status: entry.status, + updatedAt: typeof entry.updatedAt === "string" ? entry.updatedAt : new Date(0).toISOString(), + ...(assessment ? { assessment } : {}), + }; + } + } + + profiles.push({ + id, + name, + createdAt: typeof candidate.createdAt === "string" ? candidate.createdAt : new Date(0).toISOString(), + updatedAt: typeof candidate.updatedAt === "string" ? candidate.updatedAt : new Date(0).toISOString(), + progress, + }); + } + + const requestedActiveId = typeof value.activeProfileId === "string" ? value.activeProfileId : null; + return { + version: STORAGE_VERSION, + activeProfileId: profiles.some(({ id }) => id === requestedActiveId) ? requestedActiveId : profiles[0]?.id ?? null, + profiles, + }; +} + +export class ProfileStore { + #storage; + #now; + #listeners = new Set(); + #state; + + constructor(storage = globalThis.localStorage, now = () => new Date()) { + this.#storage = storage; + this.#now = now; + this.#state = this.#read(); + } + + #read() { + try { + return sanitizeState(JSON.parse(this.#storage.getItem(STORAGE_KEY) || "null")); + } catch { + return createEmptyState(); + } + } + + #commit(nextState) { + this.#state = sanitizeState(nextState); + this.#storage.setItem(STORAGE_KEY, JSON.stringify(this.#state)); + for (const listener of this.#listeners) listener(this.state); + return this.state; + } + + get state() { + return structuredClone(this.#state); + } + + get activeProfile() { + const profile = this.#state.profiles.find(({ id }) => id === this.#state.activeProfileId); + return profile ? structuredClone(profile) : null; + } + + subscribe(listener) { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + + reload() { + this.#state = this.#read(); + for (const listener of this.#listeners) listener(this.state); + } + + addProfile(name) { + const cleanedName = cleanName(name); + if (!cleanedName) throw new Error("Please enter a name."); + const timestamp = this.#now().toISOString(); + const profile = { id: makeId(), name: cleanedName, createdAt: timestamp, updatedAt: timestamp, progress: {} }; + return this.#commit({ ...this.#state, activeProfileId: profile.id, profiles: [...this.#state.profiles, profile] }); + } + + setActive(profileId) { + if (!this.#state.profiles.some(({ id }) => id === profileId)) return this.state; + return this.#commit({ ...this.#state, activeProfileId: profileId }); + } + + renameActive(name) { + const cleanedName = cleanName(name); + if (!cleanedName) throw new Error("Please enter a name."); + const timestamp = this.#now().toISOString(); + return this.#commit({ + ...this.#state, + profiles: this.#state.profiles.map((profile) => + profile.id === this.#state.activeProfileId ? { ...profile, name: cleanedName, updatedAt: timestamp } : profile, + ), + }); + } + + deleteActive() { + const profiles = this.#state.profiles.filter(({ id }) => id !== this.#state.activeProfileId); + return this.#commit({ ...this.#state, profiles, activeProfileId: profiles[0]?.id ?? null }); + } + + setProgress(topicId, status, { evidence = [], verified = false } = {}) { + if (!this.#state.activeProfileId) throw new Error("Create a child profile first."); + if (!topicId.startsWith("mt_")) throw new Error("Invalid topic identifier."); + if (status !== null && !PROGRESS_STATUSES.has(status)) throw new Error("Invalid progress status."); + + const timestamp = this.#now().toISOString(); + return this.#commit({ + ...this.#state, + profiles: this.#state.profiles.map((profile) => { + if (profile.id !== this.#state.activeProfileId) return profile; + const progress = { ...profile.progress }; + if (status === null) { + delete progress[topicId]; + } else { + const existingAssessment = progress[topicId]?.status === status ? progress[topicId].assessment : undefined; + progress[topicId] = { + status, + updatedAt: timestamp, + ...(verified + ? { assessment: { verified: true, evidence: [...new Set(evidence.filter(Number.isInteger))], assessedAt: timestamp } } + : existingAssessment ? { assessment: existingAssessment } : {}), + }; + } + return { ...profile, progress, updatedAt: timestamp }; + }), + }); + } + + resetActiveProgress() { + if (!this.#state.activeProfileId) return this.state; + const timestamp = this.#now().toISOString(); + return this.#commit({ + ...this.#state, + profiles: this.#state.profiles.map((profile) => + profile.id === this.#state.activeProfileId ? { ...profile, progress: {}, updatedAt: timestamp } : profile, + ), + }); + } +} diff --git a/explorer/src/taxonomy.js b/explorer/src/taxonomy.js new file mode 100644 index 00000000..9ac46054 --- /dev/null +++ b/explorer/src/taxonomy.js @@ -0,0 +1,36 @@ +export async function loadTaxonomy(baseUrl = "../data") { + const [topicResponse, dependencyResponse] = await Promise.all([ + fetch(`${baseUrl}/topics.json`), + fetch(`${baseUrl}/dependencies.json`), + ]); + if (!topicResponse.ok || !dependencyResponse.ok) throw new Error("The taxonomy data could not be loaded."); + const [{ topics }, { dependencies }] = await Promise.all([topicResponse.json(), dependencyResponse.json()]); + return buildTaxonomy(topics, dependencies); +} + +export function buildTaxonomy(topics, dependencies) { + const byId = new Map(topics.map((topic) => [topic.id, topic])); + const prerequisites = new Map(topics.map(({ id }) => [id, []])); + const unlocks = new Map(topics.map(({ id }) => [id, []])); + + for (const dependency of dependencies) { + prerequisites.get(dependency.topicId)?.push({ ...dependency, topic: byId.get(dependency.prerequisiteId) }); + unlocks.get(dependency.prerequisiteId)?.push({ ...dependency, topic: byId.get(dependency.topicId) }); + } + + return { + topics, + dependencies, + byId, + prerequisites, + unlocks, + subjects: [...new Set(topics.map(({ subject }) => subject))].sort(), + minAge: Math.min(...topics.map(({ ageRangeStart }) => ageRangeStart)), + maxAge: Math.max(...topics.map(({ ageRangeEnd }) => ageRangeEnd)), + }; +} + +export function assessmentPromptFor(topic, childName) { + const replacement = childName || "the child"; + return topic.assessmentPrompt.replaceAll("{{name}}", replacement); +} diff --git a/explorer/styles.css b/explorer/styles.css new file mode 100644 index 00000000..a4306795 --- /dev/null +++ b/explorer/styles.css @@ -0,0 +1,169 @@ +:root { + color-scheme: dark; + --bg: #08111f; + --surface: #0d192a; + --surface-2: #12233a; + --line: rgba(173, 195, 226, 0.14); + --muted: #9eacc2; + --text: #f5f8fc; + --accent: #7df0bd; + --accent-ink: #062619; + --danger: #ff8c96; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; +} + +* { box-sizing: border-box; } +html, body { height: 100%; } +body { margin: 0; background: var(--bg); color: var(--text); overflow: hidden; } +button, input, select { font: inherit; } +button, select { cursor: pointer; } +button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } + +.app-header { height: 70px; display: flex; align-items: center; justify-content: space-between; padding: 0 20px; border-bottom: 1px solid var(--line); background: rgba(8, 17, 31, .92); } +.brand { display: flex; align-items: center; gap: 11px; color: var(--text); text-decoration: none; } +.brand-mark { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 12px; background: linear-gradient(145deg, #8cf4c6, #5cd6d4); color: #09221a; font-weight: 900; box-shadow: 0 8px 28px rgba(70, 224, 179, .2); } +.brand strong, .brand small { display: block; } +.brand strong { font-size: 15px; } +.brand small { margin-top: 2px; color: var(--muted); font-size: 11px; letter-spacing: .02em; } +.profile-controls { display: flex; gap: 8px; align-items: center; } + +select, input { min-height: 38px; border: 1px solid var(--line); border-radius: 9px; color: var(--text); background: var(--surface-2); padding: 0 11px; } +select:disabled, button:disabled { cursor: not-allowed; opacity: .48; } +.button { min-height: 38px; padding: 0 15px; border-radius: 9px; border: 1px solid transparent; color: var(--text); background: transparent; font-weight: 650; } +.button.primary, .assessment-action { background: var(--accent); color: var(--accent-ink); } +.button.secondary { border-color: var(--line); background: rgba(255,255,255,.035); } +.button.danger { background: rgba(255, 94, 107, .14); border-color: rgba(255, 94, 107, .35); color: var(--danger); } +.button.text-danger { color: var(--danger); } +.icon-button { display: grid; place-items: center; width: 38px; height: 38px; padding: 0; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--text); font-size: 20px; } + +.workspace { display: grid; grid-template-columns: minmax(0, 1fr) 390px; height: calc(100% - 70px); } +.graph-shell { min-width: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr); border-right: 1px solid var(--line); } +.toolbar { display: flex; align-items: end; gap: 10px; padding: 13px 16px; background: #0a1626; border-bottom: 1px solid var(--line); } +.toolbar label { display: grid; gap: 5px; color: var(--muted); font-size: 11px; font-weight: 700; } +.toolbar .search-container { position: relative; min-width: 190px; flex: 1; } +.toolbar .search-field { position: relative; display: block; } +.search-field svg { position: absolute; left: 11px; bottom: 11px; width: 17px; fill: none; stroke: var(--muted); stroke-width: 1.8; } +.search-field input { width: 100%; padding-left: 36px; } +.search-results { position: absolute; z-index: 5; top: calc(100% + 7px); left: 0; right: 0; max-height: 370px; overflow-y: auto; padding: 6px; border: 1px solid var(--line); border-radius: 11px; background: #13233a; box-shadow: 0 18px 50px rgba(0,0,0,.45); } +.search-results button { display: grid; grid-template-columns: auto 1fr; gap: 10px; align-items: center; width: 100%; padding: 9px; border: 0; border-radius: 7px; text-align: left; color: var(--text); background: transparent; } +.search-results button:hover, .search-results button:focus-visible { background: rgba(255,255,255,.065); } +.search-results i { width: 9px; height: 9px; border-radius: 50%; } +.search-results strong, .search-results small { display: block; } +.search-results strong { font-size: 11px; } +.search-results small { margin-top: 3px; color: var(--muted); font-size: 9px; } +.search-results p { margin: 8px; color: var(--muted); font-size: 11px; } + +.summary-bar { display: grid; grid-template-columns: auto minmax(100px, 1fr) auto; align-items: center; gap: 16px; padding: 11px 17px; background: rgba(13,25,42,.88); border-bottom: 1px solid var(--line); } +.progress-copy strong, .progress-copy span { display: block; } +.progress-copy strong { font-size: 13px; } +.progress-copy span { color: var(--muted); font-size: 11px; margin-top: 3px; } +.progress-track { height: 5px; border-radius: 10px; background: rgba(255,255,255,.08); overflow: hidden; } +.progress-track span { display: block; height: 100%; width: 0; border-radius: inherit; background: linear-gradient(90deg, #56d4bd, var(--accent)); transition: width .25s ease; } +.legend { display: flex; gap: 10px; font-size: 10px; color: var(--muted); white-space: nowrap; } +.legend span { display: flex; align-items: center; gap: 4px; } +.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #72839d; } +.dot.learning { border: 2px solid #ffb454; background: transparent; } +.dot.mastered { border: 2px solid #7df0bd; background: transparent; } +.dot.assessed { border: 2px solid #fff3a6; background: #7df0bd; } + +.canvas-wrap { position: relative; min-height: 0; overflow: hidden; background: radial-gradient(circle at 60% 40%, rgba(31,65,96,.36), transparent 55%), #07111e; } +#graph { display: block; touch-action: none; cursor: grab; } +#graph.dragging { cursor: grabbing; } +.graph-actions { position: absolute; top: 14px; right: 14px; display: grid; gap: 6px; } +.icon-button.glass { background: rgba(9, 21, 36, .78); backdrop-filter: blur(8px); } +.reset-view { font-size: 16px; } +.graph-hint { position: absolute; left: 16px; bottom: 7px; color: #7e8da5; font-size: 11px; pointer-events: none; } +.empty-state { position: absolute; inset: 0; margin: auto; width: fit-content; height: fit-content; padding: 22px; text-align: center; background: rgba(11, 24, 41, .9); border: 1px solid var(--line); border-radius: 14px; } +.empty-state strong { display: block; margin-bottom: 13px; } + +.details-panel { overflow-y: auto; background: var(--surface); } +.details-empty { min-height: 100%; display: grid; place-content: center; justify-items: center; text-align: center; padding: 42px; } +.details-empty[hidden], #details-content[hidden] { display: none; } +.details-empty h1 { margin: 25px 0 8px; font-size: 24px; } +.details-empty p { max-width: 290px; margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; } +.empty-orbit { width: 74px; height: 74px; border: 1px solid rgba(125,240,189,.35); border-radius: 50%; position: relative; } +.empty-orbit::before { content: ""; position: absolute; inset: 12px; border: 1px dashed rgba(125,240,189,.25); border-radius: 50%; } +.empty-orbit span { position: absolute; top: 8px; left: 28px; width: 12px; height: 12px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 22px var(--accent); } +.topic-header { padding: 24px 23px 21px; border-bottom: 1px solid var(--line); } +.topic-kicker { display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: 11px; font-weight: 700; } +.subject-dot { width: 8px; height: 8px; border-radius: 50%; } +.topic-header h1 { margin: 10px 0 10px; font-size: 24px; letter-spacing: -.025em; line-height: 1.18; } +.topic-header > p { margin: 0; color: #bdc7d6; font-size: 13px; line-height: 1.58; } +.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 15px; } +.chips span { padding: 5px 8px; border-radius: 6px; background: rgba(255,255,255,.055); color: var(--muted); font-size: 10px; text-transform: capitalize; } +.chips .verified-chip { color: #fff3a6; background: rgba(255,243,166,.09); } +.detail-section { padding: 20px 23px; border-bottom: 1px solid var(--line); } +.detail-section h2 { margin: 0 0 13px; font-size: 14px; line-height: 1.45; } +.status-control { display: grid; grid-template-columns: repeat(3, 1fr); padding: 3px; border: 1px solid var(--line); border-radius: 10px; background: rgba(0,0,0,.12); } +.status-button { min-height: 36px; border: 0; border-radius: 7px; background: transparent; color: var(--muted); font-size: 11px; font-weight: 700; } +.status-button:hover { color: var(--text); } +.status-button.active { color: var(--text); background: var(--surface-2); box-shadow: 0 3px 10px rgba(0,0,0,.2); } +.assessment-card { margin: 15px; padding: 18px; border: 1px solid rgba(125,240,189,.16); border-radius: 13px; background: linear-gradient(145deg, rgba(125,240,189,.065), rgba(20,44,57,.15)); } +.assessment-card .eyebrow, .eyebrow { margin: 0 0 8px; color: var(--accent); font-size: 10px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; } +.assessment-card h2 { font-size: 15px; } +.assessment-help, .inline-note { color: var(--muted); font-size: 11px; line-height: 1.5; } +.evidence-list { display: grid; gap: 8px; margin: 15px 0; } +.evidence-list label { display: grid; grid-template-columns: auto 1fr; gap: 9px; align-items: start; color: #d1d8e2; font-size: 11px; line-height: 1.45; cursor: pointer; } +.evidence-list input { min-height: auto; accent-color: var(--accent); margin: 2px 0 0; } +.assessment-action { width: 100%; font-size: 11px; } +.section-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; } +.section-heading h3 { margin: 0; font-size: 13px; } +.section-heading > span { display: grid; place-items: center; min-width: 23px; height: 20px; padding: 0 5px; border-radius: 10px; color: var(--muted); background: rgba(255,255,255,.06); font-size: 10px; } +.relationship-item { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 0; text-align: left; border: 0; border-top: 1px solid rgba(255,255,255,.055); color: var(--text); background: transparent; } +.relationship-item:hover strong { color: var(--accent); } +.relationship-item strong, .relationship-item small { display: block; } +.relationship-item strong { font-size: 11px; } +.relationship-item small { margin-top: 3px; color: var(--muted); font-size: 9px; } +.relationship-arrow { color: var(--muted); } + +dialog { width: min(470px, calc(100% - 28px)); padding: 0; border: 1px solid var(--line); border-radius: 16px; color: var(--text); background: var(--surface); box-shadow: 0 30px 90px rgba(0,0,0,.55); } +dialog::backdrop { background: rgba(2, 8, 16, .75); backdrop-filter: blur(4px); } +.dialog-card { padding: 23px; } +.dialog-heading { display: flex; justify-content: space-between; align-items: start; } +.dialog-heading h2 { margin: 0; font-size: 22px; } +.privacy-note { padding: 11px 12px; border-radius: 9px; color: #b7c9c4; background: rgba(125,240,189,.07); font-size: 11px; line-height: 1.5; } +.field { display: grid; gap: 7px; margin-top: 18px; color: var(--muted); font-size: 11px; font-weight: 700; } +.field input { width: 100%; } +.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; } +.danger-zone { margin-top: 23px; padding-top: 17px; border-top: 1px solid rgba(255, 94, 107, .18); } +.danger-zone p { color: var(--muted); font-size: 11px; line-height: 1.45; } +.danger-zone strong { color: var(--danger); } +.danger-zone > div { display: flex; justify-content: space-between; gap: 8px; } +.toast { position: fixed; z-index: 10; left: 50%; bottom: 20px; transform: translate(-50%, 20px); padding: 10px 15px; border: 1px solid var(--line); border-radius: 9px; color: var(--text); background: #17283e; box-shadow: 0 10px 35px rgba(0,0,0,.4); opacity: 0; pointer-events: none; transition: .2s ease; font-size: 12px; } +.toast.visible { opacity: 1; transform: translate(-50%, 0); } + +@media (max-width: 1050px) { + .workspace { grid-template-columns: minmax(0, 1fr) 340px; } + .legend { display: none; } + .toolbar { flex-wrap: wrap; } + .toolbar .search-container { flex-basis: 100%; } +} + +@media (max-width: 760px) { + body { overflow: auto; } + .app-header { height: auto; min-height: 68px; padding: 10px 12px; } + .brand small { display: none; } + .profile-controls select { max-width: 145px; } + .workspace { display: block; height: auto; } + .graph-shell { height: 73vh; min-height: 530px; border-right: 0; } + .toolbar { padding: 9px; gap: 7px; } + .toolbar > label { flex: 1; } + .toolbar > label select { width: 100%; min-width: 0; font-size: 11px; } + .summary-bar { grid-template-columns: 1fr; gap: 8px; } + .progress-track { grid-row: 2; } + .details-panel { min-height: 60vh; overflow: visible; border-top: 1px solid var(--line); } + .graph-hint { display: none; } +} + +@media (max-width: 480px) { + .brand > span:last-child { display: none; } + .profile-controls select { max-width: 128px; } + .profile-controls { gap: 5px; } + .profile-controls .button { padding: 0 10px; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; } +} diff --git a/package.json b/package.json index 54db870c..62ebefb1 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "license": "(ODbL-1.0 AND CC-BY-SA-4.0)", "homepage": "https://withmarble.com", "scripts": { - "validate": "node scripts/validate.mjs" + "validate": "node scripts/validate.mjs", + "test": "node --test tests/*.test.mjs", + "serve": "node scripts/serve.mjs" } } diff --git a/scripts/serve.mjs b/scripts/serve.mjs new file mode 100644 index 00000000..e45267d3 --- /dev/null +++ b/scripts/serve.mjs @@ -0,0 +1,40 @@ +import { createReadStream, statSync } from "node:fs"; +import { createServer } from "node:http"; +import { extname, join, normalize, relative, resolve } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const port = Number.parseInt(process.env.PORT || "4173", 10); +const contentTypes = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".svg": "image/svg+xml", +}; + +createServer((request, response) => { + const pathname = decodeURIComponent(new URL(request.url, `http://${request.headers.host}`).pathname); + const requestedPath = pathname === "/" ? "/explorer/" : pathname; + let filePath = normalize(join(root, requestedPath)); + + if (relative(root, filePath).startsWith("..")) { + response.writeHead(403).end("Forbidden"); + return; + } + + try { + if (statSync(filePath).isDirectory()) filePath = join(filePath, "index.html"); + const stats = statSync(filePath); + response.writeHead(200, { + "Content-Type": contentTypes[extname(filePath)] || "application/octet-stream", + "Content-Length": stats.size, + "Cache-Control": filePath.endsWith(".json") ? "public, max-age=300" : "no-cache", + }); + createReadStream(filePath).pipe(response); + } catch { + response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }).end("Not found"); + } +}).listen(port, "127.0.0.1", () => { + console.log(`Marble Learning Map: http://127.0.0.1:${port}/explorer/`); +}); diff --git a/tests/profile-store.test.mjs b/tests/profile-store.test.mjs new file mode 100644 index 00000000..cff65088 --- /dev/null +++ b/tests/profile-store.test.mjs @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { ProfileStore, STORAGE_KEY, sanitizeState } from "../explorer/src/profile-store.js"; + +class MemoryStorage { + values = new Map(); + getItem(key) { return this.values.get(key) ?? null; } + setItem(key, value) { this.values.set(key, value); } +} + +const date = new Date("2026-06-15T10:00:00.000Z"); + +describe("ProfileStore", () => { + it("keeps independent progress for multiple child profiles", () => { + const store = new ProfileStore(new MemoryStorage(), () => date); + store.addProfile("Ada"); + const adaId = store.activeProfile.id; + store.setProgress("mt_one", "mastered"); + store.addProfile("Linus"); + store.setProgress("mt_two", "learning"); + + assert.deepEqual(store.activeProfile.progress, { + mt_two: { status: "learning", updatedAt: date.toISOString() }, + }); + store.setActive(adaId); + assert.equal(store.activeProfile.progress.mt_one.status, "mastered"); + assert.equal(store.activeProfile.progress.mt_two, undefined); + }); + + it("records verified assessment evidence with mastery", () => { + const store = new ProfileStore(new MemoryStorage(), () => date); + store.addProfile("Ada"); + store.setProgress("mt_one", "mastered", { verified: true, evidence: [0, 1, 1, 2] }); + + assert.deepEqual(store.activeProfile.progress.mt_one.assessment, { + verified: true, + evidence: [0, 1, 2], + assessedAt: date.toISOString(), + }); + + store.setProgress("mt_one", "mastered"); + assert.equal(store.activeProfile.progress.mt_one.assessment.verified, true, "reapplying mastery preserves its assessment"); + }); + + it("can clear one concept or reset only the active profile", () => { + const store = new ProfileStore(new MemoryStorage(), () => date); + store.addProfile("Ada"); + store.setProgress("mt_one", "learning"); + store.setProgress("mt_one", null); + assert.deepEqual(store.activeProfile.progress, {}); + + store.setProgress("mt_two", "mastered"); + store.addProfile("Linus"); + store.setProgress("mt_three", "mastered"); + store.resetActiveProgress(); + assert.deepEqual(store.activeProfile.progress, {}); + + store.setActive(store.state.profiles[0].id); + assert.equal(store.activeProfile.progress.mt_two.status, "mastered"); + }); + + it("persists and restores state from the versioned local-storage key", () => { + const storage = new MemoryStorage(); + const first = new ProfileStore(storage, () => date); + first.addProfile("Ada"); + first.setProgress("mt_one", "learning"); + const restored = new ProfileStore(storage, () => date); + + assert.equal(JSON.parse(storage.getItem(STORAGE_KEY)).version, 1); + assert.equal(restored.activeProfile.name, "Ada"); + assert.equal(restored.activeProfile.progress.mt_one.status, "learning"); + }); +}); + +describe("sanitizeState", () => { + it("rejects malformed profiles, progress, and unknown statuses", () => { + const state = sanitizeState({ + activeProfileId: "p1", + profiles: [ + { id: "p1", name: " Ada ", progress: { mt_ok: { status: "learning" }, mt_bad: { status: "guessed" }, nope: { status: "mastered" } } }, + { id: "p1", name: "duplicate", progress: {} }, + { id: "p2", name: " ", progress: {} }, + ], + }); + + assert.equal(state.profiles.length, 1); + assert.equal(state.profiles[0].name, "Ada"); + assert.deepEqual(Object.keys(state.profiles[0].progress), ["mt_ok"]); + }); +}); diff --git a/tests/taxonomy.test.mjs b/tests/taxonomy.test.mjs new file mode 100644 index 00000000..73405a5f --- /dev/null +++ b/tests/taxonomy.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { assessmentPromptFor, buildTaxonomy } from "../explorer/src/taxonomy.js"; + +const topics = [ + { id: "mt_a", name: "A", subject: "Science", ageRangeStart: 5, ageRangeEnd: 6, assessmentPrompt: "Can {{name}} do A?" }, + { id: "mt_b", name: "B", subject: "Mathematics", ageRangeStart: 7, ageRangeEnd: 8, assessmentPrompt: "Can {{name}} do B?" }, +]; +const dependencies = [{ topicId: "mt_b", prerequisiteId: "mt_a", strength: "hard", reason: "A comes first" }]; + +describe("buildTaxonomy", () => { + it("indexes prerequisite and unlock relationships in both directions", () => { + const result = buildTaxonomy(topics, dependencies); + assert.equal(result.prerequisites.get("mt_b")[0].topic.name, "A"); + assert.equal(result.unlocks.get("mt_a")[0].topic.name, "B"); + assert.deepEqual(result.subjects, ["Mathematics", "Science"]); + assert.equal(result.minAge, 5); + assert.equal(result.maxAge, 8); + }); +}); + +describe("assessmentPromptFor", () => { + it("fills every child-name placeholder without mutating the topic", () => { + assert.equal(assessmentPromptFor(topics[0], "Ada"), "Can Ada do A?"); + assert.equal(topics[0].assessmentPrompt, "Can {{name}} do A?"); + }); +}); From 62ff9526e4c44c1b8f6b8648dfcb3003001a83d7 Mon Sep 17 00:00:00 2001 From: Florin Badita-Nistor Date: Sat, 11 Jul 2026 20:47:10 +0300 Subject: [PATCH 2/2] feat: add gamified learner logbook --- README.md | 7 +- explorer/index.html | 33 +++- explorer/src/app.js | 333 ++++++++++++++++++++++++++++++++-- explorer/src/logbook.js | 59 ++++++ explorer/src/profile-store.js | 35 +++- explorer/styles.css | 126 +++++++++++++ tests/logbook.test.mjs | 35 ++++ tests/profile-store.test.mjs | 17 +- 8 files changed, 618 insertions(+), 27 deletions(-) create mode 100644 explorer/src/logbook.js create mode 100644 tests/logbook.test.mjs diff --git a/README.md b/README.md index 4091c529..cab0bc4a 100644 --- a/README.md +++ b/README.md @@ -104,11 +104,16 @@ node scripts/validate.mjs This repository includes a dependency-free browser explorer that can track learning progress privately on one device. It supports multiple child profiles, `learning` and `known` states, evidence-based assessments, subject/age/search filters, and dimming or hiding concepts a child already knows. +Two complementary views share the same local profile: + +- **Graph** visualizes all prerequisite relationships and opens the evidence checklist for any concept. +- **Logbook** summarizes known, learning, assessed, and not-yet-started concepts; suggests next topics whose hard prerequisites are complete; shows subject journeys; and keeps a chronological activity trail. + ```bash npm run serve ``` -Then open [http://localhost:4173/explorer/](http://localhost:4173/explorer/). Profiles and progress are versioned and stored only in the browser's `localStorage`; the explorer makes no network requests after loading the taxonomy JSON. Use **Manage** beside the profile picker to rename or delete a profile, or reset its progress. +Then open [http://localhost:4173/explorer/](http://localhost:4173/explorer/). A child's name is requested once and substituted automatically into every assessment prompt. Profiles, progress, and the most recent 500 logbook activities are versioned and stored only in the browser's `localStorage`; the explorer makes no network requests after loading the taxonomy JSON. Use **Manage** beside the profile picker to explicitly edit a name, delete a profile, or reset its progress and activity trail. The explorer is intentionally a static ES-module application with no build step. Its persistence layer lives in `explorer/src/profile-store.js`, independently of the graph renderer, so a server-backed profile adapter can replace it later without changing the taxonomy data or graph interaction. diff --git a/explorer/index.html b/explorer/index.html index e570e3fc..ba40c723 100644 --- a/explorer/index.html +++ b/explorer/index.html @@ -20,6 +20,15 @@ + +
@@ -28,8 +37,8 @@
-
-
+
+
+ +
@@ -112,11 +125,20 @@

Add a child

Profiles and learning progress stay in this browser. Nothing is uploaded.

-
+ diff --git a/explorer/src/app.js b/explorer/src/app.js index e0087563..3f79c537 100644 --- a/explorer/src/app.js +++ b/explorer/src/app.js @@ -1,4 +1,5 @@ import { GraphView, SUBJECT_COLORS } from "./graph-view.js"; +import { filterLogbookTopics, getProgressStats, getRecommendedTopics, getSubjectJourneys } from "./logbook.js"; import { ProfileStore } from "./profile-store.js"; import { assessmentPromptFor, loadTaxonomy } from "./taxonomy.js"; @@ -7,7 +8,9 @@ const elements = Object.fromEntries( "profile-select", "add-profile", "manage-profile", "search", "search-results", "subject-filter", "age-filter", "mastery-filter", "profile-greeting", "progress-summary", "progress-bar", "graph", "zoom-in", "zoom-out", "reset-view", "empty-state", "clear-filters", "details-empty", "details-content", "profile-dialog", "dialog-title", - "profile-name", "save-profile", "profile-danger", "danger-copy", "reset-progress", "delete-profile", "toast", + "profile-name", "profile-name-field", "save-profile", "profile-danger", "danger-copy", "reset-progress", "delete-profile", "toast", + "workspace", "graph-mode", "logbook-mode", "logbook-view", "logbook-content", "profile-dialog-actions", + "managed-profile-summary", "managed-profile-name", "managed-profile-avatar", "edit-profile-name", "name-once-note", "celebration", ].map((id) => [id, document.getElementById(id)]), ); @@ -17,6 +20,10 @@ let graph; let selectedId = null; let dialogMode = "add"; let toastTimer; +let currentView = "graph"; +let logbookFilter = "all"; +let logbookSearch = ""; +let logbookLimit = 60; function activeProgress() { return store.activeProfile?.progress ?? {}; @@ -58,25 +65,23 @@ function renderProfiles() { elements["manage-profile"].disabled = !state.activeProfileId; renderSummary(); renderDetails(); + renderLogbook(); applyFilters(); } function renderSummary() { if (!taxonomy) return; const profile = store.activeProfile; - const entries = Object.values(profile?.progress ?? {}); - const mastered = entries.filter(({ status }) => status === "mastered").length; - const learning = entries.filter(({ status }) => status === "learning").length; - const assessed = entries.filter(({ assessment }) => assessment?.verified).length; - const percent = Math.round((mastered / taxonomy.topics.length) * 100); + const stats = getProgressStats(taxonomy.topics, profile?.progress ?? {}); + const percent = Math.round((stats.known / stats.total) * 100); elements["profile-greeting"].textContent = profile ? `${profile.name}'s learning map` : "Learning overview"; elements["progress-summary"].textContent = profile - ? `${mastered} known · ${learning} learning · ${assessed} assessed · ${taxonomy.topics.length - mastered - learning} not started` + ? `${stats.known} known · ${stats.learning} learning · ${stats.assessed} assessed · ${stats.notStarted} not started` : "Create a private child profile to start tracking progress."; elements["progress-bar"].style.width = `${percent}%`; const track = elements["progress-bar"].parentElement; track.setAttribute("aria-valuenow", String(percent)); - track.setAttribute("aria-valuetext", `${mastered} of ${taxonomy.topics.length} concepts known`); + track.setAttribute("aria-valuetext", `${stats.known} of ${stats.total} concepts known`); } function filters() { @@ -131,6 +136,276 @@ function renderSearchResults() { elements.search.setAttribute("aria-expanded", "true"); } +const SUBJECT_GLYPHS = { + Computing: "⌘", + English: "Aa", + History: "⌛", + "Learning to Learn": "✦", + "Life Skills": "☀", + Mathematics: "π", + "Personal & Social Development": "♥", + Science: "⚗", +}; + +function celebrate() { + if (globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return; + const container = elements.celebration; + container.replaceChildren(); + const colors = ["#7df0bd", "#fff3a6", "#7c6df2", "#eb6f92", "#4e83e6"]; + for (let index = 0; index < 24; index += 1) { + const piece = makeElement("i"); + piece.style.setProperty("--x", `${(Math.random() - 0.5) * 520}px`); + piece.style.setProperty("--y", `${-120 - Math.random() * 330}px`); + piece.style.setProperty("--r", `${Math.random() * 720 - 360}deg`); + piece.style.setProperty("--delay", `${Math.random() * 0.16}s`); + piece.style.background = colors[index % colors.length]; + container.append(piece); + } + container.classList.remove("playing"); + requestAnimationFrame(() => container.classList.add("playing")); + setTimeout(() => container.replaceChildren(), 1400); +} + +function switchView(view) { + currentView = view; + const logbook = view === "logbook"; + elements.workspace.classList.toggle("logbook-mode", logbook); + elements["logbook-view"].hidden = !logbook; + elements["graph-mode"].classList.toggle("active", !logbook); + elements["graph-mode"].setAttribute("aria-pressed", String(!logbook)); + elements["logbook-mode"].classList.toggle("active", logbook); + elements["logbook-mode"].setAttribute("aria-pressed", String(logbook)); + if (logbook) renderLogbook(); + else requestAnimationFrame(() => graph?.resize()); +} + +function openFromLogbook(topicId) { + switchView("graph"); + selectTopic(topicId); +} + +function logbookSection(title, description) { + const section = makeElement("section", { className: "logbook-section" }); + const heading = makeElement("div", { className: "logbook-section-heading" }); + const copy = makeElement("div"); + copy.append(makeElement("h2", { text: title }), makeElement("p", { text: description })); + heading.append(copy); + section.append(heading); + return { section, heading }; +} + +function logbookStatus(entry) { + if (entry?.assessment?.verified) return { label: "Assessed", className: "assessed", icon: "★" }; + if (entry?.status === "mastered") return { label: "Known", className: "known", icon: "✓" }; + if (entry?.status === "learning") return { label: "Learning", className: "learning", icon: "↗" }; + return { label: "Not yet", className: "not-started", icon: "○" }; +} + +function renderLogbook() { + if (!taxonomy) return; + const content = elements["logbook-content"]; + content.replaceChildren(); + const profile = store.activeProfile; + if (!profile) { + const empty = makeElement("div", { className: "logbook-empty" }); + empty.append( + makeElement("span", { className: "logbook-empty-icon", text: "✦" }), + makeElement("h1", { text: "Every adventure needs a learner" }), + makeElement("p", { text: "Create one private profile, then the logbook will organize everything they know, are learning, and can try next." }), + ); + const add = makeElement("button", { className: "button primary", text: "Create child profile", attrs: { type: "button" } }); + add.addEventListener("click", () => openProfileDialog("add")); + empty.append(add); + content.append(empty); + return; + } + + const progress = profile.progress; + const stats = getProgressStats(taxonomy.topics, progress); + const points = stats.known * 10 + stats.assessed * 5 + stats.learning * 2; + const level = Math.floor(points / 100) + 1; + const levelProgress = points % 100; + + const hero = makeElement("header", { className: "logbook-hero" }); + const heroCopy = makeElement("div"); + heroCopy.append( + makeElement("p", { className: "eyebrow", text: `${profile.name}'s learning adventure` }), + makeElement("h1", { text: "Learning Logbook" }), + makeElement("p", { text: `One friendly place to see what ${profile.name} knows, what is in progress, and which adventure could come next.` }), + ); + const levelCard = makeElement("div", { className: "level-card", attrs: { title: "Learning points come from concepts tracked in this browser." } }); + const levelRing = makeElement("div", { className: "level-ring" }); + levelRing.style.setProperty("--level-progress", `${levelProgress * 3.6}deg`); + levelRing.append(makeElement("strong", { text: String(level) }), makeElement("small", { text: "LEVEL" })); + const pointCopy = makeElement("span"); + pointCopy.append(makeElement("strong", { text: `${points} points` }), makeElement("small", { text: `${100 - levelProgress} to level ${level + 1}` })); + levelCard.append(levelRing, pointCopy); + hero.append(heroCopy, levelCard); + content.append(hero); + + const statsGrid = makeElement("div", { className: "logbook-stats" }); + for (const [value, label, icon, kind] of [ + [stats.known, "Known", "✓", "known"], + [stats.learning, "Learning", "↗", "learning"], + [stats.assessed, "Assessed", "★", "assessed"], + [stats.notStarted, "Not yet", "○", "not-started"], + ]) { + const card = makeElement("button", { className: `logbook-stat ${kind}`, attrs: { type: "button" } }); + card.append(makeElement("i", { text: icon }), makeElement("strong", { text: String(value) }), makeElement("span", { text: label })); + card.addEventListener("click", () => { + logbookFilter = kind === "not-started" ? "not-started" : kind; + document.getElementById("logbook-ledger")?.scrollIntoView({ behavior: "smooth" }); + renderLogbook(); + setTimeout(() => document.getElementById("logbook-ledger")?.scrollIntoView({ behavior: "smooth" }), 0); + }); + statsGrid.append(card); + } + content.append(statsGrid); + + const recommendations = getRecommendedTopics(taxonomy, progress, 5); + const adventure = logbookSection("Next adventures", `A suggested path based on what ${profile.name} is learning and which prerequisites are complete.`); + const path = makeElement("div", { className: "adventure-path" }); + for (const [index, topic] of recommendations.entries()) { + const entry = progress[topic.id]; + const step = makeElement("article", { className: `path-step${entry?.status === "learning" ? " current" : ""}` }); + const orb = makeElement("button", { className: "path-orb", attrs: { type: "button", "aria-label": `Open ${topic.name}` } }); + orb.style.setProperty("--subject-color", SUBJECT_COLORS[topic.subject] || "#92a3bb"); + orb.append(makeElement("span", { text: SUBJECT_GLYPHS[topic.subject] || "✦" })); + orb.addEventListener("click", () => openFromLogbook(topic.id)); + const copy = makeElement("div", { className: "path-copy" }); + copy.append( + makeElement("small", { text: entry?.status === "learning" ? "Continue learning" : index === 0 ? "Suggested next" : `Step ${index + 1}` }), + makeElement("h3", { text: topic.name }), + makeElement("p", { text: `${topic.subject} · ${topic.domain} · ages ${topic.ageRangeStart}–${topic.ageRangeEnd}` }), + ); + const action = makeElement("button", { + className: `button ${entry?.status === "learning" ? "secondary" : "path-action"}`, + text: entry?.status === "learning" ? "Continue" : "Start", + attrs: { type: "button" }, + }); + action.addEventListener("click", () => { + if (entry?.status === "learning") return openFromLogbook(topic.id); + store.setProgress(topic.id, "learning"); + showToast(`${profile.name} started ${topic.name}!`); + }); + step.append(orb, copy, action); + path.append(step); + } + adventure.section.append(path); + content.append(adventure.section); + + const subjectSection = logbookSection("Subject journeys", "Progress across the whole taxonomy. Open any journey back in the graph."); + const journeys = makeElement("div", { className: "subject-journeys" }); + for (const journey of getSubjectJourneys(taxonomy.topics, progress)) { + const percent = Math.round((journey.known / journey.total) * 100); + const card = makeElement("button", { className: "journey-card", attrs: { type: "button" } }); + const icon = makeElement("i", { text: SUBJECT_GLYPHS[journey.subject] || "✦" }); + icon.style.background = SUBJECT_COLORS[journey.subject] || "#92a3bb"; + const copy = makeElement("span"); + copy.append( + makeElement("strong", { text: journey.subject }), + makeElement("small", { text: `${journey.known} known · ${journey.learning} learning · ${journey.total} total` }), + ); + const bar = makeElement("span", { className: "journey-progress" }); + const fill = makeElement("i"); + fill.style.width = `${Math.max(percent, journey.known ? 2 : 0)}%`; + fill.style.background = SUBJECT_COLORS[journey.subject] || "#92a3bb"; + bar.append(fill); + card.append(icon, copy, makeElement("b", { text: `${percent}%` }), bar); + card.addEventListener("click", () => { + elements["subject-filter"].value = journey.subject; + elements.search.value = ""; + applyFilters(); + switchView("graph"); + }); + journeys.append(card); + } + subjectSection.section.append(journeys); + content.append(subjectSection.section); + + const ledger = logbookSection("The complete logbook", "Search every concept or focus on what is known, in progress, assessed, or not started yet."); + ledger.section.id = "logbook-ledger"; + const controls = makeElement("div", { className: "ledger-controls" }); + const search = makeElement("input", { attrs: { type: "search", placeholder: "Search the logbook…", "aria-label": "Search the logbook" } }); + search.value = logbookSearch; + const statusFilter = makeElement("select", { attrs: { "aria-label": "Logbook status" } }); + for (const [value, label] of [["all", "All concepts"], ["learning", "Learning"], ["known", "Known"], ["assessed", "Assessed"], ["not-started", "Not yet"]]) { + statusFilter.append(new Option(label, value, false, logbookFilter === value)); + } + const resultCount = makeElement("span", { className: "ledger-count" }); + controls.append(search, statusFilter, resultCount); + ledger.heading.append(controls); + const rows = makeElement("div", { className: "ledger-rows" }); + const loadMore = makeElement("button", { className: "button secondary load-more", text: "Show more", attrs: { type: "button" } }); + + function updateLedger() { + const matches = filterLogbookTopics(taxonomy.topics, progress, { status: logbookFilter, query: logbookSearch }); + resultCount.textContent = `${matches.length} concept${matches.length === 1 ? "" : "s"}`; + rows.replaceChildren(); + if (!matches.length) rows.append(makeElement("p", { className: "ledger-empty", text: "Nothing matches yet. Try another filter or search." })); + for (const topic of matches.slice(0, logbookLimit)) { + const entry = progress[topic.id]; + const status = logbookStatus(entry); + const row = makeElement("article", { className: "ledger-row" }); + const subjectDot = makeElement("i", { className: "ledger-subject" }); + subjectDot.style.background = SUBJECT_COLORS[topic.subject] || "#92a3bb"; + const topicCopy = makeElement("button", { className: "ledger-topic", attrs: { type: "button" } }); + topicCopy.append(makeElement("strong", { text: topic.name }), makeElement("small", { text: `${topic.subject} · ${topic.domain} · ages ${topic.ageRangeStart}–${topic.ageRangeEnd}` })); + topicCopy.addEventListener("click", () => openFromLogbook(topic.id)); + const badge = makeElement("span", { className: `ledger-status ${status.className}` }); + badge.append(makeElement("i", { text: status.icon }), document.createTextNode(status.label)); + const actions = makeElement("div", { className: "ledger-actions" }); + if (entry?.status !== "learning") { + const learn = makeElement("button", { text: "Learn", attrs: { type: "button", title: "Mark as learning" } }); + learn.addEventListener("click", () => { store.setProgress(topic.id, "learning"); showToast(`${profile.name} is learning ${topic.name}`); }); + actions.append(learn); + } + if (entry?.status !== "mastered") { + const know = makeElement("button", { className: "know-action", text: "Know it", attrs: { type: "button", title: "Mark as known" } }); + know.addEventListener("click", () => { store.setProgress(topic.id, "mastered"); celebrate(); showToast(`Wonderful — ${profile.name} knows ${topic.name}!`); }); + actions.append(know); + } + const assess = makeElement("button", { text: "Assess", attrs: { type: "button", title: "Open the evidence checklist" } }); + assess.addEventListener("click", () => openFromLogbook(topic.id)); + actions.append(assess); + row.append(subjectDot, topicCopy, badge, actions); + rows.append(row); + } + loadMore.hidden = matches.length <= logbookLimit; + } + search.addEventListener("input", (event) => { logbookSearch = event.target.value; logbookLimit = 60; updateLedger(); }); + statusFilter.addEventListener("change", (event) => { logbookFilter = event.target.value; logbookLimit = 60; updateLedger(); }); + loadMore.addEventListener("click", () => { logbookLimit += 60; updateLedger(); }); + updateLedger(); + ledger.section.append(rows, loadMore); + content.append(ledger.section); + + const recent = logbookSection("Recent moments", `A private activity trail for ${profile.name}, stored only in this browser.`); + const timeline = makeElement("div", { className: "activity-timeline" }); + const recentActivities = [...profile.activities].reverse().slice(0, 12); + if (!recentActivities.length) { + timeline.append(makeElement("p", { className: "activity-empty", text: "New learning moments will appear here as you update the logbook." })); + } else { + const labels = { + learning: ["↗", "started learning"], + mastered: ["✓", "marked as known"], + assessed: ["★", "completed an assessment for"], + cleared: ["○", "moved back to not started"], + }; + for (const activity of recentActivities) { + const topic = taxonomy.byId.get(activity.topicId); + if (!topic) continue; + const [icon, verb] = labels[activity.action]; + const item = makeElement("button", { className: `activity-item ${activity.action}`, attrs: { type: "button" } }); + item.append(makeElement("i", { text: icon }), makeElement("span", { text: `${profile.name} ${verb} “${topic.name}”` }), makeElement("time", { text: new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(activity.at)), attrs: { datetime: activity.at } })); + item.addEventListener("click", () => openFromLogbook(topic.id)); + timeline.append(item); + } + } + recent.section.append(timeline); + content.append(recent.section); +} + function applyFilters() { if (!taxonomy || !graph) return; const { query, subject, age, masteryMode } = filters(); @@ -159,7 +434,9 @@ function statusButton(label, value, current, topicId) { button.addEventListener("click", () => { if (!store.activeProfile) return openProfileDialog("add"); store.setProgress(topicId, value); - showToast(value === null ? "Progress cleared" : value === "learning" ? "Marked as learning" : "Marked as known"); + const name = store.activeProfile?.name ?? "Your learner"; + if (value === "mastered") celebrate(); + showToast(value === null ? "Progress cleared" : value === "learning" ? `${name} started a new adventure!` : `Wonderful — ${name} knows this!`); }); return button; } @@ -212,7 +489,12 @@ function renderDetails() { const assessment = makeElement("section", { className: "detail-section assessment-card" }); assessment.append(makeElement("p", { className: "eyebrow", text: "Quick assessment" })); assessment.append(makeElement("h2", { text: assessmentPromptFor(topic, profile?.name) })); - assessment.append(makeElement("p", { className: "assessment-help", text: "Check each piece of evidence you observed. This does not score the child; it records your judgement." })); + assessment.append(makeElement("p", { + className: "assessment-help", + text: profile + ? `${profile.name}'s name is added automatically. Check each piece of evidence you observed; this records your judgement rather than scoring the child.` + : "The profile name will be added automatically. Check each piece of evidence you observed; this records your judgement rather than scoring the child.", + })); const checklist = makeElement("div", { className: "evidence-list" }); const savedEvidence = new Set(entry?.assessment?.evidence ?? []); const checkboxes = topic.evidence.map((evidence, index) => { @@ -238,7 +520,8 @@ function renderDetails() { assessButton.addEventListener("click", () => { if (!store.activeProfile) return openProfileDialog("add"); store.setProgress(topic.id, "mastered", { verified: true, evidence: checkboxes.map((_, index) => index) }); - showToast("Assessment saved privately in this browser"); + celebrate(); + showToast(`Assessment complete — great work, ${store.activeProfile?.name}!`); }); assessment.append(checklist, assessButton); @@ -266,18 +549,25 @@ function selectTopic(topicId) { selectedId = topicId; graph.select(topicId); renderDetails(); - if (globalThis.innerWidth < 900) document.getElementById("details").scrollIntoView({ behavior: "smooth", block: "start" }); + if (globalThis.innerWidth < 900) document.getElementById("details").scrollIntoView({ behavior: "smooth", block: "start" }); } function openProfileDialog(mode) { dialogMode = mode; const profile = store.activeProfile; - elements["dialog-title"].textContent = mode === "add" ? "Add a child" : "Manage profile"; - elements["profile-name"].value = mode === "manage" ? profile?.name ?? "" : ""; - elements["profile-danger"].hidden = mode !== "manage"; + const adding = mode === "add"; + elements["dialog-title"].textContent = adding ? "Add a child" : "Manage profile"; + elements["profile-name"].value = adding ? "" : profile?.name ?? ""; + elements["profile-name-field"].hidden = !adding; + elements["profile-dialog-actions"].hidden = !adding; + elements["managed-profile-summary"].hidden = adding; + elements["name-once-note"].hidden = !adding; + elements["profile-danger"].hidden = adding; + elements["managed-profile-name"].textContent = profile?.name ?? ""; + elements["managed-profile-avatar"].textContent = profile?.name?.slice(0, 1).toLocaleUpperCase() ?? ""; elements["danger-copy"].textContent = profile ? `Reset or remove ${profile.name}'s browser-only data.` : ""; elements["profile-dialog"].showModal(); - setTimeout(() => elements["profile-name"].focus(), 0); + if (adding) setTimeout(() => elements["profile-name"].focus(), 0); } function clearFilters() { @@ -289,6 +579,8 @@ function clearFilters() { } function bindEvents() { + elements["graph-mode"].addEventListener("click", () => switchView("graph")); + elements["logbook-mode"].addEventListener("click", () => switchView("logbook")); elements["profile-select"].addEventListener("change", (event) => store.setActive(event.target.value)); elements["add-profile"].addEventListener("click", () => openProfileDialog("add")); elements["manage-profile"].addEventListener("click", () => openProfileDialog("manage")); @@ -300,6 +592,15 @@ function bindEvents() { elements["zoom-in"].addEventListener("click", () => graph.zoom(1.25)); elements["zoom-out"].addEventListener("click", () => graph.zoom(0.8)); elements["reset-view"].addEventListener("click", () => graph.fit()); + elements["edit-profile-name"].addEventListener("click", () => { + dialogMode = "rename"; + elements["dialog-title"].textContent = "Edit child's name"; + elements["managed-profile-summary"].hidden = true; + elements["profile-name-field"].hidden = false; + elements["profile-dialog-actions"].hidden = false; + elements["profile-name"].value = store.activeProfile?.name ?? ""; + setTimeout(() => elements["profile-name"].focus(), 0); + }); elements["save-profile"].addEventListener("click", (event) => { event.preventDefault(); if (!elements["profile-name"].reportValidity()) return; diff --git a/explorer/src/logbook.js b/explorer/src/logbook.js new file mode 100644 index 00000000..51924259 --- /dev/null +++ b/explorer/src/logbook.js @@ -0,0 +1,59 @@ +export function getProgressStats(topics, progress) { + const entries = Object.values(progress); + const known = entries.filter(({ status }) => status === "mastered").length; + const learning = entries.filter(({ status }) => status === "learning").length; + const assessed = entries.filter(({ assessment }) => assessment?.verified).length; + return { known, learning, assessed, notStarted: topics.length - known - learning, total: topics.length }; +} + +export function getSubjectJourneys(topics, progress) { + const journeys = new Map(); + for (const topic of topics) { + const journey = journeys.get(topic.subject) ?? { subject: topic.subject, total: 0, known: 0, learning: 0, assessed: 0 }; + const entry = progress[topic.id]; + journey.total += 1; + if (entry?.status === "mastered") journey.known += 1; + if (entry?.status === "learning") journey.learning += 1; + if (entry?.assessment?.verified) journey.assessed += 1; + journeys.set(topic.subject, journey); + } + return [...journeys.values()].sort((left, right) => right.known - left.known || left.subject.localeCompare(right.subject)); +} + +export function getRecommendedTopics(taxonomy, progress, limit = 5) { + const mastered = new Set(Object.entries(progress).filter(([, entry]) => entry.status === "mastered").map(([id]) => id)); + const learning = new Set(Object.entries(progress).filter(([, entry]) => entry.status === "learning").map(([id]) => id)); + const unlockedByMastery = new Set( + taxonomy.dependencies.filter(({ prerequisiteId }) => mastered.has(prerequisiteId)).map(({ topicId }) => topicId), + ); + + return taxonomy.topics + .filter((topic) => !mastered.has(topic.id)) + .filter((topic) => taxonomy.prerequisites.get(topic.id).filter(({ strength }) => strength === "hard").every(({ prerequisiteId }) => mastered.has(prerequisiteId))) + .sort((left, right) => { + const leftPriority = learning.has(left.id) ? 0 : unlockedByMastery.has(left.id) ? 1 : 2; + const rightPriority = learning.has(right.id) ? 0 : unlockedByMastery.has(right.id) ? 1 : 2; + return leftPriority - rightPriority || left.ageRangeStart - right.ageRangeStart || right.centrality - left.centrality || left.name.localeCompare(right.name); + }) + .slice(0, limit); +} + +export function filterLogbookTopics(topics, progress, { status = "all", query = "" } = {}) { + const normalizedQuery = query.trim().toLocaleLowerCase(); + return topics + .filter((topic) => { + const entry = progress[topic.id]; + if (status === "known" && entry?.status !== "mastered") return false; + if (status === "learning" && entry?.status !== "learning") return false; + if (status === "not-started" && entry) return false; + if (status === "assessed" && !entry?.assessment?.verified) return false; + if (normalizedQuery && !`${topic.name} ${topic.subject} ${topic.domain}`.toLocaleLowerCase().includes(normalizedQuery)) return false; + return true; + }) + .sort((left, right) => { + const leftUpdated = progress[left.id]?.updatedAt ?? ""; + const rightUpdated = progress[right.id]?.updatedAt ?? ""; + if (leftUpdated || rightUpdated) return rightUpdated.localeCompare(leftUpdated); + return left.ageRangeStart - right.ageRangeStart || left.subject.localeCompare(right.subject) || left.name.localeCompare(right.name); + }); +} diff --git a/explorer/src/profile-store.js b/explorer/src/profile-store.js index e3e4324d..949cf06a 100644 --- a/explorer/src/profile-store.js +++ b/explorer/src/profile-store.js @@ -1,6 +1,8 @@ export const STORAGE_KEY = "marble-taxonomy:learner-profiles"; -export const STORAGE_VERSION = 1; +export const STORAGE_VERSION = 2; export const PROGRESS_STATUSES = new Set(["learning", "mastered"]); +export const ACTIVITY_ACTIONS = new Set(["learning", "mastered", "assessed", "cleared"]); +const ACTIVITY_LIMIT = 500; function makeId() { if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID(); @@ -24,6 +26,21 @@ function sanitizeAssessment(value) { }; } +function sanitizeActivities(value) { + if (!Array.isArray(value)) return []; + return value + .filter((activity) => + activity && + typeof activity.id === "string" && + typeof activity.topicId === "string" && + activity.topicId.startsWith("mt_") && + ACTIVITY_ACTIONS.has(activity.action) && + typeof activity.at === "string", + ) + .slice(-ACTIVITY_LIMIT) + .map(({ id, topicId, action, at }) => ({ id, topicId, action, at })); +} + export function sanitizeState(value) { if (!value || typeof value !== "object" || !Array.isArray(value.profiles)) return createEmptyState(); @@ -54,6 +71,7 @@ export function sanitizeState(value) { createdAt: typeof candidate.createdAt === "string" ? candidate.createdAt : new Date(0).toISOString(), updatedAt: typeof candidate.updatedAt === "string" ? candidate.updatedAt : new Date(0).toISOString(), progress, + activities: sanitizeActivities(candidate.activities), }); } @@ -115,7 +133,7 @@ export class ProfileStore { const cleanedName = cleanName(name); if (!cleanedName) throw new Error("Please enter a name."); const timestamp = this.#now().toISOString(); - const profile = { id: makeId(), name: cleanedName, createdAt: timestamp, updatedAt: timestamp, progress: {} }; + const profile = { id: makeId(), name: cleanedName, createdAt: timestamp, updatedAt: timestamp, progress: {}, activities: [] }; return this.#commit({ ...this.#state, activeProfileId: profile.id, profiles: [...this.#state.profiles, profile] }); } @@ -146,7 +164,12 @@ export class ProfileStore { if (!topicId.startsWith("mt_")) throw new Error("Invalid topic identifier."); if (status !== null && !PROGRESS_STATUSES.has(status)) throw new Error("Invalid progress status."); + const activeProfile = this.#state.profiles.find(({ id }) => id === this.#state.activeProfileId); + const previous = activeProfile?.progress[topicId]; + if (!verified && (previous?.status ?? null) === status) return this.state; + const timestamp = this.#now().toISOString(); + const action = verified ? "assessed" : status === "learning" ? "learning" : status === "mastered" ? "mastered" : "cleared"; return this.#commit({ ...this.#state, profiles: this.#state.profiles.map((profile) => { @@ -164,7 +187,11 @@ export class ProfileStore { : existingAssessment ? { assessment: existingAssessment } : {}), }; } - return { ...profile, progress, updatedAt: timestamp }; + const activities = [ + ...profile.activities, + { id: makeId(), topicId, action, at: timestamp }, + ].slice(-ACTIVITY_LIMIT); + return { ...profile, progress, activities, updatedAt: timestamp }; }), }); } @@ -175,7 +202,7 @@ export class ProfileStore { return this.#commit({ ...this.#state, profiles: this.#state.profiles.map((profile) => - profile.id === this.#state.activeProfileId ? { ...profile, progress: {}, updatedAt: timestamp } : profile, + profile.id === this.#state.activeProfileId ? { ...profile, progress: {}, activities: [], updatedAt: timestamp } : profile, ), }); } diff --git a/explorer/styles.css b/explorer/styles.css index a4306795..433072b1 100644 --- a/explorer/styles.css +++ b/explorer/styles.css @@ -28,6 +28,10 @@ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px s .brand strong { font-size: 15px; } .brand small { margin-top: 2px; color: var(--muted); font-size: 11px; letter-spacing: .02em; } .profile-controls { display: flex; gap: 8px; align-items: center; } +.view-switch { display: flex; gap: 3px; padding: 3px; border: 1px solid var(--line); border-radius: 11px; background: rgba(255,255,255,.035); } +.view-switch button { min-height: 34px; padding: 0 14px; border: 0; border-radius: 8px; color: var(--muted); background: transparent; font-size: 11px; font-weight: 750; } +.view-switch button span { margin-right: 5px; } +.view-switch button.active { color: var(--text); background: var(--surface-2); box-shadow: 0 4px 13px rgba(0,0,0,.22); } select, input { min-height: 38px; border: 1px solid var(--line); border-radius: 9px; color: var(--text); background: var(--surface-2); padding: 0 11px; } select:disabled, button:disabled { cursor: not-allowed; opacity: .48; } @@ -118,6 +122,105 @@ select:disabled, button:disabled { cursor: not-allowed; opacity: .48; } .relationship-item small { margin-top: 3px; color: var(--muted); font-size: 9px; } .relationship-arrow { color: var(--muted); } +.workspace.logbook-mode { display: block; overflow-y: auto; background: radial-gradient(circle at 75% 5%, rgba(124,109,242,.11), transparent 35%), var(--bg); } +.workspace.logbook-mode .graph-shell, .workspace.logbook-mode .details-panel { display: none; } +.logbook-view[hidden] { display: none; } +.logbook-view { min-height: 100%; padding: 38px 24px 70px; } +.logbook-content { width: min(1120px, 100%); margin: 0 auto; } +.logbook-hero { position: relative; overflow: hidden; display: flex; align-items: center; justify-content: space-between; gap: 25px; padding: 32px 35px; border: 1px solid rgba(125,240,189,.18); border-radius: 24px; background: linear-gradient(135deg, rgba(21,55,65,.95), rgba(21,31,61,.95)); box-shadow: 0 22px 60px rgba(0,0,0,.18); } +.logbook-hero::after { content: ""; position: absolute; width: 260px; height: 260px; right: -90px; top: -130px; border: 1px solid rgba(125,240,189,.15); border-radius: 50%; box-shadow: 0 0 0 35px rgba(125,240,189,.025), 0 0 0 70px rgba(125,240,189,.018); } +.logbook-hero > * { position: relative; z-index: 1; } +.logbook-hero h1 { margin: 5px 0 9px; font-size: clamp(30px, 5vw, 48px); line-height: 1; letter-spacing: -.045em; } +.logbook-hero > div:first-child > p:last-child { max-width: 620px; margin: 0; color: #bdcad8; line-height: 1.55; font-size: 14px; } +.level-card { display: flex; align-items: center; gap: 13px; min-width: 210px; padding: 13px; border: 1px solid rgba(255,255,255,.1); border-radius: 17px; background: rgba(4,14,27,.3); } +.level-ring { --level-progress: 0deg; display: grid; place-content: center; flex: 0 0 auto; width: 68px; height: 68px; border-radius: 50%; text-align: center; background: radial-gradient(circle at center, #14283a 56%, transparent 58%), conic-gradient(var(--accent) var(--level-progress), rgba(255,255,255,.09) 0); } +.level-ring strong, .level-ring small, .level-card > span strong, .level-card > span small { display: block; } +.level-ring strong { font-size: 23px; line-height: 1; } +.level-ring small { margin-top: 3px; color: var(--muted); font-size: 7px; font-weight: 900; letter-spacing: .12em; } +.level-card > span strong { font-size: 13px; } +.level-card > span small { margin-top: 4px; color: var(--muted); font-size: 10px; } +.logbook-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 16px 0 38px; } +.logbook-stat { display: grid; grid-template-columns: auto 1fr; align-items: center; column-gap: 11px; padding: 16px; text-align: left; border: 1px solid var(--line); border-radius: 15px; color: var(--text); background: var(--surface); } +.logbook-stat:hover { transform: translateY(-2px); border-color: rgba(255,255,255,.24); } +.logbook-stat > i { grid-row: span 2; display: grid; place-items: center; width: 36px; height: 36px; border-radius: 12px; font-style: normal; background: rgba(255,255,255,.055); } +.logbook-stat strong { font-size: 20px; line-height: 1; } +.logbook-stat span { color: var(--muted); font-size: 10px; } +.logbook-stat.known > i { color: var(--accent); } +.logbook-stat.learning > i { color: #ffb454; } +.logbook-stat.assessed > i { color: #fff3a6; } +.logbook-stat.not-started > i { color: #8090a8; } +.logbook-section { margin-top: 38px; } +.logbook-section-heading { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 16px; } +.logbook-section-heading h2 { margin: 0 0 5px; font-size: 21px; letter-spacing: -.02em; } +.logbook-section-heading p { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.5; } +.adventure-path { position: relative; display: grid; gap: 9px; padding: 8px 0; } +.adventure-path::before { content: ""; position: absolute; left: 34px; top: 35px; bottom: 35px; width: 3px; border-radius: 3px; background: linear-gradient(var(--accent), rgba(125,240,189,.08)); } +.path-step { position: relative; display: grid; grid-template-columns: 70px minmax(0,1fr) auto; gap: 14px; align-items: center; min-height: 88px; padding: 10px 15px 10px 0; border: 1px solid transparent; border-radius: 18px; } +.path-step:hover, .path-step.current { border-color: var(--line); background: rgba(255,255,255,.025); } +.path-orb { position: relative; z-index: 1; display: grid; place-items: center; width: 68px; height: 68px; border: 5px solid #102034; border-radius: 50%; color: #07111e; background: var(--subject-color); box-shadow: inset 0 -7px 0 rgba(0,0,0,.14), 0 7px 18px rgba(0,0,0,.24); } +.path-orb:hover { transform: scale(1.06) rotate(-3deg); } +.path-orb span { font-size: 20px; font-weight: 900; } +.path-copy small { color: var(--accent); font-size: 9px; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; } +.path-copy h3 { margin: 5px 0 5px; font-size: 14px; } +.path-copy p { margin: 0; color: var(--muted); font-size: 10px; } +.path-action { background: #ffdf69; color: #302500; box-shadow: inset 0 -3px 0 rgba(0,0,0,.13); } +.subject-journeys { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; } +.journey-card { display: grid; grid-template-columns: auto 1fr auto; gap: 11px; align-items: center; padding: 15px; text-align: left; border: 1px solid var(--line); border-radius: 15px; color: var(--text); background: var(--surface); } +.journey-card:hover { border-color: rgba(125,240,189,.3); background: var(--surface-2); } +.journey-card > i { grid-row: span 2; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 13px; color: #07111e; font-style: normal; font-weight: 900; } +.journey-card strong, .journey-card small { display: block; } +.journey-card strong { font-size: 12px; } +.journey-card small { margin-top: 4px; color: var(--muted); font-size: 9px; } +.journey-card b { font-size: 11px; } +.journey-progress { grid-column: 2 / 4; height: 4px; overflow: hidden; border-radius: 4px; background: rgba(255,255,255,.07); } +.journey-progress i { display: block; height: 100%; border-radius: inherit; } +.ledger-controls { display: flex; align-items: center; gap: 8px; } +.ledger-controls input { width: 220px; } +.ledger-count { min-width: 75px; color: var(--muted); text-align: right; font-size: 10px; } +.ledger-rows { overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); } +.ledger-row { display: grid; grid-template-columns: auto minmax(180px,1fr) auto auto; gap: 11px; align-items: center; min-height: 64px; padding: 9px 13px; border-bottom: 1px solid rgba(255,255,255,.055); } +.ledger-row:last-child { border-bottom: 0; } +.ledger-row:hover { background: rgba(255,255,255,.025); } +.ledger-subject { width: 9px; height: 9px; border-radius: 50%; } +.ledger-topic { padding: 3px 0; text-align: left; border: 0; color: var(--text); background: transparent; } +.ledger-topic strong, .ledger-topic small { display: block; } +.ledger-topic strong { font-size: 11px; } +.ledger-topic small { margin-top: 4px; color: var(--muted); font-size: 9px; } +.ledger-status { display: flex; align-items: center; gap: 5px; min-width: 85px; color: var(--muted); font-size: 9px; font-weight: 700; } +.ledger-status i { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; font-style: normal; background: rgba(255,255,255,.05); } +.ledger-status.known, .ledger-status.known i { color: var(--accent); } +.ledger-status.learning, .ledger-status.learning i { color: #ffb454; } +.ledger-status.assessed, .ledger-status.assessed i { color: #fff3a6; } +.ledger-actions { display: flex; justify-content: flex-end; gap: 5px; } +.ledger-actions button { min-height: 29px; padding: 0 9px; border: 1px solid var(--line); border-radius: 7px; color: var(--muted); background: rgba(255,255,255,.025); font-size: 9px; font-weight: 700; } +.ledger-actions button:hover { color: var(--text); background: var(--surface-2); } +.ledger-actions .know-action { color: var(--accent); border-color: rgba(125,240,189,.22); } +.load-more { display: block; margin: 14px auto 0; } +.load-more[hidden] { display: none; } +.ledger-empty, .activity-empty { margin: 0; padding: 25px; text-align: center; color: var(--muted); font-size: 11px; } +.activity-timeline { display: grid; gap: 7px; } +.activity-item { display: grid; grid-template-columns: auto 1fr auto; gap: 11px; align-items: center; padding: 12px 13px; text-align: left; border: 1px solid var(--line); border-radius: 12px; color: var(--text); background: rgba(255,255,255,.022); } +.activity-item:hover { background: var(--surface-2); } +.activity-item > i { display: grid; place-items: center; width: 29px; height: 29px; border-radius: 10px; color: var(--accent); background: rgba(125,240,189,.08); font-style: normal; } +.activity-item.learning > i { color: #ffb454; background: rgba(255,180,84,.08); } +.activity-item.assessed > i { color: #fff3a6; background: rgba(255,243,166,.08); } +.activity-item span { font-size: 11px; } +.activity-item time { color: var(--muted); font-size: 9px; } +.logbook-empty { display: grid; place-items: center; min-height: 70vh; text-align: center; } +.logbook-empty-icon { display: grid; place-items: center; width: 72px; height: 72px; border-radius: 24px; color: var(--accent-ink); background: var(--accent); font-size: 30px; box-shadow: 0 15px 45px rgba(125,240,189,.2); } +.logbook-empty h1 { margin: 24px 0 8px; } +.logbook-empty p { max-width: 470px; margin: 0 0 20px; color: var(--muted); line-height: 1.6; } +.name-once-note { margin: 14px 0 0; color: #c9d5de; font-size: 12px; line-height: 1.5; } +.managed-profile-summary { display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: center; margin-top: 17px; padding: 13px; border: 1px solid var(--line); border-radius: 13px; background: rgba(255,255,255,.025); } +.managed-profile-summary[hidden], .field[hidden], .dialog-actions[hidden], .name-once-note[hidden] { display: none; } +.profile-avatar { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 14px; color: var(--accent-ink); background: var(--accent); font-weight: 900; } +.managed-profile-summary strong, .managed-profile-summary small { display: block; } +.managed-profile-summary small { margin-top: 4px; color: var(--muted); font-size: 9px; } +.celebration { position: fixed; z-index: 30; left: 50%; bottom: 25%; width: 1px; height: 1px; pointer-events: none; } +.celebration i { position: absolute; width: 9px; height: 14px; border-radius: 2px; opacity: 0; } +.celebration.playing i { animation: confetti-pop 1.25s cubic-bezier(.12,.7,.25,1) var(--delay) both; } +@keyframes confetti-pop { 0% { opacity: 0; transform: translate(0,0) rotate(0); } 12% { opacity: 1; } 100% { opacity: 0; transform: translate(var(--x),var(--y)) rotate(var(--r)); } } + dialog { width: min(470px, calc(100% - 28px)); padding: 0; border: 1px solid var(--line); border-radius: 16px; color: var(--text); background: var(--surface); box-shadow: 0 30px 90px rgba(0,0,0,.55); } dialog::backdrop { background: rgba(2, 8, 16, .75); backdrop-filter: blur(4px); } .dialog-card { padding: 23px; } @@ -139,11 +242,15 @@ dialog::backdrop { background: rgba(2, 8, 16, .75); backdrop-filter: blur(4px); .legend { display: none; } .toolbar { flex-wrap: wrap; } .toolbar .search-container { flex-basis: 100%; } + .ledger-row { grid-template-columns: auto minmax(150px,1fr) auto; } + .ledger-actions { grid-column: 2 / 4; } } @media (max-width: 760px) { body { overflow: auto; } .app-header { height: auto; min-height: 68px; padding: 10px 12px; } + .app-header { flex-wrap: wrap; gap: 8px; } + .view-switch { order: 3; flex-basis: 100%; justify-content: center; } .brand small { display: none; } .profile-controls select { max-width: 145px; } .workspace { display: block; height: auto; } @@ -155,6 +262,19 @@ dialog::backdrop { background: rgba(2, 8, 16, .75); backdrop-filter: blur(4px); .progress-track { grid-row: 2; } .details-panel { min-height: 60vh; overflow: visible; border-top: 1px solid var(--line); } .graph-hint { display: none; } + .logbook-view { padding: 20px 12px 55px; } + .logbook-hero { display: grid; padding: 25px 22px; } + .level-card { width: fit-content; } + .logbook-stats { grid-template-columns: repeat(2, 1fr); } + .subject-journeys { grid-template-columns: 1fr; } + .logbook-section-heading { display: grid; } + .ledger-controls { flex-wrap: wrap; } + .ledger-controls input { flex: 1 1 180px; width: auto; } + .ledger-row { grid-template-columns: auto minmax(0,1fr) auto; } + .ledger-status { min-width: auto; } + .ledger-actions { grid-column: 2 / 4; } + .activity-item { grid-template-columns: auto 1fr; } + .activity-item time { grid-column: 2; } } @media (max-width: 480px) { @@ -162,6 +282,12 @@ dialog::backdrop { background: rgba(2, 8, 16, .75); backdrop-filter: blur(4px); .profile-controls select { max-width: 128px; } .profile-controls { gap: 5px; } .profile-controls .button { padding: 0 10px; } + .path-step { grid-template-columns: 60px minmax(0,1fr); padding-right: 4px; } + .path-orb { width: 58px; height: 58px; } + .adventure-path::before { left: 29px; } + .path-step > .button { grid-column: 2; justify-self: start; } + .managed-profile-summary { grid-template-columns: auto 1fr; } + .managed-profile-summary .button { grid-column: 1 / 3; } } @media (prefers-reduced-motion: reduce) { diff --git a/tests/logbook.test.mjs b/tests/logbook.test.mjs new file mode 100644 index 00000000..6947ef65 --- /dev/null +++ b/tests/logbook.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { filterLogbookTopics, getProgressStats, getRecommendedTopics, getSubjectJourneys } from "../explorer/src/logbook.js"; +import { buildTaxonomy } from "../explorer/src/taxonomy.js"; + +const topics = [ + { id: "mt_a", name: "Foundations", subject: "Science", domain: "Basics", ageRangeStart: 4, ageRangeEnd: 5, centrality: 0.8 }, + { id: "mt_b", name: "Next step", subject: "Science", domain: "Basics", ageRangeStart: 5, ageRangeEnd: 6, centrality: 0.7 }, + { id: "mt_c", name: "Numbers", subject: "Mathematics", domain: "Number", ageRangeStart: 4, ageRangeEnd: 5, centrality: 0.9 }, +]; +const taxonomy = buildTaxonomy(topics, [{ topicId: "mt_b", prerequisiteId: "mt_a", strength: "hard", reason: "first" }]); +const progress = { + mt_a: { status: "mastered", updatedAt: "2026-01-01T00:00:00.000Z", assessment: { verified: true } }, + mt_c: { status: "learning", updatedAt: "2026-01-02T00:00:00.000Z" }, +}; + +describe("logbook summaries", () => { + it("counts current progress and groups it by subject", () => { + assert.deepEqual(getProgressStats(topics, progress), { known: 1, learning: 1, assessed: 1, notStarted: 1, total: 3 }); + assert.deepEqual(getSubjectJourneys(topics, progress), [ + { subject: "Science", total: 2, known: 1, learning: 0, assessed: 1 }, + { subject: "Mathematics", total: 1, known: 0, learning: 1, assessed: 0 }, + ]); + }); + + it("recommends learning topics first and concepts whose prerequisites are met", () => { + assert.deepEqual(getRecommendedTopics(taxonomy, progress).map(({ id }) => id), ["mt_c", "mt_b"]); + }); + + it("filters by state and searches across subject and domain", () => { + assert.deepEqual(filterLogbookTopics(topics, progress, { status: "known" }).map(({ id }) => id), ["mt_a"]); + assert.deepEqual(filterLogbookTopics(topics, progress, { status: "not-started" }).map(({ id }) => id), ["mt_b"]); + assert.deepEqual(filterLogbookTopics(topics, progress, { query: "number" }).map(({ id }) => id), ["mt_c"]); + }); +}); diff --git a/tests/profile-store.test.mjs b/tests/profile-store.test.mjs index cff65088..354ca26c 100644 --- a/tests/profile-store.test.mjs +++ b/tests/profile-store.test.mjs @@ -66,10 +66,24 @@ describe("ProfileStore", () => { first.setProgress("mt_one", "learning"); const restored = new ProfileStore(storage, () => date); - assert.equal(JSON.parse(storage.getItem(STORAGE_KEY)).version, 1); + assert.equal(JSON.parse(storage.getItem(STORAGE_KEY)).version, 2); assert.equal(restored.activeProfile.name, "Ada"); assert.equal(restored.activeProfile.progress.mt_one.status, "learning"); }); + + it("keeps a chronological activity log and clears it with progress", () => { + const storage = new MemoryStorage(); + const store = new ProfileStore(storage, () => date); + store.addProfile("Ada"); + store.setProgress("mt_one", "learning"); + store.setProgress("mt_one", "mastered"); + store.setProgress("mt_one", "mastered", { verified: true, evidence: [0] }); + + assert.deepEqual(store.activeProfile.activities.map(({ action }) => action), ["learning", "mastered", "assessed"]); + assert.equal(store.activeProfile.activities[0].topicId, "mt_one"); + store.resetActiveProgress(); + assert.deepEqual(store.activeProfile.activities, []); + }); }); describe("sanitizeState", () => { @@ -86,5 +100,6 @@ describe("sanitizeState", () => { assert.equal(state.profiles.length, 1); assert.equal(state.profiles[0].name, "Ada"); assert.deepEqual(Object.keys(state.profiles[0].progress), ["mt_ok"]); + assert.deepEqual(state.profiles[0].activities, []); }); });