diff --git a/src/features/dashboard/components/HomeworkDetailActions.svelte b/src/features/dashboard/components/HomeworkDetailActions.svelte
index 804c49220..ae9b60a84 100644
--- a/src/features/dashboard/components/HomeworkDetailActions.svelte
+++ b/src/features/dashboard/components/HomeworkDetailActions.svelte
@@ -1,5 +1,6 @@
-
-
diff --git a/src/features/dashboard/components/HomeworkDetailCommentsAside.svelte b/src/features/dashboard/components/HomeworkDetailCommentsAside.svelte
index 73f827ef5..8ce66a70a 100644
--- a/src/features/dashboard/components/HomeworkDetailCommentsAside.svelte
+++ b/src/features/dashboard/components/HomeworkDetailCommentsAside.svelte
@@ -1,6 +1,5 @@
-
+{#key `comments:homework:${homework.id}`}
+
+{/key}
diff --git a/src/features/dashboard/components/HomeworkDetailDescription.svelte b/src/features/dashboard/components/HomeworkDetailDescription.svelte
index 9e15895cd..c714ca3a9 100644
--- a/src/features/dashboard/components/HomeworkDetailDescription.svelte
+++ b/src/features/dashboard/components/HomeworkDetailDescription.svelte
@@ -11,7 +11,7 @@ export let homework: DashboardHomeworkDetailItem;
export let homeworksCopy: DashboardHomeworkDetailCopy;
-
+
{#if homework.description}
-import CheckCircleIcon from "@lucide/svelte/icons/check-circle";
-import RefreshCw from "@lucide/svelte/icons/refresh-cw";
-import type { DashboardMyHomeworksCopy } from "@/features/dashboard/lib/dashboard-controller-types";
-import * as Dialog from "$lib/components/ui/dialog/index.js";
-import { ScrollArea } from "$lib/components/ui/scroll-area/index.js";
+import DetailDialog from "$lib/components/DetailDialog.svelte";
import type {
DashboardHomeworkCommentsPanel,
DashboardHomeworkCompletionToggle,
@@ -26,68 +22,49 @@ export let homeworkDetailHref: DashboardHomeworkDetailAction;
export let homeworkEtaLabel: DashboardHomeworkDetailFormatter;
export let homeworkCourseLabel: DashboardHomeworkDetailAction;
export let homeworkSavingById: Record;
-export let homeworkSectionHref: DashboardHomeworkDetailAction;
export let homeworksCopy: DashboardHomeworkDetailCopy;
-export let homeworkCopy: DashboardMyHomeworksCopy;
export let homeworkStatus: DashboardHomeworkDetailAction;
export let onClose: () => void;
export let toggleHomeworkCompletion: DashboardHomeworkCompletionToggle;
{#if homework}
- {
- if (!open) onClose();
- }}
+ {@const selected = homework}
+
+
-
- {@const selectedCourseLabel = homeworkCourseLabel(homework)}
- {@const SelectedCompletionIcon = homework.completion ? RefreshCw : CheckCircleIcon}
-
- {homework.title}
-
- {selectedCourseLabel} · {homeworkCopy.due}:
- {fmtDate(homework.submissionDueAt)}
-
-
-
-
-
-
+ {#snippet body()}
+
-
+
-
-
-
-
-
-
-
+
+ {/snippet}
+
+ {#snippet aside()}
+
+ {/snippet}
+
{/if}
diff --git a/src/features/dashboard/components/HomeworkDetailMetadata.svelte b/src/features/dashboard/components/HomeworkDetailMetadata.svelte
index 567442183..7bc2684d6 100644
--- a/src/features/dashboard/components/HomeworkDetailMetadata.svelte
+++ b/src/features/dashboard/components/HomeworkDetailMetadata.svelte
@@ -1,7 +1,12 @@
-
-
-
-
- {homeworksCopy.submissionDue}
-
- {fmtDate(homework.submissionDueAt)}
-
-
-
- {homeworkStatus(homework)}
-
-
-
- {homeworkEtaLabel(homework.submissionDueAt)}
-
-
-
-
- {homeworksCopy.submissionStart}
- {fmtDate(homework.submissionStartAt)}
-
-
-
-
- {homeworksCopy.homeworkPublishedAt}
- {fmtDate(homework.publishedAt)}
-
-
-
-
+
+
+
-
- {#if homework.isMajor}
-
- {homeworksCopy.tagMajor}
-
- {/if}
- {#if homework.requiresTeam}
-
- {homeworksCopy.tagTeam}
-
- {/if}
-
+
diff --git a/src/features/dashboard/components/HomeworksTab.svelte b/src/features/dashboard/components/HomeworksTab.svelte
index d2c2aaf23..0c938838c 100644
--- a/src/features/dashboard/components/HomeworksTab.svelte
+++ b/src/features/dashboard/components/HomeworksTab.svelte
@@ -197,13 +197,11 @@ $: ({
bind:createHomeworkSubmissionStartAt
{fmtDate}
{homeworkCompletionActionLabel}
- {homeworkCopy}
{homeworkCourseLabel}
{homeworkDetailHref}
{homeworkEtaLabel}
{homeworksCopy}
{homeworkSavingById}
- {homeworkSectionHref}
{homeworkSectionLabel}
{homeworkStatus}
{isCreatingHomework}
diff --git a/src/features/dashboard/components/HomeworksTabDialogs.svelte b/src/features/dashboard/components/HomeworksTabDialogs.svelte
index 17d204027..9aa314f89 100644
--- a/src/features/dashboard/components/HomeworksTabDialogs.svelte
+++ b/src/features/dashboard/components/HomeworksTabDialogs.svelte
@@ -36,13 +36,11 @@ export let createHomeworkSubmissionDueAt: string;
export let createHomeworkSubmissionStartAt: string;
export let fmtDate: DashboardHomeworkDetailFormatter;
export let homeworkCompletionActionLabel: HomeworkAction;
-export let homeworkCopy: DashboardMyHomeworksCopy;
export let homeworkCourseLabel: HomeworkAction;
export let homeworkDetailHref: HomeworkAction;
export let homeworkEtaLabel: DashboardHomeworkDetailFormatter;
export let homeworksCopy: DashboardHomeworksCopy;
export let homeworkSavingById: Record;
-export let homeworkSectionHref: HomeworkAction;
export let homeworkSectionLabel: (
section: DashboardHomeworkCreateSection,
) => string;
@@ -92,9 +90,7 @@ export let toggleHomeworkCompletion: (
{homeworkEtaLabel}
{homeworkCourseLabel}
{homeworkSavingById}
- {homeworkSectionHref}
{homeworksCopy}
- {homeworkCopy}
{homeworkStatus}
onClose={() => {
selectedHomework = null;
diff --git a/src/features/dashboard/components/TodoDetailDialog.svelte b/src/features/dashboard/components/TodoDetailDialog.svelte
index 6a1b6a5dd..593ab85e7 100644
--- a/src/features/dashboard/components/TodoDetailDialog.svelte
+++ b/src/features/dashboard/components/TodoDetailDialog.svelte
@@ -1,15 +1,18 @@
{#if todo}
- {
- if (!open) onClose();
- }}
- >
-
-
- {todo.title}
-
- {todo.priority} · {fmtDate(todo.dueAt)}
-
-
-
- {#if todo.content}
-
- {:else}
-
{todosCopy.contentPlaceholder}
- {/if}
-
-
- {todosCopy.priority[todo.priority]}
-
-
{todoStatus(todo)}
+ {@const selected = todo}
+
+
+ {#snippet body()}
+
+
+ {#if selected.content}
+
+ {:else}
+ {todosCopy.contentPlaceholder}
+ {/if}
+
+
+
+
+
+
+ {todosCopy.dueLabel}
+ {fmtDate(selected.dueAt)}
+
+
+
+ {todoStatus(selected)}
+
+
+
+
+
+
+
+
- {todosCopy.priorityLabel}
+ -
+
+ {todosCopy.priority[selected.priority]}
+
+
-
+
+
+
+
+
{
- deleteTodo(todo);
+ deleteTodo(selected);
}}
>
- {todoSavingById[todo.id] ? todosCopy.saving : todosCopy.delete}
+ {todoSavingById[selected.id] ? todosCopy.saving : todosCopy.delete}
-
+
{
- openTodoEditor(todo);
+ openTodoEditor(selected);
}}
>
+
{todosCopy.editTitle}
{
- toggleTodoCompletion(todo);
+ toggleTodoCompletion(selected);
}}
>
- {#if todo.completed}
-
+ {#if selected.completed}
+
{:else}
{/if}
- {todoSavingById[todo.id] ? todosCopy.saving : todoActionLabel(todo)}
+ {todoSavingById[selected.id] ? todosCopy.saving : todoActionLabel(selected)}
-
-
+ {/snippet}
+
{/if}
diff --git a/src/features/dashboard/components/dashboard-homework-detail-types.ts b/src/features/dashboard/components/dashboard-homework-detail-types.ts
index e03a77359..1ffedd768 100644
--- a/src/features/dashboard/components/dashboard-homework-detail-types.ts
+++ b/src/features/dashboard/components/dashboard-homework-detail-types.ts
@@ -37,6 +37,7 @@ export type DashboardHomeworkCompletionToggle = (
) => void | Promise
;
export type DashboardHomeworkCommentsPanel = Component<{
+ heading?: string | null;
permalinkBaseHref?: string | null;
targetId: string;
targetType: "homework";
diff --git a/src/features/dashboard/lib/dashboard-controller-types.ts b/src/features/dashboard/lib/dashboard-controller-types.ts
index 6bfb51933..60319928e 100644
--- a/src/features/dashboard/lib/dashboard-controller-types.ts
+++ b/src/features/dashboard/lib/dashboard-controller-types.ts
@@ -358,6 +358,7 @@ export type DashboardTodosCopy = DashboardRecord & {
delete: string;
deleteAriaLabel: string;
dueAtLabel: string;
+ dueLabel: string;
editTitle: string;
errorContentTooLong: string;
errorInvalidDueAt: string;
diff --git a/src/features/homeworks/components/HomeworkDetailTags.svelte b/src/features/homeworks/components/HomeworkDetailTags.svelte
new file mode 100644
index 000000000..b033b94ae
--- /dev/null
+++ b/src/features/homeworks/components/HomeworkDetailTags.svelte
@@ -0,0 +1,14 @@
+
+
+{#if tags.length > 0}
+
+ {#each tags as tag (tag.key)}
+ {tag.label}
+ {/each}
+
+{/if}
diff --git a/src/features/homeworks/components/HomeworkDueSummary.svelte b/src/features/homeworks/components/HomeworkDueSummary.svelte
new file mode 100644
index 000000000..e39176cfa
--- /dev/null
+++ b/src/features/homeworks/components/HomeworkDueSummary.svelte
@@ -0,0 +1,24 @@
+
+
+
+
+
+ {summary.dueLabel}
+ {summary.dueValue}
+
+
+
+ {summary.statusLabel}
+
+
+
+ {#if summary.etaLabel}
+ {summary.etaLabel}
+ {/if}
+
diff --git a/src/features/homeworks/components/HomeworkMetaList.svelte b/src/features/homeworks/components/HomeworkMetaList.svelte
new file mode 100644
index 000000000..aa3eae8d0
--- /dev/null
+++ b/src/features/homeworks/components/HomeworkMetaList.svelte
@@ -0,0 +1,14 @@
+
+
+
+ {#each rows as row (row.key)}
+
+
- {row.label}
+ - {row.value}
+
+ {/each}
+
diff --git a/src/features/homeworks/lib/homework-detail-meta.ts b/src/features/homeworks/lib/homework-detail-meta.ts
new file mode 100644
index 000000000..3aa5ef716
--- /dev/null
+++ b/src/features/homeworks/lib/homework-detail-meta.ts
@@ -0,0 +1,137 @@
+export type HomeworkDetailDateValue = Date | string | null | undefined;
+
+export type HomeworkDetailDateFormatter = (
+ value: HomeworkDetailDateValue,
+) => string;
+
+/**
+ * Primary homework properties per `docs/contracts/_ui.json` "Model Property
+ * Priority": the due time, the completion status, and the relative label users
+ * scan for. Rendered as the "due summary" block of the detail popup.
+ */
+export type HomeworkDueSummary = {
+ completed: boolean;
+ dueLabel: string;
+ dueValue: string;
+ etaLabel: string | null;
+ statusLabel: string;
+};
+
+export type HomeworkDetailMetaKey = "publishedAt" | "submissionStartAt";
+
+export type HomeworkDetailMetaRow = {
+ key: HomeworkDetailMetaKey;
+ label: string;
+ value: string;
+};
+
+export type HomeworkDetailTagKey = "major" | "team";
+
+export type HomeworkDetailTag = {
+ key: HomeworkDetailTagKey;
+ label: string;
+};
+
+function optionalText(value: string | null | undefined) {
+ const trimmed = value?.trim();
+ return trimmed ? trimmed : null;
+}
+
+/**
+ * Completion status, never the "standard homework" tag: default homework does
+ * not get a badge of its own.
+ */
+export function homeworkCompletionStatusLabel(
+ completed: boolean,
+ labels: {
+ completedStatus: string;
+ incompleteStatus: string;
+ },
+) {
+ return completed ? labels.completedStatus : labels.incompleteStatus;
+}
+
+export function buildHomeworkDueSummary({
+ completed,
+ dueLabel,
+ etaLabel,
+ formatDate,
+ homework,
+ statusLabel,
+}: {
+ completed: boolean;
+ dueLabel: string;
+ etaLabel?: string | null;
+ formatDate: HomeworkDetailDateFormatter;
+ homework: { submissionDueAt?: HomeworkDetailDateValue };
+ statusLabel: string;
+}): HomeworkDueSummary {
+ return {
+ completed,
+ dueLabel,
+ dueValue: formatDate(homework.submissionDueAt),
+ etaLabel: optionalText(etaLabel),
+ statusLabel,
+ };
+}
+
+/**
+ * Secondary/tertiary timestamps for the vertical metadata list. The due date
+ * lives in the due summary and platform `createdAt` is intentionally excluded,
+ * matching the documented detail popup order.
+ */
+export function buildHomeworkMetadataRows({
+ formatDate,
+ homework,
+ labels,
+}: {
+ formatDate: HomeworkDetailDateFormatter;
+ homework: {
+ publishedAt?: HomeworkDetailDateValue;
+ submissionStartAt?: HomeworkDetailDateValue;
+ };
+ labels: {
+ publishedAt: string;
+ submissionStart: string;
+ };
+}): HomeworkDetailMetaRow[] {
+ return [
+ {
+ key: "publishedAt",
+ label: labels.publishedAt,
+ value: formatDate(homework.publishedAt),
+ },
+ {
+ key: "submissionStartAt",
+ label: labels.submissionStart,
+ value: formatDate(homework.submissionStartAt),
+ },
+ ];
+}
+
+/**
+ * Only non-default attributes become chips: standard homework never gets a
+ * "standard" badge.
+ */
+export function buildHomeworkDetailTags({
+ homework,
+ labels,
+}: {
+ homework: {
+ isMajor?: boolean | null;
+ requiresTeam?: boolean | null;
+ };
+ labels: {
+ tagMajor: string;
+ tagTeam: string;
+ };
+}): HomeworkDetailTag[] {
+ const tags: HomeworkDetailTag[] = [];
+ if (homework.isMajor) {
+ tags.push({ key: "major", label: labels.tagMajor });
+ }
+ if (homework.requiresTeam) {
+ tags.push({ key: "team", label: labels.tagTeam });
+ }
+ return tags;
+}
diff --git a/src/features/section-detail/components/SectionDetailDialogs.svelte b/src/features/section-detail/components/SectionDetailDialogs.svelte
index aeb28d390..a0c1a7da1 100644
--- a/src/features/section-detail/components/SectionDetailDialogs.svelte
+++ b/src/features/section-detail/components/SectionDetailDialogs.svelte
@@ -40,7 +40,6 @@ export let homeworkAuditActorName: SectionDetailDialogsProps["homeworkAuditActor
export let homeworkAuditLogs: SectionDetailDialogsProps["homeworkAuditLogs"];
export let homeworkCopy: SectionDetailDialogsProps["homeworkCopy"];
export let homeworkMessage: string;
-export let homeworkStatus: SectionDetailDialogsProps["homeworkStatus"];
export let isCalendarDialogOpen: boolean;
export let isHomeworkAuditDialogOpen: boolean;
export let sectionCopy: SectionDetailDialogsProps["sectionCopy"];
@@ -145,7 +144,6 @@ $: if (isCalendarDialogOpen) {
{homeworkAuditLogs}
{homeworkCopy}
{homeworkMessage}
- {homeworkStatus}
{isHomeworkAuditDialogOpen}
{sectionCopy}
sectionJwId={data.section.jwId}
diff --git a/src/features/section-detail/components/SectionDetailPageController.svelte b/src/features/section-detail/components/SectionDetailPageController.svelte
index 05bf474ce..f0ae498e9 100644
--- a/src/features/section-detail/components/SectionDetailPageController.svelte
+++ b/src/features/section-detail/components/SectionDetailPageController.svelte
@@ -23,7 +23,6 @@ import {
canManageSectionHomework,
canWriteSectionHomework,
sectionHomeworkAuditLogs,
- sectionHomeworkStatus,
} from "@/features/section-detail/lib/section-detail-derived-state";
import { createSectionDetailHomeworkActions } from "@/features/section-detail/lib/section-detail-homework-actions";
import { createSectionHomeworkTimestampActions } from "@/features/section-detail/lib/section-detail-homework-timestamp-actions";
@@ -400,10 +399,6 @@ const {
},
});
-function _homeworkStatus(homework: SectionHomework) {
- return sectionHomeworkStatus(homework, _homeworkCopy);
-}
-
function _auditLogsForHomework(homeworkId: string) {
return sectionHomeworkAuditLogs(_homeworkAuditLogs, homeworkId);
}
@@ -526,7 +521,6 @@ onMount(() => {
homeworkAuditLogs={_homeworkAuditLogs}
homeworkCopy={_homeworkCopy}
homeworkMessage={_homeworkMessage}
- homeworkStatus={_homeworkStatus}
isCalendarDialogOpen={_isCalendarDialogOpen}
isHomeworkAuditDialogOpen={_isHomeworkAuditDialogOpen}
sectionCopy={_sectionCopy}
diff --git a/src/features/section-detail/components/SectionHomeworkActionBar.svelte b/src/features/section-detail/components/SectionHomeworkActionBar.svelte
index f76920189..c54f28fcd 100644
--- a/src/features/section-detail/components/SectionHomeworkActionBar.svelte
+++ b/src/features/section-detail/components/SectionHomeworkActionBar.svelte
@@ -1,59 +1,65 @@
-
-
- {#if canWrite}
- {
- if (editing) cancelEdit();
- else startEdit();
- }}
- >
- {editing ? sectionCopy.close : homeworkCopy.editAction}
-
- {/if}
- {#if canWrite}
- {
- if (homework) toggleHomeworkCompletion(homework);
- }}
- >
- {homework.completion ? homeworkCopy.markIncomplete : homeworkCopy.markComplete}
-
- {/if}
- {#if canManage}
- {
- setDeleteHomeworkTarget(homework);
- }}
- >
- {homeworkCopy.deleteAction}
-
- {/if}
-
+
+{#if canWrite || canManage}
+
+
+
+ {#if canManage}
+
{
+ setDeleteHomeworkTarget(homework);
+ }}
+ >
+
+ {homeworkCopy.deleteAction}
+
+ {/if}
+ {#if canWrite}
+
+
+
+ {homeworkCopy.editAction}
+
+
{
+ toggleHomeworkCompletion(homework);
+ }}
+ >
+ {#if homework.completion}
+
+ {:else}
+
+ {/if}
+ {homework.completion ? homeworkCopy.markIncomplete : homeworkCopy.markComplete}
+
+
+ {/if}
+
+
+{/if}
diff --git a/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte b/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte
index 0fa27cbc8..aab65c58a 100644
--- a/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte
+++ b/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte
@@ -1,10 +1,7 @@
{#if _selectedHomework}
- {
- if (!open) close();
- }}
- >
-
-
-
-
- {_selectedHomework.title}
-
- {_sectionCopy.due} {_fmtDateTime(_selectedHomework.submissionDueAt)} · {_homeworkStatus(_selectedHomework)}
-
-
-
- {_sectionCopy.close}
-
-
-
+ {@const homework = _selectedHomework}
+
+
+
+ {#snippet body()}
+ {#if _editingHomework}
+
+ {:else}
+
-
-
-
- {#if _editingHomework}
-
- {:else}
-
- {/if}
+
+ {/if}
-
+
+ {/snippet}
-
-
-
-
-
-
-
- {#key `comments:homework:${_selectedHomework.id}`}
-
- {/key}
-
-
-
-
-
+ {#snippet aside()}
+ {#key `comments:homework:${homework.id}`}
+
+ {/key}
+ {/snippet}
+
{/if}
diff --git a/src/features/section-detail/components/SectionHomeworkDialogs.svelte b/src/features/section-detail/components/SectionHomeworkDialogs.svelte
index e3924ec13..d48f77301 100644
--- a/src/features/section-detail/components/SectionHomeworkDialogs.svelte
+++ b/src/features/section-detail/components/SectionHomeworkDialogs.svelte
@@ -49,7 +49,6 @@ export let homeworkAuditActorName: SectionDetailHomeworkDialogsProps["homeworkAu
export let homeworkAuditLogs: SectionDetailHomeworkDialogsProps["homeworkAuditLogs"];
export let homeworkCopy: SectionDetailHomeworkDialogsProps["homeworkCopy"];
export let homeworkMessage: string;
-export let homeworkStatus: SectionDetailHomeworkDialogsProps["homeworkStatus"];
export let isHomeworkAuditDialogOpen: boolean;
export let sectionCopy: SectionDetailHomeworkDialogsProps["sectionCopy"];
export let sectionJwId: number | string;
@@ -109,8 +108,6 @@ export let updateHomework: SectionDetailHomeworkDialogsProps["updateHomework"];
_formatMessage={formatMessage}
_homeworkAuditActionLabel={homeworkAuditActionLabel}
_homeworkCopy={homeworkCopy}
- _homeworkStatus={homeworkStatus}
- _sectionCopy={sectionCopy}
_selectedHomework={selectedHomework}
_semesterDate={semesterDate}
_setDeleteHomeworkTarget={setDeleteHomeworkTarget}
diff --git a/src/features/section-detail/components/SectionHomeworkReadOnlySummary.svelte b/src/features/section-detail/components/SectionHomeworkReadOnlySummary.svelte
index 75cfd9c02..fc7506e77 100644
--- a/src/features/section-detail/components/SectionHomeworkReadOnlySummary.svelte
+++ b/src/features/section-detail/components/SectionHomeworkReadOnlySummary.svelte
@@ -1,6 +1,14 @@
-
+
{#if homework.description?.content}
{#if homework.description.renderedHtml}
@@ -36,22 +62,8 @@ export let homeworkCopy: SectionHomeworkCopy;
-
-
- - {homeworkCopy.publishedAt}
- - {fmtDateTime(homework.publishedAt)}
-
-
- - {homeworkCopy.submissionStart}
- - {fmtDateTime(homework.submissionStartAt)}
-
-
- - {homeworkCopy.submissionDue}
- - {fmtDateTime(homework.submissionDueAt)}
-
-
+
+
+
-
- {#if homework.isMajor}{homeworkCopy.tagMajor}{/if}
- {#if homework.requiresTeam}{homeworkCopy.tagTeam}{/if}
-
+
diff --git a/src/features/section-detail/components/section-detail-dialog-types.ts b/src/features/section-detail/components/section-detail-dialog-types.ts
index 97b76bb4d..220e52c2c 100644
--- a/src/features/section-detail/components/section-detail-dialog-types.ts
+++ b/src/features/section-detail/components/section-detail-dialog-types.ts
@@ -132,7 +132,6 @@ export type SectionDetailHomeworkDialogsProps = {
homeworkAuditLogs: SectionHomeworkAuditLog[];
homeworkCopy: SectionDetailHomeworkCopy;
homeworkMessage: string;
- homeworkStatus: (homework: SectionHomework) => string;
isHomeworkAuditDialogOpen: boolean;
sectionCopy: SectionDetailSectionCopy;
selectedHomework: SectionHomework | null;
diff --git a/src/features/section-detail/components/section-homework-display-types.ts b/src/features/section-detail/components/section-homework-display-types.ts
index 70034c1c7..6860201b5 100644
--- a/src/features/section-detail/components/section-homework-display-types.ts
+++ b/src/features/section-detail/components/section-homework-display-types.ts
@@ -9,6 +9,7 @@ export interface SectionHomeworkCopy extends HomeworkStyleGuideCopy {
auditTitle: string;
calendarButtonLabel: string;
cancel: string;
+ completedLabel: string;
contentHistoryAction: string;
contentHistoryActor: string;
deleteAction: string;
@@ -18,6 +19,7 @@ export interface SectionHomeworkCopy extends HomeworkStyleGuideCopy {
descriptionLabel: string;
descriptionPlaceholder: string;
editAction: string;
+ filterIncomplete: string;
helperClear: string;
helperMonth: string;
helperPublishNow: string;
@@ -37,10 +39,6 @@ export interface SectionHomeworkCopy extends HomeworkStyleGuideCopy {
titlePlaceholder: string;
}
-export type SectionHomeworkSectionCopy = {
- close?: string;
-};
-
export type SectionHomeworkCommonCopy = {
unknown: string;
};
diff --git a/src/features/section-detail/lib/section-detail-controller-types.ts b/src/features/section-detail/lib/section-detail-controller-types.ts
index 13ccd6a0c..2ea4f835b 100644
--- a/src/features/section-detail/lib/section-detail-controller-types.ts
+++ b/src/features/section-detail/lib/section-detail-controller-types.ts
@@ -172,6 +172,7 @@ export type SectionDetailCopy = {
descriptionLabel: string;
descriptionPlaceholder: string;
editAction: string;
+ filterIncomplete: string;
helperClear: string;
helperMonth: string;
helperPublishNow: string;
diff --git a/src/features/section-detail/lib/section-detail-derived-state.ts b/src/features/section-detail/lib/section-detail-derived-state.ts
index b05d26729..3d8ace58d 100644
--- a/src/features/section-detail/lib/section-detail-derived-state.ts
+++ b/src/features/section-detail/lib/section-detail-derived-state.ts
@@ -89,15 +89,6 @@ export function canManageSectionHomework(
);
}
-export function sectionHomeworkStatus(
- homework: SectionHomework,
- homeworkCopy: SectionDetailCopy["homeworks"],
-) {
- return homework.completion
- ? homeworkCopy.completedLabel
- : homeworkCopy.tagDefault;
-}
-
export function sectionHomeworkAuditLogs(
logs: HomeworkAuditLog[],
homeworkId: string,
diff --git a/src/lib/components/DetailDialog.svelte b/src/lib/components/DetailDialog.svelte
new file mode 100644
index 000000000..9022104e8
--- /dev/null
+++ b/src/lib/components/DetailDialog.svelte
@@ -0,0 +1,75 @@
+
+
+ {
+ if (!next) onClose();
+ }}
+>
+ [data-slot=dialog-close]]:top-3.5 [&>[data-slot=dialog-close]]:right-3.5",
+ aside ? "sm:max-w-5xl" : "sm:max-w-lg",
+ className,
+ )}
+ >
+
+
+ {title}
+
+ {#if subtitle}
+ {subtitle}
+ {/if}
+
+
+
+
+
+ {@render body()}
+
+ {#if aside}
+
+ {@render aside()}
+
+ {/if}
+
+
+
+
diff --git a/src/lib/components/ui/dialog/dialog-content.svelte b/src/lib/components/ui/dialog/dialog-content.svelte
index c0551ee39..9cad06d7c 100644
--- a/src/lib/components/ui/dialog/dialog-content.svelte
+++ b/src/lib/components/ui/dialog/dialog-content.svelte
@@ -37,9 +37,14 @@
{#if showCloseButton}
{#snippet child({ props })}
-
-
- Close
+
+
{/snippet}
diff --git a/src/lib/components/ui/sheet/sheet-content.svelte b/src/lib/components/ui/sheet/sheet-content.svelte
index 20957dccb..11261a0c0 100644
--- a/src/lib/components/ui/sheet/sheet-content.svelte
+++ b/src/lib/components/ui/sheet/sheet-content.svelte
@@ -44,9 +44,14 @@
{#if showCloseButton}
{#snippet child({ props })}
-
-
- Close
+
+
{/snippet}
diff --git a/tests/e2e/src/app/dashboard/homeworks/test.ts b/tests/e2e/src/app/dashboard/homeworks/test.ts
index 8db03a6aa..d391ddd3b 100644
--- a/tests/e2e/src/app/dashboard/homeworks/test.ts
+++ b/tests/e2e/src/app/dashboard/homeworks/test.ts
@@ -23,6 +23,14 @@
*/
import { expect, test } from "@playwright/test";
import { signInAsDebugUser } from "../../../../utils/auth";
+import {
+ closeDetailDialog,
+ detailDialog,
+ detailDialogAside,
+ detailDialogBody,
+ expectDialogActionsInBody,
+ expectHomeworkDetailOrder,
+} from "../../../../utils/detail-dialog";
import { DEV_SEED } from "../../../../utils/dev-seed";
import { cleanupHomeworksForE2e } from "../../../../utils/homeworks";
import { visibleText } from "../../../../utils/locators";
@@ -338,6 +346,61 @@ test.describe("仪表盘作业", () => {
await captureStepScreenshot(page, testInfo, "homeworks/completion-error");
});
+ test("作业详情弹窗展示状态徽标、时间线、讨论与底部操作", async ({
+ page,
+ }, testInfo) => {
+ await signInAsDebugUser(page, "/workspace/homeworks");
+ await ensureSeedSectionSubscription(page);
+ await gotoAndWaitForReady(page, "/workspace/homeworks");
+
+ await page
+ .getByRole("radio", { name: /全部|All/i })
+ .first()
+ .click();
+
+ const row = page
+ .getByRole("row")
+ .filter({ hasText: DEV_SEED.homeworks.title })
+ .first();
+ await row
+ .getByRole("button", { name: new RegExp(DEV_SEED.homeworks.title) })
+ .first()
+ .click();
+
+ const dialog = detailDialog(page);
+ await expect(dialog).toBeVisible();
+ await expect(
+ dialog.getByRole("heading", {
+ name: new RegExp(DEV_SEED.homeworks.title),
+ }),
+ ).toBeVisible();
+ await expect(
+ dialog.getByText(/待处理|已完成|Pending|Completed/i).first(),
+ ).toBeVisible();
+
+ await expectHomeworkDetailOrder(dialog);
+
+ await expect(
+ detailDialogAside(dialog).getByRole("heading", {
+ name: /作业讨论|Homework discussion/i,
+ }),
+ ).toBeVisible();
+
+ await expect(
+ detailDialogBody(dialog).getByRole("link", {
+ name: /查看详情|View details/i,
+ }),
+ ).toBeVisible();
+ await expectDialogActionsInBody(
+ dialog,
+ /标记为完成|取消完成|Mark as complete|Mark as incomplete/i,
+ );
+
+ await captureStepScreenshot(page, testInfo, "homeworks/detail-dialog");
+
+ await closeDetailDialog(page, dialog);
+ });
+
test("查看详情链接到带作业锚点的班级页面", async ({ page }, testInfo) => {
await signInAsDebugUser(page, "/workspace/homeworks");
await ensureSeedSectionSubscription(page);
diff --git a/tests/e2e/src/app/dashboard/todos/test.ts b/tests/e2e/src/app/dashboard/todos/test.ts
index c887d269d..349a9111d 100644
--- a/tests/e2e/src/app/dashboard/todos/test.ts
+++ b/tests/e2e/src/app/dashboard/todos/test.ts
@@ -21,6 +21,11 @@
*/
import { expect, test } from "@playwright/test";
import { signInAsDebugUser } from "../../../../utils/auth";
+import {
+ closeDetailDialog,
+ detailDialog,
+ expectDialogActionsInBody,
+} from "../../../../utils/detail-dialog";
import { DEV_SEED } from "../../../../utils/dev-seed";
import { visibleText } from "../../../../utils/locators";
import { gotoAndWaitForReady } from "../../../../utils/page-ready";
@@ -161,6 +166,31 @@ test.describe("仪表盘待办", () => {
await captureStepScreenshot(page, testInfo, "dashboard-todos-completed");
});
+ test("待办详情弹窗展示优先级、状态与底部操作", async ({ page }, testInfo) => {
+ await signInAsDebugUser(page, "/workspace/todos");
+
+ await visibleText(page, DEV_SEED.todos.dueTodayTitle).first().click();
+
+ const dialog = detailDialog(page);
+ await expect(dialog).toBeVisible();
+ await expect(
+ dialog.getByRole("heading", {
+ name: new RegExp(DEV_SEED.todos.dueTodayTitle),
+ }),
+ ).toBeVisible();
+ await expect(
+ dialog.getByText(/待处理|已完成|Pending|Completed/i).first(),
+ ).toBeVisible();
+
+ await expectDialogActionsInBody(dialog, /删除待办|Delete todo/i);
+ await expectDialogActionsInBody(dialog, /编辑待办|Edit Todo/i);
+ await expectDialogActionsInBody(dialog, /标记为完成|Mark as complete/i);
+
+ await captureStepScreenshot(page, testInfo, "todos/detail-dialog");
+
+ await closeDetailDialog(page, dialog);
+ });
+
test("嵌套待办路由渲染服务端操作错误", async ({ page }, testInfo) => {
await signInAsDebugUser(page, "/workspace/todos");
diff --git a/tests/e2e/src/app/sections/[jwId]/test.ts b/tests/e2e/src/app/sections/[jwId]/test.ts
index cd0f0aded..134d9c74d 100644
--- a/tests/e2e/src/app/sections/[jwId]/test.ts
+++ b/tests/e2e/src/app/sections/[jwId]/test.ts
@@ -45,6 +45,14 @@ import {
snapshotDescriptionTargetForE2e,
waitForDescriptionAuditRows,
} from "../../../../utils/description-state";
+import {
+ closeDetailDialog,
+ detailDialog,
+ detailDialogAside,
+ expectDetailDialogFitsViewport,
+ expectDialogActionsInBody,
+ expectHomeworkDetailOrder,
+} from "../../../../utils/detail-dialog";
import { DEV_SEED } from "../../../../utils/dev-seed";
import { getCurrentSessionUser } from "../../../../utils/e2e-db";
import { withE2ePrisma } from "../../../../utils/e2e-db/prisma";
@@ -1156,6 +1164,93 @@ test.describe("/catalog/sections/[jwId] 班级详情页", () => {
}
});
+ test("作业详情弹窗展示状态徽标、时间线、讨论与图标关闭按钮", async ({
+ page,
+ }, testInfo) => {
+ await signInAsDebugUser(page, SECTION_URL);
+ await gotoAndWaitForReady(page, SECTION_URL);
+ await jumpToSection(page, /作业|Homework/i, "#homework");
+
+ await page
+ .getByRole("button", {
+ name: new RegExp(escapeForRegExp(DEV_SEED.homeworks.title)),
+ })
+ .first()
+ .click();
+
+ const dialog = detailDialog(page);
+ await expect(dialog).toBeVisible();
+ await expect(
+ dialog.getByRole("heading", {
+ name: new RegExp(escapeForRegExp(DEV_SEED.homeworks.title)),
+ }),
+ ).toBeVisible();
+ await expect(
+ dialog.getByText(/未完成|已完成|Incomplete|Completed/i).first(),
+ ).toBeVisible();
+
+ await expectHomeworkDetailOrder(dialog);
+
+ await expect(
+ detailDialogAside(dialog).getByRole("heading", {
+ name: /作业讨论|Homework discussion/i,
+ }),
+ ).toBeVisible();
+
+ await expectDialogActionsInBody(dialog, /编辑信息|Edit details/i);
+ await expectDialogActionsInBody(
+ dialog,
+ /标记为完成|取消完成|Mark as complete|Mark as incomplete/i,
+ );
+
+ await captureStepScreenshot(
+ page,
+ testInfo,
+ "section/homework-detail-dialog",
+ );
+
+ await closeDetailDialog(page, dialog);
+ });
+
+ test("移动端作业详情弹窗纵向排布且不产生横向溢出", async ({
+ page,
+ }, testInfo) => {
+ await page.setViewportSize({ height: 844, width: 390 });
+ await signInAsDebugUser(page, SECTION_URL);
+ await gotoAndWaitForReady(page, SECTION_URL);
+ await jumpToSection(page, /作业|Homework/i, "#homework");
+
+ await page
+ .getByRole("button", {
+ name: new RegExp(escapeForRegExp(DEV_SEED.homeworks.title)),
+ })
+ .first()
+ .click();
+
+ const dialog = detailDialog(page);
+ await expect(dialog).toBeVisible();
+ await expectDetailDialogFitsViewport(page, dialog);
+ await expectHomeworkDetailOrder(dialog);
+ await expectDialogActionsInBody(
+ dialog,
+ /标记为完成|取消完成|Mark as complete|Mark as incomplete/i,
+ );
+
+ await expect(
+ detailDialogAside(dialog).getByRole("heading", {
+ name: /作业讨论|Homework discussion/i,
+ }),
+ ).toBeVisible();
+
+ await captureStepScreenshot(
+ page,
+ testInfo,
+ "section/homework-detail-dialog-mobile",
+ );
+
+ await closeDetailDialog(page, dialog);
+ });
+
test("可编辑班级作业的截止日期、说明、重要和组队标记", async ({
page,
}, testInfo) => {
@@ -1230,7 +1325,7 @@ test.describe("/catalog/sections/[jwId] 班级详情页", () => {
).toBeVisible();
const dueValue = detailDialog
- .locator("dl")
+ .locator('[data-slot="item"]')
.filter({ hasText: /Submission due|提交截止/ })
.first();
await expect(dueValue).toContainText(
diff --git a/tests/e2e/utils/detail-dialog.ts b/tests/e2e/utils/detail-dialog.ts
new file mode 100644
index 000000000..14f4d9a41
--- /dev/null
+++ b/tests/e2e/utils/detail-dialog.ts
@@ -0,0 +1,97 @@
+import { expect, type Locator, type Page } from "@playwright/test";
+
+const DETAIL_DIALOG_SELECTOR = '[data-slot="dialog-content"]';
+
+export function detailDialog(page: Page) {
+ return page.locator(DETAIL_DIALOG_SELECTOR).first();
+}
+
+export function detailDialogBody(dialog: Locator) {
+ return dialog.locator('[data-slot="detail-dialog-body"]');
+}
+
+export function detailDialogAside(dialog: Locator) {
+ return dialog.locator('[data-slot="detail-dialog-aside"]');
+}
+
+/**
+ * The shared dialog shell names its close control with `aria-label` only, so a
+ * visible "Close" string next to the icon is a regression.
+ */
+export async function expectIconOnlyCloseButton(dialog: Locator) {
+ const close = dialog.getByRole("button", { name: /^(Close|关闭)$/i }).first();
+ await expect(close).toBeVisible();
+ expect((await close.innerText()).trim()).toBe("");
+ return close;
+}
+
+export async function closeDetailDialog(page: Page, dialog: Locator) {
+ const close = await expectIconOnlyCloseButton(dialog);
+ await close.click();
+ await expect(page.locator(DETAIL_DIALOG_SELECTOR)).toHaveCount(0, {
+ timeout: 5_000,
+ });
+}
+
+export async function expectDetailDialogFitsViewport(
+ page: Page,
+ dialog: Locator,
+) {
+ expect(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= window.innerWidth,
+ ),
+ ).toBe(true);
+
+ const viewport = page.viewportSize();
+ const box = await dialog.boundingBox();
+ expect(box).not.toBeNull();
+ if (!box || !viewport) return;
+ expect(box.x).toBeGreaterThanOrEqual(0);
+ expect(box.x + box.width).toBeLessThanOrEqual(viewport.width + 1);
+ expect(box.height).toBeLessThanOrEqual(viewport.height);
+}
+
+/**
+ * Documented homework popup order (`docs/contracts/homework.json`): description,
+ * due summary, vertical metadata excluding platform createdAt, action controls,
+ * then discussion. The due summary carries the primary properties, so it must
+ * come before the metadata rows, and the metadata list must not repeat the due
+ * date or expose a creation timestamp.
+ */
+export async function expectHomeworkDetailOrder(dialog: Locator) {
+ const body = detailDialogBody(dialog);
+
+ const dueSummary = body
+ .locator('[data-slot="item"]')
+ .filter({ hasText: /提交截止|Submission due/i })
+ .first();
+ await expect(dueSummary).toBeVisible();
+
+ const metadata = body.locator("dl").first();
+ await expect(metadata).toBeVisible();
+ await expect(metadata.getByText(/发布日期|Published/i)).toBeVisible();
+ await expect(metadata.getByText(/提交开始|Submission opens/i)).toBeVisible();
+ await expect(metadata.getByText(/提交截止|Submission due/i)).toHaveCount(0);
+ await expect(metadata.getByText(/创建于|Created/i)).toHaveCount(0);
+
+ const dueBox = await dueSummary.boundingBox();
+ const metadataBox = await metadata.boundingBox();
+ expect(dueBox).not.toBeNull();
+ expect(metadataBox).not.toBeNull();
+ if (dueBox && metadataBox) {
+ expect(dueBox.y).toBeLessThan(metadataBox.y);
+ }
+}
+
+/** Action controls belong in the details column, ahead of the discussion. */
+export async function expectDialogActionsInBody(
+ dialog: Locator,
+ actionName: RegExp,
+) {
+ const action = detailDialogBody(dialog)
+ .getByRole("button", { name: actionName })
+ .first();
+ await expect(action).toBeVisible();
+ return action;
+}
diff --git a/tests/unit/homework-detail-meta.test.ts b/tests/unit/homework-detail-meta.test.ts
new file mode 100644
index 000000000..e1112026b
--- /dev/null
+++ b/tests/unit/homework-detail-meta.test.ts
@@ -0,0 +1,189 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ buildHomeworkDetailTags,
+ buildHomeworkDueSummary,
+ buildHomeworkMetadataRows,
+ homeworkCompletionStatusLabel,
+} from "@/features/homeworks/lib/homework-detail-meta";
+
+const statusLabels = {
+ completedStatus: "Completed",
+ incompleteStatus: "Incomplete",
+};
+
+const metaLabels = {
+ publishedAt: "Published",
+ submissionStart: "Submission opens",
+};
+
+const tagLabels = {
+ tagMajor: "Major assignment",
+ tagTeam: "Team required",
+};
+
+function formatDate(value: Date | string | null | undefined) {
+ return value ? `formatted:${String(value)}` : "TBD";
+}
+
+describe("homeworkCompletionStatusLabel", () => {
+ it("reports completion status rather than a homework attribute", () => {
+ expect(homeworkCompletionStatusLabel(true, statusLabels)).toBe("Completed");
+ expect(homeworkCompletionStatusLabel(false, statusLabels)).toBe(
+ "Incomplete",
+ );
+ });
+});
+
+describe("buildHomeworkDueSummary", () => {
+ it("carries the due date, status and relative label as primary properties", () => {
+ expect(
+ buildHomeworkDueSummary({
+ completed: false,
+ dueLabel: "Submission due",
+ etaLabel: "in 3 days",
+ formatDate,
+ homework: { submissionDueAt: "2026-06-20T00:00:00Z" },
+ statusLabel: "Incomplete",
+ }),
+ ).toEqual({
+ completed: false,
+ dueLabel: "Submission due",
+ dueValue: "formatted:2026-06-20T00:00:00Z",
+ etaLabel: "in 3 days",
+ statusLabel: "Incomplete",
+ });
+ });
+
+ it("formats a missing due date through the caller's formatter", () => {
+ const formatter = vi.fn(formatDate);
+
+ const summary = buildHomeworkDueSummary({
+ completed: true,
+ dueLabel: "Submission due",
+ formatDate: formatter,
+ homework: {},
+ statusLabel: "Completed",
+ });
+
+ expect(formatter).toHaveBeenCalledWith(undefined);
+ expect(summary.dueValue).toBe("TBD");
+ expect(summary.completed).toBe(true);
+ });
+
+ it("keeps a trimmed relative label and drops blank ones", () => {
+ const trimmed = buildHomeworkDueSummary({
+ completed: false,
+ dueLabel: "Submission due",
+ etaLabel: " overdue ",
+ formatDate,
+ homework: {},
+ statusLabel: "Incomplete",
+ });
+ const blank = buildHomeworkDueSummary({
+ completed: false,
+ dueLabel: "Submission due",
+ etaLabel: " ",
+ formatDate,
+ homework: {},
+ statusLabel: "Incomplete",
+ });
+ const missing = buildHomeworkDueSummary({
+ completed: false,
+ dueLabel: "Submission due",
+ formatDate,
+ homework: {},
+ statusLabel: "Incomplete",
+ });
+
+ expect(trimmed.etaLabel).toBe("overdue");
+ expect(blank.etaLabel).toBeNull();
+ expect(missing.etaLabel).toBeNull();
+ });
+});
+
+describe("buildHomeworkMetadataRows", () => {
+ it("lists the publication date before the submission window opens", () => {
+ const rows = buildHomeworkMetadataRows({
+ formatDate,
+ homework: {
+ publishedAt: "2026-06-01T00:00:00Z",
+ submissionStartAt: "2026-06-10T00:00:00Z",
+ },
+ labels: metaLabels,
+ });
+
+ expect(rows).toEqual([
+ {
+ key: "publishedAt",
+ label: "Published",
+ value: "formatted:2026-06-01T00:00:00Z",
+ },
+ {
+ key: "submissionStartAt",
+ label: "Submission opens",
+ value: "formatted:2026-06-10T00:00:00Z",
+ },
+ ]);
+ });
+
+ it("excludes the due date and platform timestamps", () => {
+ const rows = buildHomeworkMetadataRows({
+ formatDate,
+ homework: {
+ createdAt: "2026-05-01T00:00:00Z",
+ submissionDueAt: "2026-06-20T00:00:00Z",
+ } as Parameters[0]["homework"],
+ labels: metaLabels,
+ });
+
+ expect(rows.map((row) => row.key)).toEqual([
+ "publishedAt",
+ "submissionStartAt",
+ ]);
+ expect(rows.every((row) => row.value === "TBD")).toBe(true);
+ });
+});
+
+describe("buildHomeworkDetailTags", () => {
+ it("returns nothing for standard homework", () => {
+ expect(
+ buildHomeworkDetailTags({
+ homework: { isMajor: false, requiresTeam: false },
+ labels: tagLabels,
+ }),
+ ).toEqual([]);
+ expect(
+ buildHomeworkDetailTags({
+ homework: { isMajor: null, requiresTeam: undefined },
+ labels: tagLabels,
+ }),
+ ).toEqual([]);
+ });
+
+ it("lists major before team", () => {
+ expect(
+ buildHomeworkDetailTags({
+ homework: { isMajor: true, requiresTeam: true },
+ labels: tagLabels,
+ }),
+ ).toEqual([
+ { key: "major", label: "Major assignment" },
+ { key: "team", label: "Team required" },
+ ]);
+ });
+
+ it("includes only the flags that are set", () => {
+ expect(
+ buildHomeworkDetailTags({
+ homework: { isMajor: true },
+ labels: tagLabels,
+ }).map((tag) => tag.key),
+ ).toEqual(["major"]);
+ expect(
+ buildHomeworkDetailTags({
+ homework: { requiresTeam: true },
+ labels: tagLabels,
+ }).map((tag) => tag.key),
+ ).toEqual(["team"]);
+ });
+});