Compare commits

..

1 Commits

Author SHA1 Message Date
RaymondVerhoef
20449f0bc1 fix: gebruik theme_session_completions voor learnDone op dashboard
getLearnDone was een deprecated stub die altijd false teruggaf nadat
de learn_progress-collectie is gedropt. Hierdoor toonde het dashboard
permanent 'Complete your theme session first', zelfs als de theme
session al voltooid was, en werd cycle progress nooit opgeteld.

Alle drie call-sites vervangen door getThemeSessionCompletion (de
huidige bron van waarheid), en de dode stubs verwijderd uit db.js.

Closes #9

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 12:49:12 +02:00
10 changed files with 35 additions and 951 deletions

View File

@@ -3,12 +3,9 @@ import * as d3 from 'd3';
import * as db from '../../lib/db'; import * as db from '../../lib/db';
import { callLLM } from '../../lib/llm'; import { callLLM } from '../../lib/llm';
import { EMIT_GRAPH_ACTIONS_TOOL } from '../../lib/llmTools'; import { EMIT_GRAPH_ACTIONS_TOOL } from '../../lib/llmTools';
import { Network, Table2 } from 'lucide-react';
import { useGraphData } from '../../hooks/useGraphData'; import { useGraphData } from '../../hooks/useGraphData';
import { filterAiActions } from '../../lib/graphGuard';
import GraphControls from './graph/GraphControls'; import GraphControls from './graph/GraphControls';
import NodeDetailPanel from './graph/NodeDetailPanel'; import NodeDetailPanel from './graph/NodeDetailPanel';
import GraphTable from './graph/GraphTable';
// ── Edge visual style per relation type ──────────────────────────────────────── // ── Edge visual style per relation type ────────────────────────────────────────
// stroke — line colour // stroke — line colour
@@ -26,21 +23,11 @@ const SYSTEM_PROMPTS = {
full: `You are a strict Data Quality AI maintaining a Knowledge Graph for Respellion. full: `You are a strict Data Quality AI maintaining a Knowledge Graph for Respellion.
Evaluate the provided topics and relations and emit the actions to take via the emit_graph_actions tool. Evaluate the provided topics and relations and emit the actions to take via the emit_graph_actions tool.
PROTECTED TOPICS — NEVER include these in merges (as deleteId) or in deletions:
• Topics with learning_relevance="exclude" are REFERENCE MATERIAL, kept on purpose
so R42 can still answer questions about them. They are intentionally outside
the theme-learning flow. Do not propose deleting or merging them away.
• Topics with relevance_locked=true are admin-pinned. Do not change their
learning_relevance and do not delete or merge them.
These topics may appear as the keepId in a merge (others fold into them), and
may appear as source/target of newRelations, but their own row must survive.
Rules: Rules:
1. Identify topics that mean exactly the same thing. Choose one to keep, one to delete (merges). 1. Identify topics that mean exactly the same thing. Choose one to keep, one to delete (merges).
2. Identify topics that are too vague, irrelevant, or malformed (deletions). 2. Identify topics that are too vague, irrelevant, or malformed (deletions).
3. Identify missing logical relations (depends_on, part_of, related_to, executed_by) between conceptually linked topics (newRelations). 3. Identify missing logical relations (depends_on, part_of, related_to, executed_by) between conceptually linked topics (newRelations).
4. Evaluate learning_relevance. Mark purely operational topics (printer guides, etc.) as "exclude"; low-priority as "peripheral" (relevanceUpdates). 4. Evaluate learning_relevance. Mark purely operational topics (printer guides, etc.) as "exclude"; low-priority as "peripheral" (relevanceUpdates).
Skip topics where relevance_locked=true — omit them from relevanceUpdates entirely.
Do not return the entire graph — only the actions to take.`, Do not return the entire graph — only the actions to take.`,
@@ -84,13 +71,11 @@ const KnowledgeGraph = () => {
const [showExcludeNodes, setShowExcludeNodes] = useState(false); const [showExcludeNodes, setShowExcludeNodes] = useState(false);
const [isAnalyzing, setIsAnalyzing] = useState(false); const [isAnalyzing, setIsAnalyzing] = useState(false);
const [analyzeError, setAnalyzeError] = useState(null); const [analyzeError, setAnalyzeError] = useState(null);
const [analyzeNotice, setAnalyzeNotice] = useState(null); // info-level message from last analyze
const [isRestoring, setIsRestoring] = useState(false); const [isRestoring, setIsRestoring] = useState(false);
const [viewMode, setViewMode] = useState('graph'); // 'graph' | 'table'
const { const {
topics, relations, snapshotMeta, topics, relations, snapshotMeta,
reload, updateTopic, bulkUpdateTopics, deleteTopic, addRelation, removeRelation, bulkSave, restoreSnapshot, reload, updateTopic, deleteTopic, addRelation, removeRelation, bulkSave, restoreSnapshot,
} = useGraphData(); } = useGraphData();
// ── Canvas sizing ──────────────────────────────────────────────────────────── // ── Canvas sizing ────────────────────────────────────────────────────────────
@@ -109,7 +94,6 @@ const KnowledgeGraph = () => {
// ── D3 force graph ─────────────────────────────────────────────────────────── // ── D3 force graph ───────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (viewMode !== 'graph') return;
if (!svgRef.current || topics.length === 0) return; if (!svgRef.current || topics.length === 0) return;
const { width, height } = dimensions; const { width, height } = dimensions;
@@ -252,7 +236,7 @@ const KnowledgeGraph = () => {
}); });
return () => simulation.stop(); return () => simulation.stop();
}, [dimensions, topics, relations, showExcludeNodes, viewMode]); }, [dimensions, topics, relations, showExcludeNodes]);
// ── Pan/zoom canvas to a node ──────────────────────────────────────────────── // ── Pan/zoom canvas to a node ────────────────────────────────────────────────
@@ -317,7 +301,6 @@ const KnowledgeGraph = () => {
setIsAnalyzing(true); setIsAnalyzing(true);
setAnalyzeError(null); setAnalyzeError(null);
setAnalyzeNotice(null);
try { try {
const [currentTopics, currentRelations] = await Promise.all([ const [currentTopics, currentRelations] = await Promise.all([
@@ -327,16 +310,13 @@ const KnowledgeGraph = () => {
const tier = scope === 'full' ? 'reasoning' : 'standard'; const tier = scope === 'full' ? 'reasoning' : 'standard';
// For full + relevance scopes the model needs to see relevance_locked so // For relevance scope, include relevance_locked so the model can skip those.
// it can honor the "do not touch protected topics" rule. (relations-scope
// only emits newRelations, so the flag is irrelevant there.)
const sendLocked = scope === 'full' || scope === 'relevance';
const compactTopics = currentTopics.map(t => ({ const compactTopics = currentTopics.map(t => ({
id: t.id, id: t.id,
label: t.label, label: t.label,
type: t.type, type: t.type,
learning_relevance: t.learning_relevance, learning_relevance: t.learning_relevance,
...(sendLocked && t.relevance_locked ? { relevance_locked: true } : {}), ...(scope === 'relevance' && t.relevance_locked ? { relevance_locked: true } : {}),
})); }));
const compactRelations = currentRelations.map(({ source, target, type }) => ({ const compactRelations = currentRelations.map(({ source, target, type }) => ({
source, target, type, source, target, type,
@@ -352,26 +332,8 @@ const KnowledgeGraph = () => {
maxTokens: scope === 'full' ? 4096 : 2048, maxTokens: scope === 'full' ? 4096 : 2048,
}); });
const rawActions = llmResult.toolUses[0]?.input; const actions = llmResult.toolUses[0]?.input;
if (!rawActions) throw new Error('Graph analysis did not emit a tool result.'); if (!actions) throw new Error('Graph analysis did not emit a tool result.');
// ── Safety guard ─────────────────────────────────────────────────────
// Strip merges/deletions that target excluded or locked topics. Excluded
// topics are reference material kept on purpose for R42; locked topics
// are admin-pinned. The AI may suggest removing them anyway — we drop
// those suggestions before they reach bulkSave.
const { filtered: actions, dropped } = filterAiActions(currentTopics, rawActions);
const droppedCount = dropped.deletions.length + dropped.merges.length;
if (droppedCount > 0) {
// Visible feedback so the admin knows the AI tried to touch protected rows.
console.warn(
`[graph guard] Blocked ${droppedCount} destructive action(s) on protected topics`,
dropped,
);
setAnalyzeNotice(
`Guard blocked ${droppedCount} destructive AI action${droppedCount === 1 ? '' : 's'} on excluded or locked topics. ${dropped.deletions.length} deletion${dropped.deletions.length === 1 ? '' : 's'}, ${dropped.merges.length} merge${dropped.merges.length === 1 ? '' : 's'}.`,
);
}
let updatedTopics = [...currentTopics]; let updatedTopics = [...currentTopics];
let updatedRelations = [...currentRelations]; let updatedRelations = [...currentRelations];
@@ -434,58 +396,17 @@ const KnowledgeGraph = () => {
return ( return (
<div className="relative w-full h-full flex flex-col md:flex-row"> <div className="relative w-full h-full flex flex-col md:flex-row">
{/* Main view: graph canvas or table */} {/* D3 canvas */}
<div className="flex-1 flex flex-col border-r border-bg-warm min-w-0"> <div
{/* View-mode toggle */} ref={wrapperRef}
<div className="flex items-center gap-1 px-3 py-2 border-b border-bg-warm bg-paper"> className="flex-1 h-[400px] md:h-full cursor-grab active:cursor-grabbing border-r border-bg-warm"
<button >
onClick={() => setViewMode('graph')} {topics.length === 0 ? (
className={`flex items-center gap-1.5 px-3 py-1 text-xs rounded-[var(--r-sm)] transition-colors ${ <div className="h-full flex items-center justify-center text-fg-muted p-8 text-center">
viewMode === 'graph' ? 'bg-bg-warm text-teal font-medium' : 'text-fg-muted hover:bg-bg-warm/50' No knowledge graph data yet. Upload source material in the Sources tab first.
}`}
title="Force-directed graph view"
>
<Network size={13} /> Graph
</button>
<button
onClick={() => setViewMode('table')}
className={`flex items-center gap-1.5 px-3 py-1 text-xs rounded-[var(--r-sm)] transition-colors ${
viewMode === 'table' ? 'bg-bg-warm text-teal font-medium' : 'text-fg-muted hover:bg-bg-warm/50'
}`}
title="Sortable table with bulk edit"
>
<Table2 size={13} /> Table
</button>
</div>
{viewMode === 'graph' ? (
<div
ref={wrapperRef}
className="flex-1 h-[400px] md:h-full cursor-grab active:cursor-grabbing"
>
{topics.length === 0 ? (
<div className="h-full flex items-center justify-center text-fg-muted p-8 text-center">
No knowledge graph data yet. Upload source material in the Sources tab first.
</div>
) : (
<svg ref={svgRef} className="w-full h-full" />
)}
</div> </div>
) : ( ) : (
<div className="flex-1 min-h-[400px] overflow-hidden"> <svg ref={svgRef} className="w-full h-full" />
{topics.length === 0 ? (
<div className="h-full flex items-center justify-center text-fg-muted p-8 text-center">
No knowledge graph data yet. Upload source material in the Sources tab first.
</div>
) : (
<GraphTable
topics={topics}
relations={relations}
onBulkUpdate={bulkUpdateTopics}
onSelectNode={setSelectedNode}
/>
)}
</div>
)} )}
</div> </div>
@@ -494,11 +415,9 @@ const KnowledgeGraph = () => {
<GraphControls <GraphControls
showExcludeNodes={showExcludeNodes} showExcludeNodes={showExcludeNodes}
onShowExcludeChange={setShowExcludeNodes} onShowExcludeChange={setShowExcludeNodes}
excludedCount={topics.filter(t => t.learning_relevance === 'exclude').length}
onAnalyze={analyzeGraph} onAnalyze={analyzeGraph}
isAnalyzing={isAnalyzing} isAnalyzing={isAnalyzing}
analyzeError={analyzeError} analyzeError={analyzeError}
analyzeNotice={analyzeNotice}
disabled={topics.length === 0} disabled={topics.length === 0}
onApplied={reload} onApplied={reload}
snapshotMeta={snapshotMeta} snapshotMeta={snapshotMeta}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { RotateCcw, RefreshCw, AlertCircle, ChevronDown, ShieldCheck } from 'lucide-react'; import { RotateCcw, RefreshCw, AlertCircle, ChevronDown } from 'lucide-react';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
import SuggestionsQueue from '../SuggestionsQueue'; import SuggestionsQueue from '../SuggestionsQueue';
@@ -24,11 +24,9 @@ const SCOPE_OPTIONS = [
export default function GraphControls({ export default function GraphControls({
showExcludeNodes, showExcludeNodes,
onShowExcludeChange, onShowExcludeChange,
excludedCount = 0,
onAnalyze, onAnalyze,
isAnalyzing, isAnalyzing,
analyzeError, analyzeError,
analyzeNotice,
disabled, disabled,
onApplied, onApplied,
snapshotMeta, snapshotMeta,
@@ -52,14 +50,7 @@ export default function GraphControls({
onChange={e => onShowExcludeChange(e.target.checked)} onChange={e => onShowExcludeChange(e.target.checked)}
className="rounded bg-bg-warm border-transparent focus:ring-0 text-teal" className="rounded bg-bg-warm border-transparent focus:ring-0 text-teal"
/> />
<span> Show Excluded Nodes (Reference Material)
Show Excluded Nodes (Reference Material)
{excludedCount > 0 && (
<span className="ml-1 text-xs text-fg-muted/80">
· {excludedCount} {showExcludeNodes ? 'visible' : 'hidden'}
</span>
)}
</span>
</label> </label>
<div className="space-y-2"> <div className="space-y-2">
@@ -115,16 +106,6 @@ export default function GraphControls({
{analyzeError} {analyzeError}
</p> </p>
)} )}
{analyzeNotice && (
<p
className="text-xs text-teal flex items-start gap-1 bg-teal/5 border border-teal/20 rounded-[var(--r-sm)] p-2"
title="The AI suggested removing excluded/locked topics. Those suggestions were dropped client-side before saving."
>
<ShieldCheck size={14} className="shrink-0 mt-0.5" />
{analyzeNotice}
</p>
)}
</div> </div>
<SuggestionsQueue onApplied={onApplied} /> <SuggestionsQueue onApplied={onApplied} />

View File

@@ -1,466 +0,0 @@
import { useMemo, useState } from 'react';
import { ArrowUp, ArrowDown, ArrowUpDown, X, Search, ChevronRight, ChevronDown, Lock, Unlock } from 'lucide-react';
import Button from '../../ui/Button';
const TYPES = ['concept', 'role', 'process'];
const RELEVANCE = ['core', 'standard', 'peripheral', 'exclude'];
const SORTABLE = ['label', 'type', 'learning_relevance', 'theme', 'complexity_weight', 'difficulty', 'relations'];
const GROUP_OPTIONS = [
{ value: 'none', label: 'No grouping' },
{ value: 'type', label: 'Type' },
{ value: 'learning_relevance', label: 'Relevance' },
{ value: 'theme', label: 'Theme' },
{ value: 'difficulty', label: 'Difficulty' },
];
const RELEVANCE_RANK = { core: 0, standard: 1, peripheral: 2, exclude: 3 };
const DIFFICULTY_RANK = { beginner: 0, intermediate: 1, advanced: 2 };
function compareValues(a, b, key) {
if (key === 'learning_relevance') {
return (RELEVANCE_RANK[a] ?? 99) - (RELEVANCE_RANK[b] ?? 99);
}
if (key === 'difficulty') {
return (DIFFICULTY_RANK[a] ?? 99) - (DIFFICULTY_RANK[b] ?? 99);
}
if (typeof a === 'number' && typeof b === 'number') return a - b;
return String(a ?? '').localeCompare(String(b ?? ''), undefined, { sensitivity: 'base' });
}
function SortIcon({ active, dir }) {
if (!active) return <ArrowUpDown size={11} className="text-fg-muted/60 inline-block ml-1" />;
return dir === 'asc'
? <ArrowUp size={11} className="text-teal inline-block ml-1" />
: <ArrowDown size={11} className="text-teal inline-block ml-1" />;
}
/**
* Table view of the knowledge graph.
*
* Supports sort (per column), grouping (per key), free-text label filter,
* multi-row selection, and bulk-change of type / learning_relevance / lock.
*
* Persistence is delegated to onBulkUpdate(ids, patch). The parent (KnowledgeGraph)
* provides this from useGraphData.bulkUpdateTopics.
*/
export default function GraphTable({
topics,
relations,
onBulkUpdate,
onSelectNode,
}) {
const [sortKey, setSortKey] = useState('label');
const [sortDir, setSortDir] = useState('asc');
const [groupBy, setGroupBy] = useState('none');
const [query, setQuery] = useState('');
const [selectedIds, setSelectedIds] = useState(() => new Set());
const [collapsed, setCollapsed] = useState(() => new Set());
// Bulk-action form state
const [bulkType, setBulkType] = useState('');
const [bulkRelevance, setBulkRelevance] = useState('');
const [bulkLockOnRelevance, setBulkLockOnRelevance] = useState(true);
const [isApplying, setIsApplying] = useState(false);
const [feedback, setFeedback] = useState(null);
// Pre-compute relation counts per topic so the column is cheap to render.
const relationCount = useMemo(() => {
const counts = new Map();
for (const r of relations) {
counts.set(r.source, (counts.get(r.source) || 0) + 1);
counts.set(r.target, (counts.get(r.target) || 0) + 1);
}
return counts;
}, [relations]);
// Filter + sort
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
const rows = topics
.map(t => ({
...t,
learning_relevance: t.learning_relevance || 'standard',
type: t.type || 'concept',
difficulty: t.difficulty || 'intermediate',
complexity_weight: t.complexity_weight ?? 3,
theme: t.theme || '',
relations: relationCount.get(t.id) || 0,
}))
.filter(t => !q || t.label?.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q));
const sorted = [...rows].sort((a, b) => {
const cmp = compareValues(a[sortKey], b[sortKey], sortKey);
return sortDir === 'asc' ? cmp : -cmp;
});
return sorted;
}, [topics, relationCount, query, sortKey, sortDir]);
// Group rows (flat array if groupBy === 'none')
const groups = useMemo(() => {
if (groupBy === 'none') return [{ key: '__all__', label: null, rows: filtered }];
const map = new Map();
for (const t of filtered) {
const k = t[groupBy] || '—';
if (!map.has(k)) map.set(k, []);
map.get(k).push(t);
}
return [...map.entries()]
.sort(([a], [b]) => compareValues(a, b, groupBy))
.map(([key, rows]) => ({ key, label: String(key), rows }));
}, [filtered, groupBy]);
// ── Selection helpers ─────────────────────────────────────────────────────
const allVisibleIds = useMemo(() => filtered.map(t => t.id), [filtered]);
const visibleSelected = useMemo(
() => allVisibleIds.filter(id => selectedIds.has(id)).length,
[allVisibleIds, selectedIds],
);
const allVisibleChecked = visibleSelected === allVisibleIds.length && allVisibleIds.length > 0;
const someVisibleChecked = visibleSelected > 0 && !allVisibleChecked;
const toggleOne = (id) => {
setSelectedIds(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const toggleAllVisible = () => {
setSelectedIds(prev => {
const next = new Set(prev);
if (allVisibleChecked) {
for (const id of allVisibleIds) next.delete(id);
} else {
for (const id of allVisibleIds) next.add(id);
}
return next;
});
};
const toggleGroup = (groupRows) => {
setSelectedIds(prev => {
const next = new Set(prev);
const ids = groupRows.map(r => r.id);
const allOn = ids.every(id => next.has(id));
for (const id of ids) (allOn ? next.delete(id) : next.add(id));
return next;
});
};
const clearSelection = () => setSelectedIds(new Set());
// ── Sorting ───────────────────────────────────────────────────────────────
const onSort = (key) => {
if (!SORTABLE.includes(key)) return;
if (sortKey === key) {
setSortDir(d => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortKey(key);
setSortDir('asc');
}
};
// ── Bulk actions ──────────────────────────────────────────────────────────
const runBulk = async (patch, label) => {
if (selectedIds.size === 0) return;
setIsApplying(true);
setFeedback(null);
try {
const n = await onBulkUpdate([...selectedIds], patch);
setFeedback(`${label}${n} topic${n === 1 ? '' : 's'} updated.`);
} catch (e) {
setFeedback(`Failed: ${e.message || 'unknown error'}`);
} finally {
setIsApplying(false);
}
};
const applyBulkType = () => {
if (!bulkType) return;
runBulk({ type: bulkType }, `Type → ${bulkType}`);
};
const applyBulkRelevance = () => {
if (!bulkRelevance) return;
const patch = { learning_relevance: bulkRelevance };
if (bulkLockOnRelevance) patch.relevance_locked = true;
runBulk(patch, `Relevance → ${bulkRelevance}${bulkLockOnRelevance ? ' (locked)' : ''}`);
};
const applyLock = (locked) => {
runBulk({ relevance_locked: locked }, locked ? 'Locked relevance' : 'Unlocked relevance');
};
// ── Render ────────────────────────────────────────────────────────────────
const totalSel = selectedIds.size;
const isCollapsed = (key) => collapsed.has(key);
const toggleCollapse = (key) => {
setCollapsed(prev => {
const next = new Set(prev);
next.has(key) ? next.delete(key) : next.add(key);
return next;
});
};
return (
<div className="h-full flex flex-col bg-paper">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3 p-3 border-b border-bg-warm bg-bg">
<div className="relative">
<Search size={14} className="absolute left-2 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Filter by label or description…"
className="pl-7 pr-3 py-1.5 text-sm border border-bg-warm rounded-[var(--r-sm)] bg-paper w-64"
/>
</div>
<label className="text-xs text-fg-muted flex items-center gap-1.5">
Group by
<select
value={groupBy}
onChange={e => setGroupBy(e.target.value)}
className="border border-bg-warm rounded-[var(--r-sm)] px-2 py-1 text-xs bg-paper"
>
{GROUP_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</label>
<span className="text-xs text-fg-muted ml-auto">
{filtered.length} of {topics.length} topics
{totalSel > 0 && <> · <span className="text-teal font-medium">{totalSel} selected</span></>}
</span>
</div>
{/* Bulk action bar */}
{totalSel > 0 && (
<div className="flex flex-wrap items-center gap-2 p-3 border-b border-bg-warm bg-teal/5">
<span className="text-xs font-medium text-teal mr-1">Bulk:</span>
{/* Type */}
<div className="flex items-center gap-1">
<select
value={bulkType}
onChange={e => setBulkType(e.target.value)}
className="border border-bg-warm rounded-[var(--r-sm)] px-2 py-1 text-xs bg-paper"
disabled={isApplying}
>
<option value=""> set type </option>
{TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<Button
onClick={applyBulkType}
disabled={!bulkType || isApplying}
className="px-2 py-1 text-xs"
>Apply</Button>
</div>
{/* Relevance */}
<div className="flex items-center gap-1">
<select
value={bulkRelevance}
onChange={e => setBulkRelevance(e.target.value)}
className="border border-bg-warm rounded-[var(--r-sm)] px-2 py-1 text-xs bg-paper"
disabled={isApplying}
>
<option value=""> set relevance </option>
{RELEVANCE.map(r => <option key={r} value={r}>{r}</option>)}
</select>
<label className="flex items-center gap-1 text-xs text-fg-muted cursor-pointer">
<input
type="checkbox"
checked={bulkLockOnRelevance}
onChange={e => setBulkLockOnRelevance(e.target.checked)}
className="rounded bg-bg-warm border-transparent focus:ring-0 text-teal"
/>
lock
</label>
<Button
onClick={applyBulkRelevance}
disabled={!bulkRelevance || isApplying}
className="px-2 py-1 text-xs"
>Apply</Button>
</div>
{/* Lock toggles */}
<button
onClick={() => applyLock(true)}
disabled={isApplying}
className="flex items-center gap-1 px-2 py-1 text-xs border border-bg-warm rounded-[var(--r-sm)] bg-paper hover:bg-bg-warm/50 disabled:opacity-40"
title="Lock relevance for selected topics"
>
<Lock size={11} /> Lock
</button>
<button
onClick={() => applyLock(false)}
disabled={isApplying}
className="flex items-center gap-1 px-2 py-1 text-xs border border-bg-warm rounded-[var(--r-sm)] bg-paper hover:bg-bg-warm/50 disabled:opacity-40"
title="Unlock relevance for selected topics"
>
<Unlock size={11} /> Unlock
</button>
<button
onClick={clearSelection}
className="ml-auto flex items-center gap-1 px-2 py-1 text-xs text-fg-muted hover:text-fg"
>
<X size={12} /> Clear
</button>
{feedback && (
<span className="basis-full text-xs text-fg-muted">{feedback}</span>
)}
</div>
)}
{/* Table */}
<div className="flex-1 overflow-auto">
<table className="w-full text-sm">
<thead className="bg-bg sticky top-0 z-10">
<tr className="text-left border-b border-bg-warm">
<th className="px-3 py-2 w-8">
<input
type="checkbox"
checked={allVisibleChecked}
ref={el => { if (el) el.indeterminate = someVisibleChecked; }}
onChange={toggleAllVisible}
className="rounded bg-bg-warm border-transparent focus:ring-0 text-teal"
title={allVisibleChecked ? 'Deselect all visible' : 'Select all visible'}
/>
</th>
{[
['label', 'Label'],
['type', 'Type'],
['learning_relevance', 'Relevance'],
['theme', 'Theme'],
['difficulty', 'Difficulty'],
['complexity_weight', 'Weight'],
['relations', 'Rel.'],
].map(([key, label]) => (
<th
key={key}
onClick={() => onSort(key)}
className="px-3 py-2 text-xs uppercase tracking-wider text-fg-muted cursor-pointer select-none hover:text-teal"
>
{label}<SortIcon active={sortKey === key} dir={sortDir} />
</th>
))}
<th className="px-3 py-2 text-xs uppercase tracking-wider text-fg-muted">Lock</th>
</tr>
</thead>
<tbody>
{groups.map(group => (
<Group
key={group.key}
group={group}
groupBy={groupBy}
collapsed={isCollapsed(group.key)}
onToggleCollapse={() => toggleCollapse(group.key)}
onToggleGroup={() => toggleGroup(group.rows)}
selectedIds={selectedIds}
onToggleOne={toggleOne}
onSelectNode={onSelectNode}
/>
))}
{filtered.length === 0 && (
<tr>
<td colSpan={9} className="px-3 py-12 text-center text-fg-muted text-sm">
No topics match the current filter.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
function Group({
group, groupBy, collapsed, onToggleCollapse, onToggleGroup,
selectedIds, onToggleOne, onSelectNode,
}) {
const groupHeader = groupBy !== 'none' && group.label != null;
const groupSelectedCount = group.rows.filter(r => selectedIds.has(r.id)).length;
const allGroupSelected = groupSelectedCount === group.rows.length && group.rows.length > 0;
const someGroupSelected = groupSelectedCount > 0 && !allGroupSelected;
return (
<>
{groupHeader && (
<tr className="bg-bg-warm/40 border-b border-bg-warm">
<td className="px-3 py-1.5 w-8">
<input
type="checkbox"
checked={allGroupSelected}
ref={el => { if (el) el.indeterminate = someGroupSelected; }}
onChange={onToggleGroup}
className="rounded bg-bg-warm border-transparent focus:ring-0 text-teal"
/>
</td>
<td colSpan={8} className="px-3 py-1.5 text-xs font-medium text-fg-muted">
<button
onClick={onToggleCollapse}
className="inline-flex items-center gap-1 text-fg-muted hover:text-teal"
>
{collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
<span className="uppercase tracking-wider">{group.label}</span>
<span className="text-fg-muted/70 normal-case">· {group.rows.length}</span>
</button>
</td>
</tr>
)}
{!collapsed && group.rows.map(t => (
<Row
key={t.id}
topic={t}
checked={selectedIds.has(t.id)}
onToggle={() => onToggleOne(t.id)}
onSelectNode={onSelectNode}
/>
))}
</>
);
}
function Row({ topic, checked, onToggle, onSelectNode }) {
return (
<tr
className={`border-b border-bg-warm/60 hover:bg-bg-warm/30 transition-colors ${checked ? 'bg-teal/5' : ''}`}
>
<td className="px-3 py-2 w-8">
<input
type="checkbox"
checked={checked}
onChange={onToggle}
className="rounded bg-bg-warm border-transparent focus:ring-0 text-teal"
/>
</td>
<td className="px-3 py-2">
<button
onClick={() => onSelectNode?.(topic)}
className="text-left hover:text-teal underline-offset-2 hover:underline"
title={topic.description || ''}
>
{topic.label}
</button>
</td>
<td className="px-3 py-2 font-mono text-xs">{topic.type}</td>
<td className="px-3 py-2 font-mono text-xs">{topic.learning_relevance}</td>
<td className="px-3 py-2 text-xs text-fg-muted">{topic.theme || '—'}</td>
<td className="px-3 py-2 text-xs">{topic.difficulty}</td>
<td className="px-3 py-2 text-xs text-center">{topic.complexity_weight}</td>
<td className="px-3 py-2 text-xs text-center text-fg-muted">{topic.relations}</td>
<td className="px-3 py-2 text-center">
{topic.relevance_locked
? <Lock size={12} className="text-teal inline" />
: <Unlock size={12} className="text-fg-muted/40 inline" />}
</td>
</tr>
);
}

View File

@@ -104,23 +104,6 @@ export function useGraphData() {
return true; return true;
}, []); }, []);
/**
* Apply the same patch to many topics in one call. `ids` is the set of
* topic IDs to update; `patch` is the partial-topic to merge into each one
* (e.g. `{ type: 'process' }` or `{ learning_relevance: 'core', relevance_locked: true }`).
*
* Persists each topic via db.upsertTopic in parallel and mirrors into state.
* Returns the number of topics actually written (selected ∩ existing).
*/
const bulkUpdateTopics = useCallback(async (ids, patch) => {
const idSet = new Set(ids);
const targets = topicsRef.current.filter(t => idSet.has(t.id));
const updated = targets.map(t => ({ ...t, ...patch }));
await Promise.all(updated.map(t => db.upsertTopic(t)));
setTopics(prev => prev.map(t => (idSet.has(t.id) ? { ...t, ...patch } : t)));
return updated.length;
}, []);
/** Remove a specific (source, target, type) relation triple. */ /** Remove a specific (source, target, type) relation triple. */
const removeRelation = useCallback(async (source, target, type) => { const removeRelation = useCallback(async (source, target, type) => {
await db.removeRelation(source, target, type); await db.removeRelation(source, target, type);
@@ -182,7 +165,6 @@ export function useGraphData() {
snapshotMeta, snapshotMeta,
reload, reload,
updateTopic, updateTopic,
bulkUpdateTopics,
deleteTopic, deleteTopic,
addRelation, addRelation,
removeRelation, removeRelation,

View File

@@ -1,97 +0,0 @@
import { describe, expect, it } from 'vitest';
import { validateSchedule } from '../curriculumService';
const week = (n, theme, topicIds, duration = 30) => ({
week_number: n,
theme,
topic_ids: topicIds,
estimated_duration: duration,
week_rationale: 'r',
});
const makeTopic = (id, theme) => ({
id,
theme,
type: 'concept',
learning_relevance: 'include',
});
const buildScheduleFromTopics = (topics) => {
const ids = topics.map(t => t.id);
return Array.from({ length: 26 }, (_, i) => {
const chunk = ids.slice(i * 2, i * 2 + 2);
return week(i + 1, topics[i * 2]?.theme || 'General', chunk.length ? chunk : [ids[0]]);
});
};
describe('validateSchedule', () => {
it('does not warn about missing themes when merging was required (themes_kb > 26)', () => {
const topics = [];
for (let i = 0; i < 60; i++) topics.push(makeTopic(`t${i}`, `Theme ${i % 30}`));
const schedule = buildScheduleFromTopics(topics);
const result = validateSchedule(schedule, topics);
expect(result.valid).toBe(true);
const themeWarning = result.warnings.find(w => w.includes('not scheduled'));
expect(themeWarning).toBeUndefined();
});
it('warns about missing themes only when merging was not required', () => {
const topics = [];
for (let i = 0; i < 10; i++) topics.push(makeTopic(`t${i}`, `Theme ${i}`));
const schedule = Array.from({ length: 26 }, (_, i) =>
week(i + 1, 'Theme 0', ['t0'])
);
const result = validateSchedule(schedule, topics);
const themeWarning = result.warnings.find(w => w.includes('not scheduled'));
expect(themeWarning).toBeDefined();
expect(themeWarning).toMatch(/9 theme/);
});
it('warns when learning topics are absent from every week (real coverage gap)', () => {
const topics = [
makeTopic('t1', 'A'),
makeTopic('t2', 'A'),
makeTopic('t3', 'B'),
];
const schedule = Array.from({ length: 26 }, (_, i) =>
week(i + 1, 'A', ['t1'])
);
const result = validateSchedule(schedule, topics);
const coverageWarning = result.warnings.find(w => w.includes('not covered'));
expect(coverageWarning).toBeDefined();
expect(coverageWarning).toMatch(/2 learning topic/);
});
it('ignores topics with type=fact or learning_relevance=exclude in coverage', () => {
const topics = [
makeTopic('t1', 'A'),
{ ...makeTopic('t2', 'A'), type: 'fact' },
{ ...makeTopic('t3', 'B'), learning_relevance: 'exclude' },
];
const schedule = Array.from({ length: 26 }, (_, i) =>
week(i + 1, 'A', ['t1'])
);
const result = validateSchedule(schedule, topics);
expect(result.warnings.find(w => w.includes('not covered'))).toBeUndefined();
});
it('flags hard errors: wrong week count, bad duration, unknown topic_id', () => {
const topics = [makeTopic('t1', 'A')];
const schedule = [week(1, 'A', ['t1'])];
const result = validateSchedule(schedule, topics);
expect(result.valid).toBe(false);
expect(result.errors[0]).toMatch(/exactly 26 weeks/);
const fullSchedule = Array.from({ length: 26 }, (_, i) => week(i + 1, 'A', ['t1']));
fullSchedule[2].estimated_duration = 5;
fullSchedule[5].topic_ids = ['missing-id'];
const result2 = validateSchedule(fullSchedule, topics);
expect(result2.valid).toBe(false);
expect(result2.errors.some(e => e.includes('out-of-range duration'))).toBe(true);
expect(result2.errors.some(e => e.includes('unknown topic_id'))).toBe(true);
});
});

View File

@@ -1,125 +0,0 @@
import { describe, expect, it } from 'vitest';
import { isProtectedTopic, filterAiActions } from '../graphGuard';
const topic = (id, extras = {}) => ({
id,
label: `Topic ${id}`,
type: 'concept',
learning_relevance: 'standard',
relevance_locked: false,
...extras,
});
describe('isProtectedTopic', () => {
it('flags excluded topics', () => {
expect(isProtectedTopic(topic('a', { learning_relevance: 'exclude' }))).toBe(true);
});
it('flags locked topics', () => {
expect(isProtectedTopic(topic('a', { relevance_locked: true }))).toBe(true);
});
it('flags topics that are both excluded and locked', () => {
expect(
isProtectedTopic(topic('a', { learning_relevance: 'exclude', relevance_locked: true })),
).toBe(true);
});
it('does not flag standard / core / peripheral topics', () => {
expect(isProtectedTopic(topic('a', { learning_relevance: 'core' }))).toBe(false);
expect(isProtectedTopic(topic('a', { learning_relevance: 'standard' }))).toBe(false);
expect(isProtectedTopic(topic('a', { learning_relevance: 'peripheral' }))).toBe(false);
});
it('treats null/undefined as not protected', () => {
expect(isProtectedTopic(null)).toBe(false);
expect(isProtectedTopic(undefined)).toBe(false);
});
});
describe('filterAiActions', () => {
const topics = [
topic('safe'),
topic('excluded', { learning_relevance: 'exclude' }),
topic('locked', { relevance_locked: true }),
topic('both', { learning_relevance: 'exclude', relevance_locked: true }),
topic('other'),
];
it('drops deletions that target excluded topics', () => {
const { filtered, dropped } = filterAiActions(topics, {
deletions: ['safe', 'excluded', 'other'],
merges: [],
});
expect(filtered.deletions).toEqual(['safe', 'other']);
expect(dropped.deletions).toEqual(['excluded']);
});
it('drops deletions that target locked topics', () => {
const { filtered, dropped } = filterAiActions(topics, {
deletions: ['locked'],
merges: [],
});
expect(filtered.deletions).toEqual([]);
expect(dropped.deletions).toEqual(['locked']);
});
it('drops merges whose deleteId is protected', () => {
const merges = [
{ keepId: 'safe', deleteId: 'other' }, // ok
{ keepId: 'safe', deleteId: 'excluded' }, // drop — excluded
{ keepId: 'other', deleteId: 'locked' }, // drop — locked
{ keepId: 'safe', deleteId: 'both' }, // drop — both flags
];
const { filtered, dropped } = filterAiActions(topics, { merges, deletions: [] });
expect(filtered.merges).toEqual([{ keepId: 'safe', deleteId: 'other' }]);
expect(dropped.merges).toHaveLength(3);
expect(dropped.merges.map(m => m.deleteId)).toEqual(['excluded', 'locked', 'both']);
});
it('keeps merges where a protected topic is the keepId (canonical survivor)', () => {
const merges = [
{ keepId: 'excluded', deleteId: 'safe' }, // ok — protected is survivor
{ keepId: 'locked', deleteId: 'other' }, // ok — protected is survivor
];
const { filtered, dropped } = filterAiActions(topics, { merges, deletions: [] });
expect(filtered.merges).toEqual(merges);
expect(dropped.merges).toEqual([]);
});
it('passes through other action keys untouched', () => {
const actions = {
deletions: [],
merges: [],
newRelations: [{ source: 'a', target: 'b', type: 'related_to' }],
relevanceUpdates: [{ id: 'safe', learning_relevance: 'peripheral' }],
};
const { filtered } = filterAiActions(topics, actions);
expect(filtered.newRelations).toEqual(actions.newRelations);
expect(filtered.relevanceUpdates).toEqual(actions.relevanceUpdates);
});
it('tolerates missing actions keys', () => {
const { filtered, dropped } = filterAiActions(topics, {});
expect(filtered.deletions).toEqual([]);
expect(filtered.merges).toEqual([]);
expect(dropped.deletions).toEqual([]);
expect(dropped.merges).toEqual([]);
});
it('tolerates null actions', () => {
const { filtered, dropped } = filterAiActions(topics, null);
expect(filtered.deletions).toEqual([]);
expect(filtered.merges).toEqual([]);
expect(dropped.deletions).toEqual([]);
expect(dropped.merges).toEqual([]);
});
it('drops references to unknown ids by treating them as not protected (delete pass-through)', () => {
// If the AI hallucinates an id, the deletion is harmless downstream (no
// topic by that id exists). We do NOT block on unknown ids — that would
// mask real bugs. Verify pass-through behavior.
const { filtered } = filterAiActions(topics, { deletions: ['ghost-id'], merges: [] });
expect(filtered.deletions).toEqual(['ghost-id']);
});
});

View File

@@ -60,16 +60,8 @@ export function buildThemeTopicMap(topics) {
/** /**
* Validates a 26-week schedule against the provided topics. * Validates a 26-week schedule against the provided topics.
* * Checks for exactly 26 weeks, duration range, theme existence, and topic existence.
* Warning policy: * Returns { valid: boolean, errors: string[] }
* - Theme names not appearing as a week label are NOT a warning when the KB
* has more than 26 themes — the AI is required to merge in that case, and
* topic_ids from the merged themes are carried through under the chosen
* week label. We only warn about missing themes when merging wasn't needed.
* - Real coverage is measured on TOPICS: a topic that exists in the KB but
* is absent from every week's topic_ids is a genuine gap and gets a warning.
*
* Returns { valid: boolean, errors: string[], warnings: string[] }
*/ */
export function validateSchedule(schedule, topics) { export function validateSchedule(schedule, topics) {
const errors = []; // Hard errors — schedule is unusable const errors = []; // Hard errors — schedule is unusable
@@ -79,13 +71,10 @@ export function validateSchedule(schedule, topics) {
errors.push(`Schedule must contain exactly 26 weeks. Found ${schedule?.length || 0}.`); errors.push(`Schedule must contain exactly 26 weeks. Found ${schedule?.length || 0}.`);
} }
const learningTopics = topics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude'); const validThemes = new Set(topics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude').map(t => t.theme || 'General'));
const validThemes = new Set(learningTopics.map(t => t.theme || 'General'));
const validTopicIds = new Set(topics.map(t => t.id)); const validTopicIds = new Set(topics.map(t => t.id));
const learningTopicIds = new Set(learningTopics.map(t => t.id));
const scheduledThemes = new Set(); const scheduledThemes = new Set();
const scheduledTopicIds = new Set();
for (let i = 0; i < (schedule || []).length; i++) { for (let i = 0; i < (schedule || []).length; i++) {
const week = schedule[i]; const week = schedule[i];
@@ -97,7 +86,7 @@ export function validateSchedule(schedule, topics) {
} }
// Allow AI-merged theme names — only flag truly unknown themes as warnings // Allow AI-merged theme names — only flag truly unknown themes as warnings
scheduledThemes.add(week.theme); scheduledThemes.add(week.theme);
if (!week.topic_ids || week.topic_ids.length === 0) { if (!week.topic_ids || week.topic_ids.length === 0) {
errors.push(`Week ${week.week_number} has no topic_ids.`); errors.push(`Week ${week.week_number} has no topic_ids.`);
} else { } else {
@@ -105,30 +94,20 @@ export function validateSchedule(schedule, topics) {
if (!validTopicIds.has(tId)) { if (!validTopicIds.has(tId)) {
errors.push(`Week ${week.week_number} references unknown topic_id: ${tId}`); errors.push(`Week ${week.week_number} references unknown topic_id: ${tId}`);
} }
scheduledTopicIds.add(tId);
} }
} }
} }
// Theme coverage — only warn when merging wasn't required. When themes_kb > 26 // Theme coverage — soft warnings, not hard errors
// the AI is *required* to merge, so absent theme labels are expected. // When there are more themes than 26 weeks, the AI must merge some.
if (validThemes.size <= 26) { const missingThemes = [];
const missingThemes = []; for (const t of validThemes) {
for (const t of validThemes) { if (!scheduledThemes.has(t)) {
if (!scheduledThemes.has(t)) { missingThemes.push(t);
missingThemes.push(t);
}
}
if (missingThemes.length > 0) {
warnings.push(`${missingThemes.length} theme(s) not scheduled: ${missingThemes.join(', ')}`);
} }
} }
if (missingThemes.length > 0) {
// Topic coverage — the real signal. Topics carried under a merged theme are warnings.push(`${missingThemes.length} theme(s) not directly scheduled (topics may appear under merged themes): ${missingThemes.join(', ')}`);
// still covered; topics absent from every week are not.
const missingTopicCount = [...learningTopicIds].filter(id => !scheduledTopicIds.has(id)).length;
if (missingTopicCount > 0) {
warnings.push(`${missingTopicCount} learning topic(s) not covered by any week of the schedule.`);
} }
return { valid: errors.length === 0, errors, warnings }; return { valid: errors.length === 0, errors, warnings };
@@ -245,9 +224,7 @@ Rules:
throw new Error(`Generated schedule failed validation after retry:\n- ${validationResult.errors.join('\n- ')}`); throw new Error(`Generated schedule failed validation after retry:\n- ${validationResult.errors.join('\n- ')}`);
} }
// Log warnings but don't fail. With themes_kb > 26 the AI must merge themes, // Log warnings but don't fail
// so most "missing theme" noise is filtered upstream in validateSchedule —
// anything that lands here is a genuine coverage gap worth surfacing.
if (validationResult.warnings.length > 0) { if (validationResult.warnings.length > 0) {
console.warn('[Curriculum] Schedule generated with warnings:', validationResult.warnings); console.warn('[Curriculum] Schedule generated with warnings:', validationResult.warnings);
} }
@@ -373,7 +350,7 @@ export async function getYearProgress(userId, personalWeekNumber) {
let completed = 0; let completed = 0;
for (let w = cycleStartWeek; w <= cycleEndWeek; w++) { for (let w = cycleStartWeek; w <= cycleEndWeek; w++) {
const done = await db.getLearnDone(userId, w); const done = await db.getThemeSessionCompletion(userId, w);
if (done) completed++; if (done) completed++;
} }

View File

@@ -326,13 +326,6 @@ export function setCachedQuiz(userId, weekNumber, quiz) {
return Promise.resolve(quiz); return Promise.resolve(quiz);
} }
// ── Learn Progress (DEPRECATED — collection dropped) ────────────────────────
/** @deprecated learn_progress collection has been dropped. */
export async function getLearnDone() { return false; }
/** @deprecated learn_progress collection has been dropped. */
export async function setLearnDone() { return null; }
// ── Theme Sessions ────────────────────────────────────────────────────────── // ── Theme Sessions ──────────────────────────────────────────────────────────
/** /**

View File

@@ -1,81 +0,0 @@
/**
* Knowledge-graph safety guards.
*
* The "Full Analysis" pass lets the LLM propose `merges` (collapse two topics
* into one) and `deletions` (drop a topic entirely). Both are destructive and
* persisted via bulkSave → db.saveTopics, which wipes-and-rewrites the
* `topics` collection.
*
* Two classes of topic must NEVER be removed by an AI suggestion:
*
* 1. `learning_relevance === 'exclude'`
* Reference material — kept on purpose, surfaced only to R42 search,
* intentionally excluded from theme topics. An admin made this choice;
* the AI's "irrelevant" judgement must not override it.
*
* 2. `relevance_locked === true`
* Admin pinned this row's relevance. By extension the row itself is
* also pinned — deleting it would be a louder change than re-scoring it.
*
* `filterAiActions` strips any merge/deletion that would touch a protected id
* BEFORE it reaches bulkSave. The model still gets to suggest them (we can't
* stop it generating), but those suggestions are dropped client-side.
*
* The function is intentionally pure so it is trivially unit-testable.
*/
/**
* @param {{ learning_relevance?: string, relevance_locked?: boolean }} topic
*/
export function isProtectedTopic(topic) {
if (!topic) return false;
if (topic.learning_relevance === 'exclude') return true;
if (topic.relevance_locked === true) return true;
return false;
}
/**
* Strip protected topics out of `actions.deletions` and `actions.merges`.
*
* Merges have two ids:
* - `keepId` — the survivor
* - `deleteId` — gets removed and its relations re-pointed
*
* If `deleteId` is protected we drop the entire merge (we will not delete a
* protected topic, even to consolidate it). If `keepId` is protected we keep
* the merge — folding others into a protected canonical topic is fine.
*
* @param {object[]} currentTopics
* @param {{ merges?: {keepId: string, deleteId: string}[], deletions?: string[], newRelations?: object[], relevanceUpdates?: object[] }} actions
* @returns {{ filtered: object, dropped: { deletions: string[], merges: object[] } }}
*/
export function filterAiActions(currentTopics, actions) {
const byId = new Map(currentTopics.map(t => [t.id, t]));
const isProtected = (id) => isProtectedTopic(byId.get(id));
const droppedDeletions = [];
const keptDeletions = [];
for (const id of actions?.deletions ?? []) {
if (isProtected(id)) droppedDeletions.push(id);
else keptDeletions.push(id);
}
const droppedMerges = [];
const keptMerges = [];
for (const m of actions?.merges ?? []) {
if (isProtected(m?.deleteId)) droppedMerges.push(m);
else keptMerges.push(m);
}
return {
filtered: {
...(actions || {}),
deletions: keptDeletions,
merges: keptMerges,
},
dropped: {
deletions: droppedDeletions,
merges: droppedMerges,
},
};
}

View File

@@ -31,13 +31,14 @@ const Dashboard = () => {
if (!currentUser) return; if (!currentUser) return;
const load = async () => { const load = async () => {
const [topic, learnDone, testResult, allUsers, leaderboard] = await Promise.all([ const [topic, learnCompletion, testResult, allUsers, leaderboard] = await Promise.all([
getAssignedTopic(currentUser.id, weekNumber), getAssignedTopic(currentUser.id, weekNumber),
db.getLearnDone(currentUser.id, weekNumber), db.getThemeSessionCompletion(currentUser.id, weekNumber),
db.getQuizResult(currentUser.id, weekNumber), db.getQuizResult(currentUser.id, weekNumber),
db.getTeamMembers(), db.getTeamMembers(),
db.getLeaderboard(), db.getLeaderboard(),
]); ]);
const learnDone = !!learnCompletion;
const nonAdminIds = allUsers.filter(u => u.role !== 'admin').map(u => u.id); const nonAdminIds = allUsers.filter(u => u.role !== 'admin').map(u => u.id);
const filtered = leaderboard.filter(u => nonAdminIds.includes(u.user_id)); const filtered = leaderboard.filter(u => nonAdminIds.includes(u.user_id));
@@ -48,7 +49,7 @@ const Dashboard = () => {
const activity = []; const activity = [];
for (let w = weekNumber; w >= Math.max(1, weekNumber - 3); w--) { for (let w = weekNumber; w >= Math.max(1, weekNumber - 3); w--) {
const [pastLearn, pastTest, pastTopic] = await Promise.all([ const [pastLearn, pastTest, pastTopic] = await Promise.all([
db.getLearnDone(currentUser.id, w), db.getThemeSessionCompletion(currentUser.id, w),
db.getQuizResult(currentUser.id, w), db.getQuizResult(currentUser.id, w),
getAssignedTopic(currentUser.id, w), getAssignedTopic(currentUser.id, w),
]); ]);