From 2a3d8a9702fe586610c339be81cc812ab3ec5d78 Mon Sep 17 00:00:00 2001 From: JohnDamilola Date: Fri, 2 Jan 2026 13:05:54 -0500 Subject: [PATCH 1/3] Initial commit for the porfolio dashboard --- VERSION | 2 +- modules/portfolio_diff/.gitignore | 41 + modules/portfolio_diff/README.md | 36 + .../portfolio_diff/build_and_add_to_module.sh | 5 + modules/portfolio_diff/eslint.config.mjs | 14 + modules/portfolio_diff/jsconfig.json | 7 + modules/portfolio_diff/lo_event.sh | 14 + modules/portfolio_diff/next.config.mjs | 21 + modules/portfolio_diff/package.json | 33 + modules/portfolio_diff/postcss.config.mjs | 5 + modules/portfolio_diff/public/file.svg | 1 + modules/portfolio_diff/public/globe.svg | 1 + modules/portfolio_diff/public/next.svg | 1 + modules/portfolio_diff/public/vercel.svg | 1 + modules/portfolio_diff/public/window.svg | 1 + .../src/app/components/Breadcrumb.js | 70 + .../src/app/components/MetricsPanel.js | 899 ++++++++ .../src/app/components/Navbar.js | 190 ++ .../src/app/components/TypeFilterDropdown.js | 104 + modules/portfolio_diff/src/app/favicon.ico | Bin 0 -> 25931 bytes modules/portfolio_diff/src/app/globals.css | 43 + modules/portfolio_diff/src/app/layout.js | 18 + modules/portfolio_diff/src/app/page.js | 7 + .../src/app/students/compare/page.js | 1878 +++++++++++++++++ .../StudentDetail/SingleEssayModel.js | 1039 +++++++++ .../StudentDetail/StudentDetailCompare.js | 584 +++++ .../StudentDetail/StudentDetailGrowth.js | 527 +++++ .../components/StudentDetail/index.js | 948 +++++++++ .../app/students/components/StudentsIndex.js | 659 ++++++ .../portfolio_diff/src/app/students/page.js | 23 + modules/portfolio_diff/src/app/utils/data.js | 359 ++++ .../src/app/utils/navigation.js | 22 + modules/wo_portfolio_diff/MANIFEST.in | 1 + modules/wo_portfolio_diff/README.md | 146 ++ modules/wo_portfolio_diff/VERSION | 1 + modules/wo_portfolio_diff/pyproject.toml | 3 + modules/wo_portfolio_diff/setup.cfg | 14 + modules/wo_portfolio_diff/test.sh | 11 + modules/wo_portfolio_diff/tests/test_utils.py | 6 + 39 files changed, 7734 insertions(+), 1 deletion(-) create mode 100644 modules/portfolio_diff/.gitignore create mode 100644 modules/portfolio_diff/README.md create mode 100644 modules/portfolio_diff/build_and_add_to_module.sh create mode 100644 modules/portfolio_diff/eslint.config.mjs create mode 100644 modules/portfolio_diff/jsconfig.json create mode 100755 modules/portfolio_diff/lo_event.sh create mode 100644 modules/portfolio_diff/next.config.mjs create mode 100644 modules/portfolio_diff/package.json create mode 100644 modules/portfolio_diff/postcss.config.mjs create mode 100644 modules/portfolio_diff/public/file.svg create mode 100644 modules/portfolio_diff/public/globe.svg create mode 100644 modules/portfolio_diff/public/next.svg create mode 100644 modules/portfolio_diff/public/vercel.svg create mode 100644 modules/portfolio_diff/public/window.svg create mode 100644 modules/portfolio_diff/src/app/components/Breadcrumb.js create mode 100644 modules/portfolio_diff/src/app/components/MetricsPanel.js create mode 100644 modules/portfolio_diff/src/app/components/Navbar.js create mode 100644 modules/portfolio_diff/src/app/components/TypeFilterDropdown.js create mode 100644 modules/portfolio_diff/src/app/favicon.ico create mode 100644 modules/portfolio_diff/src/app/globals.css create mode 100644 modules/portfolio_diff/src/app/layout.js create mode 100644 modules/portfolio_diff/src/app/page.js create mode 100644 modules/portfolio_diff/src/app/students/compare/page.js create mode 100644 modules/portfolio_diff/src/app/students/components/StudentDetail/SingleEssayModel.js create mode 100644 modules/portfolio_diff/src/app/students/components/StudentDetail/StudentDetailCompare.js create mode 100644 modules/portfolio_diff/src/app/students/components/StudentDetail/StudentDetailGrowth.js create mode 100644 modules/portfolio_diff/src/app/students/components/StudentDetail/index.js create mode 100644 modules/portfolio_diff/src/app/students/components/StudentsIndex.js create mode 100644 modules/portfolio_diff/src/app/students/page.js create mode 100644 modules/portfolio_diff/src/app/utils/data.js create mode 100644 modules/portfolio_diff/src/app/utils/navigation.js create mode 100644 modules/wo_portfolio_diff/MANIFEST.in create mode 100644 modules/wo_portfolio_diff/README.md create mode 100644 modules/wo_portfolio_diff/VERSION create mode 100644 modules/wo_portfolio_diff/pyproject.toml create mode 100644 modules/wo_portfolio_diff/setup.cfg create mode 100755 modules/wo_portfolio_diff/test.sh create mode 100644 modules/wo_portfolio_diff/tests/test_utils.py diff --git a/VERSION b/VERSION index 5efb812a6..66ede4dd3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2025.08.15T13.29.30.819Z.f61cb8b1.master +0.1.0+2026.01.02T18.05.55.3NZ.3385b46.dami.portfolio diff --git a/modules/portfolio_diff/.gitignore b/modules/portfolio_diff/.gitignore new file mode 100644 index 000000000..5ef6a5207 --- /dev/null +++ b/modules/portfolio_diff/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/modules/portfolio_diff/README.md b/modules/portfolio_diff/README.md new file mode 100644 index 000000000..66bb426ff --- /dev/null +++ b/modules/portfolio_diff/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.js`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/modules/portfolio_diff/build_and_add_to_module.sh b/modules/portfolio_diff/build_and_add_to_module.sh new file mode 100644 index 000000000..fc1d54121 --- /dev/null +++ b/modules/portfolio_diff/build_and_add_to_module.sh @@ -0,0 +1,5 @@ +rm -rf out/ +npm run build +rm -rf ../wo_portfolio_diff/wo_portfolio_diff/portfolio_diff/ +mkdir ../wo_portfolio_diff/wo_portfolio_diff/portfolio_diff/ +cp -r out/ ../wo_portfolio_diff/wo_portfolio_diff/portfolio_diff/ \ No newline at end of file diff --git a/modules/portfolio_diff/eslint.config.mjs b/modules/portfolio_diff/eslint.config.mjs new file mode 100644 index 000000000..348c45a2f --- /dev/null +++ b/modules/portfolio_diff/eslint.config.mjs @@ -0,0 +1,14 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [...compat.extends("next/core-web-vitals")]; + +export default eslintConfig; diff --git a/modules/portfolio_diff/jsconfig.json b/modules/portfolio_diff/jsconfig.json new file mode 100644 index 000000000..b8d6842d7 --- /dev/null +++ b/modules/portfolio_diff/jsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/modules/portfolio_diff/lo_event.sh b/modules/portfolio_diff/lo_event.sh new file mode 100755 index 000000000..4efe395b5 --- /dev/null +++ b/modules/portfolio_diff/lo_event.sh @@ -0,0 +1,14 @@ +# `lo_event` is the Learning Observer event library (and also `lo_assess` within it is the Learning Observer activity library, which should be factored out eventually). + +# * It's a library, and there's no practical stand-alone way to do more extensive development without some use-cases. This is a good set of use cases. +# * As we develop those, we often make changes to `lo_event` and `lo_assess`. Quite a lot, actually. + +# This script packages `lo_event` into a node package, wipes the `next.js` cache (which often contains relics before changes), and installs it. + +# Without this, development of `lo_event` is painful. Even with this, in an ideal case, we would rerun this whenever there were changes to `lo_event` automatically with some kind of watch daemon. +rm -Rf .next/cache/ +pushd ../lo_event/ +npm run prebuild +npm pack +popd +npm install ../lo_event/lo_event-0.0.3.tgz --no-save diff --git a/modules/portfolio_diff/next.config.mjs b/modules/portfolio_diff/next.config.mjs new file mode 100644 index 000000000..97c5387b7 --- /dev/null +++ b/modules/portfolio_diff/next.config.mjs @@ -0,0 +1,21 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + basePath: "/_next/wo_portfolio_diff/portfolio_diff", + eslint: { + ignoreDuringBuilds: true, + }, + transpilePackages: ["lo_event", "lo_dash_react_components"], + webpack: ( + config, + { buildId, dev, isServer, defaultLoaders, nextRuntime, webpack } + ) => { + config.resolve.fallback = { + ...config.resolve.fallback, + fs: false, // tells Webpack to ignore fs in client bundle + }; + return config; + }, + output: "export", +}; + +export default nextConfig; diff --git a/modules/portfolio_diff/package.json b/modules/portfolio_diff/package.json new file mode 100644 index 000000000..6aa7876ec --- /dev/null +++ b/modules/portfolio_diff/package.json @@ -0,0 +1,33 @@ +{ + "name": "portfolio_diff", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "babel-loader": "^10.0.0", + "echarts": "^6.0.0", + "echarts-for-react": "^3.0.5", + "framer-motion": "^12.23.6", + "lo_dash_react_components": "file:../lo_dash_react_components", + "lo_event": "file:../lo_event", + "lucide-react": "^0.525.0", + "next": "15.3.5", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-redux": "^9.2.0", + "recharts": "^3.1.2", + "redux": "^5.0.1" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "eslint": "^9", + "eslint-config-next": "15.3.5", + "tailwindcss": "^4" + } +} diff --git a/modules/portfolio_diff/postcss.config.mjs b/modules/portfolio_diff/postcss.config.mjs new file mode 100644 index 000000000..c7bcb4b1e --- /dev/null +++ b/modules/portfolio_diff/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/modules/portfolio_diff/public/file.svg b/modules/portfolio_diff/public/file.svg new file mode 100644 index 000000000..004145cdd --- /dev/null +++ b/modules/portfolio_diff/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/portfolio_diff/public/globe.svg b/modules/portfolio_diff/public/globe.svg new file mode 100644 index 000000000..567f17b0d --- /dev/null +++ b/modules/portfolio_diff/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/portfolio_diff/public/next.svg b/modules/portfolio_diff/public/next.svg new file mode 100644 index 000000000..5174b28c5 --- /dev/null +++ b/modules/portfolio_diff/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/portfolio_diff/public/vercel.svg b/modules/portfolio_diff/public/vercel.svg new file mode 100644 index 000000000..770539603 --- /dev/null +++ b/modules/portfolio_diff/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/portfolio_diff/public/window.svg b/modules/portfolio_diff/public/window.svg new file mode 100644 index 000000000..b2b2a44f6 --- /dev/null +++ b/modules/portfolio_diff/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/portfolio_diff/src/app/components/Breadcrumb.js b/modules/portfolio_diff/src/app/components/Breadcrumb.js new file mode 100644 index 000000000..ce4cb03e3 --- /dev/null +++ b/modules/portfolio_diff/src/app/components/Breadcrumb.js @@ -0,0 +1,70 @@ +"use client"; + +import Link from "next/link"; +import { Home } from "lucide-react"; +import PropTypes from "prop-types"; + +export default function Breadcrumb({ + items = [], + homeHref = "/", + homeLabel = "Dashboard", + className = "", +}) { + const wrapCls = `mb-4 ${className}`.trim(); + + return ( + + ); +} + +Breadcrumb.propTypes = { + items: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string.isRequired, + href: PropTypes.string, + }) + ), + homeHref: PropTypes.string, + homeLabel: PropTypes.string, + className: PropTypes.string, +}; diff --git a/modules/portfolio_diff/src/app/components/MetricsPanel.js b/modules/portfolio_diff/src/app/components/MetricsPanel.js new file mode 100644 index 000000000..c18466d2e --- /dev/null +++ b/modules/portfolio_diff/src/app/components/MetricsPanel.js @@ -0,0 +1,899 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + ArrowLeftRight, + ChevronDown, + FileText, + Gauge, + Languages, + ListCollapse, + MessageSquareText, + MessagesSquare, + Quote, + Speech, + Trash2, + Users, + WholeWord, +} from "lucide-react"; + +/* ---------------------- deterministic helpers ---------------------- */ +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", +]; + +const highlightClassForMetric = (metricId) => { + const idx = seedFrom(metricId || "metric") % HIGHLIGHT_CLASSES.length; + return HIGHLIGHT_CLASSES[idx]; +}; + +/* ============================================================= + METRICS (FULL LIST) — matches EssayComparison + ============================================================= */ + +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 ALL_KEYS = METRIC_DEFS.map((m) => m.id); +const CATEGORIES = Array.from(new Set(METRIC_DEFS.map((m) => m.category))); + +const DEFAULT_PRESETS = { + "Core (language + structure)": [ + "academic_language", + "informal_language", + "latinate_words", + "transition_words", + "citations", + "sentences", + "paragraphs", + ], + "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 normalizePresetMetrics(arr) { + const uniq = Array.from(new Set((arr || []).filter(Boolean))); + const known = new Set(ALL_KEYS); + return uniq.filter((id) => known.has(id)); +} + +/* ============================================================= + MetricsPanel (updated to match EssayComparison sidebar) + - Keeps backward compat with your existing call: + + ============================================================= */ + +export function MetricsPanel({ + // Backward-compatible props + metrics, + setMetrics, + + // Optional UI knobs + stickyTopClassName = "top-24", + title = "Metrics", +}) { + const selectedMetrics = Array.isArray(metrics) ? metrics : []; + const setSelectedMetrics = typeof setMetrics === "function" ? setMetrics : () => {}; + + /* ---------------------- Presets (stateful, deletable, creatable) ---------------------- */ + 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; + + if (parsed && typeof parsed === "object") { + const merged = { ...DEFAULT_PRESETS }; + for (const [k, v] of Object.entries(parsed)) { + if (!k) continue; + merged[k] = normalizePresetMetrics(v); + } + setPresets(merged); + } else { + setPresets(DEFAULT_PRESETS); + } + }, []); + + useEffect(() => { + if (typeof window === "undefined") return; + window.localStorage.setItem(PRESETS_STORAGE_KEY, JSON.stringify(presets)); + }, [presets]); + + const createPreset = useCallback(() => { + const name = (presetName || "").trim(); + if (!name) return; + + const arr = normalizePresetMetrics(selectedMetrics); + if (!arr.length) return; + + setPresets((prev) => ({ + ...prev, + [name]: arr, + })); + setPresetName(""); + }, [presetName, selectedMetrics]); + + const deletePreset = useCallback((name) => { + setPresets((prev) => { + const next = { ...prev }; + delete next[name]; + if (!Object.keys(next).length) return { ...DEFAULT_PRESETS }; + return next; + }); + }, []); + + const applyPreset = useCallback( + (name) => { + const arr = presets?.[name] || []; + setSelectedMetrics(normalizePresetMetrics(arr)); + }, + [presets, 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 selectedCount = selectedMetrics.length; + + return ( + + ); +} diff --git a/modules/portfolio_diff/src/app/components/Navbar.js b/modules/portfolio_diff/src/app/components/Navbar.js new file mode 100644 index 000000000..1789e6464 --- /dev/null +++ b/modules/portfolio_diff/src/app/components/Navbar.js @@ -0,0 +1,190 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useRef, useState } from "react"; +import { + Search, + Bell, + User, + ChevronDown, + Menu, + X, + BookOpen, + Settings, + LogOut, +} from "lucide-react"; +import { navigateTo } from "../utils/navigation"; + +export default function Navbar() { + const [open, setOpen] = useState(false); // mobile sheet + const [menuOpen, setMenuOpen] = useState(false); // desktop profile menu + const menuRef = useRef(null); + + // Close profile menu on outside click + useEffect(() => { + const onClick = (e) => { + if (!menuRef.current) return; + if (!menuRef.current.contains(e.target)) setMenuOpen(false); + }; + const onKey = (e) => { + if (e.key === "Escape") setMenuOpen(false); + }; + window.addEventListener("mousedown", onClick); + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("mousedown", onClick); + window.removeEventListener("keydown", onKey); + }; + }, []); + + return ( +
+ + + {/* Mobile sheet */} +
+
+
+ + +
+ + {/* Mobile profile actions */} +
+
+
+
+ +
+
+

Sarah Rodriguez

+

Mentor

+
+
+
+ +
+ setOpen(false)} + > + View Profile + + setOpen(false)} + > + Settings + + +
+
+
+
+
+ ); +} diff --git a/modules/portfolio_diff/src/app/components/TypeFilterDropdown.js b/modules/portfolio_diff/src/app/components/TypeFilterDropdown.js new file mode 100644 index 000000000..d97516057 --- /dev/null +++ b/modules/portfolio_diff/src/app/components/TypeFilterDropdown.js @@ -0,0 +1,104 @@ +import { useState, useRef, useEffect } from "react"; +import { ChevronDown, Filter } from "lucide-react"; +import { motion, AnimatePresence } from "framer-motion"; + +const essayTypes = [ + "Argumentative", + "Narrative", + "Personal", + "Analytical", + "Reflective", + "Opinion", + "Education", + "Descriptive", + "Experience", + "Economics", + "Research", +]; + +export default function TypeFilterDropdown({ selectedTypes, setSelectedTypes }) { + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const dropdownRef = useRef(null); + + const toggleType = (type) => { + setSelectedTypes((prev) => + prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type] + ); + }; + + const handleClearAll = () => { + setSelectedTypes([]); + }; + + // Close on outside click + useEffect(() => { + function handleClickOutside(event) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setIsDropdownOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + return ( +
+ {/* Toggle Button */} + + + {/* Dropdown Panel with Animation */} + + {isDropdownOpen && ( + +
+ + + {essayTypes.map((type) => ( + + ))} +
+
+ )} +
+
+ ); +} diff --git a/modules/portfolio_diff/src/app/favicon.ico b/modules/portfolio_diff/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 zcmeHv30#a{`}aL_*G&7qml|y<+KVaDM2m#dVr!KsA!#An?kSQM(q<_dDNCpjEux83 zLb9Z^XxbDl(w>%i@8hT6>)&Gu{h#Oeyszu?xtw#Zb1mO{pgX9699l+Qppw7jXaYf~-84xW z)w4x8?=youko|}Vr~(D$UXIbiXABHh`p1?nn8Po~fxRJv}|0e(BPs|G`(TT%kKVJAdg5*Z|x0leQq0 zkdUBvb#>9F()jo|T~kx@OM8$9wzs~t2l;K=woNssA3l6|sx2r3+kdfVW@e^8e*E}v zA1y5{bRi+3Z`uD3{F7LgFJDdvm;nJilkzDku>BwXH(8ItVCXk*-lSJnR?-2UN%hJ){&rlvg`CDTj z)Bzo!3v7Ou#83zEDEFcKt(f1E0~=rqeEbTnMvWR#{+9pg%7G8y>u1OVRUSoox-ovF z2Ydma(;=YuBY(eI|04{hXzZD6_f(v~H;C~y5=DhAC{MMS>2fm~1H_t2$56pc$NH8( z5bH|<)71dV-_oCHIrzrT`2s-5w_+2CM0$95I6X8p^r!gHp+j_gd;9O<1~CEQQGS8) zS9Qh3#p&JM-G8rHekNmKVewU;pJRcTAog68KYo^dRo}(M>36U4Us zfgYWSiHZL3;lpWT=zNAW>Dh#mB!_@Lg%$ms8N-;aPqMn+C2HqZgz&9~Eu z4|Kp<`$q)Uw1R?y(~S>ePdonHxpV1#eSP1B;Ogo+-Pk}6#0GsZZ5!||ev2MGdh}_m z{DeR7?0-1^zVs&`AV6Vt;r3`I`OI_wgs*w=eO%_#7Kepl{B@xiyCANc(l zzIyd4y|c6PXWq9-|KM8(zIk8LPk(>a)zyFWjhT!$HJ$qX1vo@d25W<fvZQ2zUz5WRc(UnFMKHwe1| zWmlB1qdbiA(C0jmnV<}GfbKtmcu^2*P^O?MBLZKt|As~ge8&AAO~2K@zbXelK|4T<{|y4`raF{=72kC2Kn(L4YyenWgrPiv z@^mr$t{#X5VuIMeL!7Ab6_kG$&#&5p*Z{+?5U|TZ`B!7llpVmp@skYz&n^8QfPJzL z0G6K_OJM9x+Wu2gfN45phANGt{7=C>i34CV{Xqlx(fWpeAoj^N0Biu`w+MVcCUyU* zDZuzO0>4Z6fbu^T_arWW5n!E45vX8N=bxTVeFoep_G#VmNlQzAI_KTIc{6>c+04vr zx@W}zE5JNSU>!THJ{J=cqjz+4{L4A{Ob9$ZJ*S1?Ggg3klFp!+Y1@K+pK1DqI|_gq z5ZDXVpge8-cs!o|;K73#YXZ3AShj50wBvuq3NTOZ`M&qtjj#GOFfgExjg8Gn8>Vq5 z`85n+9|!iLCZF5$HJ$Iu($dm?8~-ofu}tEc+-pyke=3!im#6pk_Wo8IA|fJwD&~~F zc16osQ)EBo58U7XDuMexaPRjU@h8tXe%S{fA0NH3vGJFhuyyO!Uyl2^&EOpX{9As0 zWj+P>{@}jxH)8|r;2HdupP!vie{sJ28b&bo!8`D^x}TE$%zXNb^X1p@0PJ86`dZyj z%ce7*{^oo+6%&~I!8hQy-vQ7E)0t0ybH4l%KltWOo~8cO`T=157JqL(oq_rC%ea&4 z2NcTJe-HgFjNg-gZ$6!Y`SMHrlj}Etf7?r!zQTPPSv}{so2e>Fjs1{gzk~LGeesX%r(Lh6rbhSo_n)@@G-FTQy93;l#E)hgP@d_SGvyCp0~o(Y;Ee8{ zdVUDbHm5`2taPUOY^MAGOw*>=s7=Gst=D+p+2yON!0%Hk` zz5mAhyT4lS*T3LS^WSxUy86q&GnoHxzQ6vm8)VS}_zuqG?+3td68_x;etQAdu@sc6 zQJ&5|4(I?~3d-QOAODHpZ=hlSg(lBZ!JZWCtHHSj`0Wh93-Uk)_S%zsJ~aD>{`A0~ z9{AG(e|q3g5B%wYKRxiL2Y$8(4w6bzchKuloQW#e&S3n+P- z8!ds-%f;TJ1>)v)##>gd{PdS2Oc3VaR`fr=`O8QIO(6(N!A?pr5C#6fc~Ge@N%Vvu zaoAX2&(a6eWy_q&UwOhU)|P3J0Qc%OdhzW=F4D|pt0E4osw;%<%Dn58hAWD^XnZD= z>9~H(3bmLtxpF?a7su6J7M*x1By7YSUbxGi)Ot0P77`}P3{)&5Un{KD?`-e?r21!4vTTnN(4Y6Lin?UkSM z`MXCTC1@4A4~mvz%Rh2&EwY))LeoT=*`tMoqcEXI>TZU9WTP#l?uFv+@Dn~b(>xh2 z;>B?;Tz2SR&KVb>vGiBSB`@U7VIWFSo=LDSb9F{GF^DbmWAfpms8Sx9OX4CnBJca3 zlj9(x!dIjN?OG1X4l*imJNvRCk}F%!?SOfiOq5y^mZW)jFL@a|r-@d#f7 z2gmU8L3IZq0ynIws=}~m^#@&C%J6QFo~Mo4V`>v7MI-_!EBMMtb%_M&kvAaN)@ZVw z+`toz&WG#HkWDjnZE!6nk{e-oFdL^$YnbOCN}JC&{$#$O27@|Tn-skXr)2ml2~O!5 zX+gYoxhoc7qoU?C^3~&!U?kRFtnSEecWuH0B0OvLodgUAi}8p1 zrO6RSXHH}DMc$&|?D004DiOVMHV8kXCP@7NKB zgaZq^^O<7PoKEp72kby@W0Z!Y*Ay{&vfg#C&gG@YVR9g?FEocMUi1gSN$+V+ayF45{a zuDZDTN}mS|;BO%gEf}pjBfN2-gIrU#G5~cucA;dokXW89%>AyXJJI z9X4UlIWA|ZYHgbI z5?oFk@A=Ik7lrEQPDH!H+b`7_Y~aDb_qa=B2^Y&Ow41cU=4WDd40dp5(QS-WMN-=Y z9g;6_-JdNU;|6cPwf$ak*aJIcwL@1n$#l~zi{c{EW?T;DaW*E8DYq?Umtz{nJ&w-M zEMyTDrC&9K$d|kZe2#ws6)L=7K+{ zQw{XnV6UC$6-rW0emqm8wJoeZK)wJIcV?dST}Z;G0Arq{dVDu0&4kd%N!3F1*;*pW zR&qUiFzK=@44#QGw7k1`3t_d8&*kBV->O##t|tonFc2YWrL7_eqg+=+k;!F-`^b8> z#KWCE8%u4k@EprxqiV$VmmtiWxDLgnGu$Vs<8rppV5EajBXL4nyyZM$SWVm!wnCj-B!Wjqj5-5dNXukI2$$|Bu3Lrw}z65Lc=1G z^-#WuQOj$hwNGG?*CM_TO8Bg-1+qc>J7k5c51U8g?ZU5n?HYor;~JIjoWH-G>AoUP ztrWWLbRNqIjW#RT*WqZgPJXU7C)VaW5}MiijYbABmzoru6EmQ*N8cVK7a3|aOB#O& zBl8JY2WKfmj;h#Q!pN%9o@VNLv{OUL?rixHwOZuvX7{IJ{(EdPpuVFoQqIOa7giLVkBOKL@^smUA!tZ1CKRK}#SSM)iQHk)*R~?M!qkCruaS!#oIL1c z?J;U~&FfH#*98^G?i}pA{ z9Jg36t4=%6mhY(quYq*vSxptes9qy|7xSlH?G=S@>u>Ebe;|LVhs~@+06N<4CViBk zUiY$thvX;>Tby6z9Y1edAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJwb z_^v8bbg`SAn{I*4bH$u(RZ6*xUhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=pC^S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk($?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvhCL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397*_cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111aH}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*IcmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU&68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-=A=yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v#ix45EVrcEhr>!NMhprl$InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~&^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7<4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}scZlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2`1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M=hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S(O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/modules/portfolio_diff/src/app/globals.css b/modules/portfolio_diff/src/app/globals.css new file mode 100644 index 000000000..7ddd1f015 --- /dev/null +++ b/modules/portfolio_diff/src/app/globals.css @@ -0,0 +1,43 @@ +@import "tailwindcss"; + +:root { + --background: #ffffff; + --foreground: #171717; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --background: #f2f2f2; + --foreground: #ededed; + } +} + +body { + background: var(--background); + font-family: Arial, Helvetica, sans-serif; +} + +a, button { + cursor: pointer; +} + +.text-p { + font-size: 15px; +} + + +.dashboard-card { + cursor: pointer; + box-sizing: border-box; + border: 1px solid rgba(60,73,81,0.16); + border-radius: 8px; + box-shadow: 0 4px 0 0 rgba(35,57,91,0.08); +} \ No newline at end of file diff --git a/modules/portfolio_diff/src/app/layout.js b/modules/portfolio_diff/src/app/layout.js new file mode 100644 index 000000000..1b19a882f --- /dev/null +++ b/modules/portfolio_diff/src/app/layout.js @@ -0,0 +1,18 @@ +import Navbar from "./components/Navbar"; +import "./globals.css"; + +export const metadata = { + title: "Writing Portfolio", + description: "Generated by create next app", +}; + +export default function RootLayout({ children }) { + return ( + + + + {children} + + + ); +} diff --git a/modules/portfolio_diff/src/app/page.js b/modules/portfolio_diff/src/app/page.js new file mode 100644 index 000000000..24252a56f --- /dev/null +++ b/modules/portfolio_diff/src/app/page.js @@ -0,0 +1,7 @@ +"use client"; + +import StudentsPage from "./students/page"; + +export default function WritingPortfolioDashboard() { + 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 new file mode 100644 index 000000000..179213dcb --- /dev/null +++ b/modules/portfolio_diff/src/app/students/compare/page.js @@ -0,0 +1,1878 @@ +"use client"; + +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, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { useLOConnectionDataManager } from "lo_event/lo_event/lo_assess/components/components.jsx"; +import dynamic from "next/dynamic"; + +import { MetricsPanel } from "@/app/components/MetricsPanel"; + +const WOTextHighlight = dynamic( + () => import("lo_dash_react_components/src/lib").then((m) => m.WOTextHighlight), + { ssr: false } +); + +/* ---------------------- deterministic helpers ---------------------- */ +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) + ============================================================= */ + +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", +]; + +const highlightClassForMetric = (metricId) => { + const idx = seedFrom(metricId || "metric") % HIGHLIGHT_CLASSES.length; + return HIGHLIGHT_CLASSES[idx]; +}; + +function buildSpansFromDoc(doc, metricIds) { + const text = (doc?.text || "").toString(); + const spans = []; + + for (const metricId of metricIds || []) { + const m = doc?.[metricId]; + const offsets = m?.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]); + 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)); + 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); + } + 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]; + if (b <= a) continue; + + const active = []; + for (const sp of spans) { + if (sp.start <= a && sp.end >= b) active.push(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 + ============================================================= */ +function metricCoveragePercent(doc, metricId) { + 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]); + 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)); + 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]; + + 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; + } + } + covered += curE - curS; + + return (covered / 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; + + const v = doc?.[id]?.metric; + const hasNum = v != null && !Number.isNaN(Number(v)); + + const cov = metricCoveragePercent(doc, id); + const covStr = `${cov.toFixed(1)}% of text`; + + if (hasNum) { + lines.push(`${label}: ${formatMetricValue(v, id)} (${covStr})`); + } else { + lines.push(`${label}: ${covStr}`); + } + } + return lines.join("\n"); +} + +/* ---------------------- Floating tooltip (custom, reliable) ---------------------- */ +function clamp(n, lo, hi) { + return Math.max(lo, Math.min(hi, n)); +} + +function FloatingTooltip({ tooltip }) { + if (!tooltip?.visible) return null; + + return ( +
+
+ {tooltip.content} +
+
+ ); +} + +function HighlightedEssay({ + doc, + activeMetricIds, + containerRef, + onShowTooltip, + onMoveTooltip, + onHideTooltip, +}) { + const { text, spans } = useMemo(() => buildSpansFromDoc(doc, activeMetricIds), [doc, activeMetricIds]); + const segments = useMemo(() => segmentTextBySpans(text, spans), [text, spans]); + + 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 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} + + ); + })} +
+ ); +} + +/* ---------------------- URL param reader (client-safe) ---------------------- */ +function readCompareParamsFromLocation() { + 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; + } + + return { urlReady: true, studentID, docIds }; +} + +function buildEssayFromDoc({ docId, text, side }) { + const content = (text || "").trim(); + const words = content ? content.split(/\s+/).filter(Boolean).length : 0; + + return { + id: docId || `${side}-unknown`, + title: docId ? `Document: ${docId}` : `Document (${side})`, + date: "", + minutes: Math.max(10, Math.round(words / 30)), + words, + grade: "—", + tags: [], + content: content || "(No text returned for this document.)", + }; +} + +/* ---------------------- Metrics comparison UI 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}%`; +} + +/* ---------------------- 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 = []; + 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]); + } + if (!spans.length) return []; + + spans.sort((a, b) => a[0] - b[0] || a[1] - b[1]); + + 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); + + 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); + } + return out; +} + +function MetricDeltaIcon({ delta }) { + const d = Number(delta) || 0; + if (d > 0.0001) return ; + if (d < -0.0001) return ; + return ; +} + +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)} + + ); +} + +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"; + + return ( +
+
{label}
+
+
+
+ {isDisabled ? "—" : metricTitle || "—"} +
+
{isDisabled ? "" : category || ""}
+
+ +
+ +
+ {formatPct(left)} + + {formatPct(right)} +
+
+ ); +} + +function MetricRow({ row, isFocused, onFocusToggle, onShow }) { + const { def, left, right, delta } = row; + return ( +
+
+ +
+
+
{def.title}
+ · {def.category} +
+
{def.desc}
+
+ +
+
+ {formatPct(left)} + + {formatPct(right)} +
+ +
+ + +
+ + + + +
+
+
+ ); +} + +export default function EssayComparison() { + const initial = useMemo(() => readCompareParamsFromLocation(), []); + const [urlReady, setUrlReady] = useState(initial.urlReady); + const [studentID, setStudentID] = useState(initial.studentID); + const [docIds, setDocIds] = useState(initial.docIds); + + 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); + if (!urlReady) setUrlReady(true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const leftDocId = docIds[0] || ""; + const rightDocId = docIds[1] || ""; + + const enabled = urlReady && !!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", + ]); + + /* ---------------------- Available docs list (for replacement selection) ---------------------- */ + const docsListEnabled = urlReady && !!studentID; + + const dataScopeList = useMemo(() => { + if (!docsListEnabled) { + return { + wo: { + execution_dag: "writing_observer", + target_exports: [], + kwargs: {}, + }, + }; + } + return { + wo: { + execution_dag: "writing_observer", + target_exports: ["student_with_docs"], + kwargs: { + course_id: "12345678901", + student_id: [{ user_id: studentID }], + }, + }, + }; + }, [docsListEnabled, studentID]); + + const { data: loListData } = useLOConnectionDataManager({ + url: "ws://localhost:8888/wsapi/communication_protocol", + dataScope: dataScopeList, + }); + + const availableDocIds = useMemo(() => { + const docsObj = loListData?.students?.[studentID]?.docs || {}; + const ids = Object.keys(docsObj || {}); + ids.sort(); + return ids; + }, [loListData, studentID]); + + /* ---------------------- comparison data fetch ---------------------- */ + const dataScope = useMemo(() => { + 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"], + kwargs: { + course_id: "12345678901", + student_id: docIds.map(() => ({ user_id: studentID })), + document: docIds.map((doc_id) => ({ doc_id })), + nlp_options: selectedMetrics, + }, + }, + }; + }, [enabled, studentID, docIds, selectedMetrics]); + + const { data: loData, errors: loErrors, connection: loConnection } = useLOConnectionDataManager({ + url: "ws://localhost:8888/wsapi/communication_protocol", + dataScope, + }); + + 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 leftText = leftHasTextField ? leftDoc?.text || "" : ""; + const rightText = rightHasTextField ? rightDoc?.text || "" : ""; + + const showInlineWarning = enabled && isDocsLoading && !!loErrors; + + const [leftEssay, setLeftEssay] = useState(() => buildEssayFromDoc({ docId: leftDocId, text: "", side: "left" })); + const [rightEssay, setRightEssay] = useState(() => buildEssayFromDoc({ docId: rightDocId, text: "", side: "right" })); + + useEffect(() => { + setLeftEssay(buildEssayFromDoc({ docId: leftDocId, text: "", side: "left" })); + setRightEssay(buildEssayFromDoc({ docId: rightDocId, text: "", side: "right" })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [leftDocId, rightDocId]); + + useEffect(() => { + if (!enabled) return; + if (leftHasTextField) setLeftEssay(buildEssayFromDoc({ docId: leftDocId, text: leftText, side: "left" })); + if (rightHasTextField) setRightEssay(buildEssayFromDoc({ docId: rightDocId, text: rightText, side: "right" })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, leftHasTextField, rightHasTextField, leftDocId, rightDocId, leftText, rightText]); + + /* ---------------------- CUSTOM TOOLTIP STATE ---------------------- */ + 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] + ); + + /* ---------------------- Replace Modal (no shifting, full doc list) ---------------------- */ + const [replaceModal, setReplaceModal] = useState({ open: false, side: "left" }); + const [replaceQuery, setReplaceQuery] = useState(""); + const [replaceActiveIdx, setReplaceActiveIdx] = useState(0); + + const openReplace = (side) => { + setReplaceQuery(""); + setReplaceActiveIdx(0); + setReplaceModal({ open: true, side }); + if (typeof document !== "undefined") document.body.style.overflow = "hidden"; + }; + const closeReplace = () => { + setReplaceModal({ open: false, side: "left" }); + setReplaceQuery(""); + setReplaceActiveIdx(0); + if (typeof document !== "undefined") document.body.style.overflow = ""; + }; + + useEffect(() => { + return () => { + if (typeof document !== "undefined") document.body.style.overflow = ""; + }; + }, []); + + const currentIdForSide = replaceModal.side === "left" ? leftDocId : rightDocId; + 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) => id.toLowerCase().includes(q)); + }, [replaceQuery, availableDocIds]); + + 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; + } + }; + + // Keep active index in bounds as filter changes + useEffect(() => { + if (!replaceModal.open) return; + setReplaceActiveIdx(0); + }, [replaceModal.open, replaceQuery]); + + /* ============================================================= + METRICS COMPARISON (Coverage-based) + ============================================================= */ + + 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]); + + 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); + 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; + }, [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; + + 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 }; + }, [coverageRows]); + + const topChanges = useMemo(() => coverageRows.slice(0, 8), [coverageRows]); + const allRemaining = useMemo(() => (coverageRows.length > 8 ? coverageRows.slice(8) : []), [coverageRows]); + + const leftEssayRef = useRef(null); + const 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 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 ( +
+ + + {/* Replace Modal */} + {replaceModal.open && ( +
+
+
+
+
+
+
+ Replace {replaceModal.side === "left" ? "Left" : "Right"} document +
+
+ Student: {studentID} + {" • "} + Docs: {availableDocIds.length} + {" • "} + Current: {currentIdForSide || "—"} +
+
+ +
+ +
+
+ + setReplaceQuery(e.target.value)} + autoFocus + placeholder="Search by document id…" + 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.
+
+ +
+ {replaceMatches.length === 0 ? ( +
No matches.
+ ) : ( + replaceMatches.map((id, idx) => { + const isActive = idx === replaceActiveIdx; + const isCurrent = id === currentIdForSide; + const isOther = id === otherIdForSide; + + return ( + + ); + }) + )} +
+ +
+ + +
+
+
+
+ )} + +
+ + + {missingParams ? ( +
+ Missing URL params. Expected: ?student_id=...&ids=docA,docB +
+ ) : null} + + {urlReady ? ( +
+ ids: {docIds.join(", ")} + {loConnection?.status ? • ws: {String(loConnection.status)} : null} + {availableDocIds.length ? • available docs: {availableDocIds.length} : null} +
+ ) : null} +
+ +
+ {isDocsLoading ? ( +
+
+
+
Loading documents…
+
+
+ Waiting until both documents return non-empty text. +
+
+ ) : ( +
+ {/* ✅ Sidebar replaced with MetricsPanel */} +
+ +
+ +
+ {showInlineWarning ? ( +
+ Some data errors were reported while loading documents. +
+ ) : null} + + {/* Essays */} +
+ {/* Left */} +
+
+
+

{leftEssay.title}

+ +
+ +
+ + {leftEssay.minutes} min + + + {leftEssay.words.toLocaleString()} words + +
+ + {focusedMetricId ? ( +
+ + + Focus: {focusedMeta?.title || focusedMetricId} + + +
+ ) : null} +
+ +
+
+ Hover highlights to see metric tooltip. + {focusedMetricId ? Showing only the focused metric highlights. : null} +
+ +
+
+ + {/* Right */} +
+
+
+

{rightEssay.title}

+ +
+ +
+ + {rightEssay.minutes} min + + + {rightEssay.words.toLocaleString()} words + +
+ + {focusedMetricId ? ( +
+ + + Focus: {focusedMeta?.title || focusedMetricId} + + +
+ ) : null} +
+ +
+
+ Hover highlights to see metric tooltip. + {focusedMetricId ? Showing only the focused metric highlights. : null} +
+ +
+
+
+ + {/* ========================= + What changed in the writing (Coverage) + ========================= */} + {selectedMetrics.length > 0 && ( +
+
+
+

What is different

+
+ Coverage = % of essay text highlighted for a signal. +
+
+ + {focusedMetricId ? ( + + ) : null} +
+ + {/* Story cards (Improved | Consistent | Dropped) */} +
+ + + + + +
+ +
+
+
+
Top changes
+
+ Click “Focus” to show only that metric’s highlights in both essays. +
+
+
+ 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 new file mode 100644 index 000000000..40607a1a2 --- /dev/null +++ b/modules/portfolio_diff/src/app/students/components/StudentDetail/SingleEssayModel.js @@ -0,0 +1,1039 @@ +"use client"; + +import React, { useEffect, useMemo, useState } from "react"; +import { + X, + History, + Sparkles, + Gauge, + Layers, + Activity, + Loader2, + AlertTriangle, + Tag, + SlidersHorizontal, + ArrowUpDown, +} from "lucide-react"; + +import { MetricsPanel } from "@/app/components/MetricsPanel"; +import { useLOConnectionDataManager } from "lo_event/lo_event/lo_assess/components/components.jsx"; + +/* ========================================================= + Helpers +========================================================= */ + +const DEBUG = true; + +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) => { + 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); + } +} + +/** + * Robust normalization to handle whatever MetricsPanel emits. + */ +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 + ); + } + 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); + } + + return []; +} + +function metricCoveragePercent(doc, metricId) { + 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]); + if (!Number.isFinite(start) || !Number.isFinite(len) || len <= 0) continue; + + const s = clamp(start, 0, L); + const e = clamp(start + 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]; + 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; + } + } + covered += curE - curS; + return (covered / 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 (0% to 100%) +========================================================= */ + +function BaselineCurrentChart({ baselinePct, currentPct, height = 120 }) { + 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 yMin = 0; + const yMax = 100; + + 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 tickTargets = [0, 25, 50, 75, 100]; + + return ( + + {tickTargets.map((t) => { + const y = Y(t); + return ( + + + + {t}% + + + ); + })} + + + + + + Baseline + + + Current + + + + + + + + {b.toFixed(1)}% + + + {c.toFixed(1)}% + + + ); +} + +function MetricTile({ metricKey, baseline, currentValue }) { + return ( +
+
+
{metricKey}
+
Baseline (prior essays only) → Current (this essay)
+
+
+ +
+
+ ); +} + +/* ========================================================= + Evidence blocks for Actionable Feedback +========================================================= */ + +function EvidenceProduct({ cues }) { + const list = Array.isArray(cues) ? cues : []; + if (!list.length) { + return ( +
+ No product evidence attached yet. +
+ ); + } + return ( +
+
+ Evidence cues (justification signals) +
+
+ {list.map((c, i) => ( +
+
{c.label}
+ {c.sub ?
{c.sub}
: null} +
+ ))} +
+
+ ); +} + +function EvidenceProcess({ features }) { + const list = Array.isArray(features) ? features : []; + if (!list.length) { + return ( +
+ No process evidence attached yet. +
+ ); + } + 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) +
+
+ ))} +
+
+ ); +} + +/* ========================================================= + Feedback Controls (left column in feedback tab) +========================================================= */ + +function FeedbackControls({ + detailLevel, + setDetailLevel, + ordering, + setOrdering, + priority, + setPriority, + includeEvidence, + setIncludeEvidence, + includeProcessSignals, + setIncludeProcessSignals, +}) { + return ( +
+
+
+ + + +
+
Feedback Controls
+
+
+
Choose ordering and how many items to show.
+
+ +
+
+
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) => ( + + ))} +
+
+ +
+
+ Include in feedback +
+
+ + + +
+
+ +
Tip: “Highest impact + Top 1 + Brief” works well for quick LMS comments.
+
+
+ ); +} + +/* ========================================================= + LO runner (hook lives here) + Remounting this component forces a fresh subscription/request. +========================================================= */ + +function LORunner({ url, dataScope, onData, onErrors, scopeKey }) { + const { data, errors } = useLOConnectionDataManager({ url, 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; +} + +/* ========================================================= + Exported Modal +========================================================= */ + +export function SingleEssayModal({ + studentKey, + docId, + docIds, + docTitle, + docIndex, + initialWords, + subtitleDate, + onClose, +}) { + useEffect(() => { + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = prev || ""; + }; + }, []); + + useEffect(() => { + const onKey = (e) => { + if (e.key === "Escape") onClose?.(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + const avatar = initialsFromStudentKey(studentKey); + const title = docTitle || (docIndex ? `Document ${docIndex}` : "Document"); + const subtitle = `• Document • ${studentKey || "—"}${subtitleDate ? ` • ${subtitleDate}` : ""}`; + + return ( +
+
onClose?.()} /> + +
+
e.stopPropagation()} + > +
+
+
+
+ {avatar} +
+ +
+
{title}
+
{subtitle}
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ ); +} + +/* ========================================================= + Inner modal (Trajectory + Actionable Feedback fully included) +========================================================= */ + +function SingleEssayInnerModal({ studentKey, docId, docIds }) { + const [activeTab, setActiveTab] = useState("trajectory"); + const [feedbackMode, setFeedbackMode] = useState("product"); + const [selectedMetrics, setSelectedMetricsState] = useState(["academic_language"]); + + // LO outputs stored in state (decouple UI from hook internals) + const [loData, setLoData] = useState(null); + const [loErrors, setLoErrors] = useState(null); + + // 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); + + const setSelectedMetrics = (next) => { + setSelectedMetricsState((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; + }); + }; + + const exportEnabled = + activeTab === "trajectory" && + !!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"], + kwargs: { + course_id: "12345678901", + student_id: docIds.map(() => ({ user_id: studentKey })), + document: docIds.map((d) => ({ doc_id: d })), + nlp_options: selectedMetrics, + }, + }, + }; + }, [exportEnabled, studentKey, docIds, selectedMetrics]); + + // The key that forces LORunner (and hook) remount + const scopeKey = useMemo(() => { + const signature = { + exportEnabled, + studentKey, + docId, + docIds, + selectedMetrics, + target_exports: dataScope?.wo?.target_exports || [], + }; + return stableStringify(signature); + }, [exportEnabled, studentKey, docId, docIds, selectedMetrics, dataScope]); + + useEffect(() => { + if (!DEBUG) return; + console.groupCollapsed("[SingleEssayInnerModal] scopeKey changed -> forcing LO remount"); + console.log("scopeKey:", scopeKey); + console.log("dataScope:", dataScope); + console.groupEnd(); + }, [scopeKey, dataScope]); + + // reset outputs on a new scope + useEffect(() => { + if (!exportEnabled) return; + setLoData(null); + setLoErrors(null); + }, [scopeKey, exportEnabled]); + + const docsObj = useMemo(() => loData?.students?.[studentKey]?.documents || {}, [loData, studentKey]); + + const hasError = useMemo(() => { + if (!exportEnabled) return false; + if (!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]); + + const hasAllDocs = useMemo(() => { + if (!exportEnabled) return false; + return docIds.every((id) => { + const d = docsObj?.[id]; + return d && typeof d.text === "string" && d.text.length > 0; + }); + }, [exportEnabled, docIds, docsObj]); + + const isLOLoading = useMemo(() => exportEnabled && !hasError && !hasAllDocs, [exportEnabled, hasError, hasAllDocs]); + + const currentDocIndex = useMemo(() => { + const idx = docIds.findIndex((id) => String(id) === String(docId)); + return Math.max(0, idx); + }, [docIds, docId]); + + const hasPriorData = currentDocIndex > 0; + + 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); + const baseline = prior.length ? mean(prior) : 0; + return { key: metricKey, baseline, currentValue: current }; + }); + }, [exportEnabled, hasAllDocs, selectedMetrics, docIds, docsObj, currentDocIndex]); + + const currentText = useMemo(() => (docsObj?.[docId]?.text || "").toString(), [docsObj, docId]); + + const wordCount = useMemo(() => { + const t = (currentText || "").trim(); + if (!t) return 0; + return t.split(/\s+/).filter(Boolean).length; + }, [currentText]); + + /* -------------------------- + Actionable feedback content + (keep these blocks as-is or replace with real output later) + -------------------------- */ + + 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: "" }; // brief removes the “why” sentence + } + return b; + }; + + const orderingLabel = useMemo(() => { + return ordering === "lowest_impact" ? "Lowest impact" : "Highest impact"; + }, [ordering]); + + const sortedAndFilteredFeedbackBlocks = useMemo(() => { + const base = feedbackMode === "product" ? feedbackBlocks.product : feedbackBlocks.process; + const blocks = [...base]; + + 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)); + } + + if (priority === "top1") return blocks.slice(0, 1); + if (priority === "top2") return blocks.slice(0, 2); + return blocks; + }, [feedbackBlocks, feedbackMode, ordering, priority]); + + return ( +
+ {/* Hidden runner that forces re-run by remounting on scopeKey changes */} + {exportEnabled ? ( + setLoData(d)} + onErrors={(e) => setLoErrors(e)} + /> + ) : null} + +
+
+
+ Essay: {docId} •{" "} + {wordCount.toLocaleString()} words +
+ +
+ + + +
+
+
+ +
+
+ {/* Left column */} + + + {/* Middle column: essay */} +
+
+
+
+
+
+
Essay
+
+ + + Tags + + + Document + +
+
+ +
+ + {wordCount.toLocaleString()} words + +
+
+
+ +
+
+ {currentText ? ( +

{currentText}

+ ) : ( +

No text available yet.

+ )} +
+
+
+
+
+ + {/* Right column */} +
+ {activeTab === "trajectory" ? ( +
+
+
+
+
+ + + +

Writing Trajectory

+
+
+ Baseline = average of prior essays only. Current = this essay. +
+
+
+
+ +
+ {selectedMetrics.length === 0 ? ( +
+
+ Select metrics on the left to view trajectory +
+
This starts empty by design — pick signals first.
+
+ ) : isLOLoading ? ( +
+
+ + Computing trajectory… +
+
+ ) : hasError ? ( +
+
+ + Couldn’t compute trajectory +
+
+                        {JSON.stringify(loErrors, null, 2)}
+                      
+
+ ) : !hasPriorData ? ( +
+
No prior data to compare with
+
+ This is the student’s first essay in the trajectory sequence, so we can’t compute a baseline yet. +
+
+ ) : ( +
+ {metricSummaries.map((m) => ( + + ))} +
+ )} +
+
+ ) : ( +
+
+
+
+
+ + + +

Actionable Feedback

+
+
+ {orderingLabel} • {priority === "top1" ? "Top 1" : priority === "top2" ? "Top 2" : "All"} •{" "} + {detailLevel === "brief" ? "Brief" : "Standard"} +
+
+ +
+ + +
+
+
+ +
+ {sortedAndFilteredFeedbackBlocks.length === 0 ? ( +
+
No feedback items available
+
+ Try switching Product/Process or set Priority to “All”. +
+
+ ) : ( + sortedAndFilteredFeedbackBlocks.map((raw, i) => { + const b = applyDetailLevel(raw); + + return ( +
+
{b.heading}
+ + {b.why ?
{b.why}
: null} + +
+
Comment
+
{b.suggestion}
+
+ + {feedbackMode === "product" ? ( + includeEvidence ? ( +
+
+ Evidence (justification) +
+ +
+ ) : null + ) : includeProcessSignals ? ( +
+
+ Evidence (process signals) +
+ +
+ ) : null} +
+ ); + }) + )} +
+
+ )} +
+
+
+
+ ); +} diff --git a/modules/portfolio_diff/src/app/students/components/StudentDetail/StudentDetailCompare.js b/modules/portfolio_diff/src/app/students/components/StudentDetail/StudentDetailCompare.js new file mode 100644 index 000000000..f767fb75b --- /dev/null +++ b/modules/portfolio_diff/src/app/students/components/StudentDetail/StudentDetailCompare.js @@ -0,0 +1,584 @@ +"use client"; + +import React, { useMemo, useState } from "react"; +import { + X, + Calendar, + Search, + ChevronDown, + Info, + Maximize2, + GitCompareArrows, +} from "lucide-react"; + +import { SingleEssayModal } from "./SingleEssayModel"; + +/* ========================================================= + Student Compare (Modal imported) +========================================================= */ + +export default function StudentDetailCompare({ + groupedEssays, + studentId, + + selectedEssays, + setSelectedEssays, + handleEssaySelect, + + cardsPerRow, + setCardsPerRow, + sortBy, + setSortBy, + search, + setSearch, + filterTags, + setFilterTags, + tagOpen, + setTagOpen, + tagQuery, + setTagQuery, + tagRef, + baseTags, + clearFilters, + isAnyFilter, + + getGridCols, + getGradeColor, + strengthAndFocusForEssay, + + loDocData, + loDocErrors, + loDocConnection, + documentIDS, +}) { + 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 : () => {}; + + // ---- 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)); + + const isDocsLoading = + !!(loDocConnection && + (loDocConnection.loading || loDocConnection.isLoading || loDocConnection.status === "loading")) || + (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; + + const dateISO = + doc?.dateISO || doc?.date_iso || doc?.date || doc?.submitted_at || doc?.created_at || ""; + + const grade = doc?.grade ?? doc?.score ?? ""; + const tagsFromDoc = Array.isArray(doc?.tags) + ? doc.tags + : Array.isArray(doc?.meta?.tags) + ? doc.meta.tags + : ["Document"]; + + return { + id: docId, + title: doc?.title || `Document ${index + 1}`, + 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, + }; + }); + }, [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 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 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(); + if (!hay.includes(q)) return false; + } + return true; + }) + .sort((a, b) => { + const mode = String(sortBy || "date"); + 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; + }); + }, [docList, search, filterTags, sortBy]); + + const groupedDocs = useMemo(() => { + return filteredDocs.reduce((acc, d) => { + const key = d.dateISO + ? new Date(d.dateISO).toLocaleString("en-US", { month: "long", year: "numeric" }) + : "Undated"; + (acc[key] ||= []).push(d); + return acc; + }, {}); + }, [filteredDocs]); + + const SkeletonCard = ({ i }) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); + + return ( + <> + {isDocsLoading ? ( +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ) : isDocsEmpty ? ( +
+
No documents yet
+
We didn’t find any documents for this student.
+
+ ) : ( + <> + {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) + ); + + return ( +
+ {index !== 0 &&
} + +
+
+

{category}

+
+ {list.length} essays • Avg {wordsAvg.toLocaleString()} words +
+
+ + {index === 0 && ( +
+
+
+ + + {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) => ( + + ))} +
+
+ )} +
+ +
+ + 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" + /> +
+ +
+ +
+ Sort by: + +
+ +
+ Cards per row + +
+
+ + {isAnyFilter && ( +
+ {(Array.isArray(filterTags) ? filterTags : []).map((t) => ( + + Tag: {t} + + + ))} + + {String(search || "").trim().length > 0 && ( + + Search: “{search}” + + + )} + + +
+ )} +
+ )} +
+ +
+ {list.map((essay) => { + const essayTags = Array.isArray(essay?.tags) ? essay.tags : []; + const isSelected = Array.isArray(selectedEssays) ? selectedEssays.includes(essay.id) : false; + const { strength, focus } = safeStrengthAndFocus(essay); + + return ( +
safeHandleEssaySelect(essay.id)} + > +
+
+
+ safeHandleEssaySelect(essay.id)} + className="w-5 h-5 accent-emerald-600" + onClick={(e) => e.stopPropagation()} + aria-label={`Select ${essay.title}`} + /> +

{essay.title}

+
+ + +
+ +
+

+ + {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 ? "▼" : ""} + + )} +
+ +
+ {essayTags.map((tag, i) => ( + + {tag} + + ))} +
+
+
+ ); + })} +
+
+ ); + })} + + )} + + {Array.isArray(selectedEssays) && selectedEssays.length < 2 && ( +
+
+ +

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

+
+
+ )} + +
+
+
+
+
+
+ + + Selected {Array.isArray(selectedEssays) ? selectedEssays.length : 0}/2 + + +
+ {[0, 1].map((i) => { + const id = Array.isArray(selectedEssays) ? selectedEssays[i] : undefined; + return ( +
+ {id ? `#${id}` : "—"} + {id && ( + + )} +
+ ); + })} +
+ + +
+ + +
+
+ + {openEssay?.docId && ( + setOpenEssay(null)} + /> + )} + + ); +} diff --git a/modules/portfolio_diff/src/app/students/components/StudentDetail/StudentDetailGrowth.js b/modules/portfolio_diff/src/app/students/components/StudentDetail/StudentDetailGrowth.js new file mode 100644 index 000000000..4fe95dcf9 --- /dev/null +++ b/modules/portfolio_diff/src/app/students/components/StudentDetail/StudentDetailGrowth.js @@ -0,0 +1,527 @@ +"use client"; + +import { useMemo, useCallback, useState, useEffect, useRef } from "react"; +import dynamic from "next/dynamic"; +import { Loader2 } from "lucide-react"; + +import { useLOConnectionDataManager } from "lo_event/lo_event/lo_assess/components/components.jsx"; +import { MetricsPanel } from "@/app/components/MetricsPanel"; + +const ReactECharts = dynamic(() => import("echarts-for-react"), { ssr: false }); + +const DEBUG = false; + +/* ---------------------- 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); + } +} + +/* ---------------------- 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 + ); + } + 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); + } + + return []; +} + +/* ---------------------- Coverage helpers ---------------------- */ +function coveragePercentFromDoc(doc, metricId) { + 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]); + 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) 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]; + 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; + } + } + covered += curE - curS; + + return (covered / L) * 100; +} + +/* ---------------------- LO Runner ---------------------- */ +function LORunner({ wsUrl, dataScope, scopeKey, onData, onErrors }) { + 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 ---------------------- */ +function buildEChartOption({ metricId, points }) { + const labels = points.map((p) => p.label); + + const barData = points.map((p) => ({ + value: p.barValue, + docId: p.docId, + label: p.label, + raw: p.raw, + })); + + const lineData = points.map((p) => ({ + value: p.value, + docId: p.docId, + label: p.label, + raw: p.raw, + })); + + return { + animation: false, + grid: { top: 20, right: 20, bottom: 40, left: 60 }, + + tooltip: { + trigger: "axis", + confine: true, + axisPointer: { + type: "line", + shadowStyle: { opacity: 0 }, + lineStyle: { color: "rgba(107,114,128,0.55)", width: 1 }, + }, + formatter: (params) => { + const primary = Array.isArray(params) ? 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 label = d.label || primary?.axisValue || ""; + + return ` +
+
${metricId}
+
${label}
+
Coverage: ${pct}%
+
Document: ${docId}
+
+ `; + }, + }, + + 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 }, + }, + }, + + yAxis: { + type: "value", + min: 0, + max: 100, + axisLabel: { formatter: "{value}%" }, + }, + series: [ + { + name: "Coverage (bar)", + type: "bar", + data: barData, + barMaxWidth: 28, + emphasis: { focus: "none" }, + select: { disabled: true }, + blur: { itemStyle: { opacity: 1 } }, + }, + { + 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 } }, + }, + ], + }; +} + +export default function StudentDetailGrowth({ + metrics, + setMetrics, + + studentID, + courseId = "12345678901", + wsUrl = "ws://localhost:8888/wsapi/communication_protocol", + + essaysInRangeAsc = [], +}) { + const selectedMetrics = useMemo(() => normalizeSelectedMetrics(metrics), [metrics]); + + const docIdsAsc = useMemo(() => { + return (Array.isArray(essaysInRangeAsc) ? essaysInRangeAsc : []) + .map((e) => (e?.id || "").toString().trim()) + .filter(Boolean); + }, [essaysInRangeAsc]); + + const enabled = !!studentID && docIdsAsc.length > 0 && selectedMetrics.length > 0; + + const dataScope = useMemo(() => { + 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"], + kwargs: { + course_id: courseId, + student_id: docIdsAsc.map(() => ({ user_id: studentID })), + document: docIdsAsc.map((doc_id) => ({ doc_id })), + 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 [loData, setLoData] = useState(null); + const [loErrors, setLoErrors] = useState(null); + const [isFetching, setIsFetching] = useState(false); + const prevScopeKeyRef = useRef(scopeKey); + + useEffect(() => { + if (!enabled) { + setIsFetching(false); + return; + } + if (prevScopeKeyRef.current !== scopeKey) { + prevScopeKeyRef.current = scopeKey; + setIsFetching(true); + setLoErrors(null); + } + }, [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); + 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 seriesByMetric = useMemo(() => { + const out = {}; + if (!enabled || !loData) return out; + + for (const metricId of selectedMetrics) { + // keep per-metric loader behavior for newly added metrics + if (isFetching && !isMetricReady(metricId)) { + out[metricId] = null; + continue; + } + + 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?.title && String(essay.title)) || + `Essay ${i + 1}`; + + const raw = coveragePercentFromDoc(doc, metricId); + + points.push({ + idx: i, + label, + docId, + raw, + value: raw, + barValue: raw, + metricLabel: metricId, + }); + } + + out[metricId] = points; + } + + return out; + }, [enabled, loData, selectedMetrics, docIdsAsc, docsObj, essaysInRangeAsc, isFetching, isMetricReady]); + + // lock tooltip per metric + const [lockedIndexByMetric, setLockedIndexByMetric] = useState({}); + const chartRefs = useRef({}); + + const showEmpty = selectedMetrics.length === 0; + + return ( +
+ {enabled ? ( + setLoData(d)} + onErrors={(e) => setLoErrors(e)} + /> + ) : null} + + + +
+
+
+ Showing {docIdsAsc.length} docs +
+ + {enabled && isFetching ? ( +
+ + Updating… +
+ ) : null} +
+ + {showEmpty ? ( +
+ Select one or more metrics from the left to view trends over time. +
+ ) : !loData ? ( +
+ + Loading documents… +
+ ) : ( +
+ {selectedMetrics.map((metricId) => { + const points = seriesByMetric?.[metricId]; + + if (enabled && isFetching && points === null) { + return ( +
+
+

{metricId}

+
+ + Fetching… +
+
+ +
+
+ + Computing new 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} +
+
+ +
+ { + const inst = ref?.getEchartsInstance?.(); + if (inst) chartRefs.current[metricId] = inst; + }} + /> + + {enabled && isFetching ? ( +
+
+ + Fetching updated metrics… +
+
+ ) : null} +
+ +

+ Hover to show more information. +

+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/modules/portfolio_diff/src/app/students/components/StudentDetail/index.js b/modules/portfolio_diff/src/app/students/components/StudentDetail/index.js new file mode 100644 index 000000000..190001a6b --- /dev/null +++ b/modules/portfolio_diff/src/app/students/components/StudentDetail/index.js @@ -0,0 +1,948 @@ +"use client"; + +import { Calendar, FileText, GitCompareArrows, TrendingUp, Users } 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"; + +/* ============================================================= + CONSTANTS + ============================================================= */ + +const MODES = { COMPARE: "compare", GROWTH: "growth" }; + +const STUDENTS_BREADCRUMB_HREF = + "http://localhost:8888/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 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", +}; + +const iconForCategoryKey = (catKey) => { + switch (catKey) { + 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" }, + { id: "consequential_transition_words", title: "Consequential Transition Words", categoryKey: "transitions", function: "total", desc: "Total consequential transitions" }, + { id: "contrastive_transition_words", title: "Contrastive Transition Words", categoryKey: "transitions", function: "total", desc: "Total contrastive transitions" }, + { id: "counterpoint_transition_words", title: "Counterpoint Transition Words", categoryKey: "transitions", function: "total", desc: "Total counterpoint transitions" }, + { id: "comparative_transition_words", title: "Comparative Transition Words", categoryKey: "transitions", function: "total", desc: "Total comparative transitions" }, + { id: "cross_referential_transition_words", title: "Cross Referential Transition Words", categoryKey: "transitions", function: "total", desc: "Total cross-referential transitions" }, + { id: "illustrative_transition_words", title: "Illustrative Transition Words", categoryKey: "transitions", function: "total", desc: "Total illustrative transitions" }, + { id: "negative_transition_words", title: "Negative Transition Words", categoryKey: "transitions", function: "total", desc: "Total negative transitions" }, + { id: "emphatic_transition_words", title: "Emphatic Transition Words", categoryKey: "transitions", function: "total", desc: "Total emphatic transitions" }, + { id: "evenidentiary_transition_words", title: "Evenidentiary Transition Words", categoryKey: "transitions", function: "total", desc: "Total evidentiary transitions" }, + { id: "general_transition_words", title: "General Transition Words", categoryKey: "transitions", function: "total", desc: "Total general transitions" }, + { id: "ordinal_transition_words", title: "Ordinal Transition Words", categoryKey: "transitions", function: "total", desc: "Total ordinal transitions" }, + { id: "purposive_transition_words", title: "Purposive Transition Words", categoryKey: "transitions", function: "total", desc: "Total purposive transitions" }, + { id: "periphrastic_transition_words", title: "Periphrastic Transition Words", categoryKey: "transitions", function: "total", desc: "Total periphrastic transitions" }, + { 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" }, + { id: "proper_nouns", title: "Proper Nouns", categoryKey: "pos", function: "total", desc: "Total proper nouns" }, + { id: "verbs", title: "Verbs", categoryKey: "pos", function: "total", desc: "Total verbs" }, + { id: "numbers", title: "Numbers", categoryKey: "pos", function: "total", desc: "Total numbers" }, + { id: "prepositions", title: "Prepositions", categoryKey: "pos", function: "total", desc: "Total prepositions" }, + { id: "coordinating_conjunction", title: "Coordinating Conjunction", categoryKey: "pos", function: "total", desc: "Total coordinating conjunctions" }, + { 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)" }, + { id: "simple_with_compound_complex_predicates", title: "Simple with Compound Complex Predicates", categoryKey: "sentence_type", function: "total", desc: "Total simple (compound complex predicates)" }, + { 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" }, + { id: "paragraphs", title: "Paragraphs", categoryKey: "other", function: "total", desc: "Total paragraphs" }, + { id: "character_trait_words", title: "Character Trait Words", categoryKey: "other", function: "percent", desc: "Percent character trait tokens" }, + { id: "in_past_tense", title: "In Past Tense", categoryKey: "other", function: "percent", desc: "Percent past tense scope" }, + { id: "explicit_claims", title: "Explicit Claims", categoryKey: "other", function: "percent", desc: "Percent explicit claims" }, + { id: "social_awareness", title: "Social Awareness", categoryKey: "other", function: "percent", desc: "Percent social awareness" }, +]; + +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%)" }; + +/* ============================================================= + HELPERS + ============================================================= */ + +const getStudentById = (id) => { + const init = (id || "ST").slice(0, 2).toUpperCase(); + return { + id, + name: id ? String(id).replace(/[-_]/g, " ") : "Student", + initials: init, + avatarColor: "bg-gray-100", + textColor: "text-gray-700", + gradeLevel: "—", + section: "—", + }; +}; + +const median = (arr) => { + if (!arr.length) return 0; + const a = [...arr].sort((x, y) => x - y); + const mid = Math.floor(a.length / 2); + return a.length % 2 ? a[mid] : (a[mid - 1] + a[mid]) / 2; +}; +const mean = (a) => a.reduce((s, x) => s + x, 0) / Math.max(1, a.length); +const std = (a) => { + if (a.length < 2) return 0; + const m = mean(a); + return Math.sqrt(mean(a.map((v) => (v - m) * (v - m)))); +}; +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 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 formatDocTitle = (docId, meta) => { + 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 = (data2, studentID, docId) => { + const s = data2?.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; + return null; +}; + +const getDocTextFromLO = (data2, studentID, docId) => { + const doc = getDocObjFromLO(data2, 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 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]); + 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)); + 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]; + + 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; + } + } + covered += curE - curS; + + return (covered / L) * 100; +} + +const buildEssaysFromDocs = ({ studentID, documentIDS, docsObj, data2 }) => { + 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 dateISO = lastAccessMs ? new Date(lastAccessMs).toISOString() : ""; + const dateObj = lastAccessMs ? new Date(lastAccessMs) : null; + + const dateStr = dateObj + ? `${monthsShort[dateObj.getMonth()]} ${dateObj.getDate()}, ${dateObj.getFullYear()}` + : "—"; + + const category = dateObj ? `${monthsLong[dateObj.getMonth()]} ${dateObj.getFullYear()}` : "Unknown date"; + + const doc = getDocObjFromLO(data2, studentID, docId); + const text = + doc?.text && typeof doc.text === "string" ? doc.text : getDocTextFromLO(data2, studentID, docId); + + const wordsArr = wordSplit(text); + const words = wordsArr.length; + + const lowerWords = wordsArr.map((w) => w.toLowerCase()); + const uniqueWords = new Set(lowerWords).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; + + return { + id: docId, + title: formatDocTitle(docId, meta), + date: dateStr, + dateISO: dateISO || new Date(0).toISOString(), + category, + words, + uniqueWords, + lexicalDiversity, + avgSentenceLen, + grade: "—", + preview: makePreviewFromText(text), + tags: ["Document"], // ✅ never empty + _doc: doc || { text }, + }; + }); + + return out.sort((a, b) => new Date(b.dateISO) - new Date(a.dateISO)); +}; + +/* ============================================================= + child: fetch docs by id & lift to parent + ============================================================= */ + +function StudentDocsByIdFetcher({ studentID, documentIDS, setData2, setErrors2, setConnection2 }) { + const dataScope2 = useMemo(() => { + return { + wo: { + execution_dag: "writing_observer", + target_exports: ["single_student_doc_by_id"], + kwargs: { + course_id: "12345678901", + student_id: documentIDS.map(() => ({ user_id: studentID })), + document: documentIDS.map((doc_id) => ({ doc_id })), + }, + }, + }; + }, [studentID, documentIDS]); + + const { data, errors, connection } = useLOConnectionDataManager({ + url: "ws://localhost:8888/wsapi/communication_protocol", + dataScope: dataScope2, + }); + + const prevSigsRef = useRef({ dataSig: "", errSig: "", connSig: "" }); + + const dataSig = useMemo(() => { + const studentsCount = + data?.students && typeof data.students === "object" ? Object.keys(data.students).length : 0; + const topKeys = data && typeof data === "object" ? Object.keys(data).length : 0; + return `top:${topKeys}|students:${studentsCount}`; + }, [data]); + + const errSig = useMemo(() => { + if (!errors) return "noerr"; + if (Array.isArray(errors)) return `errarr:${errors.length}`; + if (typeof errors === "object") return `errobj:${Object.keys(errors).length}`; + return "err:1"; + }, [errors]); + + const connSig = useMemo(() => { + if (!connection) return "noconn"; + const s = connection.status ?? connection.readyState ?? "unknown"; + const u = connection.url ?? ""; + return `status:${s}|url:${u}`; + }, [connection]); + + useEffect(() => { + const prev = prevSigsRef.current; + + if (prev.dataSig !== dataSig) { + prev.dataSig = dataSig; + setData2(data); + } + if (prev.errSig !== errSig) { + prev.errSig = errSig; + setErrors2(errors); + } + if (prev.connSig !== connSig) { + prev.connSig = connSig; + setConnection2(connection); + } + }, [dataSig, errSig, connSig, data, errors, connection, setData2, setErrors2, setConnection2]); + + return null; +} + +/* ============================================================= + 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); + + // quick range active state (for the pill buttons below) + const [activeQuickRange, setActiveQuickRange] = useState("all"); // "all" | "3mo" | "6mo" | "9mo" + + // ------ LO connection #1: student_with_docs ------ + const dataScope = useMemo(() => { + return { + wo: { + execution_dag: "writing_observer", + target_exports: ["student_with_docs"], + kwargs: { + course_id: "12345678901", + student_id: [{ user_id: studentID }], + }, + }, + }; + }, [studentID]); + + const { data } = useLOConnectionDataManager({ + url: "ws://localhost:8888/wsapi/communication_protocol", + dataScope, + }); + + const docsObj = data?.students?.[studentID]?.docs || {}; + + const documentIDS = useMemo(() => { + const ids = Object.keys(docsObj || {}); + ids.sort(); + return ids; + }, [docsObj]); + + // ------ LO connection #2: single_student_doc_by_id (lifted state) ------ + const [data2, setData2] = useState(null); + const [errors2, setErrors2] = useState(null); + const [connection2, setConnection2] = useState(null); + + const essays = useMemo(() => { + return buildEssaysFromDocs({ + studentID, + documentIDS, + docsObj, + data2, + }); + }, [studentID, documentIDS, docsObj, data2]); + + // Build metricSections INSIDE component (stable, and uses icon components) + 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 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, + }; + }, []); + + const essaysAscAll = useMemo(() => { + return [...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 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; + }); + 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] + ); + + const getGenreSegments = () => { + 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; + } + } + segs.push({ x1: start, x2: essaysInRangeAsc.length - 1, genre: current }); + return segs; + }; + const genreSegments = useMemo(() => getGenreSegments(), [essaysInRangeAsc]); + + const filteredEssaysCompare = useMemo(() => { + 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)) + ); + }; + + 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(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 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"; + } + }; + + const tagRef = useRef(null); + useEffect(() => { + const onClick = (e) => { + if (tagRef.current && !tagRef.current.contains(e.target)) setTagOpen(false); + }; + window.addEventListener("click", onClick); + return () => window.removeEventListener("click", onClick); + }, []); + + // only All time / 3 mo / 6 mo / 9 mo, and show active + const applyQuickRange = (key) => { + setActiveQuickRange(key); + + 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 start = new Date(last); + + if (key === "3mo") start.setMonth(start.getMonth() - 3); + if (key === "6mo") start.setMonth(start.getMonth() - 6); + if (key === "9mo") start.setMonth(start.getMonth() - 9); + + setStartDate(start.toISOString().slice(0, 10)); + setEndDate(end.toISOString().slice(0, 10)); + }; + + // If user manually edits dates, reflect that by clearing the “active” pill highlight + const onStartDateChange = (v) => { + setStartDate(v); + setActiveQuickRange(""); // custom + }; + const onEndDateChange = (v) => { + setEndDate(v); + setActiveQuickRange(""); // custom + }; + + const clearFilters = () => { + setFilterTags([]); + setTagQuery(""); + setSearch(""); + }; + const isAnyFilter = filterTags.length > 0 || search.trim().length > 0; + + const handleEssaySelect = (essayId) => { + if (mode !== MODES.COMPARE) return; + setSelectedEssays((prev) => { + if (prev.includes(essayId)) return prev.filter((id) => id !== essayId); + if (prev.length >= 2) return prev; + return [...prev, essayId]; + }); + }; + + return ( +
+ {documentIDS.length > 0 && ( + + )} + +
+ + +
+
+
+

Essays in Portfolio

+ +
+

{documentIDS.length}

+

Available documents

+
+ +
+
+

Most Improved

+ +
+

+

Uses selected growth metrics

+
+ +
+
+

Biggest Decline

+ +
+

+

Uses selected growth metrics

+
+ +
+
+

Avg. Time on Task

+ +
+

+

(Not available yet)

+
+ +
+
+

Writing Fluency

+ +
+

+

(Not available yet)

+
+
+ +
+
+ + + +
+
+ + {mode === MODES.GROWTH && ( +
+
+
+ + onStartDateChange(e.target.value)} + className="border-0 text-sm focus:outline-none" + aria-label="Start date" + /> + + onEndDateChange(e.target.value)} + className="border-0 text-sm focus:outline-none" + aria-label="End date" + /> +
+ + {/* Apply active styling HERE (this is the date filter you referenced) */} +
+ + + + + + + +
+
+
+ )} +
+ +
+ {mode === MODES.GROWTH ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/modules/portfolio_diff/src/app/students/components/StudentsIndex.js b/modules/portfolio_diff/src/app/students/components/StudentsIndex.js new file mode 100644 index 000000000..38b30a76c --- /dev/null +++ b/modules/portfolio_diff/src/app/students/components/StudentsIndex.js @@ -0,0 +1,659 @@ +"use client"; + +import { + CheckCircle, + ChevronDown, + ChevronUp, + Clock, + Download, + FileText, + Search, + Users, + AlertTriangle, + TrendingUp, + Minus, + Loader, +} from "lucide-react"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { + useLOConnectionDataManager, + LO_CONNECTION_STATUS, +} from "lo_event/lo_event/lo_assess/components/components.jsx"; + +const SEVEN_DAYS_SECS = 7 * 24 * 60 * 60; + +function deepMerge(a, b) { + const aObj = a && typeof a === "object" && !Array.isArray(a); + const bObj = b && typeof b === "object" && !Array.isArray(b); + if (aObj && bObj) { + const out = { ...a }; + for (const k of Object.keys(b)) out[k] = deepMerge(a[k], b[k]); + return out; + } + return b; +} + +function latestLastAccessSec(availableDocuments) { + if (!availableDocuments) return null; + const docs = Object.values(availableDocuments); + if (!docs.length) return null; + + let max = null; + for (const d of docs) { + const v = d?.last_access; + if (v == null) continue; + const n = Number(v); + if (Number.isNaN(n)) continue; + if (max == null || n > max) max = n; + } + return max; +} + +function formatLastActivity(lastAccessSec) { + if (!lastAccessSec) return "—"; + + const nowSec = Date.now() / 1000; + const diffSec = Math.max(0, nowSec - Number(lastAccessSec)); + + const mins = Math.floor(diffSec / 60); + const hrs = Math.floor(diffSec / 3600); + const days = Math.floor(diffSec / 86400); + + if (mins < 1) return "Just now"; + if (mins < 60) return `${mins} min ago`; + if (hrs < 24) return `${hrs} hr${hrs === 1 ? "" : "s"} ago`; + if (days <= 14) return `${days} day${days === 1 ? "" : "s"} ago`; + + try { + return new Date(Number(lastAccessSec) * 1000).toLocaleDateString(); + } catch { + return "—"; + } +} + +function riskBadgeClass(risk) { + if (risk === "At-Risk") return "text-amber-800 bg-amber-100 ring-1 ring-inset ring-amber-200"; + if (risk === "Top Growth") return "text-emerald-800 bg-emerald-100 ring-1 ring-inset ring-emerald-200"; + if (risk === "Under Review") return "text-gray-800 bg-gray-100 ring-1 ring-inset ring-gray-200"; + return "text-gray-700 bg-gray-100 ring-1 ring-inset ring-gray-200"; +} + +function riskIcon(risk) { + if (risk === "At-Risk") return ; + if (risk === "Top Growth") return ; + if (risk === "Under Review") return ; + return ; +} + +export default function WritingPortfolioDashboard() { + // table controls + const [query, setQuery] = useState(""); + const [focusFilter, setFocusFilter] = useState("All"); // All | At-Risk | Top Growth + const [pageSize, setPageSize] = useState(10); + const [page, setPage] = useState(1); + const [sortKey, setSortKey] = useState("id"); + const [sortDir, setSortDir] = useState("asc"); + const [selected, setSelected] = useState(new Set()); + + // ------ LO connection setup ------ + const decoded = {}; + decoded.course_id = "12345678901"; + decoded.student_id = [{ user_id: "tc-testcase-Alberta" }]; + decoded.document = [{ doc_id: "fake-google-doc-id-1" }]; + decoded.nlp_options = ["academic_language"]; + + const dataScope = { + wo: { + execution_dag: "writing_observer", + target_exports: ["roster", "document_list"], + kwargs: decoded, + }, + }; + + const { data, errors, connection } = useLOConnectionDataManager({ + url: "ws://localhost:8888/wsapi/communication_protocol", + dataScope, + }); + + // Merge roster + document_list if they are separate; otherwise use data.students if already merged. + const studentsMap = useMemo(() => { + if (!data) return {}; + + // In many LO setups, patches are already applied into `data.students` + const flatStudents = data?.students ?? {}; + if (flatStudents && Object.keys(flatStudents).length) return flatStudents; + + const rosterStudents = data?.wo?.roster?.students ?? {}; + const docsStudents = data?.wo?.document_list?.students ?? {}; + + const merged = { ...rosterStudents }; + for (const [k, v] of Object.entries(docsStudents)) { + merged[k] = deepMerge(merged[k] ?? {}, v ?? {}); + } + return merged; + }, [data]); + + // Build rows for the table + const DATA = useMemo(() => { + const list = Object.values(studentsMap || {}); + return list.map((s, idx) => { + const profile = s?.profile ?? {}; + const nameObj = profile?.name ?? {}; + const availableDocuments = s?.availableDocuments ?? {}; + const docCount = Object.keys(availableDocuments).length; + + const lastAccessSec = latestLastAccessSec(availableDocuments); + const lastActivity = formatLastActivity(lastAccessSec); + + const risk = "Under Review"; + + return { + id: s?.user_id ?? `student-${idx + 1}`, + firstname: nameObj?.given_name || `Student ${idx + 1}`, + lastname: nameObj?.family_name || "", + avatar: "", + documents: docCount, + lastAccessSec: lastAccessSec ?? null, // for sorting + lastActivity, + risk, + }; + }); + }, [studentsMap]); + + // Cards metrics + const metrics = useMemo(() => { + const totalStudents = DATA.length; + + let totalDocuments = 0; + let studentsWithDocs = 0; + let active7d = 0; + + const nowSec = Date.now() / 1000; + + for (const s of DATA) { + const docs = Number(s.documents) || 0; + totalDocuments += docs; + if (docs > 0) studentsWithDocs += 1; + + const last = s.lastAccessSec ? Number(s.lastAccessSec) : null; + if (last && nowSec - last <= SEVEN_DAYS_SECS) active7d += 1; + } + + const coverage = totalStudents === 0 ? 0 : Math.round((studentsWithDocs / totalStudents) * 100); + + return { totalStudents, totalDocuments, active7d, coverage }; + }, [DATA]); + + const toggleSort = (key) => { + if (key === sortKey) setSortDir((d) => (d === "asc" ? "desc" : "asc")); + else { + setSortKey(key); + setSortDir("asc"); + } + }; + + const filtered = useMemo(() => { + let rows = DATA.slice(); + + // search + if (query.trim()) { + const q = query.toLowerCase(); + rows = rows.filter((r) => { + const full = `${r.firstname || ""} ${r.lastname || ""}`.toLowerCase(); + return full.includes(q) || String(r.id).toLowerCase().includes(q); + }); + } + + // focus tabs now drive the Risk Level column and filtering + if (focusFilter === "At-Risk") rows = rows.filter((r) => r.risk === "At-Risk"); + if (focusFilter === "Top Growth") rows = rows.filter((r) => r.risk === "Top Growth"); + + // sorting + rows.sort((a, b) => { + if (sortKey === "lastActivity") { + const aT = a.lastAccessSec ?? -Infinity; + const bT = b.lastAccessSec ?? -Infinity; + if (aT < bT) return sortDir === "asc" ? -1 : 1; + if (aT > bT) return sortDir === "asc" ? 1 : -1; + return 0; + } + + const A = a[sortKey]; + const B = b[sortKey]; + + if (A < B) return sortDir === "asc" ? -1 : 1; + if (A > B) return sortDir === "asc" ? 1 : -1; + return 0; + }); + + return rows; + }, [DATA, query, focusFilter, sortKey, sortDir]); + + // pagination + const total = filtered.length; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const safePage = Math.min(page, totalPages); + const start = (safePage - 1) * pageSize; + const rows = filtered.slice(start, start + pageSize); + + const allIdsOnPage = (pageRows) => pageRows.map((s) => s.id); + const allSelectedOnPage = rows.length > 0 && rows.every((r) => selected.has(r.id)); + + // Loading state (improved) + const isConnecting = + connection?.status === LO_CONNECTION_STATUS.CONNECTING || + connection?.status === LO_CONNECTION_STATUS.RECONNECTING; + + const hasErrors = errors && Object.keys(errors).length > 0; + const hasStudents = (DATA?.length ?? 0) > 0; + + const showLoading = !hasErrors && !hasStudents; + + console.log(hasErrors, isConnecting, hasStudents, data); + console.log(DATA); + + const renderLoading = () => ( +
+
+
+
+ {isConnecting ? "Connecting to data source…" : "Loading roster…"} +
+
+ +
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+ +
+
+ {[0, 1, 2, 3, 4].map((i) => ( +
+ ))} +
+
+ ); + + const renderError = () => ( +
+
Failed to load dashboard data
+
{JSON.stringify(errors, null, 2)}
+
+ ); + + // Export selected rows as CSV + const exportSelectedToCsv = () => { + const esc = (v) => { + const s = v == null ? "" : String(v); + const needsQuotes = /[",\n\r]/.test(s); + const out = s.replace(/"/g, '""'); + return needsQuotes ? `"${out}"` : out; + }; + + const headers = ["ID", "First Name", "Last Name", "Documents", "Last Activity", "Risk Level"]; + + const selectedRows = DATA.filter((r) => selected.has(r.id)); + const lines = [ + headers.map(esc).join(","), + ...selectedRows.map((r) => + [r.id, r.firstname, r.lastname, r.documents, r.lastActivity, r.risk].map(esc).join(",") + ), + ]; + + const csv = lines.join("\r\n"); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + + const a = document.createElement("a"); + a.href = url; + a.download = `students_export_${new Date().toISOString().slice(0, 10)}.csv`; + document.body.appendChild(a); + a.click(); + a.remove(); + + URL.revokeObjectURL(url); + }; + + // UPDATED: bulk bar shows ONLY Export (and performs CSV download) + const renderBulkBar = () => ( +
+
+ {selected.size} selected +
+
+ +
+
+ ); + + const renderStatsRow = () => ( +
+ {/* Total Students */} + + + {/* Total Documents */} + + + {/* Active last 7 days */} + + + {/* Roster coverage */} + +
+ ); + + const renderHeaderBar = () => ( +
+
+
+

Student Overview & Signals

+

Search for student or use the filter tabs to focus on a subset and review students' writing activities.

+
+ + {/* Tabs mapped to risk */} +
+ {["All", "At-Risk", "Top Growth"].map((opt) => ( + + ))} +
+
+
+ ); + + const renderTable = () => ( +
+
+
+

Students

+ Total: {total.toLocaleString()} +
+ +
+
+ + { + setQuery(e.target.value); + setPage(1); + }} + onKeyDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + className="w-full md:w-64 pl-8 pr-3 py-2 text-sm bg-white border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-emerald-500" + placeholder="Search name or ID…" + autoComplete="off" + autoCorrect="off" + spellCheck={false} + /> +
+
+
+ + {selected.size > 0 && renderBulkBar()} + +
+ + + + + + {[ + { key: "id", label: "ID", align: "text-left" }, + { key: "firstname", label: "First Name", align: "text-left" }, + { key: "lastname", label: "Last Name", align: "text-left" }, + { key: "documents", label: "Documents", align: "text-center" }, + { key: "lastActivity", label: "Last Activity", align: "text-left" }, + { key: "risk", label: "Risk Level", align: "text-left" }, + ].map((c) => ( + + ))} + + + + + + + {rows.map((student) => ( + + + + + + + + + + + + + + + + + + ))} + + {rows.length === 0 && ( + + + + )} + +
+ { + const next = new Set(selected); + if (e.target.checked) allIdsOnPage(rows).forEach((id) => next.add(id)); + else allIdsOnPage(rows).forEach((id) => next.delete(id)); + setSelected(next); + }} + /> + + + + Actions +
+ { + const next = new Set(selected); + if (e.target.checked) next.add(student.id); + else next.delete(student.id); + setSelected(next); + }} + /> + + {student.id} + +
+
+ {student.avatar} +
+
{student.firstname}
+
+
+ {student.lastname} + + {student.documents} + + {student.lastActivity} + + + {riskIcon(student.risk)} + {student.risk} + + + + + +
+ No students match your filters. +
+
+ +
+
+
+ + {total === 0 ? "0" : `${start + 1}–${Math.min(start + pageSize, total)} of ${total.toLocaleString()}`} + + + + +
+ +
+ + + Page {safePage} of {totalPages} + + +
+
+
+
+ ); + + return ( +
+ {/* Welcome header */} +
+
+
+
+

+ Welcome back, Ms. Rodriguez! 👋 +

+

A snapshot of your class writing activity and progress.

+ +
+ 📚 English 10A + 📅 Fall 2025 +
+
+
+
+
+ + {/* Main content */} +
+ {hasErrors ? renderError() : showLoading ? renderLoading() : ( + <> + {renderStatsRow()} + {renderHeaderBar()} + {renderTable()} + + )} +
+
+ ); +} diff --git a/modules/portfolio_diff/src/app/students/page.js b/modules/portfolio_diff/src/app/students/page.js new file mode 100644 index 000000000..b39d86fb7 --- /dev/null +++ b/modules/portfolio_diff/src/app/students/page.js @@ -0,0 +1,23 @@ +// app/students/page.js +"use client"; + +import { Suspense } from "react"; +import { useSearchParams } from "next/navigation"; +import StudentsIndex from "./components/StudentsIndex"; +import StudentDetail from "./components/StudentDetail"; + +function StudentsPageContent() { + const sp = useSearchParams(); + const studentId = sp.get("student_id"); + + if (!studentId) return ; + return ; +} + +export default function StudentsPage() { + return ( + Loading…
}> + + + ); +} diff --git a/modules/portfolio_diff/src/app/utils/data.js b/modules/portfolio_diff/src/app/utils/data.js new file mode 100644 index 000000000..23027c4c0 --- /dev/null +++ b/modules/portfolio_diff/src/app/utils/data.js @@ -0,0 +1,359 @@ +export const students = [ + { + id: 1, + name: "Robert Fox", + period: "Period 1A", + avgScore: 85.3, + essays: 12, + mostImproved: "Grammar", + avatar: "RF", + gender: "Male", + age: 17, + class: "1A", + missingDays: 0, + }, + { + id: 2, + name: "Marvin McKinney", + period: "Period 1B", + avgScore: 92.1, + essays: 15, + mostImproved: "Argumentation", + avatar: "MM", + gender: "Male", + age: 6, + class: "1B", + missingDays: 0, + }, + { + id: 3, + name: "Darrell Steward", + period: "Period 4C", + avgScore: 78.9, + essays: 8, + mostImproved: "Structure", + avatar: "DS", + gender: "Female", + age: 10, + class: "4C", + missingDays: 6, + }, + { + id: 4, + name: "Savannah Nguyen", + period: "Period 4C", + avgScore: 88.7, + essays: 11, + mostImproved: "Transitions", + avatar: "SN", + gender: "Male", + age: 11, + class: "4C", + missingDays: 6, + }, + { + id: 5, + name: "Dianne Russell", + period: "Period 11B", + avgScore: 91.2, + essays: 14, + mostImproved: "Grammar", + avatar: "DR", + gender: "Female", + age: 16, + class: "11B", + missingDays: 10, + }, + { + id: 6, + name: "Cody Fisher", + period: "Period 4A", + avgScore: 82.4, + essays: 9, + mostImproved: "Structure", + avatar: "CF", + gender: "Female", + age: 11, + class: "4A", + missingDays: 20, + }, + { + id: 7, + name: "Leslie Alexander", + period: "Period 5A", + avgScore: 87.6, + essays: 13, + mostImproved: "Argumentation", + avatar: "LA", + gender: "Female", + age: 12, + class: "5A", + missingDays: 0, + }, + { + id: 8, + name: "Albert Flores", + period: "Period 7B", + avgScore: 84.1, + essays: 10, + mostImproved: "Grammar", + avatar: "AF", + gender: "Male", + age: 14, + class: "7B", + missingDays: 0, + }, + { + id: 9, + name: "Ralph Edwards", + period: "Period 11C", + avgScore: 89.5, + essays: 16, + mostImproved: "Transitions", + avatar: "RE", + gender: "Male", + age: 17, + class: "11C", + missingDays: 1, + }, + { + id: 10, + name: "Darlene Robertson", + period: "Period 1A", + avgScore: 86.8, + essays: 12, + mostImproved: "Structure", + avatar: "DR", + gender: "Female", + age: 18, + class: "1A", + missingDays: 0, + }, +]; + +export const assignments = [ + { + id: 1, + title: "Persuasive Essay", + submissions: "26/28", + avgScore: 84.3, + dueDate: "May 12", + status: "Graded", + statusColor: "text-green-600 bg-green-50", + }, + { + id: 2, + title: "Book Report #2", + submissions: "22/28", + avgScore: 79.6, + dueDate: "May 5", + status: "Grading", + statusColor: "text-orange-600 bg-orange-50", + }, + { + id: 3, + title: "Poetry Analysis", + submissions: "28/28", + avgScore: 88.9, + dueDate: "Apr 30", + status: "Graded", + statusColor: "text-green-600 bg-green-50", + }, + { + id: 4, + title: "Research Paper Draft", + submissions: "18/28", + avgScore: 76.2, + dueDate: "May 20", + status: "Open", + statusColor: "text-blue-600 bg-blue-50", + }, + { + id: 5, + title: "Creative Writing", + submissions: "24/28", + avgScore: 91.4, + dueDate: "Apr 25", + status: "Graded", + statusColor: "text-green-600 bg-green-50", + }, + { + id: 6, + title: "Compare & Contrast Essay", + submissions: "28/28", + avgScore: 82.7, + dueDate: "Apr 15", + status: "Graded", + statusColor: "text-green-600 bg-green-50", + }, +]; + +export const baseTags = [ + "Argumentative", + "Narrative", + "Personal", + "Analytical", + "Reflective", + "Opinion", + "Education", + "Descriptive", + "Experience", + "Economics", + "Research", +]; + +// genre colors for shading +export const GENRE_COLORS = { + Argumentative: "#ecfccb", + Narrative: "#e0e7ff", + Analytical: "#e0f2fe", + Reflective: "#fae8ff", + Descriptive: "#fef9c3", + Personal: "#fef3c7", + Opinion: "#f5f5f5", + Education: "#f0fdf4", + Experience: "#fff1f2", + Economics: "#eef2ff", + Research: "#f1f5f9", +}; + +// ---- Metric definitions (adds longPauseRatioPct + clearer labels) ---- +export const METRIC_DEFS = [ + // PRODUCT + { + key: "wordCount", + label: "Word Count", + category: "Product", + unit: "words", + get: (e) => e.words, + }, + { + key: "uniqueWords", + label: "Unique Words", + category: "Product", + unit: "words", + get: (e) => e.uniqueWords, + }, + { + key: "lexicalDiversity", + label: "Lexical Diversity", + category: "Product", + unit: "%", + get: (e) => e.lexicalDiversity, + }, + { + key: "avgSentenceLen", + label: "Avg Sentence Length", + category: "Product", + unit: "words/sent", + get: (e) => e.avgSentenceLen, + }, + // PROCESS + { + key: "wpm", + label: "Fluency (WPM in bursts)", + category: "Process", + unit: "wpm", + get: (e) => e.wpm, + }, + { + key: "burst", + label: "Burst Length", + category: "Process", + unit: "words", + get: (e) => Math.max(1, Math.round(e.burst / 1)), + }, + { + key: "avgPauseSec", + label: "Avg Pause (sec)", + category: "Process", + unit: "sec", + get: (e) => e.avgPauseSec, + }, + { + key: "longPauseRatioPct", + label: "Long-Pause Ratio (%)", + category: "Process", + unit: "%", + get: (e) => { + const total = Math.max(1, e.pauses + e.longPauses); + return Number(((e.longPauses / total) * 100).toFixed(1)); + }, + }, + // REVISION + { + key: "revisionDepth", + label: "Revision Depth (count)", + category: "Revision", + unit: "revisions", + get: (e) => e.revisionDepth, + }, + { + key: "deletions", + label: "Deletions", + category: "Revision", + unit: "ops", + get: (e) => e.deletions, + }, + { + key: "insertions", + label: "Insertions", + category: "Revision", + unit: "ops", + get: (e) => e.insertions, + }, + { + key: "editRatioPct", + label: "Revision Density (% per 100 words)", + category: "Revision", + unit: "%", + get: (e) => e.editRatioPct, + }, + // OUTCOME + { + key: "gradePct", + label: "Grade (%)", + category: "Quality", + unit: "%", + get: (e) => gradeToPct(e.grade), + }, +]; + +export const metricSections = [ + { + name: "Product Metrics", + keys: ["wordCount", "uniqueWords", "lexicalDiversity", "avgSentenceLen"], + }, + { + name: "Process Metrics", + keys: ["wpm", "burst", "avgPauseSec", "longPauseRatioPct"], + }, + { + name: "Revision Metrics", + keys: ["revisionDepth", "deletions", "insertions", "editRatioPct"], + }, + { name: "Quality & Outcomes", keys: ["gradePct"] }, +]; + +export const DEFAULT_METRICS = [ + "wpm", + "longPauseRatioPct", + "burst", + "editRatioPct", + "gradePct", +]; + +const gradeToPct = (g) => { + const map = { + "A+": 97, + A: 94, + "A-": 91, + "B+": 88, + B: 85, + "B-": 82, + "C+": 78, + C: 75, + "C-": 72, + }; + return map[g] ?? 80; +}; \ No newline at end of file diff --git a/modules/portfolio_diff/src/app/utils/navigation.js b/modules/portfolio_diff/src/app/utils/navigation.js new file mode 100644 index 000000000..5b687c67a --- /dev/null +++ b/modules/portfolio_diff/src/app/utils/navigation.js @@ -0,0 +1,22 @@ +/** + * Force a full page navigation to a route with query params. + * This intentionally avoids Next.js router to prevent `_rsc` fetches. + * + * @param {string} path - Relative path (e.g. "students/compare") + * @param {Object} queryParams - Key/value pairs for query string + */ +export function navigateTo(path, queryParams = {}) { + if (typeof window === "undefined") return; + + const query = Object.entries(queryParams) + .filter(([, value]) => value !== undefined && value !== null && value !== "") + .map(([key, value]) => { + return `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`; + }) + .join("&"); + + const url = query ? `${path}?${query}` : path; + + // Force full navigation (prevents Next.js from app-router data fetching) + window.location.assign("/wo_portfolio_diff/portfolio_diff/" + url); +} diff --git a/modules/wo_portfolio_diff/MANIFEST.in b/modules/wo_portfolio_diff/MANIFEST.in new file mode 100644 index 000000000..234482533 --- /dev/null +++ b/modules/wo_portfolio_diff/MANIFEST.in @@ -0,0 +1 @@ +include wo_portfolio_diff/assets/* diff --git a/modules/wo_portfolio_diff/README.md b/modules/wo_portfolio_diff/README.md new file mode 100644 index 000000000..d59b431b7 --- /dev/null +++ b/modules/wo_portfolio_diff/README.md @@ -0,0 +1,146 @@ +# Learning Observer Example Module + +Welcome to the Learning Observer (LO) example module. This document +will detail everything need to create a module for the LO. + +## packaage structure + +```bash +module/ + wo_portfolio_diff/ + assets/ + ... + module.py + reducers.py + dash_dashboards.py + utils.py + tests/ + test_utils.py + MANIFEST.in + setup.cfg + pyproject.toml + VERSION +``` + +### setup.cfg + +Notice we include the following items in our `setup.cfg` file. + +```cfg +[options.entry_points] +lo_modules = + wo_portfolio_diff = wo_portfolio_diff.module + +[options.package_data] +wo_portfolio_diff = helpers/* +``` + +The `lo_modules` entry point tells Learning Observer to treat `wo_portfolio_diff.module` as a pluggable application. + +The package data section is where we include additional directories we want included in the build. + +### pyproject.toml + +The `pyproject.toml` file specifies the build system, which in this case is `setuptools`. It works alongside the `setup.cfg` file to provide metadata for the installation process. + +### MANIFEST.in + +The manifest specifies which files to include during Python packaging. This specifies the additional non-python files we want included. If you do not have additional files needed, this file is unnecessary. + +For modules with Dash-made dashboards, this will typically include a relative path to the assets folder. + +### VERSION + +The VERSION file specifies the version of the package. Each one defaults to `0.1.0`. + +### module.py + +This file defines everything about the module. See the dedicated section below. + +## Defining a module (module.py) + +Modules can include a variety items. This will cover each item and its purpose on the system. + +### NAME + +This one is pretty self explanatory. Give the module a short name to refer to it by. + +### EXECUTION_DAG + +The execution directed acyclic graph (DAG) is how we interact with the communication protocol. + +See `wo_portfolio_diff/module.py:EXECUTION_DAG` for a detailed example. + +### REDUCERS + +Reducers to define on the system. These are functions that will run over incoming events from students. + +See `wo_portfolio_diff/module.py:REDUCERS` for a detailed example. + +### DASH_PAGES + +Dashboards built using the Dash framework should be defined here. + +See `wo_portfolio_diff/module.py:DASH_PAGES` for a detailed example. + +### COURSE_DASHBOARDS + +The registered course dashboards are provided to the users for navigating around dashboards, such as on their Home screen. + +See `wo_portfolio_diff/module.py:COURSE_DASHBOARDS` for a detailed example. + +Note that the student counterpart, `STUDENT_DASHBOARDS`, exists. + +### THIRD_PARTY + +The third party items are downloaded and included when serving items from the module. This is usually used for including extra Javascript or CSS files. + +```python +THIRD_PARTY = { + 'name_of_item': { + 'url': 'url_to_third_party_tool', + 'hash': 'hash_of_download_OR_dict_of_versions_and_hashes' + } +} +``` + +### STATIC_FILE_GIT_REPOS + +We're still figuring this out, but we'd like to support hosting static files from the git repo of the module. +This allows us to have a Merkle-tree style record of which version is deployed in our log files. + +A common use case for this is serving static `.html` and `.js` files for your module. + +```python +STATIC_FILE_GIT_REPOS = { + 'repo_name': { + 'url': 'url_to_repo', + 'prefix': 'relative/path/to/directory', + # Branches we serve. This can either be a whitelist (e.g. which ones + # are available) or a blacklist (e.g. which ones are blocked) + 'whitelist': ['master'] + } +} +``` + +### EXTRA_VIEWS + +These are extra views to publish to the user. Currently, we only support `.json` files. + +```python +EXTRA_VIEWS = [{ + 'name': 'Name of view', + 'suburl': 'view-suburl', + 'static_json': python_dictionary_to_return +}] +``` + +## Creating a reducer (reducers.py) + +Reducers are ran over incoming student events. They can be defined using a decorator in the `learning_observer.stream_analytics` module. + +Each reducer should take the incoming `event` and the previous `internal_state` as parameters and return 2 new state objects. + +## Creating dashboards with Dash (dash_dashboard.py) + +Dash pages consist of a layout and callback functions. See `dash_dashboard.py` for a more detailed overview. diff --git a/modules/wo_portfolio_diff/VERSION b/modules/wo_portfolio_diff/VERSION new file mode 100644 index 000000000..66ede4dd3 --- /dev/null +++ b/modules/wo_portfolio_diff/VERSION @@ -0,0 +1 @@ +0.1.0+2026.01.02T18.05.55.3NZ.3385b46.dami.portfolio diff --git a/modules/wo_portfolio_diff/pyproject.toml b/modules/wo_portfolio_diff/pyproject.toml new file mode 100644 index 000000000..8fe2f47af --- /dev/null +++ b/modules/wo_portfolio_diff/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/modules/wo_portfolio_diff/setup.cfg b/modules/wo_portfolio_diff/setup.cfg new file mode 100644 index 000000000..906c9a554 --- /dev/null +++ b/modules/wo_portfolio_diff/setup.cfg @@ -0,0 +1,14 @@ +[metadata] +name = Writing Observer Portfolio Diff +version = file:VERSION +description = Use this as a base template for creating new modules on the Learning Observer. + +[options] +packages = wo_portfolio_diff + +[options.package_data] +wo_portfolio_diff = assets/* + +[options.entry_points] +lo_modules = + wo_portfolio_diff = wo_portfolio_diff.module diff --git a/modules/wo_portfolio_diff/test.sh b/modules/wo_portfolio_diff/test.sh new file mode 100755 index 000000000..185ef70a4 --- /dev/null +++ b/modules/wo_portfolio_diff/test.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# modules/wo_portfolio_diff/test.sh +echo "=================================================" +echo "Running tests for Writing Observer Portfolio Diff" +echo "=================================================" + +# Modify the commands below to fit your testing needs +echo "Running traditional pytests" +pytest tests/ +echo "Running doctests" +pytest --doctest-modules diff --git a/modules/wo_portfolio_diff/tests/test_utils.py b/modules/wo_portfolio_diff/tests/test_utils.py new file mode 100644 index 000000000..259118105 --- /dev/null +++ b/modules/wo_portfolio_diff/tests/test_utils.py @@ -0,0 +1,6 @@ +import wo_portfolio_diff.utils as unit + +def test_increment(): + n = 1 + result = unit.increment(n) + assert result == n + 1 From b995bb5fbd098ef58eed8dc9931813059cef2815 Mon Sep 17 00:00:00 2001 From: JohnDamilola Date: Fri, 2 Jan 2026 13:41:47 -0500 Subject: [PATCH 2/3] Add new endpoints for portfolio dashboard --- VERSION | 2 +- modules/writing_observer/VERSION | 2 +- .../writing_observer/module.py | 40 +++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 66ede4dd3..ef015be66 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2026.01.02T18.05.55.3NZ.3385b46.dami.portfolio +0.1.0+2026.01.02T18.41.47.3NZ.2a3d8a9.dami.portfolio diff --git a/modules/writing_observer/VERSION b/modules/writing_observer/VERSION index c98f3678a..ef015be66 100644 --- a/modules/writing_observer/VERSION +++ b/modules/writing_observer/VERSION @@ -1 +1 @@ -0.1.0+2025.08.07T19.58.28.937Z.ed90597d.berickson.202507.new.lti.updates +0.1.0+2026.01.02T18.41.47.3NZ.2a3d8a9.dami.portfolio diff --git a/modules/writing_observer/writing_observer/module.py b/modules/writing_observer/writing_observer/module.py index 009262cef..79b19433b 100644 --- a/modules/writing_observer/writing_observer/module.py +++ b/modules/writing_observer/writing_observer/module.py @@ -93,6 +93,26 @@ EXECUTION_DAG = { "execution_dag": { "roster": course_roster(runtime=q.parameter("runtime"), course_id=q.parameter("course_id", required=True)), + # all documents for a student + 'student_with_docs': q.select( + q.keys( + 'writing_observer.document_list', + STUDENTS=q.parameter("student_id", required=True), + STUDENTS_path='user_id' + ), + fields={'docs': 'docs'} + ), + # a single document by explicit doc id + 'single_student_doc_by_id': q.select( + q.keys( + 'writing_observer.reconstruct', + STUDENTS=q.parameter("student_id", required=True), + STUDENTS_path='user_id', + RESOURCES=q.parameter("document", required=True), + RESOURCES_path='doc_id' + ), + fields={'text': 'text'} + ), "doc_ids": q.select(q.keys('writing_observer.last_document', STUDENTS=q.variable("roster"), STUDENTS_path='user_id'), fields={'document_id': 'doc_id'}), 'update_docs': update_via_google(runtime=q.parameter("runtime"), doc_ids=q.variable('doc_sources')), "docs": q.select(q.keys('writing_observer.reconstruct', STUDENTS=q.variable("roster"), STUDENTS_path='user_id', RESOURCES=q.variable("update_docs"), RESOURCES_path='doc_id'), fields={'text': 'text'}), @@ -167,6 +187,26 @@ "parameters": ["course_id"], "output": "" }, + "student_with_docs": { + "returns": "student_with_docs", + # "parameters": ["student_id"], + "output": "" + }, + "single_student_doc_by_id": { + "returns": "single_student_doc_by_id", + "parameters": ["student_id", "document"], + "output": "" + }, + "single_student_all_reconstruct": { + "returns": "single_student_all_reconstruct", + "parameters": ["student_id"], + "output": "" + }, + "single_student_docs_with_nlp_annotations": { + "returns": "single_student_nlp", + "parameters": ["student_id", "document", "nlp_options"], + "output": "" + }, "document_list": { "returns": "document_list", "parameters": ["course_id"], From 4ffa4d925211bafbd852c5945ec6704c5dcdafcc Mon Sep 17 00:00:00 2001 From: JohnDamilola Date: Mon, 5 Jan 2026 13:09:47 -0500 Subject: [PATCH 3/3] Add LO entry point file --- VERSION | 2 +- modules/wo_portfolio_diff/MANIFEST.in | 1 - modules/wo_portfolio_diff/VERSION | 2 +- modules/wo_portfolio_diff/setup.cfg | 3 -- modules/wo_portfolio_diff/test.sh | 11 ------- modules/wo_portfolio_diff/tests/test_utils.py | 6 ---- .../wo_portfolio_diff/__init__.py | 0 .../wo_portfolio_diff/module.py | 31 +++++++++++++++++++ 8 files changed, 33 insertions(+), 23 deletions(-) delete mode 100644 modules/wo_portfolio_diff/MANIFEST.in delete mode 100755 modules/wo_portfolio_diff/test.sh delete mode 100644 modules/wo_portfolio_diff/tests/test_utils.py create mode 100644 modules/wo_portfolio_diff/wo_portfolio_diff/__init__.py create mode 100644 modules/wo_portfolio_diff/wo_portfolio_diff/module.py diff --git a/VERSION b/VERSION index ef015be66..97fe52c3a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2026.01.02T18.41.47.3NZ.2a3d8a9.dami.portfolio +0.1.0+2026.01.05T18.09.47.3NZ.b995bb5.dami.portfolio diff --git a/modules/wo_portfolio_diff/MANIFEST.in b/modules/wo_portfolio_diff/MANIFEST.in deleted file mode 100644 index 234482533..000000000 --- a/modules/wo_portfolio_diff/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include wo_portfolio_diff/assets/* diff --git a/modules/wo_portfolio_diff/VERSION b/modules/wo_portfolio_diff/VERSION index 66ede4dd3..97fe52c3a 100644 --- a/modules/wo_portfolio_diff/VERSION +++ b/modules/wo_portfolio_diff/VERSION @@ -1 +1 @@ -0.1.0+2026.01.02T18.05.55.3NZ.3385b46.dami.portfolio +0.1.0+2026.01.05T18.09.47.3NZ.b995bb5.dami.portfolio diff --git a/modules/wo_portfolio_diff/setup.cfg b/modules/wo_portfolio_diff/setup.cfg index 906c9a554..c081ec603 100644 --- a/modules/wo_portfolio_diff/setup.cfg +++ b/modules/wo_portfolio_diff/setup.cfg @@ -6,9 +6,6 @@ description = Use this as a base template for creating new modules on the Learni [options] packages = wo_portfolio_diff -[options.package_data] -wo_portfolio_diff = assets/* - [options.entry_points] lo_modules = wo_portfolio_diff = wo_portfolio_diff.module diff --git a/modules/wo_portfolio_diff/test.sh b/modules/wo_portfolio_diff/test.sh deleted file mode 100755 index 185ef70a4..000000000 --- a/modules/wo_portfolio_diff/test.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash -# modules/wo_portfolio_diff/test.sh -echo "=================================================" -echo "Running tests for Writing Observer Portfolio Diff" -echo "=================================================" - -# Modify the commands below to fit your testing needs -echo "Running traditional pytests" -pytest tests/ -echo "Running doctests" -pytest --doctest-modules diff --git a/modules/wo_portfolio_diff/tests/test_utils.py b/modules/wo_portfolio_diff/tests/test_utils.py deleted file mode 100644 index 259118105..000000000 --- a/modules/wo_portfolio_diff/tests/test_utils.py +++ /dev/null @@ -1,6 +0,0 @@ -import wo_portfolio_diff.utils as unit - -def test_increment(): - n = 1 - result = unit.increment(n) - assert result == n + 1 diff --git a/modules/wo_portfolio_diff/wo_portfolio_diff/__init__.py b/modules/wo_portfolio_diff/wo_portfolio_diff/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modules/wo_portfolio_diff/wo_portfolio_diff/module.py b/modules/wo_portfolio_diff/wo_portfolio_diff/module.py new file mode 100644 index 000000000..0b2533f0f --- /dev/null +++ b/modules/wo_portfolio_diff/wo_portfolio_diff/module.py @@ -0,0 +1,31 @@ +''' +Writing Observer Portfolio Diff + +A writing observer module that shows the difference between the works of a student +''' + +# Name for the module +NAME = 'Writing Observer Portfolio Diff' + + +''' +The Course Dashboards are used to populate the modules +on the home screen. + +Note the icon uses Font Awesome v5 +''' +COURSE_DASHBOARDS = [{ + 'name': NAME, + 'url': "/wo_portfolio_diff/portfolio_diff/", + "icon": { + "type": "fas", + "icon": "fa-play-circle" + } +}] + +''' +Built NextJS pages we want to serve. +''' +NEXTJS_PAGES = [ + {'path': 'portfolio_diff/'} +] \ No newline at end of file