-
{title}
-
- {selectedCount} / {METRIC_DEFS.length}
-
+ {/* ── Metrics header ── */}
+
+
{title}
+ {selectedCount} / {METRIC_DEFS.length}
-
-
- {CATEGORIES.map((cat) => (
-
-
-
- {expanded[cat] && (
-
- {METRIC_DEFS.filter((m) => m.category === cat).map((m) => (
-
- ))}
-
- )}
-
- ))}
-
+ {/* ── Metrics list ── */}
+
+ {groupBy === "trait" ? (
+
+ ) : (
+
+
+
+ )}
);
-}
+}
\ No newline at end of file
diff --git a/modules/portfolio_diff/src/app/students/compare/page.js b/modules/portfolio_diff/src/app/students/compare/page.js
index 446ebe12..83f82e46 100644
--- a/modules/portfolio_diff/src/app/students/compare/page.js
+++ b/modules/portfolio_diff/src/app/students/compare/page.js
@@ -2,965 +2,235 @@
import { navigateTo } from "@/app/utils/navigation";
import {
- ArrowLeftRight,
- Check,
- ChevronDown,
- Clock,
- Eye,
- FileText,
- Focus,
- Gauge,
- Languages,
- ListCollapse,
- MessageSquareText,
- MessagesSquare,
- Minus,
- Quote,
- RefreshCw,
- Search,
- Speech,
- TrendingDown,
- TrendingUp,
- Users,
- WholeWord,
- X,
+ ArrowLeftRight, Check, ChevronDown, Clock, Eye, FileText, Focus, Info,
+ ListCollapse, Minus, RefreshCw, Search, TrendingDown, TrendingUp,
+ Users, X, AlertCircle,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-
import { useLOConnectionDataManager, LOConnectionLastUpdated } from "lo_event/lo_event/lo_assess/components/components.jsx";
-import dynamic from "next/dynamic";
-
-import { MetricsPanel } from "@/app/components/MetricsPanel";
+import { MetricsPanel, METRIC_BY_ID, METRIC_TEACHER_DESC } from "@/app/components/MetricsPanel";
import { useCourseIdContext } from "@/app/providers/CourseIdProvider";
import { getConfiguredWsOrigin } from "@/app/utils/ws";
-/* ---------------------- deterministic helpers ---------------------- */
+/* ── deterministic seed ── */
const seedFrom = (s) => {
let h = 2166136261;
for (let i = 0; i < s.length; i++) h = ((h ^ s.charCodeAt(i)) * 16777619) >>> 0;
return h >>> 0;
};
-/* =============================================================
- OFFSET HIGHLIGHTING HELPERS (multi-metric, overlap-safe)
- ============================================================= */
-
+/* ── highlight helpers ── */
const HIGHLIGHT_CLASSES = [
- "bg-emerald-200/70",
- "bg-sky-200/70",
- "bg-amber-200/70",
- "bg-rose-200/70",
- "bg-indigo-200/70",
- "bg-lime-200/70",
- "bg-violet-200/60",
- "bg-teal-200/70",
- "bg-fuchsia-200/60",
- "bg-orange-200/70",
+ "bg-emerald-200/70","bg-sky-200/70","bg-amber-200/70","bg-rose-200/70",
+ "bg-indigo-200/70","bg-lime-200/70","bg-violet-200/60","bg-teal-200/70",
+ "bg-fuchsia-200/60","bg-orange-200/70",
];
-
-const highlightClassForMetric = (metricId) => {
- const idx = seedFrom(metricId || "metric") % HIGHLIGHT_CLASSES.length;
- return HIGHLIGHT_CLASSES[idx];
-};
+const highlightClassForMetric = (id) => HIGHLIGHT_CLASSES[seedFrom(id || "metric") % HIGHLIGHT_CLASSES.length];
function buildSpansFromDoc(doc, metricIds) {
const text = (doc?.text || "").toString();
const spans = [];
-
for (const metricId of metricIds || []) {
- const m = doc?.[metricId];
- const offsets = m?.offsets;
+ const offsets = doc?.[metricId]?.offsets;
if (!Array.isArray(offsets)) continue;
-
for (const pair of offsets) {
if (!Array.isArray(pair) || pair.length < 2) continue;
- const start = Number(pair[0]);
- const len = Number(pair[1]);
+ const start = Number(pair[0]), len = Number(pair[1]);
if (!Number.isFinite(start) || !Number.isFinite(len) || len <= 0) continue;
-
- const end = start + len;
-
const s = Math.max(0, Math.min(text.length, start));
- const e = Math.max(0, Math.min(text.length, end));
+ const e = Math.max(0, Math.min(text.length, start + len));
if (e > s) spans.push({ start: s, end: e, metricId });
}
}
-
spans.sort((a, b) => a.start - b.start || b.end - b.start - (a.end - a.start));
return { text, spans };
}
function segmentTextBySpans(text, spans) {
const cuts = new Set([0, text.length]);
- for (const s of spans) {
- cuts.add(s.start);
- cuts.add(s.end);
- }
+ for (const s of spans) { cuts.add(s.start); cuts.add(s.end); }
const points = Array.from(cuts).sort((a, b) => a - b);
-
const segs = [];
for (let i = 0; i < points.length - 1; i++) {
- const a = points[i],
- b = points[i + 1];
+ const a = points[i], b = points[i + 1];
if (b <= a) continue;
-
- const active = [];
- for (const sp of spans) {
- if (sp.start <= a && sp.end >= b) active.push(sp.metricId);
- }
-
+ const active = spans.filter((sp) => sp.start <= a && sp.end >= b).map((sp) => sp.metricId);
segs.push({ start: a, end: b, text: text.slice(a, b), active });
}
return segs;
}
-/* =============================================================
- METRICS (FULL LIST)
- ============================================================= */
-
-const CATEGORY_LABELS = {
- language: "Language",
- argumentation: "Argumentation",
- statements: "Statements",
- transitions: "Transition Words",
- pos: "Parts of Speech",
- sentence_type: "Sentence Types",
- source_information: "Source Information",
- dialogue: "Dialogue",
- tone: "Tone",
- details: "Details",
- other: "Other",
-};
-
-const iconForCategory = (catKey) => {
- switch (catKey) {
- case "language":
- return Languages;
- case "argumentation":
- return MessagesSquare;
- case "statements":
- return MessageSquareText;
- case "transitions":
- return ArrowLeftRight;
- case "pos":
- return Speech;
- case "sentence_type":
- return WholeWord;
- case "source_information":
- return Quote;
- case "dialogue":
- return Users;
- case "tone":
- return Gauge;
- case "details":
- return ListCollapse;
- default:
- return FileText;
- }
-};
-
-const METRIC_DEFS = [
- // language
- {
- id: "academic_language",
- title: "Academic Language",
- icon: iconForCategory("language"),
- category: CATEGORY_LABELS.language,
- function: "percent",
- desc: "Percent of tokens flagged academic",
- },
- {
- id: "informal_language",
- title: "Informal Language",
- icon: iconForCategory("language"),
- category: CATEGORY_LABELS.language,
- function: "percent",
- desc: "Percent of tokens flagged informal",
- },
- {
- id: "latinate_words",
- title: "Latinate Words",
- icon: iconForCategory("language"),
- category: CATEGORY_LABELS.language,
- function: "percent",
- desc: "Percent of tokens flagged latinate",
- },
- {
- id: "opinion_words",
- title: "Opinion Words",
- icon: iconForCategory("language"),
- category: CATEGORY_LABELS.language,
- function: "total",
- desc: "Total opinion-word signals",
- },
- {
- id: "emotion_words",
- title: "Emotion Words",
- icon: iconForCategory("language"),
- category: CATEGORY_LABELS.language,
- function: "percent",
- desc: "Percent emotion words",
- },
-
- // argumentation
- {
- id: "argument_words",
- title: "Argument Words",
- icon: iconForCategory("argumentation"),
- category: CATEGORY_LABELS.argumentation,
- function: "percent",
- desc: "Percent argument words",
- },
- {
- id: "explicit_argument",
- title: "Explicit argument",
- icon: iconForCategory("argumentation"),
- category: CATEGORY_LABELS.argumentation,
- function: "percent",
- desc: "Percent explicit argument markers",
- },
-
- // statements
- {
- id: "statements_of_opinion",
- title: "Statements of Opinion",
- icon: iconForCategory("statements"),
- category: CATEGORY_LABELS.statements,
- function: "percent",
- desc: "Percent of sentences classified as opinion",
- },
- {
- id: "statements_of_fact",
- title: "Statements of Fact",
- icon: iconForCategory("statements"),
- category: CATEGORY_LABELS.statements,
- function: "percent",
- desc: "Percent of sentences classified as fact",
- },
-
- // transitions
- {
- id: "transition_words",
- title: "Transition Words",
- icon: iconForCategory("transitions"),
- category: CATEGORY_LABELS.transitions,
- function: "counts",
- desc: "Transition counts (by type)",
- },
- {
- id: "positive_transition_words",
- title: "Positive Transition Words",
- icon: iconForCategory("transitions"),
- category: CATEGORY_LABELS.transitions,
- function: "total",
- desc: "Total positive transitions",
- },
- {
- id: "conditional_transition_words",
- title: "Conditional Transition Words",
- icon: iconForCategory("transitions"),
- category: CATEGORY_LABELS.transitions,
- function: "total",
- desc: "Total conditional transitions",
- },
- {
- id: "consequential_transition_words",
- title: "Consequential Transition Words",
- icon: iconForCategory("transitions"),
- category: CATEGORY_LABELS.transitions,
- function: "total",
- desc: "Total consequential transitions",
- },
- {
- id: "contrastive_transition_words",
- title: "Contrastive Transition Words",
- icon: iconForCategory("transitions"),
- category: CATEGORY_LABELS.transitions,
- function: "total",
- desc: "Total contrastive transitions",
- },
- {
- id: "counterpoint_transition_words",
- title: "Counterpoint Transition Words",
- icon: iconForCategory("transitions"),
- category: CATEGORY_LABELS.transitions,
- function: "total",
- desc: "Total counterpoint transitions",
- },
- {
- id: "comparative_transition_words",
- title: "Comparative Transition Words",
- icon: iconForCategory("transitions"),
- category: CATEGORY_LABELS.transitions,
- function: "total",
- desc: "Total comparative transitions",
- },
- {
- id: "cross_referential_transition_words",
- title: "Cross Referential Transition Words",
- icon: iconForCategory("transitions"),
- category: CATEGORY_LABELS.transitions,
- function: "total",
- desc: "Total cross-referential transitions",
- },
- {
- id: "illustrative_transition_words",
- title: "Illustrative Transition Words",
- icon: iconForCategory("transitions"),
- category: CATEGORY_LABELS.transitions,
- function: "total",
- desc: "Total illustrative transitions",
- },
- {
- id: "negative_transition_words",
- title: "Negative Transition Words",
- icon: iconForCategory("transitions"),
- category: CATEGORY_LABELS.transitions,
- function: "total",
- desc: "Total negative transitions",
- },
- {
- id: "emphatic_transition_words",
- title: "Emphatic Transition Words",
- icon: iconForCategory("transitions"),
- category: CATEGORY_LABELS.transitions,
- function: "total",
- desc: "Total emphatic transitions",
- },
- {
- id: "evenidentiary_transition_words",
- title: "Evenidentiary_transition_words",
- icon: iconForCategory("transitions"),
- category: CATEGORY_LABELS.transitions,
- function: "total",
- desc: "Total evidentiary transitions",
- },
- {
- id: "general_transition_words",
- title: "General Transition Words",
- icon: iconForCategory("transitions"),
- category: CATEGORY_LABELS.transitions,
- function: "total",
- desc: "Total general transitions",
- },
- {
- id: "ordinal_transition_words",
- title: "Ordinal Transition Words",
- icon: iconForCategory("transitions"),
- category: CATEGORY_LABELS.transitions,
- function: "total",
- desc: "Total ordinal transitions",
- },
- {
- id: "purposive_transition_words",
- title: "Purposive Transition Words",
- icon: iconForCategory("transitions"),
- category: CATEGORY_LABELS.transitions,
- function: "total",
- desc: "Total purposive transitions",
- },
- {
- id: "periphrastic_transition_words",
- title: "Periphrastic Transition Words",
- icon: iconForCategory("transitions"),
- category: CATEGORY_LABELS.transitions,
- function: "total",
- desc: "Total periphrastic transitions",
- },
- {
- id: "hypothetical_transition_words",
- title: "Hypothetical Transition Words",
- icon: iconForCategory("transitions"),
- category: CATEGORY_LABELS.transitions,
- function: "total",
- desc: "Total hypothetical transitions",
- },
- {
- id: "summative_transition_words",
- title: "Summative Transition Words",
- icon: iconForCategory("transitions"),
- category: CATEGORY_LABELS.transitions,
- function: "total",
- desc: "Total summative transitions",
- },
- {
- id: "introductory_transition_words",
- title: "Introductory Transition Words",
- icon: iconForCategory("transitions"),
- category: CATEGORY_LABELS.transitions,
- function: "total",
- desc: "Total introductory transitions",
- },
-
- // parts of speech
- {
- id: "adjectives",
- title: "Adjectives",
- icon: iconForCategory("pos"),
- category: CATEGORY_LABELS.pos,
- function: "total",
- desc: "Total adjectives",
- },
- {
- id: "adverbs",
- title: "Adverbs",
- icon: iconForCategory("pos"),
- category: CATEGORY_LABELS.pos,
- function: "total",
- desc: "Total adverbs",
- },
- {
- id: "nouns",
- title: "Nouns",
- icon: iconForCategory("pos"),
- category: CATEGORY_LABELS.pos,
- function: "total",
- desc: "Total nouns",
- },
- {
- id: "proper_nouns",
- title: "Proper Nouns",
- icon: iconForCategory("pos"),
- category: CATEGORY_LABELS.pos,
- function: "total",
- desc: "Total proper nouns",
- },
- {
- id: "verbs",
- title: "Verbs",
- icon: iconForCategory("pos"),
- category: CATEGORY_LABELS.pos,
- function: "total",
- desc: "Total verbs",
- },
- {
- id: "numbers",
- title: "Numbers",
- icon: iconForCategory("pos"),
- category: CATEGORY_LABELS.pos,
- function: "total",
- desc: "Total numbers",
- },
- {
- id: "prepositions",
- title: "Prepositions",
- icon: iconForCategory("pos"),
- category: CATEGORY_LABELS.pos,
- function: "total",
- desc: "Total prepositions",
- },
- {
- id: "coordinating_conjunction",
- title: "Coordinating Conjunction",
- icon: iconForCategory("pos"),
- category: CATEGORY_LABELS.pos,
- function: "total",
- desc: "Total coordinating conjunctions",
- },
- {
- id: "subordinating_conjunction",
- title: "Subordinating Conjunction",
- icon: iconForCategory("pos"),
- category: CATEGORY_LABELS.pos,
- function: "total",
- desc: "Total subordinating conjunctions",
- },
- {
- id: "auxiliary_verb",
- title: "Auxiliary Verb",
- icon: iconForCategory("pos"),
- category: CATEGORY_LABELS.pos,
- function: "total",
- desc: "Total auxiliary verbs",
- },
- {
- id: "pronoun",
- title: "Pronoun",
- icon: iconForCategory("pos"),
- category: CATEGORY_LABELS.pos,
- function: "total",
- desc: "Total pronouns",
- },
-
- // sentence types
- {
- id: "simple_sentences",
- title: "Simple Sentences",
- icon: iconForCategory("sentence_type"),
- category: CATEGORY_LABELS.sentence_type,
- function: "total",
- desc: "Total simple sentences",
- },
- {
- id: "simple_with_complex_predicates",
- title: "Simple with Complex Predicates",
- icon: iconForCategory("sentence_type"),
- category: CATEGORY_LABELS.sentence_type,
- function: "total",
- desc: "Total simple (complex predicates)",
- },
- {
- id: "simple_with_compound_predicates",
- title: "Simple with Compound Predicates",
- icon: iconForCategory("sentence_type"),
- category: CATEGORY_LABELS.sentence_type,
- function: "total",
- desc: "Total simple (compound predicates)",
- },
- {
- id: "simple_with_compound_complex_predicates",
- title: "Simple with Compound Complex Predicates",
- icon: iconForCategory("sentence_type"),
- category: CATEGORY_LABELS.sentence_type,
- function: "total",
- desc: "Total simple (compound complex predicates)",
- },
- {
- id: "compound_sentences",
- title: "Compound Sentences",
- icon: iconForCategory("sentence_type"),
- category: CATEGORY_LABELS.sentence_type,
- function: "total",
- desc: "Total compound sentences",
- },
- {
- id: "complex_sentences",
- title: "Complex Sentences",
- icon: iconForCategory("sentence_type"),
- category: CATEGORY_LABELS.sentence_type,
- function: "total",
- desc: "Total complex sentences",
- },
- {
- id: "compound_complex_sentences",
- title: "Compound Complex Sentences",
- icon: iconForCategory("sentence_type"),
- category: CATEGORY_LABELS.sentence_type,
- function: "total",
- desc: "Total compound-complex sentences",
- },
-
- // source info
- {
- id: "information_sources",
- title: "Information Sources",
- icon: iconForCategory("source_information"),
- category: CATEGORY_LABELS.source_information,
- function: "percent",
- desc: "Percent source references",
- },
- {
- id: "attributions",
- title: "Attributions",
- icon: iconForCategory("source_information"),
- category: CATEGORY_LABELS.source_information,
- function: "percent",
- desc: "Percent attributions",
- },
- {
- id: "citations",
- title: "Citations",
- icon: iconForCategory("source_information"),
- category: CATEGORY_LABELS.source_information,
- function: "percent",
- desc: "Percent citations",
- },
- {
- id: "quoted_words",
- title: "Quoted Words",
- icon: iconForCategory("source_information"),
- category: CATEGORY_LABELS.source_information,
- function: "percent",
- desc: "Percent quoted words",
- },
-
- // dialogue
- {
- id: "direct_speech_verbs",
- title: "Direct Speech Verbs",
- icon: iconForCategory("dialogue"),
- category: CATEGORY_LABELS.dialogue,
- function: "percent",
- desc: "Percent direct speech verbs",
- },
- {
- id: "indirect_speech",
- title: "Indirect Speech",
- icon: iconForCategory("dialogue"),
- category: CATEGORY_LABELS.dialogue,
- function: "percent",
- desc: "Percent indirect speech",
- },
-
- // tone
- {
- id: "positive_tone",
- title: "Positive Tone",
- icon: iconForCategory("tone"),
- category: CATEGORY_LABELS.tone,
- function: "percent",
- desc: "Percent positive tone",
- },
- {
- id: "negative_tone",
- title: "Negative Tone",
- icon: iconForCategory("tone"),
- category: CATEGORY_LABELS.tone,
- function: "percent",
- desc: "Percent negative tone",
- },
-
- // details
- {
- id: "concrete_details",
- title: "Concrete Details",
- icon: iconForCategory("details"),
- category: CATEGORY_LABELS.details,
- function: "percent",
- desc: "Percent concrete details",
- },
- {
- id: "main_idea_sentences",
- title: "Main Idea Sentences",
- icon: iconForCategory("details"),
- category: CATEGORY_LABELS.details,
- function: "total",
- desc: "Total main idea sentences",
- },
- {
- id: "supporting_idea_sentences",
- title: "Supporting Idea Sentences",
- icon: iconForCategory("details"),
- category: CATEGORY_LABELS.details,
- function: "total",
- desc: "Total supporting idea sentences",
- },
- {
- id: "supporting_detail_sentences",
- title: "Supporting Detail Sentences",
- icon: iconForCategory("details"),
- category: CATEGORY_LABELS.details,
- function: "total",
- desc: "Total supporting detail sentences",
- },
-
- // other
- {
- id: "polysyllabic_words",
- title: "Polysyllabic Words",
- icon: iconForCategory("other"),
- category: CATEGORY_LABELS.other,
- function: "percent",
- desc: "Percent polysyllabic tokens",
- },
- {
- id: "low_frequency_words",
- title: "Low Frequency Words",
- icon: iconForCategory("other"),
- category: CATEGORY_LABELS.other,
- function: "percent",
- desc: "Percent low-frequency tokens",
- },
- {
- id: "sentences",
- title: "Sentences",
- icon: iconForCategory("other"),
- category: CATEGORY_LABELS.other,
- function: "total",
- desc: "Total sentences",
- },
- {
- id: "paragraphs",
- title: "Paragraphs",
- icon: iconForCategory("other"),
- category: CATEGORY_LABELS.other,
- function: "total",
- desc: "Total paragraphs",
- },
- {
- id: "character_trait_words",
- title: "Character Trait Words",
- icon: iconForCategory("other"),
- category: CATEGORY_LABELS.other,
- function: "percent",
- desc: "Percent character trait tokens",
- },
- {
- id: "in_past_tense",
- title: "In Past Tense",
- icon: iconForCategory("other"),
- category: CATEGORY_LABELS.other,
- function: "percent",
- desc: "Percent past tense scope",
- },
- {
- id: "explicit_claims",
- title: "Explicit Claims",
- icon: iconForCategory("other"),
- category: CATEGORY_LABELS.other,
- function: "percent",
- desc: "Percent explicit claims",
- },
- {
- id: "social_awareness",
- title: "Social Awareness",
- icon: iconForCategory("other"),
- category: CATEGORY_LABELS.other,
- function: "percent",
- desc: "Percent social awareness",
- },
-];
-
-const METRIC_BY_ID = Object.fromEntries(METRIC_DEFS.map((m) => [m.id, m]));
-
-/* ---------------------- Tooltip values from backend ---------------------- */
-const PERCENT_IDS = new Set(METRIC_DEFS.filter((m) => m.function === "percent").map((m) => m.id));
-
-const formatMetricValue = (value, id) => {
- if (value == null) return "—";
- if (PERCENT_IDS.has(id)) return `${Math.round(Number(value))}%`;
- if (typeof value === "number") return Number.isInteger(value) ? String(value) : value.toFixed(1);
- const n = Number(value);
- return Number.isNaN(n) ? String(value) : n.toFixed(1);
-};
-
-/* =============================================================
- coverage-based metric value from offsets
- ============================================================= */
+/* ══════════════════════════════════════════════════════════════
+ 6+1 TRAIT RUBRIC MAPPING
+ ══════════════════════════════════════════════════════════════ */
+/* ══ coverage ══ */
function metricCoveragePercent(doc, metricId) {
- const text = (doc?.text || "").toString();
- const L = text.length;
- if (!L) return 0;
-
+ const text = (doc?.text || "").toString(); const L = text.length; if (!L) return 0;
const offsets = doc?.[metricId]?.offsets;
- if (!Array.isArray(offsets) || offsets.length === 0) return 0;
-
+ if (!Array.isArray(offsets) || !offsets.length) return 0;
const ranges = [];
for (const pair of offsets) {
if (!Array.isArray(pair) || pair.length < 2) continue;
- const start = Number(pair[0]);
- const len = Number(pair[1]);
- if (!Number.isFinite(start) || !Number.isFinite(len) || len <= 0) continue;
-
- let s = Math.max(0, Math.min(L, start));
- let e = Math.max(0, Math.min(L, start + len));
+ const s0 = Number(pair[0]), len = Number(pair[1]);
+ if (!Number.isFinite(s0) || !Number.isFinite(len) || len <= 0) continue;
+ const s = Math.max(0, Math.min(L, s0)), e = Math.max(0, Math.min(L, s0 + len));
if (e > s) ranges.push([s, e]);
}
if (!ranges.length) return 0;
-
ranges.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
-
- let covered = 0;
- let [curS, curE] = ranges[0];
-
+ let covered = 0; let [cs, ce] = ranges[0];
for (let i = 1; i < ranges.length; i++) {
const [s, e] = ranges[i];
- if (s <= curE) {
- curE = Math.max(curE, e);
- } else {
- covered += curE - curS;
- curS = s;
- curE = e;
- }
+ if (s <= ce) ce = Math.max(ce, e); else { covered += ce - cs; cs = s; ce = e; }
}
- covered += curE - curS;
-
- return (covered / L) * 100;
+ return ((covered + ce - cs) / L) * 100;
}
-/* ---------------------- Tooltip builder for highlights ---------------------- */
function buildHighlightTooltip(doc, metricIds) {
const uniq = Array.from(new Set(metricIds || []));
- if (uniq.length === 0) return "";
-
- const lines = [];
- for (const id of uniq) {
- const meta = METRIC_BY_ID[id];
- const label = meta?.title || id;
+ if (!uniq.length) return "";
+ return uniq.map((id) => {
+ const label = METRIC_BY_ID[id]?.title || id;
+ const desc = METRIC_TEACHER_DESC[id] || "";
+ const cov = `${metricCoveragePercent(doc, id).toFixed(1)}% of this essay`;
+ return desc ? `${label}: ${cov}\n${desc}` : `${label}: ${cov}`;
+ }).join("\n\n");
+}
- const v = doc?.[id]?.metric;
- const hasNum = v != null && !Number.isNaN(Number(v));
+/* ══ assignment type ══ */
+const ASSIGNMENT_TYPE_COLORS = {
+ Narrative: { bg:"bg-violet-50", text:"text-violet-700", ring:"ring-violet-200", dot:"bg-violet-400" },
+ Argumentative: { bg:"bg-amber-50", text:"text-amber-700", ring:"ring-amber-200", dot:"bg-amber-400" },
+ Analytical: { bg:"bg-blue-50", text:"text-blue-700", ring:"ring-blue-200", dot:"bg-blue-400" },
+ Expository: { bg:"bg-teal-50", text:"text-teal-700", ring:"ring-teal-200", dot:"bg-teal-400" },
+ Document: { bg:"bg-emerald-50",text:"text-emerald-700",ring:"ring-emerald-200",dot:"bg-emerald-400" },
+ Other: { bg:"bg-gray-50", text:"text-gray-600", ring:"ring-gray-200", dot:"bg-gray-400" },
+};
- const cov = metricCoveragePercent(doc, id);
- const covStr = `${cov.toFixed(1)}% of text`;
+function inferAssignmentType(text) {
+ const t = (text || "").toLowerCase();
+ if (/argue|claim|thesis|evidence|counterargument/.test(t)) return "Argumentative";
+ if (/analyze|analysis|examine|compare|contrast/.test(t)) return "Analytical";
+ if (/once upon|story|character|plot|setting/.test(t)) return "Narrative";
+ if (/explain|describe|inform|definition/.test(t)) return "Expository";
+ return "Document";
+}
- if (hasNum) {
- lines.push(`${label}: ${formatMetricValue(v, id)} (${covStr})`);
- } else {
- lines.push(`${label}: ${covStr}`);
- }
- }
- return lines.join("\n");
+function AssignmentTypeBadge({ type }) {
+ const c = ASSIGNMENT_TYPE_COLORS[type] || ASSIGNMENT_TYPE_COLORS.Document;
+ return (
+
+ {type}
+
+ );
}
-/* ---------------------- Floating tooltip (custom, reliable) ---------------------- */
-function clamp(n, lo, hi) {
- return Math.max(lo, Math.min(hi, n));
+// Produce a readable title from a raw doc ID.
+// Real metadata title is always preferred over this fallback.
+function humanizeDocId(docId) {
+ if (!docId) return "Document";
+ return String(docId)
+ .replace(/^fake-google-doc-id-?/i, "Doc ")
+ .replace(/^doc-id-?/i, "Doc ")
+ .replace(/[-_]/g, " ")
+ .replace(/\b\w/g, (c) => c.toUpperCase())
+ .trim() || docId;
}
+/* ══ floating tooltip ══ */
+function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); }
function FloatingTooltip({ tooltip }) {
if (!tooltip?.visible) return null;
-
return (
-
- {tooltip.content}
-
+
{tooltip.content}
);
}
-function HighlightedEssay({
- doc,
- activeMetricIds,
- containerRef,
- onShowTooltip,
- onMoveTooltip,
- onHideTooltip,
-}) {
- const { text, spans } = useMemo(() => buildSpansFromDoc(doc, activeMetricIds), [doc, activeMetricIds]);
+// Stable essay component — only rebuilds spans when text content or active metrics actually change,
+// not on every WebSocket data object re-reference, preventing visible flicker on server refresh.
+function HighlightedEssay({ doc, activeMetricIds, containerRef, onShowTooltip, onMoveTooltip, onHideTooltip }) {
+ const textContent = doc?.text || "";
+ const { text, spans } = useMemo(
+ () => buildSpansFromDoc(doc, activeMetricIds),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [textContent, activeMetricIds.join(",")]
+ );
const segments = useMemo(() => segmentTextBySpans(text, spans), [text, spans]);
-
- if (!text.trim()) {
- return (
-
- (No text returned for this document.)
-
- );
- }
-
+ if (!text.trim()) return
(No text returned for this document.)
;
return (
{segments.map((seg, idx) => {
if (!seg.active.length) return {seg.text};
-
- const top = seg.active[0];
- const cls = highlightClassForMetric(top);
+ const cls = highlightClassForMetric(seg.active[0]);
const tooltipText = buildHighlightTooltip(doc, seg.active);
-
return (
- onShowTooltip(tooltipText, e)}
onMouseMove={(e) => onMoveTooltip(e)}
onMouseLeave={() => onHideTooltip()}
- onPointerEnter={(e) => onShowTooltip(tooltipText, e)}
- onPointerMove={(e) => onMoveTooltip(e)}
- onPointerLeave={() => onHideTooltip()}
- >
- {seg.text}
-
+ >{seg.text}
);
})}
);
}
-/* ---------------------- URL param reader (client-safe) ---------------------- */
+/* ══ URL helpers ══ */
function readCompareParamsFromLocation() {
- if (typeof window === "undefined") {
- return { urlReady: false, studentID: "", docIds: [] };
- }
-
+ if (typeof window === "undefined") return { urlReady: false, studentID: "", docIds: [] };
const sp = new URLSearchParams(window.location.search);
const studentID = (sp.get("student_id") || "").trim();
- const idsRaw = (sp.get("ids") || "").trim();
-
- const parts = idsRaw
- .split(",")
- .map((s) => s.trim())
- .filter(Boolean);
-
- const seen = new Set();
- const docIds = [];
- for (const p of parts) {
- if (!seen.has(p)) {
- seen.add(p);
- docIds.push(p);
- }
- if (docIds.length === 2) break;
- }
-
+ const parts = (sp.get("ids") || "").trim().split(",").map((s) => s.trim()).filter(Boolean);
+ const seen = new Set(); const docIds = [];
+ for (const p of parts) { if (!seen.has(p)) { seen.add(p); docIds.push(p); } if (docIds.length === 2) break; }
return { urlReady: true, studentID, docIds };
}
function buildEssayFromDoc({ docId, text, side, title }) {
const content = (text || "").trim();
const words = content ? content.split(/\s+/).filter(Boolean).length : 0;
-
+ const humanTitle = title && !(/fake-google-doc|doc-id/i.test(title)) ? title : humanizeDocId(docId);
return {
- id: docId || `${side}-unknown`,
- title: title || (docId ? `Document: ${docId}` : `Document (${side})`),
- date: "",
- minutes: Math.max(10, Math.round(words / 30)),
- words,
- grade: "—",
- tags: [],
+ id: docId || `${side}-unknown`, title: humanTitle || `Essay (${side})`,
+ date: "", minutes: Math.max(10, Math.round(words / 30)), words,
content: content || "(No text returned for this document.)",
+ assignmentType: inferAssignmentType(content),
};
}
-/* ---------------------- Metrics comparison UI helpers ---------------------- */
-function formatPct(n) {
- const x = Number.isFinite(Number(n)) ? Number(n) : 0;
- return `${x.toFixed(1)}%`;
-}
+/* ══ format helpers ══ */
+function formatPct(n) { const x = Number.isFinite(Number(n)) ? Number(n) : 0; return `${x.toFixed(1)}%`; }
function formatDelta(n) {
const x = Number.isFinite(Number(n)) ? Number(n) : 0;
- const sign = x > 0 ? "+" : x < 0 ? "−" : "±";
- const abs = Math.abs(x).toFixed(1);
- return `${sign}${abs}%`;
+ return `${x > 0 ? "+" : x < 0 ? "-" : "+-"}${Math.abs(x).toFixed(1)}%`;
}
-/* ---------------------- Evidence extraction (short excerpts) ---------------------- */
function extractMetricExamples(doc, metricId, maxExamples = 2) {
- const text = (doc?.text || "").toString();
- if (!text.trim()) return [];
-
- const offsets = doc?.[metricId]?.offsets;
- if (!Array.isArray(offsets) || offsets.length === 0) return [];
-
- const L = text.length;
- const spans = [];
+ const text = (doc?.text || "").toString(); if (!text.trim()) return [];
+ const offsets = doc?.[metricId]?.offsets; if (!Array.isArray(offsets) || !offsets.length) return [];
+ const L = text.length; const spans = [];
for (const pair of offsets) {
if (!Array.isArray(pair) || pair.length < 2) continue;
- const start = Number(pair[0]);
- const len = Number(pair[1]);
- if (!Number.isFinite(start) || !Number.isFinite(len) || len <= 0) continue;
- const s = Math.max(0, Math.min(L, start));
- const e = Math.max(0, Math.min(L, start + len));
- if (e > s) spans.push([s, e]);
+ const s0 = Number(pair[0]), len = Number(pair[1]);
+ if (!Number.isFinite(s0) || !Number.isFinite(len) || len <= 0) continue;
+ spans.push([Math.max(0, Math.min(L, s0)), Math.max(0, Math.min(L, s0 + len))]);
}
- if (!spans.length) return [];
-
- spans.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
-
- const seen = new Set();
- const out = [];
+ spans.sort((a, b) => a[0] - b[0]);
+ const seen = new Set(); const out = [];
for (const [s, e] of spans) {
if (out.length >= maxExamples) break;
-
- const pad = 70;
- const a = Math.max(0, s - pad);
- const b = Math.min(L, e + pad);
-
+ const pad = 70, a = Math.max(0, s - pad), b = Math.min(L, e + pad);
let snippet = text.slice(a, b).replace(/\s+/g, " ").trim();
-
- if (a > 0) snippet = `…${snippet}`;
- if (b < L) snippet = `${snippet}…`;
-
- const key = snippet.toLowerCase();
- if (seen.has(key)) continue;
- seen.add(key);
-
- out.push(snippet);
+ if (a > 0) snippet = `...${snippet}`; if (b < L) snippet = `${snippet}...`;
+ const key = snippet.toLowerCase(); if (seen.has(key)) continue; seen.add(key); out.push(snippet);
}
return out;
}
+/* ══ UI components ══ */
function MetricDeltaIcon({ delta }) {
const d = Number(delta) || 0;
if (d > 0.0001) return
;
@@ -970,44 +240,34 @@ function MetricDeltaIcon({ delta }) {
function MetricDeltaPill({ delta }) {
const d = Number(delta) || 0;
- const cls =
- d > 0.0001
- ? "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200"
- : d < -0.0001
- ? "bg-rose-50 text-rose-700 ring-1 ring-rose-200"
- : "bg-gray-100 text-gray-700";
- return (
-
- Δ {formatDelta(d)}
-
- );
+ const cls = d > 0.0001 ? "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200"
+ : d < -0.0001 ? "bg-rose-50 text-rose-700 ring-1 ring-rose-200" : "bg-gray-100 text-gray-700";
+ return
{formatDelta(d)};
}
function StoryCard({ label, metricTitle, category, left, right, delta, tone, isDisabled }) {
- const toneCls =
- tone === "up"
- ? "border-emerald-200 bg-emerald-50/40"
- : tone === "down"
- ? "border-rose-200 bg-rose-50/40"
- : "border-gray-200 bg-gray-50";
-
+ const toneCls = tone === "up" ? "border-emerald-200 bg-emerald-50/40"
+ : tone === "down" ? "border-rose-200 bg-rose-50/40" : "border-gray-200 bg-gray-50";
+ if (isDisabled) return (
+
+
{label}
+
No significant change detected
+
+ );
return (
{label}
-
- {isDisabled ? "—" : metricTitle || "—"}
-
-
{isDisabled ? "" : category || ""}
+
{metricTitle || "—"}
+
{category || ""}
-
+
-
-
-
{formatPct(left)}
-
→
-
{formatPct(right)}
+
+ {formatPct(left)}
+ to
+ {formatPct(right)}
);
@@ -1015,54 +275,32 @@ function StoryCard({ label, metricTitle, category, left, right, delta, tone, isD
function MetricRow({ row, isFocused, onFocusToggle, onShow }) {
const { def, left, right, delta } = row;
+ const teacherDesc = METRIC_TEACHER_DESC[def.id] || def.desc || "";
return (
-
+
-
-
+
+
{def.title}
-
· {def.category}
+
· {def.category}
-
{def.desc}
+
{teacherDesc}
-
-
+
{formatPct(left)}
- →
+ to
{formatPct(right)}
-
-
-
-
-
-
-
@@ -1070,6 +308,24 @@ function MetricRow({ row, isFocused, onFocusToggle, onShow }) {
);
}
+function ComparisonTypeMismatchBanner({ typeA, typeB }) {
+ if (!typeA || !typeB || typeA === typeB || typeA === "Document" || typeB === "Document") return null;
+ return (
+
+
+
+ Different assignment types selected.{" "}
+ Comparing a {typeA} essay with a {typeB} essay.
+ Language-level signals (word choice, transitions) are still useful across types.
+ Structural signals (sentence types, paragraphs) are most meaningful within the same type.
+
+
+ );
+}
+
+/* ══════════════════════════════════════════════════════════════
+ MAIN COMPONENT
+ ══════════════════════════════════════════════════════════════ */
export default function EssayComparison() {
const initial = useMemo(() => readCompareParamsFromLocation(), []);
const [urlReady, setUrlReady] = useState(initial.urlReady);
@@ -1078,152 +334,70 @@ export default function EssayComparison() {
const { courseId } = useCourseIdContext();
useEffect(() => {
- const next = readCompareParamsFromLocation();
- if (!next.urlReady) return;
-
- const sameStudent = next.studentID === studentID;
- const sameDocs =
- next.docIds.length === docIds.length &&
- next.docIds[0] === docIds[0] &&
- next.docIds[1] === docIds[1];
-
- if (!sameStudent) setStudentID(next.studentID);
- if (!sameDocs) setDocIds(next.docIds);
+ const next = readCompareParamsFromLocation(); if (!next.urlReady) return;
+ if (next.studentID !== studentID) setStudentID(next.studentID);
+ if (!(next.docIds.length === docIds.length && next.docIds[0] === docIds[0] && next.docIds[1] === docIds[1])) setDocIds(next.docIds);
if (!urlReady) setUrlReady(true);
- // eslint-disable-next-line react-hooks/exhaustive-deps
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- const leftDocId = docIds[0] || "";
- const rightDocId = docIds[1] || "";
-
+ const leftDocId = docIds[0] || "", rightDocId = docIds[1] || "";
const hasCourseId = courseId !== undefined && courseId !== null && String(courseId).trim().length > 0;
const enabled = urlReady && hasCourseId && !!studentID && docIds.length === 2;
const missingParams = urlReady && (!studentID || docIds.length !== 2);
const [selectedMetrics, setSelectedMetrics] = useState([
- "academic_language",
- "informal_language",
- "latinate_words",
- "transition_words",
- "citations",
- "sentences",
- "paragraphs",
+ "academic_language","informal_language","latinate_words","transition_words","citations","sentences","paragraphs",
]);
- /* ---------------------- comparison data fetch ---------------------- */
const origin = getConfiguredWsOrigin();
-
const dataScope = useMemo(() => {
- if (!urlReady || !studentID || !hasCourseId) {
- return {
- wo: {
- execution_dag: "writing_observer",
- target_exports: [],
- kwargs: {},
- },
- };
- }
-
- const target_exports = ["student_with_docs", "single_student_profile"];
-
- // Only add NLP annotation export when we have 2 docs selected
- if (docIds.length === 2 && docIds[0] && docIds[1]) {
- target_exports.push("single_student_docs_with_nlp_annotations");
- }
-
- return {
- wo: {
- execution_dag: "writing_observer",
- target_exports,
- kwargs: {
- course_id: courseId,
- student_id: studentID,
- ...(docIds.length === 2 && docIds[0] && docIds[1]
- ? {
- document: docIds,
- nlp_options: selectedMetrics,
- }
- : {}),
- },
- },
- };
+ if (!urlReady || !studentID || !hasCourseId) return { wo: { execution_dag: "writing_observer", target_exports: [], kwargs: {} } };
+ const te = ["student_with_docs","single_student_profile"];
+ if (docIds.length === 2 && docIds[0] && docIds[1]) te.push("single_student_docs_with_nlp_annotations");
+ return { wo: { execution_dag: "writing_observer", target_exports: te, kwargs: {
+ course_id: courseId, student_id: studentID,
+ ...(docIds.length === 2 && docIds[0] && docIds[1] ? { document: docIds, nlp_options: selectedMetrics } : {}),
+ }}};
}, [urlReady, studentID, hasCourseId, courseId, docIds, selectedMetrics]);
const { data: loData, errors: loErrors, connection: loConnection } = useLOConnectionDataManager({
- url: `${origin}/wsapi/communication_protocol`,
- dataScope,
+ url: `${origin}/wsapi/communication_protocol`, dataScope,
});
- /* ---------------------- Available docs list (for replacement selection) ---------------------- */
const availableDocIds = useMemo(() => {
- const docsObj = loData?.students?.[studentID]?.documents || {};
- const ids = Object.keys(docsObj || {});
- ids.sort();
- return ids;
+ const ids = Object.keys(loData?.students?.[studentID]?.documents || {}); ids.sort(); return ids;
}, [loData, studentID]);
useEffect(() => {
- if (loConnection && loConnection.sendMessage && dataScope?.wo?.target_exports?.length > 0) {
- try {
- loConnection.sendMessage(JSON.stringify(dataScope));
- } catch (e) {
- console.warn("Failed to resend dataScope:", e);
- }
+ if (loConnection?.sendMessage && dataScope?.wo?.target_exports?.length > 0) {
+ try { loConnection.sendMessage(JSON.stringify(dataScope)); } catch (e) { console.warn(e); }
}
}, [dataScope, loConnection]);
- const docTitle = useCallback(
- (docId) => {
- if (!docId) return "—";
- const doc = loData?.students?.[studentID]?.documents?.[docId];
- return doc?.title || docId;
- },
- [loData, studentID]
- );
+ const docTitle = useCallback((docId) => {
+ if (!docId) return "—";
+ const raw = loData?.students?.[studentID]?.documents?.[docId]?.title || "";
+ if (raw && !(/fake-google-doc|doc-id/i.test(raw))) return raw;
+ return humanizeDocId(docId);
+ }, [loData, studentID]);
- const studentProfile = loData?.students?.[studentID];
- const studentDisplayName = studentProfile?.profile?.name?.full_name || studentID || "—";
+ const studentDisplayName = loData?.students?.[studentID]?.profile?.name?.full_name || studentID || "—";
const docsObj = loData?.students?.[studentID]?.documents || {};
const leftDoc = leftDocId ? docsObj?.[leftDocId] : null;
const rightDoc = rightDocId ? docsObj?.[rightDocId] : null;
- // ----------------- LOADING GATE (non-empty text) -----------------
- const leftHasTextField = !!(leftDoc && Object.prototype.hasOwnProperty.call(leftDoc, "text"));
- const rightHasTextField = !!(rightDoc && Object.prototype.hasOwnProperty.call(rightDoc, "text"));
-
- const leftTextNonEmpty = leftHasTextField && typeof leftDoc.text === "string" && leftDoc.text.trim().length > 0;
- const rightTextNonEmpty = rightHasTextField && typeof rightDoc.text === "string" && rightDoc.text.trim().length > 0;
-
- const docsReady = enabled && leftTextNonEmpty && rightTextNonEmpty;
- const isDocsLoading = enabled && !docsReady;
- // -------------------------------------------------------------------
-
+ const leftHasText = !!(leftDoc && "text" in leftDoc);
+ const rightHasText = !!(rightDoc && "text" in rightDoc);
+ const leftTextNonEmpty = leftHasText && typeof leftDoc.text === "string" && leftDoc.text.trim().length > 0;
+ const rightTextNonEmpty = rightHasText && typeof rightDoc.text === "string" && rightDoc.text.trim().length > 0;
+ const isDocsLoading = enabled && !(leftTextNonEmpty && rightTextNonEmpty);
const leftDocLoading = enabled && !leftTextNonEmpty;
const rightDocLoading = enabled && !rightTextNonEmpty;
- const leftText = leftHasTextField ? leftDoc?.text || "" : "";
- const rightText = rightHasTextField ? rightDoc?.text || "" : "";
-
- const hasLoadErrors = useMemo(() => {
- if (!enabled || !loErrors) return false;
- if (Array.isArray(loErrors)) return loErrors.length > 0;
- if (typeof loErrors === "object") return Object.keys(loErrors).length > 0;
- return true;
- }, [enabled, loErrors]);
-
- useEffect(() => {
- if (process.env.NODE_ENV !== "production" && hasLoadErrors) {
- console.warn("[students/compare] Non-blocking LO warnings detected:", loErrors);
- }
- }, [hasLoadErrors, loErrors]);
-
const hasPendingMetricData = useMemo(() => {
if (!enabled || !leftTextNonEmpty || !rightTextNonEmpty) return false;
- return selectedMetrics.some((metricId) => {
- const leftMetric = leftDoc?.[metricId];
- const rightMetric = rightDoc?.[metricId];
- return !leftMetric || !rightMetric;
- });
+ return selectedMetrics.some((id) => !leftDoc?.[id] || !rightDoc?.[id]);
}, [enabled, leftTextNonEmpty, rightTextNonEmpty, selectedMetrics, leftDoc, rightDoc]);
const showLoadingIndicator = isDocsLoading || hasPendingMetricData;
@@ -1238,322 +412,150 @@ export default function EssayComparison() {
useEffect(() => {
if (!enabled) return;
- if (leftHasTextField) setLeftEssay(buildEssayFromDoc({ docId: leftDocId, text: leftText, side: "left", title: docTitle(leftDocId) }));
- if (rightHasTextField) setRightEssay(buildEssayFromDoc({ docId: rightDocId, text: rightText, side: "right", title: docTitle(rightDocId) }));
- }, [enabled, leftHasTextField, rightHasTextField, leftDocId, rightDocId, leftText, rightText, docTitle]);
+ if (leftHasText) setLeftEssay(buildEssayFromDoc({ docId: leftDocId, text: leftDoc?.text || "", side: "left", title: docTitle(leftDocId) }));
+ if (rightHasText) setRightEssay(buildEssayFromDoc({ docId: rightDocId, text: rightDoc?.text || "", side: "right", title: docTitle(rightDocId) }));
+ }, [enabled, leftHasText, rightHasText, leftDocId, rightDocId, leftDoc, rightDoc, docTitle]);
- /* ---------------------- CUSTOM TOOLTIP STATE ---------------------- */
+ /* tooltip */
const [tooltip, setTooltip] = useState({ visible: false, x: 0, y: 0, content: "" });
-
- const positionFromMouse = useCallback((e) => {
- const pad = 12;
- const vw = typeof window !== "undefined" ? window.innerWidth : 1200;
- const vh = typeof window !== "undefined" ? window.innerHeight : 800;
-
- const maxW = 420;
- const maxH = 220;
-
- const x = clamp(e.clientX + pad, 8, vw - maxW);
- const y = clamp(e.clientY + pad, 8, vh - maxH);
- return { x, y };
- }, []);
-
- const onShowTooltip = useCallback(
- (content, e) => {
- if (!content) return;
- const { x, y } = positionFromMouse(e);
- setTooltip({ visible: true, x, y, content: content || "" });
- },
- [positionFromMouse]
- );
-
- const onMoveTooltip = useCallback(
- (e) => {
- setTooltip((t) => {
- if (!t.visible) return t;
- const { x, y } = positionFromMouse(e);
- return { ...t, x, y };
- });
- },
- [positionFromMouse]
- );
-
- const onHideTooltip = useCallback(() => {
- setTooltip((t) => ({ ...t, visible: false }));
- }, []);
-
- /* ---------------------- URL update (no navigation, no page shift) ---------------------- */
- const updateUrlIds = useCallback(
- (nextDocIds) => {
- if (typeof window === "undefined") return;
- const sp = new URLSearchParams(window.location.search);
- sp.set("student_id", studentID || "");
- sp.set("ids", nextDocIds.join(","));
- const next = `${window.location.pathname}?${sp.toString()}`;
- window.history.replaceState({}, "", next);
- },
- [studentID]
- );
-
- const setDocIdForSide = useCallback(
- (side, newId) => {
- const id = (newId || "").trim();
- if (!id) return;
-
- setDocIds((prev) => {
- const next = [...prev];
- const L = next[0] || "";
- const R = next[1] || "";
-
- // Prevent selecting the same doc for both sides; if chosen, swap.
- if (side === "left") {
- if (id === R) {
- next[0] = R;
- next[1] = L;
- } else {
- next[0] = id;
- next[1] = R;
- }
- } else {
- if (id === L) {
- next[0] = R;
- next[1] = L;
- } else {
- next[0] = L;
- next[1] = id;
- }
- }
-
- // Ensure length 2
- if (!next[0]) next[0] = L;
- if (!next[1]) next[1] = R;
-
- updateUrlIds(next);
- return next;
- });
- },
- [updateUrlIds]
- );
-
- const swapDocSides = useCallback(() => {
+ const positionFromMouse = useCallback((e) => ({
+ x: clamp(e.clientX + 12, 8, (typeof window !== "undefined" ? window.innerWidth : 1200) - 420),
+ y: clamp(e.clientY + 12, 8, (typeof window !== "undefined" ? window.innerHeight : 800) - 220),
+ }), []);
+ const onShowTooltip = useCallback((content, e) => {
+ if (!content) return; const { x, y } = positionFromMouse(e); setTooltip({ visible: true, x, y, content });
+ }, [positionFromMouse]);
+ const onMoveTooltip = useCallback((e) => {
+ setTooltip((t) => { if (!t.visible) return t; const { x, y } = positionFromMouse(e); return { ...t, x, y }; });
+ }, [positionFromMouse]);
+ const onHideTooltip = useCallback(() => setTooltip((t) => ({ ...t, visible: false })), []);
+
+ /* URL update */
+ const updateUrlIds = useCallback((next) => {
+ if (typeof window === "undefined") return;
+ const sp = new URLSearchParams(window.location.search);
+ sp.set("student_id", studentID || ""); sp.set("ids", next.join(","));
+ window.history.replaceState({}, "", `${window.location.pathname}?${sp.toString()}`);
+ }, [studentID]);
+
+ const setDocIdForSide = useCallback((side, newId) => {
+ const id = (newId || "").trim(); if (!id) return;
setDocIds((prev) => {
- if (!Array.isArray(prev) || prev.length < 2) return prev;
- const next = [prev[1] || "", prev[0] || ""];
- updateUrlIds(next);
- return next;
+ const n = [...prev]; const L = n[0] || "", R = n[1] || "";
+ if (side === "left") { n[0] = id === R ? R : id; n[1] = id === R ? L : R; }
+ else { n[0] = id === L ? R : L; n[1] = id === L ? L : id; }
+ if (!n[0]) n[0] = L; if (!n[1]) n[1] = R;
+ updateUrlIds(n); return n;
});
}, [updateUrlIds]);
- /* ---------------------- Replace Modal (no shifting, full doc list) ---------------------- */
+ const swapDocSides = useCallback(() => {
+ setDocIds((prev) => { if (!Array.isArray(prev) || prev.length < 2) return prev; const n = [prev[1]||"", prev[0]||""]; updateUrlIds(n); return n; });
+ }, [updateUrlIds]);
+
+ /* modals */
const [replaceModal, setReplaceModal] = useState({ open: false, side: "left" });
const [replaceQuery, setReplaceQuery] = useState("");
const [replaceActiveIdx, setReplaceActiveIdx] = useState(0);
const [metricsModalOpen, setMetricsModalOpen] = useState(false);
- const openReplace = (side) => {
- setReplaceQuery("");
- setReplaceActiveIdx(0);
- setReplaceModal({ open: true, side });
- };
- const closeReplace = () => {
- setReplaceModal({ open: false, side: "left" });
- setReplaceQuery("");
- setReplaceActiveIdx(0);
- };
-
- const openMetricsModal = () => setMetricsModalOpen(true);
- const closeMetricsModal = () => setMetricsModalOpen(false);
+ const openReplace = (side) => { setReplaceQuery(""); setReplaceActiveIdx(0); setReplaceModal({ open: true, side }); };
+ const closeReplace = () => { setReplaceModal({ open: false, side: "left" }); setReplaceQuery(""); setReplaceActiveIdx(0); };
useEffect(() => {
if (typeof document === "undefined") return;
document.body.style.overflow = replaceModal.open || metricsModalOpen ? "hidden" : "";
-
- return () => {
- document.body.style.overflow = "";
- };
+ return () => { document.body.style.overflow = ""; };
}, [replaceModal.open, metricsModalOpen]);
const currentIdForSide = replaceModal.side === "left" ? leftDocId : rightDocId;
- const otherIdForSide = replaceModal.side === "left" ? rightDocId : leftDocId;
+ const otherIdForSide = replaceModal.side === "left" ? rightDocId : leftDocId;
const replaceMatches = useMemo(() => {
const q = replaceQuery.trim().toLowerCase();
- const pool = availableDocIds || [];
- if (!q) return pool;
- return pool.filter((id) => {
- const title = docTitle(id).toLowerCase();
- return title.includes(q) || id.toLowerCase().includes(q);
- });
+ return (availableDocIds || []).filter((id) => !q || docTitle(id).toLowerCase().includes(q) || id.toLowerCase().includes(q));
}, [replaceQuery, availableDocIds, docTitle]);
- const replacePick = (id) => {
- setDocIdForSide(replaceModal.side, id);
- closeReplace();
- };
+ const replacePick = (id) => { setDocIdForSide(replaceModal.side, id); closeReplace(); };
const onReplaceKeyDown = (e) => {
if (!replaceModal.open) return;
-
- if (e.key === "Escape") {
- e.preventDefault();
- closeReplace();
- return;
- }
- if (e.key === "ArrowDown") {
- e.preventDefault();
- setReplaceActiveIdx((i) => Math.min(replaceMatches.length - 1, i + 1));
- return;
- }
- if (e.key === "ArrowUp") {
- e.preventDefault();
- setReplaceActiveIdx((i) => Math.max(0, i - 1));
- return;
- }
- if (e.key === "Enter") {
- e.preventDefault();
- const id = replaceMatches[replaceActiveIdx];
- if (id) replacePick(id);
- return;
- }
+ if (e.key === "Escape") { e.preventDefault(); closeReplace(); return; }
+ if (e.key === "ArrowDown") { e.preventDefault(); setReplaceActiveIdx((i) => Math.min(replaceMatches.length - 1, i + 1)); return; }
+ if (e.key === "ArrowUp") { e.preventDefault(); setReplaceActiveIdx((i) => Math.max(0, i - 1)); return; }
+ if (e.key === "Enter") { e.preventDefault(); const id = replaceMatches[replaceActiveIdx]; if (id) replacePick(id); }
};
- // Keep active index in bounds as filter changes
- useEffect(() => {
- if (!replaceModal.open) return;
- setReplaceActiveIdx(0);
- }, [replaceModal.open, replaceQuery]);
-
- /* =============================================================
- METRICS COMPARISON (Coverage-based)
- ============================================================= */
+ useEffect(() => { if (replaceModal.open) setReplaceActiveIdx(0); }, [replaceModal.open, replaceQuery]);
+ /* metrics comparison */
const [focusedMetricId, setFocusedMetricId] = useState(null);
const [showAllMetrics, setShowAllMetrics] = useState(false);
- // If focused metric is removed from selection, clear focus
- useEffect(() => {
- if (focusedMetricId && !selectedMetrics.includes(focusedMetricId)) {
- setFocusedMetricId(null);
- }
- }, [focusedMetricId, selectedMetrics]);
+ useEffect(() => { if (focusedMetricId && !selectedMetrics.includes(focusedMetricId)) setFocusedMetricId(null); }, [focusedMetricId, selectedMetrics]);
const activeMetricIds = focusedMetricId ? [focusedMetricId] : selectedMetrics;
const coverageRows = useMemo(() => {
if (!selectedMetrics.length) return [];
- const defs = selectedMetrics.map((id) => METRIC_BY_ID[id]).filter(Boolean);
-
- const rows = defs.map((def) => {
- const a = metricCoveragePercent(leftDoc, def.id);
- const b = metricCoveragePercent(rightDoc, def.id);
+ return selectedMetrics.map((id) => METRIC_BY_ID[id]).filter(Boolean).map((def) => {
+ const a = metricCoveragePercent(leftDoc, def.id), b = metricCoveragePercent(rightDoc, def.id);
const delta = (Number(b) || 0) - (Number(a) || 0);
- const absDelta = Math.abs(delta);
-
- return { def, left: Number(a) || 0, right: Number(b) || 0, delta, absDelta };
- });
-
- rows.sort((x, y) => y.absDelta - x.absDelta || String(x.def.title).localeCompare(String(y.def.title)));
- return rows;
+ return { def, left: Number(a)||0, right: Number(b)||0, delta, absDelta: Math.abs(delta) };
+ }).sort((x, y) => y.absDelta - x.absDelta || String(x.def.title).localeCompare(String(y.def.title)));
}, [selectedMetrics, leftDoc, rightDoc]);
const metricsSummary = useMemo(() => {
- if (!coverageRows.length) {
- return { mostIncreased: null, mostDecreased: null, mostStable: null };
- }
-
- const byDeltaDesc = [...coverageRows].sort((a, b) => b.delta - a.delta);
- const mostIncreased = byDeltaDesc[0] || null;
-
+ if (!coverageRows.length) return { mostIncreased: null, mostDecreased: null, mostStable: null };
+ const byDesc = [...coverageRows].sort((a, b) => b.delta - a.delta);
const byStable = [...coverageRows].sort((a, b) => a.absDelta - b.absDelta);
- const mostStable = byStable[0] || null;
-
- const negatives = coverageRows.filter((r) => r.delta < -0.0001);
- let mostDecreased = null;
- if (negatives.length) {
- negatives.sort((a, b) => a.delta - b.delta);
- mostDecreased = negatives[0];
- }
-
- return { mostIncreased, mostDecreased, mostStable };
+ const negs = coverageRows.filter((r) => r.delta < -0.0001).sort((a, b) => a.delta - b.delta);
+ return { mostIncreased: byDesc[0]||null, mostDecreased: negs[0]||null, mostStable: byStable[0]||null };
}, [coverageRows]);
const topChanges = useMemo(() => coverageRows.slice(0, 8), [coverageRows]);
- const allRemaining = useMemo(() => (coverageRows.length > 8 ? coverageRows.slice(8) : []), [coverageRows]);
+ const allRemaining = useMemo(() => coverageRows.length > 8 ? coverageRows.slice(8) : [], [coverageRows]);
- const leftEssayRef = useRef(null);
- const rightEssayRef = useRef(null);
+ const leftEssayRef = useRef(null), rightEssayRef = useRef(null);
const scrollToFirstHighlight = useCallback((metricId) => {
if (!metricId) return;
-
const sel = `mark[data-metrics*="${metricId}"], mark[data-primary-metric="${metricId}"]`;
-
- const leftEl = leftEssayRef.current ? leftEssayRef.current.querySelector(sel) : null;
- const rightEl = rightEssayRef.current ? rightEssayRef.current.querySelector(sel) : null;
-
- const target = leftEl || rightEl;
- if (target && typeof target.scrollIntoView === "function") {
- target.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
- }
+ const target = leftEssayRef.current?.querySelector(sel) || rightEssayRef.current?.querySelector(sel);
+ if (target) target.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
}, []);
- const focusMetric = useCallback(
- (metricId, shouldScroll = false) => {
- if (!metricId) return;
-
- setFocusedMetricId((cur) => (cur === metricId ? null : metricId));
-
- if (shouldScroll) setTimeout(() => scrollToFirstHighlight(metricId), 30);
- },
- [scrollToFirstHighlight]
- );
+ const focusMetric = useCallback((metricId, shouldScroll = false) => {
+ if (!metricId) return;
+ setFocusedMetricId((cur) => (cur === metricId ? null : metricId));
+ if (shouldScroll) setTimeout(() => scrollToFirstHighlight(metricId), 30);
+ }, [scrollToFirstHighlight]);
const focusedMeta = focusedMetricId ? METRIC_BY_ID[focusedMetricId] : null;
- const focusedExamples = useMemo(() => {
- if (!focusedMetricId) return { left: [], right: [] };
- return {
- left: extractMetricExamples(leftDoc, focusedMetricId, 2),
- right: extractMetricExamples(rightDoc, focusedMetricId, 2),
- };
- }, [focusedMetricId, leftDoc, rightDoc]);
-
return (
- {/* Metrics Modal (small screens) */}
+ {/* Mobile metrics modal */}
{metricsModalOpen && (
-
+
setMetricsModalOpen(false)} />
-
-
Metrics & Presets
-
Choose metrics and manage presets for this comparison.
-
-
-
-
+
Metrics
+
setMetricsModalOpen(false)} className="p-2 rounded-lg hover:bg-gray-50">
-
-
+
)}
- {/* Replace Modal */}
+ {/* Replace modal */}
{replaceModal.open && (
@@ -1561,446 +563,269 @@ export default function EssayComparison() {
-
- Replace {replaceModal.side === "left" ? "Left" : "Right"} document
-
+
Replace {replaceModal.side === "left" ? "left" : "right"} essay
- Student: {studentDisplayName}
- {" • "}
- Docs: {availableDocIds.length}
- {" • "}
- Current: {docTitle(currentIdForSide)}
+ {studentDisplayName} · {availableDocIds.length} essays available
-
-
-
+
-
-
{replaceMatches.length === 0 ? (
-
No matches.
- ) : (
- replaceMatches.map((id, idx) => {
- const isActive = idx === replaceActiveIdx;
- const isCurrent = id === currentIdForSide;
- const isOther = id === otherIdForSide;
-
- return (
-
replacePick(id)}
- onMouseEnter={() => setReplaceActiveIdx(idx)}
- className={`w-full text-left px-4 py-3 flex items-center gap-3 ${isActive ? "bg-emerald-50" : "hover:bg-gray-50"}`}
- >
-
-
{docTitle(id)}
-
{id}
-
-
-
- {isCurrent && (
-
-
- Current
-
- )}
- {isOther && !isCurrent && (
-
- Other side (will swap)
-
- )}
+
No matching essays.
+ ) : replaceMatches.map((id, idx) => {
+ const isCurrent = id === currentIdForSide, isOther = id === otherIdForSide;
+ const rawText = (loData?.students?.[studentID]?.documents?.[id]?.text || "").replace(/\s+/g, " ").trim();
+ const preview = rawText.slice(0, 120);
+ return (
+
replacePick(id)} onMouseEnter={() => setReplaceActiveIdx(idx)}
+ className={`w-full text-left px-4 py-3 flex items-start gap-3 border-b border-gray-50 last:border-0 ${idx === replaceActiveIdx ? "bg-emerald-50" : "hover:bg-gray-50"}`}>
+
+
+
{docTitle(id)}
+
+ {isCurrent && Current}
+ {isOther && !isCurrent && Other side}
+
-
- );
- })
- )}
+ {preview && (
+
+ {preview}{rawText.length > 120 ? "..." : ""}
+
+ )}
+
+
+ );
+ })}
-
-
- Cancel
-
-
- Done
-
+ Cancel
+ Done
)}
+ {/* Page */}
-
- {missingParams ? (
+ {missingParams && (
Missing URL params. Expected: ?student_id=...&ids=docA,docB
- ) : null}
- {urlReady ? (
-
-
+ )}
+ {urlReady && (
+
+
+
- ) : null}
+ )}
- {showLoadingIndicator ? (
+ {showLoadingIndicator && (
-
- {isDocsLoading ? "Loading document data…" : "Refreshing selected metrics…"}
-
-
-
- Keeping the current comparison visible while new data arrives.
+ {isDocsLoading ? "Loading essays..." : "Refreshing metrics..."}
+
Keeping the current comparison visible while new data arrives.
- ) : null}
+ )}
-
- {/* ✅ Sidebar replaced with MetricsPanel */}
-
-
-
+ {/* Apples-to-apples mismatch banner */}
+
-
-
-
-
- Swap documents
-
-
-
-
- Metrics & Presets
-
-
+
+
+
+
+
+
+
+ Swap documents
+
+
setMetricsModalOpen(true)}
+ className="inline-flex items-center gap-2 px-3 py-2 rounded-xl border border-gray-200 bg-white hover:bg-gray-50 text-sm text-gray-700 lg:hidden">
+ Metrics
+
+
- {/* Essays */}
-
- {/* Left */}
-
+ {/* Essay panels */}
+
+ {[
+ { essay: leftEssay, doc: leftDoc, side: "left", loading: leftDocLoading, displayIndex: 1, ref: leftEssayRef },
+ { essay: rightEssay, doc: rightDoc, side: "right", loading: rightDocLoading, displayIndex: 2, ref: rightEssayRef },
+ ].map(({ essay, doc, side, loading, displayIndex, ref }) => (
+
-
-
{leftEssay.title}
-
openReplace("left")}
- className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-200 bg-white hover:bg-gray-50 text-sm text-gray-700"
- title="Replace document"
- >
-
- Replace
+
+
+ {/* Humanized title — never raw doc ID */}
+
{essay.title}
+
+
+
openReplace(side)}
+ className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-200 bg-white hover:bg-gray-50 text-sm text-gray-700 shrink-0">
+ Replace
-
-
-
- {leftEssay.minutes} min
-
-
- {leftEssay.words.toLocaleString()} words
-
+
+
+
{essay.minutes} min
+
{essay.words.toLocaleString()} words
-
- {focusedMetricId ? (
+ {focusedMetricId && (
-
- Focus: {focusedMeta?.title || focusedMetricId}
+ Showing: {focusedMeta?.title || focusedMetricId}
- setFocusedMetricId(null)}
- className="text-xs px-2 py-1 rounded-full border border-gray-200 hover:bg-gray-50 text-gray-700"
- >
- Clear
-
+ setFocusedMetricId(null)} className="text-xs px-2 py-1 rounded-full border border-gray-200 hover:bg-gray-50 text-gray-700">Clear
- ) : null}
+ )}
-
- {leftDocLoading ? (
+ {loading ? (
-
Loading selected document…
-
Other sections stay available while this side updates.
+
Loading essay...
) : (
<>
-
- Hover highlights to see metric tooltip.
- {focusedMetricId ?
Showing only the focused metric highlights. : null}
+ {/* Plain-English baseline explanation */}
+
+ Highlights show where each selected signal appears in this essay.
+ Hover any highlight to see what it represents and how much of the essay it covers.
-
+
>
)}
+ ))}
+
- {/* Right */}
-
-
-
-
{rightEssay.title}
- openReplace("right")}
- className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-200 bg-white hover:bg-gray-50 text-sm text-gray-700"
- title="Replace document"
- >
-
- Replace
-
-
-
-
-
- {rightEssay.minutes} min
-
-
- {rightEssay.words.toLocaleString()} words
-
+ {/* What changed */}
+ {selectedMetrics.length > 0 && (
+
+
+
+
What changed between these essays
+
+ Each percentage shows how much of the essay contains that signal.
+ A higher percentage means the signal appears more throughout the writing.
-
- {focusedMetricId ? (
-
-
-
- Focus: {focusedMeta?.title || focusedMetricId}
-
- setFocusedMetricId(null)}
- className="text-xs px-2 py-1 rounded-full border border-gray-200 hover:bg-gray-50 text-gray-700"
- >
- Clear
-
-
- ) : null}
+ {focusedMetricId && (
+
setFocusedMetricId(null)}
+ className="inline-flex items-center gap-2 px-3 py-2 rounded-xl border border-gray-200 bg-white hover:bg-gray-50 text-sm text-gray-700">
+ Clear focus
+
+ )}
+
-
- {rightDocLoading ? (
-
-
-
-
Loading selected document…
-
Other sections stay available while this side updates.
-
-
- ) : (
- <>
-
- Hover highlights to see metric tooltip.
- {focusedMetricId ? Showing only the focused metric highlights. : null}
-
-
- >
- )}
-
+ {/* Summary cards */}
+
+
+
+
-
- {/* =========================
- What changed in the writing (Coverage)
- ========================= */}
- {selectedMetrics.length > 0 && (
-
-
+ {/* Top changes list */}
+
+
-
What is different
-
- Coverage = % of essay text highlighted for a signal.
-
+
Top changes
+
Click "Focus" to highlight that signal in both essays above.
+
+
+ Showing {Math.min(8, coverageRows.length)} of{" "}
+ {coverageRows.length}
-
- {focusedMetricId ? (
-
setFocusedMetricId(null)}
- className="inline-flex items-center gap-2 px-3 py-2 rounded-xl border border-gray-200 bg-white hover:bg-gray-50 text-sm text-gray-700"
- >
-
- Clear focus
-
- ) : null}
-
- {/* Story cards (Improved | Consistent | Dropped) */}
-
-
-
-
-
-
+
+ {topChanges.length === 0
+ ?
No metrics selected.
+ : topChanges.map((r) => (
+
focusMetric(r.def.id, false)}
+ onShow={() => { setFocusedMetricId(r.def.id); setTimeout(() => scrollToFirstHighlight(r.def.id), 30); }} />
+ ))
+ }
-
-
-
-
-
Top changes
-
- Click “Focus” to show only that metric’s highlights in both essays.
+ {coverageRows.length > 8 && (
+
+
setShowAllMetrics((v) => !v)}
+ className="inline-flex items-center gap-2 px-3 py-2 rounded-xl border border-gray-200 bg-white hover:bg-gray-50 text-sm text-gray-700">
+
+ {showAllMetrics ? "Hide remaining metrics" : `Show all ${coverageRows.length} metrics`}
+
+ {showAllMetrics && (
+
+ {allRemaining.map((r) => (
+ focusMetric(r.def.id, false)}
+ onShow={() => { setFocusedMetricId(r.def.id); setTimeout(() => scrollToFirstHighlight(r.def.id), 30); }} />
+ ))}
-
-
- Showing {Math.min(8, coverageRows.length)} of{" "}
- {coverageRows.length}
-
-
-
-
- {topChanges.length === 0 ? (
-
No metrics selected.
- ) : (
- topChanges.map((r) => (
-
focusMetric(r.def.id, false)}
- onShow={() => {
- setFocusedMetricId(r.def.id);
- setTimeout(() => scrollToFirstHighlight(r.def.id), 30);
- }}
- />
- ))
)}
-
- {coverageRows.length > 8 ? (
-
-
setShowAllMetrics((v) => !v)}
- className="inline-flex items-center gap-2 px-3 py-2 rounded-xl border border-gray-200 bg-white hover:bg-gray-50 text-sm text-gray-700"
- >
-
- {showAllMetrics ? "Hide all metrics" : "Show all metrics"}
-
-
- {showAllMetrics ? (
-
- {allRemaining.map((r) => (
- focusMetric(r.def.id, false)}
- onShow={() => {
- setFocusedMetricId(r.def.id);
- setTimeout(() => scrollToFirstHighlight(r.def.id), 30);
- }}
- />
- ))}
-
- ) : null}
-
- ) : null}
-
+ )}
- )}
-
-
+
+ )}
+
+
);
diff --git a/modules/portfolio_diff/src/app/students/components/StudentDetail/SingleEssayModel.js b/modules/portfolio_diff/src/app/students/components/StudentDetail/SingleEssayModel.js
index 88f7e80c..715ea3fe 100644
--- a/modules/portfolio_diff/src/app/students/components/StudentDetail/SingleEssayModel.js
+++ b/modules/portfolio_diff/src/app/students/components/StudentDetail/SingleEssayModel.js
@@ -1,617 +1,636 @@
"use client";
-import React, { useEffect, useMemo, useRef, useState } from "react";
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
- X,
- History,
- Sparkles,
- Gauge,
- Layers,
- Activity,
- Loader2,
- AlertTriangle,
- SlidersHorizontal,
- ArrowUpDown,
+ X, Gauge, Loader2, AlertTriangle, Info, Clock, Pencil,
+ ChevronLeft, ChevronRight, ChevronDown, ChevronUp,
+ TrendingUp, TrendingDown, Minus, Clipboard, AlertCircle,
+ FlaskConical, Lightbulb,
} from "lucide-react";
-import { MetricsPanel } from "@/app/components/MetricsPanel";
+import { MetricsPanel, METRIC_BY_ID, METRIC_TEACHER_DESC } from "@/app/components/MetricsPanel";
import { useLOConnectionDataManager } from "lo_event/lo_event/lo_assess/components/components.jsx";
import { useCourseIdContext } from "@/app/providers/CourseIdProvider";
import { getConfiguredWsOrigin } from "@/app/utils/ws";
-/* =========================================================
- Helpers
-========================================================= */
+/* ─────────────────────────────────────────────
+ 6+1 Trait helpers
+───────────────────────────────────────────── */
+const TRAIT_FOR_CATEGORY = {
+ language: "Word Choice", argumentation: "Ideas & Content",
+ statements: "Ideas & Content", transitions: "Organization",
+ pos: "Sentence Fluency", sentence_type: "Sentence Fluency",
+ source_information: "Ideas & Content", dialogue: "Voice",
+ tone: "Voice", details: "Ideas & Content", other: "Conventions",
+};
+
+function getMetricTrait(id) {
+ const def = METRIC_BY_ID?.[id];
+ if (def?.trait) return def.trait;
+ if (def?.categoryKey) return TRAIT_FOR_CATEGORY[def.categoryKey] || "Conventions";
+ return "Conventions";
+}
+function getMetricTitle(id) {
+ const def = METRIC_BY_ID?.[id];
+ if (def?.title) return def.title;
+ return String(id).replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
+}
-const DEBUG = true;
+const TRAIT_DOT = {
+ "Ideas & Content": "bg-amber-400",
+ "Organization": "bg-blue-400",
+ "Voice": "bg-rose-400",
+ "Word Choice": "bg-violet-400",
+ "Sentence Fluency": "bg-teal-400",
+ "Conventions": "bg-slate-400",
+};
+const TRAIT_PILL = {
+ "Ideas & Content": "bg-amber-50 text-amber-700 ring-amber-200",
+ "Organization": "bg-blue-50 text-blue-700 ring-blue-200",
+ "Voice": "bg-rose-50 text-rose-700 ring-rose-200",
+ "Word Choice": "bg-violet-50 text-violet-700 ring-violet-200",
+ "Sentence Fluency": "bg-teal-50 text-teal-700 ring-teal-200",
+ "Conventions": "bg-slate-50 text-slate-700 ring-slate-200",
+};
+
+function TraitDot({ trait, size = "h-2 w-2" }) {
+ return
;
+}
+function TraitPill({ trait }) {
+ const cls = TRAIT_PILL[trait] || "bg-gray-50 text-gray-600 ring-gray-200";
+ return (
+
+
+ {trait}
+
+ );
+}
-function clamp(v, lo, hi) {
- return Math.max(lo, Math.min(hi, v));
+/* ─────────────────────────────────────────────
+ Assignment type
+───────────────────────────────────────────── */
+const TYPE_COLORS = {
+ Narrative: { bg:"bg-violet-50", text:"text-violet-700", ring:"ring-violet-200", dot:"bg-violet-400" },
+ Argumentative: { bg:"bg-amber-50", text:"text-amber-700", ring:"ring-amber-200", dot:"bg-amber-400" },
+ Analytical: { bg:"bg-blue-50", text:"text-blue-700", ring:"ring-blue-200", dot:"bg-blue-400" },
+ Expository: { bg:"bg-teal-50", text:"text-teal-700", ring:"ring-teal-200", dot:"bg-teal-400" },
+ Document: { bg:"bg-emerald-50",text:"text-emerald-700",ring:"ring-emerald-200",dot:"bg-emerald-400" },
+ Other: { bg:"bg-gray-50", text:"text-gray-600", ring:"ring-gray-200", dot:"bg-gray-400" },
+};
+function inferAssignmentType(text) {
+ const t = (text || "").toLowerCase();
+ if (/argue|claim|thesis|evidence|counterargument/.test(t)) return "Argumentative";
+ if (/analyze|analysis|examine|compare|contrast/.test(t)) return "Analytical";
+ if (/once upon|story|character|plot|setting/.test(t)) return "Narrative";
+ if (/explain|describe|inform|definition/.test(t)) return "Expository";
+ return "Document";
+}
+function TypeBadge({ type }) {
+ const c = TYPE_COLORS[type] || TYPE_COLORS.Document;
+ return (
+
+ {type}
+
+ );
+}
+
+/* ─────────────────────────────────────────────
+ Constants
+───────────────────────────────────────────── */
+const DEFAULT_METRICS = [
+ "academic_language","informal_language","latinate_words",
+ "transition_words","citations","sentences","paragraphs",
+];
+
+const AVG_WORD_LENGTH_CHARS = 5;
+function charsToWords(chars) { return Math.round((chars || 0) / AVG_WORD_LENGTH_CHARS); }
+
+/* ─────────────────────────────────────────────
+ Extract real paste + time stats from doc node
+───────────────────────────────────────────── */
+function extractPasteStats(doc) {
+ if (!doc || typeof doc !== "object") {
+ return { largePasteCount: 0, totalPasteChars: 0, totalPasteWords: 0, copyCount: 0, allPasteCount: 0, timeOnTask: null };
+ }
+ const largePasteCount = doc.length_bins?.long_201_plus ?? 0;
+ const totalPasteChars = doc.total_paste_chars ?? 0;
+ return {
+ largePasteCount,
+ totalPasteChars,
+ totalPasteWords: charsToWords(totalPasteChars),
+ copyCount: doc.copy_count ?? 0,
+ allPasteCount: doc.pastes_with_length ?? 0,
+ timeOnTask: typeof doc.time_on_task === "number" ? doc.time_on_task : null,
+ };
}
+/* ─────────────────────────────────────────────
+ Process signal definitions
+───────────────────────────────────────────── */
+const PROCESS_DEFS = [
+ { key: "pause_to_word_ratio", label: "Pause-to-Word Ratio", desc: "How often the student pauses relative to words produced.", unit: "", category: "Fluency" },
+ { key: "revision_rate", label: "Revision Rate", desc: "Fraction of keystrokes that were deletions.", unit: "%", category: "Revision" },
+ { key: "burst_length", label: "Avg. Burst Length", desc: "Average words typed in uninterrupted runs.", unit: " wds", category: "Fluency" },
+ { key: "time_on_task_mins", label: "Time on Task", desc: "Total active writing time for this essay.", unit: " min", category: "Engagement" },
+ { key: "edit_count", label: "Edit Count", desc: "Total edit events. Reflects revision activity.", unit: "", category: "Revision" },
+];
+
+function deriveProcessMetrics(doc) {
+ if (!doc) return {};
+ return {
+ pause_to_word_ratio: doc?.pause_to_word_ratio ?? null,
+ revision_rate: doc?.revision_rate ?? null,
+ burst_length: doc?.avg_burst_length ?? null,
+ time_on_task_mins: doc?.time_on_task ?? doc?.time_on_task_mins ?? null,
+ edit_count: doc?.edit_count ?? null,
+ };
+}
+
+/* ─────────────────────────────────────────────
+ Pure helpers
+───────────────────────────────────────────── */
+function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function mean(arr) {
if (!arr.length) return 0;
return arr.reduce((s, x) => s + x, 0) / arr.length;
}
-
function stableStringify(obj) {
const seen = new WeakSet();
- const sortObj = (v) => {
+ const sort = (v) => {
if (v === null || typeof v !== "object") return v;
if (seen.has(v)) return "[Circular]";
seen.add(v);
- if (Array.isArray(v)) return v.map(sortObj);
- const keys = Object.keys(v).sort();
+ if (Array.isArray(v)) return v.map(sort);
const out = {};
- for (const k of keys) out[k] = sortObj(v[k]);
+ for (const k of Object.keys(v).sort()) out[k] = sort(v[k]);
return out;
};
- try {
- return JSON.stringify(sortObj(obj));
- } catch {
- return String(obj);
- }
+ try { return JSON.stringify(sort(obj)); } catch { return String(obj); }
}
-
-/**
- * Robust normalization to handle whatever MetricsPanel emits.
- */
-function normalizeSelectedMetrics(input) {
+function normalizeMetrics(input) {
if (!input) return [];
-
- const pickMetricId = (x) => {
+ const pick = (x) => {
if (!x) return null;
if (typeof x === "string") return x;
-
- if (x && typeof x === "object") {
- return (
- x.metricKey ||
- x.metric_key ||
- x.metricId ||
- x.metric_id ||
- x.metric ||
- x.metric_name ||
- x.metricName ||
- x.id ||
- x.key ||
- x.name ||
- x.value ||
- x.label ||
- null
- );
- }
+ if (typeof x === "object") return x.metricKey || x.id || x.key || x.name || x.value || null;
return null;
};
-
- if (Array.isArray(input)) {
- return input
- .map(pickMetricId)
- .filter(Boolean)
- .map((s) => String(s).trim())
- .filter(Boolean);
- }
-
- if (typeof input === "object") {
- return Object.entries(input)
- .filter(([, v]) => !!v)
- .map(([k]) => String(k).trim())
- .filter(Boolean);
- }
-
+ if (Array.isArray(input)) return input.map(pick).filter(Boolean).map((s) => String(s).trim()).filter(Boolean);
+ if (typeof input === "object") return Object.entries(input).filter(([, v]) => !!v).map(([k]) => String(k).trim()).filter(Boolean);
return [];
}
-function metricCoveragePercent(doc, metricId) {
+/* ─────────────────────────────────────────────
+ metricValue — the single source of truth for
+ reading a metric number from a doc node.
+
+ Priority order:
+ 1. doc[metricId].metric — pre-computed number
+ from the NLP pipeline (present in WS response
+ as confirmed in the JSON: "metric": 19).
+ This is the correct value to use for trajectory
+ comparison since it's what the pipeline computed.
+ 2. Offset-based coverage % — fallback for older
+ data shapes that don't have .metric yet.
+───────────────────────────────────────────── */
+function metricValue(doc, id) {
+ // Prefer the pre-computed metric value — already the right number
+ const direct = doc?.[id]?.metric;
+ if (direct != null && Number.isFinite(Number(direct))) return Number(direct);
+
+ // Fallback: compute coverage % from character offsets
const text = (doc?.text || "").toString();
const L = text.length;
if (!L) return 0;
-
- const offsets = doc?.[metricId]?.offsets;
- if (!Array.isArray(offsets) || offsets.length === 0) return 0;
-
+ const offsets = doc?.[id]?.offsets;
+ if (!Array.isArray(offsets) || !offsets.length) return 0;
const ranges = [];
- for (const pair of offsets) {
- if (!Array.isArray(pair) || pair.length < 2) continue;
- const start = Number(pair[0]);
- const len = Number(pair[1]);
- if (!Number.isFinite(start) || !Number.isFinite(len) || len <= 0) continue;
-
- const s = clamp(start, 0, L);
- const e = clamp(start + len, 0, L);
+ for (const p of offsets) {
+ if (!Array.isArray(p) || p.length < 2) continue;
+ const s0 = Number(p[0]), len = Number(p[1]);
+ if (!Number.isFinite(s0) || !Number.isFinite(len) || len <= 0) continue;
+ const s = clamp(s0, 0, L), e = clamp(s0 + len, 0, L);
if (e > s) ranges.push([s, e]);
}
if (!ranges.length) return 0;
-
- ranges.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
-
- let covered = 0;
- let [curS, curE] = ranges[0];
+ ranges.sort((a, b) => a[0] - b[0]);
+ let covered = 0, [cs, ce] = ranges[0];
for (let i = 1; i < ranges.length; i++) {
const [s, e] = ranges[i];
- if (s <= curE) curE = Math.max(curE, e);
- else {
- covered += curE - curS;
- curS = s;
- curE = e;
- }
+ if (s <= ce) ce = Math.max(ce, e);
+ else { covered += ce - cs; cs = s; ce = e; }
}
- covered += curE - curS;
- return (covered / L) * 100;
+ return ((covered + ce - cs) / L) * 100;
}
-function initialsFromStudentKey(studentKey) {
- const s = String(studentKey || "").trim();
- if (!s) return "ST";
- const parts = s.split(/[^a-zA-Z0-9]+/).filter(Boolean);
- const a = (parts[0]?.[0] || "S").toUpperCase();
- const b = (parts[1]?.[0] || "T").toUpperCase();
- return `${a}${b}`.slice(0, 2);
-}
-
-/* =========================================================
- Charts (Auto-zoom for low ranges + delta annotation)
-========================================================= */
-
-function niceStep(range) {
- if (range <= 0.5) return 0.1;
- if (range <= 1) return 0.2;
- if (range <= 2) return 0.5;
- if (range <= 5) return 1;
- if (range <= 10) return 2;
- if (range <= 20) return 5;
- if (range <= 40) return 10;
- return 25;
-}
-
-function buildTicks(yMin, yMax, maxTicks = 5) {
- const span = Math.max(1e-6, yMax - yMin);
- const step = niceStep(span);
- const start = Math.floor(yMin / step) * step;
- const end = Math.ceil(yMax / step) * step;
+/* ─────────────────────────────────────────────
+ INSTRUCTIONAL SUGGESTION LOGIC
+───────────────────────────────────────────── */
+function getMetricSuggestion(metricKey, trait, delta, assignmentType) {
+ const title = getMetricTitle(metricKey);
+ const drop = Math.abs(delta).toFixed(1);
+
+ const specific = {
+ academic_language: `Academic language coverage dropped ${drop}pts on this essay. Before the next assignment, try a targeted vocabulary activity — give the student a word bank of formal alternatives for common informal terms they use.`,
+ informal_language: `Informal language increased by ${drop}pts. A focused editing pass before submission — specifically looking for casual phrasing — can help. Consider a brief 1:1 to discuss register and audience awareness.`,
+ latinate_words: `Use of Latinate (sophisticated) vocabulary fell ${drop}pts. Modeling three or four academic synonyms during whole-class discussion, or building a personal word wall for this student, could help rebuild this over the next few essays.`,
+ transition_words: `Transition word coverage dropped ${drop}pts. A sentence-starter scaffold for the next assignment — listing connective phrases the student can draw from — often produces quick improvement in this dimension.`,
+ citations: `Citation use declined ${drop}pts. Check whether the assignment prompt required source integration; if so, a brief conference reviewing how to embed and introduce evidence is warranted before the next task.`,
+ sentences: `Sentence count or variety fell ${drop}pts. A sentence-combining revision exercise focused on this essay — asking the student to merge two short sentences — can build structural complexity over time.`,
+ paragraphs: `Paragraph structure declined ${drop}pts. A quick conference reviewing paragraph expectations (topic sentence, evidence, wrap-up) before the next assignment may help this student rebuild organizational habits.`,
+ explicit_argument: `Explicit argument signals weakened ${drop}pts. Consider a targeted mini-lesson on thesis statement framing or claim-evidence structure before the next argumentative task.`,
+ concrete_details: `Use of concrete details dropped ${drop}pts. Ask the student to identify one claim in this essay and add a specific example or piece of evidence — this targeted revision habit builds detail use over time.`,
+ };
- const ticks = [];
- for (let v = start; v <= end + 1e-9; v += step) {
- if (v >= yMin - 1e-9 && v <= yMax + 1e-9) ticks.push(Number(v.toFixed(3)));
- }
+ if (specific[metricKey]) return specific[metricKey];
- if (ticks.length > maxTicks) {
- const stride = Math.ceil(ticks.length / maxTicks);
- return ticks.filter((_, i) => i % stride === 0);
- }
+ const traitFallback = {
+ "Word Choice": `${title} (Word Choice) fell ${drop}pts. A targeted vocabulary activity or editing checklist before the next assignment can help reinforce this dimension.`,
+ "Ideas & Content": `${title} (Ideas & Content) declined ${drop}pts. Consider a brief conference on the specific content expectation before the next essay.`,
+ "Organization": `${title} (Organization) dropped ${drop}pts. Review structural expectations explicitly with this student — a graphic organizer for the next assignment can help anchor organization.`,
+ "Voice": `${title} (Voice) fell ${drop}pts. Encourage the student to read a paragraph of their essay aloud — this often reveals where their voice feels flat or inconsistent.`,
+ "Sentence Fluency": `${title} (Sentence Fluency) declined ${drop}pts. A sentence-level revision task on this essay can help; ask the student to vary the opening of three sentences.`,
+ "Conventions": `${title} (Conventions) dropped ${drop}pts. A targeted editing pass focused specifically on this convention before the next submission may help.`,
+ };
- if (!ticks.length) return [yMin, yMax];
- if (ticks[0] !== yMin) ticks.unshift(yMin);
- if (ticks[ticks.length - 1] !== yMax) ticks.push(yMax);
- return ticks;
+ return traitFallback[trait] || `${title} declined ${drop}pts on this essay. Consider targeted feedback before the next assignment.`;
}
-function BaselineCurrentChart({
- baselinePct,
- currentPct,
- height = 120,
- autoZoom = true,
-}) {
- const width = 520;
- const padL = 42;
- const padR = 10;
- const padT = 10;
- const padB = 28;
-
- const b = Number.isFinite(baselinePct) ? Number(baselinePct) : 0;
- const c = Number.isFinite(currentPct) ? Number(currentPct) : 0;
-
- const deltaPP = c - b;
- const relDelta = b !== 0 ? (deltaPP / b) * 100 : null;
-
- const minVal = Math.min(b, c);
- const maxVal = Math.max(b, c);
- const spread = maxVal - minVal;
-
- const shouldZoom =
- autoZoom && (maxVal <= 15 || spread <= 3) && maxVal < 98;
-
- let yMin = 0;
- let yMax = 100;
- let zoomLabel = "";
-
- if (shouldZoom) {
- const pad = Math.max(0.6, spread * 0.6);
- yMin = clamp(minVal - pad, 0, 100);
- yMax = clamp(maxVal + pad, 0, 100);
-
- const minSpan = 4;
- if (yMax - yMin < minSpan) {
- const mid = (yMin + yMax) / 2;
- yMin = clamp(mid - minSpan / 2, 0, 100);
- yMax = clamp(mid + minSpan / 2, 0, 100);
- }
+function MetricSuggestion({ metricKey, trait, delta, assignmentType }) {
+ const [expanded, setExpanded] = useState(false);
+ const suggestion = getMetricSuggestion(metricKey, trait, delta, assignmentType);
+ return (
+
+
setExpanded((v) => !v)}
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-left hover:bg-amber-100 transition-colors">
+
+
+ {expanded ? "Hide suggestion" : "Show suggestion"}
+
+
+ {expanded && (
+
+
{suggestion}
+
+ Verify against your knowledge of this student and assignment context before acting.
+
+
+ )}
+
+ );
+}
- const span = yMax - yMin;
- const step = niceStep(span);
- yMin = clamp(Math.floor(yMin / step) * step, 0, 100);
- yMax = clamp(Math.ceil(yMax / step) * step, 0, 100);
+/* ─────────────────────────────────────────────
+ MetricRow — shows direction label + raw values.
+ Unit label adapts to the metric's summary_type:
+ percent → show as "X.0" (already a %)
+ total → show as integer count
+ counts → show as integer count
+───────────────────────────────────────────── */
+function MetricRow({ metricKey, baseline, currentValue, assignmentType }) {
+ const title = getMetricTitle(metricKey);
+ const trait = getMetricTrait(metricKey);
+ const desc = METRIC_TEACHER_DESC?.[metricKey] || "";
+ const delta = currentValue - baseline;
+ const isUp = delta > 0.15;
+ const isDown = delta < -0.15;
+ const absDelta = Math.abs(delta);
+ const qualifier = absDelta < 1 ? "Slightly" : absDelta < 3 ? "Notably" : "Much";
+ const directionLabel = isUp
+ ? `↑ ${qualifier} higher than usual`
+ : isDown
+ ? `↓ ${qualifier} lower than usual`
+ : "About the same as usual";
+ const directionColor = isUp
+ ? absDelta >= 3 ? "text-emerald-700" : absDelta >= 1 ? "text-emerald-600" : "text-emerald-500"
+ : isDown
+ ? absDelta >= 3 ? "text-rose-700" : absDelta >= 1 ? "text-rose-500" : "text-rose-400"
+ : "text-gray-400";
+
+ // Format the raw value sensibly:
+ // percent metrics → show with 1 decimal + "%"
+ // total/count metrics → show as integer
+ const def = METRIC_BY_ID?.[metricKey];
+ const isPercent = def?.function === "percent";
+ const fmt = (v) => isPercent ? `${Number(v).toFixed(1)}%` : `${Math.round(Number(v))}`;
- if (yMax <= yMin) {
- yMin = clamp(minVal - 2, 0, 100);
- yMax = clamp(maxVal + 2, 0, 100);
- }
+ return (
+ <>
+
+
+
+
+ {title}
+
+ {desc &&
{desc}
}
+
+
+
{directionLabel}
+
+ Usual: {fmt(baseline)} · This essay: {fmt(currentValue)}
+
+
+
+ {isDown && (
+
+ )}
+ >
+ );
+}
- zoomLabel = `Zoomed: ${yMin.toFixed(1)}–${yMax.toFixed(1)}%`;
+function MetricSummaryPanel({ metricSummaries, assignmentType }) {
+ const [collapsed, setCollapsed] = useState({});
+ const TRAIT_ORDER = ["Ideas & Content","Organization","Voice","Word Choice","Sentence Fluency","Conventions"];
+ const byTrait = {};
+ for (const m of metricSummaries) {
+ const t = getMetricTrait(m.key);
+ if (!byTrait[t]) byTrait[t] = [];
+ byTrait[t].push(m);
}
-
- const xBaseline = padL;
- const xCurrent = width - padR;
-
- const Y = (v) => {
- const t = (v - yMin) / Math.max(1e-6, yMax - yMin);
- return padT + (1 - t) * (height - padT - padB);
- };
-
- const yBaseline = Y(b);
- const yCurrent = Y(c);
-
- const ticks = shouldZoom ? buildTicks(yMin, yMax, 5) : [0, 25, 50, 75, 100];
-
- const tickFmt = (v) => {
- const span = yMax - yMin;
- const needsDecimal = span <= 10 || shouldZoom;
- return `${needsDecimal ? Number(v).toFixed(1) : Number(v).toFixed(0)}%`;
- };
-
- const deltaText = `${deltaPP >= 0 ? "+" : ""}${deltaPP.toFixed(1)} pp${
- relDelta === null ? "" : ` (${relDelta >= 0 ? "+" : ""}${relDelta.toFixed(0)}%)`
- }`;
+ const traitGroups = TRAIT_ORDER.filter((t) => byTrait[t]?.length);
+ if (!metricSummaries.length) return null;
return (
-