diff --git a/VERSION b/VERSION index c3e9c3de..00ee180f 100644 --- a/VERSION +++ b/VERSION @@ -1,5 +1 @@ -<<<<<<< HEAD -0.1.0+2025.10.01T21.16.15.146Z.8b205d0c.master -======= -0.1.0+2026.04.23T20.03.53.409Z.887fd5d9.berickson.20260423.test.fix ->>>>>>> upstream/master +0.1.0+2026.06.08T14.57.09.3NZ.c99f98d.refactor.portfolio.diff diff --git a/modules/lo_event/lo_event/lo_assess/components/components.jsx b/modules/lo_event/lo_event/lo_assess/components/components.jsx new file mode 100644 index 00000000..f1f6f060 --- /dev/null +++ b/modules/lo_event/lo_event/lo_assess/components/components.jsx @@ -0,0 +1,14 @@ +/** + * Public exports for the LO WebSocket connection layer. + * + * Provides hooks for subscribing to real-time Learning Observer data + * (useLOConnectionDataManager, useLOConnection), a timestamp display + * component (LOConnectionLastUpdated), and the connection status enum + * (LO_CONNECTION_STATUS). + */ + +export { useLOConnectionDataManager } from "./utilities/useLOConnectionDataManager.jsx"; +export { useLOConnection } from "./utilities/useLOConnection.jsx"; +export { LOConnectionLastUpdated } from "./utilities/LOConnectionLastUpdated.jsx"; + +export { LO_CONNECTION_STATUS } from "./constants/LO_CONNECTION_STATUS.jsx" \ No newline at end of file diff --git a/modules/lo_event/lo_event/lo_assess/components/utilities/LOConnectionLastUpdated.jsx b/modules/lo_event/lo_event/lo_assess/components/utilities/LOConnectionLastUpdated.jsx index 1c9bbb00..4032b712 100644 --- a/modules/lo_event/lo_event/lo_assess/components/utilities/LOConnectionLastUpdated.jsx +++ b/modules/lo_event/lo_event/lo_assess/components/utilities/LOConnectionLastUpdated.jsx @@ -63,7 +63,7 @@ export const LOConnectionLastUpdated = ({ message, connectionStatus, showText=fa
{showText ? {titles[connectionStatus]} : ''} - {lastUpdatedMessage} + {lastUpdatedMessage}
); }; diff --git a/modules/portfolio_diff/src/app/components/MetricsPanel.js b/modules/portfolio_diff/src/app/components/MetricsPanel.js index e44412ee..f86b90b1 100644 --- a/modules/portfolio_diff/src/app/components/MetricsPanel.js +++ b/modules/portfolio_diff/src/app/components/MetricsPanel.js @@ -1,9 +1,9 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; import { ArrowLeftRight, ChevronDown, + ChevronUp, FileText, Gauge, Languages, @@ -14,676 +14,482 @@ import { Speech, Trash2, Users, - WholeWord, + WholeWord } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; -/* ---------------------- deterministic helpers ---------------------- */ +/* ── deterministic seed (for highlight swatches) ── */ const seedFrom = (s) => { let h = 2166136261; for (let i = 0; i < s.length; i++) h = ((h ^ s.charCodeAt(i)) * 16777619) >>> 0; return h >>> 0; }; - 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 = (id) => + HIGHLIGHT_CLASSES[seedFrom(id || "metric") % HIGHLIGHT_CLASSES.length]; + +/* ══════════════════════════════════════════════════════════════ + 6+1 TRAIT MAPPING + Every metric is mapped to both its internal category AND its + 6+1 Trait rubric label so both grouping modes work from one + source of truth. + ══════════════════════════════════════════════════════════════ */ +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", +}; -const highlightClassForMetric = (metricId) => { - const idx = seedFrom(metricId || "metric") % HIGHLIGHT_CLASSES.length; - return HIGHLIGHT_CLASSES[idx]; +// Canonical display label for each internal category key +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", }; -/* ============================================================= - METRICS (FULL LIST) — matches EssayComparison - ============================================================= */ +// Canonical trait order for 6+1 grouping +const TRAIT_ORDER = [ + "Ideas & Content", + "Organization", + "Voice", + "Word Choice", + "Sentence Fluency", + "Conventions", +]; -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 TRAIT_STYLE = { + "Ideas & Content": { dot:"bg-amber-400", header:"bg-amber-50 text-amber-800 border-amber-200", badge:"bg-amber-50 text-amber-700 ring-amber-200" }, + "Organization": { dot:"bg-blue-400", header:"bg-blue-50 text-blue-800 border-blue-200", badge:"bg-blue-50 text-blue-700 ring-blue-200" }, + "Voice": { dot:"bg-rose-400", header:"bg-rose-50 text-rose-800 border-rose-200", badge:"bg-rose-50 text-rose-700 ring-rose-200" }, + "Word Choice": { dot:"bg-violet-400", header:"bg-violet-50 text-violet-800 border-violet-200",badge:"bg-violet-50 text-violet-700 ring-violet-200" }, + "Sentence Fluency": { dot:"bg-teal-400", header:"bg-teal-50 text-teal-800 border-teal-200", badge:"bg-teal-50 text-teal-700 ring-teal-200" }, + "Conventions": { dot:"bg-slate-400", header:"bg-slate-50 text-slate-800 border-slate-200", badge:"bg-slate-50 text-slate-700 ring-slate-200" }, }; 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 map = { + language: Languages, argumentation: MessagesSquare, statements: MessageSquareText, + transitions: ArrowLeftRight, pos: Speech, sentence_type: WholeWord, + source_information: Quote, dialogue: Users, tone: Gauge, details: ListCollapse, + }; + return map[catKey] || FileText; }; +/* ── full metric definitions ── */ +const mk = (id, title, catKey, fn, desc) => ({ + id, title, + icon: iconForCategory(catKey), + categoryKey: catKey, + category: CATEGORY_LABELS[catKey], + trait: TRAIT_FOR_CATEGORY[catKey], + function: fn, + desc, +}); + 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", - }, + mk("academic_language", "Academic Language", "language", "percent","Percent of tokens flagged academic"), + mk("informal_language", "Informal Language", "language", "percent","Percent of tokens flagged informal"), + mk("latinate_words", "Latinate Words", "language", "percent","Percent of tokens flagged latinate"), + mk("opinion_words", "Opinion Words", "language", "total", "Total opinion-word signals"), + mk("emotion_words", "Emotion Words", "language", "percent","Percent emotion words"), + mk("argument_words", "Argument Words", "argumentation", "percent","Percent argument words"), + mk("explicit_argument", "Explicit Argument", "argumentation", "percent","Percent explicit argument markers"), + mk("statements_of_opinion","Statements of Opinion","statements", "percent","Percent of sentences classified as opinion"), + mk("statements_of_fact", "Statements of Fact", "statements", "percent","Percent of sentences classified as fact"), + mk("transition_words", "Transition Words", "transitions", "counts", "Transition counts (by type)"), + mk("positive_transition_words", "Positive Transitions", "transitions","total","Total positive transitions"), + mk("conditional_transition_words", "Conditional Transitions", "transitions","total","Total conditional transitions"), + mk("consequential_transition_words","Consequential Transitions", "transitions","total","Total consequential transitions"), + mk("contrastive_transition_words", "Contrastive Transitions", "transitions","total","Total contrastive transitions"), + mk("counterpoint_transition_words", "Counterpoint Transitions", "transitions","total","Total counterpoint transitions"), + mk("comparative_transition_words", "Comparative Transitions", "transitions","total","Total comparative transitions"), + mk("cross_referential_transition_words","Cross-Referential Transitions","transitions","total","Total cross-referential transitions"), + mk("illustrative_transition_words", "Illustrative Transitions", "transitions","total","Total illustrative transitions"), + mk("negative_transition_words", "Negative Transitions", "transitions","total","Total negative transitions"), + mk("emphatic_transition_words", "Emphatic Transitions", "transitions","total","Total emphatic transitions"), + mk("evenidentiary_transition_words","Evidentiary Transitions", "transitions","total","Total evidentiary transitions"), + mk("general_transition_words", "General Transitions", "transitions","total","Total general transitions"), + mk("ordinal_transition_words", "Ordinal Transitions", "transitions","total","Total ordinal transitions"), + mk("purposive_transition_words", "Purposive Transitions", "transitions","total","Total purposive transitions"), + mk("periphrastic_transition_words", "Periphrastic Transitions", "transitions","total","Total periphrastic transitions"), + mk("hypothetical_transition_words", "Hypothetical Transitions", "transitions","total","Total hypothetical transitions"), + mk("summative_transition_words", "Summative Transitions", "transitions","total","Total summative transitions"), + mk("introductory_transition_words", "Introductory Transitions", "transitions","total","Total introductory transitions"), + mk("adjectives", "Adjectives", "pos", "total","Total adjectives"), + mk("adverbs", "Adverbs", "pos", "total","Total adverbs"), + mk("nouns", "Nouns", "pos", "total","Total nouns"), + mk("proper_nouns", "Proper Nouns", "pos", "total","Total proper nouns"), + mk("verbs", "Verbs", "pos", "total","Total verbs"), + mk("numbers", "Numbers", "pos", "total","Total numbers"), + mk("prepositions", "Prepositions", "pos", "total","Total prepositions"), + mk("coordinating_conjunction", "Coordinating Conjunctions", "pos", "total","Total coordinating conjunctions"), + mk("subordinating_conjunction","Subordinating Conjunctions","pos", "total","Total subordinating conjunctions"), + mk("auxiliary_verb", "Auxiliary Verbs", "pos", "total","Total auxiliary verbs"), + mk("pronoun", "Pronouns", "pos", "total","Total pronouns"), + mk("simple_sentences", "Simple Sentences", "sentence_type","total","Total simple sentences"), + mk("simple_with_complex_predicates", "Simple + Complex Predicates", "sentence_type","total","Total simple (complex predicates)"), + mk("simple_with_compound_predicates", "Simple + Compound Predicates", "sentence_type","total","Total simple (compound predicates)"), + mk("simple_with_compound_complex_predicates","Simple + Compound-Complex Predicates","sentence_type","total","Total simple (compound complex predicates)"), + mk("compound_sentences", "Compound Sentences", "sentence_type", "total","Total compound sentences"), + mk("complex_sentences", "Complex Sentences", "sentence_type", "total","Total complex sentences"), + mk("compound_complex_sentences","Compound-Complex Sentences","sentence_type", "total","Total compound-complex sentences"), + mk("information_sources","Information Sources","source_information","percent","Percent source references"), + mk("attributions", "Attributions", "source_information","percent","Percent attributions"), + mk("citations", "Citations", "source_information","percent","Percent citations"), + mk("quoted_words", "Quoted Words", "source_information","percent","Percent quoted words"), + mk("direct_speech_verbs","Direct Speech Verbs","dialogue", "percent","Percent direct speech verbs"), + mk("indirect_speech", "Indirect Speech", "dialogue", "percent","Percent indirect speech"), + mk("positive_tone", "Positive Tone", "tone", "percent","Percent positive tone"), + mk("negative_tone", "Negative Tone", "tone", "percent","Percent negative tone"), + mk("concrete_details", "Concrete Details", "details","percent","Percent concrete details"), + mk("main_idea_sentences", "Main Idea Sentences", "details","total", "Total main idea sentences"), + mk("supporting_idea_sentences","Supporting Idea Sentences","details","total", "Total supporting idea sentences"), + mk("supporting_detail_sentences","Supporting Detail Sentences","details","total","Total supporting detail sentences"), + mk("polysyllabic_words", "Polysyllabic Words", "other","percent","Percent polysyllabic tokens"), + mk("low_frequency_words", "Low Frequency Words", "other","percent","Percent low-frequency tokens"), + mk("sentences", "Sentences", "other","total", "Total sentences"), + mk("paragraphs", "Paragraphs", "other","total", "Total paragraphs"), + mk("character_trait_words","Character Trait Words","other","percent","Percent character trait tokens"), + mk("in_past_tense", "In Past Tense", "other","percent","Percent past tense scope"), + mk("explicit_claims", "Explicit Claims", "other","percent","Percent explicit claims"), + mk("social_awareness", "Social Awareness", "other","percent","Percent social awareness"), ]; -const ALL_KEYS = METRIC_DEFS.map((m) => m.id); + +/* ── Teacher-friendly metric descriptions (exported for use in other pages) ── */ +export const METRIC_TEACHER_DESC = { + academic_language: "Words signalling formal register — e.g. 'analyze', 'demonstrate'", + informal_language: "Casual words that may not suit the assignment genre", + latinate_words: "Longer Latin-derived words linked to sophisticated vocabulary", + opinion_words: "Words expressing the writer's personal stance or judgment", + emotion_words: "Words conveying feeling — useful in narrative, limiting in argument", + argument_words: "Words signalling reasoning — e.g. 'because', 'therefore'", + explicit_argument: "Phrases making the writer's claim directly visible to the reader", + statements_of_opinion: "Sentences where the writer expresses a personal view", + statements_of_fact: "Sentences asserting something as objectively true", + transition_words: "Words and phrases connecting ideas within and across sentences", + positive_transition_words: "Additive transitions — e.g. 'furthermore', 'in addition'", + conditional_transition_words: "Conditional transitions — e.g. 'if', 'unless'", + consequential_transition_words:"Cause-and-effect transitions — e.g. 'therefore', 'as a result'", + contrastive_transition_words: "Contrasting transitions — e.g. 'however', 'on the other hand'", + counterpoint_transition_words: "Transitions introducing a counterpoint or concession", + comparative_transition_words: "Comparison transitions — e.g. 'similarly', 'likewise'", + cross_referential_transition_words: "Transitions pointing back or forward in the text", + illustrative_transition_words: "Example transitions — e.g. 'for instance', 'such as'", + negative_transition_words: "Transitions signalling negation or contrast", + emphatic_transition_words: "Emphatic transitions — e.g. 'indeed', 'above all'", + evenidentiary_transition_words:"Transitions introducing evidence or support", + general_transition_words: "Common connective words used broadly", + ordinal_transition_words: "Sequence transitions — e.g. 'first', 'next', 'finally'", + purposive_transition_words: "Purpose transitions — e.g. 'in order to', 'so that'", + periphrastic_transition_words: "Transition phrases using indirect phrasing", + hypothetical_transition_words: "Hypothetical transitions — e.g. 'suppose that'", + summative_transition_words: "Summary transitions — e.g. 'in conclusion', 'overall'", + introductory_transition_words: "Transitions that open a point or section", + adjectives: "Describing words — how the student characterises people and things", + adverbs: "Modifying words that qualify verbs, adjectives, or other adverbs", + nouns: "The things and concepts the student writes about", + proper_nouns: "Named people, places, or organisations — signals specific detail", + verbs: "Action or state words — reflect the energy and precision of writing", + numbers: "Numeric references — can signal use of data or evidence", + prepositions: "Words showing relationships in time or space", + coordinating_conjunction: "Words joining equal clauses — e.g. 'and', 'but', 'or'", + subordinating_conjunction: "Words creating complex sentences — e.g. 'although', 'because'", + auxiliary_verb: "Helping verbs that shape tense and mood — e.g. 'would', 'could'", + pronoun: "Words replacing nouns — affect clarity and point of view", + simple_sentences: "Single-clause sentences — easier to read but less complex", + simple_with_complex_predicates:"Simple sentences with a richer predicate structure", + simple_with_compound_predicates:"Simple sentences with more than one action or state", + simple_with_compound_complex_predicates: "Simple sentences with the highest predicate complexity", + compound_sentences: "Two independent clauses joined — shows coordination", + complex_sentences: "A main clause with at least one subordinate clause", + compound_complex_sentences: "The most structurally complex sentence type", + information_sources: "Overall use of outside evidence and references", + attributions: "Credit given to a speaker or source — e.g. 'According to...'", + citations: "Direct references to an outside source", + quoted_words: "Exact words borrowed from a source in quotation marks", + direct_speech_verbs: "Verbs introducing dialogue — e.g. 'said', 'asked', 'replied'", + indirect_speech: "Reported speech summarising rather than quoting", + positive_tone: "Language carrying an optimistic or affirmative feeling", + negative_tone: "Language carrying a critical or opposing feeling", + concrete_details: "Specific, tangible examples rather than vague abstractions", + main_idea_sentences: "Sentences introducing or summarising a central point", + supporting_idea_sentences: "Sentences developing or expanding on a main idea", + supporting_detail_sentences: "Sentences providing specific evidence or examples", + polysyllabic_words: "Words of three or more syllables — rough measure of vocabulary complexity", + low_frequency_words: "Uncommon words not in everyday language — signals advanced vocabulary", + sentences: "Total sentences — a basic measure of text length", + paragraphs: "Total paragraphs — reflects structural organisation", + character_trait_words: "Words describing character qualities — important in narrative writing", + in_past_tense: "Past-tense scope — important for narrative coherence", + explicit_claims: "Direct statements of position or argument", + social_awareness: "Language indicating awareness of community or broader context", +}; + +/* ── Lookup maps (exported for use in other pages) ── */ +export const METRIC_BY_ID = Object.fromEntries(METRIC_DEFS.map((m) => [m.id, m])); + +export const ALL_KEYS = METRIC_DEFS.map((m) => m.id); + +/* ── derived groupings ── */ + +// category-mode: list of unique category display labels in original order const CATEGORIES = Array.from(new Set(METRIC_DEFS.map((m) => m.category))); +// trait-mode: for each trait, for each sub-category within it, list metrics +const TRAIT_SUBCATEGORY_MAP = (() => { + const out = {}; + for (const trait of TRAIT_ORDER) out[trait] = {}; // { catLabel: [metricDef, ...] } + for (const m of METRIC_DEFS) { + const trait = m.trait; + const cat = m.category; + if (!out[trait]) out[trait] = {}; + if (!out[trait][cat]) out[trait][cat] = []; + out[trait][cat].push(m); + } + return out; +})(); + +/* ── presets ── */ const DEFAULT_PRESETS = { "Core (language + structure)": [ - "academic_language", - "informal_language", - "latinate_words", - "transition_words", - "citations", - "sentences", - "paragraphs", + "academic_language","informal_language","latinate_words", + "transition_words","citations","sentences","paragraphs", ], - "Sources & Evidence": ["information_sources", "attributions", "citations", "quoted_words"], + "Sources & Evidence": ["information_sources","attributions","citations","quoted_words"], }; - const PRESETS_STORAGE_KEY = "wo_metric_presets_v1"; -/* ---------------------- presets helpers ---------------------- */ -function safeParseJSON(s) { - try { - return JSON.parse(s); - } catch { - return null; - } -} - +function safeParseJSON(s) { try { return JSON.parse(s); } catch { return null; } } function normalizePresetMetrics(arr) { - const uniq = Array.from(new Set((arr || []).filter(Boolean))); const known = new Set(ALL_KEYS); - return uniq.filter((id) => known.has(id)); + return Array.from(new Set((arr || []).filter(Boolean))).filter((id) => known.has(id)); +} + +/* ══════════════════════════════════════════════════════════════ + TRAIT-MODE SIDEBAR CONTENT + Trait group -> sub-category group -> metric rows + ══════════════════════════════════════════════════════════════ */ +function TraitGroupedMetrics({ selectedSet, onToggle, onToggleGroup, onToggleTrait }) { + // trait collapse state + const [traitCollapsed, setTraitCollapsed] = useState( + Object.fromEntries(TRAIT_ORDER.map((t) => [t, !["Ideas & Content","Organization"].includes(t)])) + ); + // sub-category collapse state per trait (default: all open when trait is open) + const [catCollapsed, setCatCollapsed] = useState({}); + + return ( +
+ {TRAIT_ORDER.map((trait) => { + const subcats = TRAIT_SUBCATEGORY_MAP[trait] || {}; + const allMetricIds = Object.values(subcats).flat().map((m) => m.id); + if (!allMetricIds.length) return null; + + const style = TRAIT_STYLE[trait] || TRAIT_STYLE["Conventions"]; + const isTraitCollapsed = traitCollapsed[trait]; + const selectedInTrait = allMetricIds.filter((id) => selectedSet.has(id)).length; + const allTraitSelected = selectedInTrait === allMetricIds.length; + + return ( +
+ {/* Trait header */} +
setTraitCollapsed((c) => ({ ...c, [trait]: !c[trait] }))} + > +
+ + {trait} + {selectedInTrait > 0 && ( + + {selectedInTrait} + + )} +
+
+ + {isTraitCollapsed + ? + : + } +
+
+ + {/* Sub-category groups within trait */} + {!isTraitCollapsed && ( +
+ {Object.entries(subcats).map(([catLabel, metrics]) => { + const catKey = `${trait}__${catLabel}`; + const isCatCollapsed = catCollapsed[catKey] ?? false; + const catIds = metrics.map((m) => m.id); + const selectedInCat = catIds.filter((id) => selectedSet.has(id)).length; + const allCatSelected = selectedInCat === catIds.length; + + return ( +
+ {/* Sub-category header — indented */} +
setCatCollapsed((c) => ({ ...c, [catKey]: !c[catKey] }))} + > +
+ {catLabel} + {selectedInCat > 0 && ( + ({selectedInCat}) + )} +
+
+ + {isCatCollapsed + ? + : + } +
+
+ + {/* Metric rows */} + {!isCatCollapsed && ( +
+ {metrics.map((m) => { + const isChecked = selectedSet.has(m.id); + return ( + + ); + })} +
+ )} +
+ ); + })} +
+ )} +
+ ); + })} +
+ ); } -/* ============================================================= - MetricsPanel (updated to match EssayComparison sidebar) - - Keeps backward compat with your existing call: - - ============================================================= */ +/* ══════════════════════════════════════════════════════════════ + CATEGORY-MODE SIDEBAR CONTENT (original flat grouping) + ══════════════════════════════════════════════════════════════ */ +function CategoryGroupedMetrics({ selectedMetrics, onToggle }) { + const [expanded, setExpanded] = useState( + Object.fromEntries(CATEGORIES.map((c) => [c, true])) + ); + return ( +
+ {CATEGORIES.map((cat) => { + const metricsInCat = METRIC_DEFS.filter((m) => m.category === cat); + return ( +
+ + {expanded[cat] && ( +
+ {metricsInCat.map((m) => ( + + ))} +
+ )} +
+ ); + })} +
+ ); +} + +/* ══════════════════════════════════════════════════════════════ + MetricsPanel — main export + Props: + metrics string[] currently selected metric IDs + setMetrics (ids: string[]) => void + groupBy "trait" | "category" default "category" + stickyTopClassName default "top-24" + title default "Metrics" + className outer aside class + panelClassName inner card class + useSticky default true + ══════════════════════════════════════════════════════════════ */ export function MetricsPanel({ - // Backward-compatible props metrics, setMetrics, - - // Optional UI knobs + groupBy = "category", stickyTopClassName = "top-24", title = "Metrics", className = "", @@ -692,25 +498,21 @@ export function MetricsPanel({ }) { const selectedMetrics = Array.isArray(metrics) ? metrics : []; const setSelectedMetrics = typeof setMetrics === "function" ? setMetrics : () => {}; + const selectedSet = new Set(selectedMetrics); - /* ---------------------- Presets (stateful, deletable, creatable) ---------------------- */ + /* ── presets ── */ const [presets, setPresets] = useState(DEFAULT_PRESETS); const [presetName, setPresetName] = useState(""); useEffect(() => { if (typeof window === "undefined") return; - const raw = window.localStorage.getItem(PRESETS_STORAGE_KEY); - const parsed = raw ? safeParseJSON(raw) : null; - + const parsed = safeParseJSON(window.localStorage.getItem(PRESETS_STORAGE_KEY)); if (parsed && typeof parsed === "object") { const merged = { ...DEFAULT_PRESETS }; for (const [k, v] of Object.entries(parsed)) { - if (!k) continue; - merged[k] = normalizePresetMetrics(v); + if (k) merged[k] = normalizePresetMetrics(v); } setPresets(merged); - } else { - setPresets(DEFAULT_PRESETS); } }, []); @@ -722,14 +524,9 @@ export function MetricsPanel({ const createPreset = useCallback(() => { const name = (presetName || "").trim(); if (!name) return; - const arr = normalizePresetMetrics(selectedMetrics); if (!arr.length) return; - - setPresets((prev) => ({ - ...prev, - [name]: arr, - })); + setPresets((prev) => ({ ...prev, [name]: arr })); setPresetName(""); }, [presetName, selectedMetrics]); @@ -737,168 +534,118 @@ export function MetricsPanel({ setPresets((prev) => { const next = { ...prev }; delete next[name]; - if (!Object.keys(next).length) return { ...DEFAULT_PRESETS }; - return next; + return Object.keys(next).length ? next : { ...DEFAULT_PRESETS }; }); }, []); - const applyPreset = useCallback( - (name) => { - const arr = presets?.[name] || []; - setSelectedMetrics(normalizePresetMetrics(arr)); - }, - [presets, setSelectedMetrics] - ); + const applyPreset = useCallback((name) => { + setSelectedMetrics(normalizePresetMetrics(presets?.[name] || [])); + }, [presets, setSelectedMetrics]); + + /* ── toggle helpers ── */ + const handleToggle = useCallback((id) => { + setSelectedMetrics((prev) => + (prev || []).includes(id) ? prev.filter((x) => x !== id) : [...(prev || []), id] + ); + }, [setSelectedMetrics]); + + const handleToggleGroup = useCallback((ids, allSelected) => { + setSelectedMetrics((prev) => { + const s = new Set(Array.isArray(prev) ? prev : []); + if (allSelected) ids.forEach((id) => s.delete(id)); + else ids.forEach((id) => s.add(id)); + return Array.from(s); + }); + }, [setSelectedMetrics]); - /* ---------------------- Category collapse state ---------------------- */ - const [expanded, setExpanded] = useState(() => { - const o = {}; - CATEGORIES.forEach((c) => (o[c] = true)); - return o; - }); - - const handleMetricToggle = useCallback( - (id) => { - setSelectedMetrics((prev) => - (prev || []).includes(id) ? prev.filter((x) => x !== id) : [...(prev || []), id] - ); - }, - [setSelectedMetrics] - ); + const handleToggleTrait = useCallback((ids, allSelected) => { + handleToggleGroup(ids, allSelected); + }, [handleToggleGroup]); const selectedCount = selectedMetrics.length; return ( ); -} +} \ 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
+
-
- +
)} - {/* 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
- +
-
- setReplaceQuery(e.target.value)} - autoFocus - placeholder="Search by document…" - className="w-full pl-9 pr-3 py-2.5 text-sm border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-emerald-500" - /> + setReplaceQuery(e.target.value)} autoFocus + placeholder="Search essays..." className="w-full pl-9 pr-3 py-2.5 text-sm border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-emerald-500" />
- -
Tip: Use ↑ / ↓ then Enter to select.
+
Use arrow keys then Enter to select.
-
{replaceMatches.length === 0 ? ( -
No matches.
- ) : ( - replaceMatches.map((id, idx) => { - const isActive = idx === replaceActiveIdx; - const isCurrent = id === currentIdForSide; - const isOther = id === otherIdForSide; - - return ( - - ); - }) - )} + {preview && ( +

+ {preview}{rawText.length > 120 ? "..." : ""} +

+ )} +
+ + ); + })}
-
- - + +
)} + {/* 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 */} + -
-
- - - -
+
+
+ +
+
+
+ + +
- {/* 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}

-
- -
- - {leftEssay.minutes} min - - - {leftEssay.words.toLocaleString()} words - +
+ + {essay.minutes} min + {essay.words.toLocaleString()} words
- - {focusedMetricId ? ( + {focusedMetricId && (
- - Focus: {focusedMeta?.title || focusedMetricId} + Showing: {focusedMeta?.title || focusedMetricId} - +
- ) : 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}

- -
- -
- - {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} - - -
- ) : null}
+ {focusedMetricId && ( + + )} +
-
- {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 ? ( - - ) : 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 && ( +
+ + {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 ? ( -
- - - {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 ( +
+ + {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 ( - - {ticks.map((t) => { - const y = Y(t); +
+ {traitGroups.map((trait) => { + const rows = byTrait[trait]; + const isCollapsed = collapsed[trait]; + const decliningCount = rows.filter((m) => (m.currentValue - m.baseline) < -0.15).length; return ( - - - - {tickFmt(t)} - - +
+ + {!isCollapsed && rows.map((m) => ( + + ))} +
); })} - - - - - - Baseline - - - Current - - - {shouldZoom ? ( - - - - {zoomLabel} - - - ) : null} - - - - - - - {b.toFixed(1)}% - - - {c.toFixed(1)}% - - - - - - {deltaText} - - - - ); -} - -function MetricTile({ metricKey, baseline, currentValue }) { - return ( -
-
-
{metricKey}
-
Baseline (prior essays only) → Current (this essay)
-
-
- -
); } -/* ========================================================= - Evidence blocks for Actionable Feedback -========================================================= */ +/* ───────────────────────────────────────────── + PasteCard +───────────────────────────────────────────── */ +function PasteCard({ pasteStats }) { + if (!pasteStats) return null; + const { largePasteCount, totalPasteWords, copyCount, timeOnTask } = pasteStats; + const isNotable = largePasteCount >= 3 || totalPasteWords >= 80; + + const stats = [ + { val: largePasteCount, label: "large pastes", desc: "Paste events 200+ chars", hi: largePasteCount >= 3 }, + { val: `~${totalPasteWords}`, label: "words pasted", desc: "Estimated from paste chars", hi: totalPasteWords >= 80 }, + { val: copyCount, label: "copy events", desc: "Total copy actions", hi: false }, + ]; -function EvidenceProduct({ cues }) { - const list = Array.isArray(cues) ? cues : []; - if (!list.length) { - return ( -
- No product evidence attached yet. -
- ); - } return ( -
-
- Evidence cues (justification signals) +
+
+
+ + Paste Activity +
+
+ {isNotable && ( + + Notable + + )} +
-
- {list.map((c, i) => ( -
-
{c.label}
- {c.sub ?
{c.sub}
: null} +
+ {stats.map(({ val, label, desc, hi }) => ( +
+
{val}
+
{label}
+
{desc}
))}
+ {isNotable && ( +

+ This essay contains notable paste activity. Pasted content may reflect research, drafting from notes, or copied material — consider discussing with the student how the pasted content connects to their own ideas. +

+ )}
); } -function EvidenceProcess({ features }) { - const list = Array.isArray(features) ? features : []; - if (!list.length) { - return ( -
- No process evidence attached yet. -
- ); - } +function DeltaBadge({ delta }) { + if (delta == null || !Number.isFinite(delta)) return null; + const isUp = delta > 0.15; const isDown = delta < -0.15; + const Icon = isUp ? TrendingUp : isDown ? TrendingDown : Minus; + const cls = isUp ? "text-emerald-600" : isDown ? "text-rose-500" : "text-gray-400"; return ( -
-
- Process signals (keystroke / behavior metrics) -
-
- {list.map((f, i) => ( -
-
{f.name}
-
- Baseline {Number(f.baseline).toFixed(1)} → Current {Number(f.current).toFixed(1)} (Score{" "} - {Number(f.score).toFixed(0)}/100) -
-
- ))} -
-
+ + + {isUp ? `+${delta.toFixed(1)}` : isDown ? `${delta.toFixed(1)}` : "on baseline"} + ); } -/* ========================================================= - Feedback Controls (left column in feedback tab) -========================================================= */ - -function FeedbackControls({ - detailLevel, - setDetailLevel, - ordering, - setOrdering, - priority, - setPriority, - includeEvidence, - setIncludeEvidence, - includeProcessSignals, - setIncludeProcessSignals, -}) { +function ProcessCard({ current, baseline }) { + const hasAny = current && Object.values(current).some((v) => v != null); return (
-
-
- - - -
-
Feedback Controls
-
-
-
Choose ordering and how many items to show.
+
+ Process Signals + + Prototype + + RQ1 keystroke features
- -
-
-
Detail level
-
- {["brief", "standard"].map((k) => ( - - ))} -
-
- -
-
-
Sequence / ordering
- - - Order - -
-
- {[ - { key: "highest_impact", label: "Highest impact" }, - { key: "lowest_impact", label: "Lowest impact" }, - ].map((opt) => ( - - ))} -
-
- -
-
Priority
-
- {[ - { key: "top1", label: "Top 1" }, - { key: "top2", label: "Top 2" }, - { key: "all", label: "All" }, - ].map((opt) => ( - - ))} -
+ {!hasAny ? ( +
No process data available. Wire to keystroke pipeline.
+ ) : ( +
+ {PROCESS_DEFS.map((def) => { + const cur = current?.[def.key]; const base = baseline?.[def.key]; + const hasCur = cur != null && Number.isFinite(Number(cur)); + const hasBase = base != null && Number.isFinite(Number(base)); + const delta = hasCur && hasBase ? Number(cur) - Number(base) : null; + return ( +
+
+
{def.label}
+
{def.desc}
+
+
+ {hasCur ? {Number(cur).toFixed(1)}{def.unit} + : n/a} + {delta != null &&
} +
+
+ ); + })}
+ )} +
+ ); +} -
-
- Include in feedback -
-
- - - -
+function BaselineInfo({ docIds, currentIdx, docsObj }) { + const [open, setOpen] = useState(false); + const priorIds = (docIds || []).slice(0, currentIdx); + if (!priorIds.length) return null; + const priorTypes = [...new Set(priorIds.map((id) => inferAssignmentType(docsObj?.[id]?.text || "")))]; + const mixed = priorTypes.length > 1; + return ( +
+ + {open && ( +
+ {mixed && ( +

+ Baseline spans {priorTypes.join(" and ")} essays. Comparisons are most reliable within the same genre. +

+ )} + {priorIds.map((id, i) => { + const type = inferAssignmentType(docsObj?.[id]?.text || ""); + const words = docsObj?.[id]?.text ? docsObj[id].text.trim().split(/\s+/).filter(Boolean).length : null; + return ( +
+ #{i + 1} + + {words != null && {words.toLocaleString()} words} +
+ ); + })}
+ )} +
+ ); +} -
- Tip: "Highest impact + Top 1 + Brief" works well for quick LMS comments. -
-
+function EffortPills({ doc }) { + const t = doc?.time_on_task ?? doc?.time_on_task_mins; + const e = doc?.edit_count; + if (t == null && e == null) return null; + return ( +
+ {t != null && ( + + {Math.round(t)} min + + )} + {e != null && ( + + {e} edits + + )}
); } -/* ========================================================= +/* ───────────────────────────────────────────── Exported Modal -========================================================= */ - + Accepts initialDocsObj — the already-loaded + frozen doc data from StudentDetail/Compare. + Seeding from this means text and paste stats + show immediately on open without waiting for + the modal's own WS connection. +───────────────────────────────────────────── */ export function SingleEssayModal({ - studentKey, - docId, - docIds, - docTitle, - docIndex, - initialWords, - subtitleDate, - onClose, + studentKey, docId, docIds, docTitle, docIndex, + initialWords, subtitleDate, onClose, onNavigate, + initialDocsObj, }) { useEffect(() => { const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; - return () => { - document.body.style.overflow = prev || ""; - }; + return () => { document.body.style.overflow = prev || ""; }; }, []); useEffect(() => { - const onKey = (e) => { - if (e.key === "Escape") onClose?.(); - }; + const onKey = (e) => { if (e.key === "Escape") onClose?.(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); - const title = docTitle || (docIndex ? `Document ${docIndex}` : "Document"); - const subtitle = subtitleDate ? `Document • ${subtitleDate}` : "Document"; + const title = docTitle || (docIndex ? `Essay ${docIndex}` : "Essay"); + const subtitle = subtitleDate || ""; + + const currentNavIdx = useMemo(() => { + if (!Array.isArray(docIds)) return -1; + return docIds.findIndex((id) => String(id) === String(docId)); + }, [docIds, docId]); + + const canGoPrev = currentNavIdx > 0; + const canGoNext = currentNavIdx >= 0 && currentNavIdx < (docIds?.length ?? 0) - 1; + const handleNav = (delta) => { + if (!onNavigate || !docIds) return; + const nx = currentNavIdx + delta; + if (nx >= 0 && nx < docIds.length) onNavigate(docIds[nx], nx); + }; return (
-
onClose?.()} /> - -
-
e.stopPropagation()} - > -
-
-
-
-
{title}
-
{subtitle}
+
onClose?.()} /> +
+
e.stopPropagation()}> + {/* Header */} +
+
+ {onNavigate && ( +
+ + + {currentNavIdx >= 0 && ( + {currentNavIdx + 1} / {docIds?.length} + )}
-
- -
- + )} +
+

{title}

+ {subtitle &&

{subtitle}

}
+
-
- +
+
@@ -619,102 +638,124 @@ export function SingleEssayModal({ ); } -/* ========================================================= +/* ───────────────────────────────────────────── Inner modal -========================================================= */ - -function SingleEssayInnerModal({ studentKey, docId, docIds }) { - const [activeTab, setActiveTab] = useState("trajectory"); - const [feedbackMode, setFeedbackMode] = useState("product"); - const [selectedMetrics, setSelectedMetricsState] = useState(["academic_language"]); - - // feedback controls - const [detailLevel, setDetailLevel] = useState("standard"); - const [ordering, setOrdering] = useState("highest_impact"); - const [priority, setPriority] = useState("all"); - const [includeEvidence, setIncludeEvidence] = useState(true); - const [includeProcessSignals, setIncludeProcessSignals] = useState(true); +───────────────────────────────────────────── */ +function SingleEssayInner({ studentKey, docId, docIds, initialDocsObj }) { + const [selectedMetrics, setSelectedMetricsRaw] = useState(DEFAULT_METRICS); const { courseId } = useCourseIdContext(); - const setSelectedMetrics = (next) => { - setSelectedMetricsState((prev) => { + const setSelectedMetrics = useCallback((next) => { + setSelectedMetricsRaw((prev) => { const resolved = typeof next === "function" ? next(prev) : next; - const normalized = normalizeSelectedMetrics(resolved); - - if (DEBUG) { - console.groupCollapsed("[SingleEssayInnerModal] metrics-change"); - console.log("resolved from panel:", resolved); - console.log("normalized ids:", normalized); - console.groupEnd(); - } - - return normalized; + return normalizeMetrics(resolved); }); - }; + }, []); - const exportEnabled = - activeTab === "trajectory" && - !!studentKey && - !!docId && - Array.isArray(docIds) && - docIds.length > 0 && - selectedMetrics.length > 0; + const exportEnabled = !!studentKey && !!docId && + Array.isArray(docIds) && docIds.length > 0 && selectedMetrics.length > 0; const dataScope = useMemo(() => { if (!exportEnabled) return { wo: { execution_dag: "writing_observer", target_exports: [], kwargs: {} } }; - return { wo: { execution_dag: "writing_observer", - target_exports: ["single_student_docs_with_nlp_annotations"], + target_exports: [ + "single_student_docs_with_nlp_annotations", + "single_student_paste", + "single_student_copy_cut", + "single_student_time_on_task", + ], kwargs: { - course_id: courseId, - student_id: studentKey, - document: docIds, + course_id: courseId, + student_id: studentKey, + document: docIds, + doc_ids: docIds, nlp_options: selectedMetrics, }, }, }; }, [exportEnabled, courseId, docIds, selectedMetrics, studentKey]); - // Connect to LO websocket const origin = getConfiguredWsOrigin(); + const { connection, data: loData, errors: loErrors } = useLOConnectionDataManager({ + url: `${origin}/wsapi/communication_protocol`, dataScope, + }); - const url = `${origin}/wsapi/communication_protocol`; - const { connection, data: loData, errors: loErrors } = useLOConnectionDataManager({ url, dataScope }); - - // When dataScope changes, send the new scope over the existing connection const prevScopeRef = useRef(null); useEffect(() => { if (!connection?.sendMessage || !exportEnabled) return; + const str = stableStringify(dataScope); + if (str === prevScopeRef.current) return; + prevScopeRef.current = str; + try { connection.sendMessage(JSON.stringify(dataScope)); } catch (e) { console.warn(e); } + }, [connection, dataScope, exportEnabled]); - const scopeStr = stableStringify(dataScope); - if (scopeStr === prevScopeRef.current) return; - prevScopeRef.current = scopeStr; + // ── MERGED DOC ACCUMULATOR ─────────────────────────────────── + // docsVersion is a counter that increments whenever the accumulator + // gains new data. This gives useMemo a stable dependency to react to, + // since mutating a ref doesn't trigger re-renders on its own. + const [docsVersion, setDocsVersion] = useState(0); + const mergedDocsAccRef = useRef({}); + const mergedStudentKeyRef = useRef(null); + const seededRef = useRef(false); + + if (mergedStudentKeyRef.current !== studentKey) { + mergedStudentKeyRef.current = studentKey; + mergedDocsAccRef.current = {}; + seededRef.current = false; + } - if (DEBUG) { - console.groupCollapsed("[SingleEssayInnerModal] sending updated dataScope"); - console.log("dataScope:", dataScope); - console.groupEnd(); + // Seed once from the parent's already-loaded data. + // Uses useEffect so the state increment happens after render, + // which triggers one re-render with the seeded data in place. + useEffect(() => { + if (seededRef.current) return; + if (!initialDocsObj || Object.keys(initialDocsObj).length === 0) return; + seededRef.current = true; + for (const [dId, doc] of Object.entries(initialDocsObj)) { + const existing = mergedDocsAccRef.current[dId] || {}; + const merged = { ...existing }; + for (const [k, v] of Object.entries(doc)) { + if (v !== null && v !== undefined) merged[k] = v; + } + mergedDocsAccRef.current[dId] = merged; } + setDocsVersion((v) => v + 1); + }, [initialDocsObj]); - try { - connection.sendMessage(JSON.stringify(dataScope)); - } catch (e) { - console.warn("[SingleEssayInnerModal] failed to send updated dataScope", e); + // Merge each incoming WS tick on top (NLP annotations etc.) + // Also bump docsVersion so downstream memos recompute. + useEffect(() => { + const rawDocs = loData?.students?.[studentKey]?.documents || {}; + if (Object.keys(rawDocs).length === 0) return; + let changed = false; + for (const [dId, incoming] of Object.entries(rawDocs)) { + const existing = mergedDocsAccRef.current[dId] || {}; + const merged = { ...existing }; + for (const [k, v] of Object.entries(incoming)) { + if (v !== null && v !== undefined) merged[k] = v; + } + mergedDocsAccRef.current[dId] = merged; + changed = true; } - }, [connection, dataScope, exportEnabled]); + if (changed) setDocsVersion((v) => v + 1); + }, [loData, studentKey]); - const docsObj = useMemo(() => loData?.students?.[studentKey]?.documents || {}, [loData, studentKey]); + // Stable snapshot for this render — docsVersion ensures memos + // below recompute whenever the accumulator is updated. + // eslint-disable-next-line react-hooks/exhaustive-deps + const docsObj = useMemo(() => ({ ...mergedDocsAccRef.current }), [docsVersion]); const hasError = useMemo(() => { - if (!exportEnabled) return false; - if (!loErrors) return false; + if (!exportEnabled || !loErrors) return false; if (Array.isArray(loErrors)) return loErrors.length > 0; if (typeof loErrors === "object") return Object.keys(loErrors).length > 0; return true; }, [exportEnabled, loErrors]); + // hasAllDocs: true as soon as every doc has text — satisfied immediately + // if initialDocsObj was provided (the normal case when opened from the card grid). const hasAllDocs = useMemo(() => { if (!exportEnabled) return false; return docIds.every((id) => { @@ -723,351 +764,141 @@ function SingleEssayInnerModal({ studentKey, docId, docIds }) { }); }, [exportEnabled, docIds, docsObj]); - const isLOLoading = useMemo(() => exportEnabled && !hasError && !hasAllDocs, [exportEnabled, hasError, hasAllDocs]); + const isLoading = useMemo(() => exportEnabled && !hasError && !hasAllDocs, [exportEnabled, hasError, hasAllDocs]); - const currentDocIndex = useMemo(() => { - const idx = docIds.findIndex((id) => String(id) === String(docId)); - return Math.max(0, idx); + const currentIdx = useMemo(() => { + const i = docIds.findIndex((id) => String(id) === String(docId)); + return Math.max(0, i); }, [docIds, docId]); - const hasPriorData = currentDocIndex > 0; + const hasPrior = currentIdx > 0; + // metricSummaries uses metricValue() which prefers doc[key].metric + // (the pre-computed number from the NLP pipeline — confirmed present + // in the WS response JSON: "metric": 19, 11, 20, etc.) const metricSummaries = useMemo(() => { if (!exportEnabled || !hasAllDocs) return []; - return selectedMetrics.map((metricKey) => { - const series = docIds.map((id) => metricCoveragePercent(docsObj?.[id], metricKey)); - const current = Number(series[currentDocIndex] ?? 0); - const prior = series.slice(0, currentDocIndex).map((x) => Number(x) || 0); + return selectedMetrics.map((key) => { + const series = docIds.map((id) => metricValue(docsObj?.[id], key)); + const current = Number(series[currentIdx] ?? 0); + const prior = series.slice(0, currentIdx).map((x) => Number(x) || 0); const baseline = prior.length ? mean(prior) : 0; - return { key: metricKey, baseline, currentValue: current }; + return { key, baseline, currentValue: current }; }); - }, [exportEnabled, hasAllDocs, selectedMetrics, docIds, docsObj, currentDocIndex]); - - const currentText = useMemo(() => (docsObj?.[docId]?.text || "").toString(), [docsObj, docId]); + }, [exportEnabled, hasAllDocs, selectedMetrics, docIds, docsObj, currentIdx]); - const wordCount = useMemo(() => { + const currentDoc = useMemo(() => docsObj?.[docId] || null, [docsObj, docId]); + const currentText = useMemo(() => (currentDoc?.text || "").toString(), [currentDoc]); + const assignmentType = useMemo(() => inferAssignmentType(currentText), [currentText]); + const wordCount = useMemo(() => { const t = (currentText || "").trim(); - if (!t) return 0; - return t.split(/\s+/).filter(Boolean).length; + return t ? t.split(/\s+/).filter(Boolean).length : 0; }, [currentText]); - /* -------------------------- - Actionable feedback content - -------------------------- */ - - const feedbackBlocks = useMemo(() => { - const product = [ - { - category: "clarity", - heading: "Clarify the claim early", - why: "Your central claim becomes clear only midway through the essay.", - suggestion: "Rewrite the opening as: claim → reason → preview of evidence (2–3 lines).", - evidence: { cues: [{ label: "Thesis appears late", sub: "Main stance introduced after several sentences." }] }, - impact: 0.9, - }, - { - category: "organization", - heading: "Use a stronger paragraph map", - why: "Paragraph purposes aren't clearly signposted.", - suggestion: "Add a 1-sentence topic line at the start of each paragraph to guide the reader.", - evidence: { cues: [{ label: "Weak topic sentences", sub: "Paragraph goals inferred rather than stated." }] }, - impact: 0.75, - }, - { - category: "evidence", - heading: "Connect evidence to the claim explicitly", - why: "Evidence is present, but the link back to the thesis is implicit.", - suggestion: 'After each quote/example, add one sentence: "This shows ___ because ___."', - evidence: { cues: [{ label: "Evidence-to-claim bridge", sub: "Explanation is shorter than evidence in places." }] }, - impact: 0.7, - }, - ]; - - const process = [ - { - category: "overall", - heading: "Revise globally before polishing", - why: "Edits appear late and are mostly sentence-level.", - suggestion: "Next time: draft quickly → structure pass → line edits.", - evidence: { features: [{ name: "Late revision burst", baseline: 42.1, current: 61.4, score: 68 }] }, - impact: 0.8, - }, - { - category: "organization", - heading: "Pause to outline before drafting", - why: "Drafting begins immediately without a planning phase.", - suggestion: "Spend 3–5 minutes outlining: claim → reasons → evidence before writing.", - evidence: { features: [{ name: "Planning time", baseline: 18.0, current: 6.5, score: 42 }] }, - impact: 0.7, - }, - { - category: "focus", - heading: "Avoid long uninterrupted drafting runs", - why: "Long runs often reduce clarity and increase later cleanup work.", - suggestion: 'Try a short checkpoint every 5–7 minutes: "Does this paragraph support my claim?"', - evidence: { features: [{ name: "Longest uninterrupted run (min)", baseline: 9.2, current: 14.8, score: 55 }] }, - impact: 0.6, - }, - ]; - - return { product, process }; - }, []); - - const applyDetailLevel = (b) => { - if (detailLevel === "brief") { - return { ...b, why: "" }; + const pasteStats = useMemo(() => extractPasteStats(currentDoc), [currentDoc]); + const currentProcess = useMemo(() => deriveProcessMetrics(currentDoc), [currentDoc]); + const baselineProcess = useMemo(() => { + const priors = docIds.slice(0, currentIdx).map((id) => docsObj?.[id]).filter(Boolean); + if (!priors.length) return null; + const out = {}; + for (const def of PROCESS_DEFS) { + const vals = priors.map((d) => deriveProcessMetrics(d)?.[def.key]) + .filter((v) => v != null && Number.isFinite(Number(v))); + out[def.key] = vals.length ? mean(vals.map(Number)) : null; } - return b; - }; - - const orderingLabel = useMemo(() => { - return ordering === "lowest_impact" ? "Lowest impact" : "Highest impact"; - }, [ordering]); + return out; + }, [docIds, currentIdx, docsObj]); - const sortedAndFilteredFeedbackBlocks = useMemo(() => { - const base = feedbackMode === "product" ? feedbackBlocks.product : feedbackBlocks.process; - const blocks = [...base]; + return ( +
+ + {/* ── Left sidebar: Metrics panel ── */} + + + {/* ── Middle: essay text ── */} +
+
+
+ + + {wordCount.toLocaleString()} words + + +
+
+
+ {currentText + ?

{currentText}

+ :

No text available yet.

+ } +
+
- if (ordering === "highest_impact") { - blocks.sort((a, b) => (Number(b.impact) || 0) - (Number(a.impact) || 0)); - } else { - blocks.sort((a, b) => (Number(a.impact) || 0) - (Number(b.impact) || 0)); - } + {/* ── Right panel: trajectory results ── */} + +
); } diff --git a/modules/portfolio_diff/src/app/students/components/StudentDetail/StudentDetailCompare.js b/modules/portfolio_diff/src/app/students/components/StudentDetail/StudentDetailCompare.js index 9d516b01..d9206cfd 100644 --- a/modules/portfolio_diff/src/app/students/components/StudentDetail/StudentDetailCompare.js +++ b/modules/portfolio_diff/src/app/students/components/StudentDetail/StudentDetailCompare.js @@ -9,22 +9,201 @@ import { Info, Maximize2, GitCompareArrows, + Clock, + FileText, + AlertCircle, + CheckCircle2, + BookOpen, + Pencil, + Clipboard, } from "lucide-react"; import { SingleEssayModal } from "./SingleEssayModel"; /* ========================================================= - Student Compare (Modal imported) + RUBRIC ALIGNMENT ========================================================= */ +const METRIC_TEACHER_DESC = { + academic_language: "Words that signal academic or formal register (e.g. 'analyze', 'demonstrate')", + informal_language: "Casual or conversational words that may not suit the assignment genre", + latinate_words: "Longer, Latin-derived words often associated with sophisticated vocabulary", + opinion_words: "Words that express the writer's personal stance or judgment", + emotion_words: "Words that convey feeling — useful in narrative, potentially limiting in argument", + argument_words: "Words that signal reasoning and argumentation (e.g. 'because', 'therefore')", + explicit_argument: "Phrases that make the writer's claim or position directly visible to the reader", + statements_of_opinion: "Sentences where the writer expresses a personal view rather than a fact", + statements_of_fact: "Sentences that assert something as objectively true", + transition_words: "Words and phrases that connect ideas within and across sentences", + positive_transition_words: "Transitions that add or reinforce ('also', 'furthermore', 'in addition')", + contrastive_transition_words: "Transitions that contrast ideas ('however', 'on the other hand')", + conditional_transition_words: "Transitions that show conditions ('if', 'unless', 'provided that')", + consequential_transition_words:"Transitions that show cause-effect ('therefore', 'as a result')", + citations: "Moments where the student references an outside source", + attributions: "Phrases that credit a speaker or source ('According to...', 'Smith argues...')", + quoted_words: "Exact words taken directly from a source and placed in quotation marks", + information_sources: "Overall use of external information and evidence", + sentences: "Total number of sentences — a basic measure of text length and density", + paragraphs: "Total number of paragraphs — reflects structural organization", + simple_sentences: "Sentences with a single main clause — easier to read but less complex", + complex_sentences: "Sentences with a main clause and one or more subordinate clauses", + compound_sentences: "Sentences joining two independent clauses — shows coordination ability", + compound_complex_sentences: "Sentences combining compound and complex structures — highest complexity", + polysyllabic_words: "Longer words (3+ syllables) — a rough indicator of vocabulary complexity", + low_frequency_words: "Uncommon words not found in everyday language — signals advanced vocabulary", + positive_tone: "Language that carries a generally optimistic or affirmative feeling", + negative_tone: "Language that carries a critical, pessimistic, or opposing feeling", + concrete_details: "Specific, tangible examples rather than vague or abstract statements", + main_idea_sentences: "Sentences that introduce or summarize a central point", + supporting_idea_sentences: "Sentences that develop or expand on a main idea", + supporting_detail_sentences: "Sentences that provide specific evidence or examples", + direct_speech_verbs: "Verbs that introduce direct dialogue ('said', 'asked', 'replied')", + indirect_speech: "Reported speech — summarizing what someone said rather than quoting directly", + character_trait_words: "Words describing character qualities — important in narrative writing", + explicit_claims: "Direct statements of position or argument", + social_awareness: "Language indicating awareness of community, society, or broader context", +}; + +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" }, +}; +/* ========================================================= + SUB-COMPONENTS +========================================================= */ + +function AssignmentTypeBadge({ type }) { + const colors = ASSIGNMENT_TYPE_COLORS[type] || ASSIGNMENT_TYPE_COLORS["Document"]; + return ( + + + {type} + + ); +} + +function ComparisonTypeMismatchBanner({ typeA, typeB }) { + if (!typeA || !typeB || typeA === typeB || typeA === "Document" || typeB === "Document") return null; + return ( +
+ +
+ Different assignment types selected.{" "} + You are comparing a {typeA} essay with an {typeB} essay. + For valid comparisons, select essays of the same type.{" "} + + Language-level metrics (word choice, transitions) are most useful across types; + structural metrics are most meaningful within the same type. + +
+
+ ); +} + +function MetricTooltip({ metricId, children }) { + const [show, setShow] = useState(false); + const desc = METRIC_TEACHER_DESC[metricId]; + if (!desc) return <>{children}; + return ( + setShow(true)} onMouseLeave={() => setShow(false)}> + {children} + + {show && ( + + {desc} + + )} + + ); +} + +const SkeletonCard = () => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+); + +/* ========================================================= + HELPERS +========================================================= */ +function humanizeDocId(docId, index) { + if (!docId) return "Document"; + if (/fake-google-doc|doc-id/i.test(docId)) return `Essay ${index ?? ""}`.trim(); + return String(docId).replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +function inferAssignmentType(tags, text) { + if (Array.isArray(tags)) { + const known = ["Narrative","Argumentative","Analytical","Expository"]; + const match = tags.find((t) => known.includes(t)); + if (match) return match; + } + 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"; +} + +const AVG_WORD_LENGTH_CHARS = 5; +function charsToWords(chars) { return Math.round(chars / AVG_WORD_LENGTH_CHARS); } + +/* ========================================================= + EXPAND BUTTON WITH TOOLTIP +========================================================= */ +function ExpandButton({ onClick }) { + const [show, setShow] = useState(false); + return ( +
+ + {show && ( + + Open essay detail + + + )} +
+ ); +} + +/* ========================================================= + MAIN COMPONENT +========================================================= */ export default function StudentDetailCompare({ groupedEssays, studentId, - selectedEssays, setSelectedEssays, handleEssaySelect, - cardsPerRow, setCardsPerRow, sortBy, @@ -41,128 +220,109 @@ export default function StudentDetailCompare({ baseTags, clearFilters, isAnyFilter, - getGridCols, getGradeColor, strengthAndFocusForEssay, - loDocData, loDocErrors, loDocConnection, documentIDS, + assignmentTypeFilter, + AssignmentTypeBadge: ExternalBadge, + EffortPill, + pasteStatsByDoc, }) { const safeGetGridCols = typeof getGridCols === "function" ? getGridCols : () => "grid-cols-3"; - const safeGetGradeColor = - typeof getGradeColor === "function" - ? getGradeColor - : () => "bg-gray-50 text-gray-700 ring-1 ring-gray-200"; - const safeStrengthAndFocus = - typeof strengthAndFocusForEssay === "function" - ? strengthAndFocusForEssay - : () => ({ strength: null, focus: null }); const safeHandleEssaySelect = typeof handleEssaySelect === "function" ? handleEssaySelect : () => {}; const safeSetSelectedEssays = typeof setSelectedEssays === "function" ? setSelectedEssays : () => {}; + const safeStrengthAndFocus = typeof strengthAndFocusForEssay === "function" + ? strengthAndFocusForEssay : () => ({ strength: null, focus: null }); + const safePasteStats = pasteStatsByDoc ?? {}; - // ---- Modal state (inside compare) ---- const [openEssay, setOpenEssay] = useState(null); - // ---- LO docs for compare list ---- const loStudentID = String(studentId); const docsObj = loDocData?.students?.[loStudentID]?.documents || {}; - const expectedDocIds = Array.isArray(documentIDS) ? documentIDS : []; - const receivedDocIds = Object.keys(docsObj || {}); + const hasAllExpectedDocs = - expectedDocIds.length === 0 || expectedDocIds.every((id) => receivedDocIds.includes(id)); + expectedDocIds.length > 0 && + expectedDocIds.every((id) => { + const doc = docsObj?.[id]; + return doc && typeof doc === "object" && typeof doc.text === "string" && doc.text.length > 0; + }); + + const connectionLoading = !!(loDocConnection && + (loDocConnection.loading || loDocConnection.isLoading || loDocConnection.status === "loading")); const isDocsLoading = - !!(loDocConnection && - (loDocConnection.loading || loDocConnection.isLoading || loDocConnection.status === "loading")) || + connectionLoading || + !loDocData || + !loDocData?.students?.[loStudentID] || (expectedDocIds.length > 0 && !hasAllExpectedDocs); const isDocsEmpty = !isDocsLoading && Object.keys(docsObj || {}).length === 0; - // ---- Build doc list (used for modal props too) ---- const docList = useMemo(() => { return Object.entries(docsObj || {}).map(([docId, doc], index) => { const text = typeof doc?.text === "string" ? doc.text : ""; const words = text ? text.trim().split(/\s+/).filter(Boolean).length : 0; - - let dateISO = - doc?.dateISO || doc?.date_iso || doc?.date || doc?.submitted_at || doc?.created_at || ""; - + let dateISO = doc?.dateISO || doc?.date_iso || doc?.date || doc?.submitted_at || doc?.created_at || ""; if (!dateISO && doc?.last_access != null) { const la = Number(doc.last_access); - if (Number.isFinite(la) && la > 0) { - // Handle both seconds and milliseconds timestamps - const ms = la > 1e12 ? la : la * 1000; - dateISO = new Date(ms).toISOString(); - } + if (Number.isFinite(la) && la > 0) { const ms = la > 1e12 ? la : la * 1000; dateISO = new Date(ms).toISOString(); } } - const grade = doc?.grade ?? doc?.score ?? ""; - const tagsFromDoc = Array.isArray(doc?.tags) - ? doc.tags - : Array.isArray(doc?.meta?.tags) - ? doc.meta.tags - : ["Document"]; + const tagsFromDoc = Array.isArray(doc?.tags) ? doc.tags : Array.isArray(doc?.meta?.tags) ? doc.meta.tags : ["Document"]; + const assignmentType = inferAssignmentType(tagsFromDoc, text); + const humanTitle = doc?.title || humanizeDocId(docId, index + 1); + + // Read time_on_task directly from doc node (real WS field name). + // Fall back to time_on_task_mins for backwards compatibility. + const timeOnTaskMins = + typeof doc?.time_on_task === "number" ? doc.time_on_task : + typeof doc?.time_on_task_mins === "number" ? doc.time_on_task_mins : + null; + + const editCount = doc?.edit_count ?? null; return { - id: docId, - title: doc?.title || `Document ${index + 1}`, - date: dateISO - ? new Date(dateISO).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) - : "", + id: docId, title: humanTitle, + date: dateISO ? new Date(dateISO).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) : "", dateISO: dateISO ? new Date(dateISO).toISOString() : "", - words, - grade: grade === null || grade === undefined ? "" : String(grade), - preview: text, - tags: tagsFromDoc.map(String), - _raw: doc, - _index: index + 1, + words, grade: doc?.grade != null ? String(doc.grade) : "", + preview: text, tags: tagsFromDoc.map(String), + assignmentType, timeOnTaskMins, editCount, + _raw: doc, _index: index + 1, }; }); }, [docsObj]); const allDocIds = useMemo(() => docList.map((d) => d.id).filter(Boolean), [docList]); - const docMetaById = useMemo(() => { const m = new Map(); for (const d of docList) m.set(String(d.id), d); return m; }, [docList]); - // ---- tags base ---- + const STANDARD_GENRES = ["Narrative", "Argumentative", "Analytical", "Expository", "Other", "Document"]; const safeBaseTags = useMemo(() => { - if (Array.isArray(baseTags) && baseTags.length) return baseTags; - const s = new Set(); - for (const d of docList) { - for (const t of (Array.isArray(d?.tags) ? d.tags : [])) s.add(String(t)); - } - return Array.from(s).sort((a, b) => a.localeCompare(b)); - }, [baseTags, docList]); - - // ---- filtering + sorting ---- + const presentInDocs = new Set(docList.map((d) => d.assignmentType)); + const standard = STANDARD_GENRES.filter((g) => presentInDocs.has(g)); + const extras = Array.from(presentInDocs).filter((g) => !STANDARD_GENRES.includes(g)).sort(); + return [...standard, ...extras]; + }, [docList]); + const filteredDocs = useMemo(() => { const q = String(search || "").trim().toLowerCase(); const activeTags = Array.isArray(filterTags) ? filterTags : []; - return (docList || []) .filter((d) => { if (activeTags.length > 0) { const dtags = Array.isArray(d?.tags) ? d.tags.map(String) : []; if (!activeTags.every((t) => dtags.includes(t))) return false; } - if (q) { - const hay = [ - d?.title || "", - d?.preview || "", - Array.isArray(d?.tags) ? d.tags.join(" ") : "", - d?.grade || "", - d?.date || "", - ] - .join(" ") - .toLowerCase(); + const hay = [d?.title || "", d?.preview || "", Array.isArray(d?.tags) ? d.tags.join(" ") : "", d?.grade || "", d?.date || ""].join(" ").toLowerCase(); if (!hay.includes(q)) return false; } return true; @@ -172,7 +332,6 @@ export default function StudentDetailCompare({ if (mode === "words") return (Number(b.words) || 0) - (Number(a.words) || 0); if (mode === "title") return String(a.title || "").localeCompare(String(b.title || "")); if (mode === "grade") return (Number(b.grade) || 0) - (Number(a.grade) || 0); - const ad = a?.dateISO ? new Date(a.dateISO).getTime() : 0; const bd = b?.dateISO ? new Date(b.dateISO).getTime() : 0; return bd - ad; @@ -181,94 +340,64 @@ export default function StudentDetailCompare({ const groupedDocs = useMemo(() => { return filteredDocs.reduce((acc, d) => { - const key = d.dateISO - ? new Date(d.dateISO).toLocaleString("en-US", { month: "long", year: "numeric" }) - : "Undated"; + const key = d.dateISO ? new Date(d.dateISO).toLocaleString("en-US", { month: "long", year: "numeric" }) : "Undated"; (acc[key] ||= []).push(d); return acc; }, {}); }, [filteredDocs]); - const hasFilteredDocs = filteredDocs.length > 0; - - const SkeletonCard = ({ i }) => ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ); + const selectedMeta = (Array.isArray(selectedEssays) ? selectedEssays : []).map((id) => docMetaById.get(String(id))).filter(Boolean); + const typeA = selectedMeta[0]?.assignmentType; + const typeB = selectedMeta[1]?.assignmentType; return ( - <> +
{isDocsLoading ? ( -
-
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
+
+ {Array.from({ length: 6 }).map((_, i) => )}
) : isDocsEmpty ? ( -
+
+
No documents yet
-
We didn’t find any documents for this student.
+
We didn't find any documents for this student.
) : ( <> -
+ {/* Toolbar */} +
+ + {/* Genre filter */}
- {tagOpen && ( -
-
+
+
typeof setTagQuery === "function" && setTagQuery(e.target.value)} className="w-full bg-transparent text-sm outline-none" />
-
+
{safeBaseTags .filter((t) => String(t).toLowerCase().includes(String(tagQuery || "").toLowerCase())) .map((t) => ( - ))}
@@ -287,24 +416,26 @@ export default function StudentDetailCompare({ )}
+ {/* Search */}
- + typeof setSearch === "function" && setSearch(e.target.value)} - placeholder="Search title, tags, text…" - className="pl-8 pr-3 py-2 border border-gray-300 rounded-md text-sm bg-white w-64 focus:outline-none focus:ring-2 focus:ring-emerald-500" + placeholder="Search title, text..." + className="pl-8 pr-3 py-2 border border-gray-300 rounded-lg text-sm bg-white w-56 focus:outline-none focus:ring-2 focus:ring-emerald-500" />
+ {/* Sort */}
- Sort by: + Sort by:
-
- Cards per row - + {/* Cards per row */} +
+ {[1,2,3,4].map((n) => ( + + ))}
+ {/* Active filter chips */} {isAnyFilter && ( -
+
{(Array.isArray(filterTags) ? filterTags : []).map((t) => ( - - Tag: {t} - ))} - {String(search || "").trim().length > 0 && ( - - Search: “{search}” - + + "{search}" + )} - -
)}
- {!hasFilteredDocs && ( -
+ {/* Apples-to-apples warning */} + + + {filteredDocs.length === 0 && ( +
+
No matching documents
-
Try adjusting your search or removing filters.
+
Try adjusting your search or removing filters.
)} + {/* Grouped essay cards */} {Object.entries(groupedDocs).map(([category, list], index) => { - const wordsAvg = Math.round( - list.reduce((s, e) => s + (Number(e.words) || 0), 0) / Math.max(1, list.length) - ); + const wordsAvg = Math.round(list.reduce((s, e) => s + (Number(e.words) || 0), 0) / Math.max(1, list.length)); return (
- {index !== 0 &&
} + {index !== 0 &&
} -
-
-

{category}

-
- {list.length} essays • Avg {wordsAvg.toLocaleString()} words -
+
+

{category}

+
+ {list.length} {list.length === 1 ? "essay" : "essays"} · avg {wordsAvg.toLocaleString()} words
-
+
{list.map((essay) => { - const essayTags = Array.isArray(essay?.tags) ? essay.tags : []; - const isSelected = Array.isArray(selectedEssays) ? selectedEssays.includes(essay.id) : false; + const isSelected = Array.isArray(selectedEssays) && selectedEssays.includes(essay.id); const { strength, focus } = safeStrengthAndFocus(essay); + // Paste stats from pasteStatsByDoc (populated by StudentDetail from merged doc node) + const pasteEntry = safePasteStats[essay.id] ?? {}; + // largePasteCount: prefer the explicit field, fall back to pasteCount for compatibility + const largePasteCount = pasteEntry.largePasteCount ?? pasteEntry.pasteCount ?? 0; + const totalPasteChars = pasteEntry.totalPasteChars ?? pasteEntry.pasteChars ?? 0; + const pasteWords = charsToWords(totalPasteChars); + const hasPastes = largePasteCount > 0; + + // Time on task: prefer from essay object (set by buildEssaysFromDocs reading time_on_task), + // fall back to pasteStatsByDoc entry which also carries timeOnTask + const timeOnTask = essay.timeOnTaskMins ?? pasteEntry.timeOnTask ?? null; + return (
safeHandleEssaySelect(essay.id)} + className={`group relative bg-white rounded-2xl transition-all duration-150 cursor-pointer + shadow-sm hover:shadow-md flex flex-col ${ isSelected - ? "border-emerald-500 ring-2 ring-emerald-200" - : "border-gray-200 hover:border-gray-300" + ? "border-2 border-emerald-500 shadow-emerald-100 shadow-md" + : "border border-gray-200 hover:border-gray-300" }`} - onClick={() => safeHandleEssaySelect(essay.id)} > -
-
-
+
+ + {/* Header row */} +
+
safeHandleEssaySelect(essay.id)} - className="w-5 h-5 accent-emerald-600" onClick={(e) => e.stopPropagation()} + className="mt-0.5 h-4 w-4 accent-emerald-600 shrink-0" aria-label={`Select ${essay.title}`} /> -

{essay.title}

+

{essay.title}

- - + />
-
-

- + {/* Meta row: genre badge, date, word count, time on task */} +

+ + + {essay.date || "Unknown date"} -

-
- - {(Number(essay.words) || 0).toLocaleString()} words - -
-
- -
-

{essay.preview || ""}

-
- -
- {strength && ( - - Strength: {String(strength.label || "").split("(")[0].trim()}{" "} - {Number(strength.delta) > 0 ? "▲" : ""} - - )} - {focus && ( - - Focus: {String(focus.label || "").split("(")[0].trim()}{" "} - {Number(focus.delta) < 0 ? "▼" : ""} + + + + {(Number(essay.words) || 0).toLocaleString()} words + + {timeOnTask != null && ( + + + {Math.round(timeOnTask)} min )}
-
- {essayTags.map((tag, i) => ( - - {tag} + {/* Preview */} +

{essay.preview || ""}

+ + {/* Strength / Focus chips */} + {(strength || focus) && ( +
+ {strength && ( + + + {String(strength.label || "").split("(")[0].trim()} + + )} + {focus && ( + + + {String(focus.label || "").split("(")[0].trim()} + + )} +
+ )} + + {/* Large paste signal — always shown */} +
+ +

+ + {largePasteCount} large {largePasteCount === 1 ? "paste" : "pastes"} - ))} + {hasPastes && ( + <> + {" "}(~{pasteWords} words).{" "} + + + )} +

+ + {/* Selected footer */} + {isSelected && ( +
+ + Selected for comparison +
+ )}
); })} @@ -502,88 +666,92 @@ export default function StudentDetailCompare({ )} - {Array.isArray(selectedEssays) && selectedEssays.length < 2 && ( -
-
- -

Tip: click cards or use the checkboxes to add essays to the selection tray (max 2).

-
-
- )} + {/* Bottom bar */} +
-
-
-
-
-
+ {Array.isArray(selectedEssays) && selectedEssays.length < 2 && ( +
+
+ + Select up to 2 essays to compare them side-by-side. For best results, choose essays of the same type (e.g. two Argumentative essays).
+
+ )} + +
+
+
+
+
+ {[0,1].map((i) => ( +
i + ? "bg-emerald-400" : "bg-white/20" + }`} /> + ))} +
+ + {Array.isArray(selectedEssays) ? selectedEssays.length : 0}/2 + +
- - Selected {Array.isArray(selectedEssays) ? selectedEssays.length : 0}/2 - - -
- {[0, 1].map((i) => { - const id = Array.isArray(selectedEssays) ? selectedEssays[i] : undefined; - const meta = id ? docMetaById.get(String(id)) : null; - const displayName = meta?.title || (id ? `#${id}` : "—"); - return ( -
- {displayName} - {id && ( - - )} -
- ); - })} +
+ {[0,1].map((i) => { + const id = Array.isArray(selectedEssays) ? selectedEssays[i] : undefined; + const meta = id ? docMetaById.get(String(id)) : null; + const displayName = meta?.title || (id ? humanizeDocId(id, meta?._index) : null); + const type = meta?.assignmentType; + const typeColors = type ? ASSIGNMENT_TYPE_COLORS[type] : null; + return ( +
+ {type && typeColors && } + {displayName || `Essay ${i + 1}`} + {id && ( + + )} +
+ ); + })} +
+ + {Array.isArray(selectedEssays) && selectedEssays.length > 0 && ( + + )}
- -
+
+ {openEssay?.docId && ( setOpenEssay(null)} + initialDocsObj={docsObj} /> )} - +
); } diff --git a/modules/portfolio_diff/src/app/students/components/StudentDetail/StudentDetailGrowth.js b/modules/portfolio_diff/src/app/students/components/StudentDetail/StudentDetailGrowth.js index 75c71f70..7a8f6827 100644 --- a/modules/portfolio_diff/src/app/students/components/StudentDetail/StudentDetailGrowth.js +++ b/modules/portfolio_diff/src/app/students/components/StudentDetail/StudentDetailGrowth.js @@ -2,7 +2,7 @@ import { useMemo, useCallback, useState, useEffect, useRef } from "react"; import dynamic from "next/dynamic"; -import { Loader2 } from "lucide-react"; +import { Loader2, Info, TrendingDown, Lightbulb } from "lucide-react"; import { useLOConnectionDataManager } from "lo_event/lo_event/lo_assess/components/components.jsx"; import { MetricsPanel } from "@/app/components/MetricsPanel"; @@ -13,254 +13,481 @@ const ReactECharts = dynamic(() => import("echarts-for-react"), { ssr: false }); const DEBUG = false; -/* ---------------------- stable stringify ---------------------- */ +/* ══════════════════════════════════════════════════════════════ + 6+1 TRAIT RUBRIC MAPPING + ══════════════════════════════════════════════════════════════ */ +const METRIC_DISPLAY = { + academic_language: { title: "Academic Language", trait: "Word Choice" }, + informal_language: { title: "Informal Language", trait: "Word Choice" }, + latinate_words: { title: "Latinate Words", trait: "Word Choice" }, + opinion_words: { title: "Opinion Words", trait: "Word Choice" }, + emotion_words: { title: "Emotion Words", trait: "Word Choice" }, + argument_words: { title: "Argument Words", trait: "Ideas & Content" }, + explicit_argument: { title: "Explicit Argument", trait: "Ideas & Content" }, + statements_of_opinion: { title: "Statements of Opinion", trait: "Ideas & Content" }, + statements_of_fact: { title: "Statements of Fact", trait: "Ideas & Content" }, + information_sources: { title: "Information Sources", trait: "Ideas & Content" }, + attributions: { title: "Attributions", trait: "Ideas & Content" }, + citations: { title: "Citations", trait: "Ideas & Content" }, + quoted_words: { title: "Quoted Words", trait: "Ideas & Content" }, + concrete_details: { title: "Concrete Details", trait: "Ideas & Content" }, + main_idea_sentences: { title: "Main Idea Sentences", trait: "Ideas & Content" }, + supporting_idea_sentences: { title: "Supporting Idea Sentences", trait: "Ideas & Content" }, + supporting_detail_sentences: { title: "Supporting Detail Sentences", trait: "Ideas & Content" }, + explicit_claims: { title: "Explicit Claims", trait: "Ideas & Content" }, + social_awareness: { title: "Social Awareness", trait: "Ideas & Content" }, + transition_words: { title: "Transition Words", trait: "Organization" }, + positive_transition_words: { title: "Positive Transitions", trait: "Organization" }, + conditional_transition_words: { title: "Conditional Transitions", trait: "Organization" }, + consequential_transition_words:{ title: "Consequential Transitions", trait: "Organization" }, + contrastive_transition_words: { title: "Contrastive Transitions", trait: "Organization" }, + counterpoint_transition_words: { title: "Counterpoint Transitions", trait: "Organization" }, + comparative_transition_words: { title: "Comparative Transitions", trait: "Organization" }, + cross_referential_transition_words: { title: "Cross-Referential Transitions", trait: "Organization" }, + illustrative_transition_words: { title: "Illustrative Transitions", trait: "Organization" }, + negative_transition_words: { title: "Negative Transitions", trait: "Organization" }, + emphatic_transition_words: { title: "Emphatic Transitions", trait: "Organization" }, + evenidentiary_transition_words:{ title: "Evidentiary Transitions", trait: "Organization" }, + general_transition_words: { title: "General Transitions", trait: "Organization" }, + ordinal_transition_words: { title: "Ordinal Transitions", trait: "Organization" }, + purposive_transition_words: { title: "Purposive Transitions", trait: "Organization" }, + periphrastic_transition_words: { title: "Periphrastic Transitions", trait: "Organization" }, + hypothetical_transition_words: { title: "Hypothetical Transitions", trait: "Organization" }, + summative_transition_words: { title: "Summative Transitions", trait: "Organization" }, + introductory_transition_words: { title: "Introductory Transitions", trait: "Organization" }, + direct_speech_verbs: { title: "Direct Speech Verbs", trait: "Voice" }, + indirect_speech: { title: "Indirect Speech", trait: "Voice" }, + positive_tone: { title: "Positive Tone", trait: "Voice" }, + negative_tone: { title: "Negative Tone", trait: "Voice" }, + character_trait_words: { title: "Character Trait Words", trait: "Voice" }, + adjectives: { title: "Adjectives", trait: "Sentence Fluency" }, + adverbs: { title: "Adverbs", trait: "Sentence Fluency" }, + nouns: { title: "Nouns", trait: "Sentence Fluency" }, + proper_nouns: { title: "Proper Nouns", trait: "Sentence Fluency" }, + verbs: { title: "Verbs", trait: "Sentence Fluency" }, + numbers: { title: "Numbers", trait: "Sentence Fluency" }, + prepositions: { title: "Prepositions", trait: "Sentence Fluency" }, + coordinating_conjunction: { title: "Coordinating Conjunctions", trait: "Sentence Fluency" }, + subordinating_conjunction: { title: "Subordinating Conjunctions", trait: "Sentence Fluency" }, + auxiliary_verb: { title: "Auxiliary Verbs", trait: "Sentence Fluency" }, + pronoun: { title: "Pronouns", trait: "Sentence Fluency" }, + simple_sentences: { title: "Simple Sentences", trait: "Sentence Fluency" }, + simple_with_complex_predicates:{ title: "Simple + Complex Predicates", trait: "Sentence Fluency" }, + simple_with_compound_predicates:{ title: "Simple + Compound Predicates", trait: "Sentence Fluency" }, + simple_with_compound_complex_predicates: { title: "Simple + Compound-Complex Predicates", trait: "Sentence Fluency" }, + compound_sentences: { title: "Compound Sentences", trait: "Sentence Fluency" }, + complex_sentences: { title: "Complex Sentences", trait: "Sentence Fluency" }, + compound_complex_sentences: { title: "Compound-Complex Sentences", trait: "Sentence Fluency" }, + polysyllabic_words: { title: "Polysyllabic Words", trait: "Conventions" }, + low_frequency_words: { title: "Low Frequency Words", trait: "Conventions" }, + sentences: { title: "Sentences", trait: "Conventions" }, + paragraphs: { title: "Paragraphs", trait: "Conventions" }, + in_past_tense: { title: "In Past Tense", trait: "Conventions" }, +}; + +function getMetricTitle(metricId) { + return METRIC_DISPLAY[metricId]?.title + || metricId.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +function getMetricTrait(metricId) { + return METRIC_DISPLAY[metricId]?.trait || "Conventions"; +} + +/* ══════════════════════════════════════════════════════════════ + TRAIT STYLE TOKENS + ══════════════════════════════════════════════════════════════ */ +const TRAIT_STYLE = { + "Ideas & Content": { badge: "bg-amber-50 text-amber-700 ring-amber-200" }, + "Organization": { badge: "bg-blue-50 text-blue-700 ring-blue-200" }, + "Voice": { badge: "bg-rose-50 text-rose-700 ring-rose-200" }, + "Word Choice": { badge: "bg-violet-50 text-violet-700 ring-violet-200" }, + "Sentence Fluency": { badge: "bg-teal-50 text-teal-700 ring-teal-200" }, + "Conventions": { badge: "bg-slate-50 text-slate-700 ring-slate-200" }, +}; + +function TraitBadge({ trait }) { + const cls = TRAIT_STYLE[trait]?.badge || "bg-gray-50 text-gray-600 ring-gray-200"; + return ( + + {trait} + + ); +} + +/* ══════════════════════════════════════════════════════════════ + ASSIGNMENT TYPE COLORS — full canonical list always in legend + ══════════════════════════════════════════════════════════════ */ +const GENRE_BAR_COLORS = { + Narrative: "#7c3aed", + Argumentative: "#d97706", + Analytical: "#2563eb", + Expository: "#0d9488", + Informational: "#0891b2", + Document: "#059669", + Other: "#6b7280", +}; + +const ALL_GENRES = ["Narrative", "Argumentative", "Analytical", "Expository", "Informational", "Document", "Other"]; + +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"; +} + +/* ══════════════════════════════════════════════════════════════ + TREND ANALYSIS + ══════════════════════════════════════════════════════════════ */ +function analyzeTrend(points, windowSize = 3) { + if (!points || points.length < 2) return "stable"; + const recent = points.slice(-Math.min(windowSize, points.length)); + const delta = (recent[recent.length - 1]?.raw ?? 0) - (recent[0]?.raw ?? 0); + if (delta < -1.5) return "declining"; + if (delta > 1.5) return "improving"; + return "stable"; +} + +function computeSlope(points, windowSize = 3) { + if (!points || points.length < 2) return 0; + const recent = points.slice(-Math.min(windowSize, points.length)); + const n = recent.length; + let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0; + for (let i = 0; i < n; i++) { + sumX += i; sumY += recent[i].raw; + sumXY += i * recent[i].raw; sumX2 += i * i; + } + const denom = n * sumX2 - sumX * sumX; + return denom === 0 ? 0 : (n * sumXY - sumX * sumY) / denom; +} + +function getInstructionalSuggestion(metricId, trait, slope) { + const title = getMetricTitle(metricId); + const dropPct = Math.abs(slope * 3).toFixed(1); + const map = { + academic_language: `${title} has declined over the last few essays (~${dropPct}pp). Consider a pre-writing vocabulary activity or a word bank to reinforce formal register.`, + informal_language: `Informal language use has increased recently. A brief editing checklist focusing on word choice before submission may help.`, + latinate_words: `Use of sophisticated vocabulary (Latinate words) is declining. Consider modeling academic synonyms during class discussion or mini-lessons.`, + transition_words: `Transition word use has dropped (~${dropPct}pp). A sentence-starter scaffold or revision task targeting connective language could help.`, + citations: `Citation use has declined recently. Review expectations for source integration and consider a brief conference on evidence-based writing.`, + sentences: `Sentence count or variety has been declining. Consider a sentence-combining exercise or revision pass focused on structural complexity.`, + paragraphs: `Paragraph structure appears to be declining. A quick conference reviewing organization expectations may be warranted before the next essay.`, + explicit_argument: `Explicit argument signals have weakened. Consider a targeted mini-lesson on thesis statements or claim framing.`, + concrete_details: `Use of concrete details has dropped. Encourage the student to add specific examples or evidence in the next revision cycle.`, + }; + return map[metricId] + || `${title} (${trait}) has shown a declining trend (~${dropPct}pp). Consider targeted feedback on this dimension before the next assignment.`; +} + +/* ══════════════════════════════════════════════════════════════ + INSTRUCTIONAL SUGGESTION CALLOUT + ══════════════════════════════════════════════════════════════ */ +function InstructionalSuggestion({ metricId, trait, points }) { + const [expanded, setExpanded] = useState(false); + const trend = analyzeTrend(points); + const slope = computeSlope(points); + if (trend !== "declining") return null; + const suggestion = getInstructionalSuggestion(metricId, trait, slope); + + return ( +
+ + {expanded && ( +
+

{suggestion}

+

+ Based on trend over last 3 essays. Verify against your knowledge of this student before acting. +

+
+ )} +
+ ); +} + +/* ══════════════════════════════════════════════════════════════ + PINNED TOOLTIP + ══════════════════════════════════════════════════════════════ */ +function PinnedTooltip({ point, metricTitle, onClear }) { + if (!point) return null; + const genreColor = GENRE_BAR_COLORS[point.assignmentType] || GENRE_BAR_COLORS.Document; + const pct = Number.isFinite(Number(point.raw)) ? Number(point.raw).toFixed(1) : "0.0"; + const date = point.date && !point.date.startsWith("Essay") ? point.date : null; + + return ( +
+
+
+ + {point.title} +
+ {date && {date}} +
+ {metricTitle}: + {pct}% + ({point.assignmentType}) +
+
+ +
+ ); +} + +/* ══════════════════════════════════════════════════════════════ + STABLE STRINGIFY + ══════════════════════════════════════════════════════════════ */ function stableStringify(obj) { const seen = new WeakSet(); const sortObj = (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(); const out = {}; for (const k of keys) out[k] = sortObj(v[k]); return out; }; - - try { - return JSON.stringify(sortObj(obj)); - } catch { - return String(obj); - } + try { return JSON.stringify(sortObj(obj)); } catch { return String(obj); } } -/* ---------------------- Metric normalization ---------------------- */ +/* ══════════════════════════════════════════════════════════════ + METRIC NORMALIZATION + ══════════════════════════════════════════════════════════════ */ function normalizeSelectedMetrics(input) { if (!input) return []; - const pickMetricId = (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.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; } 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(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); return []; } -/* ---------------------- Coverage helpers ---------------------- */ +/* ══════════════════════════════════════════════════════════════ + COVERAGE HELPER + ══════════════════════════════════════════════════════════════ */ function coveragePercentFromDoc(doc, metricId) { const text = (doc?.text || "").toString(); - const L = text.length; - if (!L) return 0; - + const L = text.length; if (!L) return 0; const offsets = doc?.[metricId]?.offsets; if (!Array.isArray(offsets) || offsets.length === 0) 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]); + const start = Number(pair[0]), 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)); + const s = Math.max(0, Math.min(L, start)), e = Math.max(0, Math.min(L, start + 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 [curS, curE] = 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 <= curE) curE = Math.max(curE, e); else { covered += curE - curS; curS = s; curE = e; } } - covered += curE - curS; - - return (covered / L) * 100; + return ((covered + curE - curS) / L) * 100; } -/* ---------------------- LO Runner ---------------------- */ +/* ══════════════════════════════════════════════════════════════ + LO RUNNER — isolated so its re-renders never reach charts + ══════════════════════════════════════════════════════════════ */ function LORunner({ wsUrl, dataScope, scopeKey, onData, onErrors }) { - const { data, errors } = useLOConnectionDataManager({ - url: wsUrl, - dataScope, - }); - + const { data, errors } = useLOConnectionDataManager({ url: wsUrl, dataScope }); useEffect(() => onData?.(data), [data, onData]); useEffect(() => onErrors?.(errors), [errors, onErrors]); - useEffect(() => { if (!DEBUG) return; console.log("[LORunner] mounted scopeKey:", scopeKey); return () => console.log("[LORunner] unmounted scopeKey:", scopeKey); }, [scopeKey]); - return null; } -/* ---------------------- ECharts option builder ---------------------- */ +/* ══════════════════════════════════════════════════════════════ + ECHART OPTION BUILDER + ══════════════════════════════════════════════════════════════ */ function buildEChartOption({ metricId, points }) { - const labels = points.map((p) => p.label); + const rawLabels = points.map((p) => p.label); + const uniqueDates = new Set(rawLabels.filter((l) => l && !l.startsWith("Essay"))); + const allSameDate = uniqueDates.size <= 1 && rawLabels.length > 1; + const xLabels = points.map((p, i) => allSameDate ? `Essay ${i + 1}` : (p.label || `Essay ${i + 1}`)); const values = points.map((p) => p.raw).filter(Number.isFinite); const dataMin = values.length ? Math.min(...values) : 0; - const dataMax = values.length ? Math.max(...values) : 100; + const dataMax = values.length ? Math.max(...values) : 10; const range = dataMax - dataMin; - - // Add padding: 20% of range on each side, minimum ±5 percentage points - const padding = Math.max(range * 0.2, 5); - const yMin = Math.max(0, Math.floor((dataMin - padding) / 5) * 5); - const yMax = Math.min(100, Math.ceil((dataMax + padding) / 5) * 5); - - // If all values are identical or very close, center around that value - const finalMin = yMin === yMax ? Math.max(0, yMin - 10) : yMin; - const finalMax = yMin === yMax ? Math.min(100, yMax + 10) : yMax; - - const barData = points.map((p) => ({ - value: p.barValue, - docId: p.docId, - label: p.label, - title: p.title, - raw: p.raw, + const padding = Math.max(range * 0.25, 2); + const yMin = Math.max(0, Math.floor((dataMin - padding) / 2) * 2); + const yMax = Math.min(100, Math.ceil((dataMax + padding) / 2) * 2); + const finalMin = yMin === yMax ? Math.max(0, yMin - 5) : yMin; + const finalMax = yMin === yMax ? Math.min(100, yMax + 5) : yMax; + + const barData = points.map((p, i) => ({ + value: p.raw, docId: p.docId, label: xLabels[i], date: p.label, + title: p.title, raw: p.raw, assignmentType: p.assignmentType || "Document", + itemStyle: { color: GENRE_BAR_COLORS[p.assignmentType] || GENRE_BAR_COLORS.Document }, })); - const lineData = points.map((p) => ({ - value: p.value, - docId: p.docId, - label: p.label, - title: p.title, - raw: p.raw, + const lineData = points.map((p, i) => ({ + value: p.raw, label: xLabels[i], date: p.label, + title: p.title, raw: p.raw, assignmentType: p.assignmentType || "Document", })); + const metricTitle = getMetricTitle(metricId); + return { animation: false, - grid: { top: 20, right: 20, bottom: 40, left: 60 }, - + grid: { top: 20, right: 20, bottom: 48, left: 56 }, + legend: { + show: true, bottom: 0, + data: [ + { name: "Essay value", icon: "roundRect" }, + { name: "Trend (rolling avg)", icon: "line" }, + ], + textStyle: { fontSize: 10, color: "#6b7280" }, + itemWidth: 12, itemHeight: 6, + }, tooltip: { - trigger: "axis", - confine: true, - axisPointer: { - type: "line", - shadowStyle: { opacity: 0 }, - lineStyle: { color: "rgba(107,114,128,0.55)", width: 1 }, - }, + trigger: "axis", confine: true, + axisPointer: { type: "line", lineStyle: { color: "rgba(107,114,128,0.4)", width: 1 } }, formatter: (params) => { - const primary = Array.isArray(params) ? params[0] : params; + const primary = Array.isArray(params) + ? params.find((p) => p.seriesName === "Essay value") || params[0] : params; const d = primary?.data || {}; const pct = Number.isFinite(Number(d.raw)) ? Number(d.raw).toFixed(1) : "0.0"; - const docId = d.docId || "—"; const title = d.title || "Untitled"; - const date = d.label || primary?.axisValue || ""; - - return ` -
-
${metricId}
-
${title}
-
${date}
-
Coverage: ${pct}%
-
Document: ${docId}
-
- `; + const date = d.date && !d.date.startsWith("Essay") ? d.date : ""; + const genre = d.assignmentType || "Document"; + const genreColor = GENRE_BAR_COLORS[genre] || GENRE_BAR_COLORS.Document; + return [ + `
`, + `
${title}
`, + date ? `
${date}
` : "", + `
`, + ``, + `${genre}
`, + `
${metricTitle}: ${pct}%
`, + `
Click to pin · Click again to unpin
`, + `
`, + ].join(""); }, }, - xAxis: { - type: "category", - data: labels, - axisLabel: { fontSize: 11, interval: "auto" }, - axisPointer: { - show: true, - type: "line", - shadowStyle: { opacity: 0 }, - lineStyle: { color: "rgba(107,114,128,0.55)", width: 1 }, - }, + type: "category", data: xLabels, + axisLabel: { fontSize: 11, interval: 0, rotate: xLabels.length > 6 ? 30 : 0, overflow: "truncate", width: 80 }, + axisPointer: { show: true, type: "line", lineStyle: { color: "rgba(107,114,128,0.4)", width: 1 } }, }, - yAxis: { - type: "value", - min: finalMin, - max: finalMax, - axisLabel: { formatter: "{value}%" }, + type: "value", min: finalMin, max: finalMax, + axisLabel: { formatter: "{value}%", fontSize: 11 }, + splitLine: { lineStyle: { color: "#f3f4f6" } }, }, - series: [ { - name: "Coverage (bar)", - type: "bar", - data: barData, - barMaxWidth: 28, - emphasis: { focus: "none" }, - select: { disabled: true }, - blur: { itemStyle: { opacity: 1 } }, + name: "Essay value", type: "bar", data: barData, barMaxWidth: 32, + emphasis: { focus: "none" }, select: { disabled: true }, + blur: { itemStyle: { opacity: 1 } }, z: 2, }, { - name: "Coverage (line)", - type: "line", - data: lineData, - smooth: true, - symbol: "circle", - symbolSize: 8, - emphasis: { focus: "none" }, - select: { disabled: true }, - blur: { lineStyle: { opacity: 1 }, itemStyle: { opacity: 1 } }, + name: "Trend (rolling avg)", type: "line", data: lineData, + smooth: true, symbol: "circle", symbolSize: 7, + lineStyle: { color: "#84cc16", width: 2 }, itemStyle: { color: "#84cc16" }, + emphasis: { focus: "none" }, select: { disabled: true }, + blur: { lineStyle: { opacity: 1 }, itemStyle: { opacity: 1 } }, z: 3, }, ], }; } +/* ══════════════════════════════════════════════════════════════ + FROZEN CHART COMPONENT + Receives a fully-built, stable option object. + Re-renders only when frozenOption reference changes. + Pinned tooltip state lives in the parent and does NOT + pass through here, so tooltip interactions never redraw charts. + ══════════════════════════════════════════════════════════════ */ +const FrozenChart = ({ frozenOption, onBarClick }) => { + const onEvents = useMemo(() => ({ + click: (params) => { + if (params?.seriesName !== "Essay value") return; + onBarClick?.(params?.dataIndex); + }, + }), [onBarClick]); + + return ( + + ); +}; + +/* ══════════════════════════════════════════════════════════════ + COMPUTE SERIES FROM DOCS + Pure function — no hooks, safe to call from useEffect. + ══════════════════════════════════════════════════════════════ */ +function computeSeriesFromDocs({ selectedMetrics, docIdsAsc, docsObj, essaysInRangeAsc }) { + const out = {}; + for (const metricId of selectedMetrics) { + const points = []; + for (let i = 0; i < docIdsAsc.length; i++) { + const docId = docIdsAsc[i]; + const doc = docsObj?.[docId]; + const essay = essaysInRangeAsc[i] || {}; + const dateLabel = (essay?.date && String(essay.date) !== "—") ? String(essay.date) : null; + const label = dateLabel || `Essay ${i + 1}`; + const title = (essay?.title && String(essay.title)) || `Document ${i + 1}`; + const assignmentType = essay?.assignmentType || essay?.tags?.[0] || inferAssignmentType(doc?.text || ""); + const raw = coveragePercentFromDoc(doc, metricId); + points.push({ idx: i, label, title, docId, raw, value: raw, assignmentType, metricLabel: metricId }); + } + out[metricId] = points; + } + return out; +} + +/* ══════════════════════════════════════════════════════════════ + MAIN COMPONENT + ══════════════════════════════════════════════════════════════ */ export default function StudentDetailGrowth({ metrics, setMetrics, - studentID, - essaysInRangeAsc = [], }) { const selectedMetrics = useMemo(() => normalizeSelectedMetrics(metrics), [metrics]); const { courseId } = useCourseIdContext(); + const origin = getConfiguredWsOrigin(); const wsUrl = `${origin}/wsapi/communication_protocol`; const docIdsAsc = useMemo(() => { @@ -271,36 +498,29 @@ export default function StudentDetailGrowth({ const enabled = !!studentID && docIdsAsc.length > 0 && selectedMetrics.length > 0; - const origin = getConfiguredWsOrigin(); - const dataScope = useMemo(() => { - if (!enabled) { - return { wo: { execution_dag: "writing_observer", target_exports: [], kwargs: {} } }; - } - + if (!enabled) return { wo: { execution_dag: "writing_observer", target_exports: [], kwargs: {} } }; return { wo: { execution_dag: "writing_observer", - target_exports: ["single_student_docs_with_nlp_annotations", 'student_with_docs'], - kwargs: { - course_id: courseId, - student_id: studentID, - document: docIdsAsc, - nlp_options: selectedMetrics, - }, + target_exports: ["single_student_docs_with_nlp_annotations", "student_with_docs"], + kwargs: { course_id: courseId, student_id: studentID, document: docIdsAsc, nlp_options: selectedMetrics }, }, }; }, [enabled, courseId, studentID, docIdsAsc, selectedMetrics]); - const scopeKey = useMemo(() => { - const signature = { enabled, studentID, courseId, docIdsAsc, selectedMetrics }; - return stableStringify(signature); - }, [enabled, studentID, courseId, docIdsAsc, selectedMetrics]); + const scopeKey = useMemo(() => + stableStringify({ enabled, studentID, courseId, docIdsAsc, selectedMetrics }), + [enabled, studentID, courseId, docIdsAsc, selectedMetrics]); + // ── Raw WS stream state ─────────────────────────────────────── const [loData, setLoData] = useState(null); const [loErrors, setLoErrors] = useState(null); - const [isFetching, setIsFetching] = useState(false); - const prevScopeKeyRef = useRef(scopeKey); + + // ── Loading indicator state ─────────────────────────────────── + // Starts true when enabled so the loading spinner shows immediately. + const [isFetching, setIsFetching] = useState(enabled); + const prevScopeKeyRef = useRef(null); // null so first scope always triggers reset useEffect(() => { if (!enabled) { @@ -310,89 +530,76 @@ export default function StudentDetailGrowth({ if (prevScopeKeyRef.current !== scopeKey) { prevScopeKeyRef.current = scopeKey; setIsFetching(true); + setLoData(null); setLoErrors(null); + setFrozenSeriesByMetric({}); + setFrozenChartOptions({}); } }, [enabled, scopeKey]); useEffect(() => { if (!enabled) return; + const hasErrors = loErrors && ( + (Array.isArray(loErrors) && loErrors.length > 0) || + (typeof loErrors === "object" && Object.keys(loErrors).length > 0) + ); + if (hasErrors) { setIsFetching(false); } + }, [enabled, loErrors]); + + // ── FROZEN SERIES & CHART OPTIONS ──────────────────────────── + // These are only ever updated once per scope: when loData first + // arrives with actual doc content. After that, WS ticks may keep + // updating loData but we ignore them for chart rendering. + const [frozenSeriesByMetric, setFrozenSeriesByMetric] = useState({}); + const [frozenChartOptions, setFrozenChartOptions] = useState({}); + const frozenScopeRef = useRef(null); // tracks which scopeKey the frozen data belongs to - const hasErrors = - loErrors && - ((Array.isArray(loErrors) && loErrors.length > 0) || - (typeof loErrors === "object" && Object.keys(loErrors).length > 0)); + useEffect(() => { + if (!enabled || !loData) return; - if (hasErrors) { - setIsFetching(false); - return; - } - if (loData) setIsFetching(false); - }, [enabled, loData, loErrors]); - - const docsObj = loData?.students?.[studentID]?.documents || {}; - - const isMetricReady = useCallback( - (metricId) => { - if (!metricId) return false; - for (const d of docIdsAsc) { - const doc = docsObj?.[d]; - const offsets = doc?.[metricId]?.offsets; - if (Array.isArray(offsets) && offsets.length > 0) return true; - } - return false; - }, - [docsObj, docIdsAsc] - ); + const docsObj = loData?.students?.[studentID]?.documents || {}; - const seriesByMetric = useMemo(() => { - const out = {}; - if (!enabled || !loData) return out; + // Only freeze once per scopeKey — ignore subsequent WS ticks + if (frozenScopeRef.current === scopeKey) return; - for (const metricId of selectedMetrics) { - // keep per-metric loader behavior for newly added metrics - if (isFetching && !isMetricReady(metricId)) { - out[metricId] = null; - continue; - } + // Check that at least one metric has data in at least one doc + // before freezing, so we don't freeze on a partial/empty response + const hasAnyData = selectedMetrics.some((metricId) => + docIdsAsc.some((docId) => { + const offsets = docsObj?.[docId]?.[metricId]?.offsets; + return Array.isArray(offsets) && offsets.length > 0; + }) + ); - const points = []; - for (let i = 0; i < docIdsAsc.length; i++) { - const docId = docIdsAsc[i]; - const doc = docsObj?.[docId]; - - const essay = essaysInRangeAsc[i] || {}; - const label = - (essay?.date && String(essay.date)) || - `Essay ${i + 1}`; - const title = - (essay?.title && String(essay.title)) || - `Document ${i + 1}`; - - const raw = coveragePercentFromDoc(doc, metricId); - - points.push({ - idx: i, - label, - title, - docId, - raw, - value: raw, - barValue: raw, - metricLabel: metricId, - }); - } + if (!hasAnyData) return; // wait for a richer tick - out[metricId] = points; + // Freeze + frozenScopeRef.current = scopeKey; + const series = computeSeriesFromDocs({ selectedMetrics, docIdsAsc, docsObj, essaysInRangeAsc }); + setFrozenSeriesByMetric(series); + + const options = {}; + for (const metricId of selectedMetrics) { + const points = series[metricId]; + if (Array.isArray(points)) { + options[metricId] = buildEChartOption({ metricId, points }); + } } + setFrozenChartOptions(options); + setIsFetching(false); + }, [loData, enabled, scopeKey, selectedMetrics, docIdsAsc, essaysInRangeAsc, studentID]); - return out; - }, [enabled, loData, selectedMetrics, docIdsAsc, docsObj, essaysInRangeAsc, isFetching, isMetricReady]); + // ── Pinned tooltips — never cause chart re-renders ──────────── + const [pinnedPointByMetric, setPinnedPointByMetric] = useState({}); - // lock tooltip per metric - const [lockedIndexByMetric, setLockedIndexByMetric] = useState({}); - const chartRefs = useRef({}); + // ── Genres present in documents ────────────────────────────── + const genresPresent = useMemo(() => { + const types = new Set(essaysInRangeAsc.map((e) => e?.assignmentType || e?.tags?.[0] || "Document")); + return new Set(ALL_GENRES.filter((g) => types.has(g))); + }, [essaysInRangeAsc]); const showEmpty = selectedMetrics.length === 0; + const showLoading = isFetching && Object.keys(frozenChartOptions).length === 0; return (
@@ -407,141 +614,156 @@ export default function StudentDetailGrowth({ /> ) : null} - +
-
-
- Showing {docIdsAsc.length} docs + + {/* ── Legend header ─────────────────────────────────── */} +
+
+
+ Showing {docIdsAsc.length}{" "} + {docIdsAsc.length === 1 ? "essay" : "essays"} +
+ + {/* Full genre legend — always show all, dim absent ones */} +
+ {ALL_GENRES.map((genre) => { + const active = genresPresent.has(genre); + return ( + + + {genre} + + ); + })} +
- {enabled && isFetching ? ( -
- - Updating… +
+ {/* Rolling avg legend */} +
+ + Rolling avg
- ) : null} + + {isFetching && ( +
+ Updating... +
+ )} +
+ {/* Genre mixing warning */} + {genresPresent.size > 1 && ( +
+ + + This student has written multiple assignment types (shown by bar color). + Changes between different genres may reflect task demands rather than skill growth. + For clearest growth signals, compare bars of the same color. + +
+ )} + + {/* ── Content ───────────────────────────────────────── */} {showEmpty ? (
- Select one or more metrics from the left to view trends over time. + Select one or more metrics from the left panel to view trends over time.
- ) : !loData ? ( + ) : showLoading ? (
- - Loading documents… + Loading documents...
) : (
{selectedMetrics.map((metricId) => { - const points = seriesByMetric?.[metricId]; + const points = frozenSeriesByMetric[metricId]; + const frozenOption = frozenChartOptions[metricId]; + const metricTitle = getMetricTitle(metricId); + const trait = getMetricTrait(metricId); + const pinnedPoint = pinnedPointByMetric[metricId] || null; - if (enabled && isFetching && points === null) { + if (!frozenOption || !Array.isArray(points)) { return (
-
-

{metricId}

-
- - Fetching… -
+
+

{metricTitle}

+ +
-
- - Computing new metric… + Computing metric...
); } - const data = Array.isArray(points) ? points : []; - const lockedIndex = lockedIndexByMetric?.[metricId]; - - const option = buildEChartOption({ metricId, points: data }); - - const onEvents = { - click: (params) => { - const idx = params?.dataIndex; - if (typeof idx !== "number") return; - - setLockedIndexByMetric((prev) => { - const cur = prev?.[metricId]; - if (typeof cur === "number" && cur === idx) { - const next = { ...prev }; - delete next[metricId]; - return next; - } - return { ...prev, [metricId]: idx }; - }); - - const inst = chartRefs.current?.[metricId]; - if (inst) { - // lock tooltip without any highlight/selection - inst.dispatchAction({ type: "showTip", seriesIndex: 0, dataIndex: idx }); - } - }, - }; - return (
-
-

{metricId}

- -
- {data.length} points - - {typeof lockedIndex === "number" ? ( - - ) : null} +
+
+

{metricTitle}

+
+ + {points.length} + {" "}{points.length === 1 ? "essay" : "essays"} +
+ {/* Frozen chart — WS ticks never reach here */}
- { - const inst = ref?.getEchartsInstance?.(); - if (inst) chartRefs.current[metricId] = inst; + { + const point = points[idx]; + if (!point) return; + setPinnedPointByMetric((prev) => { + const cur = prev[metricId]; + if (cur && cur.idx === idx) { + const next = { ...prev }; delete next[metricId]; return next; + } + return { ...prev, [metricId]: { ...point, idx } }; + }); }} /> - - {enabled && isFetching ? ( -
-
- - Fetching updated metrics… -
-
- ) : null}
-

- Hover to show more information. -

+ {pinnedPoint ? ( + setPinnedPointByMetric((prev) => { + const n = { ...prev }; delete n[metricId]; return n; + })} + /> + ) : ( +

+ Hover a bar to see essay details. Click any bar to pin details below the chart. + {genresPresent.size > 1 && " Bar color shows assignment type."} +

+ )} + +
); })} diff --git a/modules/portfolio_diff/src/app/students/components/StudentDetail/index.js b/modules/portfolio_diff/src/app/students/components/StudentDetail/index.js index 455e5f26..d509003e 100644 --- a/modules/portfolio_diff/src/app/students/components/StudentDetail/index.js +++ b/modules/portfolio_diff/src/app/students/components/StudentDetail/index.js @@ -1,85 +1,78 @@ "use client"; -import { Calendar, FileText, GitCompareArrows, TrendingUp, Users } from "lucide-react"; +import { + AlertTriangle, + BarChart3, + Calendar, + CheckCircle2, + Clipboard, + Clock, + FileText, + GitCompareArrows, + Info, + Minus, + Sparkles, + TrendingDown, + TrendingUp, + Users, + X +} from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useLOConnectionDataManager } from "lo_event/lo_event/lo_assess/components/components.jsx"; -import StudentDetailCompare from "./StudentDetailCompare"; -import StudentDetailGrowth from "./StudentDetailGrowth"; import { useCourseIdContext } from "@/app/providers/CourseIdProvider"; import { getConfiguredWsOrigin } from "@/app/utils/ws"; +import StudentDetailCompare from "./StudentDetailCompare"; +import StudentDetailGrowth from "./StudentDetailGrowth"; /* ============================================================= CONSTANTS ============================================================= */ const MODES = { COMPARE: "compare", GROWTH: "growth" }; - -const STUDENTS_BREADCRUMB_HREF = - "/wo_portfolio_diff/portfolio_diff/students"; - -const monthsShort = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; -const monthsLong = [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", -]; +const STUDENTS_BREADCRUMB_HREF = "/wo_portfolio_diff/portfolio_diff/students"; +const monthsShort = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; +const monthsLong = ["January","February","March","April","May","June","July","August","September","October","November","December"]; + +const ASSIGNMENT_TYPES = ["All Types", "Narrative", "Argumentative", "Analytical", "Expository", "Other"]; + +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" }, + Other: { bg: "bg-gray-50", text: "text-gray-600", ring: "ring-gray-200", dot: "bg-gray-400" }, + Document: { bg: "bg-emerald-50", text: "text-emerald-700", ring: "ring-emerald-200", dot: "bg-emerald-400" }, +}; const CATEGORY_KEYS = { - 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", + 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 iconForCategoryKey = (catKey) => { switch (catKey) { - case "tone": - return TrendingUp; - case "dialogue": - return Users; - case "details": - return FileText; - default: - return FileText; + case "tone": return TrendingUp; + case "dialogue": return Users; + case "details": return FileText; + default: return FileText; } }; const METRIC_DEFS_RAW = [ - // language { id: "academic_language", title: "Academic Language", categoryKey: "language", function: "percent", desc: "Percent of tokens flagged academic" }, { id: "informal_language", title: "Informal Language", categoryKey: "language", function: "percent", desc: "Percent of tokens flagged informal" }, { id: "latinate_words", title: "Latinate Words", categoryKey: "language", function: "percent", desc: "Percent of tokens flagged latinate" }, { id: "opinion_words", title: "Opinion Words", categoryKey: "language", function: "total", desc: "Total opinion-word signals" }, { id: "emotion_words", title: "Emotion Words", categoryKey: "language", function: "percent", desc: "Percent emotion words" }, - - // argumentation { id: "argument_words", title: "Argument Words", categoryKey: "argumentation", function: "percent", desc: "Percent argument words" }, { id: "explicit_argument", title: "Explicit argument", categoryKey: "argumentation", function: "percent", desc: "Percent explicit argument markers" }, - - // statements { id: "statements_of_opinion", title: "Statements of Opinion", categoryKey: "statements", function: "percent", desc: "Percent of sentences classified as opinion" }, { id: "statements_of_fact", title: "Statements of Fact", categoryKey: "statements", function: "percent", desc: "Percent of sentences classified as fact" }, - - // transitions { id: "transition_words", title: "Transition Words", categoryKey: "transitions", function: "counts", desc: "Transition counts (by type)" }, { id: "positive_transition_words", title: "Positive Transition Words", categoryKey: "transitions", function: "total", desc: "Total positive transitions" }, { id: "conditional_transition_words", title: "Conditional Transition Words", categoryKey: "transitions", function: "total", desc: "Total conditional transitions" }, @@ -99,8 +92,6 @@ const METRIC_DEFS_RAW = [ { id: "hypothetical_transition_words", title: "Hypothetical Transition Words", categoryKey: "transitions", function: "total", desc: "Total hypothetical transitions" }, { id: "summative_transition_words", title: "Summative Transition Words", categoryKey: "transitions", function: "total", desc: "Total summative transitions" }, { id: "introductory_transition_words", title: "Introductory Transition Words", categoryKey: "transitions", function: "total", desc: "Total introductory transitions" }, - - // parts of speech { id: "adjectives", title: "Adjectives", categoryKey: "pos", function: "total", desc: "Total adjectives" }, { id: "adverbs", title: "Adverbs", categoryKey: "pos", function: "total", desc: "Total adverbs" }, { id: "nouns", title: "Nouns", categoryKey: "pos", function: "total", desc: "Total nouns" }, @@ -112,8 +103,6 @@ const METRIC_DEFS_RAW = [ { id: "subordinating_conjunction", title: "Subordinating Conjunction", categoryKey: "pos", function: "total", desc: "Total subordinating conjunctions" }, { id: "auxiliary_verb", title: "Auxiliary Verb", categoryKey: "pos", function: "total", desc: "Total auxiliary verbs" }, { id: "pronoun", title: "Pronoun", categoryKey: "pos", function: "total", desc: "Total pronouns" }, - - // sentence types { id: "simple_sentences", title: "Simple Sentences", categoryKey: "sentence_type", function: "total", desc: "Total simple sentences" }, { id: "simple_with_complex_predicates", title: "Simple with Complex Predicates", categoryKey: "sentence_type", function: "total", desc: "Total simple (complex predicates)" }, { id: "simple_with_compound_predicates", title: "Simple with Compound Predicates", categoryKey: "sentence_type", function: "total", desc: "Total simple (compound predicates)" }, @@ -121,28 +110,18 @@ const METRIC_DEFS_RAW = [ { id: "compound_sentences", title: "Compound Sentences", categoryKey: "sentence_type", function: "total", desc: "Total compound sentences" }, { id: "complex_sentences", title: "Complex Sentences", categoryKey: "sentence_type", function: "total", desc: "Total complex sentences" }, { id: "compound_complex_sentences", title: "Compound Complex Sentences", categoryKey: "sentence_type", function: "total", desc: "Total compound-complex sentences" }, - - // source info { id: "information_sources", title: "Information Sources", categoryKey: "source_information", function: "percent", desc: "Percent source references" }, { id: "attributions", title: "Attributions", categoryKey: "source_information", function: "percent", desc: "Percent attributions" }, { id: "citations", title: "Citations", categoryKey: "source_information", function: "percent", desc: "Percent citations" }, { id: "quoted_words", title: "Quoted Words", categoryKey: "source_information", function: "percent", desc: "Percent quoted words" }, - - // dialogue { id: "direct_speech_verbs", title: "Direct Speech Verbs", categoryKey: "dialogue", function: "percent", desc: "Percent direct speech verbs" }, { id: "indirect_speech", title: "Indirect Speech", categoryKey: "dialogue", function: "percent", desc: "Percent indirect speech" }, - - // tone { id: "positive_tone", title: "Positive Tone", categoryKey: "tone", function: "percent", desc: "Percent positive tone" }, { id: "negative_tone", title: "Negative Tone", categoryKey: "tone", function: "percent", desc: "Percent negative tone" }, - - // details { id: "concrete_details", title: "Concrete Details", categoryKey: "details", function: "percent", desc: "Percent concrete details" }, { id: "main_idea_sentences", title: "Main Idea Sentences", categoryKey: "details", function: "total", desc: "Total main idea sentences" }, { id: "supporting_idea_sentences", title: "Supporting Idea Sentences", categoryKey: "details", function: "total", desc: "Total supporting idea sentences" }, { id: "supporting_detail_sentences", title: "Supporting Detail Sentences", categoryKey: "details", function: "total", desc: "Total supporting detail sentences" }, - - // other { id: "polysyllabic_words", title: "Polysyllabic Words", categoryKey: "other", function: "percent", desc: "Percent polysyllabic tokens" }, { id: "low_frequency_words", title: "Low Frequency Words", categoryKey: "other", function: "percent", desc: "Percent low-frequency tokens" }, { id: "sentences", title: "Sentences", categoryKey: "other", function: "total", desc: "Total sentences" }, @@ -154,25 +133,110 @@ const METRIC_DEFS_RAW = [ ]; const METRIC_BY_ID = Object.fromEntries(METRIC_DEFS_RAW.map((m) => [m.id, m])); +const DEFAULT_METRICS = ["academic_language","informal_language","latinate_words","transition_words","citations","sentences","paragraphs"]; +const GENRE_COLORS = { Document: "hsl(160 70% 40%)" }; -const DEFAULT_METRICS = [ - "academic_language", - "informal_language", - "latinate_words", - "transition_words", - "citations", - "sentences", - "paragraphs", -]; +/* ============================================================= + PASTE / TIME HELPERS + Real data shape (confirmed from WS response): + liveData.students[studentID].documents[docId] = { + text: string + time_on_task: number // minutes — already the right unit + pastes_with_length: number // total paste events (any size) + total_paste_chars: number // total chars pasted + length_bins: { + short_1_20: number, + medium_21_200: number, + long_201_plus: number // large pastes (200+ chars) + } + copy_count: number + last_ts: number + last_access: number + } + ============================================================= */ +const LARGE_PASTE_THRESHOLD_CHARS = 200; +const AVG_WORD_LENGTH_CHARS = 5; -const GENRE_COLORS = { Document: "hsl(160 70% 40%)" }; +function charsToWords(chars) { + return Math.round(chars / AVG_WORD_LENGTH_CHARS); +} + +// Extract all activity stats from a single doc node. +// Returns a consistent shape regardless of which fields are present. +function extractDocStats(docNode) { + if (!docNode || typeof docNode !== "object") { + return { largePasteCount: 0, totalPasteChars: 0, copyCount: 0, timeOnTask: null }; + } + return { + largePasteCount: docNode.length_bins?.long_201_plus ?? 0, + totalPasteChars: docNode.total_paste_chars ?? 0, + copyCount: docNode.copy_count ?? 0, + // time_on_task is in minutes already — no conversion needed + timeOnTask: typeof docNode.time_on_task === "number" ? docNode.time_on_task : null, + }; +} /* ============================================================= - HELPERS + RQ2b/RQ2c PREDICTION ============================================================= */ +const PREDICTION = { IMPROVING: "Improving", PLATEAUING: "Plateauing", STAGNATING: "At Risk of Stagnation" }; +const PRED_STYLE = { + [PREDICTION.IMPROVING]: { bg: "bg-emerald-50", text: "text-emerald-800", ring: "ring-emerald-200", border: "border-emerald-200", headerBg: "bg-emerald-50", icon: , barColor: "bg-emerald-400" }, + [PREDICTION.PLATEAUING]: { bg: "bg-blue-50", text: "text-blue-800", ring: "ring-blue-200", border: "border-blue-200", headerBg: "bg-blue-50", icon: , barColor: "bg-blue-400" }, + [PREDICTION.STAGNATING]: { bg: "bg-rose-50", text: "text-rose-800", ring: "ring-rose-200", border: "border-rose-200", headerBg: "bg-rose-50", icon: , barColor: "bg-rose-400" }, +}; +function confQualifier(pct) { + if (pct >= 75) return { label: "Strong signal", desc: "This student's writing process shows a consistent pattern across enough essays to make a reliable prediction." }; + if (pct >= 55) return { label: "Moderate signal", desc: "There is a pattern here, but it is still developing. Use this alongside your own classroom observations." }; + return { label: "Early indication", desc: "This student has fewer essays on record. Treat this as a prompt for closer attention, not a firm prediction." }; +} +function deriveStudentPrediction(docCount) { + if (docCount === 0) return { + label: PREDICTION.STAGNATING, confidence: 0.78, + narrative: "No essays have been submitted yet, so there is no writing process history to draw from.", + componentTrends: [], similarCase: null, + }; + if (docCount >= 8) return { + label: PREDICTION.IMPROVING, confidence: 0.84, + narrative: "This student's typing fluency and revision depth have both improved steadily across their last several essays. Students with a similar upward pattern have historically scored in the top quartile on their next assignment.", + componentTrends: [ + { trait: "Word Choice", direction: "up" }, + { trait: "Sentence Fluency", direction: "up" }, + { trait: "Organization", direction: "flat" }, + { trait: "Ideas & Content", direction: "flat" }, + ], + similarCase: { count: 9, total: 11, outcome: "top quartile on their next essay" }, + }; + if (docCount >= 4) return { + label: PREDICTION.PLATEAUING, confidence: 0.67, + narrative: "This student's revision frequency and sentence-planning pauses have stayed consistent for the last four essays with no clear upward or downward movement. Students with a similar stable pattern have typically maintained their current performance level.", + componentTrends: [ + { trait: "Word Choice", direction: "flat" }, + { trait: "Sentence Fluency", direction: "flat" }, + { trait: "Organization", direction: "up" }, + { trait: "Ideas & Content", direction: "down" }, + ], + similarCase: { count: 7, total: 10, outcome: "the same performance level on their next essay" }, + }; + return { + label: PREDICTION.STAGNATING, confidence: 0.61, + narrative: "This student's uninterrupted writing bursts have shortened and their active writing time has decreased over the last three essays. Students with a similar declining pattern have historically scored below the class median on their next assignment.", + componentTrends: [ + { trait: "Word Choice", direction: "down" }, + { trait: "Sentence Fluency", direction: "down" }, + { trait: "Organization", direction: "flat" }, + { trait: "Ideas & Content", direction: "down" }, + ], + similarCase: { count: 8, total: 13, outcome: "below the class median on their next essay" }, + }; +} + +/* ============================================================= + HELPERS + ============================================================= */ const median = (arr) => { if (!arr.length) return 0; const a = [...arr].sort((x, y) => x - y); @@ -187,144 +251,91 @@ const std = (a) => { }; const slopePerIndex = (series) => { if (series.length < 2) return 0; - const xs = series.map((p) => p.idx); - const ys = series.map((p) => p.value); - const xbar = mean(xs); - const ybar = mean(ys); + const xs = series.map((p) => p.idx); const ys = series.map((p) => p.value); + const xbar = mean(xs); const ybar = mean(ys); const num = xs.reduce((s, x, i) => s + (x - xbar) * (ys[i] - ybar), 0); const den = xs.reduce((s, x) => s + (x - xbar) * (x - xbar), 0) || 1; return num / den; }; - -const safeNum = (v, fallback = 0) => { - const n = Number(v); - return Number.isFinite(n) ? n : fallback; -}; - -const sentenceSplit = (text) => { - const t = (text || "").trim(); - if (!t) return []; - return t.split(/(?<=[.!?])\s+/).filter(Boolean); -}; - -const wordSplit = (text) => { - const t = (text || "").trim(); - if (!t) return []; - return t - .split(/\s+/) - .map((w) => w.replace(/[^\p{L}\p{N}'-]+/gu, "").trim()) - .filter(Boolean); -}; - -const makePreviewFromText = (text, maxChars = 420) => { - const t = (text || "").replace(/\s+/g, " ").trim(); - if (!t) return ""; - return t.length > maxChars ? `${t.slice(0, maxChars)}…` : t; -}; - +const safeNum = (v, fallback = 0) => { const n = Number(v); return Number.isFinite(n) ? n : fallback; }; +const sentenceSplit = (text) => { const t = (text || "").trim(); if (!t) return []; return t.split(/(?<=[.!?])\s+/).filter(Boolean); }; +const wordSplit = (text) => { const t = (text || "").trim(); if (!t) return []; return t.split(/\s+/).map((w) => w.replace(/[^\p{L}\p{N}'-]+/gu, "").trim()).filter(Boolean); }; +const makePreviewFromText = (text, maxChars = 380) => { const t = (text || "").replace(/\s+/g, " ").trim(); return t.length > maxChars ? `${t.slice(0, maxChars)}...` : t; }; const formatDocTitle = (docId, meta) => { - const fromMeta = - meta?.title || - meta?.name || - meta?.doc_title || - meta?.document_title || - meta?.filename || - meta?.file_name; + const fromMeta = meta?.title || meta?.name || meta?.doc_title || meta?.document_title || meta?.filename || meta?.file_name; if (fromMeta && String(fromMeta).trim()) return String(fromMeta).trim(); return docId ? String(docId).replace(/[-_]/g, " ") : "Document"; }; - const getDocObjFromLO = (data, studentID, docId) => { const s = data?.students?.[studentID]; - const d1 = s?.documents?.[docId]; - if (d1 && typeof d1 === "object") return d1; - const d2 = s?.docs?.[docId]; - if (d2 && typeof d2 === "object") return d2; - const d3 = s?.doc_by_id?.[docId]; - if (d3 && typeof d3 === "object") return d3; - const d4 = s?.documents?.[docId]?.value; - if (d4 && typeof d4 === "object") return d4; + const d1 = s?.documents?.[docId]; if (d1 && typeof d1 === "object") return d1; + const d2 = s?.docs?.[docId]; if (d2 && typeof d2 === "object") return d2; + const d3 = s?.doc_by_id?.[docId]; if (d3 && typeof d3 === "object") return d3; + const d4 = s?.documents?.[docId]?.value; if (d4 && typeof d4 === "object") return d4; return null; }; - -const getDocTextFromLO = (data, studentID, docId) => { - const doc = getDocObjFromLO(data, studentID, docId); - const t = doc?.text; - return typeof t === "string" ? t : ""; -}; +const getDocTextFromLO = (data, studentID, docId) => { const doc = getDocObjFromLO(data, studentID, docId); const t = doc?.text; return typeof t === "string" ? t : ""; }; 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; - 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]); + 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)); + let s = Math.max(0, Math.min(L, start)); let e = Math.max(0, Math.min(L, start + 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 [curS, curE] = 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; - } + else { covered += curE - curS; curS = s; curE = e; } } covered += curE - curS; - return (covered / L) * 100; } +function inferAssignmentType(meta, text) { + const explicit = meta?.assignment_type || meta?.genre || meta?.type; + if (explicit) { const e = String(explicit).trim(); if (ASSIGNMENT_TYPES.includes(e)) return e; } + 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"; +} + +// Builds essay objects from doc data. +// Now reads time_on_task, largePasteCount, totalPasteChars, copyCount +// directly from the doc node so per-essay cards have all activity data. const buildEssaysFromDocs = ({ studentID, documentIDS, docsObj, data }) => { const out = (documentIDS || []).map((docId) => { const meta = docsObj?.[docId] || {}; const lastAccess = meta?.last_access; - const lastAccessMs = - typeof lastAccess === "number" - ? (lastAccess > 1e12 ? lastAccess : lastAccess * 1000) - : null; - + const lastAccessMs = typeof lastAccess === "number" ? (lastAccess > 1e12 ? lastAccess : lastAccess * 1000) : null; const dateISO = lastAccessMs ? new Date(lastAccessMs).toISOString() : ""; const dateObj = lastAccessMs ? new Date(lastAccessMs) : null; - - const dateStr = dateObj - ? `${monthsShort[dateObj.getMonth()]} ${dateObj.getDate()}, ${dateObj.getFullYear()}` - : "—"; - + const dateStr = dateObj ? `${monthsShort[dateObj.getMonth()]} ${dateObj.getDate()}, ${dateObj.getFullYear()}` : "—"; const category = dateObj ? `${monthsLong[dateObj.getMonth()]} ${dateObj.getFullYear()}` : "Unknown date"; - const doc = getDocObjFromLO(data, studentID, docId); - const text = - doc?.text && typeof doc.text === "string" ? doc.text : getDocTextFromLO(data, studentID, docId); - + const text = doc?.text && typeof doc.text === "string" ? doc.text : getDocTextFromLO(data, studentID, docId); const wordsArr = wordSplit(text); const words = wordsArr.length; - - const lowerWords = wordsArr.map((w) => w.toLowerCase()); - const uniqueWords = new Set(lowerWords).size; + const uniqueWords = new Set(wordsArr.map((w) => w.toLowerCase())).size; const lexicalDiversity = words ? Number(((uniqueWords / words) * 100).toFixed(1)) : 0; - const sents = sentenceSplit(text); const sentences = Math.max(1, sents.length || 1); const avgSentenceLen = words ? Math.round(words / sentences) : 0; + const assignmentType = inferAssignmentType(meta, text); + + // Activity stats — read directly from doc node (confirmed in WS response) + const stats = extractDocStats(meta); return { id: docId, @@ -332,286 +343,567 @@ const buildEssaysFromDocs = ({ studentID, documentIDS, docsObj, data }) => { date: dateStr, dateISO: dateISO || new Date(0).toISOString(), category, - words, - uniqueWords, - lexicalDiversity, - avgSentenceLen, + words, uniqueWords, lexicalDiversity, avgSentenceLen, grade: "—", preview: makePreviewFromText(text), - tags: ["Document"], // ✅ never empty + tags: [assignmentType], + assignmentType, + // Activity fields surfaced for per-essay cards and summary stats + timeOnTaskMins: stats.timeOnTask, + largePasteCount: stats.largePasteCount, + totalPasteChars: stats.totalPasteChars, + copyCount: stats.copyCount, + editCount: meta?.edit_count ?? null, _doc: doc || { text }, }; }); - return out.sort((a, b) => new Date(b.dateISO) - new Date(a.dateISO)); }; /* ============================================================= - component + SUB-COMPONENTS + ============================================================= */ + +function InfoTooltip({ text }) { + const [visible, setVisible] = useState(false); + const [coords, setCoords] = useState({ top: 0, left: 0 }); + const iconRef = useRef(null); + const show = () => { + if (!iconRef.current) return; + const rect = iconRef.current.getBoundingClientRect(); + setCoords({ top: rect.top + window.scrollY - 8, left: rect.left + rect.width / 2 + window.scrollX }); + setVisible(true); + }; + const hide = () => setVisible(false); + return ( + + + {visible && ( + + {text} + + + )} + + ); +} + +function AssignmentTypeBadge({ type }) { + const colors = ASSIGNMENT_TYPE_COLORS[type] || ASSIGNMENT_TYPE_COLORS["Document"]; + return ( + + + {type} + + ); +} + +function EffortPill({ icon: Icon, value, label, title }) { + if (value == null) return null; + return ( + + {value} {label} + + ); +} + +function ComparisonContextBanner({ filterType }) { + if (!filterType || filterType === "All Types") return null; + return ( +
+ + Showing {filterType} assignments only. Comparisons are within the same assignment type for valid analysis. +
+ ); +} + +function DirectionArrow({ direction }) { + if (direction === "up") return ; + if (direction === "down") return ; + return ; +} + +/* ============================================================= + STAT CARDS + ============================================================= */ + +function EssaysCard({ count }) { + return ( +
+
+

Essays in Portfolio

+ +
+

{count}

+

Available documents

+
+ ); +} + +function PredictedTrajectoryCard({ prediction, onOpen }) { + if (!prediction) { + return ( +
+
+
+
+
+
+
+
+ ); + } + const style = PRED_STYLE[prediction.label] || PRED_STYLE[PREDICTION.PLATEAUING]; + const conf = confQualifier(Math.round((prediction.confidence ?? 0) * 100)); + return ( +
+
+ +

Predicted Trajectory

+ +
+ +
+

{prediction.label}

+

{conf.label} · click for details

+
+ ); +} + +// Shows total large paste count across all essays. +// largePasteCount = sum of length_bins.long_201_plus +// totalPasteWords = estimated words from total_paste_chars +function LargePastesCard({ largePasteCount, totalPasteWords }) { + const hasActivity = largePasteCount > 0; + return ( +
+
+ +

Large Pastes

+ +
+ +
+ {hasActivity ? ( + <> +

{largePasteCount}

+

~{totalPasteWords.toLocaleString()} words pasted across all essays

+ + ) : ( + <> +

0

+

No large paste events detected

+ + )} +
+ ); +} + +// Shows average time on task across all essays. +// avgTime is real minutes from time_on_task — no fallback dummy. +function AvgTimeCard({ avgTime }) { + const hasData = avgTime != null; + return ( +
+
+ +

Avg. Time on Task

+ +
+ +
+ {hasData ? ( + <> +

{Math.round(avgTime)} min

+

Effort signal per essay

+ + ) : ( + <> +

+

Loading...

+ + )} +
+ ); +} + +/* ============================================================= + PREDICTION MODAL + ============================================================= */ + +function StudentPredictionPanel({ prediction, studentName, onDismiss }) { + const style = PRED_STYLE[prediction.label] || PRED_STYLE[PREDICTION.PLATEAUING]; + const confPct = Math.round((prediction.confidence ?? 0) * 100); + const conf = confQualifier(confPct); + + const handleBackdrop = (e) => { if (e.target === e.currentTarget) onDismiss(); }; + + useEffect(() => { + const onKey = (e) => { if (e.key === "Escape") onDismiss(); }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onDismiss]); + + return ( +
+
+
+
+ {style.icon} +
+

Predicted Writing Trajectory

+

{prediction.label}

+
+
+ +
+ +
+
+
+ {conf.label} + {conf.desc} +
+
+ +
+
+

What the data shows

+
+

{prediction.narrative}

+
+ {prediction.similarCase && ( +
+ +

+ {prediction.similarCase.count}/{prediction.similarCase.total} students with a similar pattern went on to achieve{" "} + {prediction.similarCase.outcome}. +

+
+ )} +
+ +
+ {prediction.componentTrends?.length > 0 && ( + <> +

Rubric Trait Signals

+
+ {prediction.componentTrends.map((ct) => ( +
+ {ct.trait} + + + {ct.direction === "up" ? "Improving" : ct.direction === "down" ? "Declining" : "Stable"} + +
+ ))} +
+ + )} + +

Suggested Action

+
+

+ {prediction.label === PREDICTION.STAGNATING + ? "Schedule a writing conference before this student's next essay." + : prediction.label === PREDICTION.PLATEAUING + ? "Give targeted feedback on one specific trait to break the plateau." + : "Acknowledge the growth — check Growth Over Time to see what is working."} +

+
+
+
+ +

+ Trajectory predictions use pattern heuristics and will be replaced by a trained model once enough data is available. +

+
+
+
+ ); +} + +/* ============================================================= + MAIN COMPONENT ============================================================= */ export default function StudentDetail({ studentId }) { - // console.count("StudentDetail render"); const router = useRouter(); const searchParams = useSearchParams(); - const studentID = searchParams.get("student_id") || String(studentId); const [mode, setMode] = useState(MODES.COMPARE); const [selectedEssays, setSelectedEssays] = useState([]); - const [cardsPerRow, setCardsPerRow] = useState(3); const [sortBy, setSortBy] = useState("date"); const [search, setSearch] = useState(""); - const [filterTags, setFilterTags] = useState([]); const [tagOpen, setTagOpen] = useState(false); const [tagQuery, setTagQuery] = useState(""); - const [startDate, setStartDate] = useState(""); const [endDate, setEndDate] = useState(""); - const [metrics, setMetrics] = useState([...DEFAULT_METRICS]); - const [openEssay, setOpenEssay] = useState(null); - const { courseId } = useCourseIdContext(); - const [activeQuickRange, setActiveQuickRange] = useState("all"); + const [assignmentTypeFilter, setAssignmentTypeFilter] = useState("All Types"); + const [typeFilterOpen, setTypeFilterOpen] = useState(false); + const typeFilterRef = useRef(null); + const [showPredictionPanel, setShowPredictionPanel] = useState(false); + + const { courseId } = useCourseIdContext(); - // ------ Initial dataScope: only student_with_docs ------ const initialDataScope = useMemo(() => ({ wo: { execution_dag: "writing_observer", - target_exports: ["student_with_docs", "single_student_profile"], - kwargs: { - course_id: courseId, - student_id: studentID, - }, + target_exports: ["student_with_docs", "single_student_profile", "single_student_paste", "single_student_copy_cut", "single_student_time_on_task"], + kwargs: { course_id: courseId, student_id: studentID }, }, }), [courseId, studentID]); const origin = getConfiguredWsOrigin(); - - // Single hook for the entire page - const { data, errors, connection } = useLOConnectionDataManager({ + const { data: liveData, errors, connection } = useLOConnectionDataManager({ url: `${origin}/wsapi/communication_protocol`, dataScope: initialDataScope, }); - // ------ Derive docsObj & documentIDS from student_with_docs response ------ - const docsObj = data?.students?.[studentID]?.documents || {}; - const studentProfile = data?.students?.[studentID]?.profile?.name || {}; - const studentName = studentProfile?.full_name || studentProfile?.name || studentID; + // ── MERGED DOC ACCUMULATOR ─────────────────────────────────── + // WS sends doc data across multiple ticks. Tick 1 may have + // paste/time fields, tick 2 may have text. We merge every tick + // into a persistent accumulator so no fields are overwritten. + const mergedDocsAccRef = useRef({}); + const mergedDocsStudentIDRef = useRef(null); - const getStudentById = useCallback((id) => { - const profile = data?.students?.[id]?.profile; - const name = profile?.name?.full_name || profile?.name?.name || (id ? String(id).replace(/[-_]/g, " ") : "Student"); + if (mergedDocsStudentIDRef.current !== studentID) { + mergedDocsStudentIDRef.current = studentID; + mergedDocsAccRef.current = {}; + } - const initials = name - .split(" ") - .map((w) => w[0]) - .join("") - .toUpperCase() - .slice(0, 2) || "ST"; - return { - id, - name, - initials, - avatarColor: "bg-gray-100", - textColor: "text-gray-700", - gradeLevel: profile?.grade_level || "—", - section: profile?.section || "—", - }; - }, [data]); + const rawDocsFromTick = liveData?.students?.[studentID]?.documents || {}; + for (const [docId, incoming] of Object.entries(rawDocsFromTick)) { + const existing = mergedDocsAccRef.current[docId] || {}; + const merged = { ...existing }; + for (const [k, v] of Object.entries(incoming)) { + if (v !== null && v !== undefined) merged[k] = v; + } + mergedDocsAccRef.current[docId] = merged; + } - const documentIDS = useMemo(() => { - const ids = Object.keys(docsObj || {}); - ids.sort(); - return ids; - }, [docsObj]); + // Single source of truth for all doc data + const liveDocsObj = mergedDocsAccRef.current; - // ------ When documentIDS changes, send updated dataScope via connection ------ - const prevDocIDSSigRef = useRef(""); + // ── FREEZE: document IDs ───────────────────────────────────── + const liveDocIds = useMemo(() => Object.keys(liveDocsObj).sort(), [ + // eslint-disable-next-line react-hooks/exhaustive-deps + Object.keys(liveDocsObj).sort().join(","), + ]); - useEffect(() => { - const sig = documentIDS.join(","); - if (sig === prevDocIDSSigRef.current) return; - if (documentIDS.length === 0) return; - if (!connection?.sendMessage) return; + const frozenDocIdsRef = useRef(null); + const frozenStudentIDRef = useRef(null); + if (frozenStudentIDRef.current !== studentID) { + frozenStudentIDRef.current = studentID; + frozenDocIdsRef.current = null; + } + if (frozenDocIdsRef.current === null && liveDocIds.length > 0) { + frozenDocIdsRef.current = liveDocIds; + } + const documentIDS = frozenDocIdsRef.current ?? liveDocIds; + + // ── GATE: all docs have both text AND activity data ──────────── + // Text arrives via single_student_doc_by_id. + // time_on_task arrives via single_student_time_on_task. + // Both must be present before we freeze so the snapshot is complete. + const allDocsHaveText = documentIDS.length > 0 && + documentIDS.every((id) => { + const d = liveDocsObj?.[id]; + return ( + d && + typeof d.text === "string" && d.text.length > 0 && + d.time_on_task != null + ); + }); - prevDocIDSSigRef.current = sig; + // ── FREEZE: docsObj snapshot (text + all activity fields merged) ─ + const frozenDocsObjRef = useRef(null); + const frozenDocsStudentIDRef = useRef(null); + if (frozenDocsStudentIDRef.current !== studentID) { + frozenDocsStudentIDRef.current = studentID; + frozenDocsObjRef.current = null; + } + if (frozenDocsObjRef.current === null && allDocsHaveText) { + // Snapshot the accumulator — has both text AND activity fields + frozenDocsObjRef.current = { ...liveDocsObj }; + } + const docsObj = frozenDocsObjRef.current ?? liveDocsObj; + + // ── FREEZE: full liveData for child components ──────────────── + const frozenDataRef = useRef(null); + const frozenDataStudentIDRef = useRef(null); + if (frozenDataStudentIDRef.current !== studentID) { + frozenDataStudentIDRef.current = studentID; + frozenDataRef.current = null; + } + if (frozenDataRef.current === null && allDocsHaveText) { + frozenDataRef.current = liveData; + } + const frozenData = frozenDataRef.current ?? liveData; + + // ── FREEZE: student name ────────────────────────────────────── + const liveProfile = liveData?.students?.[studentID]?.profile?.name || {}; + const liveStudentName = liveProfile?.full_name || liveProfile?.name || studentID; + const frozenNameRef = useRef(null); + const frozenNameStudentIDRef = useRef(null); + if (frozenNameStudentIDRef.current !== studentID) { + frozenNameStudentIDRef.current = studentID; + frozenNameRef.current = null; + } + if (frozenNameRef.current === null && liveStudentName && liveStudentName !== studentID) { + frozenNameRef.current = liveStudentName; + } + const studentName = frozenNameRef.current ?? liveStudentName; + + // ── FREEZE: prediction ──────────────────────────────────────── + const frozenPredRef = useRef(null); + const frozenPredStudentIDRef = useRef(null); + if (frozenPredStudentIDRef.current !== studentID) { + frozenPredStudentIDRef.current = studentID; + frozenPredRef.current = null; + } + if (frozenPredRef.current === null && documentIDS.length > 0) { + frozenPredRef.current = + liveData?.students?.[studentID]?.prediction ?? + deriveStudentPrediction(documentIDS.length); + } + const studentPrediction = frozenPredRef.current ?? null; - const updatedDataScope = { - wo: { - execution_dag: "writing_observer", - target_exports: ["student_with_docs", "single_student_doc_by_id", "single_student_profile"], - kwargs: { - course_id: courseId, - student_id: studentID, - doc_ids: documentIDS, - }, - } - }; + // ── DOWNSTREAM MEMOS ───────────────────────────────────────── + const getStudentById = useCallback((id) => { + const profile = frozenData?.students?.[id]?.profile; + const name = profile?.name?.full_name || profile?.name?.name || (id ? String(id).replace(/[-_]/g, " ") : "Student"); + const initials = name.split(" ").map((w) => w[0]).join("").toUpperCase().slice(0, 2) || "ST"; + return { id, name, initials, avatarColor: "bg-gray-100", textColor: "text-gray-700", gradeLevel: profile?.grade_level || "—", section: profile?.section || "—" }; + }, [frozenData]); + + const docsObjContentSig = useMemo(() => + JSON.stringify(Object.fromEntries( + Object.entries(docsObj || {}).map(([k, v]) => [k, typeof v?.text === "string" ? v.text.slice(0, 80) : ""]) + )), + [docsObj]); + + // essays now carries timeOnTaskMins, largePasteCount, totalPasteChars, copyCount + const essays = useMemo( + () => buildEssaysFromDocs({ studentID, documentIDS, docsObj, data: frozenData }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [studentID, docsObjContentSig], + ); - connection.sendMessage(JSON.stringify(updatedDataScope)); - }, [documentIDS, connection, courseId, studentID]); + // ── SUMMARY STATS derived from essays ──────────────────────── + // All read from the essay objects which get data from the merged doc node. + const summaryStats = useMemo(() => { + const withTime = essays.filter((e) => e.timeOnTaskMins != null && Number.isFinite(e.timeOnTaskMins)); + const avgTime = withTime.length ? mean(withTime.map((e) => e.timeOnTaskMins)) : null; - const essays = useMemo(() => { - return buildEssaysFromDocs({ - studentID, - documentIDS, - docsObj, - data, - }); - }, [studentID, documentIDS, docsObj, data]); - - const metricSections = useMemo(() => { - return Object.entries(CATEGORY_KEYS).map(([categoryKey, title]) => { - const list = METRIC_DEFS_RAW.filter((m) => m.categoryKey === categoryKey).map((m) => m.id); - return { - title, - icon: iconForCategoryKey(categoryKey), - metrics: list, - }; - }); - }, []); + const totalLargePastes = essays.reduce((s, e) => s + (e.largePasteCount ?? 0), 0); + const totalPasteWords = essays.reduce((s, e) => s + charsToWords(e.totalPasteChars ?? 0), 0); - const metricByKey = useCallback((key) => { - const raw = METRIC_BY_ID[key]; - if (!raw) return null; + return { avgTime, totalLargePastes, totalPasteWords }; + }, [essays]); + + const metricSections = useMemo(() => Object.entries(CATEGORY_KEYS).map(([categoryKey, title]) => ({ + title, icon: iconForCategoryKey(categoryKey), + metrics: METRIC_DEFS_RAW.filter((m) => m.categoryKey === categoryKey).map((m) => m.id), + })), []); + const metricByKey = useCallback((key) => { + const raw = METRIC_BY_ID[key]; if (!raw) return null; const get = (essay) => { const doc = essay?._doc || {}; const direct = doc?.[key]?.metric; if (direct != null && !Number.isNaN(Number(direct))) return Number(direct); return metricCoveragePercent(doc, key); }; - - return { - key, - label: raw.title, - unit: raw.function === "percent" ? "%" : "", - get, - desc: raw.desc, - }; + return { key, label: raw.title, unit: raw.function === "percent" ? "%" : "", get, desc: raw.desc }; }, []); - const essaysAscAll = useMemo(() => { - return [...essays].sort((a, b) => new Date(a.dateISO) - new Date(b.dateISO)); - }, [essays]); + const essaysAscAll = useMemo(() => [...essays].sort((a, b) => new Date(a.dateISO) - new Date(b.dateISO)), [essays]); const baselineByMetric = useMemo(() => { const out = {}; for (const k of metrics) { - const def = metricByKey(k); - if (!def) continue; + const def = metricByKey(k); if (!def) continue; const vals = essaysAscAll.map((e) => safeNum(def.get(e), 0)); out[k] = { median: median(vals), sd: std(vals) }; } return out; }, [essaysAscAll, metrics, metricByKey]); - const essaysInRangeAsc = useMemo(() => { - const inRange = essaysAscAll.filter((e) => { - const d = new Date(e.dateISO); - const afterStart = !startDate || d >= new Date(startDate); - const beforeEnd = !endDate || d <= new Date(endDate); - return afterStart && beforeEnd; + const essaysInRangeAsc = useMemo(() => essaysAscAll.filter((e) => { + const d = new Date(e.dateISO); + const afterStart = !startDate || d >= new Date(startDate); + const beforeEnd = !endDate || d <= new Date(endDate); + return afterStart && beforeEnd; + }), [essaysAscAll, startDate, endDate]); + + const getSeriesForMetric = useCallback((key) => { + const def = metricByKey(key); if (!def) return []; + const base = baselineByMetric[key] || { median: 0, sd: 0 }; + return essaysInRangeAsc.map((e, idx) => { + const raw = safeNum(def.get(e), 0); const delta = raw - base.median; + const badge = base.sd > 0 ? (delta > 0.75 * base.sd ? "▲" : delta < -0.75 * base.sd ? "▼" : "●") : "●"; + return { idx, label: e.date, title: e.title, date: e.date, genre: e.tags[0], raw, value: delta, delta, badge, unit: def.unit }; }); - return inRange; - }, [essaysAscAll, startDate, endDate]); - - const getSeriesForMetric = useCallback( - (key) => { - const def = metricByKey(key); - if (!def) return []; - const base = baselineByMetric[key] || { median: 0, sd: 0 }; - - return essaysInRangeAsc.map((e, idx) => { - const raw = safeNum(def.get(e), 0); - const delta = raw - base.median; - const badge = - base.sd > 0 ? (delta > 0.75 * base.sd ? "▲" : delta < -0.75 * base.sd ? "▼" : "●") : "●"; - return { - idx, - label: e.date, - title: e.title, - date: e.date, - genre: e.tags[0], - raw, - value: delta, - delta, - badge, - unit: def.unit, - }; - }); - }, - [metricByKey, baselineByMetric, essaysInRangeAsc] - ); + }, [metricByKey, baselineByMetric, essaysInRangeAsc]); - const getGenreSegments = () => { - const segs = []; - if (!essaysInRangeAsc.length) return segs; - let start = 0; - let current = essaysInRangeAsc[0].tags[0]; + const genreSegments = useMemo(() => { + const segs = []; if (!essaysInRangeAsc.length) return segs; + let start = 0; let current = essaysInRangeAsc[0].tags[0]; for (let i = 1; i < essaysInRangeAsc.length; i++) { const g = essaysInRangeAsc[i].tags[0]; - if (g !== current) { - segs.push({ x1: start, x2: i - 1, genre: current }); - current = g; - start = i; - } + if (g !== current) { segs.push({ x1: start, x2: i - 1, genre: current }); current = g; start = i; } } segs.push({ x1: start, x2: essaysInRangeAsc.length - 1, genre: current }); return segs; - }; - const genreSegments = useMemo(() => getGenreSegments(), [essaysInRangeAsc]); + }, [essaysInRangeAsc]); const filteredEssaysCompare = useMemo(() => { - const byTags = (e) => filterTags.length === 0 || filterTags.some((t) => (e.tags || []).includes(t)); + const byType = (e) => assignmentTypeFilter === "All Types" || e.assignmentType === assignmentTypeFilter; + const byTags = (e) => filterTags.length === 0 || filterTags.some((t) => (e.tags || []).includes(t)); const bySearch = (e) => { if (!search.trim()) return true; const q = search.toLowerCase(); - return ( - (e.title || "").toLowerCase().includes(q) || - (e.preview || "").toLowerCase().includes(q) || - (e.tags || []).some((t) => t.toLowerCase().includes(q)) - ); + return (e.title || "").toLowerCase().includes(q) || (e.preview || "").toLowerCase().includes(q) || (e.tags || []).some((t) => t.toLowerCase().includes(q)); }; - const sorted = [...essays].sort((a, b) => { if (sortBy === "words") return safeNum(b.words) - safeNum(a.words); if (sortBy === "title") return (a.title || "").localeCompare(b.title || ""); return new Date(a.dateISO) < new Date(b.dateISO) ? 1 : -1; }); + return sorted.filter(byType).filter(byTags).filter(bySearch); + }, [essays, assignmentTypeFilter, filterTags, sortBy, search]); - return sorted.filter(byTags).filter(bySearch); - }, [essays, filterTags, sortBy, search]); - - const groupedEssays = useMemo(() => { - return filteredEssaysCompare.reduce((acc, essay) => { - if (!acc[essay.category]) acc[essay.category] = []; - acc[essay.category].push(essay); - return acc; - }, {}); - }, [filteredEssaysCompare]); + const groupedEssays = useMemo(() => filteredEssaysCompare.reduce((acc, essay) => { + if (!acc[essay.category]) acc[essay.category] = []; + acc[essay.category].push(essay); + return acc; + }, {}), [filteredEssaysCompare]); const getGridCols = () => { switch (cardsPerRow) { - case 1: - return "grid-cols-1"; - case 2: - return "grid-cols-2"; - case 3: - return "grid-cols-3"; - case 4: - return "grid-cols-4"; - case 5: - return "grid-cols-5"; - case 6: - return "grid-cols-6"; - default: - return "grid-cols-4"; + case 1: return "grid-cols-1"; case 2: return "grid-cols-2"; case 3: return "grid-cols-3"; + case 4: return "grid-cols-4"; case 5: return "grid-cols-5"; case 6: return "grid-cols-6"; + default: return "grid-cols-4"; } }; @@ -619,49 +911,46 @@ export default function StudentDetail({ studentId }) { useEffect(() => { const onClick = (e) => { if (tagRef.current && !tagRef.current.contains(e.target)) setTagOpen(false); + if (typeFilterRef.current && !typeFilterRef.current.contains(e.target)) setTypeFilterOpen(false); }; window.addEventListener("click", onClick); return () => window.removeEventListener("click", onClick); }, []); + // WS message — request doc text once we know the doc IDs + const prevDocIDSSigRef = useRef(""); + useEffect(() => { + const sig = documentIDS.join(","); + if (sig === prevDocIDSSigRef.current) return; + if (documentIDS.length === 0) return; + if (!connection?.sendMessage) return; + prevDocIDSSigRef.current = sig; + connection.sendMessage(JSON.stringify({ + wo: { + execution_dag: "writing_observer", + target_exports: ["student_with_docs", "single_student_doc_by_id", "single_student_profile", "single_student_paste", "single_student_copy_cut", "single_student_time_on_task"], + kwargs: { course_id: courseId, student_id: studentID, doc_ids: documentIDS }, + } + })); + }, [documentIDS, connection, courseId, studentID]); + const applyQuickRange = (key) => { setActiveQuickRange(key); - - if (key === "all") { - setStartDate(""); - setEndDate(""); - return; - } - + if (key === "all") { setStartDate(""); setEndDate(""); return; } if (!essaysAscAll.length) return; - const last = new Date(essaysAscAll[essaysAscAll.length - 1].dateISO); - const end = new Date(last); - const start2 = new Date(last); - + const end = new Date(last); const start2 = new Date(last); if (key === "3mo") start2.setMonth(start2.getMonth() - 3); if (key === "6mo") start2.setMonth(start2.getMonth() - 6); if (key === "9mo") start2.setMonth(start2.getMonth() - 9); - setStartDate(start2.toISOString().slice(0, 10)); setEndDate(end.toISOString().slice(0, 10)); }; - const onStartDateChange = (v) => { - setStartDate(v); - setActiveQuickRange(""); - }; - const onEndDateChange = (v) => { - setEndDate(v); - setActiveQuickRange(""); - }; - - const clearFilters = () => { - setFilterTags([]); - setTagQuery(""); - setSearch(""); - }; - const isAnyFilter = filterTags.length > 0 || search.trim().length > 0; + const onStartDateChange = (v) => { setStartDate(v); setActiveQuickRange(""); }; + const onEndDateChange = (v) => { setEndDate(v); setActiveQuickRange(""); }; + const clearFilters = () => { setFilterTags([]); setTagQuery(""); setSearch(""); setAssignmentTypeFilter("All Types"); }; + const isAnyFilter = filterTags.length > 0 || search.trim().length > 0 || assignmentTypeFilter !== "All Types"; const handleEssaySelect = (essayId) => { if (mode !== MODES.COMPARE) return; @@ -672,32 +961,48 @@ export default function StudentDetail({ studentId }) { }); }; + const presentTypes = useMemo(() => { + const types = new Set(essays.map((e) => e.assignmentType)); + return ["All Types", ...ASSIGNMENT_TYPES.slice(1).filter((t) => types.has(t))]; + }, [essays]); + + // pasteStatsByDoc passed to StudentDetailCompare for per-card display + // Shape: { [docId]: { largePasteCount, totalPasteChars, copyCount, timeOnTask } } + const pasteStatsByDoc = useMemo(() => Object.fromEntries( + essays.map((e) => [e.id, { + largePasteCount: e.largePasteCount ?? 0, + totalPasteChars: e.totalPasteChars ?? 0, + copyCount: e.copyCount ?? 0, + timeOnTask: e.timeOnTaskMins ?? null, + }]) + ), [essays]); + return (
+ + {showPredictionPanel && studentPrediction && ( + setShowPredictionPanel(false)} + /> + )} +
-