Boost Azure Demo

This commit is contained in:
MangoPig
2026-05-25 17:05:06 +01:00
parent 675285e99d
commit 4f79137d89
230 changed files with 43275 additions and 2644 deletions

View File

@@ -17,7 +17,6 @@ build:
SAVE ARTIFACT dist AS LOCAL ./dist
dev-image:
ARG REGISTRY="registry.mangopig.tech"
ARG IMAGE_NAME="boost-ai/demo-frontend-dev"
ARG TAG="latest"
@@ -29,6 +28,38 @@ dev-image:
EXPOSE 4321
SAVE IMAGE $IMAGE_NAME:$TAG
dev-image-push:
ARG REGISTRY="registry.mangopig.tech"
ARG IMAGE_NAME="boost-ai/demo-frontend-dev"
ARG TAG="latest"
FROM +dev-image --IMAGE_NAME=$IMAGE_NAME --TAG=$TAG
SAVE IMAGE --push $REGISTRY/$IMAGE_NAME:$TAG
prod-image:
ARG IMAGE_NAME="boost-ai/demo-frontend-prod-a"
ARG TAG="latest"
FROM +deps
COPY . .
RUN pnpm build
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
EXPOSE 3000
ENTRYPOINT ["pnpm", "start", "--host", "0.0.0.0", "--port", "3000"]
SAVE IMAGE $IMAGE_NAME:$TAG
prod-image-push:
ARG REGISTRY="registry.mangopig.tech"
ARG IMAGE_NAME="boost-ai/demo-frontend-prod-a"
ARG TAG="latest"
FROM +prod-image --IMAGE_NAME=$IMAGE_NAME --TAG=$TAG
SAVE IMAGE --push $REGISTRY/$IMAGE_NAME:$TAG
# image:

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 664 B

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,32 +1,53 @@
// Path: Frontend/src/app.tsx
import { Router, useLocation } from "@solidjs/router";
import { Router, useLocation, useNavigate } from "@solidjs/router";
import { FileRoutes } from "@solidjs/start/router";
import { Suspense, Show, type ParentComponent } from "solid-js";
import { Transition } from "solid-transition-group";
import { Suspense, createEffect, type ParentComponent } from "solid-js";
import { AuthProvider, useAuth } from "./context/auth/context";
import { ThemeProvider } from "./context/theme/context";
import { getPostAuthRedirectHref, isPublicRoute } from "./lib/routes";
import "./styles/main.scss";
const AppRoot: ParentComponent = (props) => {
const location = useLocation();
const isViewportLockedRoute = () => location.pathname === "/" || location.pathname.startsWith("/auth/");
const navigate = useNavigate();
const auth = useAuth();
const routeIsPublic = () => isPublicRoute(location.pathname);
createEffect(() => {
if (!auth.isReady()) return;
const pathname = location.pathname;
if (pathname === "/") return;
if (pathname.startsWith("/auth/")) {
if (auth.user()) {
navigate(getPostAuthRedirectHref(auth.user()!.role), { replace: true });
}
return;
}
if (!auth.user()) {
navigate("/auth/login", { replace: true });
}
});
return (
<Suspense>
<div classList={{
"app-route-shell": true,
"app-route-shell-viewport-locked": isViewportLockedRoute(),
}}>
<Transition name="page-fade-slide">
<Show when={location.pathname} keyed>
{(path) => (
<div class="page-transition-stage" data-route={path}>
{props.children}
</div>
)}
</Show>
</Transition>
</div>
{!auth.isReady() && !routeIsPublic() ? (
<div class="app-route-shell app-route-shell-viewport-locked">
<div style={{ display: "grid", "place-items": "center", padding: "2rem", color: "var(--text-muted)" }}>
Loading your session
</div>
</div>
) : (
<div classList={{
"app-route-shell": true,
"app-route-shell-viewport-locked": routeIsPublic(),
}}>
{props.children}
</div>
)}
</Suspense>
);
};
@@ -34,9 +55,11 @@ const AppRoot: ParentComponent = (props) => {
export default function App() {
return (
<ThemeProvider>
<Router root={AppRoot}>
<FileRoutes />
</Router>
<AuthProvider>
<Router root={AppRoot}>
<FileRoutes />
</Router>
</AuthProvider>
</ThemeProvider>
);
}

View File

@@ -1,27 +0,0 @@
import { A, useLocation, useParams } from "@solidjs/router";
import type { Component } from "solid-js";
import styles from "../../routes/assignment/assignment-page.module.scss";
const AssignmentTabs: Component = () => {
const params = useParams();
const location = useLocation();
const reviewHref = () => `/assignment/${params.id}`;
const workHref = () => `/assignment/${params.id}/work`;
const isWork = () => location.pathname === workHref();
return (
<nav class={styles.tabs} aria-label="Assignment views">
<div class={styles.tabList}>
<A href={reviewHref()} class={`${styles.tabLink} ${!isWork() ? styles.tabLinkActive : ""}`.trim()}>
Review
</A>
<A href={workHref()} class={`${styles.tabLink} ${isWork() ? styles.tabLinkActive : ""}`.trim()}>
Work
</A>
</div>
</nav>
);
};
export default AssignmentTabs;

View File

@@ -1,251 +0,0 @@
import rawAssignments from "../../../../Mock-Data/assignments.json";
import rawAssignmentAssignees from "../../../../Mock-Data/assignment_assignees.json";
import rawAssignmentQuestions from "../../../../Mock-Data/assignment_questions.json";
import rawQuestionBank from "../../../../Mock-Data/question_bank.json";
import rawStudentAnswers from "../../../../Mock-Data/student_answers.json";
import rawStudents from "../../../../Mock-Data/students.json";
import rawClassroom from "../../../../Mock-Data/classroom.json";
type Assignment = {
id: number;
name: string;
topic: string;
due_date: number;
status: "DRAFT" | "PUBLISHED" | "CLOSED";
maximum_marks: number;
};
type AssignmentAssignee = {
id: number;
assignment_id: number;
student_id: number;
status: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED";
total_marks: number;
started_at: number | null;
submitted_at: number | null;
};
type AssignmentQuestion = {
id: number;
assignment_id: number;
question_bank_id: number;
question_order: number;
maximum_marks: number;
};
type QuestionBankItem = {
id: number;
topic: string;
sub_topic: string | null;
difficulty: "EASY" | "MEDIUM" | "HARD";
question_text: string;
correct_answer: string;
step_by_step_solution: string | null;
};
type StudentAnswer = {
assignee_id: number;
assignment_question_id: number;
extracted_answer: string;
ai_reasoning: string;
_solve_mode: "just_answer" | "step_by_step" | "solve_together" | "handwritten";
_is_correct: boolean;
_time_on_task_seconds: number;
};
type Student = {
id: number;
fullname: string;
_persona: string;
};
type ClassroomFile = {
classroom: {
name: string;
target_level: number;
};
tutor: {
fullname: string;
};
};
export type AssignmentPageData = {
id: number;
title: string;
topic: string;
status: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED";
statusLabel: string;
dueLabel: string;
studentName: string;
classroomName: string;
tutorName: string;
headline: string;
description: string;
primaryAction: string;
primaryHref: string;
stats: Array<{ label: string; value: string }>;
coachCard: {
title: string;
description: string;
items: string[];
};
questions: Array<{
id: number;
order: number;
prompt: string;
topic: string;
subTopic: string | null;
difficulty: "EASY" | "MEDIUM" | "HARD";
marks: number;
statusLabel: string;
statusTone: "success" | "warning" | "muted";
responseLabel: string;
responseValue: string;
feedback: string;
solveModeLabel?: string;
initialAnswer?: string;
initialSolveMode?: "just_answer" | "step_by_step" | "solve_together" | "handwritten";
showAnswerKey: boolean;
correctAnswer: string;
}>;
};
const assignments = rawAssignments as Assignment[];
const assignmentAssignees = rawAssignmentAssignees as AssignmentAssignee[];
const assignmentQuestions = rawAssignmentQuestions as AssignmentQuestion[];
const questionBank = rawQuestionBank as QuestionBankItem[];
const studentAnswers = rawStudentAnswers as StudentAnswer[];
const students = rawStudents as Student[];
const classroomFile = rawClassroom as ClassroomFile;
const defaultStudentId = 201;
const assignmentById = new Map(assignments.map((entry) => [entry.id, entry]));
const questionById = new Map(questionBank.map((entry) => [entry.id, entry]));
const student = students.find((entry) => entry.id === defaultStudentId) ?? students[0];
const formatDate = (timestamp: number) =>
new Intl.DateTimeFormat("en-GB", {
weekday: "short",
month: "short",
day: "numeric",
}).format(new Date(timestamp));
const formatSolveMode = (value: StudentAnswer["_solve_mode"]) => {
switch (value) {
case "just_answer":
return "Just answer";
case "step_by_step":
return "Step by step";
case "solve_together":
return "Solve together";
case "handwritten":
return "Handwritten";
}
};
const personaHint = (persona: string) => {
switch (persona) {
case "fraction_inversion":
return "Slow down on fraction rules and check each step before moving on.";
case "place_value_gaps":
return "Check place value carefully before you calculate the final answer.";
case "rushed_careless":
return "Pause before submitting so small slips do not cost easy marks.";
case "solve_together_dependent":
return "Try one independent attempt first, then ask for guided help if you need it.";
case "word_problem_weak":
return "Underline the key numbers and turn the sentence into a maths step first.";
default:
return "Work through one question at a time and keep your method tidy.";
}
};
export const getAssignmentPageData = (assignmentId: number): AssignmentPageData | null => {
const assignment = assignmentById.get(assignmentId);
if (!assignment) return null;
const assignee = assignmentAssignees.find((entry) => entry.assignment_id === assignment.id && entry.student_id === student.id);
if (!assignee) return null;
const assignmentQuestionRows = assignmentQuestions
.filter((entry) => entry.assignment_id === assignment.id)
.sort((left, right) => left.question_order - right.question_order)
.map((entry) => {
const question = questionById.get(entry.question_bank_id);
if (!question) throw new Error(`Missing question bank record ${entry.question_bank_id}`);
const answer = studentAnswers.find((studentAnswer) => studentAnswer.assignee_id === assignee.id && studentAnswer.assignment_question_id === entry.id);
return { entry, question, answer };
});
const answeredCount = assignmentQuestionRows.filter((row) => !!row.answer).length;
const correctCount = assignmentQuestionRows.filter((row) => row.answer?._is_correct).length;
const accuracy = answeredCount > 0 ? Math.round((correctCount / answeredCount) * 100) : 0;
const statusLabel = assignee.status === "SUBMITTED" ? "Submitted" : assignee.status === "IN_PROGRESS" ? "In progress" : "Not started";
const primaryAction = assignee.status === "SUBMITTED" ? "Review assignment" : assignee.status === "IN_PROGRESS" ? "Continue assignment" : "Start assignment";
const questions = assignmentQuestionRows.map(({ entry, question, answer }) => ({
id: entry.id,
order: entry.question_order,
prompt: question.question_text,
topic: question.topic,
subTopic: question.sub_topic,
difficulty: question.difficulty,
marks: entry.maximum_marks,
statusLabel: answer ? (answer._is_correct ? "Correct" : "Needs review") : "Not answered",
statusTone: answer ? (answer._is_correct ? "success" : "warning") : "muted",
responseLabel: answer ? "Your latest answer" : "Status",
responseValue: answer ? answer.extracted_answer : "No attempt yet",
feedback: answer ? answer.ai_reasoning : "This sample question is ready when you are.",
solveModeLabel: answer ? formatSolveMode(answer._solve_mode) : undefined,
initialAnswer: answer?.extracted_answer,
initialSolveMode: answer?._solve_mode,
showAnswerKey: assignee.status === "SUBMITTED",
correctAnswer: question.correct_answer,
}));
return {
id: assignment.id,
title: assignment.name,
topic: assignment.topic,
status: assignee.status,
statusLabel,
dueLabel: formatDate(assignment.due_date),
studentName: student.fullname,
classroomName: classroomFile.classroom.name,
tutorName: classroomFile.tutor.fullname,
headline:
assignee.status === "SUBMITTED"
? `Review how you did in ${assignment.topic}`
: assignee.status === "IN_PROGRESS"
? `Keep going — you are already part way through`
: `Start this assignment with a steady first pass`,
description:
assignee.status === "SUBMITTED"
? `You scored ${assignee.total_marks}/${assignment.maximum_marks}. Use the sample questions below to revisit what felt easy and what still needs another try.`
: assignee.status === "IN_PROGRESS"
? `You have answered ${answeredCount} of ${assignmentQuestionRows.length} questions. Finish the rest while the topic is still fresh.`
: `This assignment has ${assignmentQuestionRows.length} sample questions. Start with the easier wins, then work up to the harder ones.`,
primaryAction,
primaryHref: `/assignment/${assignment.id}/work`,
stats: [
{ label: "Status", value: statusLabel },
{ label: "Due", value: formatDate(assignment.due_date) },
{ label: "Questions", value: `${assignmentQuestionRows.length}` },
{ label: assignee.status === "SUBMITTED" ? "Score" : "Answered", value: assignee.status === "SUBMITTED" ? `${assignee.total_marks}/${assignment.maximum_marks}` : `${answeredCount}/${assignmentQuestionRows.length}` },
],
coachCard: {
title: "How to approach this one",
description: personaHint(student._persona),
items: [
`${assignment.topic} focus`,
`${accuracy}% accuracy so far`,
`${correctCount} correct answers logged`,
],
},
questions,
};
};

View File

@@ -1,14 +1,24 @@
/* Path: Frontend/src/components/assignment/shared/assignment-header.module.scss */
.headerCard {
display: grid;
gap: 1.1rem;
padding: 1.5rem;
border-radius: 1.5rem;
border-radius: var(--radius-3xl);
background: linear-gradient(135deg, var(--surface-hero-start), var(--surface-hero-end));
color: var(--text-on-accent);
border: 1px solid var(--border-overlay);
box-shadow: var(--shadow-elevated);
}
@media (max-width: 768px) {
.headerCard {
gap: 0.8rem;
padding: 1rem;
border-radius: var(--radius-2xl);
}
}
.headerTop {
display: flex;
align-items: center;
@@ -16,13 +26,21 @@
gap: 1rem;
}
@media (max-width: 768px) {
.headerTop {
align-items: flex-start;
flex-wrap: wrap;
gap: 0.7rem;
}
}
.backLink,
.statusPill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.55rem 0.85rem;
border-radius: 9999px;
border-radius: var(--radius-full);
background: var(--surface-overlay-soft);
border: 1px solid var(--border-overlay);
color: var(--text-on-accent);
@@ -47,6 +65,21 @@
}
}
@media (max-width: 768px) {
.copy {
gap: 0.22rem;
}
.copy h1 {
font-size: clamp(1.5rem, 1.15rem + 1.35vw, 2rem);
line-height: 1.04;
}
.copy > p:not(.eyebrow) {
display: none;
}
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.1em;

View File

@@ -1,17 +1,21 @@
import type { Component } from "solid-js";
// Path: Frontend/src/components/assignment/shared/assignment-header.tsx
import { A } from "@solidjs/router";
import type { AssignmentPageData } from "./assignment.data";
import type { Component } from "solid-js";
import { getDashboardHomeHref } from "../../../lib/routes";
import styles from "./assignment-header.module.scss";
import type { AssignmentPageData } from "./assignment-types";
type Props = {
data: AssignmentPageData;
backHref?: string;
};
const AssignmentHeader: Component<Props> = (props) => {
return (
<section class={styles.headerCard}>
<div class={styles.headerTop}>
<A href="/dashboard" class={styles.backLink}>
<A href={props.backHref ?? getDashboardHomeHref("student")} class={styles.backLink}>
Back to dashboard
</A>
<span class={styles.statusPill}>{props.data.statusLabel}</span>

View File

@@ -1,3 +1,5 @@
/* Path: Frontend/src/components/assignment/shared/assignment-overview.module.scss */
.stack {
display: grid;
gap: 1rem;
@@ -7,7 +9,7 @@
display: grid;
gap: 1rem;
padding: 1.2rem;
border-radius: 1.35rem;
border-radius: var(--radius-2xl);
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
@@ -39,7 +41,7 @@
display: grid;
gap: 0.25rem;
padding: 0.9rem;
border-radius: 1rem;
border-radius: var(--radius-md);
background: var(--surface-panel-strong);
border: 1px solid var(--border-divider);
@@ -76,10 +78,35 @@
align-items: center;
justify-content: center;
padding: 0.9rem 1rem;
border-radius: 1rem;
border-radius: var(--radius-md);
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
color: var(--action-primary-text);
font-weight: 600;
box-shadow: var(--action-primary-shadow);
text-decoration: none;
}
.feedbackBlock {
display: grid;
gap: 0.45rem;
padding: 0.95rem 1rem;
border-radius: var(--radius-md);
background: var(--surface-panel-strong);
border: 1px solid var(--border-divider);
}
.feedbackLabel {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
font-weight: 700;
}
.feedbackCopy {
font-size: 0.94rem;
line-height: 1.55;
color: var(--text-muted);
white-space: pre-wrap;
overflow-wrap: anywhere;
}

View File

@@ -1,8 +1,10 @@
// Path: Frontend/src/components/assignment/shared/assignment-overview.tsx
import { A } from "@solidjs/router";
import type { Component } from "solid-js";
import { For } from "solid-js";
import { A } from "@solidjs/router";
import type { AssignmentPageData } from "./assignment.data";
import styles from "./assignment-overview.module.scss";
import type { AssignmentPageData } from "./assignment-types";
type Props = {
data: AssignmentPageData;
@@ -45,6 +47,29 @@ const AssignmentOverview: Component<Props> = (props) => {
{props.data.primaryAction}
</A>
</section>
{(props.data.assignmentAiFeedback || props.data.assignmentTeacherFeedback) && (
<section class={styles.panel}>
<div class={styles.cardHeader}>
<h2>Shared assignment feedback</h2>
<p>{props.data.tutorName}</p>
</div>
{props.data.assignmentAiFeedback && (
<div class={styles.feedbackBlock}>
<p class={styles.feedbackLabel}>AI feedback</p>
<p class={styles.feedbackCopy}>{props.data.assignmentAiFeedback}</p>
</div>
)}
{props.data.assignmentTeacherFeedback && (
<div class={styles.feedbackBlock}>
<p class={styles.feedbackLabel}>Teacher feedback</p>
<p class={styles.feedbackCopy}>{props.data.assignmentTeacherFeedback}</p>
</div>
)}
</section>
)}
</div>
);
};

View File

@@ -1,3 +1,5 @@
/* Path: Frontend/src/components/assignment/shared/assignment-page.module.scss */
.page {
min-height: 100dvh;
padding: 1.25rem;
@@ -18,7 +20,7 @@
display: grid;
gap: 1.25rem;
@media (min-width: 1080px) {
@include respond(workspace) {
grid-template-columns: minmax(0, 1.5fr) minmax(18rem, 0.75fr);
align-items: start;
}
@@ -34,7 +36,7 @@
align-items: center;
gap: 0.4rem;
padding: 0.35rem;
border-radius: 9999px;
border-radius: var(--radius-full);
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
@@ -52,7 +54,7 @@
align-items: center;
justify-content: center;
padding: 0.72rem 1rem;
border-radius: 9999px;
border-radius: var(--radius-full);
text-decoration: none;
color: var(--text-muted);
font-weight: 500;
@@ -80,7 +82,7 @@
}
.sideColumn {
@media (min-width: 1080px) {
@include respond(workspace) {
position: sticky;
top: 1.25rem;
}
@@ -90,7 +92,7 @@
display: grid;
gap: 0.8rem;
padding: 2rem;
border-radius: 1.5rem;
border-radius: var(--radius-3xl);
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
@@ -119,7 +121,7 @@
align-items: center;
justify-content: center;
padding: 0.85rem 1rem;
border-radius: 9999px;
border-radius: var(--radius-full);
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
color: var(--action-primary-text);
text-decoration: none;

View File

@@ -30,7 +30,7 @@
display: grid;
gap: 0.85rem;
padding: 1.2rem;
border-radius: 1.35rem;
border-radius: var(--radius-2xl);
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
@@ -59,7 +59,7 @@
.statusPill {
padding: 0.45rem 0.75rem;
border-radius: 9999px;
border-radius: var(--radius-full);
font-size: 0.82rem;
font-weight: 600;
white-space: nowrap;
@@ -87,7 +87,7 @@
span {
padding: 0.35rem 0.6rem;
border-radius: 9999px;
border-radius: var(--radius-full);
background: var(--surface-panel-strong);
border: 1px solid var(--border-divider);
font-size: 0.82rem;
@@ -96,11 +96,12 @@
}
.responseBlock,
.answerKey {
.answerKey,
.supportBlock {
display: grid;
gap: 0.25rem;
padding: 0.95rem 1rem;
border-radius: 1rem;
border-radius: var(--radius-md);
background: var(--surface-panel-strong);
border: 1px solid var(--border-divider);
@@ -116,8 +117,12 @@
font-weight: 600;
}
span {
span,
pre {
font-size: 0.9rem;
color: var(--text-muted);
white-space: pre-wrap;
overflow-wrap: anywhere;
margin: 0;
}
}

View File

@@ -1,7 +1,9 @@
// Path: Frontend/src/components/assignment/shared/assignment-question-list.tsx
import type { Component } from "solid-js";
import { For, Show } from "solid-js";
import type { AssignmentPageData } from "./assignment.data";
import styles from "./assignment-question-list.module.scss";
import type { AssignmentPageData } from "./assignment-types";
type Props = {
data: AssignmentPageData;
@@ -11,8 +13,8 @@ const AssignmentQuestionList: Component<Props> = (props) => {
return (
<section class={styles.section}>
<div class={styles.header}>
<h2>Sample questions</h2>
<p>{props.data.questions.length} loaded from the mock dataset</p>
<h2>Question review</h2>
<p>{props.data.questions.length} questions in this assignment</p>
</div>
<div class={styles.list}>
@@ -32,8 +34,12 @@ const AssignmentQuestionList: Component<Props> = (props) => {
<Show when={question.subTopic}>
<span>{question.subTopic}</span>
</Show>
<span>{question.difficulty}</span>
<span>{question.marks} mark</span>
<Show when={question.difficulty}>
<span>{question.difficulty}</span>
</Show>
<Show when={question.marks !== null}>
<span>{question.marks} mark</span>
</Show>
<Show when={question.solveModeLabel}>
<span>{question.solveModeLabel}</span>
</Show>
@@ -42,12 +48,21 @@ const AssignmentQuestionList: Component<Props> = (props) => {
<div class={styles.responseBlock}>
<p>{question.responseLabel}</p>
<strong>{question.responseValue}</strong>
<span>{question.feedback}</span>
<Show when={question.feedback}>
<span>{question.feedback}</span>
</Show>
</div>
<Show when={question.workingSteps}>
<div class={styles.supportBlock}>
<p>Your steps and explanation</p>
<pre>{question.workingSteps}</pre>
</div>
</Show>
<Show when={question.showAnswerKey}>
<div class={styles.answerKey}>
<p>Answer key</p>
<p>Correct answer</p>
<strong>{question.correctAnswer}</strong>
</div>
</Show>

View File

@@ -0,0 +1,39 @@
// Path: Frontend/src/components/assignment/shared/assignment-tabs.tsx
import { A, useLocation, useParams } from "@solidjs/router";
import type { Component } from "solid-js";
import type { AppRole } from "../../../lib/routes";
import { getAssignmentReviewHref, getAssignmentWorkHref } from "../../../lib/routes";
import styles from "./assignment-page.module.scss";
type AssignmentTabsProps = {
showWork?: boolean;
role?: AppRole;
};
const AssignmentTabs: Component<AssignmentTabsProps> = (props) => {
const params = useParams();
const location = useLocation();
const role = () => props.role ?? (location.pathname.includes("/teacher/") ? "teacher" : "student");
const reviewHref = () => getAssignmentReviewHref(role(), params.id as string);
const workHref = () => getAssignmentWorkHref(params.id as string);
const isWork = () => location.pathname === workHref();
return (
<nav class={styles.tabs} aria-label="Assignment views">
<div class={styles.tabList}>
<A href={reviewHref()} class={`${styles.tabLink} ${!isWork() ? styles.tabLinkActive : ""}`.trim()}>
Review
</A>
{props.showWork !== false ? (
<A href={workHref()} class={`${styles.tabLink} ${isWork() ? styles.tabLinkActive : ""}`.trim()}>
Work
</A>
) : null}
</div>
</nav>
);
};
export default AssignmentTabs;

View File

@@ -0,0 +1,46 @@
// Path: Frontend/src/components/assignment/shared/assignment-types.ts
export type AssignmentPageData = {
id: number;
title: string;
topic: string;
status: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED";
statusLabel: string;
dueLabel: string;
studentName: string;
classroomName: string;
tutorName: string;
headline: string;
description: string;
primaryAction: string;
primaryHref: string;
stats: Array<{ label: string; value: string }>;
coachCard: {
title: string;
description: string;
items: string[];
};
assignmentAiFeedback?: string;
assignmentTeacherFeedback?: string;
questions: Array<{
id: number;
order: number;
prompt: string;
topic: string;
subTopic: string | null;
difficulty: string | null;
marks: number | null;
statusLabel: string;
statusTone: "success" | "warning" | "muted";
responseLabel: string;
responseValue: string;
feedback: string;
solveModeLabel?: string;
workingSteps?: string;
initialAnswer?: string;
initialSolveMode?: "just_answer" | "step_by_step" | "solve_together" | "handwritten";
showAnswerKey: boolean;
correctAnswer: string | null;
isCorrect?: boolean | null;
}>;
};

View File

@@ -0,0 +1,204 @@
// Path: Frontend/src/components/assignment/student/assignment-review.data.ts
import { apiFetchJson } from "../../../lib/api";
import type { ApiAssignment, ApiAssignmentStudentQuestionDetail, ApiClassroom, ApiListResponse, ApiUser } from "../../../lib/api-types";
import { getAssignmentWorkHref } from "../../../lib/routes";
import type { AssignmentPageData } from "../shared/assignment-types";
const formatDateLabel = (value: string | null) => {
if (!value) return "No due date";
return new Intl.DateTimeFormat("en-GB", {
weekday: "short",
month: "short",
day: "numeric",
}).format(new Date(value));
};
const extractTopic = (assignment: ApiAssignment, questions: ApiAssignmentStudentQuestionDetail[]) => {
const fromInstructions = assignment.instructions?.match(/^Topic:\s*(.+)$/i)?.[1]?.trim();
if (fromInstructions) return fromInstructions;
return questions[0]?.subject ?? "Assignment";
};
const deriveStudentStatus = (questions: ApiAssignmentStudentQuestionDetail[], assignmentStatus: ApiAssignment["status"]) => {
const total = questions.length;
const answered = questions.filter((question) => question.answer_id).length;
const reviewed = questions.filter((question) => question.answer_status === "reviewed").length;
if (answered === 0) return "NOT_STARTED" as const;
if (reviewed === total || assignmentStatus === "closed") return "SUBMITTED" as const;
return "IN_PROGRESS" as const;
};
const deriveStatusLabel = (status: AssignmentPageData["status"]) => {
switch (status) {
case "SUBMITTED":
return "Reviewed";
case "IN_PROGRESS":
return "In progress";
default:
return "Not started";
}
};
const deriveHeadline = (status: AssignmentPageData["status"], topic: string) => {
switch (status) {
case "SUBMITTED":
return `Review how you did in ${topic}`;
case "IN_PROGRESS":
return "Keep going — you already have momentum here";
default:
return "Start this assignment with a steady first pass";
}
};
const deriveDescription = (status: AssignmentPageData["status"], answered: number, reviewed: number, total: number, topic: string) => {
switch (status) {
case "SUBMITTED":
return `${reviewed} of ${total} questions have teacher-reviewed answers. Use this review view to see what landed well and what still needs another pass in ${topic}.`;
case "IN_PROGRESS":
return `You have touched ${answered} of ${total} questions so far. Finish the remaining questions while ${topic.toLowerCase()} still feels familiar.`;
default:
return `This assignment has ${total} questions. Start with a calm first pass, then come back here for one shared review summary across the assignment.`;
}
};
const derivePrimaryAction = (status: AssignmentPageData["status"]) => {
switch (status) {
case "SUBMITTED":
return "Open workspace";
case "IN_PROGRESS":
return "Continue assignment";
default:
return "Start assignment";
}
};
const mapQuestionStatus = (question: ApiAssignmentStudentQuestionDetail) => {
if (question.is_correct === true) {
return {
statusLabel: "Correct",
statusTone: "success" as const,
};
}
if (question.answer_status === "reviewed") {
return {
statusLabel: "Reviewed",
statusTone: "success" as const,
};
}
if (question.answer_status === "submitted") {
return {
statusLabel: "Submitted",
statusTone: "warning" as const,
};
}
if (question.answer_status === "in_progress") {
return {
statusLabel: "In progress",
statusTone: "warning" as const,
};
}
return {
statusLabel: "Not started",
statusTone: "muted" as const,
};
};
export const getAssignmentReviewPageData = async (assignmentId: number, studentId: number): Promise<AssignmentPageData | null> => {
if (!Number.isFinite(assignmentId) || assignmentId <= 0) return null;
try {
const assignment = await apiFetchJson<ApiAssignment>(`/api/assignments/${assignmentId}`);
const [student, teacher, classrooms, questionDetails] = await Promise.all([
apiFetchJson<ApiUser>(`/api/users/${studentId}`),
apiFetchJson<ApiUser>(`/api/users/${assignment.teacher_id}`),
apiFetchJson<ApiListResponse<ApiClassroom>>(`/api/teachers/${assignment.teacher_id}/classrooms`),
apiFetchJson<ApiListResponse<ApiAssignmentStudentQuestionDetail>>(`/api/assignments/${assignmentId}/students/${studentId}/questions`),
]);
const classroom = classrooms.data.find((entry) => entry.id === assignment.classroom_id);
const questions = questionDetails.data;
const topic = extractTopic(assignment, questions);
const answeredCount = questions.filter((question) => question.answer_id).length;
const reviewedCount = questions.filter((question) => question.answer_status === "reviewed").length;
const assignmentAiFeedback = questions[0]?.assignment_ai_feedback?.trim() || "";
const assignmentTeacherFeedback = questions[0]?.assignment_teacher_feedback?.trim() || "";
const status = deriveStudentStatus(questions, assignment.status);
const statusLabel = deriveStatusLabel(status);
return {
id: assignment.id,
title: assignment.title,
topic,
status,
statusLabel,
dueLabel: formatDateLabel(assignment.due_at),
studentName: student.full_name,
classroomName: classroom?.name ?? "Classroom",
tutorName: teacher.full_name,
headline: deriveHeadline(status, topic),
description: deriveDescription(status, answeredCount, reviewedCount, questions.length, topic),
primaryAction: derivePrimaryAction(status),
primaryHref: getAssignmentWorkHref(assignment.id),
stats: [
{ label: "Status", value: statusLabel },
{ label: "Due", value: formatDateLabel(assignment.due_at) },
{ label: "Questions", value: `${questions.length}` },
{ label: status === "SUBMITTED" ? "Reviewed" : "Answered", value: `${status === "SUBMITTED" ? reviewedCount : answeredCount}/${questions.length}` },
],
coachCard: {
title: "Review notes",
description: `Use this review view to compare your latest answers with the shared AI and teacher feedback for ${topic.toLowerCase()}.`,
items: [`${answeredCount} questions attempted`, `${reviewedCount} questions reviewed`, `${Math.max(questions.length - answeredCount, 0)} questions still untouched`],
},
assignmentAiFeedback: assignmentAiFeedback || undefined,
assignmentTeacherFeedback: assignmentTeacherFeedback || undefined,
questions: questions.map((question) => {
const questionStatus = mapQuestionStatus(question);
return {
id: question.question_id,
order: question.position,
prompt: question.prompt,
topic: question.subject,
subTopic: null,
difficulty: "Backend",
marks: null,
statusLabel: questionStatus.statusLabel,
statusTone: questionStatus.statusTone,
responseLabel: question.answer_id ? "Latest answer" : "Status",
responseValue: question.answer_text?.trim() || "No attempt yet",
feedback: "",
workingSteps: question.working_steps?.trim() || "",
solveModeLabel:
question.solve_mode === "step_by_step"
? "Step by step"
: question.solve_mode === "solve_together"
? "Solve together"
: question.solve_mode === "handwritten"
? "Handwritten"
: question.solve_mode === "just_answer"
? "Just answer"
: undefined,
showAnswerKey: Boolean(question.correct_answer && question.answer_id),
correctAnswer: question.correct_answer?.trim() || null,
isCorrect: typeof question.is_correct === "boolean" ? question.is_correct : null,
};
}),
};
} catch (error) {
if (error instanceof Error && error.message === "not_found") {
return null;
}
throw error;
}
};

View File

@@ -0,0 +1,281 @@
import type { Component } from "solid-js";
import { A, useNavigate } from "@solidjs/router";
import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
import type { TeacherAssignmentPassStatus, TeacherAssignmentReviewPageData, TeacherNextStepOutcome } from "./assignment-teacher-review.data";
import { updateAssignmentTeacherFeedback } from "./assignment-teacher-review.data";
import { AssignmentFeedbackSection, type TeacherReviewNotice } from "./assignment-teacher-review.sections";
import { getAssignmentReviewHref, getTeacherAssignmentRedoPlanHref } from "../../../lib/routes";
import styles from "./assignment-teacher-review.module.scss";
type Props = {
data: TeacherAssignmentReviewPageData;
};
type NextStepDraft = {
teacherFeedback?: string;
decision?: TeacherNextStepOutcome;
passStatusOverride?: TeacherAssignmentPassStatus | null;
};
const PASS_STATUS_OVERRIDE_OPTIONS: Array<{ value: TeacherAssignmentPassStatus | null; label: string; help: string }> = [
{ value: null, label: "Automatic", help: "Use the calculated status from the fixed pass rule." },
{ value: "pass", label: "Pass", help: "Override the calculated result and mark this review as pass." },
{ value: "no_pass", label: "No pass", help: "Override the calculated result and mark this review as no pass." },
];
const nextStepStorageKey = (assignmentId: number, studentId: number) => `teacher-next-step-draft:${assignmentId}:${studentId}`;
const readNextStepDraft = (assignmentId: number, studentId: number): NextStepDraft => {
if (typeof window === "undefined") return {};
try {
const raw = window.localStorage.getItem(nextStepStorageKey(assignmentId, studentId));
if (!raw) return {};
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? (parsed as NextStepDraft) : {};
} catch {
return {};
}
};
const writeNextStepDraft = (assignmentId: number, studentId: number, draft: NextStepDraft) => {
if (typeof window === "undefined") return;
const key = nextStepStorageKey(assignmentId, studentId);
if (!draft.teacherFeedback && !draft.decision && draft.passStatusOverride == null) {
window.localStorage.removeItem(key);
return;
}
window.localStorage.setItem(key, JSON.stringify(draft));
};
const OUTCOME_OPTIONS: Array<{ value: TeacherNextStepOutcome; title: string; description: string }> = [
{ value: "redo", title: "Redo assignment", description: "Ask the student to revisit the assignment and try again with the latest review in mind." },
{ value: "accept", title: "Accept and continue", description: "Mark this review as ready to move on and continue the student into the next piece of work." },
{ value: "support", title: "Needs support", description: "Flag that the student needs extra coaching, a follow-up message, or a guided reteach step." },
];
const AssignmentTeacherNextStep: Component<Props> = (props) => {
const navigate = useNavigate();
const [teacherFeedbackDraft, setTeacherFeedbackDraft] = createSignal("");
const [decisionDraft, setDecisionDraft] = createSignal<TeacherNextStepOutcome | null>(null);
const [passStatusOverrideDraft, setPassStatusOverrideDraft] = createSignal<TeacherAssignmentPassStatus | null>(null);
const [savingFeedback, setSavingFeedback] = createSignal(false);
const [notice, setNotice] = createSignal<TeacherReviewNotice>(null);
const selectedStudentId = createMemo(() => props.data.selectedStudentId);
const canChooseNextStep = createMemo(() => {
return props.data.selectedStudentSubmittedQuestions > 0 || props.data.selectedStudentReviewedQuestions > 0;
});
const hasPendingAssignmentFeedback = createMemo(() => teacherFeedbackDraft() !== props.data.assignmentTeacherFeedback);
const hasPendingDecision = createMemo(() => decisionDraft() !== props.data.nextStepOutcome);
const hasPendingPassStatusOverride = createMemo(() => passStatusOverrideDraft() !== props.data.passStatusOverride);
const overallScorePercent = createMemo(() => {
if (props.data.overallScore == null) return null;
return Math.round(props.data.overallScore * 10);
});
createEffect(() => {
const studentId = selectedStudentId();
if (!studentId) return;
const draft = readNextStepDraft(props.data.assignmentId, studentId);
setTeacherFeedbackDraft(draft.teacherFeedback ?? props.data.assignmentTeacherFeedback);
setDecisionDraft(draft.decision ?? props.data.nextStepOutcome);
setPassStatusOverrideDraft(draft.passStatusOverride ?? props.data.passStatusOverride);
setNotice(null);
});
createEffect(() => {
const studentId = selectedStudentId();
if (!studentId) return;
writeNextStepDraft(props.data.assignmentId, studentId, {
teacherFeedback: hasPendingAssignmentFeedback() ? teacherFeedbackDraft() : undefined,
decision: hasPendingDecision() ? decisionDraft() ?? undefined : undefined,
passStatusOverride: hasPendingPassStatusOverride() ? passStatusOverrideDraft() : undefined,
});
});
const saveFeedback = async () => {
const studentId = selectedStudentId();
if (!studentId) return;
if (!canChooseNextStep()) {
setNotice({
scope: "assignment",
tone: "error",
text: "Next step stays locked until the student has submitted work for review.",
});
return;
}
setSavingFeedback(true);
setNotice(null);
try {
if (hasPendingAssignmentFeedback() || hasPendingPassStatusOverride() || hasPendingDecision()) {
await updateAssignmentTeacherFeedback(props.data.assignmentId, studentId, {
teacherFeedback: teacherFeedbackDraft().trim(),
passStatusOverride: passStatusOverrideDraft(),
nextStepOutcome: decisionDraft(),
});
}
writeNextStepDraft(props.data.assignmentId, studentId, {});
if (decisionDraft() === "redo") {
navigate(getTeacherAssignmentRedoPlanHref(props.data.assignmentId, studentId));
return;
}
navigate(getAssignmentReviewHref("teacher", props.data.assignmentId));
} catch (error) {
setNotice({
scope: "assignment",
tone: "error",
text: error instanceof Error ? error.message : "Could not save teacher feedback right now.",
});
} finally {
setSavingFeedback(false);
}
};
return (
<div class={styles.contentGrid}>
<div class={styles.mainColumn}>
<section class={styles.section}>
<div class={styles.sectionHeader}>
<h2>Choose the next step</h2>
<p>
<Show when={props.data.selectedStudentName} fallback="Choose what should happen after this review.">
{props.data.selectedStudentName} has been reviewed. Choose what should happen next, then save your feedback at the bottom before returning to the review page.
</Show>
</p>
</div>
<Show when={props.data.selectedStudentId !== null} fallback={<div class={styles.emptyState}>Pick a student from the review queue first so you can set the right next step.</div>}>
<Show
when={canChooseNextStep()}
fallback={<div class={styles.emptyState}>This student has not submitted work yet, so next step is not available.</div>}
>
<section class={styles.nextStepCard}>
<div class={styles.sectionHeader}>
<h3>Recommended outcome</h3>
<p>Pick the next teaching move for {props.data.selectedStudentName ?? "this student"}. The selected outcome will be saved with this student's review.</p>
</div>
<div class={styles.optionGrid}>
<For each={OUTCOME_OPTIONS}>
{(option) => (
<button
type="button"
class={`${styles.optionCard} ${decisionDraft() === option.value ? styles.optionCardActive : ""}`.trim()}
onClick={() => setDecisionDraft(option.value)}
>
<strong>{option.title}</strong>
<span>{option.description}</span>
</button>
)}
</For>
</div>
<div class={styles.draftHint}>
<Show when={decisionDraft()} fallback={<span>No next-step outcome has been picked yet.</span>}>
<span>Selected outcome: {OUTCOME_OPTIONS.find((option) => option.value === decisionDraft())?.title}. Save feedback to persist it for this student.</span>
</Show>
</div>
<div class={styles.reviewScoreGrid}>
<div class={`${styles.reviewField} ${styles.scoreHighlight}`.trim()}>
<label>Overall score</label>
<div class={styles.scoreHighlightValue}>
<Show when={overallScorePercent() != null} fallback={<span>Pending review</span>}>
<strong>{overallScorePercent()}%</strong>
</Show>
</div>
<small>
<Show when={props.data.overallScore != null} fallback="The score will appear after the review has been completed.">
Blended correctness and understanding score.
</Show>
</small>
</div>
<div class={styles.reviewField}>
<label>Pass status</label>
<input
type="text"
class={styles.compactInput}
value={props.data.passStatus === "no_pass" ? "No pass" : props.data.passStatus === "pass" ? "Pass" : "Pending"}
readOnly
disabled
/>
<small>
<Show when={props.data.passStatusOverride} fallback="Currently using the automatic result.">
Teacher override is active.
</Show>
</small>
</div>
<div class={styles.reviewField}>
<label>Teacher override</label>
<select
class={styles.compactInput}
value={passStatusOverrideDraft() ?? ""}
onInput={(event) => {
const value = event.currentTarget.value.trim();
setPassStatusOverrideDraft(value === "pass" || value === "no_pass" || value === "pending" ? value : null);
}}
>
<For each={PASS_STATUS_OVERRIDE_OPTIONS}>
{(option) => (
<option value={option.value ?? ""}>{option.label}</option>
)}
</For>
</select>
<small>{PASS_STATUS_OVERRIDE_OPTIONS.find((option) => option.value === passStatusOverrideDraft())?.help ?? PASS_STATUS_OVERRIDE_OPTIONS[0]!.help}</small>
</div>
</div>
</section>
<AssignmentFeedbackSection
data={props.data}
teacherFeedbackDraft={teacherFeedbackDraft()}
hasPendingAssignmentFeedback={hasPendingAssignmentFeedback()}
busy={savingFeedback()}
notice={notice()}
draftActionLabel="Save and return to review"
onTeacherFeedbackInput={setTeacherFeedbackDraft}
actions={
<button type="button" class={styles.primaryAction} disabled={savingFeedback()} onClick={saveFeedback}>
{savingFeedback()
? decisionDraft() === "redo"
? "Saving and opening redo plan..."
: "Saving and returning..."
: decisionDraft() === "redo"
? "Save feedback and open redo plan"
: "Save feedback and return to review"}
</button>
}
/>
</Show>
</Show>
</section>
</div>
<aside class={styles.sideColumn}>
<section class={styles.sideCard}>
<p class={styles.sideEyebrow}>Student</p>
<h2>{props.data.selectedStudentName ?? "Review summary"}</h2>
<p>{props.data.selectedStudentEmail ?? props.data.coachCard.description}</p>
<Show when={props.data.selectedStudentProgress}>
<div class={styles.progressNote}>{props.data.selectedStudentProgress}</div>
</Show>
<ul class={styles.noteList}>
<For each={props.data.coachCard.items}>{(item) => <li>{item}</li>}</For>
</ul>
<div class={styles.actionRow}>
<A href={getAssignmentReviewHref("teacher", props.data.assignmentId)} class={styles.secondaryAction}>
Back to review
</A>
</div>
</section>
</aside>
</div>
);
};
export default AssignmentTeacherNextStep;

View File

@@ -0,0 +1,107 @@
import { createTeacherAssignment, generateTeacherQuestions } from "../../dashboard/teacher/dashboard-teacher-assignments.data";
import type { TeacherRedoPlanData, TeacherRedoPlanQuestion } from "./assignment-teacher-review.types";
import type { RedoPlanGenerationResult, RedoPlanGroupedItem } from "./assignment-teacher-redo-plan.types";
const compareGroupedItems = (left: RedoPlanGroupedItem, right: RedoPlanGroupedItem) => {
if (left.topic !== right.topic) return left.topic.localeCompare(right.topic);
return left.difficulty.localeCompare(right.difficulty);
};
export const groupRedoPlanQuestions = (questionSet: TeacherRedoPlanQuestion[]): RedoPlanGroupedItem[] => {
const groupedItems = new Map<
string,
{
topic: string;
difficulty: "easy" | "medium" | "hard";
count: number;
tags: Set<string>;
reasons: string[];
}
>();
for (const item of questionSet) {
const key = `${item.topicKey}::${item.difficultyKey}`;
const existing = groupedItems.get(key);
if (existing) {
existing.count += 1;
item.tags.forEach((tag) => existing.tags.add(tag));
existing.reasons.push(item.reason);
continue;
}
groupedItems.set(key, {
topic: item.topicKey,
difficulty: item.difficultyKey,
count: 1,
tags: new Set(item.tags),
reasons: [item.reason],
});
}
return Array.from(groupedItems.values())
.map((item) => ({
topic: item.topic,
difficulty: item.difficulty,
count: item.count,
tags: Array.from(item.tags),
reasons: item.reasons,
}))
.sort(compareGroupedItems);
};
export const buildPlannedAreasMarkdown = (groupedItems: RedoPlanGroupedItem[]) =>
groupedItems
.map(
(item, index) =>
`- ${index + 1}. ${item.topic.replace(/_/g, " ")} (${item.difficulty}) x${item.count}${item.reasons.length > 0 ? `${item.reasons.join("; ")}` : ""}`,
)
.join("\n");
export const createRedoAssignmentForStudent = async (input: {
data: TeacherRedoPlanData;
teacherId: number;
}): Promise<RedoPlanGenerationResult> => {
const groupedItems = groupRedoPlanQuestions(input.data.plan?.questionSet ?? []);
const plannedAreas = buildPlannedAreasMarkdown(groupedItems);
const generatedQuestionIds: number[] = [];
const generatedDescriptions: string[] = [];
for (const item of groupedItems) {
const result = await generateTeacherQuestions({
topic: item.topic,
difficulty: item.difficulty,
count: item.count,
source: "redo_plan_generated",
});
generatedQuestionIds.push(...result.generatedQuestionIds);
generatedDescriptions.push(`${item.topic.replace(/_/g, " ")} ${item.difficulty} ×${result.count} (seed ${result.seed})`);
}
const assignment = await createTeacherAssignment({
teacherId: input.teacherId,
classroomId: input.data.classroomId,
title: `${input.data.selectedStudentName} redo • ${input.data.title}`,
instructions: [
`Student-specific redo follow-up for ${input.data.selectedStudentName}.`,
`Source assignment: ${input.data.title}`,
"",
`Plan rationale: ${input.data.plan?.rationale ?? ""}`,
input.data.teacherFeedback ? `Teacher feedback: ${input.data.teacherFeedback}` : "",
input.data.weaknessSummary.weakTags.length > 0 ? `Weak tags: ${input.data.weaknessSummary.weakTags.join(", ")}` : "",
"",
"Planned focus areas:",
plannedAreas,
]
.filter(Boolean)
.join("\n"),
dueAt: "",
selectedQuestionIds: Array.from(new Set(generatedQuestionIds)),
assignedStudentIds: [input.data.selectedStudentId],
});
return {
assignmentId: assignment.id,
successMessage: `Created a redo assignment for ${input.data.selectedStudentName}. ${generatedDescriptions.join("; ")}.`,
};
};

View File

@@ -0,0 +1,177 @@
import type { Component } from "solid-js";
import { A } from "@solidjs/router";
import { For, Show } from "solid-js";
import { getAssignmentReviewHref, getTeacherAssignmentNextStepHref } from "../../../lib/routes";
import type { TeacherRedoPlanData } from "./assignment-teacher-review.types";
import styles from "./assignment-teacher-review.module.scss";
type RedoPlanMainColumnProps = {
data: TeacherRedoPlanData;
};
export const RedoPlanMainColumn: Component<RedoPlanMainColumnProps> = (props) => (
<div class={styles.mainColumn}>
<section class={styles.section}>
<div class={styles.sectionHeader}>
<h2>Redo plan</h2>
<p>
AI prepared this redo plan for {props.data.selectedStudentName} based on the completed review, weakness summary, and teacher feedback.
</p>
</div>
<Show when={props.data.error}>
<div class={`${styles.notice} ${styles.noticeError}`.trim()}>{props.data.error}</div>
</Show>
<Show
when={props.data.plan}
fallback={<div class={styles.emptyState}>No redo plan is available yet. Save the next-step page with <strong>Redo assignment</strong> selected to generate one.</div>}
>
<section class={styles.nextStepCard}>
<div class={styles.sectionHeader}>
<h3>Rationale</h3>
<p>Why this student needs the planned mix of follow-up practice.</p>
</div>
<div class={styles.responseBlock}>
<strong>{props.data.plan!.rationale}</strong>
</div>
</section>
<section class={styles.section}>
<div class={styles.sectionHeader}>
<h3>Planned question set</h3>
<p>These blueprint slices will be turned into a student-specific redo assignment when you generate it.</p>
</div>
<div class={styles.planList}>
<For each={props.data.plan!.questionSet}>
{(item, index) => (
<article class={styles.planCard}>
<div class={styles.questionTop}>
<div>
<p class={styles.order}>Item {index() + 1}</p>
<h3>{item.topic}</h3>
</div>
<span class={`${styles.statusPill} ${styles.progress}`.trim()}>{item.difficulty}</span>
</div>
<Show when={item.tags.length > 0}>
<div class={styles.tagList}>
<For each={item.tags}>{(tag) => <span class={styles.tagPill}>{tag}</span>}</For>
</div>
</Show>
<div class={styles.supportBlock}>
<p>Reason</p>
<span>{item.reason}</span>
</div>
</article>
)}
</For>
</div>
</section>
</Show>
</section>
</div>
);
type RedoPlanSidebarProps = {
data: TeacherRedoPlanData;
isCreatingRedoAssignment: boolean;
generationError: string | null;
generationSuccess: string | null;
onGenerateRedoQuestions: () => void;
};
export const RedoPlanSidebar: Component<RedoPlanSidebarProps> = (props) => (
<aside class={styles.sideColumn}>
<section class={styles.sideCard}>
<p class={styles.sideEyebrow}>Student</p>
<h2>{props.data.selectedStudentName}</h2>
<p>{props.data.selectedStudentEmail}</p>
<div class={styles.progressNote}>Generated for {props.data.title}</div>
<Show when={props.data.generatedAtLabel}>
<div class={styles.draftHint}>Generated {props.data.generatedAtLabel}</div>
</Show>
<Show when={props.data.plan?.questionSet.length}>
<button type="button" class={styles.primaryAction} onClick={props.onGenerateRedoQuestions} disabled={props.isCreatingRedoAssignment}>
{props.isCreatingRedoAssignment ? "Creating redo assignment…" : "Generate redo questions"}
</button>
<p class={styles.progressNote}>This creates and assigns an independent redo assignment for {props.data.selectedStudentName} only.</p>
</Show>
<Show when={props.generationError}>
<div class={`${styles.notice} ${styles.noticeError}`.trim()}>{props.generationError}</div>
</Show>
<Show when={props.generationSuccess}>
<div class={`${styles.notice} ${styles.noticeSuccess}`.trim()}>{props.generationSuccess}</div>
</Show>
<div class={styles.actionRow}>
<A href={getTeacherAssignmentNextStepHref(props.data.assignmentId, props.data.selectedStudentId)} class={styles.secondaryAction}>
Back to next step
</A>
<A href={getAssignmentReviewHref("teacher", props.data.assignmentId)} class={styles.secondaryAction}>
Back to review
</A>
</div>
</section>
<section class={styles.sideCard}>
<div class={styles.sectionHeader}>
<h3>Teacher feedback</h3>
<p>The feedback context used when this redo plan was created.</p>
</div>
<div class={styles.supportBlock}>
<span>{props.data.teacherFeedback || "No teacher feedback saved yet."}</span>
</div>
</section>
<section class={styles.sideCard}>
<div class={styles.sectionHeader}>
<h3>Topic scores</h3>
<p>Historical performance snapshot used to plan the redo.</p>
</div>
<Show
when={props.data.weaknessSummary.topicScores.length > 0}
fallback={<div class={styles.emptyState}>No topic history is available yet for this student.</div>}
>
<div class={styles.scoreList}>
<For each={props.data.weaknessSummary.topicScores}>
{(item) => (
<div class={styles.scoreRow}>
<span>{item.topic}</span>
<strong>{item.score.toFixed(1)}%</strong>
</div>
)}
</For>
</div>
</Show>
</section>
<section class={styles.sideCard}>
<div class={styles.sectionHeader}>
<h3>Weak tags</h3>
<p>Low-performing or flagged tags from previous work.</p>
</div>
<Show
when={props.data.weaknessSummary.weakTags.length > 0}
fallback={<div class={styles.emptyState}>No weak tags were identified from the current review history.</div>}
>
<div class={styles.tagList}>
<For each={props.data.weaknessSummary.weakTags}>{(tag) => <span class={styles.tagPill}>{tag}</span>}</For>
</div>
</Show>
</section>
<section class={styles.sideCard}>
<div class={styles.sectionHeader}>
<h3>Recent issues</h3>
<p>Recent issue reasons pulled into the weakness summary.</p>
</div>
<Show
when={props.data.weaknessSummary.recentIssues.length > 0}
fallback={<div class={styles.emptyState}>No recent issue reasons were available.</div>}
>
<ul class={styles.noteList}>
<For each={props.data.weaknessSummary.recentIssues}>{(issue) => <li>{issue}</li>}</For>
</ul>
</Show>
</section>
</aside>
);

View File

@@ -0,0 +1,59 @@
import type { Component } from "solid-js";
import { useNavigate } from "@solidjs/router";
import { createSignal } from "solid-js";
import { useAuth } from "~/context/auth/context";
import { getAssignmentReviewHref } from "../../../lib/routes";
import { createRedoAssignmentForStudent } from "./assignment-teacher-redo-plan.helpers";
import { RedoPlanMainColumn, RedoPlanSidebar } from "./assignment-teacher-redo-plan.sections";
import type { AssignmentTeacherRedoPlanProps } from "./assignment-teacher-redo-plan.types";
import styles from "./assignment-teacher-review.module.scss";
const AssignmentTeacherRedoPlan: Component<AssignmentTeacherRedoPlanProps> = (props) => {
const auth = useAuth();
const navigate = useNavigate();
const [isCreatingRedoAssignment, setIsCreatingRedoAssignment] = createSignal(false);
const [generationError, setGenerationError] = createSignal<string | null>(null);
const [generationSuccess, setGenerationSuccess] = createSignal<string | null>(null);
const handleGenerateRedoQuestions = async () => {
if (!props.data.plan?.questionSet.length) return;
const teacherId = auth.user()?.role === "teacher" ? auth.user()!.id : null;
if (!teacherId) {
setGenerationError("Your teacher session is still loading.");
return;
}
setIsCreatingRedoAssignment(true);
setGenerationError(null);
setGenerationSuccess(null);
try {
const result = await createRedoAssignmentForStudent({
data: props.data,
teacherId,
});
setGenerationSuccess(result.successMessage);
void navigate(getAssignmentReviewHref("teacher", result.assignmentId));
} catch (error) {
setGenerationError(error instanceof Error ? error.message : "Unable to create the redo assignment right now.");
} finally {
setIsCreatingRedoAssignment(false);
}
};
return (
<div class={styles.contentGrid}>
<RedoPlanMainColumn data={props.data} />
<RedoPlanSidebar
data={props.data}
isCreatingRedoAssignment={isCreatingRedoAssignment()}
generationError={generationError()}
generationSuccess={generationSuccess()}
onGenerateRedoQuestions={() => void handleGenerateRedoQuestions()}
/>
</div>
);
};
export default AssignmentTeacherRedoPlan;

View File

@@ -0,0 +1,18 @@
import type { TeacherRedoPlanData } from "./assignment-teacher-review.types";
export type AssignmentTeacherRedoPlanProps = {
data: TeacherRedoPlanData;
};
export type RedoPlanGroupedItem = {
topic: string;
difficulty: "easy" | "medium" | "hard";
count: number;
tags: string[];
reasons: string[];
};
export type RedoPlanGenerationResult = {
assignmentId: number;
successMessage: string;
};

View File

@@ -0,0 +1,246 @@
// Path: Frontend/src/components/assignment/teacher/assignment-teacher-review.data.ts
import { apiFetchJson } from "../../../lib/api";
import type {
ApiAssignment,
ApiAssignmentRedoPlanResponse,
ApiAssignmentStudentQuestionDetail,
ApiClassroom,
ApiListResponse,
ApiReviewQueueItem,
ApiReviewSummary,
} from "../../../lib/api-types";
import {
buildCloseSummary,
buildQuestionTags,
extractTopic,
formatDateLabel,
formatRelativeLabel,
formatSolveMode,
mapQueueReviewStatus,
mapRedoPlan,
mapTopicScores,
normalizeScore,
questionStatus,
queueStatusLabel,
queueTone,
} from "./assignment-teacher-review.formatters";
import type {
TeacherAssignmentPassStatus,
TeacherAssignmentReviewPageData,
TeacherNextStepOutcome,
TeacherRedoPlanData,
TeacherReviewDraftFields,
TeacherReviewQuestion,
} from "./assignment-teacher-review.types";
type UpdateTeacherAnswerReviewInput = TeacherReviewDraftFields & {
status: "not_started" | "in_progress" | "submitted" | "reviewed";
reviewTags: string[];
};
type UpdateAssignmentTeacherFeedbackInput = {
teacherFeedback: string;
passStatusOverride: TeacherAssignmentPassStatus | null;
nextStepOutcome: TeacherNextStepOutcome | null;
};
export const getTeacherAssignmentReviewPageData = async (assignmentId: number, selectedStudentId?: number | null): Promise<TeacherAssignmentReviewPageData | null> => {
if (!Number.isFinite(assignmentId) || assignmentId <= 0) return null;
try {
const assignment = await apiFetchJson<ApiAssignment>(`/api/assignments/${assignmentId}`);
const [classrooms, summary, queueResponse] = await Promise.all([
apiFetchJson<ApiListResponse<ApiClassroom>>(`/api/teachers/${assignment.teacher_id}/classrooms`),
apiFetchJson<ApiReviewSummary>(`/api/assignments/${assignmentId}/review-summary`),
apiFetchJson<ApiListResponse<ApiReviewQueueItem>>(`/api/assignments/${assignmentId}/review`),
]);
const classroom = classrooms.data.find((entry) => entry.id === assignment.classroom_id);
const queue = queueResponse.data;
const resolvedStudentId = selectedStudentId ?? queue[0]?.student_id ?? null;
const selectedStudent = queue.find((item) => item.student_id === resolvedStudentId) ?? null;
const questionDetails = resolvedStudentId
? await apiFetchJson<ApiListResponse<ApiAssignmentStudentQuestionDetail>>(`/api/assignments/${assignmentId}/students/${resolvedStudentId}/questions`)
: { data: [] as ApiAssignmentStudentQuestionDetail[] };
const questions = questionDetails.data;
const assignmentAiFeedback = questions[0]?.assignment_ai_feedback?.trim() || "";
const assignmentTeacherFeedback = questions[0]?.assignment_teacher_feedback?.trim() || "";
const overallScore = normalizeScore(questions[0]?.overall_score);
const passThreshold =
typeof assignment.pass_threshold === "number"
? assignment.pass_threshold
: typeof questions[0]?.pass_threshold === "number"
? questions[0]!.pass_threshold!
: 6;
const nextStepOutcome = (questions[0]?.next_step_outcome as TeacherNextStepOutcome | undefined) ?? null;
const passStatusOverride = (questions[0]?.pass_status_override as TeacherAssignmentPassStatus | undefined) ?? null;
const passStatus = (questions[0]?.pass_status as TeacherAssignmentPassStatus | undefined) ?? "pending";
const topic = extractTopic(assignment, questions);
const reviewCoverage = summary.total_assigned > 0 ? Math.round((summary.reviewed / summary.total_assigned) * 100) : 0;
return {
assignmentId: assignment.id,
title: assignment.title,
classroomId: assignment.classroom_id,
classroomName: classroom?.name ?? "Classroom",
statusLabel: assignment.status === "draft" ? "Draft" : assignment.status === "closed" ? "Closed" : "Live",
headline: selectedStudent ? `Review ${selectedStudent.student_name}'s responses for ${topic}` : `Review submissions for ${topic}`,
description: selectedStudent
? `${selectedStudent.student_name} has ${selectedStudent.answered_questions} answered question${selectedStudent.answered_questions === 1 ? "" : "s"}. Flag any response that still needs attention, then move to the next step when the rest is ready.`
: "Choose a student from the review queue to inspect answers, working steps, and feedback status for this assignment.",
dueLabel: formatDateLabel(assignment.due_at),
stats: [
{ label: "Assigned", value: `${summary.total_assigned}` },
{ label: "Submitted", value: `${summary.submitted}` },
{ label: "Reviewed", value: `${summary.reviewed}` },
{ label: "Coverage", value: `${reviewCoverage}%` },
],
closeSummary: buildCloseSummary(assignment.status, queue),
reviewQueue: queue.map((item) => ({
studentId: item.student_id,
studentName: item.student_name,
email: item.student_email,
reviewStatus: item.review_status,
nextStepOutcome: mapQueueReviewStatus(item.next_step_outcome),
submittedQuestions: item.submitted_questions,
reviewedQuestions: item.reviewed_questions,
progressLabel: `${item.reviewed_questions}/${item.total_questions} reviewed · ${item.answered_questions} answered`,
statusLabel: queueStatusLabel(item),
statusTone: queueTone(item),
timestampLabel: formatRelativeLabel(item.latest_submitted_at ?? item.latest_reviewed_at, "No submission yet"),
})),
selectedStudentId: selectedStudent?.student_id ?? null,
selectedStudentName: selectedStudent?.student_name ?? null,
selectedStudentEmail: selectedStudent?.student_email ?? null,
selectedStudentProgress: selectedStudent ? `${selectedStudent.reviewed_questions} of ${selectedStudent.total_questions} reviewed · ${selectedStudent.submitted_questions} waiting` : null,
selectedStudentReviewStatus: selectedStudent?.review_status ?? null,
selectedStudentSubmittedQuestions: selectedStudent?.submitted_questions ?? 0,
selectedStudentReviewedQuestions: selectedStudent?.reviewed_questions ?? 0,
assignmentAiFeedback,
assignmentTeacherFeedback,
overallScore,
passThreshold,
nextStepOutcome,
passStatusOverride,
passStatus,
coachCard: {
title: selectedStudent ? "Teacher review notes" : "Review queue guidance",
description: selectedStudent
? "Flag only the responses that still need attention. Moving to the next step will treat everything else as reviewed."
: "Start with the freshest submitted work, then move through the queue in order of urgency.",
items: [`${summary.submitted} submission${summary.submitted === 1 ? "" : "s"} waiting`, `${summary.in_progress} in progress`, `${summary.not_started} not started`],
},
questions: questions.map((question): TeacherReviewQuestion => {
const status = questionStatus(question);
return {
id: question.question_id,
order: question.position,
prompt: question.prompt,
subject: question.subject,
source: question.source,
questionTags: buildQuestionTags(question),
answerId: question.answer_id ?? null,
answerStatus: (question.answer_status as TeacherReviewQuestion["answerStatus"]) ?? null,
statusLabel: status.statusLabel,
statusTone: status.statusTone,
answerText: question.answer_text?.trim() || "No answer has been submitted for this question yet.",
solveModeLabel: formatSolveMode(question.solve_mode),
workingSteps: question.working_steps?.trim() || "",
correctAnswer: question.correct_answer?.trim() || null,
isCorrect: typeof question.is_correct === "boolean" ? question.is_correct : null,
reviewNeedsAttention: Boolean(question.review_needs_attention),
reviewIssueReason: question.review_issue_reason?.trim() || "",
reviewCorrectnessScore: normalizeScore(question.review_correctness_score),
reviewUnderstandingScore: normalizeScore(question.review_understanding_score),
reviewQuestionScore: normalizeScore(question.review_question_score),
reviewConfidence: normalizeScore(question.review_confidence),
correctnessLabel:
typeof question.is_correct === "boolean"
? question.is_correct
? "Matches the saved correct answer"
: "Does not match the saved correct answer yet"
: question.correct_answer
? "Correct answer is available for comparison"
: "No correct answer saved yet",
submittedLabel: formatDateLabel(question.submitted_at, "Not submitted"),
reviewedLabel: formatDateLabel(question.reviewed_at, "Not reviewed yet"),
};
}),
};
} catch (error) {
if (error instanceof Error && error.message === "not_found") {
return null;
}
throw error;
}
};
export const updateTeacherAnswerReview = async (answerId: number, input: UpdateTeacherAnswerReviewInput) => {
return await apiFetchJson(`/api/answers/${answerId}/review`, {
method: "PATCH",
allowNoContent: true,
body: JSON.stringify({
status: input.status,
review_needs_attention: input.reviewNeedsAttention,
review_issue_reason: input.reviewIssueReason || null,
review_correctness_score: input.reviewCorrectnessScore,
review_understanding_score: input.reviewUnderstandingScore,
review_question_score: input.reviewQuestionScore,
review_confidence: input.reviewConfidence,
review_tags: input.reviewTags,
}),
});
};
export const updateAssignmentTeacherFeedback = async (assignmentId: number, studentId: number, input: UpdateAssignmentTeacherFeedbackInput) => {
return await apiFetchJson(`/api/assignments/${assignmentId}/students/${studentId}/feedback`, {
method: "PATCH",
allowNoContent: true,
body: JSON.stringify({
teacher_feedback: input.teacherFeedback,
pass_status_override: input.passStatusOverride,
next_step_outcome: input.nextStepOutcome,
}),
});
};
export const closeTeacherAssignment = async (assignmentId: number) => {
return await apiFetchJson<ApiAssignment>(`/api/assignments/${assignmentId}/close`, {
method: "POST",
parseErrorMessage: true,
});
};
export const getTeacherAssignmentRedoPlanData = async (assignmentId: number, studentId: number): Promise<TeacherRedoPlanData | null> => {
if (!Number.isFinite(assignmentId) || assignmentId <= 0 || !Number.isFinite(studentId) || studentId <= 0) return null;
const [reviewData, redoPlan] = await Promise.all([
getTeacherAssignmentReviewPageData(assignmentId, studentId),
apiFetchJson<ApiAssignmentRedoPlanResponse>(`/api/assignments/${assignmentId}/students/${studentId}/redo-plan`, { notFoundAsNull: true }),
]);
if (!reviewData || !redoPlan || !reviewData.selectedStudentId || !reviewData.selectedStudentName || !reviewData.selectedStudentEmail) {
return null;
}
return {
assignmentId: reviewData.assignmentId,
title: reviewData.title,
classroomId: reviewData.classroomId,
classroomName: reviewData.classroomName,
selectedStudentId: reviewData.selectedStudentId,
selectedStudentName: reviewData.selectedStudentName,
selectedStudentEmail: reviewData.selectedStudentEmail,
teacherFeedback: redoPlan.teacher_feedback?.trim() || reviewData.assignmentTeacherFeedback,
generatedAtLabel: redoPlan.redo_plan_generated_at ? formatRelativeLabel(redoPlan.redo_plan_generated_at) : null,
weaknessSummary: {
studentId: redoPlan.weakness_summary.student_id,
topicScores: mapTopicScores(redoPlan.weakness_summary),
weakTags: Array.isArray(redoPlan.weakness_summary.weak_tags) ? redoPlan.weakness_summary.weak_tags.filter(Boolean) : [],
recentIssues: Array.isArray(redoPlan.weakness_summary.recent_issues) ? redoPlan.weakness_summary.recent_issues.filter(Boolean) : [],
},
plan: mapRedoPlan(redoPlan.plan),
error: redoPlan.error?.trim() || null,
};
};

View File

@@ -0,0 +1,116 @@
import type { TeacherReviewDraftFields, TeacherReviewQuestion } from "./assignment-teacher-review.types";
import { assignmentUiCopy } from "~/content/ui-copy";
export type QuestionReviewDraft = TeacherReviewDraftFields & {
answerId: number;
};
export type StudentReviewDraft = {
questionReviews?: Record<string, QuestionReviewDraft>;
};
export type AssignmentReviewDraftStore = {
students: Record<string, StudentReviewDraft>;
};
export const EMPTY_DRAFT_STORE: AssignmentReviewDraftStore = {
students: {},
};
export const draftStorageKeyForAssignment = (assignmentId: number) => `teacher-review-draft:${assignmentId}`;
export const readDraftStore = (assignmentId: number): AssignmentReviewDraftStore => {
if (typeof window === "undefined") return EMPTY_DRAFT_STORE;
try {
const raw = window.localStorage.getItem(draftStorageKeyForAssignment(assignmentId));
if (!raw) return EMPTY_DRAFT_STORE;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || typeof parsed.students !== "object" || parsed.students === null) {
return EMPTY_DRAFT_STORE;
}
return {
students: parsed.students as AssignmentReviewDraftStore["students"],
};
} catch {
return EMPTY_DRAFT_STORE;
}
};
export const writeDraftStore = (assignmentId: number, draftStore: AssignmentReviewDraftStore) => {
if (typeof window === "undefined") return;
const key = draftStorageKeyForAssignment(assignmentId);
if (Object.keys(draftStore.students).length === 0) {
window.localStorage.removeItem(key);
return;
}
window.localStorage.setItem(key, JSON.stringify(draftStore));
};
export const reviewFieldsFromQuestion = (question: TeacherReviewQuestion): TeacherReviewDraftFields => ({
reviewNeedsAttention: question.reviewNeedsAttention,
reviewIssueReason: question.reviewIssueReason,
reviewCorrectnessScore: question.reviewCorrectnessScore,
reviewUnderstandingScore: question.reviewUnderstandingScore,
reviewQuestionScore: question.reviewQuestionScore,
reviewConfidence: question.reviewConfidence,
});
const normalizeOptionalScore = (value: number | null) => (typeof value === "number" && Number.isFinite(value) ? Math.min(1, Math.max(0, Number(value.toFixed(3)))) : null);
export const sanitizeReviewFields = (fields: TeacherReviewDraftFields): TeacherReviewDraftFields => ({
reviewNeedsAttention: Boolean(fields.reviewNeedsAttention),
reviewIssueReason: fields.reviewIssueReason.trim(),
reviewCorrectnessScore: 1,
reviewUnderstandingScore: normalizeOptionalScore(fields.reviewUnderstandingScore),
reviewQuestionScore: 1,
reviewConfidence: normalizeOptionalScore(fields.reviewConfidence),
});
export const reviewFieldsEqual = (left: TeacherReviewDraftFields, right: TeacherReviewDraftFields) =>
left.reviewNeedsAttention === right.reviewNeedsAttention &&
left.reviewIssueReason === right.reviewIssueReason &&
left.reviewCorrectnessScore === right.reviewCorrectnessScore &&
left.reviewUnderstandingScore === right.reviewUnderstandingScore &&
left.reviewQuestionScore === right.reviewQuestionScore &&
left.reviewConfidence === right.reviewConfidence;
const questionStatusMeta = (status: TeacherReviewQuestion["answerStatus"]) => {
if (status === "reviewed") {
return { statusLabel: assignmentUiCopy.teacherReview.status.reviewed, statusTone: "success" as const };
}
if (status === "submitted") {
return { statusLabel: assignmentUiCopy.teacherReview.status.submitted, statusTone: "review" as const };
}
if (status === "in_progress") {
return { statusLabel: assignmentUiCopy.teacherReview.status.inProgress, statusTone: "progress" as const };
}
return { statusLabel: assignmentUiCopy.teacherReview.status.noAnswerYet, statusTone: "muted" as const };
};
export const applyReviewDrafts = (questions: TeacherReviewQuestion[], studentDraft: StudentReviewDraft | null) =>
questions.map((question) => {
const draftReview = studentDraft?.questionReviews?.[String(question.id)] ?? null;
if (!draftReview) return question;
const effectiveReview = sanitizeReviewFields({
...reviewFieldsFromQuestion(question),
...draftReview,
});
const nextStatus = effectiveReview.reviewNeedsAttention ? "submitted" : question.answerStatus;
const statusMeta = questionStatusMeta(nextStatus);
return {
...question,
...effectiveReview,
answerStatus: nextStatus,
statusLabel: effectiveReview.reviewNeedsAttention ? assignmentUiCopy.teacherReview.status.needsAttention : statusMeta.statusLabel,
statusTone: statusMeta.statusTone,
reviewedLabel: effectiveReview.reviewNeedsAttention ? assignmentUiCopy.teacherReview.status.needsAttentionDraft : question.reviewedLabel,
};
});
export const countPendingQuestionDrafts = (draftStore: AssignmentReviewDraftStore) =>
Object.values(draftStore.students).reduce((count, studentDraft) => count + Object.keys(studentDraft.questionReviews ?? {}).length, 0);

View File

@@ -0,0 +1,194 @@
import type {
ApiAssignment,
ApiAssignmentStudentQuestionDetail,
ApiRedoPlan,
ApiRedoPlanQuestion,
ApiReviewQueueItem,
ApiStudentWeaknessSummary,
} from "../../../lib/api-types";
import type {
TeacherAssignmentCloseSummary,
TeacherNextStepOutcome,
TeacherRedoPlanQuestion,
TeacherReviewQueueItem,
} from "./assignment-teacher-review.types";
import { assignmentUiCopy } from "~/content/ui-copy";
export const formatDateLabel = (value: string | null | undefined, fallback = "No due date") => {
if (!value) return fallback;
return new Intl.DateTimeFormat("en-GB", {
weekday: "short",
month: "short",
day: "numeric",
}).format(new Date(value));
};
export const formatRelativeLabel = (value: string | null | undefined, fallback = "No recent update") => {
if (!value) return fallback;
const timestamp = new Date(value).getTime();
if (Number.isNaN(timestamp)) return fallback;
const diffMinutes = Math.max(0, Math.round((Date.now() - timestamp) / 60000));
if (diffMinutes < 1) return "Just now";
if (diffMinutes < 60) return `${diffMinutes} min ago`;
const diffHours = Math.round(diffMinutes / 60);
if (diffHours < 24) return `${diffHours} hr ago`;
const diffDays = Math.round(diffHours / 24);
return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`;
};
export const formatSolveMode = (value: ApiAssignmentStudentQuestionDetail["solve_mode"]) => {
switch (value) {
case "step_by_step":
return "Step by step";
case "solve_together":
return "Solve together";
case "handwritten":
return "Handwritten";
case "just_answer":
return "Just answer";
default:
return null;
}
};
export const extractTopic = (assignment: ApiAssignment, questions: ApiAssignmentStudentQuestionDetail[]) => {
const fromInstructions = assignment.instructions?.match(/^Topic:\s*(.+)$/im)?.[1]?.trim();
if (fromInstructions) return fromInstructions;
return questions[0]?.subject ?? "this assignment";
};
export const queueTone = (item: ApiReviewQueueItem): TeacherReviewQueueItem["statusTone"] => {
if (item.review_status === "submitted") return "review";
if (item.review_status === "in_progress" || item.reviewed_questions > 0 || item.answered_questions > 0) return "progress";
if (item.next_step_outcome === "accept") return "success";
if (item.next_step_outcome === "redo" || item.next_step_outcome === "support") return "review";
return "muted";
};
export const queueStatusLabel = (item: ApiReviewQueueItem) => {
if (item.review_status === "submitted") return "Submitted";
if (item.review_status === "in_progress" || item.reviewed_questions > 0 || item.answered_questions > 0) return "In progress";
if (item.next_step_outcome === "redo") return "Redo";
if (item.next_step_outcome === "accept") return "Accept";
if (item.next_step_outcome === "support") return "Support";
return "Not started";
};
export const buildCloseSummary = (assignmentStatus: ApiAssignment["status"], queue: ApiReviewQueueItem[]): TeacherAssignmentCloseSummary => {
if (assignmentStatus === "closed") {
return {
state: "closed",
canClose: false,
blockers: [],
summary: "This assignment is already closed.",
};
}
if (queue.length === 0) {
return {
state: "blocked",
canClose: false,
blockers: ["No students have been assigned yet."],
summary: "Assign at least one student before closing this assignment.",
};
}
const blockers = queue.flatMap((item) => {
if (item.submitted_questions > 0 || item.review_status === "submitted") {
return [`${item.student_name} still has submitted work waiting for review.`];
}
if (item.in_progress_questions > 0 || item.review_status === "in_progress") {
return [`${item.student_name} still has work in progress.`];
}
if (item.answered_questions === 0 || item.review_status === "not_started") {
return [`${item.student_name} has not started this assignment yet.`];
}
if (!item.next_step_outcome) {
return [`${item.student_name} still needs a next-step decision.`];
}
return [] as string[];
});
if (blockers.length > 0) {
return {
state: "blocked",
canClose: false,
blockers,
summary: `${blockers.length} blocker${blockers.length === 1 ? "" : "s"} still need attention before this assignment can be closed.`,
};
}
return {
state: "ready",
canClose: true,
blockers: [],
summary: "All assigned students have been reviewed and given a next-step decision. This assignment is ready to close.",
};
};
export const questionStatus = (question: ApiAssignmentStudentQuestionDetail) => {
if (question.review_needs_attention) {
return { statusLabel: assignmentUiCopy.teacherReview.status.needsAttention, statusTone: "review" as const };
}
if (question.answer_status === "reviewed") {
return { statusLabel: assignmentUiCopy.teacherReview.status.reviewed, statusTone: "success" as const };
}
if (question.answer_status === "submitted") {
return { statusLabel: assignmentUiCopy.teacherReview.status.submitted, statusTone: "review" as const };
}
if (question.answer_status === "in_progress") {
return { statusLabel: assignmentUiCopy.teacherReview.status.inProgress, statusTone: "progress" as const };
}
return { statusLabel: assignmentUiCopy.teacherReview.status.noAnswerYet, statusTone: "muted" as const };
};
export const normalizeScore = (value: number | null | undefined) => (typeof value === "number" && Number.isFinite(value) ? value : null);
export const normalizeTags = (value: string[] | null | undefined) =>
Array.isArray(value)
? value.map((tag) => tag.trim()).filter(Boolean)
: [];
export const buildQuestionTags = (question: ApiAssignmentStudentQuestionDetail) => {
const tags = [...normalizeTags(question.question_tags), question.subject?.trim() || "", formatSolveMode(question.solve_mode) || ""].filter(Boolean);
return Array.from(new Set(tags));
};
export const formatTopicLabel = (value: string) =>
value
.split("_")
.map((part) => (part ? part[0]!.toUpperCase() + part.slice(1) : part))
.join(" ");
export const formatDifficultyLabel = (value: string) => (value ? value[0]!.toUpperCase() + value.slice(1).toLowerCase() : value);
export const mapTopicScores = (summary: ApiStudentWeaknessSummary) =>
Object.entries(summary.topic_scores ?? {})
.map(([topic, score]) => ({ topic: formatTopicLabel(topic), score }))
.sort((left, right) => left.score - right.score || left.topic.localeCompare(right.topic));
export const mapRedoPlanQuestion = (item: ApiRedoPlanQuestion): TeacherRedoPlanQuestion => ({
topic: formatTopicLabel(item.topic),
topicKey: item.topic,
difficulty: formatDifficultyLabel(item.difficulty),
difficultyKey: item.difficulty,
tags: Array.isArray(item.tags) ? item.tags.filter(Boolean) : [],
reason: item.reason?.trim() || "No reason provided.",
});
export const mapRedoPlan = (plan: ApiRedoPlan | null | undefined) => {
if (!plan) return null;
return {
rationale: plan.rationale?.trim() || "No rationale provided.",
questionSet: Array.isArray(plan.questionSet) ? plan.questionSet.map(mapRedoPlanQuestion) : [],
};
};
export const mapQueueReviewStatus = (value: ApiReviewQueueItem["next_step_outcome"]): TeacherNextStepOutcome | null => value ?? null;

View File

@@ -0,0 +1,824 @@
.section,
.sideCard {
background: var(--surface-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius-3xl);
box-shadow: var(--shadow-soft);
padding: 1.2rem;
min-width: 0;
}
.primaryAction,
.secondaryAction,
.queueButton {
font: inherit;
}
.primaryAction,
.secondaryAction {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.8rem 1rem;
border-radius: var(--radius-pill);
text-decoration: none;
font-weight: 600;
border: 1px solid transparent;
transition:
background-color 0.2s ease,
border-color 0.2s ease,
transform 0.2s ease,
opacity 0.2s ease;
}
.secondaryAction {
background: var(--surface-soft);
color: var(--text);
border-color: var(--border-soft);
}
.secondaryAction:hover {
text-decoration: none;
background: color-mix(in srgb, var(--surface-soft) 88%, white 12%);
border-color: var(--border-soft);
}
.primaryAction {
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
color: var(--action-primary-text);
box-shadow: var(--action-primary-shadow);
}
.primaryAction:hover {
filter: saturate(1.02);
}
.primaryAction:disabled,
.secondaryAction:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.sideEyebrow,
.order,
.feedbackBlock label,
.responseBlock p,
.supportBlock p {
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.78rem;
font-weight: 700;
color: var(--text-subtle);
}
.statusPill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.42rem 0.68rem;
border-radius: var(--radius-pill);
font-size: 0.78rem;
font-weight: 700;
line-height: 1;
white-space: nowrap;
text-transform: uppercase;
border: 1px solid transparent;
}
.review {
background: var(--surface-warning-tint);
color: var(--warning-text);
border-color: color-mix(in srgb, var(--warning-text) 24%, transparent 76%);
}
.progress {
background: color-mix(in srgb, var(--surface-info) 20%, white 80%);
color: var(--info);
border-color: color-mix(in srgb, var(--info) 24%, transparent 76%);
}
.success {
background: var(--surface-success-tint);
color: var(--success-text);
border-color: var(--border-success-soft);
}
.muted {
background: var(--surface-soft);
color: var(--text-muted);
border-color: var(--border-soft);
}
.contentGrid {
display: grid;
gap: 1.25rem;
@include respond(desktop-lg) {
grid-template-columns: minmax(0, 1.45fr) minmax(19rem, 0.85fr);
align-items: start;
}
}
@media (max-width: 1023px) {
.contentGrid {
display: flex;
flex-direction: column;
}
.sideColumn {
order: -1;
}
}
.mainColumn,
.sideColumn {
min-width: 0;
}
.sideColumn {
display: grid;
gap: 1rem;
min-height: 0;
@include respond(desktop-lg) {
position: sticky;
top: 1.25rem;
max-height: calc(100dvh - 2.5rem);
overflow-y: auto;
padding-right: 0.2rem;
}
}
.queueCard {
min-height: 0;
}
.mobileStickyNav {
position: static;
}
.mobileStickyNavHeader {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 0.75rem;
}
.mobileStickyNavActions {
display: flex;
align-items: center;
gap: 0.55rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.mobileStickyNavToggle {
display: none;
align-items: center;
justify-content: center;
padding: 0.7rem 0.9rem;
border-radius: var(--radius-full);
border: 1px solid var(--border-soft);
background: var(--surface-panel-strong);
color: var(--text);
font: inherit;
font-size: 0.84rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.mobileStickyNavBackToTop {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.7rem 0.9rem;
border-radius: var(--radius-full);
border: 1px solid color-mix(in srgb, var(--border-soft) 74%, white 26%);
background: color-mix(in srgb, var(--surface-panel-strong) 82%, white 18%);
color: var(--text);
font: inherit;
font-size: 0.84rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
box-shadow: 0 8px 18px hsl(220 35% 12% / 0.1);
transition:
background-color 0.2s ease,
border-color 0.2s ease,
transform 0.2s ease;
&:hover {
transform: translateY(-1px);
background: color-mix(in srgb, var(--surface-panel-strong) 74%, white 26%);
}
}
.mobileStickyNavContent {
display: grid;
gap: 0.85rem;
}
.mobileStickyNavContentExpanded {
display: grid;
gap: 0.85rem;
}
.mobileQuestionNavList {
display: grid;
gap: 0.65rem;
}
.mobileQuestionNavButton {
display: grid;
gap: 0.15rem;
padding: 0.85rem 0.95rem;
text-align: left;
border-radius: var(--radius-md);
border: 1px solid var(--border-divider);
background: var(--surface-panel-strong);
color: var(--text);
cursor: pointer;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
transform 0.2s ease;
&:hover {
transform: translateY(-1px);
}
strong {
font-size: 0.95rem;
color: var(--text);
}
small {
color: var(--text-muted);
}
}
.mobileQuestionNavAnswered {
border-color: color-mix(in srgb, var(--border-soft) 62%, var(--info) 38%);
background: color-mix(in srgb, var(--surface-soft) 88%, white 12%);
}
.sectionHeader,
.sideCard {
display: grid;
gap: 0.4rem;
}
.questionList,
.queueList,
.noteList {
display: grid;
gap: 0.85rem;
}
.sideActions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.queueScroller {
min-height: 0;
}
.assignmentFeedbackCard {
display: grid;
gap: 0.85rem;
padding: 1rem;
border-radius: var(--radius-lg);
border: 1px solid var(--border-soft);
background: var(--surface-panel-strong);
margin-bottom: 0.95rem;
}
.saveAllCard {
display: grid;
gap: 0.85rem;
padding: 1rem;
border-radius: var(--radius-lg);
border: 1px solid var(--border-soft);
background: var(--surface-panel-strong);
margin-top: 1rem;
}
.nextStepCard,
.optionCard {
display: grid;
gap: 0.85rem;
padding: 1rem;
border-radius: var(--radius-lg);
border: 1px solid var(--border-soft);
background: var(--surface-panel-strong);
min-width: 0;
}
.planList,
.scoreList {
display: grid;
gap: 0.85rem;
}
.planCard {
display: grid;
gap: 0.85rem;
padding: 1rem;
border-radius: var(--radius-lg);
border: 1px solid var(--border-soft);
background: var(--surface-panel-strong);
min-width: 0;
}
.scoreRow {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
padding: 0.8rem 0.95rem;
border-radius: var(--radius-md);
border: 1px solid var(--border-soft);
background: var(--surface-soft);
span {
color: var(--text);
}
strong {
color: var(--text-muted);
}
}
.questionCard,
.queueButton {
display: grid;
gap: 0.85rem;
padding: 1rem;
border-radius: var(--radius-lg);
border: 1px solid var(--border-soft);
background: var(--surface-panel-strong);
min-width: 0;
}
.optionGrid {
display: grid;
gap: 0.85rem;
@include respond(tablet) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.optionCard {
text-align: left;
cursor: pointer;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
transform 0.2s ease;
strong {
font-size: 1rem;
color: var(--text);
}
span {
color: var(--text-muted);
line-height: 1.5;
}
&:hover {
border-color: color-mix(in srgb, var(--border-soft) 68%, var(--info) 32%);
background: color-mix(in srgb, var(--surface-panel-strong) 86%, white 14%);
}
}
.optionCardActive {
border-color: color-mix(in srgb, var(--border-soft) 54%, var(--info) 46%);
background: color-mix(in srgb, var(--surface-info) 14%, white 86%);
box-shadow: var(--focus-ring-info-shadow);
}
.questionTop {
display: flex;
justify-content: space-between;
align-items: start;
gap: 0.75rem;
h3 {
line-height: 1.25;
}
}
.metaRow {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
span {
padding: 0.35rem 0.55rem;
border-radius: var(--radius-pill);
background: var(--surface-soft);
color: color-mix(in srgb, var(--text-muted) 84%, var(--info) 16%);
font-size: 0.82rem;
border: 1px solid var(--border-soft);
}
}
.responseBlock,
.supportBlock,
.feedbackBlock {
display: grid;
gap: 0.45rem;
}
.reviewEditorCard {
display: grid;
gap: 0.9rem;
padding: 0.95rem 1rem;
border-radius: var(--radius-md);
border: 1px solid var(--border-soft);
background: color-mix(in srgb, var(--surface-soft) 88%, white 12%);
}
.reviewEditorTop {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
h4 {
font-size: 1rem;
color: var(--text);
}
}
.reviewCheckbox {
display: inline-flex;
align-items: center;
gap: 0.55rem;
font-weight: 600;
color: var(--text);
input {
inline-size: 1rem;
block-size: 1rem;
}
}
.reviewField {
display: grid;
gap: 0.35rem;
label {
font-size: 0.85rem;
font-weight: 700;
color: var(--text-muted);
}
small {
color: var(--text-muted);
line-height: 1.4;
}
}
.reviewScoreGrid {
display: grid;
gap: 0.75rem;
@include respond(tablet) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.scoreHighlight {
padding: 1rem;
border-radius: var(--radius-lg);
border: 1px solid color-mix(in srgb, var(--border-soft) 56%, var(--info) 44%);
background: color-mix(in srgb, var(--surface-info) 12%, white 88%);
@include respond(tablet) {
grid-column: 1 / -1;
}
label {
font-size: 0.8rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
small {
font-size: 0.9rem;
}
}
.scoreHighlightValue {
display: flex;
align-items: baseline;
gap: 0.5rem;
min-height: 3rem;
strong,
span {
font-size: clamp(2rem, 4vw, 3rem);
line-height: 1;
font-weight: 800;
color: var(--text);
}
}
.compactInput {
width: 100%;
min-width: 0;
padding: 0.75rem 0.85rem;
border-radius: var(--radius-md);
border: 1px solid var(--border-soft);
background: var(--surface-panel);
color: var(--text);
font: inherit;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
background-color 0.2s ease;
&:focus {
outline: none;
border-color: color-mix(in srgb, var(--border-soft) 56%, var(--info) 44%);
box-shadow: var(--focus-ring-info-shadow);
background: color-mix(in srgb, var(--surface-panel) 88%, white 12%);
}
}
.tagList {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tagPill {
display: inline-flex;
align-items: center;
padding: 0.4rem 0.7rem;
border-radius: 999px;
border: 1px solid var(--border-soft);
background: var(--surface-soft);
color: var(--text-muted);
font-size: 0.9rem;
line-height: 1.2;
}
.reviewDraftEmpty {
padding: 0.9rem 1rem;
border-radius: var(--radius-md);
border: 1px dashed var(--border-soft);
background: var(--surface-soft);
color: var(--text-muted);
font-size: 0.94rem;
line-height: 1.45;
}
.responseBlock,
.supportBlock {
padding: 0.9rem 1rem;
border-radius: var(--radius-md);
border: 1px solid var(--border-soft);
background: var(--surface-soft);
}
.responseBlock strong,
.supportBlock span,
.supportBlock pre {
font-size: 0.98rem;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.responseBlock strong {
color: color-mix(in srgb, var(--text) 88%, var(--info) 12%);
}
.supportBlock span,
.supportBlock pre {
color: var(--text-muted);
margin: 0;
}
.feedbackInput {
width: 100%;
min-height: 8rem;
padding: 0.9rem 1rem;
border-radius: var(--radius-md);
border: 1px solid var(--border-soft);
background: var(--surface-soft);
color: var(--text);
font: inherit;
resize: vertical;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
background-color 0.2s ease;
}
.feedbackInput:focus {
outline: none;
border-color: color-mix(in srgb, var(--border-soft) 56%, var(--info) 44%);
box-shadow: var(--focus-ring-info-shadow);
background: color-mix(in srgb, var(--surface-soft) 88%, white 12%);
}
.draftHint {
font-size: 0.92rem;
line-height: 1.45;
color: var(--text-muted);
}
.actionRow {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.notice {
padding: 0.8rem 0.95rem;
border-radius: var(--radius-md);
font-size: 0.92rem;
font-weight: 600;
}
.noticeSuccess {
background: var(--surface-success-tint);
color: var(--success-text);
}
.noticeError {
background: var(--surface-danger);
color: var(--danger-text);
}
.queueButton {
text-align: left;
cursor: pointer;
color: var(--text);
transition:
border-color 0.2s ease,
background-color 0.2s ease;
strong {
color: var(--text);
}
p {
color: var(--text-muted);
}
&:hover {
border-color: color-mix(in srgb, var(--border-soft) 70%, var(--info) 30%);
background: color-mix(in srgb, var(--surface-soft) 92%, white 8%);
}
}
.queueButtonActive {
border-color: color-mix(in srgb, var(--border-soft) 60%, var(--info) 40%);
background: color-mix(in srgb, var(--surface-soft) 86%, white 14%);
}
.queueMeta {
display: grid;
gap: 0.35rem;
justify-items: start;
small {
color: var(--text-muted);
}
}
.queueEmpty,
.emptyState,
.progressNote {
padding: 0.95rem;
border-radius: var(--radius-md);
background: var(--surface-soft);
color: var(--text-muted);
border: 1px dashed var(--border-soft);
}
.closeAssignmentStatusRow {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.closeBlockerList {
display: grid;
gap: 0.55rem;
padding-left: 1rem;
li {
color: var(--text-muted);
line-height: 1.45;
}
}
.noteList {
padding-left: 1rem;
li {
color: var(--text-muted);
line-height: 1.45;
}
}
@media (max-width: 1023px) {
.mobileStickyNav {
position: sticky;
top: 0.75rem;
z-index: 25;
pointer-events: auto;
background: color-mix(in srgb, var(--surface-panel) 84%, white 16%);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid color-mix(in srgb, var(--border-soft) 78%, white 22%);
border-radius: calc(var(--radius-3xl) + 0.25rem);
box-shadow: 0 16px 36px hsl(220 35% 12% / 0.14);
}
.mobileStickyNav {
gap: 0.75rem;
padding: 0.95rem;
}
.mobileStickyNavHeader {
grid-template-columns: minmax(0, 1fr);
}
.mobileStickyNavActions {
justify-content: space-between;
}
.mobileStickyNavToggle {
display: inline-flex;
}
.mobileStickyNavContent {
display: none;
}
.mobileStickyNavContentExpanded {
display: grid;
}
.queueScroller,
.mobileQuestionNavList {
max-height: min(55dvh, 24rem);
overflow-y: auto;
padding-right: 0.15rem;
}
.section,
.sideCard,
.questionCard,
.queueButton,
.assignmentFeedbackCard,
.saveAllCard {
padding: 1rem;
}
.questionTop,
.reviewEditorTop {
display: grid;
gap: 0.6rem;
}
.questionCard {
scroll-margin-top: 8.5rem;
}
.sideActions {
gap: 0.55rem;
}
.sideActions > * {
flex: 1 1 auto;
}
.queueMeta {
width: 100%;
grid-template-columns: minmax(0, 1fr);
}
}
@include respond(desktop-lg) {
.mobileStickyNavToggle {
display: none;
}
.mobileStickyNavContent {
display: grid;
}
}

View File

@@ -0,0 +1,388 @@
// Path: Frontend/src/components/assignment/teacher/assignment-teacher-review.sections.tsx
import type { Component, JSX } from "solid-js";
import { A } from "@solidjs/router";
import { createSignal, For, Show } from "solid-js";
import { assignmentUiCopy } from "~/content/ui-copy";
import type { TeacherAssignmentReviewPageData, TeacherReviewDraftFields, TeacherReviewNotice, TeacherReviewQuestion } from "./assignment-teacher-review.types";
import { getDashboardAssignmentsHref } from "../../../lib/routes";
import styles from "./assignment-teacher-review.module.scss";
const toneClass = (tone: string) => {
switch (tone) {
case "review":
return styles.review;
case "progress":
return styles.progress;
case "success":
return styles.success;
default:
return styles.muted;
}
};
type AssignmentFeedbackSectionProps = {
data: TeacherAssignmentReviewPageData;
teacherFeedbackDraft: string;
hasPendingAssignmentFeedback: boolean;
busy: boolean;
notice: TeacherReviewNotice;
draftActionLabel: string;
actions?: JSX.Element;
onTeacherFeedbackInput: (value: string) => void;
};
export const AssignmentFeedbackSection: Component<AssignmentFeedbackSectionProps> = (props) => (
<section class={styles.assignmentFeedbackCard}>
<div class={styles.sectionHeader}>
<h3>{assignmentUiCopy.teacherReview.feedback.title}</h3>
<p>Keep AI feedback and teacher feedback in one place for the whole assignment.</p>
</div>
<div class={styles.supportBlock}>
<p>{assignmentUiCopy.teacherReview.feedback.aiFeedback}</p>
<span>{props.data.assignmentAiFeedback || assignmentUiCopy.teacherReview.feedback.noAiFeedback}</span>
</div>
<div class={styles.feedbackBlock}>
<label for="assignment-feedback">{assignmentUiCopy.teacherReview.feedback.teacherFeedback}</label>
<textarea
id="assignment-feedback"
class={styles.feedbackInput}
value={props.teacherFeedbackDraft}
onInput={(event) => props.onTeacherFeedbackInput(event.currentTarget.value)}
disabled={props.busy}
placeholder={assignmentUiCopy.teacherReview.feedback.teacherFeedbackPlaceholder}
/>
</div>
<Show when={props.notice?.scope === "assignment"}>
<div class={`${styles.notice} ${props.notice?.tone === "error" ? styles.noticeError : styles.noticeSuccess}`}>{props.notice?.text}</div>
</Show>
<div class={styles.draftHint}>
<Show when={props.hasPendingAssignmentFeedback} fallback={<span>{assignmentUiCopy.teacherReview.feedback.cleanDraft}</span>}>
<span>{assignmentUiCopy.teacherReview.feedback.dirtyDraftPrefix} {props.draftActionLabel}.</span>
</Show>
</div>
<Show when={props.actions}>{(actions) => <div class={styles.actionRow}>{actions()}</div>}</Show>
</section>
);
type QuestionCardProps = {
cardId?: string;
question: TeacherReviewQuestion;
notice: TeacherReviewNotice;
hasPendingDraft: boolean;
savingAll: boolean;
onResetDraft: (question: TeacherReviewQuestion) => void;
onNeedsAttention: (question: TeacherReviewQuestion) => void;
onReviewFieldChange: (question: TeacherReviewQuestion, patch: Partial<TeacherReviewDraftFields>) => void;
};
export const TeacherReviewQuestionCard: Component<QuestionCardProps> = (props) => (
<article id={props.cardId} class={styles.questionCard}>
<div class={styles.questionTop}>
<div>
<p class={styles.order}>{assignmentUiCopy.teacherReview.question.questionPrefix} {props.question.order}</p>
<h3>{props.question.prompt}</h3>
</div>
<span class={`${styles.statusPill} ${toneClass(props.question.statusTone)}`}>{props.question.statusLabel}</span>
</div>
<div class={styles.metaRow}>
<span>{props.question.submittedLabel}</span>
<span>{props.question.reviewedLabel}</span>
</div>
<div class={styles.responseBlock}>
<p>{assignmentUiCopy.teacherReview.question.studentAnswer}</p>
<strong>{props.question.answerText}</strong>
</div>
<Show when={props.question.workingSteps || props.question.solveModeLabel}>
<div class={styles.supportBlock}>
<p>{assignmentUiCopy.teacherReview.question.studentSteps}</p>
<Show when={props.question.solveModeLabel}>
<span>{props.question.solveModeLabel}</span>
</Show>
<Show when={props.question.workingSteps} fallback={<span>{assignmentUiCopy.teacherReview.question.noSteps}</span>}>
<pre>{props.question.workingSteps}</pre>
</Show>
</div>
</Show>
<div class={styles.supportBlock}>
<p>{assignmentUiCopy.teacherReview.question.correctAnswer}</p>
<Show when={props.question.correctAnswer} fallback={<span>{assignmentUiCopy.teacherReview.question.noCorrectAnswer}</span>}>
<strong>{props.question.correctAnswer}</strong>
</Show>
</div>
<Show when={props.question.answerId} fallback={<div class={styles.reviewDraftEmpty}>{assignmentUiCopy.teacherReview.question.structuredReviewLocked}</div>}>
<div class={styles.reviewEditorCard}>
<div class={styles.reviewEditorTop}>
<h4>{assignmentUiCopy.teacherReview.question.structuredReview}</h4>
<label class={styles.reviewCheckbox}>
<input
type="checkbox"
checked={props.question.reviewNeedsAttention}
disabled={props.savingAll}
onChange={(event) =>
props.onReviewFieldChange(props.question, {
reviewNeedsAttention: event.currentTarget.checked,
})
}
/>
<span>{assignmentUiCopy.teacherReview.question.needsAttention}</span>
</label>
</div>
<small>{assignmentUiCopy.teacherReview.question.weightingNote}</small>
<div class={styles.reviewField}>
<label for={`issue-reason-${props.question.id}`}>{assignmentUiCopy.teacherReview.question.issueReason}</label>
<textarea
id={`issue-reason-${props.question.id}`}
class={styles.feedbackInput}
value={props.question.reviewIssueReason}
disabled={props.savingAll}
placeholder={assignmentUiCopy.teacherReview.question.issueReasonPlaceholder}
onInput={(event) =>
props.onReviewFieldChange(props.question, {
reviewIssueReason: event.currentTarget.value,
})
}
/>
</div>
<div class={styles.reviewScoreGrid}>
<div class={styles.reviewField}>
<label for={`understanding-score-${props.question.id}`}>{assignmentUiCopy.teacherReview.question.understandingScore}</label>
<input
id={`understanding-score-${props.question.id}`}
type="number"
class={styles.compactInput}
min="0"
max="1"
step="0.05"
value={props.question.reviewUnderstandingScore ?? ""}
disabled={props.savingAll}
onInput={(event) =>
props.onReviewFieldChange(props.question, {
reviewUnderstandingScore: event.currentTarget.value === "" ? null : Number(event.currentTarget.value),
})
}
/>
</div>
<div class={styles.reviewField}>
<label for={`confidence-score-${props.question.id}`}>{assignmentUiCopy.teacherReview.question.confidence}</label>
<input
id={`confidence-score-${props.question.id}`}
type="number"
class={styles.compactInput}
min="0"
max="1"
step="0.05"
value={props.question.reviewConfidence ?? ""}
disabled={props.savingAll}
onInput={(event) =>
props.onReviewFieldChange(props.question, {
reviewConfidence: event.currentTarget.value === "" ? null : Number(event.currentTarget.value),
})
}
/>
</div>
</div>
<div class={styles.reviewField}>
<label>{assignmentUiCopy.teacherReview.question.questionTags}</label>
<Show
when={props.question.questionTags.length > 0}
fallback={<small>{assignmentUiCopy.teacherReview.question.noSavedTags}</small>}
>
<div class={styles.tagList}>
<For each={props.question.questionTags}>{(tag) => <span class={styles.tagPill}>{tag}</span>}</For>
</div>
<small>{assignmentUiCopy.teacherReview.question.inheritedTags}</small>
</Show>
</div>
</div>
</Show>
<Show when={props.notice?.scope === "question" && props.notice?.questionId === props.question.id}>
<div class={`${styles.notice} ${props.notice?.tone === "error" ? styles.noticeError : styles.noticeSuccess}`}>{props.notice?.text}</div>
</Show>
<div class={styles.actionRow}>
<Show when={props.hasPendingDraft}>
<button type="button" class={styles.secondaryAction} disabled={!props.question.answerId || props.savingAll} onClick={() => props.onResetDraft(props.question)}>
{assignmentUiCopy.teacherReview.question.resetDraft}
</button>
</Show>
<button type="button" class={styles.primaryAction} disabled={!props.question.answerId || props.savingAll} onClick={() => props.onNeedsAttention(props.question)}>
{props.hasPendingDraft ? assignmentUiCopy.teacherReview.question.needsAttentionInDraft : assignmentUiCopy.teacherReview.question.needsAttention}
</button>
</div>
</article>
);
type TeacherReviewSaveProgressCardProps = {
canMoveToNextStep: boolean;
pendingChangeCount: number;
savingAll: boolean;
closeState: TeacherAssignmentReviewPageData["closeSummary"]["state"];
notice: TeacherReviewNotice;
onSave: () => void;
};
export const TeacherReviewSaveProgressCard: Component<TeacherReviewSaveProgressCardProps> = (props) => (
<section class={styles.saveAllCard}>
<div class={styles.sectionHeader}>
<h3>{assignmentUiCopy.teacherReview.saveProgress.title}</h3>
<p>
<Show
when={props.canMoveToNextStep}
fallback={
props.closeState === "closed"
? assignmentUiCopy.teacherReview.saveProgress.closedFallback
: assignmentUiCopy.teacherReview.saveProgress.lockedFallback
}
>
{props.pendingChangeCount} question{props.pendingChangeCount === 1 ? " is" : "s are"} flagged for attention. Everything else will be marked reviewed when you continue.
</Show>
</p>
</div>
<Show when={props.notice?.scope === "global"}>
<div class={`${styles.notice} ${props.notice?.tone === "error" ? styles.noticeError : styles.noticeSuccess}`}>{props.notice?.text}</div>
</Show>
<div class={styles.actionRow}>
<button type="button" class={styles.primaryAction} disabled={props.savingAll || !props.canMoveToNextStep || props.closeState === "closed"} onClick={props.onSave}>
{props.savingAll ? assignmentUiCopy.teacherReview.saveProgress.saving : assignmentUiCopy.teacherReview.saveProgress.saveAndNextStep}
</button>
</div>
</section>
);
type TeacherReviewSidebarProps = {
data: TeacherAssignmentReviewPageData;
hasQueue: boolean;
closingAssignment: boolean;
closeNotice: TeacherReviewNotice;
onSelectStudent: (studentId: number) => void;
onCloseAssignment: () => void;
};
export const TeacherReviewSidebar: Component<TeacherReviewSidebarProps> = (props) => (
(() => {
const [isQueueExpanded, setIsQueueExpanded] = createSignal(false);
return (
<aside class={styles.sideColumn}>
<section class={`${styles.sideCard} ${styles.queueCard} ${styles.mobileStickyNav}`}>
<div class={styles.mobileStickyNavHeader}>
<div>
<p class={styles.sideEyebrow}>{assignmentUiCopy.teacherReview.sidebar.queueEyebrow}</p>
<h2>{assignmentUiCopy.teacherReview.sidebar.studentsToReview}</h2>
<p>Select a student to switch the question review panel.</p>
</div>
<button
type="button"
class={styles.mobileStickyNavToggle}
aria-expanded={isQueueExpanded()}
onClick={() => setIsQueueExpanded((value) => !value)}
>
{isQueueExpanded() ? "Hide queue" : "Open queue"}
</button>
</div>
<div class={styles.sideActions}>
<A href={getDashboardAssignmentsHref("teacher")} class={styles.secondaryAction}>
{assignmentUiCopy.teacherReview.sidebar.backToDashboard}
</A>
</div>
<div classList={{ [styles.mobileStickyNavContent]: true, [styles.mobileStickyNavContentExpanded]: isQueueExpanded() }}>
<div class={styles.queueScroller}>
<div class={styles.queueList}>
<Show when={props.hasQueue} fallback={<div class={styles.queueEmpty}>{assignmentUiCopy.teacherReview.sidebar.queueEmpty}</div>}>
<For each={props.data.reviewQueue}>
{(item) => (
<button
type="button"
class={`${styles.queueButton} ${props.data.selectedStudentId === item.studentId ? styles.queueButtonActive : ""}`.trim()}
onClick={() => {
props.onSelectStudent(item.studentId);
setIsQueueExpanded(false);
}}
>
<div>
<strong>{item.studentName}</strong>
<p>{item.progressLabel}</p>
</div>
<div class={styles.queueMeta}>
<span class={`${styles.statusPill} ${toneClass(item.statusTone)}`}>{item.statusLabel}</span>
<small>{item.timestampLabel}</small>
</div>
</button>
)}
</For>
</Show>
</div>
</div>
</div>
</section>
<section class={styles.sideCard}>
<p class={styles.sideEyebrow}>{assignmentUiCopy.teacherReview.sidebar.statusEyebrow}</p>
<h2>{assignmentUiCopy.teacherReview.sidebar.closeAssignment}</h2>
<p>{props.data.closeSummary.summary}</p>
<div class={styles.closeAssignmentStatusRow}>
<span
class={`${styles.statusPill} ${
props.data.closeSummary.state === "closed"
? styles.muted
: props.data.closeSummary.state === "ready"
? styles.success
: styles.review
}`}
>
{props.data.closeSummary.state === "closed"
? assignmentUiCopy.teacherReview.sidebar.closed
: props.data.closeSummary.state === "ready"
? assignmentUiCopy.teacherReview.sidebar.readyToClose
: assignmentUiCopy.teacherReview.sidebar.blocked}
</span>
</div>
<Show when={props.data.closeSummary.blockers.length > 0}>
<ul class={styles.closeBlockerList}>
<For each={props.data.closeSummary.blockers}>{(blocker) => <li>{blocker}</li>}</For>
</ul>
</Show>
<Show when={props.closeNotice?.scope === "assignment"}>
<div class={`${styles.notice} ${props.closeNotice?.tone === "error" ? styles.noticeError : styles.noticeSuccess}`}>{props.closeNotice?.text}</div>
</Show>
<div class={styles.sideActions}>
<button type="button" class={styles.primaryAction} disabled={!props.data.closeSummary.canClose || props.closingAssignment} onClick={props.onCloseAssignment}>
{props.closingAssignment ? assignmentUiCopy.teacherReview.sidebar.closing : props.data.closeSummary.state === "closed" ? assignmentUiCopy.teacherReview.sidebar.assignmentClosed : assignmentUiCopy.teacherReview.sidebar.closeAssignment}
</button>
</div>
</section>
<section class={styles.sideCard}>
<p class={styles.sideEyebrow}>{assignmentUiCopy.teacherReview.sidebar.overviewEyebrow}</p>
<h2>{props.data.selectedStudentName ?? assignmentUiCopy.teacherReview.sidebar.reviewSummary}</h2>
<p>{props.data.selectedStudentEmail ?? props.data.coachCard.description}</p>
<Show when={props.data.selectedStudentProgress}>
<div class={styles.progressNote}>{props.data.selectedStudentProgress}</div>
</Show>
<ul class={styles.noteList}>
<For each={props.data.coachCard.items}>{(item) => <li>{item}</li>}</For>
</ul>
</section>
</aside>
);
})()
);

View File

@@ -0,0 +1,354 @@
// Path: Frontend/src/components/assignment/teacher/assignment-teacher-review.tsx
import type { Component } from "solid-js";
import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
import { useNavigate } from "@solidjs/router";
import type { TeacherAssignmentReviewPageData, TeacherReviewDraftFields, TeacherReviewNotice, TeacherReviewQuestion } from "./assignment-teacher-review.types";
import { closeTeacherAssignment, updateTeacherAnswerReview } from "./assignment-teacher-review.data";
import {
applyReviewDrafts,
type AssignmentReviewDraftStore,
countPendingQuestionDrafts,
EMPTY_DRAFT_STORE,
type StudentReviewDraft,
readDraftStore,
reviewFieldsEqual,
reviewFieldsFromQuestion,
sanitizeReviewFields,
writeDraftStore,
} from "./assignment-teacher-review.drafts";
import styles from "./assignment-teacher-review.module.scss";
import { TeacherReviewQuestionCard, TeacherReviewSaveProgressCard, TeacherReviewSidebar } from "./assignment-teacher-review.sections";
import { getTeacherAssignmentNextStepHref } from "../../../lib/routes";
type Props = {
data: TeacherAssignmentReviewPageData;
onSelectStudent: (studentId: number) => void;
onRefresh: () => Promise<unknown> | unknown;
};
const AssignmentTeacherReview: Component<Props> = (props) => {
const navigate = useNavigate();
let questionReviewSectionRef: HTMLElement | undefined;
const [draftStore, setDraftStore] = createSignal<AssignmentReviewDraftStore>(EMPTY_DRAFT_STORE);
const [savingAll, setSavingAll] = createSignal(false);
const [closingAssignment, setClosingAssignment] = createSignal(false);
const [notice, setNotice] = createSignal<TeacherReviewNotice>(null);
const [closeNotice, setCloseNotice] = createSignal<TeacherReviewNotice>(null);
const [questionNavExpanded, setQuestionNavExpanded] = createSignal(false);
const currentStudentKey = createMemo(() => (props.data.selectedStudentId ? String(props.data.selectedStudentId) : null));
const currentStudentDraft = createMemo(() => {
const studentKey = currentStudentKey();
if (!studentKey) return null;
return draftStore().students[studentKey] ?? null;
});
const displayQuestions = createMemo(() => applyReviewDrafts(props.data.questions, currentStudentDraft()));
const pendingChangeCount = createMemo(() => countPendingQuestionDrafts(draftStore()));
createEffect(() => {
setDraftStore(readDraftStore(props.data.assignmentId));
});
createEffect(() => {
writeDraftStore(props.data.assignmentId, draftStore());
});
createEffect(() => {
setNotice(null);
});
createEffect(() => {
setCloseNotice(null);
});
const hasQueue = createMemo(() => props.data.reviewQueue.length > 0);
const canMoveToNextStep = createMemo(() => {
return props.data.selectedStudentSubmittedQuestions > 0 || props.data.selectedStudentReviewedQuestions > 0;
});
const getBaseQuestion = (questionId: number) => props.data.questions.find((entry) => entry.id === questionId);
const getSelectedStudentQuestions = () => props.data.questions.filter((question) => question.answerId);
const getQuestionCardId = (questionId: number) => `teacher-review-question-${questionId}`;
const handleSelectQuestion = (questionId: number) => {
const element = document.getElementById(getQuestionCardId(questionId));
element?.scrollIntoView({ behavior: "smooth", block: "start" });
setQuestionNavExpanded(false);
};
const handleScrollToTop = () => {
questionReviewSectionRef?.scrollIntoView({ behavior: "smooth", block: "start" });
};
const updateStudentDraft = (studentId: number, updater: (draft: StudentReviewDraft) => StudentReviewDraft | null) => {
const studentKey = String(studentId);
setDraftStore((current) => {
const nextStudents = { ...current.students };
const nextDraft = updater(nextStudents[studentKey] ?? {});
if (!nextDraft) {
delete nextStudents[studentKey];
} else {
nextStudents[studentKey] = nextDraft;
}
return {
students: nextStudents,
};
});
};
const updateQuestionReviewDraft = (question: TeacherReviewQuestion, patch: Partial<TeacherReviewDraftFields>) => {
if (!question.answerId || !props.data.selectedStudentId) return;
setNotice(null);
const baseQuestion = getBaseQuestion(question.id) ?? question;
const baseFields = sanitizeReviewFields(reviewFieldsFromQuestion(baseQuestion));
updateStudentDraft(props.data.selectedStudentId, (draft) => {
const questionReviews = { ...(draft.questionReviews ?? {}) };
const currentFields = sanitizeReviewFields(
questionReviews[String(question.id)]
? {
...baseFields,
...questionReviews[String(question.id)],
}
: baseFields
);
const nextFields = sanitizeReviewFields({
...currentFields,
...patch,
});
if (reviewFieldsEqual(nextFields, baseFields)) {
delete questionReviews[String(question.id)];
} else {
questionReviews[String(question.id)] = {
answerId: question.answerId,
...nextFields,
};
}
const nextDraft: StudentReviewDraft = {
...draft,
};
if (Object.keys(questionReviews).length > 0) {
nextDraft.questionReviews = questionReviews;
} else {
delete nextDraft.questionReviews;
}
if (Object.keys(nextDraft.questionReviews ?? {}).length === 0) {
return null;
}
return nextDraft;
});
};
const resetQuestionDraft = (question: TeacherReviewQuestion) => {
const selectedStudentId = props.data.selectedStudentId;
if (!selectedStudentId) return;
setNotice(null);
updateStudentDraft(selectedStudentId, (draft) => {
const questionReviews = { ...(draft.questionReviews ?? {}) };
delete questionReviews[String(question.id)];
if (Object.keys(questionReviews).length === 0) {
return null;
}
return {
...draft,
questionReviews,
};
});
};
const markQuestionNeedsAttention = (question: TeacherReviewQuestion) => {
updateQuestionReviewDraft(question, {
reviewNeedsAttention: true,
});
};
const saveAllAndNextStep = async () => {
setNotice(null);
const selectedStudentId = props.data.selectedStudentId;
if (!selectedStudentId) return;
if (!canMoveToNextStep()) {
setNotice({
scope: "global",
tone: "error",
text: "Students need a submitted review before you can move them to the next step.",
});
return;
}
const operations: Array<() => Promise<unknown>> = [];
const selectedStudentDraft = draftStore().students[String(selectedStudentId)] ?? null;
const questionDrafts = selectedStudentDraft?.questionReviews ?? {};
for (const question of getSelectedStudentQuestions()) {
const draft = questionDrafts[String(question.id)];
if (!question.answerId) continue;
const baseFields = sanitizeReviewFields(reviewFieldsFromQuestion(question));
const effectiveReview = sanitizeReviewFields(
draft
? {
...baseFields,
...draft,
}
: baseFields
);
const nextStatus = effectiveReview.reviewNeedsAttention ? "submitted" : "reviewed";
const hasReviewFieldChanges = !reviewFieldsEqual(effectiveReview, baseFields);
if (question.answerStatus === nextStatus && !hasReviewFieldChanges) continue;
operations.push(() =>
updateTeacherAnswerReview(question.answerId!, {
status: nextStatus,
reviewTags: [...question.questionTags],
...effectiveReview,
})
);
}
setSavingAll(true);
try {
for (const operation of operations) {
await operation();
}
updateStudentDraft(selectedStudentId, () => null);
navigate(getTeacherAssignmentNextStepHref(props.data.assignmentId, selectedStudentId));
} catch (error) {
setNotice({
scope: "global",
tone: "error",
text: error instanceof Error ? error.message : "Could not save all review changes right now.",
});
} finally {
setSavingAll(false);
}
};
const handleCloseAssignment = async () => {
setCloseNotice(null);
if (!props.data.closeSummary.canClose) {
setCloseNotice({
scope: "assignment",
tone: "error",
text: props.data.closeSummary.summary,
});
return;
}
setClosingAssignment(true);
try {
await closeTeacherAssignment(props.data.assignmentId);
await props.onRefresh();
setCloseNotice({
scope: "assignment",
tone: "success",
text: "Assignment closed.",
});
} catch (error) {
setCloseNotice({
scope: "assignment",
tone: "error",
text: error instanceof Error ? error.message : "Could not close this assignment right now.",
});
} finally {
setClosingAssignment(false);
}
};
return (
<>
<div class={styles.contentGrid}>
<div class={styles.mainColumn}>
<section ref={questionReviewSectionRef} class={styles.section}>
<div class={styles.sectionHeader}>
<h2>Question review</h2>
<p>
<Show when={props.data.selectedStudentName} fallback={hasQueue() ? "Choose a student to begin reviewing." : "This assignment has no student review queue yet."}>
{props.data.selectedStudentName} · {props.data.questions.length} questions
</Show>
</p>
</div>
<Show when={props.data.selectedStudentId !== null && displayQuestions().length > 0}>
<section class={styles.mobileStickyNav}>
<div class={styles.mobileStickyNavHeader}>
<div>
<h3>Questions</h3>
<p>{displayQuestions().length} questions in this review</p>
</div>
<div class={styles.mobileStickyNavActions}>
<button type="button" class={styles.mobileStickyNavBackToTop} onClick={handleScrollToTop}>
Back to top
</button>
<button
type="button"
class={styles.mobileStickyNavToggle}
aria-expanded={questionNavExpanded()}
onClick={() => setQuestionNavExpanded((value) => !value)}
>
{questionNavExpanded() ? "Hide list" : "Open list"}
</button>
</div>
</div>
<div
classList={{
[styles.mobileStickyNavContent]: true,
[styles.mobileStickyNavContentExpanded]: questionNavExpanded(),
}}
>
<div class={styles.mobileQuestionNavList}>
<For each={displayQuestions()}>
{(question) => (
<button
type="button"
class={`${styles.mobileQuestionNavButton} ${question.answerId ? styles.mobileQuestionNavAnswered : ""}`.trim()}
onClick={() => handleSelectQuestion(question.id)}
>
<strong>Q{question.order}</strong>
<small>{question.statusLabel}</small>
</button>
)}
</For>
</div>
</div>
</section>
</Show>
<Show when={props.data.selectedStudentId !== null} fallback={<div class={styles.emptyState}>Select a student from the review queue to inspect answers and leave feedback.</div>}>
<div class={styles.questionList}>
<For each={displayQuestions()}>
{(question) => (
<TeacherReviewQuestionCard
cardId={getQuestionCardId(question.id)}
question={question}
notice={notice()}
hasPendingDraft={Boolean(currentStudentDraft()?.questionReviews?.[String(question.id)])}
savingAll={savingAll()}
onResetDraft={resetQuestionDraft}
onNeedsAttention={markQuestionNeedsAttention}
onReviewFieldChange={updateQuestionReviewDraft}
/>
)}
</For>
</div>
<TeacherReviewSaveProgressCard
canMoveToNextStep={canMoveToNextStep()}
pendingChangeCount={pendingChangeCount()}
savingAll={savingAll()}
closeState={props.data.closeSummary.state}
notice={notice()}
onSave={saveAllAndNextStep}
/>
</Show>
</section>
</div>
<TeacherReviewSidebar data={props.data} hasQueue={hasQueue()} closingAssignment={closingAssignment()} closeNotice={closeNotice()} onSelectStudent={props.onSelectStudent} onCloseAssignment={handleCloseAssignment} />
</div>
</>
);
};
export default AssignmentTeacherReview;

View File

@@ -0,0 +1,130 @@
import type { ApiReviewQueueItem } from "../../../lib/api-types";
export type TeacherReviewQueueItem = {
studentId: number;
studentName: string;
email: string;
reviewStatus: ApiReviewQueueItem["review_status"];
nextStepOutcome: TeacherNextStepOutcome | null;
submittedQuestions: number;
reviewedQuestions: number;
progressLabel: string;
statusLabel: string;
statusTone: "review" | "progress" | "muted" | "success";
timestampLabel: string;
};
export type TeacherAssignmentCloseState = "ready" | "blocked" | "closed";
export type TeacherAssignmentCloseSummary = {
state: TeacherAssignmentCloseState;
canClose: boolean;
blockers: string[];
summary: string;
};
export type TeacherAssignmentPassStatus = "pending" | "pass" | "no_pass";
export type TeacherNextStepOutcome = "redo" | "accept" | "support";
export type TeacherReviewDraftFields = {
reviewNeedsAttention: boolean;
reviewIssueReason: string;
reviewCorrectnessScore: number | null;
reviewUnderstandingScore: number | null;
reviewQuestionScore: number | null;
reviewConfidence: number | null;
};
export type TeacherReviewQuestion = TeacherReviewDraftFields & {
id: number;
order: number;
prompt: string;
subject: string;
source: string | null;
questionTags: string[];
answerId: number | null;
answerStatus: "not_started" | "in_progress" | "submitted" | "reviewed" | null;
statusLabel: string;
statusTone: "review" | "progress" | "muted" | "success";
answerText: string;
solveModeLabel: string | null;
workingSteps: string;
correctAnswer: string | null;
isCorrect: boolean | null;
correctnessLabel: string;
submittedLabel: string;
reviewedLabel: string;
};
export type TeacherAssignmentReviewPageData = {
assignmentId: number;
title: string;
classroomId: number;
classroomName: string;
statusLabel: string;
headline: string;
description: string;
dueLabel: string;
stats: Array<{ label: string; value: string }>;
closeSummary: TeacherAssignmentCloseSummary;
reviewQueue: TeacherReviewQueueItem[];
selectedStudentId: number | null;
selectedStudentName: string | null;
selectedStudentEmail: string | null;
selectedStudentProgress: string | null;
selectedStudentReviewStatus: ApiReviewQueueItem["review_status"] | null;
selectedStudentSubmittedQuestions: number;
selectedStudentReviewedQuestions: number;
assignmentAiFeedback: string;
assignmentTeacherFeedback: string;
overallScore: number | null;
passThreshold: number;
nextStepOutcome: TeacherNextStepOutcome | null;
passStatusOverride: TeacherAssignmentPassStatus | null;
passStatus: TeacherAssignmentPassStatus;
coachCard: {
title: string;
description: string;
items: string[];
};
questions: TeacherReviewQuestion[];
};
export type TeacherRedoPlanQuestion = {
topic: string;
topicKey: string;
difficulty: string;
difficultyKey: "easy" | "medium" | "hard";
tags: string[];
reason: string;
};
export type TeacherRedoPlanData = {
assignmentId: number;
title: string;
classroomId: number;
classroomName: string;
selectedStudentId: number;
selectedStudentName: string;
selectedStudentEmail: string;
teacherFeedback: string;
generatedAtLabel: string | null;
weaknessSummary: {
studentId: number;
topicScores: Array<{ topic: string; score: number }>;
weakTags: string[];
recentIssues: string[];
};
plan: {
rationale: string;
questionSet: TeacherRedoPlanQuestion[];
} | null;
error: string | null;
};
export type TeacherReviewNotice = {
scope: "assignment" | "question" | "global";
questionId?: number;
tone: "success" | "error";
text: string;
} | null;

View File

@@ -0,0 +1,215 @@
// Path: Frontend/src/components/assignment/work/assignment-work.data.ts
import { apiFetchJson } from "../../../lib/api";
import type { ApiAssignment, ApiAssignmentStudentQuestionDetail, ApiClassroom, ApiListResponse, ApiUser } from "../../../lib/api-types";
import { getAssignmentReviewHref } from "../../../lib/routes";
import type { AssignmentPageData } from "../shared/assignment-types";
export type AssignmentWorkQuestion = AssignmentPageData["questions"][number] & {
answerId: number | null;
answerStatus: "not_started" | "in_progress" | "submitted" | "reviewed";
submittedAt: string | null;
updatedAt: string | null;
workingSteps: string;
correctAnswer: string | null;
isCorrect: boolean | null;
};
export type AssignmentWorkPageData = Omit<AssignmentPageData, "questions" | "description" | "headline" | "primaryAction"> & {
headline: string;
description: string;
primaryAction: string;
questions: AssignmentWorkQuestion[];
};
type UpsertAssignmentAnswerInput = {
assignmentId: number;
questionId: number;
answerText: string | null;
solveMode: "just_answer" | "step_by_step" | "solve_together" | "handwritten";
workingSteps: string | null;
status: "not_started" | "in_progress" | "submitted" | "reviewed";
submittedAt?: string | null;
};
const formatDateLabel = (value: string | null) => {
if (!value) return "No due date";
return new Intl.DateTimeFormat("en-GB", {
weekday: "short",
month: "short",
day: "numeric",
}).format(new Date(value));
};
const extractTopic = (assignment: ApiAssignment, questions: ApiAssignmentStudentQuestionDetail[]) => {
const fromInstructions = assignment.instructions?.match(/^Topic:\s*(.+)$/i)?.[1]?.trim();
if (fromInstructions) return fromInstructions;
return questions[0]?.subject ?? "Assignment";
};
const deriveStudentStatus = (questions: ApiAssignmentStudentQuestionDetail[], assignmentStatus: ApiAssignment["status"]) => {
const total = questions.length;
const answered = questions.filter((question) => question.answer_id).length;
const submitted = questions.filter((question) => question.answer_status === "submitted" || question.answer_status === "reviewed").length;
if (answered === 0) return "NOT_STARTED" as const;
if (submitted === total || assignmentStatus === "closed") return "SUBMITTED" as const;
return "IN_PROGRESS" as const;
};
const deriveStatusLabel = (status: AssignmentPageData["status"]) => {
switch (status) {
case "SUBMITTED":
return "Submitted";
case "IN_PROGRESS":
return "In progress";
default:
return "Not started";
}
};
const mapQuestionStatus = (question: ApiAssignmentStudentQuestionDetail) => {
if (question.answer_status === "reviewed") {
return {
statusLabel: "Reviewed",
statusTone: "success" as const,
answerStatus: "reviewed" as const,
};
}
if (question.answer_status === "submitted") {
return {
statusLabel: "Submitted",
statusTone: "warning" as const,
answerStatus: "submitted" as const,
};
}
if (question.answer_status === "in_progress") {
return {
statusLabel: "Draft saved",
statusTone: "warning" as const,
answerStatus: "in_progress" as const,
};
}
return {
statusLabel: "Not started",
statusTone: "muted" as const,
answerStatus: "not_started" as const,
};
};
export const getAssignmentWorkPageData = async (assignmentId: number, studentId: number): Promise<AssignmentWorkPageData | null> => {
if (!Number.isFinite(assignmentId) || assignmentId <= 0) return null;
try {
const assignment = await apiFetchJson<ApiAssignment>(`/api/assignments/${assignmentId}`);
const [student, teacher, classrooms, questionDetails] = await Promise.all([
apiFetchJson<ApiUser>(`/api/users/${studentId}`),
apiFetchJson<ApiUser>(`/api/users/${assignment.teacher_id}`),
apiFetchJson<ApiListResponse<ApiClassroom>>(`/api/teachers/${assignment.teacher_id}/classrooms`),
apiFetchJson<ApiListResponse<ApiAssignmentStudentQuestionDetail>>(`/api/assignments/${assignmentId}/students/${studentId}/questions`),
]);
const classroom = classrooms.data.find((entry) => entry.id === assignment.classroom_id);
const questions = questionDetails.data;
const topic = extractTopic(assignment, questions);
const answeredCount = questions.filter((question) => question.answer_id).length;
const submittedCount = questions.filter((question) => question.answer_status === "submitted" || question.answer_status === "reviewed").length;
const status = deriveStudentStatus(questions, assignment.status);
const statusLabel = deriveStatusLabel(status);
return {
id: assignment.id,
title: assignment.title,
topic,
status,
statusLabel,
dueLabel: formatDateLabel(assignment.due_at),
studentName: student.full_name,
classroomName: classroom?.name ?? "Classroom",
tutorName: teacher.full_name,
headline: `Work through ${assignment.title}`,
description: `Write your final answer here, keep your working notes in sync, and save each question back to the live assignment as you go.`,
primaryAction: status === "SUBMITTED" ? "Review submission" : "Keep working",
primaryHref: getAssignmentReviewHref("student", assignment.id),
stats: [
{ label: "Status", value: statusLabel },
{ label: "Due", value: formatDateLabel(assignment.due_at) },
{ label: "Questions", value: `${questions.length}` },
{ label: "Saved / submitted", value: `${submittedCount}/${questions.length}` },
],
coachCard: {
title: "Workspace notes",
description: `Your final answer, solve mode, and working notes all sync to the backend when you save.`,
items: [`${answeredCount} questions have an answer saved`, `${submittedCount} questions are submitted`, `${Math.max(questions.length - answeredCount, 0)} questions still untouched`],
},
questions: questions.map((question) => {
const questionStatus = mapQuestionStatus(question);
return {
id: question.question_id,
order: question.position,
prompt: question.prompt,
topic: question.subject,
subTopic: question.source,
difficulty: "Backend",
marks: null,
statusLabel: questionStatus.statusLabel,
statusTone: questionStatus.statusTone,
responseLabel: question.answer_id ? "Your saved answer" : "Status",
responseValue: question.answer_text?.trim() || "No saved answer yet",
feedback: question.teacher_feedback?.trim() || question.ai_feedback?.trim() || "Save a draft to keep your latest workspace on the assignment.",
solveModeLabel: undefined,
initialAnswer: question.answer_text?.trim() || "",
initialSolveMode: question.solve_mode ?? "just_answer",
showAnswerKey: Boolean(question.correct_answer && question.answer_id),
correctAnswer: question.correct_answer?.trim() || null,
isCorrect: typeof question.is_correct === "boolean" ? question.is_correct : null,
answerId: question.answer_id ?? null,
answerStatus: questionStatus.answerStatus,
submittedAt: question.submitted_at ?? null,
updatedAt: question.answer_updated_at ?? null,
workingSteps: question.working_steps ?? "",
};
}),
};
} catch (error) {
if (error instanceof Error && error.message === "not_found") {
return null;
}
throw error;
}
};
export const upsertAssignmentAnswer = async (input: UpsertAssignmentAnswerInput) => {
return apiFetchJson<{
id: number;
assignment_id: number;
question_id: number;
student_id: number;
answer_text?: string;
solve_mode: string;
working_steps?: string;
status: string;
submitted_at?: string;
updated_at?: string;
}>("/api/answers", {
method: "POST",
parseErrorMessage: true,
body: JSON.stringify({
assignment_id: input.assignmentId,
question_id: input.questionId,
answer_text: input.answerText,
solve_mode: input.solveMode,
working_steps: input.workingSteps,
status: input.status,
submitted_at: input.submittedAt ?? null,
}),
});
};

View File

@@ -1,3 +1,5 @@
/* Path: Frontend/src/components/assignment/work/assignment-work.module.scss */
.page {
min-height: 100dvh;
padding: 1.25rem;
@@ -20,7 +22,7 @@
align-items: center;
gap: 1rem;
padding: 1.1rem 1.25rem;
border-radius: 1.35rem;
border-radius: var(--radius-2xl);
background: var(--surface-success);
border: 1px solid color-mix(in srgb, var(--success) 28%, transparent);
box-shadow: var(--shadow-soft);
@@ -56,7 +58,7 @@
align-items: center;
justify-content: center;
padding: 0.85rem 1rem;
border-radius: 9999px;
border-radius: var(--radius-full);
background: var(--surface-panel);
border: 1px solid var(--border-soft);
color: var(--text);
@@ -68,13 +70,21 @@
display: grid;
gap: 1rem;
padding: 1.4rem;
border-radius: 1.5rem;
border-radius: var(--radius-3xl);
background: linear-gradient(135deg, var(--surface-hero-start), var(--surface-hero-end));
border: 1px solid var(--border-overlay);
box-shadow: var(--shadow-elevated);
color: var(--text-on-accent);
}
@media (max-width: 768px) {
.headerCard {
gap: 0.8rem;
padding: 1rem;
border-radius: var(--radius-2xl);
}
}
.headerTop {
display: flex;
align-items: center;
@@ -82,13 +92,21 @@
gap: 1rem;
}
@media (max-width: 768px) {
.headerTop {
align-items: flex-start;
flex-wrap: wrap;
gap: 0.7rem;
}
}
.backLink,
.statusPill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 0.8rem;
border-radius: 9999px;
border-radius: var(--radius-full);
background: var(--surface-overlay-soft);
border: 1px solid var(--border-overlay);
color: var(--text-on-accent);
@@ -111,6 +129,21 @@
}
}
@media (max-width: 768px) {
.headerCopy {
gap: 0.25rem;
}
.headerCopy h1 {
font-size: clamp(1.45rem, 1.1rem + 1.4vw, 1.9rem);
line-height: 1.05;
}
.headerCopy > p:not(.eyebrow) {
display: none;
}
}
.eyebrow {
color: var(--text-on-accent-muted);
}
@@ -123,12 +156,43 @@
flex-wrap: wrap;
}
@media (max-width: 768px) {
.headerMeta {
align-items: stretch;
flex-direction: column;
gap: 0.7rem;
}
}
.submitMeta {
display: grid;
justify-items: end;
gap: 0.45rem;
@media (max-width: 768px) {
justify-items: stretch;
width: 100%;
}
}
.saveState {
font-size: 0.92rem;
font-weight: 500;
color: var(--text-on-accent-muted);
}
.submitHint {
max-width: 28rem;
font-size: 0.82rem;
line-height: 1.4;
color: var(--text-on-accent-muted);
text-align: right;
@media (max-width: 768px) {
text-align: left;
}
}
.submitButton,
.primaryButton,
.secondaryButton,
@@ -137,7 +201,7 @@
align-items: center;
justify-content: center;
padding: 0.9rem 1rem;
border-radius: 1rem;
border-radius: var(--radius-md);
font-weight: 600;
text-decoration: none;
cursor: pointer;
@@ -161,11 +225,31 @@
}
}
.submitButton:disabled,
.primaryButton:disabled,
.secondaryButton:disabled,
.questionButton:disabled {
cursor: wait;
transform: none;
box-shadow: none;
opacity: 0.72;
}
.submitButton:disabled,
.primaryButton:disabled {
background: linear-gradient(135deg, color-mix(in srgb, var(--action-primary-start) 82%, white), color-mix(in srgb, var(--action-primary-end) 82%, white));
}
.secondaryButton:disabled,
.questionButton:disabled {
background: var(--surface-soft);
}
.contentGrid {
display: grid;
gap: 1.25rem;
@media (min-width: 1080px) {
@include respond(workspace) {
grid-template-columns: minmax(15rem, 18rem) minmax(0, 1fr);
align-items: start;
}
@@ -177,14 +261,18 @@
display: grid;
gap: 1rem;
padding: 1.2rem;
border-radius: 1.35rem;
border-radius: var(--radius-2xl);
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
}
.navigator {
@media (min-width: 1080px) {
position: sticky;
top: 0.75rem;
z-index: 25;
@include respond(workspace) {
position: sticky;
top: 1.25rem;
}
@@ -207,13 +295,42 @@
}
}
.navigatorHeader {
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
}
.navigatorToggle {
display: none;
align-items: center;
justify-content: center;
padding: 0.7rem 0.9rem;
border-radius: var(--radius-full);
border: 1px solid var(--border-soft);
background: var(--surface-panel-strong);
color: var(--text);
font-size: 0.84rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.navigatorContent {
display: grid;
gap: 1rem;
}
.navigatorContentExpanded {
display: grid;
}
.progressMeta {
display: flex;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
padding: 0.8rem 0.9rem;
border-radius: 1rem;
border-radius: var(--radius-md);
background: var(--surface-soft);
border: 1px solid var(--border-soft);
font-size: 0.85rem;
@@ -225,14 +342,69 @@
gap: 0.65rem;
}
@media (max-width: 1023px) {
.navigator {
gap: 0.75rem;
padding: 0.95rem;
}
.navigatorToggle {
display: inline-flex;
}
.navigatorContent {
display: none;
gap: 0.75rem;
}
.navigatorContentExpanded {
display: grid;
}
.progressMeta {
padding: 0.7rem 0.8rem;
}
.questionButtons {
max-height: min(55dvh, 24rem);
overflow-y: auto;
padding-right: 0.15rem;
}
.workspace,
.emptyState {
padding: 1rem;
}
.workspaceHeader {
gap: 0.45rem;
}
.workspaceHeader h2 {
font-size: 1.02rem;
line-height: 1.28;
}
}
@include respond(workspace) {
.navigatorToggle {
display: none;
}
.navigatorContent {
display: grid;
}
}
.questionButton {
display: grid;
gap: 0.15rem;
padding: 0.85rem 0.95rem;
text-align: left;
border-radius: 1rem;
border-radius: var(--radius-md);
border: 1px solid var(--border-divider);
background: var(--surface-panel-strong);
color: var(--text);
cursor: pointer;
transition:
transform 0.2s ease,
@@ -269,7 +441,7 @@
span {
padding: 0.35rem 0.6rem;
border-radius: 9999px;
border-radius: var(--radius-full);
background: var(--surface-panel-strong);
border: 1px solid var(--border-divider);
font-size: 0.82rem;
@@ -298,7 +470,7 @@
select {
width: 100%;
padding: 0.9rem 1rem;
border-radius: 1rem;
border-radius: var(--radius-md);
border: 1px solid var(--border-field);
background: var(--surface-field);
outline: none;
@@ -306,7 +478,7 @@
&:focus {
border-color: var(--primary);
box-shadow: 0 0 0 4px var(--focus-ring-primary);
box-shadow: var(--focus-ring-primary-shadow);
background: var(--surface-field-focus);
}
}
@@ -326,7 +498,7 @@
display: grid;
gap: 0.45rem;
padding: 1rem;
border-radius: 1rem;
border-radius: var(--radius-md);
background: var(--surface-soft);
border: 1px solid var(--border-soft);

View File

@@ -0,0 +1,273 @@
// Path: Frontend/src/components/assignment/work/student-assignment-work.page.tsx
import { A, useParams } from "@solidjs/router";
import type { Component } from "solid-js";
import { Show, createEffect, createMemo, createResource, createSignal } from "solid-js";
import { useAuth } from "~/context/auth/context";
import { getAssignmentReviewHref, getDashboardHomeHref } from "../../../lib/routes";
import AssignmentTabs from "../shared/assignment-tabs";
import { getAssignmentWorkPageData, upsertAssignmentAnswer } from "./assignment-work.data";
import styles from "./assignment-work.module.scss";
import { AssignmentWorkBanner, AssignmentWorkHeader, AssignmentWorkNavigator, AssignmentWorkWorkspace, type DraftState } from "./student-assignment-work.sections";
const StudentAssignmentWorkPage: Component = () => {
const params = useParams();
const auth = useAuth();
const assignmentId = createMemo(() => {
const parsed = Number(params.id);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
});
const studentSource = createMemo(() => {
const id = assignmentId();
const user = auth.user();
if (!auth.isReady() || !id || !user || user.role !== "student") return null;
return { assignmentId: id, studentId: user.id };
});
const [assignmentData, { refetch }] = createResource(studentSource, ({ assignmentId, studentId }) => getAssignmentWorkPageData(assignmentId, studentId));
const [drafts, setDrafts] = createSignal<DraftState>({});
const [activeQuestionId, setActiveQuestionId] = createSignal<number | null>(null);
const [hydratedAssignmentId, setHydratedAssignmentId] = createSignal<number | null>(null);
const [saveState, setSaveState] = createSignal<"idle" | "saving" | "saved" | "error">("idle");
const [submitState, setSubmitState] = createSignal<"idle" | "submitting" | "error">("idle");
const [lastSavedAt, setLastSavedAt] = createSignal<string | null>(null);
const [isSubmitted, setIsSubmitted] = createSignal(false);
const [submittedAt, setSubmittedAt] = createSignal<string | null>(null);
createEffect(() => {
const data = assignmentData();
if (!data || hydratedAssignmentId() === data.id) return;
setDrafts(
Object.fromEntries(
data.questions.map((question) => [
question.id,
{
answer: question.initialAnswer ?? "",
steps: question.workingSteps ?? "",
solveMode: question.initialSolveMode ?? "just_answer",
},
]),
) as DraftState,
);
setActiveQuestionId(data.questions[0]?.id ?? null);
setHydratedAssignmentId(data.id);
setIsSubmitted(data.status === "SUBMITTED");
setSubmittedAt(null);
setLastSavedAt(null);
setSaveState("idle");
setSubmitState("idle");
});
const questions = createMemo(() => assignmentData()?.questions ?? []);
const currentQuestion = createMemo(() => questions().find((question) => question.id === activeQuestionId()) ?? questions()[0]);
const activeDraft = createMemo(() => {
const question = currentQuestion();
return question ? drafts()[question.id] : undefined;
});
const answeredCount = createMemo(() => Object.values(drafts()).filter((draft) => draft.answer.trim().length > 0).length);
const remainingCount = createMemo(() => Math.max(questions().length - answeredCount(), 0));
const isLastQuestion = createMemo(() => {
const current = currentQuestion();
if (!current) return false;
return questions()[questions().length - 1]?.id === current.id;
});
const saveLabel = createMemo(() => {
if (submitState() === "submitting") return "Submitting assignment and preparing your AI draft…";
if (submitState() === "error") return "We could not submit the assignment.";
if (saveState() === "saving") return "Saving to the backend…";
if (saveState() === "error") return "We could not save that answer.";
if (saveState() === "saved" && lastSavedAt()) {
return `Saved ${new Intl.DateTimeFormat("en-GB", {
hour: "numeric",
minute: "2-digit",
}).format(new Date(lastSavedAt()!))}`;
}
return "Draft synced with the backend when you save";
});
const updateDraft = (field: "answer" | "steps" | "solveMode", value: string) => {
if (submitState() === "submitting") return;
const question = currentQuestion();
if (!question) return;
setDrafts((current) => ({
...current,
[question.id]: {
...current[question.id],
[field]: value,
},
}));
setIsSubmitted(false);
};
const saveQuestion = async (statusOverride?: "in_progress" | "submitted") => {
if (submitState() === "submitting") return;
const data = assignmentData();
const question = currentQuestion();
const draft = activeDraft();
if (!data || !question || !draft) return;
const trimmedAnswer = draft.answer.trim();
const trimmedSteps = draft.steps.trim();
const nextStatus = statusOverride ?? (trimmedAnswer || trimmedSteps ? (question.answerStatus === "submitted" || question.answerStatus === "reviewed" ? "submitted" : "in_progress") : "not_started");
setSaveState("saving");
try {
await upsertAssignmentAnswer({
assignmentId: data.id,
questionId: question.id,
answerText: trimmedAnswer || null,
workingSteps: trimmedSteps || null,
solveMode: draft.solveMode,
status: nextStatus,
submittedAt: nextStatus === "submitted" ? new Date().toISOString() : null,
});
setLastSavedAt(new Date().toISOString());
setSaveState("saved");
await refetch();
} catch {
setSaveState("error");
}
};
const moveQuestion = (direction: 1 | -1) => {
const current = currentQuestion();
if (!current) return;
const index = questions().findIndex((question) => question.id === current.id);
const target = questions()[index + direction];
if (target) setActiveQuestionId(target.id);
};
const handleSaveAndContinue = async () => {
if (submitState() === "submitting") return;
await saveQuestion();
if (!isLastQuestion()) moveQuestion(1);
};
const handleSubmit = async () => {
const data = assignmentData();
if (!data || submitState() === "submitting") return;
setSubmitState("submitting");
setSaveState("saving");
const now = new Date().toISOString();
try {
await Promise.all(
data.questions.map(async (question) => {
const draft = drafts()[question.id] ?? {
answer: question.initialAnswer ?? "",
steps: question.workingSteps ?? "",
solveMode: question.initialSolveMode ?? "just_answer",
};
const trimmedAnswer = draft.answer.trim();
const trimmedSteps = draft.steps.trim();
await upsertAssignmentAnswer({
assignmentId: data.id,
questionId: question.id,
answerText: trimmedAnswer || null,
workingSteps: trimmedSteps || null,
solveMode: draft.solveMode,
status: "submitted",
submittedAt: now,
});
}),
);
setIsSubmitted(true);
setSubmittedAt(now);
setLastSavedAt(now);
setSubmitState("idle");
setSaveState("saved");
await refetch();
} catch {
setSubmitState("error");
setSaveState("error");
}
};
return (
<main class={styles.page}>
<div class={styles.shell}>
<Show
when={!assignmentData.loading}
fallback={
<section class={styles.emptyState}>
<p class={styles.emptyEyebrow}>Loading assignment</p>
<h1>Pulling your live workspace from the backend.</h1>
</section>
}
>
<Show
when={assignmentData()}
fallback={
<section class={styles.emptyState}>
<p class={styles.emptyEyebrow}>Assignment not found</p>
<h1>We could not find that assignment workspace.</h1>
<A href={getDashboardHomeHref("student")} class={styles.backButton}>
Back to dashboard
</A>
</section>
}
>
{(data) => {
const reviewHref = createMemo(() => getAssignmentReviewHref("student", data().id));
return (
<>
<AssignmentWorkBanner data={data()} answeredCount={answeredCount()} isSubmitted={isSubmitted()} reviewHref={reviewHref()} />
<AssignmentWorkHeader
data={data()}
answeredCount={answeredCount()}
remainingCount={remainingCount()}
saveLabel={saveLabel()}
reviewHref={reviewHref()}
isSubmitting={submitState() === "submitting"}
onSubmit={() => void handleSubmit()}
/>
<AssignmentTabs role="student" />
<div class={styles.contentGrid}>
<AssignmentWorkNavigator
questions={data().questions}
drafts={drafts()}
activeQuestionId={activeQuestionId()}
answeredCount={answeredCount()}
remainingCount={remainingCount()}
saveLabel={saveLabel()}
isSubmitting={submitState() === "submitting"}
onSelectQuestion={setActiveQuestionId}
/>
<Show when={currentQuestion() && activeDraft()}>
<AssignmentWorkWorkspace
question={currentQuestion()!}
draft={activeDraft()!}
saveLabel={saveLabel()}
submittedAt={submittedAt()}
isLastQuestion={isLastQuestion()}
isSubmitting={submitState() === "submitting"}
onUpdateDraft={updateDraft}
onMoveQuestion={moveQuestion}
onSaveDraft={() => void saveQuestion()}
onSaveAndContinue={() => void handleSaveAndContinue()}
/>
</Show>
</div>
</>
);
}}
</Show>
</Show>
</div>
</main>
);
};
export default StudentAssignmentWorkPage;

View File

@@ -0,0 +1,247 @@
// Path: Frontend/src/components/assignment/work/student-assignment-work.sections.tsx
import { A } from "@solidjs/router";
import type { Component } from "solid-js";
import { createSignal, For, Show } from "solid-js";
import { assignmentUiCopy } from "~/content/ui-copy";
import type { AssignmentWorkPageData, AssignmentWorkQuestion } from "./assignment-work.data";
import styles from "./assignment-work.module.scss";
export type DraftState = Record<
number,
{
answer: string;
steps: string;
solveMode: "just_answer" | "step_by_step" | "solve_together" | "handwritten";
}
>;
export const solveModeOptions = [
{ value: "just_answer", label: assignmentUiCopy.studentWork.solveModes.justAnswer },
{ value: "step_by_step", label: assignmentUiCopy.studentWork.solveModes.stepByStep },
{ value: "solve_together", label: assignmentUiCopy.studentWork.solveModes.solveTogether },
{ value: "handwritten", label: assignmentUiCopy.studentWork.solveModes.handwritten },
] as const;
type AssignmentWorkBannerProps = {
data: AssignmentWorkPageData;
answeredCount: number;
isSubmitted: boolean;
reviewHref: string;
};
export const AssignmentWorkBanner: Component<AssignmentWorkBannerProps> = (props) => (
<Show when={props.isSubmitted}>
<section class={styles.submitBanner}>
<div>
<p class={styles.bannerEyebrow}>{assignmentUiCopy.studentWork.banner.eyebrow}</p>
<h2>
You answered {props.answeredCount} of {props.data.questions.length} questions.
</h2>
<p>Your latest answers are synced. You can review the assignment or keep refining any answer.</p>
</div>
<A href={props.reviewHref} class={styles.bannerLink}>
{assignmentUiCopy.studentWork.banner.link}
</A>
</section>
</Show>
);
type AssignmentWorkHeaderProps = {
data: AssignmentWorkPageData;
answeredCount: number;
remainingCount: number;
saveLabel: string;
reviewHref: string;
isSubmitting: boolean;
onSubmit: () => void;
};
export const AssignmentWorkHeader: Component<AssignmentWorkHeaderProps> = (props) => (
<section class={styles.headerCard}>
<div class={styles.headerTop}>
<A href={props.reviewHref} class={styles.backLink}>
{assignmentUiCopy.studentWork.header.backToReview}
</A>
<span class={styles.statusPill}>{props.data.statusLabel}</span>
</div>
<div class={styles.headerCopy}>
<p class={styles.eyebrow}>{props.data.classroomName}</p>
<h1>{assignmentUiCopy.studentWork.header.titlePrefix} {props.data.title}</h1>
<p>Answer each question, show your working, and save each answer back to the live assignment as you go.</p>
</div>
<div class={styles.headerMeta}>
<p class={styles.saveState}>{props.saveLabel}</p>
<div class={styles.submitMeta}>
<button type="button" class={styles.submitButton} onClick={props.onSubmit} disabled={props.isSubmitting}>
{props.isSubmitting ? assignmentUiCopy.studentWork.header.submitting : props.remainingCount > 0 ? `Submit ${props.answeredCount}/${props.data.questions.length}` : assignmentUiCopy.studentWork.header.submitAssignment}
</button>
<Show when={props.isSubmitting}>
<p class={styles.submitHint}>{assignmentUiCopy.studentWork.header.submitHint}</p>
</Show>
</div>
</div>
</section>
);
type AssignmentWorkNavigatorProps = {
questions: AssignmentWorkQuestion[];
drafts: DraftState;
activeQuestionId: number | null;
answeredCount: number;
remainingCount: number;
saveLabel: string;
isSubmitting: boolean;
onSelectQuestion: (questionId: number) => void;
};
export const AssignmentWorkNavigator: Component<AssignmentWorkNavigatorProps> = (props) => {
const [isExpanded, setIsExpanded] = createSignal(false);
return (
<aside class={styles.navigator}>
<div class={styles.navigatorHeader}>
<div>
<h2>{assignmentUiCopy.studentWork.navigator.title}</h2>
<p>
{props.answeredCount} of {props.questions.length} {assignmentUiCopy.studentWork.navigator.answeredSuffix}
</p>
</div>
<button
type="button"
class={styles.navigatorToggle}
aria-expanded={isExpanded()}
onClick={() => setIsExpanded((value) => !value)}
>
{isExpanded() ? "Hide list" : "Open list"}
</button>
</div>
<div classList={{ [styles.navigatorContent]: true, [styles.navigatorContentExpanded]: isExpanded() }}>
<div class={styles.progressMeta}>
<span>{props.remainingCount} {assignmentUiCopy.studentWork.navigator.remainingSuffix}</span>
<span>{props.saveLabel}</span>
</div>
<div class={styles.questionButtons}>
<For each={props.questions}>
{(question) => {
const hasDraft = () => (props.drafts[question.id]?.answer ?? "").trim().length > 0;
return (
<button
type="button"
classList={{
[styles.questionButton]: true,
[styles.activeQuestion]: props.activeQuestionId === question.id,
[styles.completedQuestion]: hasDraft(),
}}
disabled={props.isSubmitting}
onClick={() => {
props.onSelectQuestion(question.id);
setIsExpanded(false);
}}
>
<span>Q{question.order}</span>
<small>{hasDraft() ? assignmentUiCopy.studentWork.navigator.draftReady : question.statusLabel}</small>
</button>
);
}}
</For>
</div>
</div>
</aside>
);
};
type AssignmentWorkWorkspaceProps = {
question: AssignmentWorkQuestion;
draft: DraftState[number];
saveLabel: string;
submittedAt: string | null;
isLastQuestion: boolean;
isSubmitting: boolean;
onUpdateDraft: (field: "answer" | "steps" | "solveMode", value: string) => void;
onMoveQuestion: (direction: 1 | -1) => void;
onSaveDraft: () => void;
onSaveAndContinue: () => void;
};
export const AssignmentWorkWorkspace: Component<AssignmentWorkWorkspaceProps> = (props) => (
<section class={styles.workspace}>
<div class={styles.workspaceHeader}>
<div>
<p class={styles.questionEyebrow}>{assignmentUiCopy.studentWork.workspace.questionPrefix} {props.question.order}</p>
<h2>{props.question.prompt}</h2>
<p class={styles.questionStatus}>{props.question.statusLabel}</p>
</div>
<div class={styles.questionMeta}>
<span>{props.question.topic}</span>
<Show when={props.question.subTopic}>
<span>{props.question.subTopic}</span>
</Show>
<span>{props.question.difficulty}</span>
</div>
</div>
<label class={styles.fieldGroup}>
<span>{assignmentUiCopy.studentWork.workspace.solveMode}</span>
<select value={props.draft.solveMode} disabled={props.isSubmitting} onInput={(event) => props.onUpdateDraft("solveMode", event.currentTarget.value)}>
<For each={solveModeOptions}>{(option) => <option value={option.value}>{option.label}</option>}</For>
</select>
<small class={styles.fieldHint}>{assignmentUiCopy.studentWork.workspace.solveModeHint}</small>
</label>
<label class={styles.fieldGroup}>
<span>{assignmentUiCopy.studentWork.workspace.yourAnswer}</span>
<input type="text" value={props.draft.answer} disabled={props.isSubmitting} onInput={(event) => props.onUpdateDraft("answer", event.currentTarget.value)} placeholder={assignmentUiCopy.studentWork.workspace.answerPlaceholder} />
<small class={styles.fieldHint}>Keep this short your final number, fraction, or expression.</small>
</label>
<label class={styles.fieldGroup}>
<span>{assignmentUiCopy.studentWork.workspace.workingSteps}</span>
<textarea
rows={8}
value={props.draft.steps}
disabled={props.isSubmitting}
onInput={(event) => props.onUpdateDraft("steps", event.currentTarget.value)}
placeholder={assignmentUiCopy.studentWork.workspace.stepsPlaceholder}
/>
<small class={styles.fieldHint}>Show enough working that you could explain it back to your teacher later.</small>
</label>
<div class={styles.helperCard}>
<p class={styles.helperEyebrow}>{assignmentUiCopy.studentWork.workspace.helperEyebrow}</p>
<p>Try to explain why each step makes sense, not just what number you wrote next.</p>
<Show when={props.question.showAnswerKey}>
<div class={styles.answerReveal}>
<span>{assignmentUiCopy.studentWork.workspace.answerKey}</span>
<strong>{props.question.correctAnswer}</strong>
</div>
</Show>
</div>
<div class={styles.actionRow}>
<button type="button" class={styles.secondaryButton} onClick={() => props.onMoveQuestion(-1)} disabled={props.isSubmitting}>
{assignmentUiCopy.studentWork.workspace.previous}
</button>
<div class={styles.primaryActions}>
<button type="button" class={styles.secondaryButton} onClick={props.onSaveDraft} disabled={props.isSubmitting}>
{assignmentUiCopy.studentWork.workspace.saveDraft}
</button>
<button type="button" class={styles.primaryButton} onClick={props.onSaveAndContinue} disabled={props.isSubmitting}>
{props.isLastQuestion ? assignmentUiCopy.studentWork.workspace.saveThisQuestion : assignmentUiCopy.studentWork.workspace.saveAndContinue}
</button>
</div>
</div>
<div class={styles.questionFooter}>
<p>{props.saveLabel}</p>
<Show when={props.submittedAt}>
<p>{assignmentUiCopy.studentWork.workspace.lastSubmittedPrefix} {new Intl.DateTimeFormat("en-GB", { hour: "numeric", minute: "2-digit" }).format(new Date(props.submittedAt!))}</p>
</Show>
</div>
</section>
);

View File

@@ -1,76 +0,0 @@
import type { Component } from "solid-js";
import { For } from "solid-js";
import { A } from "@solidjs/router";
import { assignmentFocusGroups, assignmentFocusStats } from "./dashboard.data";
import styles from "./dashboard-assignments-focus.module.scss";
const DashboardAssignmentsFocus: Component = () => {
return (
<section class={styles.section}>
<article class={styles.heroCard}>
<div class={styles.heroCopy}>
<p class={styles.eyebrow}>Assignments</p>
<h1>Your assignment hub</h1>
<p>Stay in dashboard mode to see what is live, what needs finishing, and what is ready for review.</p>
</div>
<div class={styles.statGrid}>
<For each={assignmentFocusStats}>
{(stat) => (
<div class={styles.statCard}>
<strong>{stat.value}</strong>
<span>{stat.label}</span>
</div>
)}
</For>
</div>
</article>
<div class={styles.groupList}>
<For each={assignmentFocusGroups}>
{(group) => (
<section class={styles.group}>
<div class={styles.groupHeader}>
<div>
<h2>{group.title}</h2>
<p>{group.description}</p>
</div>
<span class={styles.groupCount}>{group.items.length}</span>
</div>
<div class={styles.cardGrid}>
<For each={group.items}>
{(item) => (
<article class={styles.assignmentCard}>
<div class={styles.cardTop}>
<span classList={{ [styles.statusChip]: true, [styles[item.tone]]: true }}>{item.statusLabel}</span>
<span class={styles.progressText}>{item.progressText}</span>
</div>
<div class={styles.cardBody}>
<h3>{item.title}</h3>
<p>{item.meta}</p>
<small>{item.subMeta}</small>
</div>
<div class={styles.cardActions}>
<A href={item.primaryHref} class={styles.primaryAction}>
{item.primaryLabel}
</A>
<A href={item.secondaryHref} class={styles.secondaryAction}>
{item.secondaryLabel}
</A>
</div>
</article>
)}
</For>
</div>
</section>
)}
</For>
</div>
</section>
);
};
export default DashboardAssignmentsFocus;

View File

@@ -1,188 +0,0 @@
.section {
display: grid;
gap: 1rem;
@media (min-width: 960px) {
gap: 1.25rem;
}
}
.heroCard {
display: grid;
gap: 1rem;
padding: 1.15rem;
border-radius: 1.5rem;
background: linear-gradient(135deg, var(--surface-panel), var(--surface-panel-strong));
border: 1px solid var(--border-strong);
box-shadow: var(--shadow-soft);
@media (min-width: 880px) {
grid-template-columns: minmax(0, 1.2fr) minmax(16rem, 0.95fr);
align-items: center;
padding: 1.35rem;
}
}
.heroCopy {
display: grid;
gap: 0.55rem;
h1 {
font-size: clamp(1.8rem, 1.45rem + 1.1vw, 2.8rem);
line-height: 1;
letter-spacing: -0.04em;
}
p:last-child {
color: var(--text-muted);
font-size: 0.96rem;
max-width: 58ch;
}
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.75rem;
color: var(--text-muted);
}
.statGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
@media (min-width: 520px) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (min-width: 880px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.statCard {
display: grid;
gap: 0.2rem;
padding: 0.95rem;
border-radius: 1.15rem;
background: var(--surface-soft);
border: 1px solid var(--border-soft);
strong {
font-size: 1.25rem;
line-height: 1;
}
span {
font-size: 0.84rem;
color: var(--text-muted);
}
}
.threadList {
display: grid;
gap: 1rem;
@media (min-width: 900px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.threadCard {
display: grid;
gap: 0.95rem;
padding: 1rem;
border-radius: 1.3rem;
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
}
.threadTop {
display: flex;
justify-content: space-between;
align-items: start;
gap: 0.85rem;
flex-wrap: wrap;
}
.senderWrap {
display: flex;
align-items: center;
gap: 0.8rem;
h2 {
font-size: 1rem;
line-height: 1.1;
}
p {
margin-top: 0.18rem;
font-size: 0.86rem;
color: var(--text-muted);
}
}
.avatar {
width: 2.5rem;
height: 2.5rem;
display: grid;
place-items: center;
border-radius: 9999px;
background: var(--surface-accent-soft);
color: var(--primary);
font-weight: 600;
flex-shrink: 0;
}
.threadMeta {
display: grid;
justify-items: end;
gap: 0.32rem;
font-size: 0.82rem;
color: var(--text-muted);
}
.unread {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.32rem 0.62rem;
border-radius: 9999px;
background: var(--surface-info-emphasis);
color: var(--text-info-strong);
font-size: 0.74rem;
font-weight: 600;
}
.preview {
font-size: 0.93rem;
color: var(--text-muted);
}
.threadActions {
display: flex;
justify-content: flex-start;
}
.primaryAction {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.8rem 1rem;
border-radius: 9999px;
font-weight: 500;
text-decoration: none;
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
color: var(--action-primary-text);
box-shadow: var(--action-primary-shadow);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
&:hover {
text-decoration: none;
transform: translateY(-1px);
}
}

View File

@@ -1,62 +0,0 @@
import type { Component } from "solid-js";
import { For } from "solid-js";
import { A } from "@solidjs/router";
import { messageFocusStats, messageFocusThreads } from "./dashboard.data";
import styles from "./dashboard-messages-focus.module.scss";
const DashboardMessagesFocus: Component = () => {
return (
<section class={styles.section}>
<article class={styles.heroCard}>
<div class={styles.heroCopy}>
<p class={styles.eyebrow}>Messages</p>
<h1>Your message centre</h1>
<p>Keep dashboard context while you check tutor guidance, assignment reminders, and quick study nudges that point you to the next useful action.</p>
</div>
<div class={styles.statGrid}>
<For each={messageFocusStats}>
{(stat) => (
<div class={styles.statCard}>
<strong>{stat.value}</strong>
<span>{stat.label}</span>
</div>
)}
</For>
</div>
</article>
<div class={styles.threadList}>
<For each={messageFocusThreads}>
{(thread) => (
<article class={styles.threadCard}>
<div class={styles.threadTop}>
<div class={styles.senderWrap}>
<span class={styles.avatar}>{thread.initials}</span>
<div>
<h2>{thread.sender}</h2>
<p>{thread.role}</p>
</div>
</div>
<div class={styles.threadMeta}>
<span>{thread.timestamp}</span>
{thread.unread && <strong class={styles.unread}>Unread</strong>}
</div>
</div>
<p class={styles.preview}>{thread.preview}</p>
<div class={styles.threadActions}>
<A href={thread.actionHref} class={styles.primaryAction}>
{thread.actionLabel}
</A>
</div>
</article>
)}
</For>
</div>
</section>
);
};
export default DashboardMessagesFocus;

View File

@@ -1,77 +0,0 @@
import type { Component } from "solid-js";
import { For } from "solid-js";
import { A } from "@solidjs/router";
import { settingsFocusPanels, settingsFocusStats, topbarSummary } from "./dashboard.data";
import styles from "./dashboard-settings-focus.module.scss";
const DashboardSettingsFocus: Component = () => {
return (
<section class={styles.section}>
<article class={styles.heroCard}>
<div class={styles.heroCopy}>
<p class={styles.eyebrow}>Settings</p>
<h1>Your dashboard settings</h1>
<p>Keep this inside the dashboard shell so profile details, study preferences, and learner goals feel like part of the same student workspace.</p>
</div>
<div class={styles.profileChip}>
<span class={styles.avatar}>{topbarSummary.profileBadge}</span>
<div>
<strong>{topbarSummary.profileName}</strong>
<span>{topbarSummary.profileRole}</span>
</div>
</div>
<div class={styles.statGrid}>
<For each={settingsFocusStats}>
{(stat) => (
<div class={styles.statCard}>
<strong>{stat.value}</strong>
<span>{stat.label}</span>
</div>
)}
</For>
</div>
</article>
<div class={styles.panelGrid}>
<For each={settingsFocusPanels}>
{(panel) => (
<article class={styles.panelCard}>
<div class={styles.panelBody}>
<div class={styles.panelCopy}>
<h2>{panel.title}</h2>
<p>{panel.description}</p>
</div>
<div class={styles.rowList}>
<For each={panel.rows}>
{(row) => (
<div class={styles.rowItem}>
<span>{row.label}</span>
<strong>{row.value}</strong>
</div>
)}
</For>
</div>
</div>
<div class={styles.panelActions}>
<A href={panel.primaryHref} class={styles.primaryAction}>
{panel.primaryLabel}
</A>
{panel.secondaryHref && panel.secondaryLabel && (
<A href={panel.secondaryHref} class={styles.secondaryAction}>
{panel.secondaryLabel}
</A>
)}
</div>
</article>
)}
</For>
</div>
</section>
);
};
export default DashboardSettingsFocus;

View File

@@ -1,62 +0,0 @@
import type { Component } from "solid-js";
import { For } from "solid-js";
import { A, useLocation } from "@solidjs/router";
import { classroomSummary, sidebarLinks, sidebarSupport } from "./dashboard.data";
import styles from "./dashboard-sidebar.module.scss";
type DashboardSidebarProps = {
onNavigate?: () => void;
};
const DashboardSidebar: Component<DashboardSidebarProps> = (props) => {
const location = useLocation();
const isActiveLink = (href?: string) => {
if (!href) return false;
if (href === "/dashboard") return location.pathname === "/dashboard";
return location.pathname === href || location.pathname.startsWith(`${href}/`);
};
return (
<aside class={styles.sidebar}>
<div class={styles.brand}>
<div class={styles.logoMark}>R</div>
<div>
<p class={styles.brandName}>Rooster AI</p>
<p class={styles.brandMeta}>{classroomSummary.name}</p>
</div>
</div>
<nav class={styles.navigation} aria-label="Dashboard navigation">
<For each={sidebarLinks}>
{(link) => (
<A
href={link.href ?? "#"}
classList={{ [styles.link]: true, [styles.active]: isActiveLink(link.href) || (!!link.active && location.pathname === "/dashboard") }}
onClick={() => props.onNavigate?.()}
>
<span class={styles.iconSlot}>{link.icon}</span>
<span class={styles.linkCopy}>
<strong>{link.label}</strong>
{link.detail && <small>{link.detail}</small>}
</span>
</A>
)}
</For>
</nav>
<div class={styles.supportCard}>
<div class={styles.avatarRow}>
<For each={sidebarSupport.avatars}>{(avatar) => <span>{avatar}</span>}</For>
</div>
<h2>{sidebarSupport.title}</h2>
<p>{sidebarSupport.description}</p>
<A href={sidebarSupport.buttonHref} class={styles.supportButton} onClick={() => props.onNavigate?.()}>
{sidebarSupport.buttonLabel}
</A>
</div>
</aside>
);
};
export default DashboardSidebar;

View File

@@ -1,218 +0,0 @@
import { A } from "@solidjs/router";
import { createSignal, For, onCleanup, onMount, Show, type Component } from "solid-js";
import DashboardThemeToggle from "./dashboard-theme-toggle";
import { topbarMessages, topbarNotifications, topbarSummary } from "./dashboard.data";
import styles from "./dashboard-topbar.module.scss";
type DashboardTopbarProps = {
isSidebarOpen: boolean;
onMenuToggle: () => void;
};
const DashboardTopbar: Component<DashboardTopbarProps> = (props) => {
const [openMenu, setOpenMenu] = createSignal<"notifications" | "messages" | "profile" | null>(null);
let menuRoot: HTMLDivElement | undefined;
const toggleMenu = (menu: "notifications" | "messages" | "profile") => {
setOpenMenu((current) => (current === menu ? null : menu));
};
onMount(() => {
const handlePointerDown = (event: MouseEvent) => {
if (!menuRoot?.contains(event.target as Node)) {
setOpenMenu(null);
}
};
document.addEventListener("pointerdown", handlePointerDown);
onCleanup(() => document.removeEventListener("pointerdown", handlePointerDown));
});
return (
<header class={styles.topbar}>
<button
type="button"
classList={{ [styles.menuButton]: true, [styles.menuButtonOpen]: props.isSidebarOpen }}
aria-label={props.isSidebarOpen ? "Close menu" : "Open menu"}
aria-expanded={props.isSidebarOpen}
onClick={props.onMenuToggle}
>
<span class={styles.menuGlyph} aria-hidden="true">
<span class={styles.menuLine} />
<span class={styles.menuLine} />
<span class={styles.menuLine} />
</span>
</button>
<label class={styles.searchField}>
<span class={styles.searchIcon} aria-hidden="true">
<svg viewBox="0 0 24 24">
<circle cx="11" cy="11" r="6" />
<path d="M20 20l-4.2-4.2" />
</svg>
</span>
<input type="search" placeholder={topbarSummary.searchPlaceholder} />
</label>
<div class={styles.actions} ref={menuRoot}>
<DashboardThemeToggle />
<div class={styles.menuGroup}>
<button
type="button"
class={styles.iconButton}
aria-label="Notifications"
aria-expanded={openMenu() === "notifications"}
onClick={() => toggleMenu("notifications")}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 5a4 4 0 0 1 4 4v2.5c0 1 .4 2 .99 2.8L18 16H6l1.01-1.7c.59-.8.99-1.8.99-2.8V9a4 4 0 0 1 4-4Z" />
<path d="M10 18a2 2 0 0 0 4 0" />
</svg>
<Show when={topbarSummary.notificationCount > 0}>
<span class={styles.countBadge}>{topbarSummary.notificationCount}</span>
</Show>
</button>
<Show when={openMenu() === "notifications"}>
<div class={styles.dropdown}>
<div class={styles.dropdownHeader}>
<div>
<p class={styles.dropdownEyebrow}>Alerts</p>
<strong>Notifications</strong>
</div>
<A href="/dashboard/progress" class={styles.dropdownLink} onClick={() => setOpenMenu(null)}>
Open progress
</A>
</div>
<div class={styles.dropdownList}>
<For each={topbarNotifications}>
{(item) => (
<A href={item.href} class={styles.dropdownItem} onClick={() => setOpenMenu(null)}>
<span class={`${styles.itemTone} ${styles[`tone-${item.tone}`]}`} aria-hidden="true" />
<div class={styles.itemCopy}>
<strong>{item.title}</strong>
<p>{item.description}</p>
</div>
<small>{item.timestamp}</small>
</A>
)}
</For>
</div>
</div>
</Show>
</div>
<div class={styles.menuGroup}>
<button
type="button"
class={styles.iconButton}
aria-label="Messages"
aria-expanded={openMenu() === "messages"}
onClick={() => toggleMenu("messages")}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 6h16v10H8l-4 3V6Z" />
</svg>
<Show when={topbarSummary.messageCount > 0}>
<span class={styles.countBadge}>{topbarSummary.messageCount}</span>
</Show>
</button>
<Show when={openMenu() === "messages"}>
<div class={styles.dropdown}>
<div class={styles.dropdownHeader}>
<div>
<p class={styles.dropdownEyebrow}>Inbox</p>
<strong>Messages</strong>
</div>
<A href="/dashboard/messages" class={styles.dropdownLink} onClick={() => setOpenMenu(null)}>
Open inbox
</A>
</div>
<div class={styles.dropdownList}>
<For each={topbarMessages}>
{(item) => (
<A href={item.actionHref} class={styles.dropdownItem} onClick={() => setOpenMenu(null)}>
<span class={styles.messageAvatar}>{item.initials}</span>
<div class={styles.itemCopy}>
<strong>{item.sender}</strong>
<p>{item.preview}</p>
</div>
<small>{item.timestamp}</small>
</A>
)}
</For>
</div>
</div>
</Show>
</div>
<div class={styles.menuGroup}>
<button
type="button"
classList={{ [styles.profileButton]: true, [styles.profileButtonOpen]: openMenu() === "profile" }}
aria-label="Profile menu"
aria-expanded={openMenu() === "profile"}
onClick={() => toggleMenu("profile")}
>
<div class={styles.profile}>
<div>
<p class={styles.profileName}>{topbarSummary.profileName}</p>
<p class={styles.profileRole}>{topbarSummary.profileRole}</p>
</div>
<span class={styles.profileBadge}>{topbarSummary.profileBadge}</span>
<span class={styles.profileChevron} aria-hidden="true">
<svg viewBox="0 0 20 20">
<path d="m5 7.5 5 5 5-5" />
</svg>
</span>
</div>
</button>
<Show when={openMenu() === "profile"}>
<div class={styles.dropdown}>
<div class={styles.dropdownHeader}>
<div>
<p class={styles.dropdownEyebrow}>Signed in as</p>
<strong>{topbarSummary.profileName}</strong>
</div>
<span class={styles.profileMenuBadge}>{topbarSummary.profileBadge}</span>
</div>
<div class={styles.dropdownList}>
<A href="/dashboard/settings" class={styles.dropdownItem} onClick={() => setOpenMenu(null)}>
<span class={`${styles.itemTone} ${styles["tone-blue"]}`} aria-hidden="true" />
<div class={styles.itemCopy}>
<strong>Profile</strong>
<p>Update your learner details and view your profile settings.</p>
</div>
</A>
<A href="/dashboard/settings" class={styles.dropdownItem} onClick={() => setOpenMenu(null)}>
<span class={`${styles.itemTone} ${styles["tone-yellow"]}`} aria-hidden="true" />
<div class={styles.itemCopy}>
<strong>Settings</strong>
<p>Manage goals, preferences, reminders, and dashboard options.</p>
</div>
</A>
<A href="/auth/login" class={styles.dropdownItem} onClick={() => setOpenMenu(null)}>
<span class={`${styles.itemTone} ${styles["tone-teal"]}`} aria-hidden="true" />
<div class={styles.itemCopy}>
<strong>Log out</strong>
<p>Return to the sign-in screen. This is a UI-only sign out for now.</p>
</div>
</A>
</div>
</div>
</Show>
</div>
</div>
</header>
);
};
export default DashboardTopbar;

View File

@@ -1,774 +0,0 @@
import rawDataset from "../../../../Mock-Data/dataset.json";
type SidebarLink = {
label: string;
detail?: string;
icon: string;
href?: string;
active?: boolean;
};
type SpotlightStat = {
label: string;
value: string;
tone: "purple" | "yellow" | "blue";
};
type QuickStat = {
label: string;
value: string;
};
type AssignmentCard = {
title: string;
lessons: string;
accent: "yellow" | "pink" | "teal" | "blue";
cta: string;
href: string;
};
type AssignmentFocusStat = {
label: string;
value: string;
};
type AssignmentFocusItem = {
title: string;
meta: string;
subMeta: string;
statusLabel: string;
progressText: string;
tone: "yellow" | "teal" | "blue";
primaryLabel: string;
primaryHref: string;
secondaryLabel: string;
secondaryHref: string;
};
type AssignmentFocusGroup = {
title: string;
description: string;
items: AssignmentFocusItem[];
};
type PracticeFocusStat = {
label: string;
value: string;
};
type PracticeFocusCard = {
title: string;
description: string;
meta: string;
tone: "yellow" | "teal" | "blue";
primaryLabel: string;
primaryHref: string;
secondaryLabel: string;
secondaryHref: string;
};
type MessageFocusStat = {
label: string;
value: string;
};
type MessageFocusThread = {
sender: string;
role: string;
initials: string;
preview: string;
timestamp: string;
unread?: boolean;
actionLabel: string;
actionHref: string;
};
type TopbarNotificationItem = {
title: string;
description: string;
timestamp: string;
href: string;
tone: "blue" | "yellow" | "teal";
};
type SettingsFocusStat = {
label: string;
value: string;
};
type SettingsFocusPanel = {
title: string;
description: string;
rows: Array<{
label: string;
value: string;
}>;
primaryLabel: string;
primaryHref: string;
secondaryLabel?: string;
secondaryHref?: string;
};
type StudentSupportCard = {
name: string;
meta: string;
initials: string;
actionLabel: string;
href: string;
};
type HighlightCard = {
value: string;
label: string;
note: string;
tone: "yellow" | "pink";
};
type PerformanceBar = {
label: string;
value: number;
tone: "blue" | "purple" | "teal" | "pink" | "yellow";
};
type UsageItem = {
label: string;
value: number;
tone: "blue" | "purple" | "teal" | "yellow";
};
type Dataset = {
_meta: {
reference_today: string;
students: number;
assignments: number;
questions_in_bank: number;
student_answers: number;
expected_top_3_at_risk_student_ids: number[];
};
classroom: {
name: string;
invite_code: string;
target_level: number;
};
tutor: {
fullname: string;
role: string;
};
students: Array<{
id: number;
fullname: string;
_persona: string;
}>;
assignments: Array<{
id: number;
name: string;
topic: string;
status: "DRAFT" | "PUBLISHED" | "CLOSED";
due_date: number;
maximum_marks: number;
}>;
assignment_assignees: Array<{
id: number;
assignment_id: number;
student_id: number;
status: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED";
total_marks: number;
}>;
student_answers: Array<{
assignee_id: number;
_is_correct: boolean;
_solve_mode: "just_answer" | "step_by_step" | "solve_together" | "handwritten";
_question_topic: string;
_answered_at: number;
}>;
activity_logs: Array<{
timestamp: number;
duration_seconds: number;
_student_id: number;
}>;
};
type StudentAssignment = Dataset["assignment_assignees"][number] & {
assignment: Dataset["assignments"][number];
answerCount: number;
accuracy: number;
};
const dataset = rawDataset as Dataset;
const referenceTime = new Date(`${dataset._meta.reference_today}T00:00:00Z`).getTime();
const referenceDate = new Date(referenceTime);
const msPerDay = 1000 * 60 * 60 * 24;
const defaultStudentId = 201;
const students = dataset.students;
const assignments = [...dataset.assignments].sort((left, right) => left.due_date - right.due_date);
const assignees = dataset.assignment_assignees;
const answers = dataset.student_answers;
const activityLogs = dataset.activity_logs;
const student = students.find((entry) => entry.id === defaultStudentId) ?? students[0];
const initialsFor = (name: string) =>
name
.split(" ")
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase();
const firstName = student.fullname.split(" ")[0];
const formatCompactNumber = (value: number) => new Intl.NumberFormat("en-GB", { notation: "compact", maximumFractionDigits: 1 }).format(value);
const formatPercent = (value: number) => `${Math.round(value)}%`;
const formatDueDate = (timestamp: number) =>
new Intl.DateTimeFormat("en-GB", {
month: "short",
day: "numeric",
}).format(new Date(timestamp));
const formatHours = (seconds: number) => `${(seconds / 3600).toFixed(1)}h`;
const daysUntil = (timestamp: number) => Math.max(0, Math.ceil((timestamp - referenceTime) / msPerDay));
const formatLastSeen = (timestamp: number) => {
const dayDiff = Math.max(0, Math.round((referenceTime - timestamp) / msPerDay));
if (dayDiff <= 0) return "today";
if (dayDiff === 1) return "yesterday";
return `${dayDiff} days ago`;
};
const topicTone = ["pink", "yellow", "blue", "purple", "teal"] as const;
const assignmentById = new Map(assignments.map((assignment) => [assignment.id, assignment]));
const assigneeById = new Map(assignees.map((assignee) => [assignee.id, assignee]));
const studentAnswers = answers.filter((answer) => assigneeById.get(answer.assignee_id)?.student_id === student.id);
const studentLogs = activityLogs.filter((log) => log._student_id === student.id);
const studentAssignments: StudentAssignment[] = assignees
.filter((assignee) => assignee.student_id === student.id)
.map((assignee) => {
const assignment = assignmentById.get(assignee.assignment_id);
if (!assignment) throw new Error(`Missing assignment ${assignee.assignment_id}`);
const assignmentAnswers = studentAnswers.filter((answer) => answer.assignee_id === assignee.id);
const answerCount = assignmentAnswers.length;
const correct = assignmentAnswers.filter((answer) => answer._is_correct).length;
return {
...assignee,
assignment,
answerCount,
accuracy: answerCount > 0 ? Math.round((correct / answerCount) * 100) : 0,
};
})
.sort((left, right) => left.assignment.due_date - right.assignment.due_date);
const totalCorrectAnswers = studentAnswers.filter((answer) => answer._is_correct).length;
const overallAccuracy = studentAnswers.length > 0 ? (totalCorrectAnswers / studentAnswers.length) * 100 : 0;
const totalDurationSeconds = studentLogs.reduce((sum, log) => sum + log.duration_seconds, 0);
const latestActivityTime = Math.max(...studentLogs.map((log) => log.timestamp));
const submittedAssignments = studentAssignments.filter((assignment) => assignment.status === "SUBMITTED");
const inProgressAssignments = studentAssignments.filter((assignment) => assignment.status === "IN_PROGRESS");
const notStartedAssignments = studentAssignments.filter((assignment) => assignment.status === "NOT_STARTED");
const pendingAssignments = studentAssignments.filter((assignment) => assignment.status !== "SUBMITTED");
const currentAssignment = inProgressAssignments[0] ?? notStartedAssignments[0] ?? studentAssignments[studentAssignments.length - 1];
const nextNotStartedAssignment = notStartedAssignments[0];
const topicBuckets = new Map<string, { correct: number; total: number }>();
for (const answer of studentAnswers) {
const bucket = topicBuckets.get(answer._question_topic) ?? { correct: 0, total: 0 };
bucket.correct += Number(answer._is_correct);
bucket.total += 1;
topicBuckets.set(answer._question_topic, bucket);
}
const topicPerformance = [...topicBuckets.entries()].map(([label, bucket]) => ({
label,
value: Math.round((bucket.correct / bucket.total) * 100),
total: bucket.total,
}));
const strongestTopic = [...topicPerformance].sort((left, right) => right.value - left.value || right.total - left.total)[0];
const weakestTopic = [...topicPerformance].sort((left, right) => left.value - right.value || right.total - left.total)[0];
const topicMasteryBars: PerformanceBar[] = [...topicPerformance]
.sort((left, right) => right.total - left.total || left.value - right.value)
.slice(0, 5)
.sort((left, right) => left.value - right.value)
.map((topic, index) => ({
label: topic.label,
value: topic.value,
tone: topicTone[index % topicTone.length],
}));
const solveModeBuckets = new Map<string, number>();
for (const answer of studentAnswers) {
solveModeBuckets.set(answer._solve_mode, (solveModeBuckets.get(answer._solve_mode) ?? 0) + 1);
}
const solveModeToneMap: Record<string, UsageItem["tone"]> = {
just_answer: "purple",
step_by_step: "blue",
solve_together: "teal",
handwritten: "yellow",
};
const solveModeLabelMap: Record<string, string> = {
just_answer: "Just answer",
step_by_step: "Step by step",
solve_together: "Solve together",
handwritten: "Handwritten",
};
const solveModeUsage: UsageItem[] = [...solveModeBuckets.entries()]
.map(([mode, count]) => ({
label: solveModeLabelMap[mode] ?? mode,
value: Math.round((count / studentAnswers.length) * 100),
tone: solveModeToneMap[mode] ?? "blue",
}))
.sort((left, right) => right.value - left.value);
const topSolveMode = solveModeUsage[0];
const personaSummary: Record<string, { title: string; recommendation: string }> = {
fraction_inversion: {
title: "Fractions is your focus this week",
recommendation: "Slow down on equivalent fractions and practise inversion rules before test prep.",
},
place_value_gaps: {
title: "Place value is worth another pass",
recommendation: "Use quick warm-ups before multi-step arithmetic to avoid slips early in the question.",
},
rushed_careless: {
title: "A calmer pace will lift your accuracy",
recommendation: "Use Step by step more often on tougher questions so careless mistakes do not snowball.",
},
solve_together_dependent: {
title: "Independent confidence is your next goal",
recommendation: "Try one question solo before switching to Solve together so you build recall first.",
},
word_problem_weak: {
title: "Word problems are the main thing to unlock",
recommendation: "Underline key information and turn the question into a quick number sentence first.",
},
stable_strong: {
title: "You are in a strong learning rhythm",
recommendation: "Keep stretching with harder mixed practice so the easier wins stay automatic.",
},
stable_mid: {
title: "You are close to a really strong streak",
recommendation: "A few focused sessions on weaker topics should move your average up quickly.",
},
stable_weak: {
title: "A steadier routine will help most right now",
recommendation: "Aim for short, regular practice blocks before trying bigger mixed assignments.",
},
};
const activePersona = personaSummary[student._persona] ?? {
title: "Keep building momentum",
recommendation: "Stay focused on one weak area at a time and finish current work before starting new tasks.",
};
const assignmentAccent = (status: StudentAssignment["status"]): AssignmentCard["accent"] => {
switch (status) {
case "IN_PROGRESS":
return "blue";
case "NOT_STARTED":
return "yellow";
default:
return "teal";
}
};
const assignmentCta = (status: StudentAssignment["status"]) => {
switch (status) {
case "IN_PROGRESS":
return "Resume now";
case "NOT_STARTED":
return "Start now";
default:
return "View recap";
}
};
const assignmentStatusCopy = (assignment: StudentAssignment) => {
if (assignment.status === "SUBMITTED") {
return `${assignment.assignment.topic} · Submitted · Score ${assignment.total_marks}/${assignment.assignment.maximum_marks}`;
}
if (assignment.status === "IN_PROGRESS") {
return `${assignment.assignment.topic} · ${assignment.answerCount} questions answered · Due ${formatDueDate(assignment.assignment.due_date)}`;
}
return `${assignment.assignment.topic} · Not started · Due ${formatDueDate(assignment.assignment.due_date)}`;
};
const assignmentCards: AssignmentCard[] = [...studentAssignments]
.sort((left, right) => {
const weight = { IN_PROGRESS: 0, NOT_STARTED: 1, SUBMITTED: 2 } as const;
return weight[left.status] - weight[right.status] || left.assignment.due_date - right.assignment.due_date;
})
.slice(0, 4)
.map((assignment) => ({
title: assignment.assignment.name,
lessons: assignmentStatusCopy(assignment),
accent: assignmentAccent(assignment.status),
cta: assignmentCta(assignment.status),
href: `/assignment/${assignment.assignment.id}`,
}));
const submittedTrend = submittedAssignments.slice(-6).map((assignment) => ({
label: assignment.assignment.topic.length > 8 ? assignment.assignment.topic.slice(0, 8) : assignment.assignment.topic,
value: assignment.assignment.maximum_marks > 0 ? Math.round((assignment.total_marks / assignment.assignment.maximum_marks) * 100) : 0,
}));
const usageSummary = {
note: topSolveMode
? `You use ${topSolveMode.label} for ${topSolveMode.value}% of your answers. When ${weakestTopic?.label?.toLowerCase() ?? "a topic"} gets tricky, try Step by step for a calmer second try.`
: "Try a mix of independent and guided solving to learn what helps you most.",
};
export const classroomSummary = {
name: dataset.classroom.name,
targetLevel: dataset.classroom.target_level,
inviteCode: dataset.classroom.invite_code,
tutorName: dataset.tutor.fullname,
tutorRole: "Lead tutor",
tutorInitials: initialsFor(dataset.tutor.fullname),
};
export const sidebarLinks: SidebarLink[] = [
{ label: "Home", detail: "Today", icon: "⌂", href: "/dashboard", active: true },
{ label: "Assignments", detail: `${pendingAssignments.length} live`, icon: "✓", href: "/dashboard/assignments" },
{ label: "Progress", detail: `${Math.round(overallAccuracy)}% accuracy`, icon: "↗", href: "/dashboard/progress" },
{ label: "Practice", detail: weakestTopic?.label ?? "Mixed skills", icon: "✦", href: "/dashboard/practice" },
{ label: "Messages", detail: dataset.tutor.fullname.split(" ")[0], icon: "✉", href: "/dashboard/messages" },
{ label: "Settings", detail: "Profile & goals", icon: "⋯", href: "/dashboard/settings" },
];
export const sidebarSupport = {
avatars: [currentAssignment.assignment.name.replace("HW", "H").split("—")[0].trim(), weakestTopic?.label.slice(0, 2) ?? "WK", nextNotStartedAssignment?.assignment.name.replace("HW", "H").split("—")[0].trim() ?? "GO"],
title: "Todays study plan",
description: `Pick up ${currentAssignment.assignment.name.split("—")[0].trim()}, spend 15 minutes on ${weakestTopic?.label ?? "your focus topic"}, then start ${nextNotStartedAssignment ? nextNotStartedAssignment.assignment.name.split("—")[0].trim() : "your next task"} while it still feels easy.`,
buttonLabel: "Open my plan",
buttonHref: `/assignment/${currentAssignment.assignment.id}/work`,
};
export const topbarSummary = {
searchPlaceholder: "Search assignments, hints, or question topics",
profileName: student.fullname,
profileRole: `${dataset.classroom.name} · Student`,
profileBadge: initialsFor(student.fullname),
notificationCount: 3,
messageCount: 1,
};
export const topbarNotifications: TopbarNotificationItem[] = [
{
title: `${currentAssignment.assignment.name.split("—")[0].trim()} is still live`,
description: currentAssignment.status === "SUBMITTED"
? `You have already finished this task. Open the review and clean up any missed marks.`
: `${currentAssignment.answerCount} questions are already touched. Finish it before ${formatDueDate(currentAssignment.assignment.due_date)}.`,
timestamp: `${daysUntil(currentAssignment.assignment.due_date)}d left`,
href: currentAssignment.status === "SUBMITTED" ? `/assignment/${currentAssignment.assignment.id}` : `/assignment/${currentAssignment.assignment.id}/work`,
tone: currentAssignment.status === "SUBMITTED" ? "teal" : "blue",
},
{
title: `${weakestTopic?.label ?? "Focus practice"} needs a quick pass`,
description: weakestTopic
? `${weakestTopic.value}% accuracy across ${weakestTopic.total} recent questions. A short focused block here should move the needle fastest.`
: "Open a short practice block and work through your next weak spot.",
timestamp: "Practice now",
href: "/dashboard/practice",
tone: "yellow",
},
{
title: `${dataset.tutor.fullname.split(" ")[0]} left feedback`,
description: `There is a fresh tutor message waiting with a calm next-step suggestion for ${weakestTopic?.label?.toLowerCase() ?? "your current focus"}.`,
timestamp: "Today",
href: "/dashboard/messages",
tone: "teal",
},
];
export const heroSummary = {
eyebrow: `Welcome back, ${firstName}`,
title: activePersona.title,
description: `You have finished ${submittedAssignments.length} assignments so far. Right now, the best next step is to keep ${currentAssignment.assignment.name.split("—")[0].trim()} moving and give ${weakestTopic?.label ?? "your focus topic"} a short confidence boost.`,
visualBadges: [formatPercent(overallAccuracy), `${currentAssignment.assignment.name.split("—")[0].trim()} live`, `${weakestTopic?.label ?? "Focus"} focus`],
};
export const spotlightStats: SpotlightStat[] = [
{ label: "Assignments done", value: `${submittedAssignments.length}/${studentAssignments.length}`, tone: "purple" },
{ label: "Current focus", value: weakestTopic?.label ?? "Mixed practice", tone: "yellow" },
{ label: "Best topic", value: strongestTopic?.label ?? "Building", tone: "blue" },
];
export const heroSideCard = {
title: currentAssignment.status === "SUBMITTED" ? `Nice work on ${currentAssignment.assignment.name.split("—")[0].trim()}` : `Pick up where you left off`,
description:
currentAssignment.status === "SUBMITTED"
? `You scored ${currentAssignment.total_marks}/${currentAssignment.assignment.maximum_marks}. A quick recap now will make the next assignment feel easier.`
: `${currentAssignment.answerCount} questions are already done. Finish it by ${formatDueDate(currentAssignment.assignment.due_date)} while the topic is still fresh.`,
buttonLabel: currentAssignment.status === "SUBMITTED" ? "Review this work" : "Resume assignment",
buttonHref: currentAssignment.status === "SUBMITTED" ? `/assignment/${currentAssignment.assignment.id}` : `/assignment/${currentAssignment.assignment.id}/work`,
};
export const quickStats: QuickStat[] = [
{ label: "Next due", value: `${daysUntil(currentAssignment.assignment.due_date)}d` },
{ label: "Last active", value: formatLastSeen(latestActivityTime) },
];
export { assignmentCards };
export const assignmentFocusStats: AssignmentFocusStat[] = [
{ label: "Live now", value: `${pendingAssignments.length}` },
{ label: "Completed", value: `${submittedAssignments.length}` },
{ label: "Average score", value: formatPercent(overallAccuracy) },
{ label: "Next due", value: formatDueDate(currentAssignment.assignment.due_date) },
];
export const progressFocusStats: AssignmentFocusStat[] = [
{ label: "Accuracy", value: formatPercent(overallAccuracy) },
{ label: "Marked", value: `${submittedAssignments.length}` },
{ label: "Strongest", value: strongestTopic?.label ?? "Building" },
{ label: "Study time", value: formatHours(totalDurationSeconds) },
];
export const practiceFocusStats: PracticeFocusStat[] = [
{ label: "Focus topic", value: weakestTopic?.label ?? "Mixed skills" },
{ label: "Topic score", value: weakestTopic ? formatPercent(weakestTopic.value) : "--" },
{ label: "Best support", value: topSolveMode?.label ?? "Try mixed modes" },
{ label: "Ready now", value: currentAssignment.assignment.name.split("—")[0].trim() },
];
export const practiceFocusCards: PracticeFocusCard[] = [
{
title: `Rebuild ${weakestTopic?.label ?? "your focus topic"}`,
description: `Start with the topic that is costing you the most marks right now, then return to your current assignment while the method is still fresh.`,
meta: weakestTopic ? `${weakestTopic.total} recent questions · ${weakestTopic.value}% accuracy` : "Short, focused practice is the fastest win.",
tone: "yellow",
primaryLabel: "Open practice",
primaryHref: `/assignment/${currentAssignment.assignment.id}/work`,
secondaryLabel: "View progress",
secondaryHref: "/dashboard/progress",
},
{
title: "Try one independent pass",
description: topSolveMode
? `${topSolveMode.label} is your default. Try answering one question solo before switching modes so you build stronger recall.`
: "Use one question to test yourself first, then bring in guided help if you need it.",
meta: `Current assignment: ${currentAssignment.assignment.name}`,
tone: "blue",
primaryLabel: currentAssignment.status === "IN_PROGRESS" ? "Resume work" : "Start assignment",
primaryHref: `/assignment/${currentAssignment.assignment.id}/work`,
secondaryLabel: "Review task",
secondaryHref: `/assignment/${currentAssignment.assignment.id}`,
},
{
title: `Finish with ${strongestTopic?.label ?? "a strong topic"}`,
description: `Once the hard bit is done, finish your session on something you usually get right so your next study block starts with confidence.`,
meta: strongestTopic ? `${strongestTopic.value}% accuracy in ${strongestTopic.label}` : "Pick a familiar topic for a strong finish.",
tone: "teal",
primaryLabel: "See assignments",
primaryHref: "/dashboard/assignments",
secondaryLabel: "Open dashboard",
secondaryHref: "/dashboard",
},
];
export const messageFocusStats: MessageFocusStat[] = [
{ label: "Tutor", value: dataset.tutor.fullname.split(" ")[0] },
{ label: "Unread", value: "3" },
{ label: "Latest topic", value: weakestTopic?.label ?? "Mixed skills" },
{ label: "Last active", value: formatLastSeen(latestActivityTime) },
];
export const messageFocusThreads: MessageFocusThread[] = [
{
sender: dataset.tutor.fullname,
role: "Tutor",
initials: initialsFor(dataset.tutor.fullname),
preview: `Lets spend the next session tightening up ${weakestTopic?.label?.toLowerCase() ?? "your focus topic"}. You only need a couple of calm wins here to lift your confidence.`,
timestamp: "Today",
unread: true,
actionLabel: "Open practice",
actionHref: "/dashboard/practice",
},
{
sender: "Assignment check-in",
role: currentAssignment.assignment.name.split("—")[0].trim(),
initials: currentAssignment.assignment.name.replace("HW", "H").split("—")[0].trim(),
preview: currentAssignment.status === "SUBMITTED"
? `Nice work finishing this one. Review the marked questions before you start the next task.`
: `${currentAssignment.answerCount} questions are already done. Finish this assignment before ${formatDueDate(currentAssignment.assignment.due_date)} while it still feels familiar.`,
timestamp: currentAssignment.status === "SUBMITTED" ? "Yesterday" : `${daysUntil(currentAssignment.assignment.due_date)}d left`,
actionLabel: currentAssignment.status === "SUBMITTED" ? "Open review" : "Resume work",
actionHref: currentAssignment.status === "SUBMITTED" ? `/assignment/${currentAssignment.assignment.id}` : `/assignment/${currentAssignment.assignment.id}/work`,
},
{
sender: "Study coach",
role: "Quick reminder",
initials: "SC",
preview: `Your strongest topic is ${strongestTopic?.label ?? "building"}. End today with one easier question there after you practise ${weakestTopic?.label?.toLowerCase() ?? "your focus topic"}.`,
timestamp: "2 days ago",
actionLabel: "View progress",
actionHref: "/dashboard/progress",
},
];
export const topbarMessages = messageFocusThreads.slice(0, 3);
export const settingsFocusStats: SettingsFocusStat[] = [
{ label: "Student", value: firstName },
{ label: "Class", value: `Year ${dataset.classroom.target_level}` },
{ label: "Focus", value: weakestTopic?.label ?? "Mixed skills" },
{ label: "Goal", value: `${Math.max(Math.round(overallAccuracy) + 10, 70)}%` },
];
export const settingsFocusPanels: SettingsFocusPanel[] = [
{
title: "Profile",
description: "The learner details and classroom context this dashboard is currently designed around.",
rows: [
{ label: "Name", value: student.fullname },
{ label: "Role", value: `${dataset.classroom.name} · Student` },
{ label: "Tutor", value: dataset.tutor.fullname },
{ label: "Invite code", value: dataset.classroom.invite_code },
],
primaryLabel: "Open messages",
primaryHref: "/dashboard/messages",
secondaryLabel: "View progress",
secondaryHref: "/dashboard/progress",
},
{
title: "Learning preferences",
description: "A few smart defaults for how this learner is currently working best.",
rows: [
{ label: "Best support mode", value: topSolveMode?.label ?? "Mixed support" },
{ label: "Strongest topic", value: strongestTopic?.label ?? "Building confidence" },
{ label: "Needs attention", value: weakestTopic?.label ?? "Mixed skills" },
{ label: "Last active", value: formatLastSeen(latestActivityTime) },
],
primaryLabel: "Open practice",
primaryHref: "/dashboard/practice",
secondaryLabel: "See assignments",
secondaryHref: "/dashboard/assignments",
},
{
title: "Goals and reminders",
description: "Settings-like controls can start by summarising the targets and nudges this learner should keep in view.",
rows: [
{ label: "Current goal", value: `Lift ${weakestTopic?.label?.toLowerCase() ?? "focus work"} by 10%` },
{ label: "Live assignments", value: `${pendingAssignments.length}` },
{ label: "Study time", value: formatHours(totalDurationSeconds) },
{ label: "Next due", value: formatDueDate(currentAssignment.assignment.due_date) },
],
primaryLabel: "Open todays plan",
primaryHref: `/assignment/${currentAssignment.assignment.id}/work`,
secondaryLabel: "Go to dashboard",
secondaryHref: "/dashboard",
},
];
const mapAssignmentFocusItem = (assignment: StudentAssignment): AssignmentFocusItem => {
const isSubmitted = assignment.status === "SUBMITTED";
const isInProgress = assignment.status === "IN_PROGRESS";
return {
title: assignment.assignment.name,
meta: assignment.assignment.topic,
subMeta: isSubmitted
? `Marked ${assignment.total_marks}/${assignment.assignment.maximum_marks} · Reviewed ${assignment.answerCount} questions`
: `Due ${formatDueDate(assignment.assignment.due_date)} · ${assignment.answerCount} questions touched so far`,
statusLabel: isSubmitted ? "Completed" : isInProgress ? "In progress" : "Ready to start",
progressText: isSubmitted ? `${assignment.accuracy}% accuracy` : `${daysUntil(assignment.assignment.due_date)}d left`,
tone: isSubmitted ? "teal" : isInProgress ? "blue" : "yellow",
primaryLabel: isSubmitted ? "Open review" : isInProgress ? "Resume work" : "Start assignment",
primaryHref: isSubmitted ? `/assignment/${assignment.assignment.id}` : `/assignment/${assignment.assignment.id}/work`,
secondaryLabel: isSubmitted ? "Open workspace" : "See review",
secondaryHref: isSubmitted ? `/assignment/${assignment.assignment.id}/work` : `/assignment/${assignment.assignment.id}`,
};
};
export const assignmentFocusGroups: AssignmentFocusGroup[] = [
{
title: "Continue now",
description: "Assignments that already have momentum and are best finished next.",
items: studentAssignments.filter((assignment) => assignment.status === "IN_PROGRESS").map(mapAssignmentFocusItem),
},
{
title: "Coming up",
description: "Work that is live but not started yet, so you can choose what to begin early.",
items: studentAssignments.filter((assignment) => assignment.status === "NOT_STARTED").map(mapAssignmentFocusItem),
},
{
title: "Completed",
description: "Marked work you can revisit for review, correction, and confidence boosts.",
items: studentAssignments.filter((assignment) => assignment.status === "SUBMITTED").map(mapAssignmentFocusItem),
},
].filter((group) => group.items.length > 0);
export const activitySummary = {
title: "Recent results",
note: `${submittedAssignments.length} assignments have been marked so far. Use this as a quick check on what is improving and what still needs another pass.`,
badge: `${submittedAssignments.length} marked`,
};
export const progressPoints = submittedTrend.map((point) => point.value);
export const progressLabels = submittedTrend.map((point) => point.label);
export const highlightCards: HighlightCard[] = [
{
value: `${studentAnswers.length}`,
label: "Questions answered",
note: `You have already submitted ${submittedAssignments.length} assignments and still have ${pendingAssignments.length} live tasks to work through.`,
tone: "yellow",
},
{
value: formatHours(totalDurationSeconds),
label: "Time invested",
note: `Last active ${formatLastSeen(latestActivityTime)}. ${activePersona.recommendation}`,
tone: "pink",
},
];
export const studentSupportList: StudentSupportCard[] = [
{
name: `Review ${weakestTopic?.label ?? "your focus topic"}`,
meta: `${weakestTopic?.value ?? 0}% accuracy across ${weakestTopic?.total ?? 0} questions. A short refresher here should give you the fastest win.`,
initials: weakestTopic?.label.slice(0, 2).toUpperCase() ?? "WK",
actionLabel: "Start practice",
href: `/assignment/${currentAssignment.assignment.id}/work`,
},
{
name: `Finish ${currentAssignment.assignment.name.split("—")[0].trim()}`,
meta:
currentAssignment.status === "IN_PROGRESS"
? `${currentAssignment.answerCount} questions are already done. One more focused block should move this over the line.`
: `This is your next live assignment. Starting early will make it feel much lighter later in the week.`,
initials: currentAssignment.assignment.name.replace("HW", "H").split("—")[0].trim(),
actionLabel: currentAssignment.status === "IN_PROGRESS" ? "Resume" : "Start",
href: `/assignment/${currentAssignment.assignment.id}/work`,
},
{
name: "Try one question solo",
meta: topSolveMode
? `${topSolveMode.label} is your default mode. Try one independent attempt before asking for help on the next tricky question.`
: "Try guided support on tougher questions, then return to independent practice.",
initials: "IP",
actionLabel: "Use this tip",
href: `/assignment/${currentAssignment.assignment.id}/work`,
},
];
export { topicMasteryBars };
export const overallPassRate = Math.round(overallAccuracy);
export { solveModeUsage, usageSummary };

View File

@@ -0,0 +1,232 @@
// Path: Frontend/src/components/dashboard/messages/dashboard-messages.data.ts
import { apiFetchJson } from "../../../lib/api";
import type { ApiListResponse } from "../../../lib/api-types";
import type { AppRole } from "../../../lib/routes";
import { getDashboardMessageThreadHref } from "../../../lib/routes";
import type { TopbarMessageItem } from "../shared/dashboard-types";
type MessageRecipient = {
id: number;
email: string;
role: "student" | "teacher";
full_name: string;
preferred_name: string | null;
profile_icon_url: string | null;
headline: string | null;
};
export type MessageParticipant = {
id: number;
email: string;
role: "student" | "teacher";
full_name: string;
preferred_name: string | null;
profile_icon_url: string | null;
headline: string | null;
joined_at: string | null;
last_read_at: string | null;
archived_at: string | null;
};
type MessageSender = {
id: number;
email: string;
role: string;
full_name: string;
preferred_name: string | null;
profile_icon_url: string | null;
headline?: string | null;
};
type MessageItem = {
id: number;
thread_id: number;
body: string;
created_at: string | null;
updated_at: string | null;
mine: boolean;
sender: MessageSender;
};
type MessageThreadSummary = {
id: number;
subject: string;
created_by_user_id: number;
created_at: string | null;
updated_at: string | null;
unread_count: number;
last_message_id: number;
last_message_body: string | null;
last_message_created_at: string | null;
last_message_sender?: MessageSender | null;
participants: MessageParticipant[];
};
type MessageThreadDetail = {
id: number;
subject: string;
created_by_user_id: number;
created_at: string | null;
updated_at: string | null;
unread_count: number;
last_read_at: string | null;
participants: MessageParticipant[];
messages: MessageItem[];
};
type DashboardMessagesInboxData = {
stats: Array<{ label: string; value: string }>;
threads: MessageThreadSummary[];
recipients: MessageRecipient[];
};
type DashboardTopbarMessagesData = {
messageCount: number;
unreadThreadCount: number;
topbarMessages: TopbarMessageItem[];
};
export const getDashboardMessagesInboxData = async (_currentUserId: number): Promise<DashboardMessagesInboxData> => {
const [threadsResponse, recipientsResponse] = await Promise.all([
apiFetchJson<ApiListResponse<MessageThreadSummary>>("/api/messages/threads", { parseErrorMessage: true }),
apiFetchJson<ApiListResponse<MessageRecipient>>("/api/messages/recipients", { parseErrorMessage: true }),
]);
const unreadThreads = threadsResponse.data.filter((thread) => thread.unread_count > 0).length;
const unreadMessages = threadsResponse.data.reduce((sum, thread) => sum + thread.unread_count, 0);
return {
stats: [
{ label: "Open threads", value: String(threadsResponse.data.length) },
{ label: "Unread threads", value: String(unreadThreads) },
{ label: "Unread messages", value: String(unreadMessages) },
{ label: "Available contacts", value: String(recipientsResponse.data.length) },
],
threads: threadsResponse.data,
recipients: recipientsResponse.data,
};
};
export const getDashboardTopbarMessagesData = async (currentUserId: number, role: AppRole): Promise<DashboardTopbarMessagesData> => {
const threadsResponse = await apiFetchJson<ApiListResponse<MessageThreadSummary>>("/api/messages/threads", { parseErrorMessage: true });
const unreadThreadCount = threadsResponse.data.filter((thread) => thread.unread_count > 0).length;
const unreadMessageCount = threadsResponse.data.reduce((sum, thread) => sum + thread.unread_count, 0);
return {
messageCount: unreadThreadCount,
unreadThreadCount,
topbarMessages: threadsResponse.data.slice(0, 5).map((thread) => {
const peer = thread.participants.find((participant) => participant.id !== currentUserId) ?? thread.participants[0];
const sender = peer ? displayName(peer) : thread.subject;
const preview = thread.last_message_body?.trim() || `Subject: ${thread.subject}`;
return {
sender,
initials: initialsFor(peer ?? { full_name: sender }),
preview,
timestamp: formatShortTimestamp(thread.last_message_created_at ?? thread.updated_at),
actionHref: getDashboardMessageThreadHref(role, thread.id),
};
}),
};
};
export const getMessageThreadDetail = (threadId: number) => apiFetchJson<MessageThreadDetail>(`/api/messages/threads/${threadId}`, { parseErrorMessage: true });
export const createMessageThread = (input: { subject: string; recipientId: number }) =>
apiFetchJson<{ thread_id: number }>("/api/messages/threads", {
method: "POST",
parseErrorMessage: true,
body: JSON.stringify({
subject: input.subject,
recipient_ids: [input.recipientId],
}),
});
export const createThreadReply = (threadId: number, input: { body: string }) =>
apiFetchJson<{ status: string }>(`/api/messages/threads/${threadId}/messages`, {
method: "POST",
parseErrorMessage: true,
body: JSON.stringify(input),
});
export const updateThreadSubject = (threadId: number, input: { subject: string }) =>
apiFetchJson<{ status: string }>(`/api/messages/threads/${threadId}`, {
method: "PATCH",
parseErrorMessage: true,
body: JSON.stringify(input),
});
export const deleteThread = (threadId: number) =>
apiFetchJson<{ status: string }>(`/api/messages/threads/${threadId}`, {
method: "DELETE",
parseErrorMessage: true,
});
export const updateThreadMessage = (threadId: number, messageId: number, input: { body: string }) =>
apiFetchJson<{ status: string }>(`/api/messages/threads/${threadId}/messages/${messageId}`, {
method: "PATCH",
parseErrorMessage: true,
body: JSON.stringify(input),
});
export const deleteThreadMessage = (threadId: number, messageId: number) =>
apiFetchJson<{ status: string }>(`/api/messages/threads/${threadId}/messages/${messageId}`, {
method: "DELETE",
parseErrorMessage: true,
});
export const markThreadRead = (threadId: number) =>
apiFetchJson<{ status: string }>(`/api/messages/threads/${threadId}/read`, {
method: "PATCH",
parseErrorMessage: true,
});
export const displayName = (person: { preferred_name?: string | null; full_name: string }) => person.preferred_name?.trim() || person.full_name;
export const initialsFor = (person: { preferred_name?: string | null; full_name: string }) => {
const value = displayName(person).trim();
if (!value) return "U";
const parts = value.split(/\s+/).filter(Boolean);
if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase();
return `${parts[0]?.[0] ?? ""}${parts[1]?.[0] ?? ""}`.toUpperCase();
};
export const roleLabel = (role: string) => (role === "teacher" ? "Teacher" : role === "student" ? "Student" : role);
export const formatShortTimestamp = (value: string | null) => {
if (!value) return "Just now";
const date = new Date(value);
const now = Date.now();
const diffMs = now - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
if (diffHours < 1) return "Now";
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return new Intl.DateTimeFormat("en-GB", {
month: "short",
day: "numeric",
}).format(date);
};
export const formatMessageTimestamp = (value: string | null) => {
if (!value) return "Just now";
return new Intl.DateTimeFormat("en-GB", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(new Date(value));
};
export const wasMessageEdited = (createdAt: string | null, updatedAt: string | null) => {
if (!createdAt || !updatedAt) return false;
return new Date(updatedAt).getTime() - new Date(createdAt).getTime() > 1000;
};

View File

@@ -0,0 +1,551 @@
/* Path: Frontend/src/components/dashboard/messages/dashboard-messages.module.scss */
.section {
display: grid;
gap: 1rem;
@include respond(desktop-sm) {
gap: 1.25rem;
}
}
.panel {
border-radius: var(--radius-3xl);
border: 1px solid var(--border-soft);
background: linear-gradient(135deg, var(--surface-panel), var(--surface-panel-strong));
box-shadow: var(--shadow-soft);
}
.panelEyebrow {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.75rem;
color: var(--text-muted);
}
.workspaceGrid {
display: grid;
gap: 1rem;
> * {
min-width: 0;
}
@include respond(desktop-lg) {
grid-template-columns: minmax(21rem, 26rem) minmax(0, 1fr);
align-items: start;
}
}
.workspaceGridMailboxCollapsed {
@include respond(desktop-lg) {
grid-template-columns: minmax(0, 1fr);
}
}
.leftRail {
display: grid;
gap: 1rem;
min-width: 0;
}
.leftRailCollapsed {
display: none;
}
.panel {
display: grid;
gap: 1rem;
padding: 1rem;
min-width: 0;
}
.railHeader,
.threadPanelHeader {
display: flex;
justify-content: space-between;
align-items: start;
gap: 0.85rem;
flex-wrap: wrap;
h2 {
font-size: 1.15rem;
line-height: 1.1;
overflow-wrap: anywhere;
}
p:last-child {
margin-top: 0.25rem;
font-size: 0.9rem;
color: var(--text-muted);
}
}
.railToggle {
display: inline-flex;
padding: 0.25rem;
border-radius: var(--radius-full);
background: var(--surface-soft);
border: 1px solid var(--border-soft);
gap: 0.25rem;
}
.toggleButton {
padding: 0.55rem 0.9rem;
border-radius: var(--radius-full);
border: none;
background: transparent;
color: var(--text-muted);
font: inherit;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
transition:
background-color 0.2s ease,
color 0.2s ease,
transform 0.2s ease;
&:hover {
transform: translateY(-1px);
}
}
.toggleButtonActive {
background: var(--surface-panel);
color: var(--text);
box-shadow: var(--shadow-soft);
}
.form,
.replyForm {
display: grid;
gap: 0.85rem;
}
.fieldLabel {
display: grid;
gap: 0.45rem;
span {
font-size: 0.84rem;
font-weight: 600;
color: var(--text-muted);
}
}
.textInput,
.textArea,
.selectInput,
.searchField input {
width: 100%;
padding: 0.85rem 0.95rem;
border-radius: var(--radius-md);
border: 1px solid var(--border-soft);
background: var(--surface-soft);
color: var(--text);
font: inherit;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
box-shadow 0.2s ease;
&:focus {
outline: none;
border-color: var(--border-strong);
background: var(--surface-panel-strong);
box-shadow: var(--focus-ring-primary-soft-shadow);
}
}
.textArea {
resize: vertical;
min-height: 6rem;
}
.searchField {
display: flex;
align-items: center;
gap: 0.7rem;
input {
padding-left: 0;
}
}
.searchIcon {
display: grid;
place-items: center;
color: var(--text-muted);
svg {
width: 1rem;
height: 1rem;
stroke: currentColor;
fill: none;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
}
.inboxTools {
display: grid;
gap: 0.65rem;
}
.resultsNote,
.composeNote,
.emptyState,
.emptyThreadState,
.preview,
.messageBody {
font-size: 0.93rem;
color: var(--text-muted);
overflow-wrap: anywhere;
word-break: break-word;
}
.composeNote {
line-height: 1.45;
}
.feedbackMessage {
padding: 0.8rem 0.95rem;
border-radius: var(--radius-md);
font-size: 0.88rem;
border: 1px solid var(--border-soft);
}
.feedbackError {
background: var(--surface-warning-tint);
color: var(--text);
}
.feedbackSuccess {
background: var(--surface-success-tint);
color: var(--text);
}
.primaryAction {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.8rem 1rem;
border-radius: var(--radius-full);
border: none;
font: inherit;
font-weight: 600;
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
color: var(--action-primary-text);
box-shadow: var(--action-primary-shadow);
cursor: pointer;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
opacity 0.2s ease;
&:hover {
transform: translateY(-1px);
box-shadow: var(--action-primary-shadow-hover);
}
&:disabled {
opacity: 0.7;
cursor: wait;
transform: none;
}
}
.secondaryAction {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.72rem 0.95rem;
border-radius: var(--radius-full);
border: 1px solid var(--border-soft);
background: var(--surface-soft);
color: var(--text);
font: inherit;
font-weight: 600;
cursor: pointer;
transition:
transform 0.2s ease,
border-color 0.2s ease,
background-color 0.2s ease;
&:hover {
transform: translateY(-1px);
border-color: var(--border-strong);
background: var(--surface-panel);
}
&:disabled {
opacity: 0.7;
cursor: wait;
transform: none;
}
}
.dangerAction {
composes: secondaryAction;
border-color: var(--border-danger-soft);
color: var(--danger);
&:hover {
border-color: var(--border-danger-strong);
background: var(--surface-danger-tint);
}
}
.threadList,
.messageStream {
display: grid;
gap: 0.75rem;
}
.threadButton {
display: grid;
gap: 0.65rem;
padding: 0.95rem;
border-radius: var(--radius-lg);
border: 1px solid var(--border-soft);
background: var(--surface-soft);
text-align: left;
color: inherit;
cursor: pointer;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
transform 0.2s ease;
&:hover {
transform: translateY(-1px);
border-color: var(--border-strong);
}
}
.threadButtonActive {
border-color: color-mix(in srgb, var(--primary) 45%, var(--border-strong));
background: color-mix(in srgb, var(--surface-accent-soft) 55%, var(--surface-panel));
}
.threadButtonTop,
.messageMeta {
display: flex;
justify-content: space-between;
align-items: start;
gap: 0.75rem;
}
.messageMetaAside {
display: grid;
justify-items: end;
gap: 0.3rem;
text-align: right;
color: var(--text-muted);
}
.senderWrap {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
strong,
h2 {
line-height: 1.1;
}
p {
margin-top: 0.18rem;
font-size: 0.84rem;
color: var(--text-muted);
}
}
.threadCopy {
min-width: 0;
strong,
p {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.avatar {
width: 2.5rem;
height: 2.5rem;
display: grid;
place-items: center;
border-radius: var(--radius-full);
background: var(--surface-accent-soft);
color: var(--primary);
font-weight: 700;
flex-shrink: 0;
overflow: hidden;
}
.threadMeta {
display: grid;
justify-items: end;
gap: 0.3rem;
color: var(--text-muted);
flex-shrink: 0;
}
.unreadBadge,
.statusNote {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.3rem 0.62rem;
border-radius: var(--radius-full);
font-size: 0.74rem;
font-weight: 700;
background: var(--surface-info-emphasis);
color: var(--text-info-strong);
}
.statusNote {
background: var(--surface-soft);
color: var(--text-muted);
font-weight: 600;
}
.preview {
line-height: 1.45;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.threadPanel {
min-height: 100%;
min-width: 0;
}
.threadHeaderActions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.65rem;
flex-wrap: wrap;
min-width: 0;
}
.inlineEditForm {
display: grid;
gap: 0.75rem;
width: min(100%, 32rem);
}
.inlineEditActions,
.messageActions {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.messageActions {
justify-content: flex-end;
opacity: 0.72;
transition: opacity 0.2s ease;
}
.messageBubble:hover .messageActions,
.messageBubble:focus-within .messageActions {
opacity: 1;
}
@media (hover: none) {
.messageActions {
opacity: 1;
}
}
.messageActionButton {
padding: 0;
border: none;
background: transparent;
color: var(--text-muted);
font: inherit;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.01em;
cursor: pointer;
transition:
color 0.2s ease,
opacity 0.2s ease;
&:hover {
color: var(--text);
}
&:disabled {
opacity: 0.7;
cursor: wait;
}
}
.messageActionDanger {
color: var(--text-danger-muted);
&:hover {
color: var(--danger);
}
}
.emptyThreadState {
padding: 1rem;
border-radius: var(--radius-md);
border: 1px dashed var(--border-soft);
background: color-mix(in srgb, var(--surface-soft) 75%, transparent);
line-height: 1.5;
}
.messageStream {
padding: 0.2rem;
}
.messageRow {
display: flex;
justify-content: flex-start;
}
.messageRowMine {
justify-content: flex-end;
}
.messageBubble {
display: grid;
gap: 0.75rem;
width: min(100%, 44rem);
padding: 0.95rem;
border-radius: var(--radius-lg);
border: 1px solid var(--border-soft);
background: var(--surface-soft);
}
.messageBubbleMine {
background: color-mix(in srgb, var(--surface-accent-soft) 60%, var(--surface-panel));
border-color: color-mix(in srgb, var(--primary) 35%, var(--border-strong));
}
.editedNote {
font-size: 0.74rem;
font-weight: 600;
color: var(--text-muted);
}
.replyActions {
display: flex;
justify-content: flex-end;
}
.emptyState {
padding: 0.75rem 0;
line-height: 1.5;
}

View File

@@ -0,0 +1,353 @@
// Path: Frontend/src/components/dashboard/messages/dashboard-messages.page.tsx
import type { Component } from "solid-js";
import { createEffect, createMemo, createResource, createSignal } from "solid-js";
import { createMessageThread, createThreadReply, deleteThread, deleteThreadMessage, getDashboardMessagesInboxData, getMessageThreadDetail, markThreadRead, updateThreadMessage, updateThreadSubject } from "./dashboard-messages.data";
import styles from "./dashboard-messages.module.scss";
import { DashboardMessagesMailbox, DashboardMessagesThreadPanel } from "./dashboard-messages.sections";
type DashboardMessagesPageProps = {
currentUserId: number;
initialThreadId?: number | null;
};
const DashboardMessagesPage: Component<DashboardMessagesPageProps> = (props) => {
const [inboxData, { refetch: refetchInbox }] = createResource(() => props.currentUserId, getDashboardMessagesInboxData);
const [selectedThreadId, setSelectedThreadId] = createSignal<number | null>(props.initialThreadId ?? null);
const [lastSyncedInitialThreadId, setLastSyncedInitialThreadId] = createSignal<number | null>(null);
const [threadDetail, { refetch: refetchThreadDetail }] = createResource(selectedThreadId, getMessageThreadDetail);
const [railMode, setRailMode] = createSignal<"inbox" | "compose">("inbox");
const [mailboxCollapsed, setMailboxCollapsed] = createSignal(false);
const [searchQuery, setSearchQuery] = createSignal("");
const [recipientId, setRecipientId] = createSignal("");
const [subject, setSubject] = createSignal("");
const [threadSubjectDraft, setThreadSubjectDraft] = createSignal("");
const [replyBody, setReplyBody] = createSignal("");
const [editingThreadTitle, setEditingThreadTitle] = createSignal(false);
const [editingMessageId, setEditingMessageId] = createSignal<number | null>(null);
const [editingMessageBody, setEditingMessageBody] = createSignal("");
const [composeError, setComposeError] = createSignal<string | null>(null);
const [composeSuccess, setComposeSuccess] = createSignal<string | null>(null);
const [replyError, setReplyError] = createSignal<string | null>(null);
const [replySuccess, setReplySuccess] = createSignal<string | null>(null);
const [threadActionError, setThreadActionError] = createSignal<string | null>(null);
const [threadActionSuccess, setThreadActionSuccess] = createSignal<string | null>(null);
const [messageActionError, setMessageActionError] = createSignal<string | null>(null);
const [messageActionSuccess, setMessageActionSuccess] = createSignal<string | null>(null);
const [creatingThread, setCreatingThread] = createSignal(false);
const [sendingReply, setSendingReply] = createSignal(false);
const [savingThreadTitle, setSavingThreadTitle] = createSignal(false);
const [deletingThreadId, setDeletingThreadId] = createSignal<number | null>(null);
const [savingMessageId, setSavingMessageId] = createSignal<number | null>(null);
const [deletingMessageId, setDeletingMessageId] = createSignal<number | null>(null);
const [markingThreadId, setMarkingThreadId] = createSignal<number | null>(null);
const threads = createMemo(() => inboxData()?.threads ?? []);
const recipients = createMemo(() => inboxData()?.recipients ?? []);
const normalizedSearch = createMemo(() => searchQuery().trim().toLowerCase());
const filteredThreads = createMemo(() => {
const query = normalizedSearch();
if (!query) return threads();
return threads().filter((thread) => {
const haystack = [thread.subject, thread.participants.map((participant) => participant.full_name).join(" "), thread.last_message_body ?? ""].join(" ").toLowerCase();
return haystack.includes(query);
});
});
const selectedThread = createMemo(() => threadDetail() ?? null);
const threadCanManage = createMemo(() => selectedThread()?.created_by_user_id === props.currentUserId);
createEffect(() => {
const availableThreads = threads();
const requestedThreadId = props.initialThreadId ?? null;
const selected = selectedThreadId();
const lastSynced = lastSyncedInitialThreadId();
if (!availableThreads.length) {
if (selected !== null) setSelectedThreadId(null);
return;
}
if (requestedThreadId !== null && requestedThreadId !== lastSynced && availableThreads.some((thread) => thread.id === requestedThreadId)) {
setSelectedThreadId(requestedThreadId);
setLastSyncedInitialThreadId(requestedThreadId);
return;
}
if (requestedThreadId === null && lastSynced !== null) {
setLastSyncedInitialThreadId(null);
}
if (selected === null || !availableThreads.some((thread) => thread.id === selected)) {
setSelectedThreadId(availableThreads[0]!.id);
}
});
createEffect(() => {
const detail = selectedThread();
if (!detail || detail.unread_count <= 0) return;
if (markingThreadId() === detail.id) return;
setMarkingThreadId(detail.id);
void markThreadRead(detail.id)
.then(async () => {
await Promise.all([refetchInbox(), refetchThreadDetail()]);
})
.catch(() => {
// silent; the detail pane still renders
})
.finally(() => {
setMarkingThreadId((current) => (current === detail.id ? null : current));
});
});
createEffect(() => {
const detail = selectedThread();
setThreadSubjectDraft(detail?.subject ?? "");
setEditingThreadTitle(false);
setEditingMessageId(null);
setEditingMessageBody("");
setThreadActionError(null);
setThreadActionSuccess(null);
setMessageActionError(null);
setMessageActionSuccess(null);
});
const submitNewThread = async (event: SubmitEvent) => {
event.preventDefault();
setComposeError(null);
setComposeSuccess(null);
const nextRecipientId = Number(recipientId());
if (!nextRecipientId || !subject().trim()) {
setComposeError("Pick a contact and add a subject to start the thread.");
return;
}
setCreatingThread(true);
try {
const created = await createMessageThread({ recipientId: nextRecipientId, subject: subject().trim() });
setRecipientId("");
setSubject("");
setComposeSuccess("Thread started. Open it to send the first message.");
setRailMode("inbox");
await refetchInbox();
setSelectedThreadId(created.thread_id);
await refetchThreadDetail();
} catch (error) {
setComposeError((error as Error).message || "We could not start that conversation right now.");
} finally {
setCreatingThread(false);
}
};
const submitReply = async (event: SubmitEvent) => {
event.preventDefault();
setReplyError(null);
setReplySuccess(null);
const threadId = selectedThreadId();
if (!threadId || !replyBody().trim()) {
setReplyError("Write a message before sending your reply.");
return;
}
setSendingReply(true);
try {
await createThreadReply(threadId, { body: replyBody().trim() });
setReplyBody("");
setReplySuccess("Reply sent.");
await Promise.all([refetchInbox(), refetchThreadDetail()]);
} catch (error) {
setReplyError((error as Error).message || "We could not send your reply right now.");
} finally {
setSendingReply(false);
}
};
const submitThreadTitle = async (event: SubmitEvent) => {
event.preventDefault();
const thread = selectedThread();
if (!thread) return;
const nextSubject = threadSubjectDraft().trim();
setThreadActionError(null);
setThreadActionSuccess(null);
if (!nextSubject) {
setThreadActionError("Add a title before saving the conversation subject.");
return;
}
setSavingThreadTitle(true);
try {
await updateThreadSubject(thread.id, { subject: nextSubject });
setEditingThreadTitle(false);
setThreadActionSuccess("Conversation title updated.");
await Promise.all([refetchInbox(), refetchThreadDetail()]);
} catch (error) {
setThreadActionError((error as Error).message || "We could not update the conversation title right now.");
} finally {
setSavingThreadTitle(false);
}
};
const removeThread = async () => {
const thread = selectedThread();
if (!thread) return;
const confirmed = globalThis.confirm?.(`Delete the conversation “${thread.subject}” for everyone? This cannot be undone.`) ?? false;
if (!confirmed) return;
setThreadActionError(null);
setThreadActionSuccess(null);
setDeletingThreadId(thread.id);
try {
await deleteThread(thread.id);
const refreshed = await refetchInbox();
const nextThreads = refreshed?.threads ?? [];
setSelectedThreadId(nextThreads[0]?.id ?? null);
} catch (error) {
setThreadActionError((error as Error).message || "We could not delete this conversation right now.");
} finally {
setDeletingThreadId(null);
}
};
const beginMessageEdit = (messageId: number, body: string) => {
setEditingMessageId(messageId);
setEditingMessageBody(body);
setMessageActionError(null);
setMessageActionSuccess(null);
};
const cancelMessageEdit = () => {
setEditingMessageId(null);
setEditingMessageBody("");
};
const submitMessageEdit = async (event: SubmitEvent, messageId: number) => {
event.preventDefault();
const threadId = selectedThreadId();
if (!threadId) return;
const nextBody = editingMessageBody().trim();
setMessageActionError(null);
setMessageActionSuccess(null);
if (!nextBody) {
setMessageActionError("Write something before saving your message.");
return;
}
setSavingMessageId(messageId);
try {
await updateThreadMessage(threadId, messageId, { body: nextBody });
setEditingMessageId(null);
setEditingMessageBody("");
setMessageActionSuccess("Message updated.");
await Promise.all([refetchInbox(), refetchThreadDetail()]);
} catch (error) {
setMessageActionError((error as Error).message || "We could not update that message right now.");
} finally {
setSavingMessageId(null);
}
};
const removeMessage = async (messageId: number) => {
const threadId = selectedThreadId();
if (!threadId) return;
const confirmed = globalThis.confirm?.("Delete this message? This cannot be undone.") ?? false;
if (!confirmed) return;
setMessageActionError(null);
setMessageActionSuccess(null);
setDeletingMessageId(messageId);
try {
await deleteThreadMessage(threadId, messageId);
if (editingMessageId() === messageId) {
setEditingMessageId(null);
setEditingMessageBody("");
}
setMessageActionSuccess("Message deleted.");
await Promise.all([refetchInbox(), refetchThreadDetail()]);
} catch (error) {
setMessageActionError((error as Error).message || "We could not delete that message right now.");
} finally {
setDeletingMessageId(null);
}
};
return (
<section class={styles.section}>
<div classList={{ [styles.workspaceGrid]: true, [styles.workspaceGridMailboxCollapsed]: mailboxCollapsed() }}>
<DashboardMessagesMailbox
railMode={railMode()}
recipients={recipients()}
threads={threads()}
filteredThreads={filteredThreads()}
selectedThreadId={selectedThreadId()}
currentUserId={props.currentUserId}
searchQuery={searchQuery()}
recipientId={recipientId()}
subject={subject()}
composeError={composeError()}
composeSuccess={composeSuccess()}
creatingThread={creatingThread()}
inboxLoading={Boolean(inboxData.loading)}
inboxError={Boolean(inboxData.error)}
onSetRailMode={setRailMode}
onRecipientInput={setRecipientId}
onSubjectInput={setSubject}
onSearchInput={setSearchQuery}
onSelectThread={setSelectedThreadId}
onSubmitNewThread={submitNewThread}
/>
<DashboardMessagesThreadPanel
selectedThreadId={selectedThreadId()}
threadLoading={Boolean(threadDetail.loading)}
threadError={Boolean(threadDetail.error)}
thread={selectedThread()}
currentUserId={props.currentUserId}
editingThreadTitle={editingThreadTitle()}
threadSubjectDraft={threadSubjectDraft()}
threadActionError={threadActionError()}
threadActionSuccess={threadActionSuccess()}
messageActionError={messageActionError() || replyError()}
messageActionSuccess={messageActionSuccess() || replySuccess()}
replyBody={replyBody()}
threadCanManage={Boolean(threadCanManage())}
mailboxCollapsed={mailboxCollapsed()}
markingThreadId={markingThreadId()}
deletingThreadId={deletingThreadId()}
savingThreadTitle={savingThreadTitle()}
editingMessageId={editingMessageId()}
editingMessageBody={editingMessageBody()}
savingMessageId={savingMessageId()}
deletingMessageId={deletingMessageId()}
sendingReply={sendingReply()}
onThreadSubjectDraftInput={setThreadSubjectDraft}
onReplyBodyInput={setReplyBody}
onEditingMessageBodyInput={setEditingMessageBody}
onToggleMailbox={() => setMailboxCollapsed((value) => !value)}
onBeginEditThreadTitle={() => setEditingThreadTitle(true)}
onCancelEditThreadTitle={() => {
setEditingThreadTitle(false);
setThreadSubjectDraft(selectedThread()?.subject ?? "");
}}
onSubmitThreadTitle={submitThreadTitle}
onRemoveThread={removeThread}
onBeginMessageEdit={beginMessageEdit}
onCancelMessageEdit={cancelMessageEdit}
onSubmitMessageEdit={submitMessageEdit}
onRemoveMessage={removeMessage}
onSubmitReply={submitReply}
/>
</div>
</section>
);
};
export default DashboardMessagesPage;

View File

@@ -0,0 +1,315 @@
// Path: Frontend/src/components/dashboard/messages/dashboard-messages.sections.tsx
import type { Component } from "solid-js";
import { For, Show } from "solid-js";
import { displayName, formatMessageTimestamp, formatShortTimestamp, getDashboardMessagesInboxData, getMessageThreadDetail, initialsFor, roleLabel, wasMessageEdited, type MessageParticipant } from "./dashboard-messages.data";
import styles from "./dashboard-messages.module.scss";
type InboxData = Awaited<ReturnType<typeof getDashboardMessagesInboxData>>;
type InboxThread = InboxData["threads"][number];
type InboxRecipient = InboxData["recipients"][number];
type ThreadDetail = Awaited<ReturnType<typeof getMessageThreadDetail>>;
type ThreadMessage = ThreadDetail["messages"][number];
export const otherParticipants = (participants: MessageParticipant[], currentUserId: number) => participants.filter((participant) => participant.id !== currentUserId);
export const participantSummary = (participants: MessageParticipant[], currentUserId: number) => {
const others = otherParticipants(participants, currentUserId);
if (!others.length) return "Just you";
return others.map((participant) => `${displayName(participant)} · ${roleLabel(participant.role)}`).join(" • ");
};
type DashboardMessagesMailboxProps = {
railMode: "inbox" | "compose";
recipients: InboxRecipient[];
threads: InboxThread[];
filteredThreads: InboxThread[];
selectedThreadId: number | null;
currentUserId: number;
searchQuery: string;
recipientId: string;
subject: string;
composeError: string | null;
composeSuccess: string | null;
creatingThread: boolean;
inboxLoading: boolean;
inboxError: boolean;
onSetRailMode: (mode: "inbox" | "compose") => void;
onRecipientInput: (value: string) => void;
onSubjectInput: (value: string) => void;
onSearchInput: (value: string) => void;
onSelectThread: (threadId: number) => void;
onSubmitNewThread: (event: SubmitEvent) => Promise<void> | void;
};
export const DashboardMessagesMailbox: Component<DashboardMessagesMailboxProps> = (props) => (
<div class={styles.leftRail}>
<article class={styles.panel}>
<div class={styles.railHeader}>
<div>
<p class={styles.panelEyebrow}>Mailbox</p>
<h2>{props.railMode === "inbox" ? "Inbox" : "Start a thread"}</h2>
</div>
<div class={styles.railToggle}>
<button type="button" classList={{ [styles.toggleButton]: true, [styles.toggleButtonActive]: props.railMode === "inbox" }} onClick={() => props.onSetRailMode("inbox")}>
Inbox
</button>
<button type="button" classList={{ [styles.toggleButton]: true, [styles.toggleButtonActive]: props.railMode === "compose" }} onClick={() => props.onSetRailMode("compose")}>
New thread
</button>
</div>
</div>
<Show
when={props.railMode === "inbox"}
fallback={
<form class={styles.form} onSubmit={(event) => void props.onSubmitNewThread(event)}>
<label class={styles.fieldLabel}>
<span>Contact</span>
<select class={styles.selectInput} value={props.recipientId} onInput={(event) => props.onRecipientInput(event.currentTarget.value)}>
<option value="">Choose a contact</option>
<For each={props.recipients}>
{(recipient) => (
<option value={String(recipient.id)}>
{displayName(recipient)} · {roleLabel(recipient.role)}
</option>
)}
</For>
</select>
</label>
<label class={styles.fieldLabel}>
<span>Subject</span>
<input class={styles.textInput} type="text" value={props.subject} placeholder="Homework check-in" onInput={(event) => props.onSubjectInput(event.currentTarget.value)} />
</label>
<Show when={props.composeError}>{(message) => <p class={`${styles.feedbackMessage} ${styles.feedbackError}`}>{message()}</p>}</Show>
<Show when={props.composeSuccess}>{(message) => <p class={`${styles.feedbackMessage} ${styles.feedbackSuccess}`}>{message()}</p>}</Show>
<p class={styles.composeNote}>The thread opens right away after creation, so you can send the first message from the conversation pane.</p>
<button class={styles.primaryAction} type="submit" disabled={props.creatingThread || props.recipients.length === 0}>
{props.creatingThread ? "Starting thread..." : "Start thread"}
</button>
</form>
}
>
<div class={styles.inboxTools}>
<label class={styles.searchField}>
<span class={styles.searchIcon} aria-hidden="true">
<svg viewBox="0 0 24 24">
<circle cx="11" cy="11" r="6" />
<path d="M20 20l-4.2-4.2" />
</svg>
</span>
<input type="search" value={props.searchQuery} placeholder="Search subjects, contacts, or previews" onInput={(event) => props.onSearchInput(event.currentTarget.value)} />
</label>
<p class={styles.resultsNote}>
{props.filteredThreads.length} of {props.threads.length} thread{props.threads.length === 1 ? "" : "s"}
</p>
</div>
<Show when={!props.inboxLoading} fallback={<div class={styles.emptyState}>Loading your inbox</div>}>
<Show when={!props.inboxError} fallback={<div class={styles.emptyState}>We could not load your threads right now.</div>}>
<Show when={props.threads.length} fallback={<div class={styles.emptyState}>No conversations yet. Use New thread to start one.</div>}>
<Show when={props.filteredThreads.length} fallback={<div class={styles.emptyState}>No threads match that search yet.</div>}>
<div class={styles.threadList}>
<For each={props.filteredThreads}>
{(thread) => {
const peers = () => otherParticipants(thread.participants, props.currentUserId);
return (
<button type="button" classList={{ [styles.threadButton]: true, [styles.threadButtonActive]: props.selectedThreadId === thread.id }} onClick={() => props.onSelectThread(thread.id)}>
<div class={styles.threadButtonTop}>
<div class={styles.senderWrap}>
<span class={styles.avatar}>{initialsFor(peers()[0] ?? { full_name: thread.subject })}</span>
<div class={styles.threadCopy}>
<strong>{thread.subject}</strong>
<p>{participantSummary(thread.participants, props.currentUserId)}</p>
</div>
</div>
<div class={styles.threadMeta}>
<small>{formatShortTimestamp(thread.last_message_created_at ?? thread.updated_at)}</small>
<Show when={thread.unread_count > 0}>
<span class={styles.unreadBadge}>{thread.unread_count}</span>
</Show>
</div>
</div>
<p class={styles.preview}>{thread.last_message_body?.trim() || "No messages yet. Open this thread to send the first one."}</p>
</button>
);
}}
</For>
</div>
</Show>
</Show>
</Show>
</Show>
</Show>
</article>
</div>
);
type DashboardMessagesThreadPanelProps = {
selectedThreadId: number | null;
threadLoading: boolean;
threadError: boolean;
thread: ThreadDetail | null;
currentUserId: number;
editingThreadTitle: boolean;
threadSubjectDraft: string;
threadActionError: string | null;
threadActionSuccess: string | null;
messageActionError: string | null;
messageActionSuccess: string | null;
replyBody: string;
threadCanManage: boolean;
mailboxCollapsed: boolean;
markingThreadId: number | null;
deletingThreadId: number | null;
savingThreadTitle: boolean;
editingMessageId: number | null;
editingMessageBody: string;
savingMessageId: number | null;
deletingMessageId: number | null;
sendingReply: boolean;
onThreadSubjectDraftInput: (value: string) => void;
onReplyBodyInput: (value: string) => void;
onEditingMessageBodyInput: (value: string) => void;
onToggleMailbox: () => void;
onBeginEditThreadTitle: () => void;
onCancelEditThreadTitle: () => void;
onSubmitThreadTitle: (event: SubmitEvent) => Promise<void> | void;
onRemoveThread: () => Promise<void> | void;
onBeginMessageEdit: (messageId: number, body: string) => void;
onCancelMessageEdit: () => void;
onSubmitMessageEdit: (event: SubmitEvent, messageId: number) => Promise<void> | void;
onRemoveMessage: (messageId: number) => Promise<void> | void;
onSubmitReply: (event: SubmitEvent) => Promise<void> | void;
};
export const DashboardMessagesThreadPanel: Component<DashboardMessagesThreadPanelProps> = (props) => (
<article class={`${styles.panel} ${styles.threadPanel}`}>
<Show when={props.selectedThreadId} fallback={<div class={styles.emptyState}>Pick a thread or start a new conversation to open the message panel.</div>}>
<Show when={!props.threadLoading} fallback={<div class={styles.emptyState}>Loading this conversation</div>}>
<Show when={!props.threadError && props.thread} keyed fallback={<div class={styles.emptyState}>We could not load this conversation right now.</div>}>
{(thread) => (
<>
<div class={styles.threadPanelHeader}>
<div>
<p class={styles.panelEyebrow}>Conversation</p>
<Show when={props.editingThreadTitle} fallback={<h2>{thread.subject}</h2>}>
<form class={styles.inlineEditForm} onSubmit={(event) => void props.onSubmitThreadTitle(event)}>
<input class={styles.textInput} type="text" value={props.threadSubjectDraft} onInput={(event) => props.onThreadSubjectDraftInput(event.currentTarget.value)} />
<div class={styles.inlineEditActions}>
<button class={styles.primaryAction} type="submit" disabled={props.savingThreadTitle}>
{props.savingThreadTitle ? "Saving..." : "Save title"}
</button>
<button class={styles.secondaryAction} type="button" onClick={props.onCancelEditThreadTitle}>
Cancel
</button>
</div>
</form>
</Show>
<p>{participantSummary(thread.participants, props.currentUserId)}</p>
<Show when={props.threadActionError}>{(message) => <p class={`${styles.feedbackMessage} ${styles.feedbackError}`}>{message()}</p>}</Show>
<Show when={props.threadActionSuccess}>{(message) => <p class={`${styles.feedbackMessage} ${styles.feedbackSuccess}`}>{message()}</p>}</Show>
</div>
<div class={styles.threadHeaderActions}>
<Show when={props.threadCanManage && !props.editingThreadTitle}>
<button type="button" class={styles.secondaryAction} onClick={props.onBeginEditThreadTitle}>
Edit title
</button>
<button type="button" class={styles.dangerAction} onClick={() => void props.onRemoveThread()} disabled={props.deletingThreadId === thread.id}>
{props.deletingThreadId === thread.id ? "Deleting..." : "Delete conversation"}
</button>
</Show>
<button type="button" class={styles.secondaryAction} onClick={props.onToggleMailbox}>
{props.mailboxCollapsed ? "Show mailbox" : "Collapse mailbox"}
</button>
<Show when={props.markingThreadId === thread.id}>
<span class={styles.statusNote}>Marking as read</span>
</Show>
</div>
</div>
<Show when={thread.messages.length} fallback={<div class={styles.emptyThreadState}>No messages yet. Send the first reply below to get this thread moving.</div>}>
<div class={styles.messageStream}>
<For each={thread.messages}>
{(message: ThreadMessage) => (
<div classList={{ [styles.messageRow]: true, [styles.messageRowMine]: message.mine }}>
<div classList={{ [styles.messageBubble]: true, [styles.messageBubbleMine]: message.mine }}>
<div class={styles.messageMeta}>
<div class={styles.senderWrap}>
<span class={styles.avatar}>{initialsFor(message.sender)}</span>
<div>
<strong>{displayName(message.sender)}</strong>
<p>{roleLabel(message.sender.role)}</p>
</div>
</div>
<div class={styles.messageMetaAside}>
<small>{formatMessageTimestamp(message.created_at)}</small>
<Show when={wasMessageEdited(message.created_at, message.updated_at)}>
<span class={styles.editedNote}>Edited</span>
</Show>
</div>
</div>
<Show when={props.editingMessageId === message.id} fallback={<p class={styles.messageBody}>{message.body}</p>}>
<form class={styles.inlineEditForm} onSubmit={(event) => void props.onSubmitMessageEdit(event, message.id)}>
<textarea class={styles.textArea} rows={4} value={props.editingMessageBody} onInput={(event) => props.onEditingMessageBodyInput(event.currentTarget.value)} />
<div class={styles.inlineEditActions}>
<button class={styles.primaryAction} type="submit" disabled={props.savingMessageId === message.id}>
{props.savingMessageId === message.id ? "Saving..." : "Save message"}
</button>
<button class={styles.secondaryAction} type="button" onClick={props.onCancelMessageEdit}>
Cancel
</button>
</div>
</form>
</Show>
<Show when={message.mine && props.editingMessageId !== message.id}>
<div class={styles.messageActions}>
<button type="button" class={styles.messageActionButton} onClick={() => props.onBeginMessageEdit(message.id, message.body)}>
Edit
</button>
<button
type="button"
class={`${styles.messageActionButton} ${styles.messageActionDanger}`}
onClick={() => void props.onRemoveMessage(message.id)}
disabled={props.deletingMessageId === message.id}
>
{props.deletingMessageId === message.id ? "Deleting..." : "Delete"}
</button>
</div>
</Show>
</div>
</div>
)}
</For>
</div>
</Show>
<Show when={props.messageActionError}>{(message) => <p class={`${styles.feedbackMessage} ${styles.feedbackError}`}>{message()}</p>}</Show>
<Show when={props.messageActionSuccess}>{(message) => <p class={`${styles.feedbackMessage} ${styles.feedbackSuccess}`}>{message()}</p>}</Show>
<form class={styles.replyForm} onSubmit={(event) => void props.onSubmitReply(event)}>
<label class={styles.fieldLabel}>
<span>Reply</span>
<textarea class={styles.textArea} rows={4} value={props.replyBody} placeholder="Write your reply..." onInput={(event) => props.onReplyBodyInput(event.currentTarget.value)} />
</label>
<Show when={props.messageActionError}>{(message) => <p class={`${styles.feedbackMessage} ${styles.feedbackError}`}>{message()}</p>}</Show>
<Show when={props.messageActionSuccess}>{(message) => <p class={`${styles.feedbackMessage} ${styles.feedbackSuccess}`}>{message()}</p>}</Show>
<div class={styles.replyActions}>
<button class={styles.primaryAction} type="submit" disabled={props.sendingReply}>
{props.sendingReply ? "Sending..." : "Send reply"}
</button>
</div>
</form>
</>
)}
</Show>
</Show>
</Show>
</article>
);

View File

@@ -1,8 +1,10 @@
/* Path: Frontend/src/components/dashboard/settings/dashboard-settings-focus.module.scss */
.section {
display: grid;
gap: 1rem;
@media (min-width: 960px) {
@include respond(desktop-sm) {
gap: 1.25rem;
}
}
@@ -11,12 +13,12 @@
display: grid;
gap: 1rem;
padding: 1.15rem;
border-radius: 1.5rem;
border-radius: var(--radius-3xl);
background: linear-gradient(135deg, var(--surface-panel), var(--surface-panel-strong));
border: 1px solid var(--border-strong);
box-shadow: var(--shadow-soft);
@media (min-width: 880px) {
@include respond(wide) {
grid-template-columns: minmax(0, 1.2fr) minmax(14rem, 0.8fr);
align-items: start;
padding: 1.35rem;
@@ -52,18 +54,24 @@
align-items: center;
gap: 0.85rem;
padding: 1rem;
border-radius: 1.15rem;
border-radius: var(--radius-lg);
background: var(--surface-soft);
border: 1px solid var(--border-soft);
strong {
display: block;
font-size: 1rem;
overflow-wrap: anywhere;
}
span:last-child {
color: var(--text-muted);
font-size: 0.88rem;
overflow-wrap: anywhere;
}
> div {
min-width: 0;
}
}
@@ -72,13 +80,19 @@
place-items: center;
width: 2.75rem;
height: 2.75rem;
border-radius: 9999px;
overflow: hidden;
border-radius: var(--radius-full);
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
color: var(--action-primary-text);
font-weight: 600;
flex-shrink: 0;
}
.avatarImage {
object-fit: cover;
display: block;
}
.statGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -88,7 +102,7 @@
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (min-width: 880px) {
@include respond(wide) {
grid-column: 1 / -1;
}
}
@@ -97,13 +111,14 @@
display: grid;
gap: 0.2rem;
padding: 0.95rem;
border-radius: 1.15rem;
border-radius: var(--radius-lg);
background: var(--surface-soft);
border: 1px solid var(--border-soft);
strong {
font-size: 1.1rem;
line-height: 1.1;
overflow-wrap: anywhere;
}
span {
@@ -126,7 +141,7 @@
display: grid;
gap: 1rem;
padding: 1.15rem;
border-radius: 1.35rem;
border-radius: var(--radius-2xl);
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
@@ -164,7 +179,7 @@
justify-content: space-between;
gap: 1rem;
padding: 0.8rem 0.9rem;
border-radius: 1rem;
border-radius: var(--radius-md);
background: var(--surface-soft);
border: 1px solid var(--border-soft);
@@ -176,28 +191,130 @@
strong {
font-size: 0.92rem;
text-align: right;
min-width: 0;
overflow-wrap: anywhere;
}
}
.panelActions {
@media (max-width: 640px) {
.rowItem {
align-items: flex-start;
flex-direction: column;
}
.rowItem strong {
text-align: left;
}
}
.fieldGrid {
display: grid;
gap: 0.85rem;
@include respond(phablet) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.fieldLabel {
display: grid;
gap: 0.45rem;
span {
font-size: 0.84rem;
font-weight: 600;
color: var(--text-muted);
}
}
.fieldLabelFull {
@include respond(phablet) {
grid-column: 1 / -1;
}
}
.textInput,
.textArea {
width: 100%;
min-width: 0;
padding: 0.85rem 0.95rem;
border-radius: var(--radius-md);
border: 1px solid var(--border-soft);
background: var(--surface-soft);
color: var(--text);
font: inherit;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
box-shadow 0.2s ease;
&:focus {
outline: none;
border-color: var(--border-strong);
background: var(--surface-panel-strong);
box-shadow: var(--focus-ring-primary-soft-shadow);
}
&::placeholder {
color: var(--text-muted);
}
}
.textArea {
resize: vertical;
min-height: 6.5rem;
}
.formActions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
@media (max-width: 640px) {
.formActions {
flex-direction: column;
}
.formActions > * {
width: 100%;
}
}
.feedbackMessage {
padding: 0.85rem 0.95rem;
border-radius: var(--radius-md);
font-size: 0.88rem;
border: 1px solid var(--border-soft);
}
.feedbackError {
background: var(--surface-warning-tint);
color: var(--text);
}
.feedbackSuccess {
background: var(--surface-success-tint);
color: var(--text);
}
.primaryAction,
.secondaryAction {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.8rem 1rem;
border-radius: 9999px;
border-radius: var(--radius-full);
font-weight: 500;
text-decoration: none;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
background-color 0.2s ease;
background-color 0.2s ease,
opacity 0.2s ease;
border: none;
cursor: pointer;
font: inherit;
}
.primaryAction {
@@ -209,6 +326,13 @@
transform: translateY(-1px);
box-shadow: var(--action-primary-shadow-hover);
}
&:disabled {
opacity: 0.7;
cursor: wait;
transform: none;
box-shadow: var(--action-primary-shadow);
}
}
.secondaryAction {

View File

@@ -0,0 +1,255 @@
// Path: Frontend/src/components/dashboard/settings/dashboard-settings-focus.tsx
import { createEffect, createMemo, createSignal, For, Show, type Component } from "solid-js";
import { createStore } from "solid-js/store";
import { useAuth, type UpdateProfileInput } from "~/context/auth/context";
import styles from "./dashboard-settings-focus.module.scss";
const emptyForm: UpdateProfileInput = {
fullName: "",
preferredName: "",
profileIconUrl: "",
headline: "",
bio: "",
timezone: "",
locale: "",
gradeLevel: "",
learningGoal: "",
};
const initialsFor = (value: string) => {
const cleaned = value.trim();
if (!cleaned) return "U";
const parts = cleaned.split(/\s+/).filter(Boolean);
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0]?.[0] ?? ""}${parts[1]?.[0] ?? ""}`.toUpperCase();
};
const roleLabel = (role?: "student" | "teacher") => {
if (role === "teacher") return "Teacher";
if (role === "student") return "Student";
return "Member";
};
const formatJoinedDate = (value?: string) => {
if (!value) return "Recently";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Recently";
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
};
const DashboardSettingsFocus: Component = () => {
const auth = useAuth();
const [form, setForm] = createStore<UpdateProfileInput>(emptyForm);
const [isSaving, setIsSaving] = createSignal(false);
const [errorMessage, setErrorMessage] = createSignal<string | null>(null);
const [successMessage, setSuccessMessage] = createSignal<string | null>(null);
createEffect(() => {
const user = auth.user();
if (!user) return;
setForm({
fullName: user.full_name ?? "",
preferredName: user.profile.preferred_name ?? "",
profileIconUrl: user.profile.profile_icon_url ?? "",
headline: user.profile.headline ?? "",
bio: user.profile.bio ?? "",
timezone: user.profile.timezone ?? "",
locale: user.profile.locale ?? "",
gradeLevel: user.profile.grade_level ?? "",
learningGoal: user.profile.learning_goal ?? "",
});
});
const displayName = createMemo(() => form.preferredName.trim() || form.fullName.trim() || "Your profile");
const displaySubtitle = createMemo(() => form.headline.trim() || `${roleLabel(auth.user()?.role)} account`);
const stats = createMemo(() => {
const user = auth.user();
return [
{ label: "Role", value: roleLabel(user?.role) },
{ label: "Email", value: user?.email ?? "Not available" },
{ label: "Timezone", value: form.timezone.trim() || "Not set" },
{ label: "Goal", value: form.learningGoal.trim() || "Add a learning goal" },
];
});
const handleInput = (field: keyof UpdateProfileInput) => (event: Event) => {
const target = event.currentTarget as HTMLInputElement | HTMLTextAreaElement;
setForm(field, target.value);
setSuccessMessage(null);
};
const handleSubmit = async (event: Event) => {
event.preventDefault();
setIsSaving(true);
setErrorMessage(null);
setSuccessMessage(null);
try {
await auth.updateProfile({ ...form });
setSuccessMessage("Profile updated.");
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : "Unable to save your profile right now.");
} finally {
setIsSaving(false);
}
};
return (
<section class={styles.section}>
<article class={styles.heroCard}>
<div class={styles.heroCopy}>
<p class={styles.eyebrow}>Settings</p>
<h1>Your profile and study settings</h1>
<p>Keep your preferred name, profile photo, and learning setup in sync so the dashboard feels like your own workspace.</p>
</div>
<div class={styles.profileChip}>
<Show when={form.profileIconUrl.trim()} fallback={<span class={styles.avatar}>{initialsFor(displayName())}</span>}>
{(iconUrl) => <img src={iconUrl()} alt="" class={`${styles.avatar} ${styles.avatarImage}`} />}
</Show>
<div>
<strong>{displayName()}</strong>
<span>{displaySubtitle()}</span>
</div>
</div>
<div class={styles.statGrid}>
<For each={stats()}>
{(stat) => (
<div class={styles.statCard}>
<strong>{stat.value}</strong>
<span>{stat.label}</span>
</div>
)}
</For>
</div>
</article>
<form class={styles.panelGrid} onSubmit={(event) => void handleSubmit(event)}>
<article class={styles.panelCard}>
<div class={styles.panelBody}>
<div class={styles.panelCopy}>
<h2>Profile details</h2>
<p>These fields shape how your name and identity show up across the dashboard.</p>
</div>
<div class={styles.fieldGrid}>
<label class={styles.fieldLabel}>
<span>Preferred name</span>
<input class={styles.textInput} value={form.preferredName} onInput={handleInput("preferredName")} placeholder="What should the dashboard call you?" />
</label>
<label class={styles.fieldLabel}>
<span>Full name</span>
<input class={styles.textInput} value={form.fullName} onInput={handleInput("fullName")} placeholder="Your full name" />
</label>
<label class={styles.fieldLabel}>
<span>Headline</span>
<input class={styles.textInput} value={form.headline} onInput={handleInput("headline")} placeholder="Short label under your name" />
</label>
<label class={styles.fieldLabel}>
<span>Profile icon URL</span>
<input class={styles.textInput} value={form.profileIconUrl} onInput={handleInput("profileIconUrl")} placeholder="https://..." />
</label>
<label class={`${styles.fieldLabel} ${styles.fieldLabelFull}`}>
<span>Bio</span>
<textarea class={styles.textArea} value={form.bio} onInput={handleInput("bio")} rows={4} placeholder="A short note about you or how you like to work" />
</label>
</div>
</div>
</article>
<article class={styles.panelCard}>
<div class={styles.panelBody}>
<div class={styles.panelCopy}>
<h2>Learning setup</h2>
<p>Use these profile fields to shape future reminders, dashboards, and classroom context.</p>
</div>
<div class={styles.fieldGrid}>
<label class={styles.fieldLabel}>
<span>Timezone</span>
<input class={styles.textInput} value={form.timezone} onInput={handleInput("timezone")} placeholder="Asia/Kuala_Lumpur" />
</label>
<label class={styles.fieldLabel}>
<span>Locale</span>
<input class={styles.textInput} value={form.locale} onInput={handleInput("locale")} placeholder="en-MY" />
</label>
<label class={styles.fieldLabel}>
<span>Grade level</span>
<input class={styles.textInput} value={form.gradeLevel} onInput={handleInput("gradeLevel")} placeholder="Year 6" />
</label>
<label class={`${styles.fieldLabel} ${styles.fieldLabelFull}`}>
<span>Learning goal</span>
<textarea class={styles.textArea} value={form.learningGoal} onInput={handleInput("learningGoal")} rows={4} placeholder="What are you working toward right now?" />
</label>
</div>
</div>
</article>
<article class={styles.panelCard}>
<div class={styles.panelBody}>
<div class={styles.panelCopy}>
<h2>Account snapshot</h2>
<p>Core account details still come from your signed-in user record.</p>
</div>
<div class={styles.rowList}>
<div class={styles.rowItem}>
<span>Email</span>
<strong>{auth.user()?.email ?? "Not available"}</strong>
</div>
<div class={styles.rowItem}>
<span>Role</span>
<strong>{roleLabel(auth.user()?.role)}</strong>
</div>
<div class={styles.rowItem}>
<span>Status</span>
<strong>{auth.user()?.is_active ? "Active" : "Inactive"}</strong>
</div>
<div class={styles.rowItem}>
<span>Member since</span>
<strong>{formatJoinedDate(auth.user()?.created_at)}</strong>
</div>
</div>
<div class={styles.formActions}>
<button type="submit" class={styles.primaryAction} disabled={isSaving()}>
{isSaving() ? "Saving…" : "Save profile"}
</button>
<button type="button" class={styles.secondaryAction} onClick={() => void auth.refresh()}>
Refresh from server
</button>
</div>
<Show when={errorMessage()}>
<p class={`${styles.feedbackMessage} ${styles.feedbackError}`}>{errorMessage()}</p>
</Show>
<Show when={successMessage()}>
<p class={`${styles.feedbackMessage} ${styles.feedbackSuccess}`}>{successMessage()}</p>
</Show>
</div>
</article>
</form>
</section>
);
};
export default DashboardSettingsFocus;

View File

@@ -1,13 +1,21 @@
import { createEffect, createSignal, onCleanup, onMount, type Component, type JSX } from "solid-js";
// Path: Frontend/src/components/dashboard/shared/dashboard-shell.tsx
import { useLocation } from "@solidjs/router";
import { Show, createEffect, createSignal, onCleanup, onMount, type Component, type JSX } from "solid-js";
import { Transition } from "solid-transition-group";
import styles from "../../../routes/dashboard/dashboard.module.scss";
import DashboardSidebar from "./dashboard-sidebar";
import DashboardTopbar from "./dashboard-topbar";
import styles from "../../routes/dashboard/dashboard.module.scss";
import type { DashboardHomeShellData } from "./dashboard-types";
type DashboardShellProps = {
children: JSX.Element;
sidebarData?: Pick<DashboardHomeShellData, "classroomSummary" | "sidebarLinks" | "sidebarSupport">;
topbarData?: Pick<DashboardHomeShellData, "topbarSummary" | "topbarNotifications" | "topbarMessages">;
};
const DashboardShell: Component<DashboardShellProps> = (props) => {
const location = useLocation();
const [sidebarOpen, setSidebarOpen] = createSignal(false);
const [desktopSidebarCollapsed, setDesktopSidebarCollapsed] = createSignal(false);
@@ -48,13 +56,7 @@ const DashboardShell: Component<DashboardShellProps> = (props) => {
<main class={styles.dashboardPage}>
<div classList={{ [styles.dashboardLayout]: true, [styles.dashboardLayoutSidebarCollapsed]: desktopSidebarCollapsed() }}>
<div class={styles.sidebarRail}>
<button
type="button"
classList={{ [styles.sidebarBackdrop]: true, [styles.sidebarBackdropVisible]: sidebarOpen() }}
aria-label="Close sidebar"
tabIndex={sidebarOpen() ? 0 : -1}
onClick={closeSidebar}
/>
<button type="button" classList={{ [styles.sidebarBackdrop]: true, [styles.sidebarBackdropVisible]: sidebarOpen() }} aria-label="Close sidebar" tabIndex={sidebarOpen() ? 0 : -1} onClick={closeSidebar} />
<div
classList={{
@@ -64,14 +66,24 @@ const DashboardShell: Component<DashboardShellProps> = (props) => {
}}
>
<div class={styles.sidebarPanelInner}>
<DashboardSidebar onNavigate={closeSidebar} />
<DashboardSidebar onNavigate={closeSidebar} data={props.sidebarData} />
</div>
</div>
</div>
<section class={styles.dashboardMain}>
<DashboardTopbar isSidebarOpen={isSidebarVisible()} onMenuToggle={toggleSidebar} />
{props.children}
<DashboardTopbar isSidebarOpen={isSidebarVisible()} onMenuToggle={toggleSidebar} data={props.topbarData} />
<div class={styles.dashboardContentShell}>
<Transition name="dashboard-content-fade">
<Show when={location.pathname} keyed>
{(path) => (
<div class={styles.dashboardContentStage} data-route={path}>
{props.children}
</div>
)}
</Show>
</Transition>
</div>
</section>
</div>
</main>

View File

@@ -1,17 +1,21 @@
/* Path: Frontend/src/components/dashboard/shared/dashboard-sidebar.module.scss */
.sidebar {
display: grid;
gap: 1rem;
padding: 1rem;
background: var(--surface-panel);
border: 1px solid var(--border-strong);
border-radius: 1.5rem;
border-radius: var(--radius-3xl);
box-shadow: var(--shadow-soft);
min-width: 0;
overflow-x: clip;
@media (min-width: 768px) {
@include respond(mobile) {
padding: 1.15rem;
}
@media (min-width: 1024px) {
@include respond(tablet) {
gap: 1.25rem;
padding: 1.3rem;
position: sticky;
@@ -20,26 +24,15 @@
}
.brand {
display: flex;
align-items: center;
gap: 0.85rem;
}
.logoMark {
width: 2.6rem;
height: 2.6rem;
display: grid;
place-items: center;
border-radius: 0.9rem;
background: linear-gradient(135deg, var(--action-primary-start), var(--info));
color: var(--action-primary-text);
font-weight: 600;
box-shadow: var(--action-primary-shadow);
gap: 0.45rem;
min-width: 0;
}
.brandName {
font-weight: 600;
font-size: 1rem;
.brandLogo {
width: min(100%, 8.5rem);
height: auto;
display: block;
}
.brandMeta {
@@ -51,19 +44,11 @@
display: grid;
gap: 0.35rem;
@media (max-width: 519px) {
display: flex;
overflow-x: auto;
gap: 0.55rem;
padding-bottom: 0.1rem;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
@include respond-max(mobile-narrow) {
grid-template-columns: minmax(0, 1fr);
}
@media (min-width: 520px) and (max-width: 1023px) {
@media (min-width: #{$mobile-narrow + 1px}) and (max-width: #{$tablet - 1px}) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@@ -73,7 +58,7 @@
align-items: flex-start;
gap: 0.8rem;
padding: 0.78rem 0.85rem;
border-radius: 1rem;
border-radius: var(--radius-md);
color: var(--text-muted);
transition:
background-color 0.2s ease,
@@ -88,15 +73,14 @@
}
}
@media (max-width: 519px) {
@include respond-max(mobile-narrow) {
.link {
flex: 0 0 auto;
align-items: center;
width: 100%;
align-items: flex-start;
gap: 0.6rem;
padding: 0.7rem 0.82rem;
border: 1px solid var(--border-soft);
background: var(--surface-panel-strong);
white-space: nowrap;
}
}
@@ -119,16 +103,16 @@
}
}
@media (max-width: 519px) {
@include respond-max(mobile-narrow) {
.linkCopy {
gap: 0;
gap: 0.08rem;
strong {
font-size: 0.9rem;
}
small {
display: none;
display: block;
}
}
}
@@ -171,10 +155,11 @@
padding: 1rem;
background: linear-gradient(180deg, var(--surface-panel-strong), var(--surface-soft));
border: 1px solid var(--border-soft);
border-radius: 1.25rem;
border-radius: var(--radius-xl);
min-width: 0;
}
@media (max-width: 519px) {
@include respond-max(mobile-narrow) {
.supportCard {
gap: 0.7rem;
padding: 0.9rem;
@@ -194,7 +179,7 @@
height: 2rem;
display: grid;
place-items: center;
border-radius: 9999px;
border-radius: var(--radius-full);
border: 2px solid var(--surface-panel);
background: var(--surface-accent-soft);
color: var(--primary);
@@ -222,15 +207,20 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
max-width: 100%;
min-width: 0;
padding: 0.8rem 1rem;
border: none;
border-radius: 9999px;
border-radius: var(--radius-full);
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
color: var(--action-primary-text);
box-shadow: var(--action-primary-shadow);
cursor: pointer;
font-weight: 600;
white-space: nowrap;
white-space: normal;
text-align: center;
line-height: 1.2;
&:hover {
text-decoration: none;

View File

@@ -0,0 +1,64 @@
// Path: Frontend/src/components/dashboard/shared/dashboard-sidebar.tsx
import { A, useLocation } from "@solidjs/router";
import type { Component } from "solid-js";
import { For } from "solid-js";
import styles from "./dashboard-sidebar.module.scss";
import type { DashboardHomeShellData } from "./dashboard-types";
import { classroomSummary, sidebarLinks, sidebarSupport } from "./dashboard.data";
type DashboardSidebarProps = {
onNavigate?: () => void;
data?: Pick<DashboardHomeShellData, "classroomSummary" | "sidebarLinks" | "sidebarSupport">;
};
const DashboardSidebar: Component<DashboardSidebarProps> = (props) => {
const location = useLocation();
const summary = () => props.data?.classroomSummary ?? classroomSummary;
const links = () => props.data?.sidebarLinks ?? sidebarLinks;
const support = () => props.data?.sidebarSupport ?? sidebarSupport;
const isActiveLink = (href?: string) => {
if (!href) return false;
if (location.pathname === href) return true;
const isHomeLink = links().some((link) => link.href === href && link.active);
if (isHomeLink) return false;
return location.pathname === href || location.pathname.startsWith(`${href}/`);
};
return (
<aside class={styles.sidebar}>
<div class={styles.brand}>
<img class={styles.brandLogo} src="/brand/boost-ai-logo-purple.png" alt="Boost AI" />
<p class={styles.brandMeta}>{summary().name}</p>
</div>
<nav class={styles.navigation} aria-label="Dashboard navigation">
<For each={links()}>
{(link) => (
<A href={link.href ?? "#"} classList={{ [styles.link]: true, [styles.active]: isActiveLink(link.href) || (!!link.active && !!link.href && location.pathname === link.href) }} onClick={() => props.onNavigate?.()}>
<span class={styles.iconSlot}>{link.icon}</span>
<span class={styles.linkCopy}>
<strong>{link.label}</strong>
{link.detail && <small>{link.detail}</small>}
</span>
</A>
)}
</For>
</nav>
<div class={styles.supportCard}>
<div class={styles.avatarRow}>
<For each={support().avatars}>{(avatar) => <span>{avatar}</span>}</For>
</div>
<h2>{support().title}</h2>
<p>{support().description}</p>
<A href={support().buttonHref} class={styles.supportButton} onClick={() => props.onNavigate?.()}>
{support().buttonLabel}
</A>
</div>
</aside>
);
};
export default DashboardSidebar;

View File

@@ -1,10 +1,12 @@
/* Path: Frontend/src/components/dashboard/shared/dashboard-theme-toggle.module.scss */
.toggleButton {
display: grid;
place-items: center;
width: 2.75rem;
height: 2.75rem;
padding: 0;
border-radius: 1rem;
border-radius: var(--radius-md);
border: 1px solid var(--border-soft);
background: var(--surface-panel);
color: var(--text-muted);
@@ -21,7 +23,7 @@
border-color: var(--border-strong);
}
@media (min-width: 768px) {
@include respond(mobile) {
width: 3rem;
height: 3rem;
}

View File

@@ -1,5 +1,7 @@
// Path: Frontend/src/components/dashboard/shared/dashboard-theme-toggle.tsx
import type { Component } from "solid-js";
import { useTheme } from "../../context/theme/context";
import { useTheme } from "../../../context/theme/context";
import styles from "./dashboard-theme-toggle.module.scss";
const DashboardThemeToggle: Component = () => {

View File

@@ -1,3 +1,5 @@
/* Path: Frontend/src/components/dashboard/shared/dashboard-topbar.module.scss */
.topbar {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
@@ -6,23 +8,26 @@
position: relative;
z-index: 50;
@media (max-width: 519px) {
@include respond-max(mobile-narrow) {
grid-template-columns: auto minmax(0, 1fr);
}
@media (min-width: 880px) {
@media (max-width: 1023px) {
grid-template-columns: auto minmax(0, 1fr) auto;
}
@include respond(wide) {
grid-template-columns: auto minmax(0, 1fr) auto;
}
}
.menuButton,
.iconButton {
width: 2.75rem;
height: 2.75rem;
display: grid;
place-items: center;
border-radius: 1rem;
border-radius: var(--radius-md);
border: 1px solid var(--border-soft);
background: var(--surface-panel);
color: var(--text-muted);
@@ -67,7 +72,7 @@
left: 0;
width: 100%;
height: 2px;
border-radius: 9999px;
border-radius: var(--radius-full);
background: currentColor;
transform-origin: center;
transition:
@@ -108,7 +113,7 @@
}
}
@media (min-width: 768px) {
@include respond(mobile) {
.menuButton,
.iconButton {
width: 3rem;
@@ -121,7 +126,7 @@
align-items: center;
gap: 0.8rem;
padding: 0 0.85rem;
border-radius: 1rem;
border-radius: var(--radius-md);
border: 1px solid var(--border-soft);
background: var(--surface-panel-strong);
box-shadow: var(--shadow-soft);
@@ -137,7 +142,19 @@
}
}
@media (min-width: 768px) {
.searchToggle {
display: none;
}
.searchDropdown {
width: min(28rem, calc(100vw - 2rem));
}
.searchFieldSheet {
margin-top: 0.15rem;
}
@include respond(mobile) {
.searchField {
padding: 0 1rem;
@@ -171,6 +188,7 @@
gap: 0.55rem;
grid-column: 1 / -1;
overflow-x: auto;
overflow-y: visible;
padding-bottom: 0.15rem;
scrollbar-width: none;
@@ -178,7 +196,7 @@
display: none;
}
@media (min-width: 880px) {
@include respond(wide) {
grid-column: auto;
justify-content: flex-end;
overflow: visible;
@@ -191,6 +209,43 @@
flex: 0 0 auto;
}
.mobileMenuBackdrop {
display: none;
}
@media (max-width: 1023px) {
.searchField {
display: none;
}
.searchToggle {
display: grid;
}
.actions {
grid-column: auto;
justify-content: flex-end;
overflow: visible;
padding-bottom: 0;
gap: 0.45rem;
}
.menuGroup {
position: static;
}
.mobileMenuBackdrop {
display: block;
position: fixed;
inset: 0;
border: none;
padding: 0;
background: hsl(0 0% 0% / 0.44);
backdrop-filter: blur(3px);
z-index: 70;
}
}
.countBadge {
position: absolute;
top: -0.15rem;
@@ -200,7 +255,7 @@
display: grid;
place-items: center;
padding: 0 0.2rem;
border-radius: 9999px;
border-radius: var(--radius-full);
background: var(--primary);
color: var(--action-primary-text);
font-size: 0.68rem;
@@ -217,7 +272,7 @@
display: grid;
gap: 0.85rem;
padding: 0.95rem;
border-radius: 1.25rem;
border-radius: var(--radius-xl);
border: 1px solid var(--border-strong);
background: var(--surface-raised);
box-shadow: var(--shadow-elevated);
@@ -225,6 +280,43 @@
z-index: 20;
}
@media (max-width: 1023px) {
.dropdown {
position: fixed;
top: calc(env(safe-area-inset-top, 0rem) + 5.6rem);
left: 1rem;
right: 1rem;
width: auto;
max-height: calc(100dvh - env(safe-area-inset-top, 0rem) - 6.6rem);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 1rem;
border-radius: max(var(--radius-xl), 1.25rem);
box-shadow: 0 24px 64px hsl(220 35% 12% / 0.28);
z-index: 80;
}
.searchDropdown {
top: calc(env(safe-area-inset-top, 0rem) + 1rem);
}
.dropdownHeader {
align-items: flex-start;
}
.dropdownList {
gap: 0.8rem;
}
.dropdownItem {
padding: 0.9rem;
}
.searchFieldSheet {
display: flex;
}
}
.dropdownHeader {
display: flex;
align-items: center;
@@ -237,6 +329,13 @@
}
}
.dropdownHeaderActions {
display: flex;
align-items: center;
gap: 0.65rem;
flex-shrink: 0;
}
.dropdownEyebrow {
font-size: 0.72rem;
letter-spacing: 0.1em;
@@ -245,6 +344,12 @@
margin-bottom: 0.12rem;
}
.dropdownMeta {
margin-top: 0.22rem;
font-size: 0.8rem;
color: var(--text-muted);
}
.dropdownLink {
color: var(--primary);
font-size: 0.85rem;
@@ -252,18 +357,62 @@
white-space: nowrap;
}
.mobileDropdownClose {
display: none;
}
@media (max-width: 1023px) {
.mobileDropdownClose {
width: 2.35rem;
height: 2.35rem;
display: grid;
place-items: center;
border-radius: var(--radius-full);
border: 1px solid var(--border-soft);
background: var(--surface-panel);
color: var(--text-muted);
box-shadow: var(--shadow-soft);
appearance: none;
cursor: pointer;
svg {
width: 0.95rem;
height: 0.95rem;
stroke: currentColor;
fill: none;
stroke-width: 1.9;
stroke-linecap: round;
stroke-linejoin: round;
}
}
.dropdownLink {
display: none;
}
}
.dropdownList {
display: grid;
gap: 0.65rem;
}
.emptyDropdownState {
padding: 0.9rem;
border-radius: var(--radius-md);
border: 1px dashed var(--border-soft);
background: var(--surface-panel);
color: var(--text-muted);
font-size: 0.85rem;
line-height: 1.45;
}
.dropdownItem {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: start;
gap: 0.7rem;
padding: 0.8rem;
border-radius: 1rem;
border-radius: var(--radius-md);
background: var(--surface-panel);
border: 1px solid var(--border-soft);
transition:
@@ -285,6 +434,15 @@
}
}
.dropdownButtonReset {
width: 100%;
text-align: left;
appearance: none;
color: inherit;
font: inherit;
cursor: pointer;
}
.itemCopy {
min-width: 0;
display: grid;
@@ -310,7 +468,7 @@
.messageAvatar {
width: 2.15rem;
height: 2.15rem;
border-radius: 9999px;
border-radius: var(--radius-full);
flex-shrink: 0;
}
@@ -347,13 +505,17 @@
align-items: center;
gap: 0.75rem;
padding: 0.4rem 0.5rem 0.4rem 0.8rem;
border-radius: 9999px;
border-radius: var(--radius-full);
border: 1px solid var(--border-soft);
background: var(--surface-panel);
box-shadow: var(--shadow-soft);
white-space: nowrap;
}
.profileCopy {
min-width: 0;
}
.profileButton {
padding: 0;
border: none;
@@ -402,12 +564,23 @@
transform: rotate(180deg);
}
@media (min-width: 768px) {
@include respond(mobile) {
.profile {
padding: 0.45rem 0.55rem 0.45rem 0.95rem;
}
}
@media (max-width: 1023px) {
.profile {
padding: 0.4rem 0.55rem;
gap: 0.55rem;
}
.profileCopy {
display: none;
}
}
.profileName {
font-weight: 500;
font-size: 0.9rem;
@@ -420,7 +593,7 @@
letter-spacing: 0.08em;
}
@media (max-width: 519px) {
@include respond-max(mobile-narrow) {
.profileRole {
display: none;
}
@@ -431,7 +604,8 @@
height: 2.2rem;
display: grid;
place-items: center;
border-radius: 9999px;
overflow: hidden;
border-radius: var(--radius-full);
background: var(--surface-accent-emphasis);
color: var(--text-accent-strong);
font-weight: 600;
@@ -442,10 +616,18 @@
height: 2.2rem;
display: grid;
place-items: center;
border-radius: 9999px;
overflow: hidden;
border-radius: var(--radius-full);
background: var(--surface-accent-emphasis);
color: var(--text-accent-strong);
font-size: 0.9rem;
font-weight: 700;
flex-shrink: 0;
}
.profileAvatarImage {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}

View File

@@ -0,0 +1,286 @@
// Path: Frontend/src/components/dashboard/shared/dashboard-topbar.tsx
import { A } from "@solidjs/router";
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show, type Component } from "solid-js";
import { useAuth } from "~/context/auth/context";
import { getDashboardMessagesHref, getDashboardSettingsHref } from "../../../lib/routes";
import { getDashboardTopbarMessagesData } from "../messages/dashboard-messages.data";
import DashboardThemeToggle from "./dashboard-theme-toggle";
import styles from "./dashboard-topbar.module.scss";
import type { DashboardHomeShellData } from "./dashboard-types";
import { topbarMessages, topbarSummary } from "./dashboard.data";
type DashboardTopbarProps = {
isSidebarOpen: boolean;
onMenuToggle: () => void;
data?: Pick<DashboardHomeShellData, "topbarSummary" | "topbarNotifications" | "topbarMessages">;
};
const initialsFor = (value: string) => {
const cleaned = value.trim();
if (!cleaned) return "U";
const parts = cleaned.split(/\s+/).filter(Boolean);
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0]?.[0] ?? ""}${parts[1]?.[0] ?? ""}`.toUpperCase();
};
const roleLabel = (role?: "student" | "teacher") => {
if (role === "teacher") return "Teacher";
if (role === "student") return "Student";
return null;
};
const DashboardTopbar: Component<DashboardTopbarProps> = (props) => {
const auth = useAuth();
const [openMenu, setOpenMenu] = createSignal<"search" | "messages" | "profile" | null>(null);
const currentUser = createMemo(() => (auth.isReady() ? (auth.user() ?? null) : null));
const topbarMessagesSource = createMemo(() => {
const user = currentUser();
if (!user) return null;
return { userId: user.id, role: user.role };
});
const [liveMessages] = createResource(topbarMessagesSource, ({ userId, role }) => getDashboardTopbarMessagesData(userId, role));
let menuRoot: HTMLDivElement | undefined;
const dashboardRole = createMemo(() => currentUser()?.role ?? "student");
const inboxHref = createMemo(() => getDashboardMessagesHref(dashboardRole()));
const settingsHref = createMemo(() => getDashboardSettingsHref(dashboardRole()));
const summary = () => {
const baseSummary = props.data?.topbarSummary ?? topbarSummary;
const live = liveMessages();
if (!live) return baseSummary;
return {
...baseSummary,
messageCount: live.messageCount,
};
};
const messages = () => liveMessages()?.topbarMessages ?? props.data?.topbarMessages ?? topbarMessages;
const unreadThreadCount = () => liveMessages()?.unreadThreadCount ?? summary().messageCount;
const toggleMenu = (menu: "messages" | "profile") => {
setOpenMenu((current) => (current === menu ? null : menu));
};
const profileName = () => auth.user()?.profile.preferred_name ?? auth.user()?.full_name ?? summary().profileName;
const profileRole = () => auth.user()?.profile.headline ?? roleLabel(auth.user()?.role) ?? summary().profileRole;
const profileBadge = () => initialsFor(profileName() || summary().profileName);
const profileIconUrl = () => auth.user()?.profile.profile_icon_url ?? null;
let mobileSearchInput: HTMLInputElement | undefined;
const handleLogout = async () => {
await auth.logout();
setOpenMenu(null);
window.location.href = "/auth/login";
};
onMount(() => {
const handlePointerDown = (event: MouseEvent) => {
if (!menuRoot?.contains(event.target as Node)) {
setOpenMenu(null);
}
};
document.addEventListener("pointerdown", handlePointerDown);
onCleanup(() => document.removeEventListener("pointerdown", handlePointerDown));
});
createEffect(() => {
if (openMenu() !== "search") return;
queueMicrotask(() => mobileSearchInput?.focus());
});
return (
<header class={styles.topbar}>
<button type="button" classList={{ [styles.menuButton]: true, [styles.menuButtonOpen]: props.isSidebarOpen }} aria-label={props.isSidebarOpen ? "Close menu" : "Open menu"} aria-expanded={props.isSidebarOpen} onClick={props.onMenuToggle}>
<span class={styles.menuGlyph} aria-hidden="true">
<span class={styles.menuLine} />
<span class={styles.menuLine} />
<span class={styles.menuLine} />
</span>
</button>
<label class={styles.searchField}>
<span class={styles.searchIcon} aria-hidden="true">
<svg viewBox="0 0 24 24">
<circle cx="11" cy="11" r="6" />
<path d="M20 20l-4.2-4.2" />
</svg>
</span>
<input type="search" placeholder={summary().searchPlaceholder} />
</label>
<div class={styles.actions} ref={menuRoot}>
<Show when={openMenu()}>
<button type="button" class={styles.mobileMenuBackdrop} aria-label="Close menu" tabIndex={-1} onClick={() => setOpenMenu(null)} />
</Show>
<div class={styles.menuGroup}>
<button type="button" classList={{ [styles.iconButton]: true, [styles.searchToggle]: true }} aria-label="Search" aria-expanded={openMenu() === "search"} onClick={() => toggleMenu("search")}>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="6" />
<path d="M20 20l-4.2-4.2" />
</svg>
</button>
<Show when={openMenu() === "search"}>
<div class={`${styles.dropdown} ${styles.searchDropdown}`}>
<div class={styles.dropdownHeader}>
<div>
<p class={styles.dropdownEyebrow}>Search</p>
<strong>Find something quickly</strong>
<p class={styles.dropdownMeta}>{summary().searchPlaceholder}</p>
</div>
<div class={styles.dropdownHeaderActions}>
<button type="button" class={styles.mobileDropdownClose} aria-label="Close search" onClick={() => setOpenMenu(null)}>
<svg viewBox="0 0 20 20" aria-hidden="true">
<path d="M5 5l10 10M15 5 5 15" />
</svg>
</button>
</div>
</div>
<label class={`${styles.searchField} ${styles.searchFieldSheet}`}>
<span class={styles.searchIcon} aria-hidden="true">
<svg viewBox="0 0 24 24">
<circle cx="11" cy="11" r="6" />
<path d="M20 20l-4.2-4.2" />
</svg>
</span>
<input ref={mobileSearchInput} type="search" placeholder={summary().searchPlaceholder} />
</label>
</div>
</Show>
</div>
<DashboardThemeToggle />
<div class={styles.menuGroup}>
<button type="button" class={styles.iconButton} aria-label="Messages" aria-expanded={openMenu() === "messages"} onClick={() => toggleMenu("messages")}>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 6h16v10H8l-4 3V6Z" />
</svg>
<Show when={summary().messageCount > 0}>
<span class={styles.countBadge}>{summary().messageCount}</span>
</Show>
</button>
<Show when={openMenu() === "messages"}>
<div class={styles.dropdown}>
<div class={styles.dropdownHeader}>
<div>
<p class={styles.dropdownEyebrow}>Inbox</p>
<strong>Messages</strong>
<p class={styles.dropdownMeta}>
{unreadThreadCount()} unread thread{unreadThreadCount() === 1 ? "" : "s"}
</p>
</div>
<div class={styles.dropdownHeaderActions}>
<A href={inboxHref()} class={styles.dropdownLink} onClick={() => setOpenMenu(null)}>
Open inbox
</A>
<button type="button" class={styles.mobileDropdownClose} aria-label="Close messages menu" onClick={() => setOpenMenu(null)}>
<svg viewBox="0 0 20 20" aria-hidden="true">
<path d="M5 5l10 10M15 5 5 15" />
</svg>
</button>
</div>
</div>
<div class={styles.dropdownList}>
<Show when={messages().length} fallback={<div class={styles.emptyDropdownState}>No active threads yet.</div>}>
<For each={messages()}>
{(item) => (
<A href={item.actionHref} class={styles.dropdownItem} onClick={() => setOpenMenu(null)}>
<span class={styles.messageAvatar}>{item.initials}</span>
<div class={styles.itemCopy}>
<strong>{item.sender}</strong>
<p>{item.preview}</p>
</div>
<small>{item.timestamp}</small>
</A>
)}
</For>
</Show>
</div>
</div>
</Show>
</div>
<div class={styles.menuGroup}>
<button type="button" classList={{ [styles.profileButton]: true, [styles.profileButtonOpen]: openMenu() === "profile" }} aria-label="Profile menu" aria-expanded={openMenu() === "profile"} onClick={() => toggleMenu("profile")}>
<div class={styles.profile}>
<div class={styles.profileCopy}>
<p class={styles.profileName}>{profileName()}</p>
<p class={styles.profileRole}>{profileRole()}</p>
</div>
<span class={styles.profileBadge}>
<Show when={profileIconUrl()} fallback={profileBadge()}>
{(iconUrl) => <img src={iconUrl()} alt="" class={styles.profileAvatarImage} />}
</Show>
</span>
<span class={styles.profileChevron} aria-hidden="true">
<svg viewBox="0 0 20 20">
<path d="m5 7.5 5 5 5-5" />
</svg>
</span>
</div>
</button>
<Show when={openMenu() === "profile"}>
<div class={styles.dropdown}>
<div class={styles.dropdownHeader}>
<div>
<p class={styles.dropdownEyebrow}>Signed in as</p>
<strong>{profileName()}</strong>
<p class={styles.dropdownMeta}>{profileRole()}</p>
</div>
<div class={styles.dropdownHeaderActions}>
<span class={styles.profileMenuBadge}>
<Show when={profileIconUrl()} fallback={profileBadge()}>
{(iconUrl) => <img src={iconUrl()} alt="" class={styles.profileAvatarImage} />}
</Show>
</span>
<button type="button" class={styles.mobileDropdownClose} aria-label="Close profile menu" onClick={() => setOpenMenu(null)}>
<svg viewBox="0 0 20 20" aria-hidden="true">
<path d="M5 5l10 10M15 5 5 15" />
</svg>
</button>
</div>
</div>
<div class={styles.dropdownList}>
<A href={settingsHref()} class={styles.dropdownItem} onClick={() => setOpenMenu(null)}>
<span class={`${styles.itemTone} ${styles["tone-blue"]}`} aria-hidden="true" />
<div class={styles.itemCopy}>
<strong>Profile</strong>
<p>Update your learner details and view your profile settings.</p>
</div>
</A>
<A href={settingsHref()} class={styles.dropdownItem} onClick={() => setOpenMenu(null)}>
<span class={`${styles.itemTone} ${styles["tone-yellow"]}`} aria-hidden="true" />
<div class={styles.itemCopy}>
<strong>Settings</strong>
<p>Manage goals, preferences, reminders, and dashboard options.</p>
</div>
</A>
<button type="button" class={`${styles.dropdownItem} ${styles.dropdownButtonReset}`} onClick={() => void handleLogout()}>
<span class={`${styles.itemTone} ${styles["tone-teal"]}`} aria-hidden="true" />
<div class={styles.itemCopy}>
<strong>Log out</strong>
<p>End your current session and return to the sign-in screen.</p>
</div>
</button>
</div>
</div>
</Show>
</div>
</div>
</header>
);
};
export default DashboardTopbar;

View File

@@ -0,0 +1,156 @@
// Path: Frontend/src/components/dashboard/shared/dashboard-types.ts
export type AccentTone = "yellow" | "pink" | "teal" | "blue";
type SpotlightTone = "purple" | "yellow" | "blue";
export type PerformanceTone = "blue" | "purple" | "teal" | "pink" | "yellow";
type UsageTone = "blue" | "purple" | "teal" | "yellow";
export type SidebarLink = {
label: string;
detail?: string;
icon: string;
href?: string;
active?: boolean;
};
type SpotlightStat = {
label: string;
value: string;
tone: SpotlightTone;
};
type QuickStat = {
label: string;
value: string;
};
export type AssignmentCard = {
title: string;
badge: string;
lessons: string;
accent: AccentTone;
cta: string;
href: string;
};
type TopbarNotificationItem = {
title: string;
description: string;
timestamp: string;
href: string;
tone: "blue" | "yellow" | "teal";
};
export type TopbarMessageItem = {
sender: string;
initials: string;
preview: string;
timestamp: string;
actionHref: string;
};
export type StudentSupportCard = {
name: string;
meta: string;
initials: string;
actionLabel: string;
href: string;
};
type HighlightCard = {
value: string;
label: string;
note: string;
tone: "yellow" | "pink";
};
export type PerformanceBar = {
label: string;
value: number;
tone: PerformanceTone;
};
export type UsageItem = {
label: string;
value: number;
tone: UsageTone;
};
export type DashboardHomeShellData = {
classroomSummary: {
name: string;
targetLevel?: number;
inviteCode?: string;
tutorName: string;
tutorRole: string;
tutorInitials: string;
};
sidebarLinks: SidebarLink[];
sidebarSupport: {
avatars: string[];
title: string;
description: string;
buttonLabel: string;
buttonHref: string;
};
topbarSummary: {
searchPlaceholder: string;
profileName: string;
profileRole: string;
profileBadge: string;
notificationCount: number;
messageCount: number;
};
topbarNotifications: TopbarNotificationItem[];
topbarMessages: TopbarMessageItem[];
};
export type DashboardHeroData = {
heroSummary: {
eyebrow: string;
title: string;
description: string;
visualBadges: string[];
};
spotlightStats: SpotlightStat[];
heroSideCard: {
title: string;
description: string;
buttonLabel: string;
buttonHref: string;
};
quickStats: QuickStat[];
};
export type DashboardActivityData = {
activitySummary: {
title: string;
note: string;
badge: string;
};
progressLabels: string[];
progressPoints: number[];
highlightCards: HighlightCard[];
};
export type DashboardInsightsData = {
masteryTitle: string;
overallTitle: string;
overallLabel: string;
usageTitle: string;
overallPassRate: number;
topicMasteryBars: PerformanceBar[];
solveModeUsage: UsageItem[];
usageSummary: {
note: string;
};
};
export type DashboardHomeData = {
shell: DashboardHomeShellData;
hero: DashboardHeroData;
assignmentCards: AssignmentCard[];
activity: DashboardActivityData;
studentSupportList: StudentSupportCard[];
insights: DashboardInsightsData;
};

View File

@@ -0,0 +1,210 @@
// Path: Frontend/src/components/dashboard/shared/dashboard.data.ts
import rawDataset from "../../../../../Mock-Data/dataset.json";
import { getAssignmentWorkHref, getDashboardAssignmentsHref, getDashboardHomeHref, getDashboardMessagesHref, getDashboardPracticeHref, getDashboardProgressHref, getDashboardSettingsHref } from "../../../lib/routes";
import type { DashboardHomeShellData, TopbarMessageItem } from "./dashboard-types";
type Dataset = {
_meta: {
reference_today: string;
};
classroom: {
name: string;
invite_code: string;
target_level: number;
};
tutor: {
fullname: string;
role: string;
};
students: Array<{
id: number;
fullname: string;
_persona: string;
}>;
assignments: Array<{
id: number;
name: string;
topic: string;
due_date: number;
}>;
assignment_assignees: Array<{
id: number;
assignment_id: number;
student_id: number;
status: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED";
total_marks: number;
}>;
student_answers: Array<{
assignee_id: number;
_is_correct: boolean;
_question_topic: string;
_answered_at: number;
}>;
activity_logs: Array<{
timestamp: number;
duration_seconds: number;
_student_id: number;
}>;
};
type StudentAssignment = Dataset["assignment_assignees"][number] & {
assignment: Dataset["assignments"][number];
answerCount: number;
accuracy: number;
};
const dataset = rawDataset as Dataset;
const referenceTime = new Date(`${dataset._meta.reference_today}T00:00:00Z`).getTime();
const msPerDay = 1000 * 60 * 60 * 24;
const defaultStudentId = 201;
const initialsFor = (name: string) =>
name
.split(" ")
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase();
const formatLastSeen = (timestamp: number) => {
const dayDiff = Math.max(0, Math.round((referenceTime - timestamp) / msPerDay));
if (dayDiff <= 0) return "today";
if (dayDiff === 1) return "yesterday";
return `${dayDiff} days ago`;
};
const daysUntil = (timestamp: number) => Math.max(0, Math.ceil((timestamp - referenceTime) / msPerDay));
const formatDueDate = (timestamp: number) =>
new Intl.DateTimeFormat("en-GB", {
month: "short",
day: "numeric",
}).format(new Date(timestamp));
const students = dataset.students;
const assignments = [...dataset.assignments].sort((left, right) => left.due_date - right.due_date);
const assignees = dataset.assignment_assignees;
const answers = dataset.student_answers;
const activityLogs = dataset.activity_logs;
const student = students.find((entry) => entry.id === defaultStudentId) ?? students[0];
const firstName = student.fullname.split(" ")[0];
const assignmentById = new Map(assignments.map((assignment) => [assignment.id, assignment]));
const assigneeById = new Map(assignees.map((assignee) => [assignee.id, assignee]));
const studentAnswers = answers.filter((answer) => assigneeById.get(answer.assignee_id)?.student_id === student.id);
const studentLogs = activityLogs.filter((log) => log._student_id === student.id);
const studentAssignments: StudentAssignment[] = assignees
.filter((assignee) => assignee.student_id === student.id)
.map((assignee) => {
const assignment = assignmentById.get(assignee.assignment_id);
if (!assignment) throw new Error(`Missing assignment ${assignee.assignment_id}`);
const assignmentAnswers = studentAnswers.filter((answer) => answer.assignee_id === assignee.id);
const answerCount = assignmentAnswers.length;
const correct = assignmentAnswers.filter((answer) => answer._is_correct).length;
return {
...assignee,
assignment,
answerCount,
accuracy: answerCount > 0 ? Math.round((correct / answerCount) * 100) : 0,
};
})
.sort((left, right) => left.assignment.due_date - right.assignment.due_date);
const totalCorrectAnswers = studentAnswers.filter((answer) => answer._is_correct).length;
const overallAccuracy = studentAnswers.length > 0 ? Math.round((totalCorrectAnswers / studentAnswers.length) * 100) : 0;
const latestActivityTime = studentLogs.length > 0 ? Math.max(...studentLogs.map((log) => log.timestamp)) : referenceTime;
const inProgressAssignments = studentAssignments.filter((assignment) => assignment.status === "IN_PROGRESS");
const notStartedAssignments = studentAssignments.filter((assignment) => assignment.status === "NOT_STARTED");
const pendingAssignments = studentAssignments.filter((assignment) => assignment.status !== "SUBMITTED");
const currentAssignment = inProgressAssignments[0] ?? notStartedAssignments[0] ?? studentAssignments[studentAssignments.length - 1];
const nextNotStartedAssignment = notStartedAssignments[0] ?? currentAssignment;
const topicBuckets = new Map<string, { correct: number; total: number }>();
for (const answer of studentAnswers) {
const bucket = topicBuckets.get(answer._question_topic) ?? { correct: 0, total: 0 };
bucket.correct += Number(answer._is_correct);
bucket.total += 1;
topicBuckets.set(answer._question_topic, bucket);
}
const topicPerformance = [...topicBuckets.entries()].map(([label, bucket]) => ({
label,
value: Math.round((bucket.correct / bucket.total) * 100),
total: bucket.total,
}));
const weakestTopic = [...topicPerformance].sort((left, right) => left.value - right.value || right.total - left.total)[0];
const assignmentShortName = (assignment: StudentAssignment | undefined) => assignment?.assignment.name.replace("HW", "H").split("—")[0].trim() ?? "Task";
export const classroomSummary: DashboardHomeShellData["classroomSummary"] = {
name: dataset.classroom.name,
targetLevel: dataset.classroom.target_level,
inviteCode: dataset.classroom.invite_code,
tutorName: dataset.tutor.fullname,
tutorRole: "Lead tutor",
tutorInitials: initialsFor(dataset.tutor.fullname),
};
export const sidebarLinks: DashboardHomeShellData["sidebarLinks"] = [
{ label: "Home", detail: "Today", icon: "⌂", href: getDashboardHomeHref("student"), active: true },
{ label: "Assignments", detail: `${pendingAssignments.length} live`, icon: "✓", href: getDashboardAssignmentsHref("student") },
{ label: "Progress", detail: `${overallAccuracy}% accuracy`, icon: "↗", href: getDashboardProgressHref() },
{ label: "Practice", detail: weakestTopic?.label ?? "Mixed skills", icon: "✦", href: getDashboardPracticeHref() },
{ label: "Messages", detail: dataset.tutor.fullname.split(" ")[0], icon: "✉", href: getDashboardMessagesHref("student") },
{ label: "Settings", detail: "Profile & goals", icon: "⋯", href: getDashboardSettingsHref("student") },
];
export const sidebarSupport: DashboardHomeShellData["sidebarSupport"] = {
avatars: [assignmentShortName(currentAssignment), weakestTopic?.label.slice(0, 2).toUpperCase() ?? "WK", assignmentShortName(nextNotStartedAssignment)],
title: "Todays study plan",
description: currentAssignment
? `Pick up ${assignmentShortName(currentAssignment)}, spend 15 minutes on ${weakestTopic?.label ?? "your focus topic"}, then start ${assignmentShortName(nextNotStartedAssignment)} while it still feels easy.`
: "Open your next task and build a short, focused study block around it.",
buttonLabel: "Open my plan",
buttonHref: currentAssignment ? getAssignmentWorkHref(currentAssignment.assignment.id) : getDashboardAssignmentsHref("student"),
};
export const topbarSummary: DashboardHomeShellData["topbarSummary"] = {
searchPlaceholder: "Search assignments, hints, or question topics",
profileName: student.fullname,
profileRole: `${dataset.classroom.name} · Student`,
profileBadge: initialsFor(student.fullname),
notificationCount: 0,
messageCount: 3,
};
export const topbarMessages: TopbarMessageItem[] = [
{
sender: dataset.tutor.fullname,
initials: initialsFor(dataset.tutor.fullname),
preview: `Lets spend the next session tightening up ${weakestTopic?.label?.toLowerCase() ?? "your focus topic"}. A couple of calm wins here should help a lot.`,
timestamp: "Today",
actionHref: getDashboardMessagesHref("student"),
},
{
sender: currentAssignment ? assignmentShortName(currentAssignment) : "Assignment check-in",
initials: currentAssignment ? assignmentShortName(currentAssignment) : "AC",
preview: currentAssignment
? currentAssignment.status === "SUBMITTED"
? "Nice work finishing this one. Open the review and tighten any missed marks."
: `${currentAssignment.answerCount} questions are already done. Finish it before ${formatDueDate(currentAssignment.assignment.due_date)} while it still feels familiar.`
: "Open your assignments to pick the next best task.",
timestamp: currentAssignment ? (currentAssignment.status === "SUBMITTED" ? "Yesterday" : `${daysUntil(currentAssignment.assignment.due_date)}d left`) : formatLastSeen(latestActivityTime),
actionHref: currentAssignment ? getAssignmentWorkHref(currentAssignment.assignment.id) : getDashboardAssignmentsHref("student"),
},
{
sender: "Study coach",
initials: "SC",
preview: `End today with one easier win after you practise ${weakestTopic?.label?.toLowerCase() ?? "your focus topic"}.`,
timestamp: formatLastSeen(latestActivityTime),
actionHref: getDashboardProgressHref(),
},
];

View File

@@ -23,7 +23,7 @@
}
}
@media (max-width: 519px) {
@include respond-max(mobile-narrow) {
.header a {
font-size: 0.85rem;
}
@@ -31,7 +31,7 @@
.progressCard,
.highlightCard {
border-radius: 1.35rem;
border-radius: var(--radius-2xl);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
}
@@ -42,11 +42,11 @@
padding: 1rem;
background: var(--surface-panel);
@media (min-width: 640px) {
@include respond(phablet) {
padding: 1.15rem;
}
@media (min-width: 960px) {
@include respond(desktop-sm) {
padding: 1.35rem;
}
}
@@ -76,7 +76,7 @@
.progressBadge {
padding: 0.45rem 0.7rem;
border-radius: 9999px;
border-radius: var(--radius-full);
background: var(--surface-info);
color: var(--info);
font-size: 0.85rem;
@@ -117,7 +117,7 @@
text-align: center;
}
@media (max-width: 519px) {
@include respond-max(mobile-narrow) {
.monthRow {
font-size: 0.72rem;
gap: 0.25rem;

View File

@@ -3,26 +3,27 @@
import type { Component } from "solid-js";
import { For } from "solid-js";
import { A } from "@solidjs/router";
import { getDashboardProgressHref } from "../../../lib/routes";
import styles from "./dashboard-activity.module.scss";
import { activitySummary, highlightCards, progressLabels, progressPoints } from "./dashboard.data";
import type { DashboardActivityData } from "../shared/dashboard-types";
const points = progressPoints.map((value, index) => `${index * 54},${120 - value}`).join(" ");
const DashboardActivity: Component<DashboardActivityData> = (props) => {
const points = () => props.progressPoints.map((value, index) => `${index * 54},${120 - value}`).join(" ");
const DashboardActivity: Component = () => {
return (
<section class={styles.section}>
<div class={styles.header}>
<h2>Your progress</h2>
<A href="/dashboard/progress">Open progress</A>
<A href={getDashboardProgressHref()}>Open progress</A>
</div>
<article class={styles.progressCard}>
<div class={styles.progressHeader}>
<div>
<h3>{activitySummary.title}</h3>
<p>{activitySummary.note}</p>
<h3>{props.activitySummary.title}</h3>
<p>{props.activitySummary.note}</p>
</div>
<div class={styles.progressBadge}>{activitySummary.badge}</div>
<div class={styles.progressBadge}>{props.activitySummary.badge}</div>
</div>
<svg viewBox="0 0 270 140" class={styles.chart} role="img" aria-label="My progress line chart">
@@ -34,17 +35,17 @@ const DashboardActivity: Component = () => {
</defs>
<line x1="0" y1="120" x2="270" y2="120" class={styles.axis} />
<polyline points={`0,120 ${points} 270,120`} class={styles.area} />
<polyline points={points} class={styles.line} />
<polyline points={`0,120 ${points()} 270,120`} class={styles.area} />
<polyline points={points()} class={styles.line} />
</svg>
<div class={styles.monthRow}>
<For each={progressLabels}>{(label) => <span>{label}</span>}</For>
<For each={props.progressLabels}>{(label) => <span>{label}</span>}</For>
</div>
</article>
<div class={styles.highlightGrid}>
<For each={highlightCards}>
<For each={props.highlightCards}>
{(card) => (
<article classList={{ [styles.highlightCard]: true, [styles[card.tone]]: true }}>
<strong>{card.value}</strong>

View File

@@ -2,7 +2,7 @@
display: grid;
gap: 1rem;
@media (min-width: 960px) {
@include respond(desktop-sm) {
gap: 1.25rem;
}
}
@@ -11,12 +11,12 @@
display: grid;
gap: 1rem;
padding: 1.15rem;
border-radius: 1.5rem;
border-radius: var(--radius-3xl);
background: linear-gradient(135deg, var(--surface-panel), var(--surface-panel-strong));
border: 1px solid var(--border-strong);
box-shadow: var(--shadow-soft);
@media (min-width: 880px) {
@include respond(wide) {
grid-template-columns: minmax(0, 1.2fr) minmax(16rem, 0.95fr);
align-items: center;
padding: 1.35rem;
@@ -56,7 +56,7 @@
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (min-width: 880px) {
@include respond(wide) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@@ -65,7 +65,7 @@
display: grid;
gap: 0.2rem;
padding: 0.95rem;
border-radius: 1.15rem;
border-radius: var(--radius-lg);
background: var(--surface-soft);
border: 1px solid var(--border-soft);
@@ -85,6 +85,15 @@
gap: 1rem;
}
.infoCard {
padding: 1rem 1.1rem;
border-radius: 1.1rem;
background: var(--surface-panel);
border: 1px solid var(--border-soft);
color: var(--text-muted);
box-shadow: var(--shadow-soft);
}
.group {
display: grid;
gap: 0.85rem;
@@ -117,7 +126,7 @@
min-width: 2.1rem;
height: 2.1rem;
padding: 0 0.7rem;
border-radius: 9999px;
border-radius: var(--radius-full);
background: var(--surface-info-emphasis);
color: var(--text-info-strong);
font-weight: 600;
@@ -137,7 +146,7 @@
display: grid;
gap: 0.9rem;
padding: 1rem;
border-radius: 1.3rem;
border-radius: var(--radius-2xl);
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
@@ -156,7 +165,7 @@
align-items: center;
justify-content: center;
padding: 0.42rem 0.68rem;
border-radius: 9999px;
border-radius: var(--radius-full);
font-size: 0.76rem;
font-weight: 600;
white-space: nowrap;
@@ -218,7 +227,7 @@
align-items: center;
justify-content: center;
padding: 0.8rem 1rem;
border-radius: 9999px;
border-radius: var(--radius-full);
font-weight: 500;
text-decoration: none;
transition:

View File

@@ -0,0 +1,94 @@
// Path: Frontend/src/components/dashboard/dashboard-assignments-focus.tsx
import { A } from "@solidjs/router";
import type { Component } from "solid-js";
import { For, Show, createResource, createSignal, onMount } from "solid-js";
import { dashboardUiCopy } from "~/content/ui-copy";
import { useAuth } from "~/context/auth/context";
import styles from "./dashboard-assignments-focus.module.scss";
import { getDashboardAssignmentsFocusData } from "./dashboard-assignments.data";
const DashboardAssignmentsFocus: Component = () => {
const auth = useAuth();
const [studentId, setStudentId] = createSignal<number | null>(null);
const [data] = createResource(studentId, getDashboardAssignmentsFocusData);
onMount(() => {
if (auth.user()?.role === "student") {
setStudentId(auth.user()!.id);
}
});
return (
<section class={styles.section}>
<article class={styles.heroCard}>
<div class={styles.heroCopy}>
<p class={styles.eyebrow}>{dashboardUiCopy.studentAssignmentsFocus.eyebrow}</p>
<h1>{dashboardUiCopy.studentAssignmentsFocus.title}</h1>
<p>{dashboardUiCopy.studentAssignmentsFocus.description}</p>
</div>
<div class={styles.statGrid}>
<For each={data()?.stats ?? []}>
{(stat) => (
<div class={styles.statCard}>
<strong>{stat.value}</strong>
<span>{stat.label}</span>
</div>
)}
</For>
</div>
</article>
<div class={styles.groupList}>
<Show when={!data.loading} fallback={<section class={styles.infoCard}>{dashboardUiCopy.studentAssignmentsFocus.loading}</section>}>
<Show when={data()?.groups.length} fallback={<section class={styles.infoCard}>{dashboardUiCopy.studentAssignmentsFocus.empty}</section>}>
<For each={data()?.groups ?? []}>
{(group) => (
<section class={styles.group}>
<div class={styles.groupHeader}>
<div>
<h2>{group.title}</h2>
<p>{group.description}</p>
</div>
<span class={styles.groupCount}>{group.items.length}</span>
</div>
<div class={styles.cardGrid}>
<For each={group.items}>
{(item) => (
<article class={styles.assignmentCard}>
<div class={styles.cardTop}>
<span classList={{ [styles.statusChip]: true, [styles[item.tone]]: true }}>{item.statusLabel}</span>
<span class={styles.progressText}>{item.progressText}</span>
</div>
<div class={styles.cardBody}>
<h3>{item.title}</h3>
<p>{item.meta}</p>
<small>{item.subMeta}</small>
</div>
<div class={styles.cardActions}>
<A href={item.primaryHref} class={styles.primaryAction}>
{item.primaryLabel}
</A>
<A href={item.secondaryHref} class={styles.secondaryAction}>
{item.secondaryLabel}
</A>
</div>
</article>
)}
</For>
</div>
</section>
)}
</For>
</Show>
</Show>
</div>
</section>
);
};
export default DashboardAssignmentsFocus;

View File

@@ -0,0 +1,169 @@
import { apiFetchJson } from "../../../lib/api";
import type { ApiAssignment, ApiAssignmentStudentQuestionDetail, ApiListResponse } from "../../../lib/api-types";
import { studentDashboardLabels } from "../../../content/dashboard-labels";
import { getAssignmentReviewHref, getAssignmentWorkHref } from "../../../lib/routes";
type AssignmentFocusStat = {
label: string;
value: string;
};
type AssignmentFocusItem = {
title: string;
meta: string;
subMeta: string;
statusLabel: string;
progressText: string;
tone: "yellow" | "teal" | "blue";
primaryLabel: string;
primaryHref: string;
secondaryLabel: string;
secondaryHref: string;
};
type AssignmentFocusGroup = {
title: string;
description: string;
items: AssignmentFocusItem[];
};
type DashboardAssignmentsFocusData = {
stats: AssignmentFocusStat[];
groups: AssignmentFocusGroup[];
};
const formatDueDate = (value: string | null) => {
if (!value) return "No due date";
return new Intl.DateTimeFormat("en-GB", {
month: "short",
day: "numeric",
}).format(new Date(value));
};
const extractTopic = (assignment: ApiAssignment, questions: ApiAssignmentStudentQuestionDetail[]) => {
const fromInstructions = assignment.instructions?.match(/^Topic:\s*(.+)$/i)?.[1]?.trim();
if (fromInstructions) return fromInstructions;
return questions[0]?.subject ?? "Assignment";
};
const deriveStatus = (assignment: ApiAssignment, questions: ApiAssignmentStudentQuestionDetail[]) => {
const total = questions.length;
const answered = questions.filter((question) => question.answer_id).length;
const reviewed = questions.filter((question) => question.answer_status === "reviewed").length;
if (answered === 0) return "NOT_STARTED" as const;
if (reviewed === total || assignment.status === "closed") return "SUBMITTED" as const;
return "IN_PROGRESS" as const;
};
const mapAssignmentItem = (assignment: ApiAssignment, questions: ApiAssignmentStudentQuestionDetail[]): AssignmentFocusItem => {
const derivedStatus = deriveStatus(assignment, questions);
const answered = questions.filter((question) => question.answer_id).length;
const reviewed = questions.filter((question) => question.answer_status === "reviewed").length;
const topic = extractTopic(assignment, questions);
if (derivedStatus === "SUBMITTED") {
return {
title: assignment.title,
meta: topic,
subMeta: `${reviewed}/${questions.length} reviewed · Due ${formatDueDate(assignment.due_at)}`,
statusLabel: "Completed",
progressText: `${reviewed}/${questions.length} reviewed`,
tone: "teal",
primaryLabel: "Open review",
primaryHref: getAssignmentReviewHref("student", assignment.id),
secondaryLabel: "Open workspace",
secondaryHref: getAssignmentWorkHref(assignment.id),
};
}
if (derivedStatus === "IN_PROGRESS") {
return {
title: assignment.title,
meta: topic,
subMeta: `${answered}/${questions.length} answered · Due ${formatDueDate(assignment.due_at)}`,
statusLabel: "In progress",
progressText: `${answered}/${questions.length} answered`,
tone: "blue",
primaryLabel: "Resume work",
primaryHref: getAssignmentWorkHref(assignment.id),
secondaryLabel: "See review",
secondaryHref: getAssignmentReviewHref("student", assignment.id),
};
}
return {
title: assignment.title,
meta: topic,
subMeta: `Due ${formatDueDate(assignment.due_at)} · Ready when you are`,
statusLabel: "Ready to start",
progressText: `${questions.length} questions`,
tone: "yellow",
primaryLabel: "Start assignment",
primaryHref: getAssignmentWorkHref(assignment.id),
secondaryLabel: "See review",
secondaryHref: getAssignmentReviewHref("student", assignment.id),
};
};
export const getDashboardAssignmentsFocusData = async (studentId: number): Promise<DashboardAssignmentsFocusData> => {
const assignmentsResponse = await apiFetchJson<ApiListResponse<ApiAssignment>>(`/api/students/${studentId}/assignments`);
const assignmentDetails = await Promise.all(
assignmentsResponse.data.map(async (assignment) => ({
assignment,
questions: (await apiFetchJson<ApiListResponse<ApiAssignmentStudentQuestionDetail>>(`/api/assignments/${assignment.id}/students/${studentId}/questions`)).data,
})),
);
const items = assignmentDetails.map(({ assignment, questions }) => ({
assignment,
questions,
item: mapAssignmentItem(assignment, questions),
derivedStatus: deriveStatus(assignment, questions),
}));
const completed = items.filter((entry) => entry.derivedStatus === "SUBMITTED");
const inProgress = items.filter((entry) => entry.derivedStatus === "IN_PROGRESS");
const notStarted = items.filter((entry) => entry.derivedStatus === "NOT_STARTED");
const liveItems = items.filter((entry) => entry.derivedStatus !== "SUBMITTED");
const nextDue = liveItems
.map((entry) => entry.assignment.due_at)
.filter((value): value is string => Boolean(value))
.sort((left, right) => new Date(left).getTime() - new Date(right).getTime())[0] ?? null;
const completionPercentages = assignmentDetails.map(({ assignment, questions }) => {
const reviewed = questions.filter((question) => question.answer_status === "reviewed").length;
if (assignment.status === "closed") return 100;
if (questions.length === 0) return 0;
return Math.round((reviewed / questions.length) * 100);
});
const averageCompletion = completionPercentages.length
? Math.round(completionPercentages.reduce((sum, value) => sum + value, 0) / completionPercentages.length)
: 0;
return {
stats: [
{ label: studentDashboardLabels.assignments.liveNow, value: `${inProgress.length + notStarted.length}` },
{ label: studentDashboardLabels.assignments.completed, value: `${completed.length}` },
{ label: studentDashboardLabels.assignments.averageReviewed, value: `${averageCompletion}%` },
{ label: studentDashboardLabels.assignments.nextDue, value: formatDueDate(nextDue) },
],
groups: [
{
title: "Continue now",
description: "Assignments that already have momentum and are best finished next.",
items: inProgress.map((entry) => entry.item),
},
{
title: "Coming up",
description: "Work that is live but not started yet, so you can choose what to begin early.",
items: notStarted.map((entry) => entry.item),
},
{
title: "Completed",
description: "Reviewed work you can revisit for correction and confidence boosts.",
items: completed.map((entry) => entry.item),
},
].filter((group) => group.items.length > 0),
};
};

View File

@@ -31,15 +31,20 @@
.courseCard {
display: grid;
grid-template-columns: auto 1fr;
grid-template-areas:
"badge body"
"cta cta";
gap: 0.85rem;
padding: 0.95rem;
border-radius: 1.25rem;
border-radius: var(--radius-xl);
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
min-width: 0;
@media (min-width: 720px) {
@include respond(compact) {
grid-template-columns: auto 1fr auto;
grid-template-areas: "badge body cta";
align-items: center;
}
@@ -49,7 +54,7 @@
justify-content: center;
width: 100%;
padding: 0.68rem 0.95rem;
border-radius: 9999px;
border-radius: var(--radius-full);
background: var(--surface-info);
color: var(--info);
font-weight: 500;
@@ -58,6 +63,7 @@
transform 0.2s ease,
background-color 0.2s ease;
text-decoration: none;
grid-area: cta;
&:hover {
transform: translateY(-1px);
@@ -67,7 +73,7 @@
}
}
@media (min-width: 720px) {
@include respond(compact) {
.courseCard .ctaLink {
width: auto;
}
@@ -78,8 +84,9 @@
height: 2.8rem;
display: grid;
place-items: center;
border-radius: 0.9rem;
border-radius: var(--radius-sm);
font-weight: 600;
grid-area: badge;
}
.yellow { background: var(--yellow-200); color: var(--yellow-500); }
@@ -90,14 +97,19 @@
.courseBody {
display: grid;
gap: 0.25rem;
grid-area: body;
min-width: 0;
h3 {
font-size: 1rem;
font-weight: 500;
line-height: 1.15;
overflow-wrap: anywhere;
}
p {
font-size: 0.9rem;
color: var(--text-muted);
overflow-wrap: anywhere;
}
}

View File

@@ -1,22 +1,27 @@
import type { Component } from "solid-js";
import { For } from "solid-js";
import { A } from "@solidjs/router";
import { assignmentCards } from "./dashboard.data";
import { getDashboardAssignmentsHref } from "../../../lib/routes";
import type { AssignmentCard } from "../shared/dashboard-types";
import styles from "./dashboard-courses.module.scss";
const DashboardCourses: Component = () => {
type DashboardCoursesProps = {
assignmentCards: AssignmentCard[];
};
const DashboardCourses: Component<DashboardCoursesProps> = (props) => {
return (
<section class={styles.section}>
<div class={styles.header}>
<h2>Keep going</h2>
<A href="/dashboard/assignments">See all</A>
<A href={getDashboardAssignmentsHref("student")}>See all</A>
</div>
<div class={styles.courseList}>
<For each={assignmentCards}>
<For each={props.assignmentCards}>
{(assignment) => (
<article class={styles.courseCard}>
<div classList={{ [styles.badge]: true, [styles[assignment.accent]]: true }}>{assignment.title.split("—")[0].trim().replace("HW", "H")}</div>
<div classList={{ [styles.badge]: true, [styles[assignment.accent]]: true }}>{assignment.badge}</div>
<div class={styles.courseBody}>
<h3>{assignment.title}</h3>
<p>{assignment.lessons}</p>

View File

@@ -2,11 +2,11 @@
display: grid;
gap: 1rem;
@media (min-width: 960px) {
@include respond(desktop-sm) {
gap: 1.25rem;
}
@media (min-width: 1120px) {
@include respond(desktop-lg) {
grid-template-columns: minmax(0, 1.6fr) minmax(18rem, 0.75fr);
}
}
@@ -22,16 +22,16 @@
display: grid;
gap: 1.25rem;
padding: 1.15rem;
border-radius: 1.5rem;
border-radius: var(--radius-3xl);
background: linear-gradient(135deg, var(--surface-hero-start), var(--surface-hero-end));
color: var(--text-on-accent);
overflow: hidden;
@media (min-width: 640px) {
@include respond(phablet) {
padding: 1.4rem;
}
@media (min-width: 960px) {
@include respond(desktop-sm) {
gap: 1.6rem;
padding: 1.75rem;
}
@@ -59,7 +59,7 @@
font-size: 0.94rem;
color: var(--text-on-accent-muted);
@media (min-width: 640px) {
@include respond(phablet) {
font-size: 1rem;
}
}
@@ -84,7 +84,7 @@
align-items: center;
gap: 0.7rem;
padding: 0.62rem 0.8rem;
border-radius: 9999px;
border-radius: var(--radius-full);
background: var(--surface-overlay-soft);
backdrop-filter: blur(8px);
border: 1px solid var(--border-overlay);
@@ -110,7 +110,7 @@
height: 2rem;
display: grid;
place-items: center;
border-radius: 9999px;
border-radius: var(--radius-full);
font-weight: 700;
color: var(--text-accent-strong);
}
@@ -136,11 +136,11 @@
display: grid;
place-items: center;
@media (min-width: 640px) {
@include respond(phablet) {
min-height: 12rem;
}
@media (min-width: 960px) {
@include respond(desktop-sm) {
min-height: 13rem;
}
}
@@ -155,7 +155,7 @@
.visualCard {
position: absolute;
padding: 0.5rem 0.72rem;
border-radius: 9999px;
border-radius: var(--radius-full);
background: var(--surface-overlay-strong);
color: var(--text-on-accent);
font-weight: 600;
@@ -182,14 +182,14 @@
display: grid;
gap: 0.85rem;
padding: 1rem;
border-radius: 1.5rem;
border-radius: var(--radius-3xl);
background: var(--surface-panel);
@media (min-width: 640px) {
@include respond(phablet) {
padding: 1.15rem;
}
@media (min-width: 960px) {
@include respond(desktop-sm) {
gap: 1rem;
padding: 1.35rem;
}
@@ -213,7 +213,7 @@
justify-content: center;
padding: 0.82rem 0.95rem;
border: none;
border-radius: 1rem;
border-radius: var(--radius-md);
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
color: var(--action-primary-text);
font-weight: 600;

View File

@@ -1,20 +1,22 @@
import type { Component } from "solid-js";
import { For } from "solid-js";
import { A } from "@solidjs/router";
import { heroSideCard, heroSummary, quickStats, spotlightStats } from "./dashboard.data";
import type { DashboardHeroData } from "../shared/dashboard-types";
import styles from "./dashboard-hero.module.scss";
const DashboardHero: Component = () => {
type DashboardHeroProps = DashboardHeroData;
const DashboardHero: Component<DashboardHeroProps> = (props) => {
return (
<section class={styles.heroGrid}>
<div class={styles.heroCard}>
<div class={styles.heroCopy}>
<p class={styles.eyebrow}>{heroSummary.eyebrow}</p>
<h1>{heroSummary.title}</h1>
<p>{heroSummary.description}</p>
<p class={styles.eyebrow}>{props.heroSummary.eyebrow}</p>
<h1>{props.heroSummary.title}</h1>
<p>{props.heroSummary.description}</p>
<div class={styles.metricRow}>
<For each={spotlightStats}>
<For each={props.spotlightStats}>
{(stat) => (
<div class={styles.metricPill}>
<span classList={{ [styles.metricIcon]: true, [styles[stat.tone]]: true }}>{stat.label.slice(0, 1)}</span>
@@ -30,19 +32,19 @@ const DashboardHero: Component = () => {
<div class={styles.heroVisual} aria-hidden="true">
<div class={styles.orbit}></div>
<For each={heroSummary.visualBadges}>{(badge) => <div class={styles.visualCard}>{badge}</div>}</For>
<For each={props.heroSummary.visualBadges}>{(badge) => <div class={styles.visualCard}>{badge}</div>}</For>
</div>
</div>
<div class={styles.sideCard}>
<h2>{heroSideCard.title}</h2>
<p>{heroSideCard.description}</p>
<A href={heroSideCard.buttonHref} class={styles.sideCardAction}>
{heroSideCard.buttonLabel}
<h2>{props.heroSideCard.title}</h2>
<p>{props.heroSideCard.description}</p>
<A href={props.heroSideCard.buttonHref} class={styles.sideCardAction}>
{props.heroSideCard.buttonLabel}
</A>
<div class={styles.quickStats}>
<For each={quickStats}>
<For each={props.quickStats}>
{(stat) => (
<div class={styles.statCard}>
<p>{stat.label}</p>

View File

@@ -0,0 +1,321 @@
import {
getAssignmentReviewHref,
getAssignmentWorkHref,
getDashboardAssignmentsHref,
getDashboardMessagesHref,
getDashboardPracticeHref,
getDashboardProgressHref,
getDashboardSettingsHref,
getDashboardHomeHref,
} from "../../../lib/routes";
import { apiFetchJson } from "../../../lib/api";
import type { ApiAssignment, ApiAssignmentStudentQuestionDetail, ApiClassroom, ApiListResponse, ApiUser } from "../../../lib/api-types";
import { studentDashboardLabels } from "../../../content/dashboard-labels";
import type { DashboardHomeData } from "../shared/dashboard-types";
import {
buildAssignmentCards,
buildDerivedAssignments,
buildProgressSeries,
buildTopicAggregates,
buildUsageItems,
daysUntil,
formatAssignmentBadge,
formatAssignmentShortTitle,
formatDueCountdown,
formatRelativeTime,
initialsFor,
masteryTones,
sortByDueDate,
toneAt,
} from "./dashboard-home.helpers";
export const getDashboardHomeData = async (studentId: number): Promise<DashboardHomeData> => {
const assignmentsResponse = await apiFetchJson<ApiListResponse<ApiAssignment>>(`/api/students/${studentId}/assignments`);
const assignments = assignmentsResponse.data;
const questionEntries = await Promise.all(
assignments.map(async (assignment) => ({
assignmentId: assignment.id,
questions: (await apiFetchJson<ApiListResponse<ApiAssignmentStudentQuestionDetail>>(`/api/assignments/${assignment.id}/students/${studentId}/questions`)).data,
})),
);
const questionsByAssignment = new Map(questionEntries.map((entry) => [entry.assignmentId, entry.questions]));
const derivedAssignments = buildDerivedAssignments(assignments, questionsByAssignment);
const activeAssignments = derivedAssignments.filter((assignment) => assignment.status !== "SUBMITTED").sort(sortByDueDate);
const completedAssignments = derivedAssignments.filter((assignment) => assignment.status === "SUBMITTED").sort(sortByDueDate);
const currentAssignment = activeAssignments.find((assignment) => assignment.status === "IN_PROGRESS") ?? activeAssignments[0] ?? completedAssignments[0] ?? null;
const nextDueAssignment = activeAssignments.find((assignment) => Boolean(assignment.assignment.due_at)) ?? null;
const nextAssignment = activeAssignments.find((assignment) => assignment.assignment.id !== currentAssignment?.assignment.id) ?? null;
const topicAggregates = buildTopicAggregates(derivedAssignments);
const weakestTopic = topicAggregates[0] ?? null;
const strongestTopic = topicAggregates.slice().sort((left, right) => right.completion - left.completion || right.total - left.total)[0] ?? null;
const latestTouch =
derivedAssignments
.map((assignment) => assignment.lastTouchedAt)
.filter((value): value is string => Boolean(value))
.sort((left, right) => new Date(right).getTime() - new Date(left).getTime())[0] ?? null;
const totalQuestions = derivedAssignments.reduce((sum, assignment) => sum + assignment.questions.length, 0);
const totalAnswered = derivedAssignments.reduce((sum, assignment) => sum + assignment.answeredCount, 0);
const totalReviewed = derivedAssignments.reduce((sum, assignment) => sum + assignment.reviewedCount, 0);
const overallProgress = totalQuestions > 0 ? Math.round((totalReviewed / totalQuestions) * 100) : 0;
const answerCoverage = totalQuestions > 0 ? Math.round((totalAnswered / totalQuestions) * 100) : 0;
const remainingCount = totalQuestions - totalAnswered;
const inProgressCount = derivedAssignments.filter((assignment) => assignment.status === "IN_PROGRESS").length;
const notStartedCount = derivedAssignments.filter((assignment) => assignment.status === "NOT_STARTED").length;
let teacher: ApiUser | null = null;
let classroom: ApiClassroom | null = null;
if (currentAssignment?.assignment.teacher_id) {
teacher = await apiFetchJson<ApiUser>(`/api/users/${currentAssignment.assignment.teacher_id}`);
const classroomsResponse = await apiFetchJson<ApiListResponse<ApiClassroom>>(`/api/teachers/${currentAssignment.assignment.teacher_id}/classrooms`);
classroom = classroomsResponse.data.find((entry) => entry.id === currentAssignment.assignment.classroom_id) ?? classroomsResponse.data[0] ?? null;
}
const firstName = teacher ? teacher.full_name.split(" ")[0] : "Coach";
const studentName = teacher?.full_name ? undefined : undefined;
const progressSeries = buildProgressSeries(derivedAssignments);
const currentAssignmentTitle = currentAssignment?.assignment.title ?? "your next assignment";
const currentAssignmentShort = currentAssignment ? formatAssignmentShortTitle(currentAssignment.assignment.title, "your next task") : "your next task";
const nextAssignmentShort = nextAssignment ? formatAssignmentShortTitle(nextAssignment.assignment.title, "another task") : "another task";
const currentAssignmentBadge = currentAssignment ? formatAssignmentBadge(currentAssignment.assignment.title) : "NT";
const nextAssignmentBadge = nextAssignment ? formatAssignmentBadge(nextAssignment.assignment.title) : "AT";
const currentActionHref = currentAssignment
? currentAssignment.status === "SUBMITTED"
? getAssignmentReviewHref("student", currentAssignment.assignment.id)
: getAssignmentWorkHref(currentAssignment.assignment.id)
: getDashboardAssignmentsHref("student");
const currentDueDays = currentAssignment && currentAssignment.status !== "SUBMITTED" && currentAssignment.assignment.due_at ? daysUntil(currentAssignment.assignment.due_at) : null;
const nextDueDays = nextDueAssignment?.assignment.due_at ? daysUntil(nextDueAssignment.assignment.due_at) : null;
const usageItems = buildUsageItems(totalQuestions, totalReviewed, totalAnswered, remainingCount);
const tutorName = teacher?.full_name ?? "Your tutor";
const tutorInitials = initialsFor(tutorName);
const studentFirstName = "Learner";
return {
shell: {
classroomSummary: {
name: classroom?.name ?? "Live dashboard",
targetLevel: undefined,
inviteCode: classroom?.code ?? undefined,
tutorName,
tutorRole: teacher ? "Lead tutor" : "Learning support",
tutorInitials,
},
sidebarLinks: [
{ label: "Home", detail: "Today", icon: "⌂", href: getDashboardHomeHref("student"), active: true },
{ label: "Assignments", detail: `${inProgressCount + notStartedCount} live`, icon: "✓", href: getDashboardAssignmentsHref("student") },
{ label: "Progress", detail: `${overallProgress}% reviewed`, icon: "↗", href: getDashboardProgressHref() },
{ label: "Practice", detail: weakestTopic?.label ?? "Mixed skills", icon: "✦", href: getDashboardPracticeHref() },
{ label: "Messages", detail: tutorName.split(" ")[0], icon: "✉", href: getDashboardMessagesHref("student") },
{ label: "Settings", detail: "Profile & goals", icon: "⋯", href: getDashboardSettingsHref("student") },
],
sidebarSupport: {
avatars: [currentAssignmentBadge, (weakestTopic?.label ?? "Focus").slice(0, 2).toUpperCase(), nextAssignmentBadge],
title: "Todays study plan",
description: currentAssignment
? `Pick up ${currentAssignmentShort}, spend 15 minutes on ${weakestTopic?.label ?? "your current topic"}, then move into ${nextAssignmentShort} while the flow is still there.`
: "Start with your assignment list, pick one live task, and use the dashboard to stay in a simple study rhythm.",
buttonLabel: currentAssignment ? "Open my plan" : "See assignments",
buttonHref: currentAssignment ? currentActionHref : getDashboardAssignmentsHref("student"),
},
topbarSummary: {
searchPlaceholder: "Search assignments, hints, or question topics",
profileName: `Student ${studentId}`,
profileRole: classroom ? `${classroom.name} · Student` : "Student dashboard",
profileBadge: tutorInitials,
notificationCount: 3,
messageCount: 3,
},
topbarNotifications: [
{
title: currentAssignment ? `${currentAssignmentShort} is your live priority` : "No live assignment yet",
description: currentAssignment
? currentAssignment.status === "SUBMITTED"
? `You already finished it. Open the review and tighten any marked answers.`
: `${currentAssignment.answeredCount}/${currentAssignment.questions.length || 0} questions touched so far. Keep the momentum going.`
: "Open your assignments list to start the next live task.",
timestamp: currentDueDays !== null ? formatDueCountdown(currentDueDays, "labelled") : "Today",
href: currentAssignment ? currentActionHref : getDashboardAssignmentsHref("student"),
tone: "blue",
},
{
title: `${weakestTopic?.label ?? "Your focus topic"} needs a short pass`,
description: weakestTopic
? `${weakestTopic.reviewed}/${weakestTopic.total} questions are fully reviewed here. A short focused block will lift your coverage fastest.`
: "Use a short practice block to warm up before you dive into assignment work.",
timestamp: "Practice now",
href: getDashboardPracticeHref(),
tone: "yellow",
},
{
title: `${tutorName.split(" ")[0]} has your next step ready`,
description: currentAssignment
? `Use ${currentAssignmentShort} as your anchor task, then finish on ${strongestTopic?.label ?? "a stronger topic"} for confidence.`
: "Check your messages for study guidance and quick next-step prompts.",
timestamp: "Today",
href: getDashboardMessagesHref("student"),
tone: "teal",
},
],
topbarMessages: [
{
sender: tutorName,
initials: tutorInitials,
preview: weakestTopic
? `Lets steady ${weakestTopic.label.toLowerCase()} first, then return to ${currentAssignmentShort}.`
: "Pick one live task and stay with it until you finish a clear chunk.",
timestamp: "Today",
actionHref: getDashboardMessagesHref("student"),
},
{
sender: "Assignment check-in",
initials: currentAssignmentBadge,
preview: currentAssignment
? currentAssignment.status === "SUBMITTED"
? `Nice work finishing ${currentAssignmentTitle}. Revisit the review before moving on.`
: `${currentAssignment.answeredCount}/${currentAssignment.questions.length || 0} questions are already moving. Resume while it is still fresh.`
: "No assignment is active yet. Start with the next live task in your list.",
timestamp: currentDueDays !== null ? formatDueCountdown(currentDueDays, "labelled") : "Soon",
actionHref: currentAssignment ? currentActionHref : getDashboardAssignmentsHref("student"),
},
{
sender: "Study coach",
initials: "SC",
preview: strongestTopic
? `Finish today with ${strongestTopic.label.toLowerCase()} after you steady ${weakestTopic?.label?.toLowerCase() ?? "your main topic"}.`
: "Finish the session with one easier win after your main assignment push.",
timestamp: formatRelativeTime(latestTouch),
actionHref: getDashboardProgressHref(),
},
],
},
hero: {
heroSummary: {
eyebrow: `Welcome back`,
title: currentAssignment
? currentAssignment.status === "SUBMITTED"
? `Nice work finishing ${currentAssignmentShort}`
: `Lets keep ${currentAssignmentShort} moving`
: "Your live dashboard is ready",
description: currentAssignment
? `You have ${completedAssignments.length} finished assignments so far. Right now, the clearest next step is to keep ${currentAssignmentShort} moving and tighten ${weakestTopic?.label ?? "your current focus"}.`
: "Your dashboard home is now loading from the live backend. Start with one assignment and build momentum from there.",
visualBadges: [
`${overallProgress}% reviewed`,
currentAssignment ? `${currentAssignmentShort} live` : "Live backend",
weakestTopic ? `${weakestTopic.label} focus` : "Focus next",
],
},
spotlightStats: [
{ label: studentDashboardLabels.spotlight.assignmentsDone, value: `${completedAssignments.length}/${derivedAssignments.length}`, tone: "purple" },
{ label: studentDashboardLabels.spotlight.currentFocus, value: weakestTopic?.label ?? "Mixed practice", tone: "yellow" },
{ label: studentDashboardLabels.spotlight.bestTopic, value: strongestTopic?.label ?? "Building", tone: "blue" },
],
heroSideCard: {
title: currentAssignment
? currentAssignment.status === "SUBMITTED"
? `Review ${currentAssignmentShort}`
: `Pick up where you left off`
: "Open your assignment hub",
description: currentAssignment
? currentAssignment.status === "SUBMITTED"
? `${currentAssignment.reviewedCount}/${currentAssignment.questions.length || 0} questions are fully reviewed. A quick recap now will make the next task easier.`
: `${currentAssignment.answeredCount}/${currentAssignment.questions.length || 0} questions are already done. Finish it while the method is still fresh.`
: "No active assignment is selected yet. Head to your assignments list and start the next live task.",
buttonLabel: currentAssignment ? (currentAssignment.status === "SUBMITTED" ? "Open review" : "Resume assignment") : "See assignments",
buttonHref: currentAssignment ? currentActionHref : getDashboardAssignmentsHref("student"),
},
quickStats: [
{ label: studentDashboardLabels.quickStats.nextDue, value: formatDueCountdown(nextDueDays) },
{ label: studentDashboardLabels.quickStats.latestUpdate, value: formatRelativeTime(latestTouch) },
],
},
assignmentCards: buildAssignmentCards(derivedAssignments).length
? buildAssignmentCards(derivedAssignments)
: [
{
title: "Start your first live assignment",
badge: formatAssignmentBadge("Start your first live assignment"),
lessons: "Your dashboard is connected to the backend and ready for real work.",
accent: "blue",
cta: "Open assignments",
href: getDashboardAssignmentsHref("student"),
},
],
activity: {
activitySummary: {
title: `${overallProgress}% reviewed so far`,
note: currentAssignment
? `${answerCoverage}% of questions have at least one answer. Use ${currentAssignmentShort} as the next push.`
: "As you work through live assignments, your progress trend will update here from backend data.",
badge: currentAssignment ? `${currentAssignment.answeredCount}/${currentAssignment.questions.length || 0} current` : "Live",
},
progressLabels: progressSeries.labels,
progressPoints: progressSeries.points,
highlightCards: [
{
value: `${completedAssignments.length}`,
label: studentDashboardLabels.activity.completed,
note: "Assignments fully reviewed and ready to revisit.",
tone: "yellow",
},
{
value: `${answerCoverage}%`,
label: studentDashboardLabels.activity.answerCoverage,
note: `${totalAnswered}/${totalQuestions || 0} questions have active work on them.`,
tone: "pink",
},
],
},
studentSupportList: [
{
name: `Tighten ${weakestTopic?.label ?? "your focus topic"}`,
meta: weakestTopic
? `${weakestTopic.reviewed}/${weakestTopic.total} reviewed so far · quickest lift`
: "Use one short focused block before the main assignment push.",
initials: (weakestTopic?.label ?? "Focus").slice(0, 2).toUpperCase(),
actionLabel: "Open practice",
href: getDashboardPracticeHref(),
},
{
name: currentAssignment ? `Finish ${currentAssignmentShort}` : "Start the next task",
meta: currentAssignment
? `${currentAssignment.answeredCount}/${currentAssignment.questions.length || 0} answered · best next move`
: "Choose one live assignment and make the first small step.",
initials: currentAssignmentBadge,
actionLabel: currentAssignment ? "Resume work" : "See assignments",
href: currentAssignment ? currentActionHref : getDashboardAssignmentsHref("student"),
},
{
name: `Finish on ${strongestTopic?.label ?? "a stronger topic"}`,
meta: strongestTopic
? `${strongestTopic.completion}% reviewed here · good confidence finish`
: "End the session with one easier win after the hard part.",
initials: (strongestTopic?.label ?? "Win").slice(0, 2).toUpperCase(),
actionLabel: "View progress",
href: getDashboardProgressHref(),
},
],
insights: {
masteryTitle: "My topic coverage",
overallTitle: "My review progress",
overallLabel: studentDashboardLabels.insights.reviewed,
usageTitle: "Question status mix",
overallPassRate: overallProgress,
topicMasteryBars: (topicAggregates.length ? topicAggregates : [{ label: "Getting started", total: 1, answered: 0, reviewed: 0, completion: 0 }]).slice(0, 5).map((topic, index) => ({
label: topic.label,
value: topic.completion,
tone: toneAt(masteryTones, index),
})),
solveModeUsage: usageItems,
usageSummary: {
note: currentAssignment
? `${remainingCount} questions still need first-pass answers. Finish ${currentAssignmentShort}, then revisit reviewed work for a cleaner close.`
: "As backend work comes in, this panel will show how much of your live question load is answered and fully reviewed.",
},
},
};
};

View File

@@ -0,0 +1,232 @@
import { getAssignmentReviewHref, getAssignmentWorkHref } from "../../../lib/routes";
import type { ApiAssignment, ApiAssignmentStudentQuestionDetail } from "../../../lib/api-types";
import { studentDashboardLabels } from "../../../content/dashboard-labels";
import type { AccentTone, AssignmentCard, PerformanceTone, UsageItem } from "../shared/dashboard-types";
type DerivedAssignment = {
assignment: ApiAssignment;
questions: ApiAssignmentStudentQuestionDetail[];
status: "NOT_STARTED" | "IN_PROGRESS" | "SUBMITTED";
answeredCount: number;
reviewedCount: number;
topic: string;
lastTouchedAt: string | null;
};
type TopicAggregate = {
label: string;
total: number;
answered: number;
reviewed: number;
completion: number;
};
const formatShortDate = (value: string | null) => {
if (!value) return "No due date";
return new Intl.DateTimeFormat("en-GB", {
month: "short",
day: "numeric",
}).format(new Date(value));
};
export const initialsFor = (name: string) =>
name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase();
export const formatAssignmentBadge = (title: string) => {
const leadSegment = title.split(/[—-]/)[0]?.trim() ?? title.trim();
const tokens = leadSegment.match(/[A-Za-z]+|\d+/g) ?? [];
if (tokens.length === 0) return "HW";
const firstWord = tokens.find((token) => /^[A-Za-z]+$/.test(token));
const firstNumber = tokens.find((token) => /^\d+$/.test(token));
if (firstWord && firstNumber) {
return `${firstWord[0]?.toUpperCase() ?? "H"}${firstNumber}`;
}
if (tokens.length === 1) {
return tokens[0]!.slice(0, 2).toUpperCase();
}
return tokens
.slice(0, 2)
.map((token) => (/^\d+$/.test(token) ? token : token[0]?.toUpperCase() ?? ""))
.join("")
.toUpperCase();
};
export const formatAssignmentShortTitle = (title: string, fallback = "Task") => {
const leadSegment = title.split(/[—-]/)[0]?.trim() ?? title.trim();
return leadSegment || fallback;
};
export const formatRelativeTime = (value: string | null) => {
if (!value) return "No recent updates";
const deltaMs = Date.now() - new Date(value).getTime();
const deltaMinutes = Math.max(0, Math.round(deltaMs / (1000 * 60)));
if (deltaMinutes < 60) return `${deltaMinutes || 1}m ago`;
const deltaHours = Math.round(deltaMinutes / 60);
if (deltaHours < 24) return `${deltaHours}h ago`;
const deltaDays = Math.round(deltaHours / 24);
if (deltaDays < 7) return `${deltaDays}d ago`;
return formatShortDate(value);
};
export const daysUntil = (value: string | null) => {
if (!value) return null;
const msPerDay = 1000 * 60 * 60 * 24;
return Math.ceil((new Date(value).getTime() - Date.now()) / msPerDay);
};
export const formatDueCountdown = (days: number | null, mode: "compact" | "labelled" = "compact") => {
if (days === null) return studentDashboardLabels.quickStats.noDeadline;
if (days < 0) {
return mode === "labelled" ? `${Math.abs(days)}d ${studentDashboardLabels.quickStats.overdue.toLowerCase()}` : studentDashboardLabels.quickStats.overdue;
}
if (days === 0) return studentDashboardLabels.quickStats.dueToday;
return mode === "labelled" ? `${days}d left` : `${days}d`;
};
const extractTopic = (assignment: ApiAssignment, questions: ApiAssignmentStudentQuestionDetail[]) => {
const fromInstructions = assignment.instructions?.match(/^Topic:\s*(.+)$/im)?.[1]?.trim();
if (fromInstructions) return fromInstructions;
return questions[0]?.subject ?? "Assignment";
};
const deriveAssignmentStatus = (assignment: ApiAssignment, questions: ApiAssignmentStudentQuestionDetail[]) => {
const total = questions.length;
const answered = questions.filter((question) => question.answer_id).length;
const reviewed = questions.filter((question) => question.answer_status === "reviewed").length;
if (answered === 0) return "NOT_STARTED" as const;
if ((total > 0 && reviewed === total) || assignment.status === "closed") return "SUBMITTED" as const;
return "IN_PROGRESS" as const;
};
export const toneAt = <T extends string>(tones: T[], index: number) => tones[index % tones.length];
export const sortByDueDate = (left: DerivedAssignment, right: DerivedAssignment) => {
const leftTime = left.assignment.due_at ? new Date(left.assignment.due_at).getTime() : Number.POSITIVE_INFINITY;
const rightTime = right.assignment.due_at ? new Date(right.assignment.due_at).getTime() : Number.POSITIVE_INFINITY;
return leftTime - rightTime;
};
export const buildDerivedAssignments = (
assignments: ApiAssignment[],
questionsByAssignment: Map<number, ApiAssignmentStudentQuestionDetail[]>,
) =>
assignments.map((assignment) => {
const questions = questionsByAssignment.get(assignment.id) ?? [];
const answeredCount = questions.filter((question) => question.answer_id).length;
const reviewedCount = questions.filter((question) => question.answer_status === "reviewed").length;
const lastTouchedAt =
questions
.flatMap((question) => [question.reviewed_at, question.submitted_at, question.answer_updated_at, question.answer_created_at])
.filter((value): value is string => Boolean(value))
.sort((left, right) => new Date(right).getTime() - new Date(left).getTime())[0] ?? assignment.updated_at;
return {
assignment,
questions,
status: deriveAssignmentStatus(assignment, questions),
answeredCount,
reviewedCount,
topic: extractTopic(assignment, questions),
lastTouchedAt,
};
});
export const buildTopicAggregates = (assignments: DerivedAssignment[]) => {
const topicMap = new Map<string, TopicAggregate>();
for (const assignment of assignments) {
for (const question of assignment.questions) {
const key = question.subject || assignment.topic || "Assignment";
const current = topicMap.get(key) ?? {
label: key,
total: 0,
answered: 0,
reviewed: 0,
completion: 0,
};
current.total += 1;
if (question.answer_id) current.answered += 1;
if (question.answer_status === "reviewed") current.reviewed += 1;
current.completion = current.total === 0 ? 0 : Math.round((current.reviewed / current.total) * 100);
topicMap.set(key, current);
}
}
return [...topicMap.values()].sort((left, right) => left.completion - right.completion || right.total - left.total);
};
export const buildAssignmentCards = (assignments: DerivedAssignment[]): AssignmentCard[] => {
const tones: AccentTone[] = ["yellow", "blue", "teal", "pink"];
return assignments
.slice()
.sort((left, right) => {
const statusWeight = {
IN_PROGRESS: 0,
NOT_STARTED: 1,
SUBMITTED: 2,
} as const;
return statusWeight[left.status] - statusWeight[right.status] || sortByDueDate(left, right);
})
.slice(0, 3)
.map((item, index) => ({
title: item.assignment.title,
badge: formatAssignmentBadge(item.assignment.title),
lessons:
item.status === "SUBMITTED"
? `${item.reviewedCount}/${item.questions.length || 0} reviewed · revisit feedback`
: item.status === "IN_PROGRESS"
? `${item.answeredCount}/${item.questions.length || 0} answered · due ${formatShortDate(item.assignment.due_at)}`
: `${item.questions.length || 0} questions · starts with ${item.topic}`,
accent: toneAt(tones, index),
cta: item.status === "SUBMITTED" ? "Open review" : item.status === "IN_PROGRESS" ? "Resume work" : "Start now",
href: item.status === "SUBMITTED" ? getAssignmentReviewHref("student", item.assignment.id) : getAssignmentWorkHref(item.assignment.id),
}));
};
export const buildProgressSeries = (assignments: DerivedAssignment[]) => {
const recent = assignments
.slice()
.sort((left, right) => new Date(left.assignment.created_at).getTime() - new Date(right.assignment.created_at).getTime())
.slice(-5);
if (recent.length === 0) {
return {
labels: ["Plan", "Start", "Work", "Review", "Finish"],
points: [20, 32, 44, 58, 66],
};
}
return {
labels: recent.map((item) => item.assignment.title.split(" ")[0]),
points: recent.map((item) => {
const total = item.questions.length || 1;
const progress = Math.round((item.reviewedCount / total) * 100);
return Math.max(12, Math.min(100, progress));
}),
};
};
export const buildUsageItems = (totalQuestions: number, totalReviewed: number, totalAnswered: number, remainingCount: number): UsageItem[] => [
{ label: studentDashboardLabels.insights.reviewed, value: totalQuestions > 0 ? Math.round((totalReviewed / totalQuestions) * 100) : 0, tone: "teal" },
{ label: studentDashboardLabels.insights.answered, value: totalQuestions > 0 ? Math.round((totalAnswered / totalQuestions) * 100) : 0, tone: "blue" },
{ label: studentDashboardLabels.insights.remaining, value: totalQuestions > 0 ? Math.round((remainingCount / totalQuestions) * 100) : 0, tone: "yellow" },
];
export const masteryTones: PerformanceTone[] = ["blue", "purple", "teal", "pink", "yellow"];

View File

@@ -2,11 +2,11 @@
display: grid;
gap: 1rem;
@media (min-width: 960px) {
@include respond(desktop-sm) {
gap: 1.25rem;
}
@media (min-width: 1120px) {
@include respond(desktop-lg) {
grid-template-columns: minmax(0, 1.3fr) minmax(16rem, 0.8fr) minmax(16rem, 0.9fr);
}
}
@@ -15,16 +15,16 @@
display: grid;
gap: 1rem;
padding: 1rem;
border-radius: 1.35rem;
border-radius: var(--radius-2xl);
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
@media (min-width: 640px) {
@include respond(phablet) {
padding: 1.15rem;
}
@media (min-width: 960px) {
@include respond(desktop-sm) {
padding: 1.35rem;
}
@@ -68,14 +68,14 @@
.dot {
width: 0.75rem;
height: 0.75rem;
border-radius: 9999px;
border-radius: var(--radius-full);
display: inline-block;
margin-right: 0.55rem;
}
.track {
height: 0.75rem;
border-radius: 9999px;
border-radius: var(--radius-full);
background: var(--surface-soft);
overflow: hidden;
}
@@ -98,7 +98,7 @@
position: relative;
min-height: 12rem;
@media (min-width: 640px) {
@include respond(phablet) {
padding: 1rem 0;
min-height: 15rem;
}
@@ -111,7 +111,7 @@
background: conic-gradient(var(--purple-400) 0 54%, var(--blue-400) 54% 74%, var(--surface-soft) 74% 100%);
position: relative;
@media (min-width: 640px) {
@include respond(phablet) {
width: 12rem;
height: 12rem;
}
@@ -124,7 +124,7 @@
background: var(--surface-panel-strong);
border: 1px solid var(--border-divider);
@media (min-width: 640px) {
@include respond(phablet) {
inset: 1.8rem;
}
}
@@ -140,7 +140,7 @@
font-size: 1.6rem;
font-weight: 700;
@media (min-width: 640px) {
@include respond(phablet) {
font-size: 2rem;
}
}

View File

@@ -1,15 +1,15 @@
import type { Component } from "solid-js";
import { For } from "solid-js";
import { overallPassRate, topicMasteryBars, solveModeUsage, usageSummary } from "./dashboard.data";
import type { DashboardInsightsData } from "../shared/dashboard-types";
import styles from "./dashboard-insights.module.scss";
const DashboardInsights: Component = () => {
const DashboardInsights: Component<DashboardInsightsData> = (props) => {
return (
<section class={styles.grid}>
<article class={styles.panel}>
<h2>My topic mastery</h2>
<h2>{props.masteryTitle}</h2>
<div class={styles.barChart}>
<For each={topicMasteryBars}>
<For each={props.topicMasteryBars}>
{(item) => (
<div class={styles.barRow}>
<div class={styles.barMeta}>
@@ -26,21 +26,21 @@ const DashboardInsights: Component = () => {
</article>
<article class={styles.panel}>
<h2>My overall accuracy</h2>
<h2>{props.overallTitle}</h2>
<div class={styles.donutWrap}>
<div class={styles.donut} style={{ background: `conic-gradient(var(--purple-400) 0 ${Math.max(0, overallPassRate - 18)}%, var(--blue-400) ${Math.max(0, overallPassRate - 18)}% ${overallPassRate}%, var(--surface-soft) ${overallPassRate}% 100%)` }}></div>
<div class={styles.donut} style={{ background: `conic-gradient(var(--purple-400) 0 ${Math.max(0, props.overallPassRate - 18)}%, var(--blue-400) ${Math.max(0, props.overallPassRate - 18)}% ${props.overallPassRate}%, var(--surface-soft) ${props.overallPassRate}% 100%)` }}></div>
<div class={styles.donutLabel}>
<strong>{overallPassRate}%</strong>
<span>Accuracy</span>
<strong>{props.overallPassRate}%</strong>
<span>{props.overallLabel}</span>
</div>
</div>
</article>
<article class={styles.panel}>
<h2>Solve mode usage</h2>
<p class={styles.usageNote}>{usageSummary.note}</p>
<h2>{props.usageTitle}</h2>
<p class={styles.usageNote}>{props.usageSummary.note}</p>
<div class={styles.usageList}>
<For each={solveModeUsage}>
<For each={props.solveModeUsage}>
{(item) => (
<div class={styles.usageItem}>
<div class={styles.usageMeta}>

View File

@@ -33,12 +33,12 @@
grid-template-columns: auto 1fr;
gap: 0.85rem;
padding: 0.95rem;
border-radius: 1.25rem;
border-radius: var(--radius-xl);
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
@media (min-width: 720px) {
@include respond(compact) {
grid-template-columns: auto 1fr auto;
align-items: center;
}
@@ -50,7 +50,7 @@
width: 100%;
padding: 0.7rem 0.95rem;
border: none;
border-radius: 9999px;
border-radius: var(--radius-full);
background: var(--surface-soft);
color: var(--text-muted);
font-weight: 500;
@@ -70,7 +70,7 @@
}
}
@media (min-width: 720px) {
@include respond(compact) {
.card .actionLink {
width: auto;
}
@@ -81,7 +81,7 @@
height: 3rem;
display: grid;
place-items: center;
border-radius: 9999px;
border-radius: var(--radius-full);
background: linear-gradient(135deg, var(--surface-accent-soft), var(--surface-info));
color: var(--primary);
font-weight: 600;

View File

@@ -3,19 +3,24 @@
import { A } from "@solidjs/router";
import type { Component } from "solid-js";
import { For } from "solid-js";
import { getDashboardPracticeHref } from "../../../lib/routes";
import styles from "./dashboard-instructors.module.scss";
import { studentSupportList } from "./dashboard.data";
import type { StudentSupportCard } from "../shared/dashboard-types";
const DashboardInstructors: Component = () => {
type DashboardInstructorsProps = {
studentSupportList: StudentSupportCard[];
};
const DashboardInstructors: Component<DashboardInstructorsProps> = (props) => {
return (
<section class={styles.section}>
<div class={styles.header}>
<h2>Try these next</h2>
<A href="/dashboard/practice">View plan</A>
<A href={getDashboardPracticeHref()}>View plan</A>
</div>
<div class={styles.list}>
<For each={studentSupportList}>
<For each={props.studentSupportList}>
{(student) => (
<article class={styles.card}>
<div class={styles.avatar}>{student.initials}</div>

View File

@@ -2,7 +2,7 @@
display: grid;
gap: 1rem;
@media (min-width: 960px) {
@include respond(desktop-sm) {
gap: 1.25rem;
}
}
@@ -11,12 +11,12 @@
display: grid;
gap: 1rem;
padding: 1.15rem;
border-radius: 1.5rem;
border-radius: var(--radius-3xl);
background: linear-gradient(135deg, var(--surface-panel), var(--surface-panel-strong));
border: 1px solid var(--border-strong);
box-shadow: var(--shadow-soft);
@media (min-width: 880px) {
@include respond(wide) {
grid-template-columns: minmax(0, 1.2fr) minmax(16rem, 0.95fr);
align-items: center;
padding: 1.35rem;
@@ -56,7 +56,7 @@
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (min-width: 880px) {
@include respond(wide) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@@ -65,7 +65,7 @@
display: grid;
gap: 0.2rem;
padding: 0.95rem;
border-radius: 1.15rem;
border-radius: var(--radius-lg);
background: var(--surface-soft);
border: 1px solid var(--border-soft);
@@ -93,7 +93,7 @@
display: grid;
gap: 0.95rem;
padding: 1rem;
border-radius: 1.3rem;
border-radius: var(--radius-2xl);
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
@@ -106,7 +106,7 @@
width: fit-content;
max-width: 100%;
padding: 0.42rem 0.72rem;
border-radius: 9999px;
border-radius: var(--radius-full);
font-size: 0.76rem;
font-weight: 600;
line-height: 1.2;
@@ -158,7 +158,7 @@
align-items: center;
justify-content: center;
padding: 0.8rem 1rem;
border-radius: 9999px;
border-radius: var(--radius-full);
font-weight: 500;
text-decoration: none;
transition:

View File

@@ -1,21 +1,23 @@
import type { Component } from "solid-js";
import { For } from "solid-js";
import { A } from "@solidjs/router";
import { practiceFocusCards, practiceFocusStats } from "./dashboard.data";
import type { DashboardPracticeData } from "./dashboard-practice.data";
import styles from "./dashboard-practice-focus.module.scss";
const DashboardPracticeFocus: Component = () => {
type DashboardPracticeFocusProps = DashboardPracticeData;
const DashboardPracticeFocus: Component<DashboardPracticeFocusProps> = (props) => {
return (
<section class={styles.section}>
<article class={styles.heroCard}>
<div class={styles.heroCopy}>
<p class={styles.eyebrow}>Practice</p>
<h1>Your practice space</h1>
<p>Stay in dashboard mode to focus on the skill that needs the most attention, choose the right support mode, and jump into the next useful question set.</p>
<p class={styles.eyebrow}>{props.hero.eyebrow}</p>
<h1>{props.hero.title}</h1>
<p>{props.hero.description}</p>
</div>
<div class={styles.statGrid}>
<For each={practiceFocusStats}>
<For each={props.hero.stats}>
{(stat) => (
<div class={styles.statCard}>
<strong>{stat.value}</strong>
@@ -27,7 +29,7 @@ const DashboardPracticeFocus: Component = () => {
</article>
<div class={styles.cardGrid}>
<For each={practiceFocusCards}>
<For each={props.cards}>
{(card) => (
<article class={styles.practiceCard}>
<span classList={{ [styles.toneChip]: true, [styles[card.tone]]: true }}>{card.meta}</span>

View File

@@ -0,0 +1,98 @@
import type { DashboardHomeShellData, PerformanceBar } from "../shared/dashboard-types";
import { getDashboardAssignmentsHref, getDashboardHomeHref, getDashboardProgressHref } from "../../../lib/routes";
import { studentDashboardLabels } from "../../../content/dashboard-labels";
import { dashboardUiCopy } from "../../../content/ui-copy";
import { getDashboardHomeData } from "./dashboard-home.data";
type PracticeFocusStat = {
label: string;
value: string;
};
type PracticeFocusCard = {
title: string;
description: string;
meta: string;
tone: "yellow" | "blue" | "teal";
primaryLabel: string;
primaryHref: string;
secondaryLabel: string;
secondaryHref: string;
};
export type DashboardPracticeData = {
shell: DashboardHomeShellData;
hero: {
eyebrow: string;
title: string;
description: string;
stats: PracticeFocusStat[];
};
cards: PracticeFocusCard[];
};
const findSpotlightValue = (items: { label: string; value: string }[], label: string, fallback: string) =>
items.find((item) => item.label === label)?.value ?? fallback;
const findTopicBar = (items: PerformanceBar[], label: string) => items.find((item) => item.label === label) ?? null;
const summarizeTopicCoverage = (topic: PerformanceBar | null) => (topic ? `${topic.value}% reviewed in ${topic.label}` : "Short, focused practice is the fastest win.");
export const getDashboardPracticeData = async (studentId: number): Promise<DashboardPracticeData> => {
const home = await getDashboardHomeData(studentId);
const focusTopic = findSpotlightValue(home.hero.spotlightStats, studentDashboardLabels.spotlight.currentFocus, "Mixed practice");
const bestTopic = findSpotlightValue(home.hero.spotlightStats, studentDashboardLabels.spotlight.bestTopic, "Building");
const focusBar = findTopicBar(home.insights.topicMasteryBars, focusTopic);
const bestBar = findTopicBar(home.insights.topicMasteryBars, bestTopic);
const liveTargetTitle = home.hero.heroSideCard.title;
const liveTargetHref = home.hero.heroSideCard.buttonHref;
const liveTargetLabel = home.hero.heroSideCard.buttonLabel;
const latestUpdate = home.hero.quickStats.find((item) => item.label === studentDashboardLabels.quickStats.latestUpdate)?.value ?? "No recent updates";
return {
shell: home.shell,
hero: {
eyebrow: dashboardUiCopy.studentPractice.eyebrow,
title: dashboardUiCopy.studentPractice.title,
description: `Use ${focusTopic.toLowerCase()} as the main repair point, keep one live assignment moving, and finish on ${bestTopic.toLowerCase()} so the session ends with momentum.`,
stats: [
{ label: studentDashboardLabels.practice.focusTopic, value: focusTopic },
{ label: studentDashboardLabels.practice.topicCoverage, value: focusBar ? `${focusBar.value}%` : "--" },
{ label: studentDashboardLabels.practice.bestTopic, value: bestTopic },
{ label: studentDashboardLabels.practice.latestUpdate, value: latestUpdate },
],
},
cards: [
{
title: `Rebuild ${focusTopic}`,
description: `Start with the topic that still has the thinnest review coverage, then return straight to your live assignment while the method is fresh.`,
meta: summarizeTopicCoverage(focusBar),
tone: "yellow",
primaryLabel: liveTargetLabel,
primaryHref: liveTargetHref,
secondaryLabel: dashboardUiCopy.studentPractice.buttons.viewProgress,
secondaryHref: getDashboardProgressHref(),
},
{
title: dashboardUiCopy.studentPractice.cards.firstPassTitle,
description: `Answer one question on your own before switching into review mode. That gives you a stronger signal on what still needs support.`,
meta: `Live target: ${liveTargetTitle}`,
tone: "blue",
primaryLabel: liveTargetLabel,
primaryHref: liveTargetHref,
secondaryLabel: dashboardUiCopy.studentPractice.buttons.seeAssignments,
secondaryHref: getDashboardAssignmentsHref("student"),
},
{
title: `Finish with ${bestTopic}`,
description: `Once the harder practice block is done, close the session on a stronger area so the next study block starts with confidence.`,
meta: bestBar ? `${bestBar.value}% reviewed in ${bestBar.label}` : "Pick a familiar topic for a strong finish.",
tone: "teal",
primaryLabel: dashboardUiCopy.studentPractice.buttons.seeAssignments,
primaryHref: getDashboardAssignmentsHref("student"),
secondaryLabel: dashboardUiCopy.studentPractice.buttons.openDashboard,
secondaryHref: getDashboardHomeHref("student"),
},
],
};
};

View File

@@ -2,7 +2,7 @@
display: grid;
gap: 1rem;
@media (min-width: 960px) {
@include respond(desktop-sm) {
gap: 1.25rem;
}
}
@@ -11,12 +11,12 @@
display: grid;
gap: 1rem;
padding: 1.15rem;
border-radius: 1.5rem;
border-radius: var(--radius-3xl);
background: linear-gradient(135deg, var(--surface-panel), var(--surface-panel-strong));
border: 1px solid var(--border-strong);
box-shadow: var(--shadow-soft);
@media (min-width: 880px) {
@include respond(wide) {
grid-template-columns: minmax(0, 1.2fr) minmax(16rem, 0.95fr);
align-items: center;
padding: 1.35rem;
@@ -56,7 +56,7 @@
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (min-width: 880px) {
@include respond(wide) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@@ -65,7 +65,7 @@
display: grid;
gap: 0.2rem;
padding: 0.95rem;
border-radius: 1.15rem;
border-radius: var(--radius-lg);
background: var(--surface-soft);
border: 1px solid var(--border-soft);
@@ -84,7 +84,7 @@
display: grid;
gap: 1rem;
@media (min-width: 960px) {
@include respond(desktop-sm) {
gap: 1.25rem;
}
}

View File

@@ -2,21 +2,23 @@ import type { Component } from "solid-js";
import { For } from "solid-js";
import DashboardActivity from "./dashboard-activity";
import DashboardInsights from "./dashboard-insights";
import { progressFocusStats } from "./dashboard.data";
import type { DashboardProgressData } from "./dashboard-progress.data";
import styles from "./dashboard-progress-focus.module.scss";
const DashboardProgressFocus: Component = () => {
type DashboardProgressFocusProps = DashboardProgressData;
const DashboardProgressFocus: Component<DashboardProgressFocusProps> = (props) => {
return (
<section class={styles.section}>
<article class={styles.heroCard}>
<div class={styles.heroCopy}>
<p class={styles.eyebrow}>Progress</p>
<h1>Your learning progress</h1>
<p>Stay inside the dashboard to review your results, track improvement, and spot where a small practice block could lift your score fastest.</p>
<p class={styles.eyebrow}>{props.hero.eyebrow}</p>
<h1>{props.hero.title}</h1>
<p>{props.hero.description}</p>
</div>
<div class={styles.statGrid}>
<For each={progressFocusStats}>
<For each={props.hero.stats}>
{(stat) => (
<div class={styles.statCard}>
<strong>{stat.value}</strong>
@@ -28,8 +30,8 @@ const DashboardProgressFocus: Component = () => {
</article>
<div class={styles.contentStack}>
<DashboardActivity />
<DashboardInsights />
<DashboardActivity {...props.activity} />
<DashboardInsights {...props.insights} />
</div>
</section>
);

View File

@@ -0,0 +1,52 @@
import type { DashboardActivityData, DashboardHomeShellData, DashboardInsightsData } from "../shared/dashboard-types";
import { studentDashboardLabels } from "../../../content/dashboard-labels";
import { dashboardUiCopy } from "../../../content/ui-copy";
import { getDashboardHomeData } from "./dashboard-home.data";
type ProgressFocusStat = {
label: string;
value: string;
};
export type DashboardProgressData = {
shell: DashboardHomeShellData;
hero: {
eyebrow: string;
title: string;
description: string;
stats: ProgressFocusStat[];
};
activity: DashboardActivityData;
insights: DashboardInsightsData;
};
const findSpotlightValue = (items: { label: string; value: string }[], label: string, fallback: string) =>
items.find((item) => item.label === label)?.value ?? fallback;
const findQuickStatValue = (items: { label: string; value: string }[], label: string, fallback: string) =>
items.find((item) => item.label === label)?.value ?? fallback;
export const getDashboardProgressData = async (studentId: number): Promise<DashboardProgressData> => {
const home = await getDashboardHomeData(studentId);
const currentFocus = findSpotlightValue(home.hero.spotlightStats, studentDashboardLabels.spotlight.currentFocus, "Mixed practice");
const strongestTopic = findSpotlightValue(home.hero.spotlightStats, studentDashboardLabels.spotlight.bestTopic, "Building");
const latestUpdate = findQuickStatValue(home.hero.quickStats, studentDashboardLabels.quickStats.latestUpdate, "No recent updates");
const answerCoverage = home.activity.highlightCards[1]?.value ?? "0%";
return {
shell: home.shell,
hero: {
eyebrow: dashboardUiCopy.studentProgress.eyebrow,
title: dashboardUiCopy.studentProgress.title,
description: `Track what has been reviewed, see how much of your question load is active, and use ${currentFocus.toLowerCase()} as the fastest next improvement area.`,
stats: [
{ label: studentDashboardLabels.progress.reviewed, value: `${home.insights.overallPassRate}%` },
{ label: studentDashboardLabels.progress.coverage, value: answerCoverage },
{ label: studentDashboardLabels.progress.strongest, value: strongestTopic },
{ label: studentDashboardLabels.progress.latestUpdate, value: latestUpdate },
],
},
activity: home.activity,
insights: home.insights,
};
};

View File

@@ -0,0 +1,43 @@
import type { AssignmentSetupForm, DifficultyValue, QuestionGenerationForm, SelectOption } from "./dashboard-teacher-assignment-create.types";
export const FIXED_PASS_THRESHOLD_LABEL = "60% (6.0/10)";
export const GENERATION_TOPIC_OPTIONS: readonly SelectOption[] = [
{ value: "place_value", label: "Place Value" },
{ value: "arithmetic", label: "Arithmetic" },
{ value: "negative_numbers", label: "Negative Numbers" },
{ value: "bidmas", label: "BIDMAS" },
{ value: "fractions", label: "Fractions" },
{ value: "algebra", label: "Algebra" },
{ value: "geometry", label: "Geometry" },
{ value: "data", label: "Data" },
];
export const GENERATION_DIFFICULTY_OPTIONS: readonly SelectOption<DifficultyValue>[] = [
{ value: "easy", label: "Easy" },
{ value: "medium", label: "Medium" },
{ value: "hard", label: "Hard" },
];
export const emptySetupForm: AssignmentSetupForm = {
classroomId: "",
title: "",
instructions: "",
dueAt: "",
selectedQuestionIds: [],
useMixedGeneration: false,
primaryTopic: "fractions",
primaryDifficulty: "easy",
totalQuestions: "10",
personalizedRatio: "30",
seed: "",
personalizedDifficulty: "easy",
subject: "Maths",
};
export const defaultGenerationForm: QuestionGenerationForm = {
topic: "arithmetic",
difficulty: "easy",
count: "3",
seed: "",
};

View File

@@ -0,0 +1,37 @@
import type { MixedGenerationInput } from "./dashboard-teacher-assignments.data";
import type { AssignmentSetupForm, QuestionGenerationForm } from "./dashboard-teacher-assignment-create.types";
import { GENERATION_TOPIC_OPTIONS } from "./dashboard-teacher-assignment-create.constants";
export const parseOptionalInteger = (value: string) => {
const trimmed = value.trim();
if (!trimmed) return undefined;
const parsed = Number(trimmed);
return Number.isInteger(parsed) ? parsed : Number.NaN;
};
export const buildMixedGenerationInput = (form: AssignmentSetupForm): MixedGenerationInput | null => {
if (!form.useMixedGeneration) return null;
const parsedTotalQuestions = Number(form.totalQuestions);
const parsedPersonalizedRatio = Number(form.personalizedRatio);
const parsedSeed = parseOptionalInteger(form.seed);
return {
primaryTopic: form.primaryTopic,
primaryDifficulty: form.primaryDifficulty,
totalQuestions: parsedTotalQuestions,
personalizedRatio: Number.isFinite(parsedPersonalizedRatio) ? parsedPersonalizedRatio / 100 : undefined,
seed: parsedSeed,
personalizedDifficulty: form.personalizedDifficulty,
subject: form.subject,
questionStatus: "published",
questionSource: "assignment_student_generated",
};
};
export const mergeSelectedQuestionIds = (currentIds: number[], generatedIds: number[]) => Array.from(new Set([...currentIds, ...generatedIds]));
export const buildGenerationSuccessMessage = (generationForm: QuestionGenerationForm, count: number, seed: number) => {
const topicLabel = GENERATION_TOPIC_OPTIONS.find((option) => option.value === generationForm.topic)?.label ?? "Generated";
return `Generated ${count} ${generationForm.difficulty} ${topicLabel} question${count === 1 ? "" : "s"} and auto-selected ${count === 1 ? "it" : "them"} for this assignment. Seed: ${seed}.`;
};

View File

@@ -0,0 +1,341 @@
import { A } from "@solidjs/router";
import type { Component, JSX } from "solid-js";
import { For, Show } from "solid-js";
import { dashboardUiCopy } from "~/content/ui-copy";
import { getDashboardAssignmentsHref, getDashboardHomeHref } from "../../../lib/routes";
import styles from "./dashboard-teacher-assignments.module.scss";
import { FIXED_PASS_THRESHOLD_LABEL, GENERATION_DIFFICULTY_OPTIONS, GENERATION_TOPIC_OPTIONS } from "./dashboard-teacher-assignment-create.constants";
import type { AssignmentSetupForm, QuestionGenerationForm } from "./dashboard-teacher-assignment-create.types";
import type { TeacherAssignmentSetupData } from "./dashboard-teacher-assignments.data";
type InputHandler = (event: Event) => void;
export const AssignmentCreateHero: Component = () => (
<article class={styles.heroCard}>
<div class={styles.heroCopy}>
<p class={styles.eyebrow}>{dashboardUiCopy.teacherAssignmentCreate.hero.eyebrow}</p>
<h1>{dashboardUiCopy.teacherAssignmentCreate.hero.title}</h1>
<p>Create live homework from here without crowding the main Assignments hub.</p>
</div>
<div class={styles.formActions}>
<A href={getDashboardAssignmentsHref("teacher")} class={styles.primaryAction}>
{dashboardUiCopy.teacherAssignmentCreate.hero.backToAssignments}
</A>
<A href={getDashboardHomeHref("teacher")} class={styles.secondaryAction}>
{dashboardUiCopy.teacherAssignmentCreate.hero.teachingOverview}
</A>
</div>
</article>
);
type AssignmentDetailsSectionProps = {
form: AssignmentSetupForm;
classrooms: TeacherAssignmentSetupData["classrooms"];
selectedQuestionCount: number;
onInput: (field: "classroomId" | "title" | "instructions" | "dueAt") => InputHandler;
};
export const AssignmentDetailsSection: Component<AssignmentDetailsSectionProps> = (props) => (
<>
<div class={styles.setupHeader}>
<div>
<p class={styles.eyebrow}>{dashboardUiCopy.teacherAssignmentCreate.details.eyebrow}</p>
<h2>{dashboardUiCopy.teacherAssignmentCreate.details.title}</h2>
<p>Pick a class, then either attach shared bank questions or publish a mixed personalized homework set for every student.</p>
</div>
<div class={styles.setupBadge}>{props.selectedQuestionCount} {dashboardUiCopy.teacherAssignmentCreate.details.selectedSuffix}</div>
</div>
<div class={styles.fieldGrid}>
<label class={styles.fieldLabel}>
<span>{dashboardUiCopy.teacherAssignmentCreate.details.classroom}</span>
<select class={styles.selectInput} value={props.form.classroomId} onInput={props.onInput("classroomId")}>
<option value="">{dashboardUiCopy.teacherAssignmentCreate.details.chooseClassroom}</option>
<For each={props.classrooms}>{(classroom) => <option value={classroom.id}>{classroom.name}</option>}</For>
</select>
</label>
<div class={styles.fieldLabel}>
<span>{dashboardUiCopy.teacherAssignmentCreate.details.passThreshold}</span>
<input class={styles.textInput} value={FIXED_PASS_THRESHOLD_LABEL} disabled />
</div>
<label class={styles.fieldLabel}>
<span>{dashboardUiCopy.teacherAssignmentCreate.details.dueDate}</span>
<input class={styles.textInput} type="datetime-local" value={props.form.dueAt} onInput={props.onInput("dueAt")} />
</label>
<label class={`${styles.fieldLabel} ${styles.fieldLabelFull}`}>
<span>{dashboardUiCopy.teacherAssignmentCreate.details.assignmentTitle}</span>
<input class={styles.textInput} value={props.form.title} onInput={props.onInput("title")} placeholder={dashboardUiCopy.teacherAssignmentCreate.details.assignmentTitlePlaceholder} />
</label>
<label class={`${styles.fieldLabel} ${styles.fieldLabelFull}`}>
<span>{dashboardUiCopy.teacherAssignmentCreate.details.teacherNotes}</span>
<textarea
class={styles.textArea}
rows={4}
value={props.form.instructions}
onInput={props.onInput("instructions")}
placeholder={dashboardUiCopy.teacherAssignmentCreate.details.teacherNotesPlaceholder}
/>
</label>
</div>
</>
);
type PersonalizedGenerationSectionProps = {
form: AssignmentSetupForm;
mixedGenerationEnabled: boolean;
mixedGenerationQuestionCount: number;
mixedGenerationRatioLabel: string;
onInput: (field: "primaryTopic" | "primaryDifficulty" | "totalQuestions" | "personalizedRatio" | "seed" | "personalizedDifficulty" | "subject") => InputHandler;
onToggle: InputHandler;
};
export const PersonalizedGenerationSection: Component<PersonalizedGenerationSectionProps> = (props) => (
<div class={styles.generatorPanel}>
<div class={styles.questionPickerHeader}>
<div>
<h3>{dashboardUiCopy.teacherAssignmentCreate.personalized.title}</h3>
<p>Optionally generate a mixed question set for each student at publish time using one shared homework blueprint.</p>
</div>
<small>{props.mixedGenerationEnabled ? dashboardUiCopy.teacherAssignmentCreate.personalized.enabled : dashboardUiCopy.teacherAssignmentCreate.personalized.disabled}</small>
</div>
<label class={styles.toggleRow}>
<input type="checkbox" checked={props.form.useMixedGeneration} onChange={props.onToggle} />
<div>
<strong>{dashboardUiCopy.teacherAssignmentCreate.personalized.toggleTitle}</strong>
<span>Uses the backend 70/30 mixed planner during assignment publish instead of relying on one shared question list.</span>
</div>
</label>
<Show when={props.mixedGenerationEnabled}>
<div class={styles.fieldGrid}>
<label class={styles.fieldLabel}>
<span>{dashboardUiCopy.teacherAssignmentCreate.personalized.primaryTopic}</span>
<select class={styles.selectInput} value={props.form.primaryTopic} onInput={props.onInput("primaryTopic")}>
<For each={GENERATION_TOPIC_OPTIONS}>{(option) => <option value={option.value}>{option.label}</option>}</For>
</select>
</label>
<label class={styles.fieldLabel}>
<span>{dashboardUiCopy.teacherAssignmentCreate.personalized.primaryDifficulty}</span>
<select class={styles.selectInput} value={props.form.primaryDifficulty} onInput={props.onInput("primaryDifficulty")}>
<For each={GENERATION_DIFFICULTY_OPTIONS}>{(option) => <option value={option.value}>{option.label}</option>}</For>
</select>
</label>
<label class={styles.fieldLabel}>
<span>{dashboardUiCopy.teacherAssignmentCreate.personalized.totalQuestions}</span>
<input class={styles.textInput} type="number" min="1" value={props.form.totalQuestions} onInput={props.onInput("totalQuestions")} />
</label>
<label class={styles.fieldLabel}>
<span>{dashboardUiCopy.teacherAssignmentCreate.personalized.personalizedShare}</span>
<input class={styles.textInput} type="number" min="0" max="90" value={props.form.personalizedRatio} onInput={props.onInput("personalizedRatio")} />
</label>
<label class={styles.fieldLabel}>
<span>{dashboardUiCopy.teacherAssignmentCreate.personalized.personalizedDifficulty}</span>
<select class={styles.selectInput} value={props.form.personalizedDifficulty} onInput={props.onInput("personalizedDifficulty")}>
<For each={GENERATION_DIFFICULTY_OPTIONS}>{(option) => <option value={option.value}>{option.label}</option>}</For>
</select>
</label>
<label class={styles.fieldLabel}>
<span>{dashboardUiCopy.teacherAssignmentCreate.personalized.subjectLabel}</span>
<input class={styles.textInput} value={props.form.subject} onInput={props.onInput("subject")} placeholder={dashboardUiCopy.teacherAssignmentCreate.personalized.subjectPlaceholder} />
</label>
<label class={styles.fieldLabel}>
<span>{dashboardUiCopy.teacherAssignmentCreate.personalized.seed}</span>
<input class={styles.textInput} type="number" step="1" value={props.form.seed} onInput={props.onInput("seed")} placeholder={dashboardUiCopy.teacherAssignmentCreate.personalized.seedPlaceholder} />
</label>
</div>
<p class={styles.generatorNote}>
Each student will receive {props.mixedGenerationQuestionCount || 0} questions with roughly {props.mixedGenerationRatioLabel} and the rest from the main topic.
</p>
</Show>
</div>
);
type SharedGenerationSectionProps = {
form: QuestionGenerationForm;
isGenerating: boolean;
isSubmitting: boolean;
onInput: (field: keyof QuestionGenerationForm) => InputHandler;
onGenerate: (event: Event) => void;
};
export const SharedGenerationSection: Component<SharedGenerationSectionProps> = (props) => (
<div class={styles.generatorPanel}>
<div class={styles.questionPickerHeader}>
<div>
<h3>{dashboardUiCopy.teacherAssignmentCreate.sharedGeneration.title}</h3>
<p>Create RNG-based shared questions by topic and difficulty, then attach them into this assignment when you want the whole class to receive the same set.</p>
</div>
<small>{dashboardUiCopy.teacherAssignmentCreate.sharedGeneration.autoSelected}</small>
</div>
<div class={styles.fieldGrid}>
<label class={styles.fieldLabel}>
<span>{dashboardUiCopy.teacherAssignmentCreate.sharedGeneration.topic}</span>
<select class={styles.selectInput} value={props.form.topic} onInput={props.onInput("topic")}>
<For each={GENERATION_TOPIC_OPTIONS}>{(option) => <option value={option.value}>{option.label}</option>}</For>
</select>
</label>
<label class={styles.fieldLabel}>
<span>{dashboardUiCopy.teacherAssignmentCreate.sharedGeneration.difficulty}</span>
<select class={styles.selectInput} value={props.form.difficulty} onInput={props.onInput("difficulty")}>
<For each={GENERATION_DIFFICULTY_OPTIONS}>{(option) => <option value={option.value}>{option.label}</option>}</For>
</select>
</label>
<label class={styles.fieldLabel}>
<span>{dashboardUiCopy.teacherAssignmentCreate.sharedGeneration.count}</span>
<input class={styles.textInput} type="number" min="1" max="25" value={props.form.count} onInput={props.onInput("count")} />
</label>
<label class={styles.fieldLabel}>
<span>{dashboardUiCopy.teacherAssignmentCreate.sharedGeneration.seed}</span>
<input class={styles.textInput} type="number" step="1" value={props.form.seed} onInput={props.onInput("seed")} placeholder={dashboardUiCopy.teacherAssignmentCreate.sharedGeneration.seedPlaceholder} />
</label>
</div>
<div class={styles.generatorActions}>
<button type="button" class={styles.secondaryAction} onClick={props.onGenerate} disabled={props.isGenerating || props.isSubmitting}>
{props.isGenerating ? dashboardUiCopy.teacherAssignmentCreate.sharedGeneration.generating : dashboardUiCopy.teacherAssignmentCreate.sharedGeneration.generateQuestions}
</button>
<p class={styles.generatorNote}>Use the same seed later if you want to recreate the same generated set.</p>
</div>
</div>
);
type QuestionBankSectionProps = {
questions: TeacherAssignmentSetupData["questions"];
loading: boolean;
mixedGenerationEnabled: boolean;
selectedQuestionIds: number[];
onToggle: (questionId: number) => InputHandler;
};
export const QuestionBankSection: Component<QuestionBankSectionProps> = (props) => (
<div class={styles.questionPicker}>
<div class={styles.questionPickerHeader}>
<div>
<h3>{dashboardUiCopy.teacherAssignmentCreate.questionBank.title}</h3>
<p>{props.mixedGenerationEnabled ? "Optional shared questions. Leave these unselected if you want this homework to rely only on per-student mixed generation." : "Select the questions you want to include in this assignment."}</p>
</div>
<small>{props.questions.length} {dashboardUiCopy.teacherAssignmentCreate.questionBank.availableSuffix}</small>
</div>
<Show when={props.loading}>
<div class={styles.questionEmptyState}>{dashboardUiCopy.teacherAssignmentCreate.questionBank.loading}</div>
</Show>
<Show when={!props.loading && !props.questions.length}>
<div class={styles.questionEmptyState}>{dashboardUiCopy.teacherAssignmentCreate.questionBank.empty}</div>
</Show>
<div class={styles.questionList}>
<For each={props.questions}>
{(question) => (
<label class={styles.questionCard}>
<div class={styles.questionCheck}>
<input type="checkbox" checked={props.selectedQuestionIds.includes(question.id)} onChange={props.onToggle(question.id)} />
</div>
<div class={styles.questionCopy}>
<div class={styles.questionMeta}>
<span>{question.subjectLabel}</span>
<span>{question.difficultyLabel}</span>
<span>{question.sourceLabel}</span>
<span>{question.statusLabel}</span>
</div>
<strong>{question.title}</strong>
<p>{question.promptPreview}</p>
</div>
</label>
)}
</For>
</div>
</div>
);
type SubmissionSectionProps = {
isSubmitting: boolean;
errorMessage: string | null;
successMessage: string | null;
onReset: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>;
};
export const SubmissionSection: Component<SubmissionSectionProps> = (props) => (
<>
<div class={styles.formActions}>
<button type="submit" class={styles.primaryAction} disabled={props.isSubmitting}>
{props.isSubmitting ? dashboardUiCopy.teacherAssignmentCreate.submission.creating : dashboardUiCopy.teacherAssignmentCreate.submission.createAssignment}
</button>
<button type="button" class={styles.secondaryAction} onClick={props.onReset} disabled={props.isSubmitting}>
{dashboardUiCopy.teacherAssignmentCreate.submission.resetForm}
</button>
</div>
<Show when={props.errorMessage}>
<p class={`${styles.feedbackMessage} ${styles.feedbackError}`}>{props.errorMessage}</p>
</Show>
<Show when={props.successMessage}>
<p class={`${styles.feedbackMessage} ${styles.feedbackSuccess}`}>{props.successMessage}</p>
</Show>
</>
);
type SetupSummarySectionProps = {
classroomName: string;
rosterLabel: string;
mixedGenerationEnabled: boolean;
mixedGenerationQuestionCount: number;
selectedQuestionCount: number;
};
export const SetupSummarySection: Component<SetupSummarySectionProps> = (props) => (
<aside class={styles.setupSummaryCard}>
<h2>{dashboardUiCopy.teacherAssignmentCreate.summary.title}</h2>
<div class={styles.summaryList}>
<div class={styles.summaryRow}>
<span>{dashboardUiCopy.teacherAssignmentCreate.summary.classroom}</span>
<strong>{props.classroomName || dashboardUiCopy.teacherAssignmentCreate.summary.notChosenYet}</strong>
</div>
<div class={styles.summaryRow}>
<span>{dashboardUiCopy.teacherAssignmentCreate.summary.roster}</span>
<strong>{props.rosterLabel || dashboardUiCopy.teacherAssignmentCreate.summary.chooseClassroom}</strong>
</div>
<div class={styles.summaryRow}>
<span>{dashboardUiCopy.teacherAssignmentCreate.summary.questions}</span>
<strong>{props.mixedGenerationEnabled ? `${props.mixedGenerationQuestionCount || 0} per student` : `${props.selectedQuestionCount} selected`}</strong>
</div>
<div class={styles.summaryRow}>
<span>{dashboardUiCopy.teacherAssignmentCreate.summary.mode}</span>
<strong>{props.mixedGenerationEnabled ? dashboardUiCopy.teacherAssignmentCreate.summary.mixedPersonalized : dashboardUiCopy.teacherAssignmentCreate.summary.sharedQuestionSet}</strong>
</div>
<div class={styles.summaryRow}>
<span>{dashboardUiCopy.teacherAssignmentCreate.summary.passThreshold}</span>
<strong>{FIXED_PASS_THRESHOLD_LABEL}</strong>
</div>
<div class={styles.summaryRow}>
<span>{dashboardUiCopy.teacherAssignmentCreate.summary.delivery}</span>
<strong>{dashboardUiCopy.teacherAssignmentCreate.summary.liveAssignment}</strong>
</div>
</div>
<p class={styles.setupNote}>
{props.mixedGenerationEnabled
? "This flow creates live homework immediately and generates a mixed personalized question set for each assigned student during publish."
: "This flow creates live homework immediately and assigns the selected shared question set to the selected classroom. Editing an existing assignment later will need a backend update route."}
</p>
</aside>
);

View File

@@ -0,0 +1,240 @@
import type { Component } from "solid-js";
import { createMemo, createResource, createSignal, onMount } from "solid-js";
import { createStore } from "solid-js/store";
import { useAuth } from "~/context/auth/context";
import styles from "./dashboard-teacher-assignments.module.scss";
import { createTeacherAssignment, generateTeacherQuestions, getTeacherAssignmentSetupData } from "./dashboard-teacher-assignments.data";
import { defaultGenerationForm, emptySetupForm } from "./dashboard-teacher-assignment-create.constants";
import { buildGenerationSuccessMessage, buildMixedGenerationInput, mergeSelectedQuestionIds, parseOptionalInteger } from "./dashboard-teacher-assignment-create.helpers";
import {
AssignmentCreateHero,
AssignmentDetailsSection,
PersonalizedGenerationSection,
QuestionBankSection,
SetupSummarySection,
SharedGenerationSection,
SubmissionSection,
} from "./dashboard-teacher-assignment-create.sections";
import type { AssignmentSetupForm, QuestionGenerationForm } from "./dashboard-teacher-assignment-create.types";
const DashboardTeacherAssignmentCreate: Component = () => {
const auth = useAuth();
const [teacherId, setTeacherId] = createSignal<number | null>(null);
const [setupData, { mutate: mutateSetupData }] = createResource(teacherId, getTeacherAssignmentSetupData);
const [form, setForm] = createStore<AssignmentSetupForm>(emptySetupForm);
const [generationForm, setGenerationForm] = createStore<QuestionGenerationForm>(defaultGenerationForm);
const [isSubmitting, setIsSubmitting] = createSignal(false);
const [isGenerating, setIsGenerating] = createSignal(false);
const [errorMessage, setErrorMessage] = createSignal<string | null>(null);
const [successMessage, setSuccessMessage] = createSignal<string | null>(null);
onMount(() => {
if (auth.user()?.role === "teacher") {
setTeacherId(auth.user()!.id);
}
});
const selectedClassroom = createMemo(() => setupData()?.classrooms.find((classroom) => `${classroom.id}` === form.classroomId));
const selectedQuestionCount = createMemo(() => form.selectedQuestionIds.length);
const mixedGenerationEnabled = createMemo(() => form.useMixedGeneration);
const mixedGenerationQuestionCount = createMemo(() => Number(form.totalQuestions) || 0);
const mixedGenerationRatioLabel = createMemo(() => {
const parsed = Number(form.personalizedRatio);
if (!Number.isFinite(parsed) || parsed <= 0) return "30% personalized";
return `${parsed}% personalized`;
});
const handleTextInput =
(
field:
| "classroomId"
| "title"
| "instructions"
| "dueAt"
| "primaryTopic"
| "primaryDifficulty"
| "totalQuestions"
| "personalizedRatio"
| "seed"
| "personalizedDifficulty"
| "subject",
) =>
(event: Event) => {
const target = event.currentTarget as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
setForm(field, target.value);
setSuccessMessage(null);
};
const handleMixedGenerationToggle = (event: Event) => {
const target = event.currentTarget as HTMLInputElement;
setForm("useMixedGeneration", target.checked);
setSuccessMessage(null);
};
const handleGenerationInput = (field: keyof QuestionGenerationForm) => (event: Event) => {
const target = event.currentTarget as HTMLInputElement | HTMLSelectElement;
setGenerationForm(field, target.value as QuestionGenerationForm[keyof QuestionGenerationForm]);
setSuccessMessage(null);
};
const handleQuestionToggle = (questionId: number) => (event: Event) => {
const target = event.currentTarget as HTMLInputElement;
setForm("selectedQuestionIds", target.checked ? [...form.selectedQuestionIds, questionId] : form.selectedQuestionIds.filter((id) => id !== questionId));
setSuccessMessage(null);
};
const resetForm = () => {
setForm({ ...emptySetupForm, classroomId: form.classroomId });
};
const mergeGeneratedQuestions = (generatedQuestionIds: number[], questions: Awaited<ReturnType<typeof generateTeacherQuestions>>["questions"]) => {
mutateSetupData((current) => {
if (!current) return current;
const existingQuestions = current.questions.filter((question) => !generatedQuestionIds.includes(question.id));
return {
...current,
questions: [...questions, ...existingQuestions],
};
});
setForm("selectedQuestionIds", mergeSelectedQuestionIds(form.selectedQuestionIds, generatedQuestionIds));
};
const handleGenerateQuestions = async (event: Event) => {
event.preventDefault();
const currentTeacherId = teacherId();
if (!currentTeacherId) {
setErrorMessage("Your teacher session is still loading.");
return;
}
const parsedCount = Number(generationForm.count);
const parsedSeed = parseOptionalInteger(generationForm.seed);
if (!Number.isInteger(parsedCount) || parsedCount < 1 || parsedCount > 25) {
setErrorMessage("Choose a question count between 1 and 25.");
return;
}
if (generationForm.seed.trim() && (!Number.isInteger(parsedSeed) || Number.isNaN(parsedSeed))) {
setErrorMessage("Seed must be a whole number when provided.");
return;
}
setIsGenerating(true);
setErrorMessage(null);
setSuccessMessage(null);
try {
const result = await generateTeacherQuestions({
topic: generationForm.topic,
difficulty: generationForm.difficulty,
count: parsedCount,
seed: parsedSeed,
});
mergeGeneratedQuestions(result.generatedQuestionIds, result.questions);
setSuccessMessage(buildGenerationSuccessMessage(generationForm, result.count, result.seed));
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : "Unable to generate questions right now.");
} finally {
setIsGenerating(false);
}
};
const handleSubmit = async (event: Event) => {
event.preventDefault();
const currentTeacherId = teacherId();
if (!currentTeacherId) {
setErrorMessage("Your teacher session is still loading.");
return;
}
setIsSubmitting(true);
setErrorMessage(null);
setSuccessMessage(null);
try {
const mixedGeneration = buildMixedGenerationInput(form);
await createTeacherAssignment({
teacherId: currentTeacherId,
classroomId: Number(form.classroomId),
title: form.title,
instructions: form.instructions,
dueAt: form.dueAt,
selectedQuestionIds: form.selectedQuestionIds,
mixedGeneration,
});
setSuccessMessage(
mixedGenerationEnabled()
? "Personalized homework created and assigned. Each student now has a mixed generated question set."
: "Homework created and assigned to the selected class.",
);
resetForm();
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : "Unable to create homework right now.");
} finally {
setIsSubmitting(false);
}
};
return (
<section class={styles.section}>
<AssignmentCreateHero />
<form class={styles.setupGrid} onSubmit={(event) => void handleSubmit(event)}>
<article class={styles.setupCard}>
<AssignmentDetailsSection
form={form}
classrooms={setupData()?.classrooms ?? []}
selectedQuestionCount={selectedQuestionCount()}
onInput={handleTextInput}
/>
<PersonalizedGenerationSection
form={form}
mixedGenerationEnabled={mixedGenerationEnabled()}
mixedGenerationQuestionCount={mixedGenerationQuestionCount()}
mixedGenerationRatioLabel={mixedGenerationRatioLabel()}
onInput={handleTextInput}
onToggle={handleMixedGenerationToggle}
/>
<SharedGenerationSection
form={generationForm}
isGenerating={isGenerating()}
isSubmitting={isSubmitting()}
onInput={handleGenerationInput}
onGenerate={(event) => void handleGenerateQuestions(event)}
/>
<QuestionBankSection
questions={setupData()?.questions ?? []}
loading={setupData.loading}
mixedGenerationEnabled={mixedGenerationEnabled()}
selectedQuestionIds={form.selectedQuestionIds}
onToggle={handleQuestionToggle}
/>
<SubmissionSection
isSubmitting={isSubmitting()}
errorMessage={errorMessage()}
successMessage={successMessage()}
onReset={resetForm}
/>
</article>
<SetupSummarySection
classroomName={selectedClassroom()?.name ?? ""}
rosterLabel={selectedClassroom()?.studentCountLabel ?? ""}
mixedGenerationEnabled={mixedGenerationEnabled()}
mixedGenerationQuestionCount={mixedGenerationQuestionCount()}
selectedQuestionCount={selectedQuestionCount()}
/>
</form>
</section>
);
};
export default DashboardTeacherAssignmentCreate;

View File

@@ -0,0 +1,29 @@
export type DifficultyValue = "easy" | "medium" | "hard";
export type SelectOption<T extends string = string> = {
value: T;
label: string;
};
export type AssignmentSetupForm = {
classroomId: string;
title: string;
instructions: string;
dueAt: string;
selectedQuestionIds: number[];
useMixedGeneration: boolean;
primaryTopic: string;
primaryDifficulty: DifficultyValue;
totalQuestions: string;
personalizedRatio: string;
seed: string;
personalizedDifficulty: DifficultyValue;
subject: string;
};
export type QuestionGenerationForm = {
topic: string;
difficulty: DifficultyValue;
count: string;
seed: string;
};

View File

@@ -0,0 +1,289 @@
import { apiFetchJson } from "../../../lib/api";
import type {
ApiAssignment,
ApiClassroom,
ApiGenerateQuestionsResponse,
ApiListResponse,
ApiQuestion,
ApiReviewSummary,
ApiStudent,
} from "../../../lib/api-types";
import { teacherDashboardLabels } from "../../../content/dashboard-labels";
import { getDashboardHomeHref } from "../../../lib/routes";
import {
FIXED_PASS_THRESHOLD,
buildAssignmentItem,
buildEmptyReviewSummary,
formatShortDate,
isIndividualRedoAssignment,
mapSetupQuestion,
sortDueSoonest,
sortNeedsAttention,
} from "./dashboard-teacher-assignments.helpers";
import type {
DashboardTeacherAssignmentsFocusData,
GenerateTeacherQuestionsInput,
GenerateTeacherQuestionsResult,
MixedGenerationInput,
TeacherAssignmentSetupData,
} from "./dashboard-teacher-assignments.types";
export type {
DashboardTeacherAssignmentsFocusData,
GenerateTeacherQuestionsInput,
GenerateTeacherQuestionsResult,
MixedGenerationInput,
TeacherAssignmentSetupData,
TeacherAssignmentSetupQuestion,
} from "./dashboard-teacher-assignments.types";
export { formatDetailedDate, formatDateTimeLocalInput, isIndividualRedoAssignment } from "./dashboard-teacher-assignments.helpers";
type CreateTeacherAssignmentInput = {
teacherId: number;
classroomId: number;
title: string;
instructions: string;
dueAt: string;
selectedQuestionIds: number[];
assignedStudentIds?: number[];
mixedGeneration?: MixedGenerationInput | null;
};
export const getTeacherAssignmentSetupData = async (teacherId: number): Promise<TeacherAssignmentSetupData> => {
const [classroomsResponse, questionsResponse] = await Promise.all([
apiFetchJson<ApiListResponse<ApiClassroom>>(`/api/teachers/${teacherId}/classrooms`),
apiFetchJson<ApiListResponse<ApiQuestion>>(`/api/teachers/${teacherId}/questions`),
]);
const classrooms = classroomsResponse.data;
const questions = questionsResponse.data;
const studentsByClassroomEntries = await Promise.all(
classrooms.map(async (classroom) => [classroom.id, (await apiFetchJson<ApiListResponse<ApiStudent>>(`/api/classrooms/${classroom.id}/students`)).data] as const),
);
const studentsByClassroom = new Map(studentsByClassroomEntries);
return {
classrooms: classrooms
.slice()
.sort((left, right) => left.name.localeCompare(right.name))
.map((classroom) => {
const studentCount = (studentsByClassroom.get(classroom.id) ?? []).length;
return {
id: classroom.id,
name: classroom.name,
description: classroom.description?.trim() || "No class note added yet.",
studentCountLabel: `${studentCount} student${studentCount === 1 ? "" : "s"}`,
};
}),
questions: questions
.slice()
.sort((left, right) => {
const rightTime = new Date(right.updated_at ?? right.created_at ?? 0).getTime();
const leftTime = new Date(left.updated_at ?? left.created_at ?? 0).getTime();
return rightTime - leftTime;
})
.map(mapSetupQuestion),
};
};
export const generateTeacherQuestions = async (input: GenerateTeacherQuestionsInput): Promise<GenerateTeacherQuestionsResult> => {
const response = await apiFetchJson<ApiGenerateQuestionsResponse>("/api/questions/generate", {
method: "POST",
body: JSON.stringify({
topic: input.topic,
difficulty: input.difficulty,
count: input.count,
seed: typeof input.seed === "number" ? input.seed : undefined,
status: input.status,
source: input.source,
}),
parseErrorMessage: true,
});
return {
seed: response.seed,
count: response.count,
questions: response.data.map((item) => mapSetupQuestion(item.question)),
generatedQuestionIds: response.data.map((item) => item.question.id),
};
};
export const createTeacherAssignment = async (input: CreateTeacherAssignmentInput) => {
const title = input.title.trim();
const instructions = input.instructions.trim();
const dueDate = input.dueAt ? new Date(input.dueAt) : null;
const mixedGeneration = input.mixedGeneration ?? null;
if (!title) throw new Error("Add an assignment title before creating it.");
if (!input.classroomId) throw new Error("Choose a classroom for this assignment.");
if (dueDate && Number.isNaN(dueDate.getTime())) throw new Error("Pick a valid due date and time.");
if (!mixedGeneration && input.selectedQuestionIds.length === 0) {
throw new Error("Select at least one question before creating homework.");
}
if (mixedGeneration) {
if (!mixedGeneration.primaryTopic.trim()) {
throw new Error("Choose a primary topic for personalized generation.");
}
if (!mixedGeneration.primaryDifficulty) {
throw new Error("Choose a primary difficulty for personalized generation.");
}
if (!Number.isInteger(mixedGeneration.totalQuestions) || mixedGeneration.totalQuestions < 1) {
throw new Error("Choose a total personalized question count greater than 0.");
}
}
const assignment = await apiFetchJson<ApiAssignment>("/api/assignments", {
method: "POST",
body: JSON.stringify({
classroom_id: input.classroomId,
teacher_id: input.teacherId,
title,
instructions: instructions || null,
pass_threshold: FIXED_PASS_THRESHOLD,
status: "assigned",
due_at: dueDate ? dueDate.toISOString() : null,
published_at: new Date().toISOString(),
}),
parseErrorMessage: true,
});
if (!mixedGeneration && input.selectedQuestionIds.length > 0) {
await Promise.all(
input.selectedQuestionIds.map((questionId, index) =>
apiFetchJson(`/api/assignments/${assignment.id}/questions`, {
method: "POST",
body: JSON.stringify({ question_id: questionId, position: index + 1 }),
parseErrorMessage: true,
}),
),
);
}
const studentIds =
input.assignedStudentIds && input.assignedStudentIds.length > 0
? Array.from(new Set(input.assignedStudentIds))
: (await apiFetchJson<ApiListResponse<ApiStudent>>(`/api/classrooms/${input.classroomId}/students`)).data.map((student) => student.id);
await Promise.all(
studentIds.map((studentId) =>
apiFetchJson(`/api/assignments/${assignment.id}/students`, {
method: "POST",
body: JSON.stringify(
mixedGeneration
? {
student_id: studentId,
mixed_generation: {
primary_topic: mixedGeneration.primaryTopic,
primary_difficulty: mixedGeneration.primaryDifficulty,
total_questions: mixedGeneration.totalQuestions,
personalized_ratio: mixedGeneration.personalizedRatio,
seed: mixedGeneration.seed,
personalized_difficulty: mixedGeneration.personalizedDifficulty,
subject: mixedGeneration.subject?.trim() || undefined,
question_status: mixedGeneration.questionStatus,
question_source: mixedGeneration.questionSource?.trim() || undefined,
},
}
: { student_id: studentId },
),
parseErrorMessage: true,
}),
),
);
return assignment;
};
export const getDashboardTeacherAssignmentsFocusData = async (teacherId: number): Promise<DashboardTeacherAssignmentsFocusData> => {
const [assignmentsResponse, classroomsResponse] = await Promise.all([
apiFetchJson<ApiListResponse<ApiAssignment>>(`/api/teachers/${teacherId}/assignments`),
apiFetchJson<ApiListResponse<ApiClassroom>>(`/api/teachers/${teacherId}/classrooms`),
]);
const assignments = assignmentsResponse.data;
const classrooms = classroomsResponse.data;
const [studentsByClassroomEntries, summaryEntries] = await Promise.all([
Promise.all(classrooms.map(async (classroom) => [classroom.id, (await apiFetchJson<ApiListResponse<ApiStudent>>(`/api/classrooms/${classroom.id}/students`)).data] as const)),
Promise.all(assignments.map(async (assignment) => [assignment.id, await apiFetchJson<ApiReviewSummary>(`/api/assignments/${assignment.id}/review-summary`)] as const)),
]);
const classroomById = new Map(classrooms.map((classroom) => [classroom.id, classroom]));
const studentsByClassroom = new Map(studentsByClassroomEntries);
const summaries = new Map(summaryEntries);
const liveAssignments = assignments.filter((assignment) => assignment.status === "assigned" || assignment.status === "draft");
const individualRedoAssignments = liveAssignments.filter(isIndividualRedoAssignment);
const liveClassroomAssignments = liveAssignments.filter((assignment) => !isIndividualRedoAssignment(assignment));
const closedAssignments = assignments.filter(
(assignment) => assignment.status === "closed" && !isIndividualRedoAssignment(assignment),
);
const needsReviewAssignments = liveAssignments.filter((assignment) => (summaries.get(assignment.id)?.submitted ?? 0) > 0);
const totalStudents = new Set(
Array.from(studentsByClassroom.values())
.flat()
.map((student) => student.id),
).size;
const submittedCount = assignments.reduce((total, assignment) => total + (summaries.get(assignment.id)?.submitted ?? 0), 0);
const totalAssigned = assignments.reduce((total, assignment) => total + (summaries.get(assignment.id)?.total_assigned ?? 0), 0);
const totalReviewed = assignments.reduce((total, assignment) => total + (summaries.get(assignment.id)?.reviewed ?? 0), 0);
const reviewCoverage = totalAssigned > 0 ? Math.round((totalReviewed / totalAssigned) * 100) : 0;
const nextDue = liveClassroomAssignments.slice().sort(sortDueSoonest).find((assignment) => assignment.due_at);
const mapGroupItems = (entries: ApiAssignment[]) =>
entries.map((assignment) =>
buildAssignmentItem(
assignment,
classroomById.get(assignment.classroom_id),
summaries.get(assignment.id) ?? buildEmptyReviewSummary(assignment.id),
(studentsByClassroom.get(assignment.classroom_id) ?? []).length,
),
);
return {
stats: [
{ label: teacherDashboardLabels.assignments.needsReview, value: `${submittedCount}`, note: "Fresh submissions waiting for feedback" },
{
label: teacherDashboardLabels.assignments.liveAssignments,
value: `${liveClassroomAssignments.length}`,
note:
individualRedoAssignments.length > 0
? `${individualRedoAssignments.length} student redo assignment${individualRedoAssignments.length === 1 ? "" : "s"} hidden from the main list`
: `${closedAssignments.length} closed assignment${closedAssignments.length === 1 ? "" : "s"} archived separately`,
},
{ label: teacherDashboardLabels.assignments.studentsCovered, value: `${totalStudents}`, note: `${classrooms.length} classroom${classrooms.length === 1 ? "" : "s"} connected` },
{ label: teacherDashboardLabels.assignments.reviewCoverage, value: `${reviewCoverage}%`, note: nextDue ? `${teacherDashboardLabels.common.nextDue} ${formatShortDate(nextDue.due_at)}` : "No live due date scheduled" },
],
groups: [
{
title: "Needs attention now",
description: "Homework with submitted work already waiting in the teacher review queue.",
items: mapGroupItems(needsReviewAssignments.slice().sort((left, right) => sortNeedsAttention(left, right, summaries))),
},
{
title: "Live homework",
description: "Published classroom homework that students can already see or are actively working through.",
items: mapGroupItems(liveClassroomAssignments.slice().sort((left, right) => sortNeedsAttention(left, right, summaries))),
},
{
title: "Closed",
description: "Past assignments that remain useful for reflection and reference.",
items: mapGroupItems(closedAssignments.slice().sort(sortDueSoonest)),
},
].filter((group) => group.items.length > 0),
coachNote: {
title: submittedCount > 0 ? "Review queue is active" : "Assignment set looks steady",
description:
submittedCount > 0
? `You have ${submittedCount} submission${submittedCount === 1 ? "" : "s"} waiting. Start with the review queue, then come back here to manage live homework.`
: "Use this page to keep live homework and closed work easy to scan so setup and follow-up stay easier to manage.",
ctaLabel: submittedCount > 0 ? "Open dashboard review queue" : "Back to teaching overview",
ctaHref: getDashboardHomeHref("teacher"),
},
};
};

View File

@@ -0,0 +1,156 @@
import { getAssignmentReviewHref } from "../../../lib/routes";
import type { ApiAssignment, ApiClassroom, ApiQuestion, ApiReviewSummary } from "../../../lib/api-types";
import type {
TeacherAssignmentSetupQuestion,
TeacherAssignmentsFocusItem,
TeacherAssignmentTone,
} from "./dashboard-teacher-assignments.types";
export const FIXED_PASS_THRESHOLD = 6.0;
export const buildEmptyReviewSummary = (assignmentId: number): ApiReviewSummary => ({
assignment_id: assignmentId,
total_questions: 0,
total_assigned: 0,
not_started: 0,
in_progress: 0,
submitted: 0,
reviewed: 0,
});
const statusLabelForQuestion = (status: string) => {
if (status === "draft") return "Draft question";
if (status === "published") return "Published question";
return status.replace(/_/g, " ").replace(/\b\w/g, (match) => match.toUpperCase());
};
const difficultyLabelForQuestion = (difficulty: string | null | undefined) => {
if (!difficulty) return "Mixed difficulty";
return difficulty.replace(/_/g, " ").replace(/\b\w/g, (match) => match.toUpperCase());
};
const sourceLabelForQuestion = (source: string | null | undefined) => {
const normalized = source?.trim();
if (!normalized) return "Question bank";
if (normalized === "rng_generated") return "RNG generated";
return normalized.replace(/_/g, " ").replace(/\b\w/g, (match) => match.toUpperCase());
};
const promptPreviewForQuestion = (prompt: string) => {
const cleaned = prompt.replace(/\s+/g, " ").trim();
if (cleaned.length <= 150) return cleaned;
return `${cleaned.slice(0, 147).trimEnd()}`;
};
export const mapSetupQuestion = (question: ApiQuestion): TeacherAssignmentSetupQuestion => ({
id: question.id,
title: question.title,
promptPreview: promptPreviewForQuestion(question.prompt),
subjectLabel: question.subject?.trim() || "General",
difficultyLabel: difficultyLabelForQuestion(question.difficulty),
sourceLabel: sourceLabelForQuestion(question.source),
statusLabel: statusLabelForQuestion(question.status),
});
export const formatShortDate = (value: string | null | undefined) => {
if (!value) return "No due date";
return new Intl.DateTimeFormat("en-GB", {
month: "short",
day: "numeric",
}).format(new Date(value));
};
export const formatDetailedDate = (value: string | null | undefined, fallback = "Not scheduled yet") => {
if (!value) return fallback;
return new Intl.DateTimeFormat("en-GB", {
weekday: "short",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
};
export const formatDateTimeLocalInput = (value: string | null | undefined) => {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return localDate.toISOString().slice(0, 16);
};
const statusLabelForAssignment = (assignment: ApiAssignment, summary: ApiReviewSummary) => {
if (assignment.status === "closed") return "Closed";
if (summary.submitted > 0) return "Needs review";
if (summary.in_progress > 0) return "In progress";
return "Live";
};
const toneForAssignment = (assignment: ApiAssignment, summary: ApiReviewSummary): TeacherAssignmentTone => {
if (assignment.status === "closed") return "closed";
if (summary.submitted > 0) return "review";
return "live";
};
export const sortDueSoonest = (left: ApiAssignment, right: ApiAssignment) => {
const leftTime = left.due_at ? new Date(left.due_at).getTime() : Number.POSITIVE_INFINITY;
const rightTime = right.due_at ? new Date(right.due_at).getTime() : Number.POSITIVE_INFINITY;
return leftTime - rightTime;
};
export const sortNeedsAttention = (
left: ApiAssignment,
right: ApiAssignment,
summaries: Map<number, ApiReviewSummary>,
) => {
const leftSummary = summaries.get(left.id) ?? buildEmptyReviewSummary(left.id);
const rightSummary = summaries.get(right.id) ?? buildEmptyReviewSummary(right.id);
const leftPriority = leftSummary.submitted > 0 ? 0 : left.status === "assigned" ? 1 : 2;
const rightPriority = rightSummary.submitted > 0 ? 0 : right.status === "assigned" ? 1 : 2;
return leftPriority - rightPriority || sortDueSoonest(left, right);
};
export const isIndividualRedoAssignment = (assignment: ApiAssignment) => {
const normalizedTitle = assignment.title.trim().toLowerCase();
// Current student-specific redo assignments are created from the redo-plan page
// with the deterministic title shape: "{studentName} redo • {originalTitle}".
// Once we add a first-class assignment type or source field, replace this
// heuristic with that canonical backend signal.
return normalizedTitle.includes(" redo • ");
};
export const buildAssignmentItem = (
assignment: ApiAssignment,
classroom: ApiClassroom | undefined,
summary: ApiReviewSummary,
studentCount: number,
): TeacherAssignmentsFocusItem => {
const canOpenTeacherReview = assignment.status === "closed" || summary.submitted > 0 || summary.in_progress > 0;
const reviewedCoverage = summary.total_assigned > 0 ? Math.round((summary.reviewed / summary.total_assigned) * 100) : 0;
const note =
summary.submitted > 0
? `${summary.submitted} submission${summary.submitted === 1 ? " is" : "s are"} waiting for feedback right now.`
: summary.in_progress > 0
? `${summary.in_progress} student${summary.in_progress === 1 ? " is" : "s are"} actively working through this assignment.`
: "Students can start this assignment as soon as they are ready.";
return {
id: assignment.id,
title: assignment.title,
classroomName: classroom?.name ?? "Classroom",
statusLabel: statusLabelForAssignment(assignment, summary),
tone: toneForAssignment(assignment, summary),
dueLabel: `Due ${formatShortDate(assignment.due_at)}`,
queueLabel: summary.submitted > 0 ? `${summary.submitted} waiting for review` : summary.in_progress > 0 ? `${summary.in_progress} in progress` : `${summary.not_started} not started`,
coverageLabel: summary.total_assigned > 0 ? `${reviewedCoverage}% reviewed` : "No student work yet",
workloadLabel: `${summary.total_questions} question${summary.total_questions === 1 ? "" : "s"} · ${studentCount} student${studentCount === 1 ? "" : "s"}`,
note,
primaryHref: canOpenTeacherReview ? getAssignmentReviewHref("teacher", assignment.id) : null,
};
};

View File

@@ -0,0 +1,674 @@
.section {
display: grid;
gap: 1.15rem;
@include respond(desktop-sm) {
gap: 1.4rem;
}
}
.setupGrid {
display: grid;
gap: 1rem;
@include respond(desktop-md) {
grid-template-columns: minmax(0, 1fr);
align-items: start;
gap: 1.15rem;
}
}
.heroCard {
display: grid;
gap: 1.15rem;
padding: 1.2rem;
border-radius: var(--radius-3xl);
background: linear-gradient(135deg, color-mix(in srgb, var(--surface-panel) 88%, var(--surface-info) 12%), var(--surface-panel-strong));
border: 1px solid var(--border-strong);
box-shadow: var(--shadow-soft);
@include respond(desktop-md) {
grid-template-columns: minmax(0, 1.18fr) minmax(20rem, 0.98fr);
align-items: start;
padding: 1.45rem;
}
}
.setupCard,
.setupSummaryCard {
display: grid;
gap: 1rem;
padding: 1.05rem 1.15rem;
border-radius: 1.25rem;
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
}
.setupSummaryCard {
align-content: start;
gap: 1.1rem;
padding: 1.15rem 1.2rem;
background: var(--surface-panel);
border-color: var(--border-soft);
h2 {
font-size: 1.2rem;
line-height: 1.12;
letter-spacing: -0.03em;
}
@include respond(desktop-md) {
padding: 1.25rem 1.3rem;
}
}
.panelEyebrow {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.72rem;
font-weight: 700;
color: var(--text-subtle);
}
.setupHeader {
display: flex;
justify-content: space-between;
align-items: start;
gap: 1rem;
flex-wrap: wrap;
h2 {
font-size: 1.2rem;
line-height: 1.1;
letter-spacing: -0.03em;
}
p:last-child {
margin-top: 0.25rem;
color: var(--text-muted);
font-size: 0.92rem;
max-width: 60ch;
}
}
.setupBadge {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2rem;
padding: 0 0.8rem;
border-radius: var(--radius-full);
background: var(--surface-info-emphasis);
color: var(--text-info-strong);
font-size: 0.82rem;
font-weight: 700;
white-space: nowrap;
}
.fieldGrid {
display: grid;
gap: 0.85rem;
@include respond(desktop-sm) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.fieldLabel {
display: grid;
gap: 0.45rem;
span {
font-size: 0.86rem;
font-weight: 600;
color: var(--text-subtle);
}
}
.fieldLabelFull {
@include respond(desktop-sm) {
grid-column: 1 / -1;
}
}
.textInput,
.selectInput,
.textArea {
width: 100%;
padding: 0.85rem 0.95rem;
border-radius: var(--radius-lg);
border: 1px solid var(--border-soft);
background: var(--surface-panel-strong);
color: var(--text);
font: inherit;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
&:focus {
outline: none;
border-color: var(--border-info-strong);
box-shadow: var(--focus-ring-soft);
}
}
.textArea {
resize: vertical;
min-height: 8rem;
}
.toggleRow {
display: flex;
align-items: start;
gap: 0.8rem;
padding: 0.9rem 1rem;
border-radius: var(--radius-lg);
background: var(--surface-soft);
border: 1px solid var(--border-soft);
cursor: pointer;
input {
margin-top: 0.15rem;
}
strong {
display: block;
font-size: 0.95rem;
}
span {
display: block;
margin-top: 0.2rem;
font-size: 0.84rem;
color: var(--text-muted);
}
}
.generatorPanel {
display: grid;
gap: 0.85rem;
padding: 1rem;
border-radius: var(--radius-xl);
background: color-mix(in srgb, var(--surface-info) 10%, var(--surface-panel) 90%);
border: 1px solid color-mix(in srgb, var(--border-soft) 72%, var(--info) 28%);
}
.generatorActions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.generatorNote {
font-size: 0.84rem;
color: var(--text-muted);
max-width: 46ch;
}
.questionPicker {
display: grid;
gap: 0.75rem;
}
.questionPickerHeader {
display: flex;
align-items: end;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
h3 {
font-size: 1rem;
}
p {
margin-top: 0.2rem;
font-size: 0.88rem;
color: var(--text-muted);
}
small {
font-size: 0.82rem;
color: var(--text-muted);
}
}
.questionList {
display: grid;
gap: 0.75rem;
max-height: 28rem;
overflow: auto;
padding-right: 0.15rem;
}
.questionCard {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.75rem;
padding: 0.9rem;
border-radius: var(--radius-lg);
background: var(--surface-panel-strong);
border: 1px solid var(--border-soft);
cursor: pointer;
}
.questionCheck {
padding-top: 0.1rem;
}
.questionCopy {
display: grid;
gap: 0.35rem;
strong {
font-size: 0.96rem;
}
p {
font-size: 0.84rem;
color: var(--text-muted);
}
}
.questionMeta {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
span {
display: inline-flex;
align-items: center;
padding: 0.3rem 0.55rem;
border-radius: var(--radius-full);
background: var(--surface-soft);
color: var(--text-muted);
font-size: 0.76rem;
}
}
.questionEmptyState {
padding: 0.95rem 1rem;
border-radius: var(--radius-lg);
background: var(--surface-soft);
border: 1px dashed var(--border-soft);
color: var(--text-muted);
font-size: 0.88rem;
}
.formActions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.feedbackMessage {
padding: 0.82rem 0.95rem;
border-radius: var(--radius-lg);
font-size: 0.88rem;
border: 1px solid transparent;
}
.feedbackError {
background: var(--surface-warning-tint);
border-color: color-mix(in srgb, var(--warning) 30%, transparent);
color: var(--warning-text);
}
.feedbackSuccess {
background: var(--surface-info-soft-tint);
border-color: color-mix(in srgb, var(--info) 28%, transparent);
color: var(--text-info-strong);
}
.summaryList {
display: grid;
gap: 0.8rem;
}
.summaryRow {
display: flex;
justify-content: space-between;
gap: 0.8rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border-soft);
span {
font-size: 0.84rem;
color: var(--text-muted);
}
strong {
text-align: right;
font-size: 0.92rem;
}
&:last-child {
padding-bottom: 0;
border-bottom: 0;
}
}
.setupNote {
font-size: 0.86rem;
color: var(--text-muted);
line-height: 1.5;
}
.heroCopy {
display: grid;
gap: 0.55rem;
h1 {
font-size: clamp(1.8rem, 1.45rem + 1.1vw, 2.8rem);
line-height: 1;
letter-spacing: -0.04em;
max-width: 15ch;
}
p:last-child {
color: var(--text-muted);
font-size: 0.96rem;
max-width: 56ch;
}
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.75rem;
font-weight: 700;
color: var(--info);
}
.groupList {
display: grid;
gap: 1rem;
}
.setupSummaryCard .formActions {
display: grid;
gap: 0.7rem;
@include respond(desktop-sm) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.setupSummaryCard .primaryAction,
.setupSummaryCard .secondaryAction {
width: 100%;
justify-self: stretch;
}
.groupStack {
display: grid;
gap: 1rem;
}
.infoCard {
display: grid;
gap: 0.8rem;
padding: 1rem 1.1rem;
border-radius: 1.1rem;
background: var(--surface-panel);
border: 1px solid var(--border-soft);
color: var(--text-muted);
box-shadow: var(--shadow-soft);
h2 {
color: var(--text);
font-size: 1.05rem;
}
}
.group {
display: grid;
gap: 0.85rem;
}
.groupHeader {
display: flex;
align-items: end;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
h2 {
font-size: 1.2rem;
line-height: 1.1;
letter-spacing: -0.03em;
}
p {
margin-top: 0.2rem;
font-size: 0.92rem;
color: var(--text-muted);
}
}
.groupCount {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.1rem;
height: 2.1rem;
padding: 0 0.7rem;
border-radius: var(--radius-full);
background: var(--surface-info-emphasis);
color: var(--text-info-strong);
font-weight: 600;
font-size: 0.88rem;
}
.cardGrid {
display: grid;
gap: 0.9rem;
@media (min-width: 940px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (min-width: 1480px) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.assignmentCard {
display: grid;
gap: 0.95rem;
padding: 1rem;
border-radius: 1.3rem;
background: var(--surface-panel);
border: 1px solid var(--border-soft);
box-shadow: var(--shadow-soft);
min-width: 0;
align-content: start;
position: relative;
overflow: hidden;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
border-color 0.2s ease;
&::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 0.3rem;
background: var(--border-soft);
}
&:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-elevated);
}
}
.assignmentCardLink {
color: inherit;
text-decoration: none;
cursor: pointer;
&:hover {
text-decoration: none;
}
&:focus-visible {
outline: none;
border-color: var(--border-info-strong);
box-shadow: var(--focus-ring-soft), var(--shadow-elevated);
}
}
.assignmentCardReview {
border-color: color-mix(in srgb, var(--warning) 24%, var(--border-soft));
&::before {
background: var(--warning);
}
}
.assignmentCardLive {
border-color: color-mix(in srgb, var(--info) 22%, var(--border-soft));
&::before {
background: var(--info);
}
}
.assignmentCardClosed {
background: linear-gradient(180deg, color-mix(in srgb, var(--surface-panel) 82%, var(--surface-soft) 18%), var(--surface-panel));
&::before {
background: var(--border-soft);
}
}
.cardTop {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.statusChip {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.42rem 0.68rem;
border-radius: var(--radius-full);
font-size: 0.76rem;
font-weight: 700;
white-space: nowrap;
text-transform: uppercase;
letter-spacing: 0.05em;
border: 1px solid transparent;
}
.review {
background: var(--surface-warning-tint);
color: var(--warning-text);
border-color: var(--border-info-strong);
}
.live {
background: var(--surface-info-soft-tint);
color: var(--info);
border-color: var(--border-info-strong);
}
.closed {
background: var(--surface-soft);
color: var(--text-muted);
border-color: var(--border-soft);
}
.progressText {
font-size: 0.82rem;
color: var(--text-muted);
white-space: nowrap;
}
.cardBody {
display: grid;
gap: 0.35rem;
min-width: 0;
h3 {
font-size: 1rem;
font-weight: 600;
overflow-wrap: anywhere;
}
p {
font-size: 0.92rem;
color: var(--text-subtle);
font-weight: 600;
}
small {
font-size: 0.83rem;
color: var(--text-muted);
line-height: 1.45;
overflow-wrap: anywhere;
}
}
.cardMeta {
display: grid;
gap: 0.5rem;
@include respond(desktop-sm) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
span {
display: grid;
align-items: start;
padding: 0.6rem 0.72rem;
border-radius: 0.9rem;
background: var(--surface-soft);
color: var(--text-muted);
font-size: 0.8rem;
line-height: 1.35;
}
}
.primaryAction,
.secondaryAction {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.8rem 1rem;
border-radius: var(--radius-full);
font-weight: 500;
text-decoration: none;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
background-color 0.2s ease;
&:hover {
text-decoration: none;
transform: translateY(-1px);
}
}
.primaryAction {
background: linear-gradient(135deg, var(--action-primary-start), var(--action-primary-end));
color: var(--action-primary-text);
box-shadow: var(--action-primary-shadow);
justify-self: start;
}
.secondaryAction {
background: var(--surface-soft);
color: var(--text);
border: 1px solid var(--border-soft);
justify-self: start;
}

View File

@@ -0,0 +1,163 @@
import { A } from "@solidjs/router";
import type { Component } from "solid-js";
import { For, Show, createResource, createSignal, onMount } from "solid-js";
import { dashboardUiCopy } from "~/content/ui-copy";
import { useAuth } from "~/context/auth/context";
import { getDashboardAssignmentCreateHref, getDashboardAssignmentsHref, getDashboardHomeHref, getDashboardTeacherClosedAssignmentsHref } from "../../../lib/routes";
import styles from "./dashboard-teacher-assignments.module.scss";
import { getDashboardTeacherAssignmentsFocusData } from "./dashboard-teacher-assignments.data";
type DashboardTeacherAssignmentsProps = {
mode?: "all" | "closed";
};
const DashboardTeacherAssignments: Component<DashboardTeacherAssignmentsProps> = (props) => {
const auth = useAuth();
const [teacherId, setTeacherId] = createSignal<number | null>(null);
const [data] = createResource(teacherId, getDashboardTeacherAssignmentsFocusData);
const mode = () => props.mode ?? "all";
onMount(() => {
if (auth.user()?.role === "teacher") {
setTeacherId(auth.user()!.id);
}
});
return (
<section class={styles.section}>
<article class={styles.heroCard}>
<div class={styles.heroCopy}>
<p class={styles.eyebrow}>{mode() === "closed" ? dashboardUiCopy.teacherAssignments.eyebrow.closed : dashboardUiCopy.teacherAssignments.eyebrow.all}</p>
<h1>{mode() === "closed" ? dashboardUiCopy.teacherAssignments.title.closed : dashboardUiCopy.teacherAssignments.title.all}</h1>
<p>
{mode() === "closed"
? "Open older closed homework here to review what students previously completed, revisit feedback, and inspect final outcomes."
: "Keep live homework and review-ready work easy to scan here, then open the dedicated create page when you want to build something new."}
</p>
</div>
<div class={styles.setupGrid}>
<aside class={styles.setupSummaryCard}>
<p class={styles.panelEyebrow}>{mode() === "closed" ? dashboardUiCopy.teacherAssignments.panelEyebrow.closed : dashboardUiCopy.teacherAssignments.panelEyebrow.all}</p>
<h2>{mode() === "closed" ? dashboardUiCopy.teacherAssignments.panelTitle.closed : dashboardUiCopy.teacherAssignments.panelTitle.all}</h2>
<p class={styles.setupNote}>
{mode() === "closed"
? "Closed homework stays available here for reflection. Open any card below to revisit the full teacher review view for that assignment."
: "Open the dedicated assignment-create page to choose a classroom, attach questions, and create live homework."}
</p>
<div class={styles.formActions}>
<Show
when={mode() !== "closed"}
fallback={
<A href={getDashboardAssignmentsHref("teacher")} class={styles.primaryAction}>
{dashboardUiCopy.teacherAssignments.actions.backToAssignments}
</A>
}
>
<A href={getDashboardAssignmentCreateHref("teacher")} class={styles.primaryAction}>
{dashboardUiCopy.teacherAssignments.actions.createAssignment}
</A>
</Show>
<Show when={mode() !== "closed"}>
<A href={getDashboardTeacherClosedAssignmentsHref()} class={styles.secondaryAction}>
{dashboardUiCopy.teacherAssignments.actions.viewClosedHomework}
</A>
</Show>
<A href={getDashboardHomeHref("teacher")} class={styles.secondaryAction}>
{dashboardUiCopy.teacherAssignments.actions.backToOverview}
</A>
</div>
</aside>
</div>
</article>
<div class={styles.groupList}>
<Show when={!data.loading} fallback={<section class={styles.infoCard}>{dashboardUiCopy.teacherAssignments.loading}</section>}>
<Show when={(data()?.groups.filter((group) => mode() === "closed" ? group.title === "Closed" : true).length ?? 0) > 0} fallback={<section class={styles.infoCard}>{mode() === "closed" ? dashboardUiCopy.teacherAssignments.empty.closed : dashboardUiCopy.teacherAssignments.empty.all}</section>}>
<div class={styles.groupStack}>
<For each={(data()?.groups ?? []).filter((group) => (mode() === "closed" ? group.title === "Closed" : true))}>
{(group) => (
<section class={styles.group}>
<div class={styles.groupHeader}>
<div>
<h2>{group.title}</h2>
<p>{group.description}</p>
</div>
<span class={styles.groupCount}>{group.items.length}</span>
</div>
<div class={styles.cardGrid}>
<For each={group.items}>
{(item) => {
const cardContent = (
<>
<div class={styles.cardTop}>
<span
classList={{
[styles.statusChip]: true,
[styles.review]: item.tone === "review",
[styles.live]: item.tone === "live",
[styles.closed]: item.tone === "closed",
}}
>
{item.statusLabel}
</span>
<span class={styles.progressText}>{item.workloadLabel}</span>
</div>
<div class={styles.cardBody}>
<h3>{item.title}</h3>
<p>{item.classroomName}</p>
<small>{item.note}</small>
</div>
<div class={styles.cardMeta}>
<span>{item.dueLabel}</span>
<span>{item.queueLabel}</span>
<span>{item.coverageLabel}</span>
</div>
</>
);
return item.primaryHref ? (
<A
href={item.primaryHref}
classList={{
[styles.assignmentCard]: true,
[styles.assignmentCardLink]: true,
[styles.assignmentCardReview]: item.tone === "review",
[styles.assignmentCardLive]: item.tone === "live",
[styles.assignmentCardClosed]: item.tone === "closed",
}}
id={`teacher-assignment-${item.id}`}
>
{cardContent}
</A>
) : (
<article
classList={{
[styles.assignmentCard]: true,
[styles.assignmentCardReview]: item.tone === "review",
[styles.assignmentCardLive]: item.tone === "live",
[styles.assignmentCardClosed]: item.tone === "closed",
}}
id={`teacher-assignment-${item.id}`}
>
{cardContent}
</article>
);
}}
</For>
</div>
</section>
)}
</For>
</div>
</Show>
</Show>
</div>
</section>
);
};
export default DashboardTeacherAssignments;

View File

@@ -0,0 +1,90 @@
import type { ApiAssignment } from "../../../lib/api-types";
export type TeacherAssignmentSetupQuestion = {
id: number;
title: string;
promptPreview: string;
subjectLabel: string;
difficultyLabel: string;
sourceLabel: string;
statusLabel: string;
};
export type TeacherAssignmentSetupData = {
classrooms: {
id: number;
name: string;
description: string;
studentCountLabel: string;
}[];
questions: TeacherAssignmentSetupQuestion[];
};
export type MixedGenerationInput = {
primaryTopic: string;
primaryDifficulty: "easy" | "medium" | "hard";
totalQuestions: number;
personalizedRatio?: number;
seed?: number;
personalizedDifficulty?: "easy" | "medium" | "hard";
subject?: string;
questionStatus?: "draft" | "published" | "archived";
questionSource?: string;
};
export type GenerateTeacherQuestionsInput = {
topic: string;
difficulty: "easy" | "medium" | "hard";
count: number;
seed?: number;
status?: "draft" | "published" | "archived";
source?: string;
};
export type GenerateTeacherQuestionsResult = {
seed: number;
count: number;
questions: TeacherAssignmentSetupQuestion[];
generatedQuestionIds: number[];
};
export type TeacherAssignmentsFocusStat = {
label: string;
value: string;
note: string;
};
export type TeacherAssignmentsFocusItem = {
id: number;
title: string;
classroomName: string;
statusLabel: string;
tone: "review" | "live" | "closed";
dueLabel: string;
queueLabel: string;
coverageLabel: string;
workloadLabel: string;
note: string;
primaryHref: string | null;
};
export type TeacherAssignmentsFocusGroup = {
title: string;
description: string;
items: TeacherAssignmentsFocusItem[];
};
export type DashboardTeacherAssignmentsFocusData = {
stats: TeacherAssignmentsFocusStat[];
groups: TeacherAssignmentsFocusGroup[];
coachNote: {
title: string;
description: string;
ctaLabel: string;
ctaHref: string;
};
};
export type TeacherAssignmentTone = TeacherAssignmentsFocusItem["tone"];
export type IndividualRedoAssignment = ApiAssignment;

View File

@@ -0,0 +1,152 @@
import { apiFetchJson } from "../../../lib/api";
import type { ApiAssignment, ApiClassroom, ApiListResponse, ApiReviewQueueItem, ApiReviewSummary, ApiStudent } from "../../../lib/api-types";
import {
getAssignmentReviewHref,
getDashboardTeacherClassroomHref,
} from "../../../lib/routes";
import { teacherDashboardLabels } from "../../../content/dashboard-labels";
import { isIndividualRedoAssignment } from "./dashboard-teacher-assignments.data";
import {
buildTeacherShell,
formatRelativeTime,
formatShortDate,
initialsFor,
queueStatusLabel,
queueStatusTone,
studentNote,
} from "./dashboard-teacher-classroom-detail.helpers";
import type { TeacherClassroomDetailData, TeacherClassroomRedoAssignmentItem } from "./dashboard-teacher-classroom-detail.types";
export type { TeacherClassroomDetailData, TeacherClassroomDetailStudentItem, TeacherClassroomRedoAssignmentItem } from "./dashboard-teacher-classroom-detail.types";
export const getTeacherClassroomDetailData = async (
teacherId: number,
classroomId: number,
selectedStudentId?: number | null,
): Promise<TeacherClassroomDetailData | null> => {
const [classroomsResponse, assignmentsResponse] = await Promise.all([
apiFetchJson<ApiListResponse<ApiClassroom>>(`/api/teachers/${teacherId}/classrooms`),
apiFetchJson<ApiListResponse<ApiAssignment>>(`/api/teachers/${teacherId}/assignments`),
]);
const classrooms = classroomsResponse.data;
const classroom = classrooms.find((entry) => entry.id === classroomId);
if (!classroom) return null;
const classroomAssignments = assignmentsResponse.data.filter((assignment) => assignment.classroom_id === classroomId);
const redoAssignments = classroomAssignments.filter(isIndividualRedoAssignment);
const [studentsResponse, reviewSummaryEntries, reviewQueueEntries] = await Promise.all([
apiFetchJson<ApiListResponse<ApiStudent>>(`/api/classrooms/${classroomId}/students`),
Promise.all(classroomAssignments.map(async (assignment) => [assignment.id, await apiFetchJson<ApiReviewSummary>(`/api/assignments/${assignment.id}/review-summary`)] as const)),
Promise.all(redoAssignments.map(async (assignment) => [assignment.id, (await apiFetchJson<ApiListResponse<ApiReviewQueueItem>>(`/api/assignments/${assignment.id}/review`)).data] as const)),
]);
const students = studentsResponse.data.slice().sort((left, right) => left.full_name.localeCompare(right.full_name));
const reviewSummaryByAssignment = new Map(reviewSummaryEntries);
const reviewQueueByAssignment = new Map(reviewQueueEntries);
const selectedStudent =
(selectedStudentId != null ? students.find((student) => student.id === selectedStudentId) : null) ?? students[0] ?? null;
const studentItems = students.map((student) => {
const studentRedoRows = redoAssignments
.map((assignment) => ({ assignment, row: (reviewQueueByAssignment.get(assignment.id) ?? []).find((item) => item.student_id === student.id) ?? null }))
.filter((entry) => entry.row != null);
const liveRedoCount = studentRedoRows.filter((entry) => entry.assignment.status !== "closed").length;
const closedRedoCount = studentRedoRows.filter((entry) => entry.assignment.status === "closed").length;
const submittedCount = studentRedoRows.filter((entry) => (entry.row?.submitted_questions ?? 0) > 0).length;
return {
id: student.id,
name: student.full_name,
email: student.email,
initials: initialsFor(student.full_name),
statusLabel: submittedCount > 0 ? "Needs review" : liveRedoCount > 0 ? "Redo active" : closedRedoCount > 0 ? "Redo history" : "No redo",
redoCountLabel: `${studentRedoRows.length} redo assignment${studentRedoRows.length === 1 ? "" : "s"}`,
note: studentNote(submittedCount, liveRedoCount, closedRedoCount),
href: getDashboardTeacherClassroomHref(classroomId, student.id),
selected: student.id === selectedStudent?.id,
};
});
const selectedStudentRedoAssignments = selectedStudent
? redoAssignments
.map((assignment) => {
const queueRow = (reviewQueueByAssignment.get(assignment.id) ?? []).find((item) => item.student_id === selectedStudent.id);
if (!queueRow) return null;
const latestActivity = queueRow.latest_submitted_at ?? queueRow.latest_reviewed_at;
const summary = reviewSummaryByAssignment.get(assignment.id);
return {
id: assignment.id,
title: assignment.title,
statusLabel: assignment.status === "closed" ? "Closed" : queueStatusLabel(queueRow),
statusTone: assignment.status === "closed" ? "success" : queueStatusTone(queueRow),
dueLabel: `Due ${formatShortDate(assignment.due_at)}`,
progressLabel: `${queueRow.reviewed_questions}/${queueRow.total_questions} reviewed`,
nextStepLabel: queueRow.next_step_outcome ? `Next step: ${queueRow.next_step_outcome.replace(/_/g, " ")}` : "Next step still pending",
note:
(queueRow.submitted_questions ?? 0) > 0
? `${queueRow.submitted_questions} question${queueRow.submitted_questions === 1 ? " is" : "s are"} waiting for review.`
: summary && summary.reviewed > 0
? `Latest activity ${formatRelativeTime(latestActivity)}.`
: assignment.status === "closed"
? "Closed redo assignment kept here for this students history."
: "Assigned redo work with no submission yet.",
href: getAssignmentReviewHref("teacher", assignment.id),
};
})
.filter((item): item is TeacherClassroomRedoAssignmentItem => item != null)
.sort((left, right) => {
const leftClosed = left.statusLabel === "Closed" ? 1 : 0;
const rightClosed = right.statusLabel === "Closed" ? 1 : 0;
return leftClosed - rightClosed || left.title.localeCompare(right.title);
})
: [];
const liveRedoCount = redoAssignments.filter((assignment) => assignment.status !== "closed").length;
const classroomNeedsReviewCount = redoAssignments.reduce((total, assignment) => {
const rows = reviewQueueByAssignment.get(assignment.id) ?? [];
return total + rows.filter((row) => row.submitted_questions > 0).length;
}, 0);
return {
shell: buildTeacherShell(classroom.name, classroom.code, classrooms.length, classroomNeedsReviewCount),
classroom: {
id: classroom.id,
name: classroom.name,
description: classroom.description?.trim() || "Track each students follow-up work and individual redo assignments from here.",
codeLabel: classroom.code ? `Invite code ${classroom.code}` : "Invite code not set",
stats: [
{ label: teacherDashboardLabels.classroomDetail.students, value: `${students.length}`, note: students.length === 0 ? "No students in this classroom yet" : "Roster ready for drilldown" },
{ label: teacherDashboardLabels.classroomDetail.redoAssignments, value: `${redoAssignments.length}`, note: `${liveRedoCount} live · ${redoAssignments.length - liveRedoCount} closed` },
{ label: teacherDashboardLabels.classroomDetail.waitingReview, value: `${classroomNeedsReviewCount}`, note: classroomNeedsReviewCount > 0 ? "Student-specific redo work needs attention" : "Nothing urgent in redo follow-up" },
],
},
students: {
title: teacherDashboardLabels.classroomDetail.students,
description: "Pick a student to inspect their individual redo assignments in this classroom.",
items: studentItems,
},
selectedStudent: {
id: selectedStudent?.id ?? null,
name: selectedStudent?.full_name ?? null,
email: selectedStudent?.email ?? null,
note: selectedStudent
? selectedStudentRedoAssignments.length > 0
? `${selectedStudentRedoAssignments.length} redo assignment${selectedStudentRedoAssignments.length === 1 ? "" : "s"} tied to this student in ${classroom.name}.`
: "This student does not have any individual redo assignments in this classroom yet."
: "Select a student to view their individual redo assignments.",
},
redoAssignments: {
title: selectedStudent ? `${selectedStudent.full_name} redo assignments` : "Student redo assignments",
description: selectedStudent
? "These are the individual redo assignments created specifically for the selected student."
: "Select a student from the roster to inspect their individual redo assignments.",
items: selectedStudentRedoAssignments,
},
};
};

View File

@@ -0,0 +1,107 @@
import type { ApiReviewQueueItem } from "../../../lib/api-types";
import {
getDashboardAssignmentsHref,
getDashboardClassroomsHref,
getDashboardHomeHref,
getDashboardMessagesHref,
getDashboardSettingsHref,
} from "../../../lib/routes";
import type { DashboardHomeShellData, TopbarMessageItem } from "../shared/dashboard-types";
import type { TeacherClassroomRedoAssignmentItem } from "./dashboard-teacher-classroom-detail.types";
export const initialsFor = (name: string) =>
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase();
export const formatShortDate = (value: string | null | undefined, fallback = "No due date") => {
if (!value) return fallback;
return new Intl.DateTimeFormat("en-GB", {
month: "short",
day: "numeric",
}).format(new Date(value));
};
export const formatRelativeTime = (value: string | null | undefined, fallback = "No recent update") => {
if (!value) return fallback;
const deltaMs = Date.now() - new Date(value).getTime();
const deltaMinutes = Math.max(0, Math.round(deltaMs / (1000 * 60)));
if (deltaMinutes < 60) return `${deltaMinutes || 1}m ago`;
const deltaHours = Math.round(deltaMinutes / 60);
if (deltaHours < 24) return `${deltaHours}h ago`;
const deltaDays = Math.round(deltaHours / 24);
if (deltaDays < 7) return `${deltaDays}d ago`;
return formatShortDate(value, fallback);
};
export const buildTeacherShell = (
classroomName: string,
inviteCode: string | null | undefined,
classroomCount: number,
needsReviewCount: number,
): DashboardHomeShellData => ({
classroomSummary: {
name: classroomName,
inviteCode: inviteCode?.trim() || undefined,
tutorName: "Teacher workspace",
tutorRole: "Teaching dashboard",
tutorInitials: "TA",
},
sidebarLinks: [
{ label: "Home", detail: "Teaching overview", icon: "OV", href: getDashboardHomeHref("teacher") },
{ label: "Assignments", detail: "Homework setup", icon: "AS", href: getDashboardAssignmentsHref("teacher") },
{ label: "Classrooms", detail: "Roster & codes", icon: "CL", href: getDashboardClassroomsHref("teacher"), active: true },
{ label: "Messages", detail: "Inbox & follow-up", icon: "MS", href: getDashboardMessagesHref("teacher") },
{ label: "Settings", detail: "Profile & preferences", icon: "ST", href: getDashboardSettingsHref("teacher") },
],
sidebarSupport: {
avatars: ["CL", "RQ", "TA"],
title: "Teaching flow",
description:
needsReviewCount > 0
? `${needsReviewCount} submission${needsReviewCount === 1 ? " is" : "s are"} still waiting in the review queue.`
: `${classroomCount} classroom${classroomCount === 1 ? " is" : "s are"} connected right now.`,
buttonLabel: needsReviewCount > 0 ? "Open assignments" : "Back to classrooms",
buttonHref: needsReviewCount > 0 ? getDashboardAssignmentsHref("teacher") : getDashboardClassroomsHref("teacher"),
},
topbarSummary: {
searchPlaceholder: "Search classes, assignments, or students",
profileName: "Teacher",
profileRole: "Teaching dashboard",
profileBadge: "TA",
notificationCount: 0,
messageCount: 0,
},
topbarNotifications: [],
topbarMessages: [] as TopbarMessageItem[],
});
export const queueStatusLabel = (item: ApiReviewQueueItem) => {
if (item.next_step_outcome === "redo") return "Redo set";
if (item.next_step_outcome === "accept") return "Accepted";
if (item.next_step_outcome === "support") return "Support next";
if (item.review_status === "submitted") return "Needs review";
if (item.review_status === "reviewed") return "Reviewed";
if (item.review_status === "in_progress" || item.answered_questions > 0 || item.reviewed_questions > 0) return "In progress";
return "Not started";
};
export const queueStatusTone = (item: ApiReviewQueueItem): TeacherClassroomRedoAssignmentItem["statusTone"] => {
if (item.next_step_outcome === "accept") return "success";
if (item.next_step_outcome === "redo" || item.next_step_outcome === "support" || item.review_status === "submitted") return "review";
if (item.review_status === "in_progress" || item.answered_questions > 0 || item.reviewed_questions > 0) return "progress";
return "muted";
};
export const studentNote = (submittedCount: number, activeRedoCount: number, closedRedoCount: number) => {
if (submittedCount > 0) return `${submittedCount} redo assignment${submittedCount === 1 ? " is" : "s are"} waiting for review.`;
if (activeRedoCount > 0) return `${activeRedoCount} live redo assignment${activeRedoCount === 1 ? "" : "s"} currently assigned.`;
if (closedRedoCount > 0) return `${closedRedoCount} closed redo assignment${closedRedoCount === 1 ? "" : "s"} available for reference.`;
return "No individual redo assignments for this student yet.";
};

View File

@@ -0,0 +1,131 @@
import { A } from "@solidjs/router";
import { For, Show, type Component } from "solid-js";
import { getDashboardClassroomsHref } from "../../../lib/routes";
import type { TeacherClassroomDetailData } from "./dashboard-teacher-classroom-detail.data";
import styles from "./dashboard-teacher-classrooms.module.scss";
type Props = {
data: TeacherClassroomDetailData;
};
const DashboardTeacherClassroomDetail: Component<Props> = (props) => {
return (
<section class={styles.page}>
<article class={styles.heroCard}>
<div class={styles.heroCopy}>
<p class={styles.eyebrow}>Classroom view</p>
<h1>{props.data.classroom.name}</h1>
<p>{props.data.classroom.description}</p>
</div>
<div class={styles.heroStats}>
<For each={props.data.classroom.stats}>
{(stat) => (
<div class={styles.statCard}>
<span>{stat.label}</span>
<strong>{stat.value}</strong>
<small>{stat.note}</small>
</div>
)}
</For>
</div>
</article>
<section class={styles.classroomDetailMetaRow}>
<span class={styles.codePill}>{props.data.classroom.codeLabel}</span>
<A href={getDashboardClassroomsHref("teacher")} class={styles.manageLink}>
Back to classrooms
</A>
</section>
<section class={styles.classroomDetailLayout}>
<article class={styles.detailPanel}>
<div class={styles.detailPanelHeader}>
<div>
<p class={styles.eyebrow}>Roster</p>
<h2>{props.data.students.title}</h2>
</div>
<p>{props.data.students.description}</p>
</div>
<Show when={props.data.students.items.length} fallback={<section class={styles.emptyState}>No students are enrolled in this classroom yet.</section>}>
<div class={styles.studentList}>
<For each={props.data.students.items}>
{(student) => (
<A href={student.href} class={`${styles.studentCard} ${student.selected ? styles.studentCardSelected : ""}`.trim()}>
<div class={styles.studentCardHeader}>
<div class={styles.studentAvatar}>{student.initials}</div>
<div>
<h3>{student.name}</h3>
<p>{student.email}</p>
</div>
</div>
<div class={styles.metaRow}>
<span>{student.statusLabel}</span>
<span>{student.redoCountLabel}</span>
</div>
<p class={styles.studentNote}>{student.note}</p>
</A>
)}
</For>
</div>
</Show>
</article>
<article class={styles.detailPanel}>
<div class={styles.detailPanelHeader}>
<div>
<p class={styles.eyebrow}>Student follow-up</p>
<h2>{props.data.redoAssignments.title}</h2>
</div>
<p>{props.data.redoAssignments.description}</p>
</div>
<article class={styles.selectedStudentCard}>
<div>
<strong>{props.data.selectedStudent.name ?? "No student selected"}</strong>
<p>{props.data.selectedStudent.email ?? props.data.selectedStudent.note}</p>
</div>
<small>{props.data.selectedStudent.note}</small>
</article>
<Show
when={props.data.selectedStudent.id != null && props.data.redoAssignments.items.length > 0}
fallback={<section class={styles.emptyState}>Select a student to inspect their individual redo assignments. If none appear, this student does not have student-specific redo work yet.</section>}
>
<div class={styles.redoAssignmentList}>
<For each={props.data.redoAssignments.items}>
{(assignment) => (
<article class={styles.redoAssignmentCard}>
<div class={styles.cardHeader}>
<div>
<h3>{assignment.title}</h3>
<p>{assignment.note}</p>
</div>
<span class={`${styles.statusPill} ${styles[`statusPill${assignment.statusTone[0].toUpperCase()}${assignment.statusTone.slice(1)}`]}`}>{assignment.statusLabel}</span>
</div>
<div class={styles.metaRow}>
<span>{assignment.dueLabel}</span>
<span>{assignment.progressLabel}</span>
<span>{assignment.nextStepLabel}</span>
</div>
<div class={styles.reviewRow}>
<strong>{assignment.statusLabel}</strong>
<A href={assignment.href} class={styles.manageLink}>
Open review
</A>
</div>
</article>
)}
</For>
</div>
</Show>
</article>
</section>
</section>
);
};
export default DashboardTeacherClassroomDetail;

View File

@@ -0,0 +1,52 @@
import type { DashboardHomeShellData } from "../shared/dashboard-types";
export type TeacherClassroomDetailStudentItem = {
id: number;
name: string;
email: string;
initials: string;
statusLabel: string;
redoCountLabel: string;
note: string;
href: string;
selected: boolean;
};
export type TeacherClassroomRedoAssignmentItem = {
id: number;
title: string;
statusLabel: string;
statusTone: "review" | "progress" | "muted" | "success";
dueLabel: string;
progressLabel: string;
nextStepLabel: string;
note: string;
href: string;
};
export type TeacherClassroomDetailData = {
shell: DashboardHomeShellData;
classroom: {
id: number;
name: string;
description: string;
codeLabel: string;
stats: Array<{ label: string; value: string; note: string }>;
};
students: {
title: string;
description: string;
items: TeacherClassroomDetailStudentItem[];
};
selectedStudent: {
id: number | null;
name: string | null;
email: string | null;
note: string;
};
redoAssignments: {
title: string;
description: string;
items: TeacherClassroomRedoAssignmentItem[];
};
};

View File

@@ -0,0 +1,341 @@
.page {
display: grid;
gap: 1.1rem;
}
.heroCard,
.classroomCard,
.emptyState {
background: var(--surface-panel);
border: 1px solid var(--border-strong);
border-radius: var(--radius-3xl);
box-shadow: var(--shadow-soft);
padding: 1.1rem;
}
.heroCard {
display: grid;
gap: 1rem;
background:
linear-gradient(135deg, color-mix(in srgb, var(--surface-panel) 86%, var(--surface-info) 14%), var(--surface-panel)),
var(--surface-panel);
@include respond(desktop-md) {
grid-template-columns: minmax(0, 1.2fr) minmax(16rem, 0.8fr);
align-items: start;
}
}
.heroCopy {
display: grid;
gap: 0.5rem;
h1 {
font-size: clamp(1.45rem, 2vw, 2.1rem);
line-height: 1.06;
max-width: 18ch;
}
p:last-child {
color: var(--text-muted);
max-width: 58ch;
}
}
.eyebrow {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--info);
}
.heroStats {
display: grid;
gap: 0.8rem;
}
.statCard {
display: grid;
gap: 0.3rem;
padding: 0.95rem;
border-radius: var(--radius-lg);
background: var(--surface-panel-strong);
border: 1px solid var(--border-soft);
span {
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-subtle);
}
strong {
font-size: 1.55rem;
line-height: 1;
}
small {
color: var(--text-muted);
}
}
.grid {
display: grid;
gap: 0.9rem;
@media (min-width: 1100px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.classroomCard {
display: grid;
gap: 0.9rem;
background: var(--surface-panel-strong);
border-color: var(--border-soft);
}
.cardHeader {
display: flex;
justify-content: space-between;
align-items: start;
gap: 0.8rem;
h2 {
font-size: 1.08rem;
margin-bottom: 0.18rem;
}
p {
color: var(--text-muted);
}
}
.codePill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.4rem 0.66rem;
border-radius: var(--radius-pill);
background: var(--surface-soft);
color: var(--text-muted);
font-size: 0.78rem;
font-weight: 700;
white-space: nowrap;
}
.metaRow {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
span {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.42rem 0.68rem;
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--surface-info-soft-tint) 70%, white 30%);
color: var(--info);
font-size: 0.8rem;
font-weight: 600;
}
}
.reviewRow {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.8rem;
padding-top: 0.2rem;
strong {
color: var(--text-main);
}
}
.manageLink {
padding: 0.66rem 0.9rem;
border-radius: var(--radius-pill);
background: var(--surface-info);
color: var(--info);
font-weight: 600;
border: 1px solid var(--border-info-soft);
&:hover {
text-decoration: none;
background: var(--surface-info-tint);
}
}
.emptyState {
color: var(--text-muted);
border-style: dashed;
background: var(--surface-soft);
}
.classroomDetailMetaRow {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.8rem;
flex-wrap: wrap;
}
.classroomDetailLayout {
display: grid;
gap: 1rem;
@media (min-width: 1100px) {
grid-template-columns: minmax(18rem, 0.8fr) minmax(0, 1.2fr);
align-items: start;
}
}
.detailPanel,
.studentCard,
.selectedStudentCard,
.redoAssignmentCard {
background: var(--surface-panel);
border: 1px solid var(--border-strong);
border-radius: var(--radius-3xl);
box-shadow: var(--shadow-soft);
padding: 1rem;
}
.detailPanel {
display: grid;
gap: 1rem;
}
.detailPanelHeader {
display: grid;
gap: 0.35rem;
h2 {
font-size: 1.15rem;
}
p:last-child {
color: var(--text-muted);
}
}
.studentList,
.redoAssignmentList {
display: grid;
gap: 0.85rem;
}
.studentCard {
display: grid;
gap: 0.75rem;
background: var(--surface-panel-strong);
border-color: var(--border-soft);
transition:
border-color 140ms ease,
transform 140ms ease,
box-shadow 140ms ease;
&:hover {
text-decoration: none;
transform: translateY(-1px);
border-color: var(--border-info-soft);
}
}
.studentCardSelected {
border-color: var(--border-info-soft);
background: color-mix(in srgb, var(--surface-info) 12%, var(--surface-panel-strong) 88%);
}
.studentCardHeader {
display: flex;
align-items: center;
gap: 0.85rem;
h3 {
font-size: 1rem;
margin-bottom: 0.15rem;
}
p {
color: var(--text-muted);
word-break: break-word;
}
}
.studentAvatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.6rem;
height: 2.6rem;
border-radius: 999px;
background: var(--surface-info);
color: var(--info);
font-weight: 700;
flex-shrink: 0;
}
.studentNote {
color: var(--text-muted);
font-size: 0.95rem;
}
.selectedStudentCard {
display: grid;
gap: 0.45rem;
background: color-mix(in srgb, var(--surface-info) 12%, var(--surface-panel) 88%);
border-color: var(--border-info-soft);
p,
small {
color: var(--text-muted);
}
}
.statusPill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.42rem 0.72rem;
border-radius: var(--radius-pill);
font-size: 0.8rem;
font-weight: 700;
white-space: nowrap;
border: 1px solid transparent;
}
.statusPillReview {
background: color-mix(in srgb, var(--surface-warning) 24%, white 76%);
color: var(--warning-strong);
border-color: color-mix(in srgb, var(--warning) 26%, var(--border-soft) 74%);
}
.statusPillProgress {
background: color-mix(in srgb, var(--surface-info) 24%, white 76%);
color: var(--info);
border-color: color-mix(in srgb, var(--info) 26%, var(--border-soft) 74%);
}
.statusPillMuted {
background: var(--surface-soft);
color: var(--text-muted);
border-color: var(--border-soft);
}
.statusPillSuccess {
background: color-mix(in srgb, var(--surface-success) 20%, white 80%);
color: var(--success-strong);
border-color: color-mix(in srgb, var(--success) 24%, var(--border-soft) 76%);
}
.redoAssignmentCard {
display: grid;
gap: 0.9rem;
background: var(--surface-panel-strong);
border-color: var(--border-soft);
}

View File

@@ -0,0 +1,67 @@
import { A } from "@solidjs/router";
import { For, Show, type Component } from "solid-js";
import { getDashboardTeacherClassroomHref } from "../../../lib/routes";
import type { TeacherDashboardHomeData } from "./dashboard-teacher-home.data";
import styles from "./dashboard-teacher-classrooms.module.scss";
type DashboardTeacherClassroomsProps = {
data: TeacherDashboardHomeData;
};
const DashboardTeacherClassrooms: Component<DashboardTeacherClassroomsProps> = (props) => {
return (
<section class={styles.page}>
<article class={styles.heroCard}>
<div class={styles.heroCopy}>
<p class={styles.eyebrow}>Classrooms</p>
<h1>Keep each class organised without crowding the home dashboard</h1>
<p>{props.data.classrooms.description}</p>
</div>
<div class={styles.heroStats}>
<div class={styles.statCard}>
<span>Classrooms</span>
<strong>{props.data.classrooms.items.length}</strong>
</div>
<div class={styles.statCard}>
<span>Students</span>
<strong>{props.data.overview.stats.find((stat) => stat.label === "Students")?.value ?? "0"}</strong>
</div>
</div>
</article>
<Show when={props.data.classrooms.items.length} fallback={<section class={styles.emptyState}>No classrooms are connected to this teacher yet.</section>}>
<div class={styles.grid}>
<For each={props.data.classrooms.items}>
{(item) => (
<article class={styles.classroomCard}>
<div class={styles.cardHeader}>
<div>
<h2>{item.name}</h2>
<p>{item.note}</p>
</div>
<span class={styles.codePill}>{item.codeLabel}</span>
</div>
<div class={styles.metaRow}>
<span>{item.studentCountLabel}</span>
<span>{item.assignmentCountLabel}</span>
<span>{item.liveLabel}</span>
</div>
<div class={styles.reviewRow}>
<strong>{item.reviewLabel}</strong>
<A href={getDashboardTeacherClassroomHref(item.id)} class={styles.manageLink}>
Open classroom
</A>
</div>
</article>
)}
</For>
</div>
</Show>
</section>
);
};
export default DashboardTeacherClassrooms;

View File

@@ -0,0 +1,341 @@
// Path: Frontend/src/components/dashboard/dashboard-teacher-home.data.ts
import {
getAssignmentReviewHref,
getDashboardAssignmentsHref,
getDashboardClassroomsHref,
getDashboardHomeHref,
getDashboardMessagesHref,
getDashboardSettingsHref,
} from "../../../lib/routes";
import { apiFetchJson } from "../../../lib/api";
import { teacherDashboardLabels } from "../../../content/dashboard-labels";
import type {
ApiAssignment,
ApiClassroom,
ApiListResponse,
ApiReviewQueueItem,
ApiReviewSummary,
ApiStudent,
} from "../../../lib/api-types";
import type { DashboardHomeShellData, TopbarMessageItem } from "../shared/dashboard-types";
type TeacherOverviewStat = {
label: string;
value: string;
note: string;
};
type TeacherOverviewAction = {
label: string;
description: string;
href: string;
tone: "primary" | "secondary";
};
type TeacherReviewQueueItem = {
studentName: string;
assignmentTitle: string;
classroomName: string;
progressLabel: string;
statusLabel: string;
timestampLabel: string;
href: string;
initials: string;
};
type TeacherAssignmentCard = {
id: number;
title: string;
classroomName: string;
statusLabel: string;
dueLabel: string;
queueLabel: string;
completionLabel: string;
href: string;
};
type TeacherClassroomCard = {
id: number;
name: string;
codeLabel: string;
studentCountLabel: string;
assignmentCountLabel: string;
reviewLabel: string;
liveLabel: string;
note: string;
};
export type TeacherDashboardHomeData = {
shell: DashboardHomeShellData;
overview: {
eyebrow: string;
title: string;
description: string;
stats: TeacherOverviewStat[];
actions: TeacherOverviewAction[];
};
reviewQueue: {
title: string;
description: string;
items: TeacherReviewQueueItem[];
};
assignmentFocus: {
title: string;
description: string;
items: TeacherAssignmentCard[];
};
classrooms: {
title: string;
description: string;
items: TeacherClassroomCard[];
};
};
const initialsFor = (name: string) =>
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase();
const compact = (value: number) => new Intl.NumberFormat("en-GB", { notation: "compact", maximumFractionDigits: 1 }).format(value);
const formatShortDate = (value: string | null | undefined) => {
if (!value) return "No due date";
return new Intl.DateTimeFormat("en-GB", {
month: "short",
day: "numeric",
}).format(new Date(value));
};
const formatRelativeTime = (value: string | null | undefined) => {
if (!value) return "No recent updates";
const deltaMs = Date.now() - new Date(value).getTime();
const deltaMinutes = Math.max(0, Math.round(deltaMs / (1000 * 60)));
if (deltaMinutes < 60) return `${deltaMinutes || 1}m ago`;
const deltaHours = Math.round(deltaMinutes / 60);
if (deltaHours < 24) return `${deltaHours}h ago`;
const deltaDays = Math.round(deltaHours / 24);
if (deltaDays < 7) return `${deltaDays}d ago`;
return formatShortDate(value);
};
const statusLabelForAssignment = (assignment: ApiAssignment, summary?: ApiReviewSummary) => {
if (assignment.status === "draft") return "Draft";
if (assignment.status === "closed") return "Closed";
if ((summary?.submitted ?? 0) > 0) return "Needs review";
if ((summary?.in_progress ?? 0) > 0) return "In progress";
return "Assigned";
};
const statusPriorityForAssignment = (assignment: ApiAssignment, summary?: ApiReviewSummary) => {
if ((summary?.submitted ?? 0) > 0) return 0;
if ((summary?.in_progress ?? 0) > 0) return 1;
if (assignment.status === "assigned") return 2;
if (assignment.status === "draft") return 3;
return 4;
};
const sortByDueSoonest = (left: ApiAssignment, right: ApiAssignment) => {
const leftTime = left.due_at ? new Date(left.due_at).getTime() : Number.POSITIVE_INFINITY;
const rightTime = right.due_at ? new Date(right.due_at).getTime() : Number.POSITIVE_INFINITY;
return leftTime - rightTime;
};
export const getTeacherDashboardHomeData = async (teacherId: number): Promise<TeacherDashboardHomeData> => {
const [assignmentResponse, classroomResponse] = await Promise.all([
apiFetchJson<ApiListResponse<ApiAssignment>>(`/api/teachers/${teacherId}/assignments`),
apiFetchJson<ApiListResponse<ApiClassroom>>(`/api/teachers/${teacherId}/classrooms`),
]);
const assignments = assignmentResponse.data;
const classrooms = classroomResponse.data;
const [studentsByClassroomEntries, reviewSummaryEntries, reviewQueueEntries] = await Promise.all([
Promise.all(classrooms.map(async (classroom) => [classroom.id, (await apiFetchJson<ApiListResponse<ApiStudent>>(`/api/classrooms/${classroom.id}/students`)).data] as const)),
Promise.all(assignments.map(async (assignment) => [assignment.id, await apiFetchJson<ApiReviewSummary>(`/api/assignments/${assignment.id}/review-summary`)] as const)),
Promise.all(assignments.map(async (assignment) => [assignment.id, (await apiFetchJson<ApiListResponse<ApiReviewQueueItem>>(`/api/assignments/${assignment.id}/review?status=submitted`)).data] as const)),
]);
const studentsByClassroom = new Map(studentsByClassroomEntries);
const reviewSummaryByAssignment = new Map(reviewSummaryEntries);
const reviewQueueByAssignment = new Map(reviewQueueEntries);
const classroomById = new Map(classrooms.map((classroom) => [classroom.id, classroom]));
const uniqueStudentIds = new Set<number>();
for (const students of studentsByClassroom.values()) {
for (const student of students) uniqueStudentIds.add(student.id);
}
const liveAssignments = assignments.filter((assignment) => assignment.status === "assigned");
const activeAssignments = assignments.filter((assignment) => assignment.status !== "closed");
const draftAssignments = assignments.filter((assignment) => assignment.status === "draft");
const needsReviewCount = assignments.reduce((total, assignment) => total + (reviewSummaryByAssignment.get(assignment.id)?.submitted ?? 0), 0);
const reviewedCount = assignments.reduce((total, assignment) => total + (reviewSummaryByAssignment.get(assignment.id)?.reviewed ?? 0), 0);
const assignedCount = assignments.reduce((total, assignment) => total + (reviewSummaryByAssignment.get(assignment.id)?.total_assigned ?? 0), 0);
const reviewCoverage = assignedCount > 0 ? Math.round((reviewedCount / assignedCount) * 100) : 0;
const nextDueAssignment = liveAssignments
.slice()
.sort(sortByDueSoonest)
.find((assignment) => assignment.due_at);
const reviewQueueItems = assignments
.flatMap((assignment) => {
const classroom = classroomById.get(assignment.classroom_id);
return (reviewQueueByAssignment.get(assignment.id) ?? []).map((item) => ({
studentName: item.student_name,
assignmentTitle: assignment.title,
classroomName: classroom?.name ?? "Classroom",
progressLabel: `${item.reviewed_questions}/${item.total_questions} reviewed`,
statusLabel: item.review_status.replaceAll("_", " "),
timestampLabel: formatRelativeTime(item.latest_submitted_at ?? item.latest_reviewed_at),
sortTimestamp: item.latest_submitted_at ?? item.latest_reviewed_at ?? "",
href: getAssignmentReviewHref("teacher", assignment.id),
initials: initialsFor(item.student_name),
}));
})
.sort((left, right) => {
const leftWeight = left.sortTimestamp ? new Date(left.sortTimestamp).getTime() : 0;
const rightWeight = right.sortTimestamp ? new Date(right.sortTimestamp).getTime() : 0;
return rightWeight - leftWeight;
})
.map(({ sortTimestamp: _sortTimestamp, ...item }) => item)
.slice(0, 6);
const assignmentHealthItems = (activeAssignments.length > 0 ? activeAssignments : assignments)
.slice()
.sort((left, right) => {
const leftSummary = reviewSummaryByAssignment.get(left.id);
const rightSummary = reviewSummaryByAssignment.get(right.id);
return statusPriorityForAssignment(left, leftSummary) - statusPriorityForAssignment(right, rightSummary) || sortByDueSoonest(left, right);
})
.slice(0, 6)
.map((assignment) => {
const summary = reviewSummaryByAssignment.get(assignment.id);
const classroom = classroomById.get(assignment.classroom_id);
const totalAssigned = summary?.total_assigned ?? 0;
const reviewed = summary?.reviewed ?? 0;
const submitted = summary?.submitted ?? 0;
const inProgress = summary?.in_progress ?? 0;
const completion = totalAssigned > 0 ? Math.round((reviewed / totalAssigned) * 100) : 0;
return {
id: assignment.id,
title: assignment.title,
classroomName: classroom?.name ?? "Classroom",
statusLabel: statusLabelForAssignment(assignment, summary),
dueLabel: assignment.status === "draft" ? "Publish when ready" : formatShortDate(assignment.due_at),
queueLabel:
assignment.status === "draft"
? "Not published yet"
: submitted > 0
? `${submitted} submission${submitted === 1 ? "" : "s"} waiting`
: `${inProgress} in progress`,
completionLabel: totalAssigned > 0 ? `${completion}% reviewed` : "No student attempts yet",
href: submitted > 0 ? getAssignmentReviewHref("teacher", assignment.id) : getDashboardAssignmentsHref("teacher"),
};
});
const classroomCards = classrooms.map((classroom) => {
const students = studentsByClassroom.get(classroom.id) ?? [];
const classroomAssignments = assignments.filter((assignment) => assignment.classroom_id === classroom.id);
const liveCount = classroomAssignments.filter((assignment) => assignment.status === "assigned").length;
const classroomReviewCount = classroomAssignments.reduce((total, assignment) => total + (reviewSummaryByAssignment.get(assignment.id)?.submitted ?? 0), 0);
const nextDue = classroomAssignments
.filter((assignment) => assignment.status === "assigned")
.slice()
.sort(sortByDueSoonest)
.find((assignment) => assignment.due_at);
return {
id: classroom.id,
name: classroom.name,
codeLabel: classroom.code ? `Invite code ${classroom.code}` : "Invite code not set",
studentCountLabel: `${students.length} student${students.length === 1 ? "" : "s"}`,
assignmentCountLabel: `${classroomAssignments.length} assignment${classroomAssignments.length === 1 ? "" : "s"}`,
reviewLabel: classroomReviewCount > 0 ? `${classroomReviewCount} waiting for review` : "Nothing waiting for review",
liveLabel: `${liveCount} live assignment${liveCount === 1 ? "" : "s"}`,
note: nextDue ? `${teacherDashboardLabels.common.nextDue} ${formatShortDate(nextDue.due_at)}` : "No due dates scheduled yet",
};
});
const shell: DashboardHomeShellData = {
classroomSummary: {
name: classrooms.length > 0 ? `${classrooms.length} classroom${classrooms.length === 1 ? "" : "s"}` : "Teacher workspace",
tutorName: "Teacher workspace",
tutorRole: "Teaching dashboard",
tutorInitials: "TA",
},
sidebarLinks: [
{ label: "Home", detail: "Teaching overview", icon: "OV", href: getDashboardHomeHref("teacher"), active: true },
{ label: "Assignments", detail: "Homework setup", icon: "AS", href: getDashboardAssignmentsHref("teacher") },
{ label: "Classrooms", detail: "Roster & codes", icon: "CL", href: getDashboardClassroomsHref("teacher") },
{ label: "Messages", detail: "Inbox & follow-up", icon: "MS", href: getDashboardMessagesHref("teacher") },
{ label: "Settings", detail: "Profile & preferences", icon: "ST", href: getDashboardSettingsHref("teacher") },
],
sidebarSupport: {
avatars: ["RQ", "AS", "CL"],
title: "Review queue",
description: needsReviewCount > 0 ? `${needsReviewCount} submission${needsReviewCount === 1 ? " is" : "s are"} ready for review.` : "Everything is caught up right now.",
buttonLabel: "Open assignments",
buttonHref: getDashboardAssignmentsHref("teacher"),
},
topbarSummary: {
searchPlaceholder: "Search classes, assignments, or students",
profileName: "Teacher",
profileRole: "Teaching dashboard",
profileBadge: "TA",
notificationCount: 0,
messageCount: 0,
},
topbarNotifications: [],
topbarMessages: [] as TopbarMessageItem[],
};
return {
shell,
overview: {
eyebrow: "Teacher dashboard",
title: needsReviewCount > 0 ? "Start with student work that needs feedback" : "Your teaching dashboard is ready for the next move",
description:
needsReviewCount > 0
? `You have ${needsReviewCount} submission${needsReviewCount === 1 ? "" : "s"} waiting. Clear the review queue first, then head into assignments to publish or adjust homework.`
: nextDueAssignment != null
? `${teacherDashboardLabels.common.nextDue}: ${nextDueAssignment.title} on ${formatShortDate(nextDueAssignment.due_at)}. Use this page to decide whether to review work, publish drafts, or check classrooms.`
: draftAssignments.length > 0
? `You have ${draftAssignments.length} draft assignment${draftAssignments.length === 1 ? "" : "s"} ready for the next publishing pass.`
: "Use this page to jump into reviews, homework setup, and classroom organisation without scanning extra noise.",
stats: [
{ label: teacherDashboardLabels.home.needsReview, value: compact(needsReviewCount), note: needsReviewCount > 0 ? "Students waiting for feedback" : "No submissions waiting" },
{ label: teacherDashboardLabels.home.draftsToPublish, value: compact(draftAssignments.length), note: liveAssignments.length > 0 ? `${compact(liveAssignments.length)} live right now` : "No live homework yet" },
{ label: teacherDashboardLabels.common.students, value: compact(uniqueStudentIds.size), note: `${classrooms.length} classroom${classrooms.length === 1 ? "" : "s"} in rotation` },
{ label: teacherDashboardLabels.home.reviewCoverage, value: `${reviewCoverage}%`, note: `${compact(reviewedCount)} of ${compact(assignedCount)} assigned reviews completed` },
],
actions: [
{ label: needsReviewCount > 0 ? "Review submissions" : "Open assignments", description: needsReviewCount > 0 ? "Jump straight into student work waiting for feedback." : "Manage drafts and live homework from one place.", href: getDashboardAssignmentsHref("teacher"), tone: "primary" },
{ label: "View classrooms", description: "Check rosters, invite codes, and classroom load.", href: getDashboardClassroomsHref("teacher"), tone: "secondary" },
],
},
reviewQueue: {
title: "Review queue",
description: "These are the freshest student submissions waiting for your attention right now.",
items: reviewQueueItems,
},
assignmentFocus: {
title: "Assignments to watch next",
description: "Keep homework setup and live teaching work visible without turning the home page into a full management board.",
items: assignmentHealthItems,
},
classrooms: {
title: "Classrooms",
description: "See each class at a glance with roster size, invite code, and workload signals.",
items: classroomCards,
},
};
};

View File

@@ -0,0 +1,478 @@
.teacherPage {
display: grid;
gap: 1.15rem;
}
.heroCard,
.sectionCard {
background: var(--surface-panel);
border: 1px solid var(--border-strong);
border-radius: var(--radius-3xl);
box-shadow: var(--shadow-soft);
padding: 1.2rem;
min-width: 0;
@include respond(desktop-sm) {
padding: 1.35rem;
}
}
.heroCard {
display: grid;
gap: 1.25rem;
background:
linear-gradient(135deg, color-mix(in srgb, var(--surface-panel) 84%, var(--surface-info) 16%), var(--surface-panel)),
var(--surface-panel);
@include respond(desktop-md) {
grid-template-columns: minmax(0, 1.28fr) minmax(18rem, 0.92fr);
align-items: start;
}
}
.eyebrow,
.sectionEyebrow {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--info);
}
.heroSummary {
display: grid;
gap: 0.9rem;
min-width: 0;
h1 {
font-size: clamp(1.5rem, 2vw, 2.2rem);
line-height: 1.05;
max-width: 18ch;
}
}
.heroDescription {
color: var(--text-muted);
max-width: 58ch;
line-height: 1.55;
}
.heroActions {
display: grid;
gap: 0.8rem;
@include respond(compact) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.heroAction {
display: grid;
gap: 0.3rem;
min-width: 0;
padding: 0.95rem 1rem;
border-radius: var(--radius-lg);
border: 1px solid var(--border-soft);
transition:
background-color 160ms ease,
border-color 160ms ease,
transform 160ms ease;
strong,
span {
overflow-wrap: anywhere;
}
strong {
font-size: 0.98rem;
}
span {
font-size: 0.9rem;
color: var(--text-muted);
}
&:hover {
text-decoration: none;
transform: translateY(-1px);
}
}
.heroActionPrimary {
background: linear-gradient(135deg, color-mix(in srgb, var(--surface-info) 82%, white 18%), var(--surface-info));
border-color: var(--border-info-emphasis);
strong {
color: var(--info);
}
span {
color: color-mix(in srgb, var(--info) 70%, var(--text-muted) 30%);
}
&:hover {
background: var(--surface-info-tint);
}
&:focus-visible {
outline: 2px solid var(--border-info-strong);
outline-offset: 2px;
}
}
.heroActionSecondary {
background: var(--surface-panel-strong);
border-color: var(--border-soft);
strong {
color: var(--text-main);
}
&:hover {
background: var(--surface-soft);
border-color: color-mix(in srgb, var(--border-soft) 72%, var(--info) 28%);
}
}
.statCardsGrid {
display: grid;
gap: 1rem;
min-width: 0;
}
.statCardsGrid {
@include respond(desktop-md) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.reviewQueueList,
.assignmentList {
display: grid;
gap: 1rem;
min-width: 0;
}
.assignmentList {
@media (min-width: 1280px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.assignmentList {
@media (min-width: 1680px) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.statCard {
padding: 1.05rem;
border-radius: var(--radius-lg);
background: linear-gradient(180deg, color-mix(in srgb, var(--surface-panel-strong) 88%, white 12%), var(--surface-panel-strong));
border: 1px solid var(--border-soft);
display: grid;
gap: 0.55rem;
align-content: start;
min-height: 8.8rem;
p {
color: var(--text-muted);
font-size: 0.88rem;
line-height: 1.45;
}
@include respond(desktop-md) {
min-height: 9.2rem;
}
}
.statValue {
font-size: 1.7rem;
line-height: 1;
letter-spacing: -0.02em;
@include respond(desktop-md) {
font-size: 1.76rem;
}
@media (min-width: 1280px) {
font-size: 1.82rem;
}
}
.statLabel {
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-subtle);
}
.sectionHeader {
display: grid;
gap: 0.4rem;
margin-bottom: 1.1rem;
h2 {
font-size: 1.1rem;
}
p {
color: var(--text-muted);
max-width: 62ch;
line-height: 1.5;
}
}
.listCard,
.assignmentCard {
display: grid;
gap: 0.95rem;
padding: 1.05rem;
border-radius: var(--radius-lg);
border: 1px solid var(--border-soft);
background: linear-gradient(180deg, color-mix(in srgb, var(--surface-panel-strong) 86%, white 14%), var(--surface-panel-strong));
min-width: 0;
transition:
background-color 160ms ease,
border-color 160ms ease,
transform 160ms ease,
box-shadow 160ms ease;
}
.listCard {
grid-template-columns: minmax(0, 1fr);
overflow: hidden;
@media (min-width: 760px) {
grid-template-columns: minmax(0, 1.25fr) minmax(0, 0.8fr) auto;
align-items: center;
}
&:hover {
text-decoration: none;
background: var(--surface-info-tint);
border-color: var(--border-info-emphasis);
transform: translateY(-1px);
}
}
.detailBlock {
display: grid;
gap: 0.18rem;
min-width: 0;
}
.detailKicker {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-subtle);
}
.detailValue {
color: var(--text-muted);
overflow-wrap: anywhere;
}
.listLead {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.8rem;
align-items: center;
min-width: 0;
}
.listDetails {
display: grid;
gap: 0.2rem;
align-content: center;
small {
color: var(--text-muted);
}
}
.avatar {
width: 2.4rem;
height: 2.4rem;
display: grid;
place-items: center;
border-radius: var(--radius-pill);
background: var(--surface-info);
color: var(--info);
font-weight: 700;
font-size: 0.82rem;
flex-shrink: 0;
}
.itemCopy,
.assignmentCopy {
display: grid;
gap: 0.18rem;
min-width: 0;
strong,
p,
small {
overflow-wrap: anywhere;
}
p,
small {
color: var(--text-muted);
}
}
.itemMeta {
display: grid;
gap: 0.35rem;
justify-items: start;
text-align: left;
@media (min-width: 760px) {
justify-items: end;
text-align: right;
}
small {
color: var(--text-muted);
}
}
.emptyState {
padding: 1rem;
border-radius: var(--radius-md);
border: 1px dashed var(--border-soft);
background: var(--surface-soft);
color: var(--text-muted);
}
.assignmentMeta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding-top: 0.1rem;
}
.assignmentCard {
align-content: start;
}
.assignmentCard {
grid-template-columns: minmax(0, 1fr);
}
.assignmentCard {
&:hover {
border-color: color-mix(in srgb, var(--border-soft) 74%, var(--info) 26%);
box-shadow: var(--shadow-soft);
}
}
.assignmentHeader {
display: flex;
justify-content: space-between;
align-items: start;
gap: 0.75rem;
padding-bottom: 0.1rem;
}
.statusPill,
.metaPill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.38rem 0.62rem;
border-radius: var(--radius-pill);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.04em;
line-height: 1;
white-space: nowrap;
}
.statusPill {
text-transform: uppercase;
border: 1px solid transparent;
}
.metaPill {
background: var(--surface-soft);
color: var(--text-muted);
border: 1px solid var(--border-soft);
justify-content: flex-start;
}
.statusToneReview {
background: var(--surface-warning-tint);
color: var(--warning-text);
border-color: var(--border-info-strong);
}
.statusToneProgress {
background: var(--surface-info-soft-tint);
color: var(--info);
border-color: var(--border-info-strong);
}
.statusToneDraft {
background: color-mix(in srgb, var(--surface-soft) 84%, white 16%);
color: var(--text-muted);
border-color: var(--border-soft);
}
.statusToneLive {
background: var(--surface-success-tint);
color: var(--success-text);
border-color: var(--border-success-soft);
}
.statusToneNeutral {
background: var(--surface-soft);
color: var(--text-muted);
border-color: var(--border-soft);
}
.assignmentLink {
justify-self: start;
padding: 0.68rem 0.92rem;
border-radius: var(--radius-pill);
background: var(--surface-info);
color: var(--info);
font-weight: 600;
border: 1px solid var(--border-info-soft);
&:hover {
text-decoration: none;
background: var(--surface-info-tint);
}
}
@media (max-width: 767px) {
.heroCard,
.sectionCard {
padding: 1rem;
border-radius: var(--radius-2xl);
}
.statCard,
.listCard,
.assignmentCard {
padding: 0.95rem;
}
.assignmentHeader {
flex-direction: column;
align-items: flex-start;
}
.itemMeta {
gap: 0.45rem;
}
}

View File

@@ -0,0 +1,133 @@
import { A } from "@solidjs/router";
import { For, Show, type Component } from "solid-js";
import type { TeacherDashboardHomeData } from "./dashboard-teacher-home.data";
import styles from "./dashboard-teacher-home.module.scss";
type DashboardTeacherHomeProps = {
data: TeacherDashboardHomeData;
};
const statusToneClass = (value: string) => {
const normalized = value.toLowerCase();
if (normalized.includes("review")) return styles.statusToneReview;
if (normalized.includes("progress")) return styles.statusToneProgress;
if (normalized.includes("draft")) return styles.statusToneDraft;
if (normalized.includes("closed")) return styles.statusToneNeutral;
return styles.statusToneLive;
};
const DashboardTeacherHome: Component<DashboardTeacherHomeProps> = (props) => {
return (
<div class={styles.teacherPage}>
<section class={styles.heroCard}>
<div class={styles.heroSummary}>
<p class={styles.eyebrow}>{props.data.overview.eyebrow}</p>
<h1>{props.data.overview.title}</h1>
<p class={styles.heroDescription}>{props.data.overview.description}</p>
<div class={styles.heroActions}>
<For each={props.data.overview.actions}>
{(action) => (
<A href={action.href} classList={{ [styles.heroAction]: true, [styles.heroActionPrimary]: action.tone === "primary", [styles.heroActionSecondary]: action.tone === "secondary" }}>
<strong>{action.label}</strong>
<span>{action.description}</span>
</A>
)}
</For>
</div>
</div>
<div class={styles.statCardsGrid}>
<For each={props.data.overview.stats}>
{(stat, index) => (
<article class={styles.statCard}>
<span class={styles.statLabel}>{stat.label}</span>
<strong class={styles.statValue}>{stat.value}</strong>
<Show when={index() === 0}>
<span class={`${styles.statusPill} ${styles.statusToneReview}`}>Priority now</span>
</Show>
<p>{stat.note}</p>
</article>
)}
</For>
</div>
</section>
<section class={styles.sectionCard}>
<header class={styles.sectionHeader}>
<p class={styles.sectionEyebrow}>Priority</p>
<h2>{props.data.reviewQueue.title}</h2>
<p>{props.data.reviewQueue.description}</p>
</header>
<Show when={props.data.reviewQueue.items.length} fallback={<div class={styles.emptyState}>No student submissions are waiting right now.</div>}>
<div class={styles.reviewQueueList}>
<For each={props.data.reviewQueue.items}>
{(item) => (
<A href={item.href} class={styles.listCard}>
<div class={styles.listLead}>
<span class={styles.avatar}>{item.initials}</span>
<div class={styles.itemCopy}>
<strong>{item.studentName}</strong>
<p>{item.assignmentTitle}</p>
</div>
</div>
<div class={styles.listDetails}>
<div class={styles.detailBlock}>
<span class={styles.detailKicker}>Classroom</span>
<small class={styles.detailValue}>{item.classroomName}</small>
</div>
<div class={styles.detailBlock}>
<span class={styles.detailKicker}>Progress</span>
<small class={styles.detailValue}>{item.progressLabel}</small>
</div>
</div>
<div class={styles.itemMeta}>
<span class={`${styles.statusPill} ${statusToneClass(item.statusLabel)}`}>{item.statusLabel}</span>
<small>{item.timestampLabel}</small>
</div>
</A>
)}
</For>
</div>
</Show>
</section>
<section class={styles.sectionCard}>
<header class={styles.sectionHeader}>
<p class={styles.sectionEyebrow}>Snapshot</p>
<h2>{props.data.assignmentFocus.title}</h2>
<p>{props.data.assignmentFocus.description}</p>
</header>
<Show when={props.data.assignmentFocus.items.length} fallback={<div class={styles.emptyState}>Assignments will appear here once this teacher has active work live in the backend.</div>}>
<div class={styles.assignmentList}>
<For each={props.data.assignmentFocus.items}>
{(item) => (
<article class={styles.assignmentCard}>
<div class={styles.assignmentHeader}>
<div class={styles.assignmentCopy}>
<strong>{item.title}</strong>
<p>{item.classroomName}</p>
</div>
<span class={`${styles.statusPill} ${statusToneClass(item.statusLabel)}`}>{item.statusLabel}</span>
</div>
<div class={styles.assignmentMeta}>
<span class={styles.metaPill}>{item.dueLabel}</span>
<span class={styles.metaPill}>{item.queueLabel}</span>
<span class={styles.metaPill}>{item.completionLabel}</span>
</div>
<A href={item.href} class={styles.assignmentLink}>
Open now
</A>
</article>
)}
</For>
</div>
</Show>
</section>
</div>
);
};
export default DashboardTeacherHome;

View File

@@ -0,0 +1,64 @@
export const studentDashboardLabels = {
spotlight: {
assignmentsDone: "Assignments done",
currentFocus: "Current focus",
bestTopic: "Best topic",
},
quickStats: {
nextDue: "Next due",
latestUpdate: "Latest update",
noDeadline: "No deadline",
dueToday: "Today",
overdue: "Overdue",
},
activity: {
completed: "Completed",
answerCoverage: "Answer coverage",
},
insights: {
reviewed: "Reviewed",
answered: "Answered",
remaining: "Remaining",
},
progress: {
reviewed: "Reviewed",
coverage: "Coverage",
strongest: "Strongest",
latestUpdate: "Latest update",
},
practice: {
focusTopic: "Focus topic",
topicCoverage: "Topic coverage",
bestTopic: "Best topic",
latestUpdate: "Latest update",
},
assignments: {
liveNow: "Live now",
completed: "Completed",
averageReviewed: "Average reviewed",
nextDue: "Next due",
},
} as const;
export const teacherDashboardLabels = {
common: {
nextDue: "Next due",
students: "Students",
},
home: {
needsReview: "Needs review",
draftsToPublish: "Drafts to publish",
reviewCoverage: "Review coverage",
},
assignments: {
needsReview: "Needs review",
liveAssignments: "Live assignments",
studentsCovered: "Students covered",
reviewCoverage: "Review coverage",
},
classroomDetail: {
students: "Students",
redoAssignments: "Redo assignments",
waitingReview: "Waiting review",
},
} as const;

View File

@@ -0,0 +1,232 @@
export const dashboardUiCopy = {
studentAssignmentsFocus: {
eyebrow: "Assignments",
title: "Your assignment hub",
description: "Stay in dashboard mode to see what is live, what needs finishing, and what is ready for review.",
loading: "Loading assignments from the backend…",
empty: "No assignments are available for this student yet.",
},
studentProgress: {
eyebrow: "Progress",
title: "Your live learning progress",
},
studentPractice: {
eyebrow: "Practice",
title: "Your live practice space",
buttons: {
viewProgress: "View progress",
seeAssignments: "See assignments",
openDashboard: "Open dashboard",
},
cards: {
firstPassTitle: "Take one clean first pass",
},
},
teacherAssignments: {
eyebrow: {
all: "Assignments",
closed: "Closed homework",
},
title: {
all: "Homework setup and assignment management",
closed: "Past homework assignments",
},
panelEyebrow: {
all: "Create workspace",
closed: "Archive access",
},
panelTitle: {
all: "Create in a separate workspace",
closed: "Review closed homework",
},
actions: {
backToAssignments: "Back to assignments",
createAssignment: "Create assignment",
viewClosedHomework: "View closed homework",
backToOverview: "Back to overview",
},
loading: "Loading teacher assignments from the backend…",
empty: {
all: "No homework has been created for this teacher yet.",
closed: "No closed homework assignments are available yet.",
},
},
teacherAssignmentCreate: {
hero: {
eyebrow: "Assignment setup",
title: "Create homework in a dedicated workspace",
backToAssignments: "Back to assignments",
teachingOverview: "Teaching overview",
},
details: {
eyebrow: "Create assignment",
title: "Create homework for a classroom",
selectedSuffix: "selected",
classroom: "Classroom",
chooseClassroom: "Choose a classroom",
passThreshold: "Pass threshold",
dueDate: "Due date",
assignmentTitle: "Assignment title",
assignmentTitlePlaceholder: "Fractions practice set",
teacherNotes: "Teacher notes or instructions",
teacherNotesPlaceholder: "Add short instructions, what students should focus on, or how you want them to show their work.",
},
personalized: {
title: "Personalized homework generation",
enabled: "Mixed generation enabled",
disabled: "Classic shared assignment",
toggleTitle: "Generate personalized question sets per student",
primaryTopic: "Primary topic",
primaryDifficulty: "Primary difficulty",
totalQuestions: "Total questions per student",
personalizedShare: "Personalized share (%)",
personalizedDifficulty: "Personalized difficulty",
subjectLabel: "Subject label",
subjectPlaceholder: "Maths",
seed: "Seed (optional)",
seedPlaceholder: "Leave blank for a fresh cohort run",
},
sharedGeneration: {
title: "Generate question set",
autoSelected: "New questions are auto-selected",
topic: "Topic",
difficulty: "Difficulty",
count: "How many questions",
seed: "Seed (optional)",
seedPlaceholder: "Leave blank for a fresh set",
generating: "Generating…",
generateQuestions: "Generate questions",
},
questionBank: {
title: "Question bank",
availableSuffix: "available",
loading: "Loading your question bank…",
empty: "No teacher-authored questions are available yet. Once questions exist, you can attach them here while creating homework.",
},
submission: {
creating: "Creating…",
createAssignment: "Create assignment",
resetForm: "Reset form",
},
summary: {
title: "Setup summary",
classroom: "Classroom",
roster: "Roster",
questions: "Questions",
mode: "Mode",
passThreshold: "Pass threshold",
delivery: "Delivery",
notChosenYet: "Not chosen yet",
chooseClassroom: "Choose a classroom",
mixedPersonalized: "Mixed personalized",
sharedQuestionSet: "Shared question set",
liveAssignment: "Live assignment",
},
},
} as const;
export const assignmentUiCopy = {
studentWork: {
solveModes: {
justAnswer: "Just answer",
stepByStep: "Step by step",
solveTogether: "Solve together",
handwritten: "Handwritten",
},
banner: {
eyebrow: "Practice submitted",
link: "Open review page",
},
header: {
backToReview: "Back to review",
titlePrefix: "Work through",
submitting: "Submitting and generating AI draft…",
submitAssignment: "Submit assignment",
submitHint: "This can take a few seconds while we save your work and prepare the draft review.",
},
navigator: {
title: "Question list",
answeredSuffix: "answered",
remainingSuffix: "left",
draftReady: "Draft ready",
},
workspace: {
questionPrefix: "Question",
solveMode: "Solve mode",
solveModeHint: "Pick the mode that best matches how you want to think through this question.",
yourAnswer: "Your answer",
answerPlaceholder: "Type your final answer",
workingSteps: "Working / steps",
stepsPlaceholder: "Write your method here. For example: find a common denominator, simplify, then check your final answer.",
helperEyebrow: "Helpful prompt",
answerKey: "Answer key",
previous: "Previous",
saveDraft: "Save draft",
saveThisQuestion: "Save this question",
saveAndContinue: "Save and continue",
lastSubmittedPrefix: "Last submitted",
},
},
teacherReview: {
feedback: {
title: "Shared assignment feedback",
aiFeedback: "AI feedback",
noAiFeedback: "No AI feedback captured yet.",
teacherFeedback: "Teacher feedback",
teacherFeedbackPlaceholder: "Leave one clear summary for the student across the whole assignment",
cleanDraft: "Shared feedback matches the last saved version.",
dirtyDraftPrefix: "Shared feedback draft is stored locally until you click",
},
question: {
questionPrefix: "Question",
studentAnswer: "Student answer",
studentSteps: "Student steps and explanation",
noSteps: "No written steps or explanation were submitted.",
correctAnswer: "Correct answer",
noCorrectAnswer: "No correct answer saved yet.",
structuredReviewLocked: "Structured review fields will unlock after the student submits an answer.",
structuredReview: "Structured review",
needsAttention: "Needs attention",
weightingNote: "Correctness and question weighting are fixed at full credit. Only understanding varies per response.",
issueReason: "Issue reason",
issueReasonPlaceholder: "Explain what still needs attention for this response",
understandingScore: "Understanding score",
confidence: "Confidence",
questionTags: "Question tags",
noSavedTags: "This question has no saved tags yet. Tags should be added on the draft question, not during review.",
inheritedTags: "Inherited from the question setup.",
resetDraft: "Reset draft",
needsAttentionInDraft: "Needs attention in draft",
},
saveProgress: {
title: "Save all and move to next step",
closedFallback: "This assignment is closed, so no further review actions are available.",
lockedFallback: "This student has not submitted work yet, so next step is locked for now.",
saving: "Saving and continuing...",
saveAndNextStep: "Save all and next step",
},
sidebar: {
statusEyebrow: "Assignment status",
closeAssignment: "Close assignment",
closed: "Closed",
readyToClose: "Ready to close",
blocked: "Blocked",
closing: "Closing assignment...",
assignmentClosed: "Assignment closed",
queueEyebrow: "Queue",
studentsToReview: "Students to review",
backToDashboard: "Back to dashboard",
queueEmpty: "No student attempts are in the review queue yet.",
overviewEyebrow: "Overview",
reviewSummary: "Review summary",
},
status: {
needsAttention: "Needs attention",
reviewed: "Reviewed",
submitted: "Submitted",
inProgress: "In progress",
noAnswerYet: "No answer yet",
needsAttentionDraft: "Needs attention draft",
},
},
} as const;

View File

@@ -0,0 +1,191 @@
// Path: Frontend/src/context/auth/context.tsx
import { createContext, createSignal, onMount, useContext, type ParentComponent } from "solid-js";
export type AuthProfile = {
preferred_name: string | null;
profile_icon_url: string | null;
headline: string | null;
bio: string | null;
timezone: string | null;
locale: string | null;
grade_level: string | null;
learning_goal: string | null;
created_at: string | null;
updated_at: string | null;
};
type AuthUser = {
id: number;
email: string;
role: "student" | "teacher";
full_name: string;
is_active: boolean;
created_at?: string;
updated_at?: string;
profile: AuthProfile;
};
type AuthResponse = {
user: AuthUser;
};
type LoginInput = {
email: string;
password: string;
rememberMe: boolean;
};
type SignupInput = {
firstName: string;
lastName: string;
email: string;
password: string;
};
export type UpdateProfileInput = {
fullName: string;
preferredName: string;
profileIconUrl: string;
headline: string;
bio: string;
timezone: string;
locale: string;
gradeLevel: string;
learningGoal: string;
};
type AuthContextType = {
user: () => AuthUser | null;
isReady: () => boolean;
login: (input: LoginInput) => Promise<AuthUser>;
signup: (input: SignupInput) => Promise<AuthUser>;
updateProfile: (input: UpdateProfileInput) => Promise<AuthUser>;
logout: () => Promise<void>;
refresh: () => Promise<AuthUser | null>;
};
const AuthContext = createContext<AuthContextType>();
const authRequest = async <T,>(path: string, init?: RequestInit): Promise<T> => {
const response = await fetch(path, {
credentials: "include",
headers: {
accept: "application/json",
...(init?.body ? { "content-type": "application/json" } : {}),
...(init?.headers ?? {}),
},
...init,
});
if (!response.ok) {
let message = `Request failed: ${response.status}`;
try {
const payload = (await response.json()) as { message?: string };
if (payload.message) message = payload.message;
} catch {
// ignore malformed error bodies
}
const error = new Error(message);
(error as Error & { status?: number }).status = response.status;
throw error;
}
return (await response.json()) as T;
};
export const AuthProvider: ParentComponent = (props) => {
const [user, setUser] = createSignal<AuthUser | null>(null);
const [isReady, setIsReady] = createSignal(false);
const refresh = async () => {
try {
const response = await authRequest<AuthResponse>("/api/auth/me");
setUser(response.user);
return response.user;
} catch (error) {
if ((error as Error & { status?: number }).status === 401) {
setUser(null);
return null;
}
throw error;
} finally {
setIsReady(true);
}
};
const login = async (input: LoginInput) => {
const response = await authRequest<AuthResponse>("/api/auth/login", {
method: "POST",
body: JSON.stringify({
email: input.email,
password: input.password,
remember_me: input.rememberMe,
}),
});
setUser(response.user);
setIsReady(true);
return response.user;
};
const signup = async (input: SignupInput) => {
const response = await authRequest<AuthResponse>("/api/auth/register", {
method: "POST",
body: JSON.stringify({
first_name: input.firstName,
last_name: input.lastName,
email: input.email,
password: input.password,
}),
});
setUser(response.user);
setIsReady(true);
return response.user;
};
const updateProfile = async (input: UpdateProfileInput) => {
const response = await authRequest<AuthResponse>("/api/auth/me", {
method: "PATCH",
body: JSON.stringify({
full_name: input.fullName,
preferred_name: input.preferredName,
profile_icon_url: input.profileIconUrl,
headline: input.headline,
bio: input.bio,
timezone: input.timezone,
locale: input.locale,
grade_level: input.gradeLevel,
learning_goal: input.learningGoal,
}),
});
setUser(response.user);
setIsReady(true);
return response.user;
};
const logout = async () => {
await authRequest<{ status: string }>("/api/auth/logout", { method: "POST" });
setUser(null);
setIsReady(true);
};
onMount(() => {
void refresh();
});
return <AuthContext.Provider value={{ user, isReady, login, signup, updateProfile, logout, refresh }}>{props.children}</AuthContext.Provider>;
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
};

View File

@@ -5,13 +5,14 @@ import { createHandler, StartServer } from "@solidjs/start/server";
export default createHandler(() => (
<StartServer
document={({ assets, children, scripts }) => (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
{assets}
document={({ assets, children, scripts }) => (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="icon" href="/favicon.ico" />
{assets}
<link rel="preload" href="/fonts/Poppins/Poppins-Light.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
<link rel="preload" href="/fonts/Poppins/Poppins-Regular.woff2" as="font" type="font/woff2" crossorigin="anonymous" />

View File

@@ -0,0 +1,185 @@
// Path: Frontend/src/lib/api-types.ts
export type ApiListResponse<T> = {
data: T[];
};
export type ApiAssignment = {
id: number;
classroom_id: number;
teacher_id: number;
title: string;
instructions: string | null;
pass_threshold?: number;
status: "draft" | "assigned" | "closed";
due_at: string | null;
published_at: string | null;
created_at: string;
updated_at: string;
};
export type ApiQuestion = {
id: number;
author_teacher_id: number;
title: string;
prompt: string;
topic?: string | null;
subject: string | null;
difficulty?: string | null;
source: string | null;
correct_answer?: string | null;
status: string;
created_at?: string;
updated_at?: string;
};
export type ApiGeneratedQuestion = {
question: ApiQuestion;
tags: string[];
worked_solution: string[];
};
export type ApiGenerateQuestionsResponse = {
seed: number;
data: ApiGeneratedQuestion[];
count: number;
};
export type ApiAssignmentQuestion = {
assignment_id: number;
question_id: number;
position: number;
author_teacher_id: number;
title: string;
prompt: string;
subject: string | null;
source: string | null;
question_status: string;
question_created_at?: string | null;
question_updated_at?: string | null;
};
export type ApiAssignmentStudentQuestionDetail = {
assignment_id: number;
student_id: number;
question_id: number;
position: number;
title: string;
prompt: string;
subject: string;
source: string | null;
question_tags?: string[];
question_status: string;
correct_answer?: string;
answer_id?: number;
answer_text?: string;
solve_mode?: "just_answer" | "step_by_step" | "solve_together" | "handwritten";
working_steps?: string;
is_correct?: boolean;
assignment_ai_feedback?: string;
assignment_teacher_feedback?: string;
overall_score?: number;
pass_threshold?: number;
next_step_outcome?: "redo" | "accept" | "support";
pass_status_override?: "pending" | "pass" | "no_pass";
pass_status?: "pending" | "pass" | "no_pass";
ai_feedback?: string;
teacher_feedback?: string;
answer_status?: string;
review_needs_attention?: boolean;
review_issue_reason?: string;
review_correctness_score?: number;
review_understanding_score?: number;
review_question_score?: number;
review_confidence?: number;
review_tags?: string[];
submitted_at?: string;
reviewed_at?: string;
answer_created_at?: string;
answer_updated_at?: string;
};
export type ApiUser = {
id: number;
email: string;
role: "student" | "teacher";
full_name: string;
is_active: boolean;
created_at?: string;
updated_at?: string;
};
export type ApiClassroom = {
id: number;
teacher_id: number;
name: string;
code?: string | null;
description?: string | null;
created_at: string;
updated_at: string;
};
export type ApiStudent = {
id: number;
full_name: string;
email: string;
role: string;
is_active: boolean;
created_at?: string;
updated_at?: string;
};
export type ApiReviewSummary = {
assignment_id: number;
total_questions: number;
total_assigned: number;
not_started: number;
in_progress: number;
submitted: number;
reviewed: number;
};
export type ApiReviewQueueItem = {
assignment_id: number;
student_id: number;
student_name: string;
student_email: string;
next_step_outcome?: "redo" | "accept" | "support";
total_questions: number;
answered_questions: number;
reviewed_questions: number;
submitted_questions: number;
in_progress_questions: number;
review_status: string;
latest_submitted_at?: string;
latest_reviewed_at?: string;
};
export type ApiStudentWeaknessSummary = {
student_id: number;
topic_scores: Record<string, number>;
weak_tags: string[];
recent_issues: string[];
};
export type ApiRedoPlanQuestion = {
topic: string;
difficulty: string;
tags: string[];
reason: string;
};
export type ApiRedoPlan = {
rationale: string;
questionSet: ApiRedoPlanQuestion[];
};
export type ApiAssignmentRedoPlanResponse = {
assignment_id: number;
student_id: number;
redo_plan_generated_at?: string | null;
teacher_feedback?: string | null;
weakness_summary: ApiStudentWeaknessSummary;
plan?: ApiRedoPlan | null;
error?: string | null;
};

58
Frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,58 @@
// Path: Frontend/src/lib/api.ts
type ApiRequestOptions = RequestInit & {
notFoundAsNull?: boolean;
allowNoContent?: boolean;
parseErrorMessage?: boolean;
};
export const apiFetchJson = async <T>(path: string, options: ApiRequestOptions = {}): Promise<T> => {
const { notFoundAsNull = false, allowNoContent = false, parseErrorMessage = false, ...init } = options;
const headers = new Headers(init.headers ?? undefined);
if (!headers.has("accept")) {
headers.set("accept", "application/json");
}
if (init.body && !headers.has("content-type")) {
headers.set("content-type", "application/json");
}
const response = await fetch(path, {
credentials: "include",
...init,
headers,
});
if (!response.ok) {
if (response.status === 404) {
if (notFoundAsNull) {
return null as T;
}
const error = new Error("not_found");
(error as Error & { status?: number }).status = 404;
throw error;
}
let message = `Request failed: ${response.status}`;
if (parseErrorMessage) {
try {
const payload = (await response.json()) as { message?: string };
if (payload.message) message = payload.message;
} catch {
// ignore malformed payloads
}
}
const error = new Error(message);
(error as Error & { status?: number }).status = response.status;
throw error;
}
if (response.status === 204 && allowNoContent) {
return null as T;
}
return (await response.json()) as T;
};

Some files were not shown because too many files have changed in this diff Show More