From d3e84ab0a74d9d41b0c7611669c3c8c9d14e045b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 8 Aug 2026 13:02:26 +0000 Subject: [PATCH 01/12] fix(ui): hide close button labels Co-authored-by: Tiankai Ma --- src/lib/components/ui/dialog/dialog-content.svelte | 11 ++++++++--- src/lib/components/ui/sheet/sheet-content.svelte | 11 ++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) 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 })} - {/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 })} - {/snippet} From 756e254850671f9001560fea220c623d6676e296 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 8 Aug 2026 14:11:00 +0000 Subject: [PATCH 02/12] refactor(ui): redesign homework detail dialog Co-authored-by: Tiankai Ma --- .../SectionHomeworkActionBar.svelte | 95 +++++++++++-------- .../SectionHomeworkDetailDialog.svelte | 40 ++++---- .../SectionHomeworkReadOnlySummary.svelte | 18 ++-- 3 files changed, 87 insertions(+), 66 deletions(-) diff --git a/src/features/section-detail/components/SectionHomeworkActionBar.svelte b/src/features/section-detail/components/SectionHomeworkActionBar.svelte index f76920189..2d3ae6639 100644 --- a/src/features/section-detail/components/SectionHomeworkActionBar.svelte +++ b/src/features/section-detail/components/SectionHomeworkActionBar.svelte @@ -1,4 +1,9 @@ -
- - {#if canWrite} - - {/if} - {#if canWrite} - - {/if} - {#if canManage} - - {/if} -
+{#if canWrite || canManage} +
+ + {#if canManage} + + {/if} + + {#if canWrite} +
+ + +
+ {/if} +
+{/if} diff --git a/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte b/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte index 0fa27cbc8..9e4ac70b8 100644 --- a/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte +++ b/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte @@ -1,10 +1,9 @@ - + {#if homework.description?.content} {#if homework.description.renderedHtml} @@ -37,17 +37,21 @@ export let homeworkCopy: SectionHomeworkCopy;
- +
{homeworkCopy.publishedAt}
-
{fmtDateTime(homework.publishedAt)}
+
{fmtDateTime(homework.publishedAt)}
- +
{homeworkCopy.submissionStart}
-
{fmtDateTime(homework.submissionStartAt)}
+
+ {fmtDateTime(homework.submissionStartAt)} +
- +
{homeworkCopy.submissionDue}
-
{fmtDateTime(homework.submissionDueAt)}
+
+ {fmtDateTime(homework.submissionDueAt)} +
From 6ccb2201d013e59ddcba017d0f8bdefaf787a253 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 8 Aug 2026 14:22:11 +0000 Subject: [PATCH 03/12] fix(ui): size homework dialog to content Co-authored-by: Tiankai Ma --- .../components/SectionHomeworkDetailDialog.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte b/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte index 9e4ac70b8..a98e7c9f9 100644 --- a/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte +++ b/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte @@ -87,7 +87,7 @@ export let sectionJwId: number | string; - +
{#if _editingHomework} From 3e1c2cb141c38a5d42e40c2d9e52c5bc73da337a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 8 Aug 2026 15:03:20 +0000 Subject: [PATCH 04/12] refactor(ui): rebuild detail dialogs on a shared shell Co-authored-by: Tiankai Ma --- .../components/HomeworkDetailActions.svelte | 53 +++--- .../HomeworkDetailCommentsAside.svelte | 22 +-- .../HomeworkDetailDescription.svelte | 2 +- .../components/HomeworkDetailDialog.svelte | 98 +++++----- .../components/HomeworkDetailMetadata.svelte | 68 +++---- .../dashboard/components/HomeworksTab.svelte | 1 - .../components/HomeworksTabDialogs.svelte | 2 - .../components/TodoDetailDialog.svelte | 150 ++++++++-------- .../dashboard-homework-detail-types.ts | 1 + .../lib/dashboard-controller-types.ts | 1 + .../components/HomeworkDetailMetaGrid.svelte | 27 +++ .../components/HomeworkDetailTags.svelte | 14 ++ .../homeworks/lib/homework-detail-meta.ts | 107 +++++++++++ .../SectionHomeworkActionBar.svelte | 88 ++++----- .../SectionHomeworkDetailDialog.svelte | 167 ++++++++---------- .../SectionHomeworkReadOnlySummary.svelte | 40 ++--- src/lib/components/DetailDialog.svelte | 91 ++++++++++ tests/e2e/src/app/dashboard/homeworks/test.ts | 62 +++++++ tests/e2e/src/app/dashboard/todos/test.ts | 37 ++++ tests/e2e/src/app/sections/[jwId]/test.ts | 102 +++++++++++ tests/e2e/utils/detail-dialog.ts | 69 ++++++++ tests/unit/homework-detail-meta.test.ts | 145 +++++++++++++++ 22 files changed, 947 insertions(+), 400 deletions(-) create mode 100644 src/features/homeworks/components/HomeworkDetailMetaGrid.svelte create mode 100644 src/features/homeworks/components/HomeworkDetailTags.svelte create mode 100644 src/features/homeworks/lib/homework-detail-meta.ts create mode 100644 src/lib/components/DetailDialog.svelte create mode 100644 tests/e2e/utils/detail-dialog.ts create mode 100644 tests/unit/homework-detail-meta.test.ts diff --git a/src/features/dashboard/components/HomeworkDetailActions.svelte b/src/features/dashboard/components/HomeworkDetailActions.svelte index 804c49220..f3184f074 100644 --- a/src/features/dashboard/components/HomeworkDetailActions.svelte +++ b/src/features/dashboard/components/HomeworkDetailActions.svelte @@ -1,7 +1,7 @@ -
- -
- -
- - -
-
-
+ + 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 { Badge } from "$lib/components/ui/badge/index.js"; import type { DashboardHomeworkCommentsPanel, DashboardHomeworkCompletionToggle, @@ -26,7 +24,6 @@ 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; @@ -35,59 +32,46 @@ export let toggleHomeworkCompletion: DashboardHomeworkCompletionToggle; {#if homework} - { - if (!open) onClose(); - }} + {@const selected = homework} + {@const courseLabel = homeworkCourseLabel(selected)} + - - {@const selectedCourseLabel = homeworkCourseLabel(homework)} - {@const SelectedCompletionIcon = homework.completion ? RefreshCw : CheckCircleIcon} - - {homework.title} - - {selectedCourseLabel} · {homeworkCopy.due}: - {fmtDate(homework.submissionDueAt)} - - - -
-
- + {#snippet badges()} + + {homeworkStatus(selected)} + + {/snippet} - + {#snippet body()} + + + {/snippet} - -
- -
-
-
-
+ {#snippet aside()} + + {/snippet} + + {#snippet footer()} + + {/snippet} + {/if} diff --git a/src/features/dashboard/components/HomeworkDetailMetadata.svelte b/src/features/dashboard/components/HomeworkDetailMetadata.svelte index 567442183..940ef8e85 100644 --- a/src/features/dashboard/components/HomeworkDetailMetadata.svelte +++ b/src/features/dashboard/components/HomeworkDetailMetadata.svelte @@ -1,9 +1,11 @@ - - - - - {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..2b7e90dc5 100644 --- a/src/features/dashboard/components/HomeworksTab.svelte +++ b/src/features/dashboard/components/HomeworksTab.svelte @@ -203,7 +203,6 @@ $: ({ {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..9473dfd3f 100644 --- a/src/features/dashboard/components/HomeworksTabDialogs.svelte +++ b/src/features/dashboard/components/HomeworksTabDialogs.svelte @@ -42,7 +42,6 @@ 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,7 +91,6 @@ export let toggleHomeworkCompletion: ( {homeworkEtaLabel} {homeworkCourseLabel} {homeworkSavingById} - {homeworkSectionHref} {homeworksCopy} {homeworkCopy} {homeworkStatus} diff --git a/src/features/dashboard/components/TodoDetailDialog.svelte b/src/features/dashboard/components/TodoDetailDialog.svelte index 6a1b6a5dd..2a512a238 100644 --- a/src/features/dashboard/components/TodoDetailDialog.svelte +++ b/src/features/dashboard/components/TodoDetailDialog.svelte @@ -1,15 +1,17 @@ {#if todo} - { - if (!open) onClose(); - }} + {@const selected = todo} + - - - {todo.title} - - {todo.priority} · {fmtDate(todo.dueAt)} - - -
- {#if todo.content} - + {#snippet badges()} + + {todosCopy.priority[selected.priority]} + + + {todoStatus(selected)} + + {/snippet} + + {#snippet body()} + + + {#if selected.content} + + {:else} + {todosCopy.contentPlaceholder} + {/if} + + + {/snippet} + + {#snippet footer()} + + + -
- - -
-
-
- - + {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/HomeworkDetailMetaGrid.svelte b/src/features/homeworks/components/HomeworkDetailMetaGrid.svelte new file mode 100644 index 000000000..a86799f7e --- /dev/null +++ b/src/features/homeworks/components/HomeworkDetailMetaGrid.svelte @@ -0,0 +1,27 @@ + + +
+ {#each rows as row (row.key)} +
+
{row.label}
+
+ + {row.value} + + {#if row.hint} + {row.hint} + {/if} +
+
+ {/each} +
diff --git a/src/features/homeworks/components/HomeworkDetailTags.svelte b/src/features/homeworks/components/HomeworkDetailTags.svelte new file mode 100644 index 000000000..1b3e27c87 --- /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/lib/homework-detail-meta.ts b/src/features/homeworks/lib/homework-detail-meta.ts new file mode 100644 index 000000000..ec59fd80f --- /dev/null +++ b/src/features/homeworks/lib/homework-detail-meta.ts @@ -0,0 +1,107 @@ +export type HomeworkDetailMetaValue = Date | string | null | undefined; + +export type HomeworkDetailMetaKey = + | "publishedAt" + | "submissionDueAt" + | "submissionStartAt"; + +export type HomeworkDetailMetaRow = { + emphasis: boolean; + hint: string | null; + key: HomeworkDetailMetaKey; + label: string; + value: string; +}; + +export type HomeworkDetailTagKey = "major" | "team"; + +export type HomeworkDetailTag = { + key: HomeworkDetailTagKey; + label: string; + variant: "secondary"; +}; + +type HomeworkDetailDates = { + publishedAt?: HomeworkDetailMetaValue; + submissionDueAt?: HomeworkDetailMetaValue; + submissionStartAt?: HomeworkDetailMetaValue; +}; + +type HomeworkDetailFlags = { + isMajor?: boolean | null; + requiresTeam?: boolean | null; +}; + +function optionalText(value: string | null | undefined) { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +/** + * Homework timeline cells in reading order (publish → submission window). The + * due cell carries `emphasis` because it is the value users scan for, and an + * optional `hint` for a relative label such as "in 3 days". + */ +export function buildHomeworkDetailMetaRows({ + dueHint, + formatDate, + homework, + labels, +}: { + dueHint?: string | null; + formatDate: (value: HomeworkDetailMetaValue) => string; + homework: HomeworkDetailDates; + labels: { + publishedAt: string; + submissionDue: string; + submissionStart: string; + }; +}): HomeworkDetailMetaRow[] { + return [ + { + emphasis: false, + hint: null, + key: "publishedAt", + label: labels.publishedAt, + value: formatDate(homework.publishedAt), + }, + { + emphasis: false, + hint: null, + key: "submissionStartAt", + label: labels.submissionStart, + value: formatDate(homework.submissionStartAt), + }, + { + emphasis: true, + hint: optionalText(dueHint), + key: "submissionDueAt", + label: labels.submissionDue, + value: formatDate(homework.submissionDueAt), + }, + ]; +} + +/** + * Attribute chips share one variant so they read as a uniform group next to the + * completion status badge, which carries its own emphasis. + */ +export function buildHomeworkDetailTags({ + homework, + labels, +}: { + homework: HomeworkDetailFlags; + labels: { + tagMajor: string; + tagTeam: string; + }; +}): HomeworkDetailTag[] { + const tags: HomeworkDetailTag[] = []; + if (homework.isMajor) { + tags.push({ key: "major", label: labels.tagMajor, variant: "secondary" }); + } + if (homework.requiresTeam) { + tags.push({ key: "team", label: labels.tagTeam, variant: "secondary" }); + } + return tags; +} diff --git a/src/features/section-detail/components/SectionHomeworkActionBar.svelte b/src/features/section-detail/components/SectionHomeworkActionBar.svelte index 2d3ae6639..3033dd81c 100644 --- a/src/features/section-detail/components/SectionHomeworkActionBar.svelte +++ b/src/features/section-detail/components/SectionHomeworkActionBar.svelte @@ -3,76 +3,54 @@ import CheckCircleIcon from "@lucide/svelte/icons/check-circle"; import PencilIcon from "@lucide/svelte/icons/pencil"; import RotateCcwIcon from "@lucide/svelte/icons/rotate-ccw"; import Trash2Icon from "@lucide/svelte/icons/trash-2"; -import XIcon from "@lucide/svelte/icons/x"; import { Button } from "$lib/components/ui/button/index.js"; import type { SectionHomeworkAction, SectionHomeworkCopy, SectionHomeworkDisplay, - SectionHomeworkSectionCopy, } from "./section-homework-display-types"; export let canManage: boolean; export let canWrite: boolean; -export let cancelEdit: () => void; -export let editing: boolean; export let homework: SectionHomeworkDisplay; export let homeworkCopy: SectionHomeworkCopy; -export let sectionCopy: SectionHomeworkSectionCopy; export let setDeleteHomeworkTarget: SectionHomeworkAction; export let startEdit: () => void; export let toggleHomeworkCompletion: SectionHomeworkAction; -{#if canWrite || canManage} -
- - {#if canManage} - - {/if} - - {#if canWrite} -
- - -
+ +{#if canManage} + +{/if} +{#if canWrite} + +
+ {homework.completion ? homeworkCopy.markIncomplete : homeworkCopy.markComplete} + {/if} diff --git a/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte b/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte index a98e7c9f9..931ca8222 100644 --- a/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte +++ b/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte @@ -1,9 +1,8 @@ {#if _selectedHomework} - { - if (!open) close(); - }} + {@const homework = _selectedHomework} + - - -
-
- - {_selectedHomework.title} - - - {_homeworkStatus(_selectedHomework)} - -
- - {_sectionCopy.due} · {_fmtDateTime(_selectedHomework.submissionDueAt)} - -
-
+ {#snippet badges()} + + {_homeworkStatus(homework)} + + {/snippet} - -
-
- {#if _editingHomework} - - {:else} - - {/if} + {#snippet body()} + {#if _editingHomework} + + {:else} + + {/if} - + + {/snippet} - -
+ {#snippet aside()} + {#key `comments:homework:${homework.id}`} + + {/key} + {/snippet} - -
-
-
-
+ {#snippet footer()} + + {/snippet} + {/if} diff --git a/src/features/section-detail/components/SectionHomeworkReadOnlySummary.svelte b/src/features/section-detail/components/SectionHomeworkReadOnlySummary.svelte index f0307a133..3bc086127 100644 --- a/src/features/section-detail/components/SectionHomeworkReadOnlySummary.svelte +++ b/src/features/section-detail/components/SectionHomeworkReadOnlySummary.svelte @@ -1,6 +1,11 @@ - + {#if homework.description?.content} {#if homework.description.renderedHtml} @@ -36,26 +48,6 @@ 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/lib/components/DetailDialog.svelte b/src/lib/components/DetailDialog.svelte new file mode 100644 index 000000000..8ab3ae194 --- /dev/null +++ b/src/lib/components/DetailDialog.svelte @@ -0,0 +1,91 @@ + + + { + 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 badges}{@render badges()}{/if} +
+ {#if subtitle} + {subtitle} + {/if} +
+ + +
+
+ {@render body()} +
+ {#if aside} +
+ {@render aside()} +
+ {/if} +
+
+ + {#if footer && showFooter} + + {@render footer()} + + {/if} +
+
diff --git a/tests/e2e/src/app/dashboard/homeworks/test.ts b/tests/e2e/src/app/dashboard/homeworks/test.ts index 8db03a6aa..bab5c2d04 100644 --- a/tests/e2e/src/app/dashboard/homeworks/test.ts +++ b/tests/e2e/src/app/dashboard/homeworks/test.ts @@ -23,6 +23,13 @@ */ import { expect, test } from "@playwright/test"; import { signInAsDebugUser } from "../../../../utils/auth"; +import { + closeDetailDialog, + detailDialog, + detailDialogAside, + detailDialogFooter, + expectHomeworkTimelineCells, +} from "../../../../utils/detail-dialog"; import { DEV_SEED } from "../../../../utils/dev-seed"; import { cleanupHomeworksForE2e } from "../../../../utils/homeworks"; import { visibleText } from "../../../../utils/locators"; @@ -338,6 +345,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 expectHomeworkTimelineCells(dialog); + + await expect( + detailDialogAside(dialog).getByRole("heading", { + name: /作业讨论|Homework discussion/i, + }), + ).toBeVisible(); + + const footer = detailDialogFooter(dialog); + await expect( + footer.getByRole("link", { name: /查看详情|View details/i }), + ).toBeVisible(); + await expect( + footer.getByRole("button", { + name: /标记为完成|取消完成|Mark as complete|Mark as incomplete/i, + }), + ).toBeVisible(); + + 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..0e497c3ec 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, + detailDialogFooter, +} 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,38 @@ 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(); + + const footer = detailDialogFooter(dialog); + await expect( + footer.getByRole("button", { name: /删除待办|Delete todo/i }), + ).toBeVisible(); + await expect( + footer.getByRole("button", { name: /编辑待办|Edit Todo/i }), + ).toBeVisible(); + await expect( + footer.getByRole("button", { name: /标记为完成|Mark as complete/i }), + ).toBeVisible(); + + 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..ce6532f0c 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, + detailDialogFooter, + expectDetailDialogFitsViewport, + expectHomeworkTimelineCells, +} 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,100 @@ 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(/常规作业|已完成|Standard|Completed/i).first(), + ).toBeVisible(); + + await expectHomeworkTimelineCells(dialog); + + await expect( + detailDialogAside(dialog).getByRole("heading", { + name: /作业讨论|Homework discussion/i, + }), + ).toBeVisible(); + + const footer = detailDialogFooter(dialog); + await expect( + footer.getByRole("button", { name: /编辑信息|Edit details/i }), + ).toBeVisible(); + await expect( + footer.getByRole("button", { + name: /标记为完成|取消完成|Mark as complete|Mark as incomplete/i, + }), + ).toBeVisible(); + + 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 expectHomeworkTimelineCells(dialog); + + const primaryAction = detailDialogFooter(dialog) + .getByRole("button", { + name: /标记为完成|取消完成|Mark as complete|Mark as incomplete/i, + }) + .first(); + await expect(primaryAction).toBeVisible(); + + 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) => { diff --git a/tests/e2e/utils/detail-dialog.ts b/tests/e2e/utils/detail-dialog.ts new file mode 100644 index 000000000..4f3d6627d --- /dev/null +++ b/tests/e2e/utils/detail-dialog.ts @@ -0,0 +1,69 @@ +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"]'); +} + +export function detailDialogFooter(dialog: Locator) { + return dialog.locator('[data-slot="dialog-footer"]'); +} + +/** + * 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); +} + +/** Publish → submission window cells rendered by `HomeworkDetailMetaGrid`. */ +export async function expectHomeworkTimelineCells(dialog: Locator) { + const body = detailDialogBody(dialog); + await expect(body.getByText(/发布日期|Published/i).first()).toBeVisible(); + await expect( + body.getByText(/提交开始|Submission opens/i).first(), + ).toBeVisible(); + await expect( + body.getByText(/提交截止|Submission due/i).first(), + ).toBeVisible(); +} diff --git a/tests/unit/homework-detail-meta.test.ts b/tests/unit/homework-detail-meta.test.ts new file mode 100644 index 000000000..8ea86276b --- /dev/null +++ b/tests/unit/homework-detail-meta.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildHomeworkDetailMetaRows, + buildHomeworkDetailTags, +} from "@/features/homeworks/lib/homework-detail-meta"; + +const labels = { + publishedAt: "Published", + submissionDue: "Submission due", + submissionStart: "Submission start", +}; + +const tagLabels = { + tagMajor: "Major assignment", + tagTeam: "Team required", +}; + +function formatDate(value: Date | string | null | undefined) { + return value ? `formatted:${String(value)}` : "TBD"; +} + +describe("buildHomeworkDetailMetaRows", () => { + it("orders cells from publish date through the submission window", () => { + const rows = buildHomeworkDetailMetaRows({ + formatDate, + homework: { + publishedAt: "2026-06-01T00:00:00Z", + submissionDueAt: "2026-06-20T00:00:00Z", + submissionStartAt: "2026-06-10T00:00:00Z", + }, + labels, + }); + + expect(rows.map((row) => row.key)).toEqual([ + "publishedAt", + "submissionStartAt", + "submissionDueAt", + ]); + expect(rows.map((row) => row.label)).toEqual([ + "Published", + "Submission start", + "Submission due", + ]); + expect(rows.map((row) => row.value)).toEqual([ + "formatted:2026-06-01T00:00:00Z", + "formatted:2026-06-10T00:00:00Z", + "formatted:2026-06-20T00:00:00Z", + ]); + }); + + it("emphasizes only the due cell", () => { + const rows = buildHomeworkDetailMetaRows({ + formatDate, + homework: {}, + labels, + }); + + expect(rows.filter((row) => row.emphasis).map((row) => row.key)).toEqual([ + "submissionDueAt", + ]); + }); + + it("formats missing dates through the caller's formatter", () => { + const formatter = vi.fn(formatDate); + + const rows = buildHomeworkDetailMetaRows({ + formatDate: formatter, + homework: { submissionDueAt: null }, + labels, + }); + + expect(formatter).toHaveBeenCalledTimes(3); + expect(formatter).toHaveBeenCalledWith(undefined); + expect(formatter).toHaveBeenCalledWith(null); + expect(rows.map((row) => row.value)).toEqual(["TBD", "TBD", "TBD"]); + }); + + it("keeps a trimmed due hint and drops blank hints", () => { + const withHint = buildHomeworkDetailMetaRows({ + dueHint: " in 3 days ", + formatDate, + homework: {}, + labels, + }); + const withBlankHint = buildHomeworkDetailMetaRows({ + dueHint: " ", + formatDate, + homework: {}, + labels, + }); + const withoutHint = buildHomeworkDetailMetaRows({ + formatDate, + homework: {}, + labels, + }); + + expect(withHint.map((row) => row.hint)).toEqual([null, null, "in 3 days"]); + expect(withBlankHint.map((row) => row.hint)).toEqual([null, null, null]); + expect(withoutHint.map((row) => row.hint)).toEqual([null, null, null]); + }); +}); + +describe("buildHomeworkDetailTags", () => { + it("returns nothing when no flag is set", () => { + 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 as one uniform chip group", () => { + expect( + buildHomeworkDetailTags({ + homework: { isMajor: true, requiresTeam: true }, + labels: tagLabels, + }), + ).toEqual([ + { key: "major", label: "Major assignment", variant: "secondary" }, + { key: "team", label: "Team required", variant: "secondary" }, + ]); + }); + + 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"]); + }); +}); From 2a525e034d6ef606a341c1b7b0393c118e8e33f2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 8 Aug 2026 16:06:21 +0000 Subject: [PATCH 05/12] refactor(ui): follow documented detail popup hierarchy Co-authored-by: Tiankai Ma --- .../components/HomeworkDetailActions.svelte | 46 ++--- .../components/HomeworkDetailDialog.svelte | 35 ++-- .../components/HomeworkDetailMetadata.svelte | 24 ++- .../dashboard/components/HomeworksTab.svelte | 1 - .../components/HomeworksTabDialogs.svelte | 2 - .../components/TodoDetailDialog.svelte | 141 ++++++++------- .../components/HomeworkDetailMetaGrid.svelte | 27 --- .../components/HomeworkDetailTags.svelte | 2 +- .../components/HomeworkDueSummary.svelte | 24 +++ .../components/HomeworkMetaList.svelte | 14 ++ .../homeworks/lib/homework-detail-meta.ts | 118 +++++++----- .../components/SectionDetailDialogs.svelte | 2 - .../SectionDetailPageController.svelte | 6 - .../SectionHomeworkActionBar.svelte | 73 ++++---- .../SectionHomeworkDetailDialog.svelte | 43 ++--- .../components/SectionHomeworkDialogs.svelte | 3 - .../SectionHomeworkReadOnlySummary.svelte | 26 ++- .../components/section-detail-dialog-types.ts | 1 - .../section-homework-display-types.ts | 6 +- .../lib/section-detail-controller-types.ts | 1 + .../lib/section-detail-derived-state.ts | 9 - src/lib/components/DetailDialog.svelte | 34 +--- tests/e2e/src/app/dashboard/homeworks/test.ts | 19 +- tests/e2e/src/app/dashboard/todos/test.ts | 15 +- tests/e2e/src/app/sections/[jwId]/test.ts | 37 ++-- tests/e2e/utils/detail-dialog.ts | 54 ++++-- tests/unit/homework-detail-meta.test.ts | 170 +++++++++++------- 27 files changed, 517 insertions(+), 416 deletions(-) delete mode 100644 src/features/homeworks/components/HomeworkDetailMetaGrid.svelte create mode 100644 src/features/homeworks/components/HomeworkDueSummary.svelte create mode 100644 src/features/homeworks/components/HomeworkMetaList.svelte diff --git a/src/features/dashboard/components/HomeworkDetailActions.svelte b/src/features/dashboard/components/HomeworkDetailActions.svelte index f3184f074..ae9b60a84 100644 --- a/src/features/dashboard/components/HomeworkDetailActions.svelte +++ b/src/features/dashboard/components/HomeworkDetailActions.svelte @@ -2,6 +2,7 @@ import CheckCircleIcon from "@lucide/svelte/icons/check-circle"; import RotateCcwIcon from "@lucide/svelte/icons/rotate-ccw"; import { Button } from "$lib/components/ui/button/index.js"; +import { Separator } from "$lib/components/ui/separator/index.js"; import type { DashboardHomeworkCompletionToggle, DashboardHomeworkDetailAction, @@ -17,23 +18,28 @@ export let homeworksCopy: DashboardHomeworkDetailCopy; export let toggleHomeworkCompletion: DashboardHomeworkCompletionToggle; - - +
+ +
+ + +
+
diff --git a/src/features/dashboard/components/HomeworkDetailDialog.svelte b/src/features/dashboard/components/HomeworkDetailDialog.svelte index 10eae1beb..92fb1234a 100644 --- a/src/features/dashboard/components/HomeworkDetailDialog.svelte +++ b/src/features/dashboard/components/HomeworkDetailDialog.svelte @@ -1,7 +1,5 @@ - + + + diff --git a/src/features/dashboard/components/HomeworksTab.svelte b/src/features/dashboard/components/HomeworksTab.svelte index 2b7e90dc5..0c938838c 100644 --- a/src/features/dashboard/components/HomeworksTab.svelte +++ b/src/features/dashboard/components/HomeworksTab.svelte @@ -197,7 +197,6 @@ $: ({ bind:createHomeworkSubmissionStartAt {fmtDate} {homeworkCompletionActionLabel} - {homeworkCopy} {homeworkCourseLabel} {homeworkDetailHref} {homeworkEtaLabel} diff --git a/src/features/dashboard/components/HomeworksTabDialogs.svelte b/src/features/dashboard/components/HomeworksTabDialogs.svelte index 9473dfd3f..9aa314f89 100644 --- a/src/features/dashboard/components/HomeworksTabDialogs.svelte +++ b/src/features/dashboard/components/HomeworksTabDialogs.svelte @@ -36,7 +36,6 @@ 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; @@ -92,7 +91,6 @@ export let toggleHomeworkCompletion: ( {homeworkCourseLabel} {homeworkSavingById} {homeworksCopy} - {homeworkCopy} {homeworkStatus} onClose={() => { selectedHomework = null; diff --git a/src/features/dashboard/components/TodoDetailDialog.svelte b/src/features/dashboard/components/TodoDetailDialog.svelte index 2a512a238..593ab85e7 100644 --- a/src/features/dashboard/components/TodoDetailDialog.svelte +++ b/src/features/dashboard/components/TodoDetailDialog.svelte @@ -12,6 +12,7 @@ import MarkdownPreview from "$lib/components/MarkdownPreview.svelte"; import { Badge } from "$lib/components/ui/badge/index.js"; import { Button } from "$lib/components/ui/button/index.js"; import * as Item from "$lib/components/ui/item/index.js"; +import { Separator } from "$lib/components/ui/separator/index.js"; export let deleteTodo: (todo: DashboardTodoItem) => void; export let fmtDate: (value: string | Date | null | undefined) => string; @@ -27,28 +28,11 @@ export let toggleTodoCompletion: (todo: DashboardTodoItem) => void; {#if todo} {@const selected = todo} - - {#snippet badges()} - - {todosCopy.priority[selected.priority]} - - - {todoStatus(selected)} - - {/snippet} - + + {#snippet body()} - + {#if selected.content} @@ -57,47 +41,82 @@ export let toggleTodoCompletion: (todo: DashboardTodoItem) => void; {/if} - {/snippet} - {#snippet footer()} - - - + + + + {todosCopy.dueLabel} + {fmtDate(selected.dueAt)} + + + + {todoStatus(selected)} + + + + + +
+
+
{todosCopy.priorityLabel}
+
+ + {todosCopy.priority[selected.priority]} + +
+
+
+ +
+ +
+ +
+ + +
+
+
{/snippet}
{/if} diff --git a/src/features/homeworks/components/HomeworkDetailMetaGrid.svelte b/src/features/homeworks/components/HomeworkDetailMetaGrid.svelte deleted file mode 100644 index a86799f7e..000000000 --- a/src/features/homeworks/components/HomeworkDetailMetaGrid.svelte +++ /dev/null @@ -1,27 +0,0 @@ - - -
- {#each rows as row (row.key)} -
-
{row.label}
-
- - {row.value} - - {#if row.hint} - {row.hint} - {/if} -
-
- {/each} -
diff --git a/src/features/homeworks/components/HomeworkDetailTags.svelte b/src/features/homeworks/components/HomeworkDetailTags.svelte index 1b3e27c87..b033b94ae 100644 --- a/src/features/homeworks/components/HomeworkDetailTags.svelte +++ b/src/features/homeworks/components/HomeworkDetailTags.svelte @@ -8,7 +8,7 @@ export let tags: HomeworkDetailTag[]; {#if tags.length > 0}
{#each tags as tag (tag.key)} - {tag.label} + {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 index ec59fd80f..3aa5ef716 100644 --- a/src/features/homeworks/lib/homework-detail-meta.ts +++ b/src/features/homeworks/lib/homework-detail-meta.ts @@ -1,13 +1,25 @@ -export type HomeworkDetailMetaValue = Date | string | null | undefined; +export type HomeworkDetailDateValue = Date | string | null | undefined; -export type HomeworkDetailMetaKey = - | "publishedAt" - | "submissionDueAt" - | "submissionStartAt"; +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 = { - emphasis: boolean; - hint: string | null; key: HomeworkDetailMetaKey; label: string; value: string; @@ -18,18 +30,6 @@ export type HomeworkDetailTagKey = "major" | "team"; export type HomeworkDetailTag = { key: HomeworkDetailTagKey; label: string; - variant: "secondary"; -}; - -type HomeworkDetailDates = { - publishedAt?: HomeworkDetailMetaValue; - submissionDueAt?: HomeworkDetailMetaValue; - submissionStartAt?: HomeworkDetailMetaValue; -}; - -type HomeworkDetailFlags = { - isMajor?: boolean | null; - requiresTeam?: boolean | null; }; function optionalText(value: string | null | undefined) { @@ -38,59 +38,89 @@ function optionalText(value: string | null | undefined) { } /** - * Homework timeline cells in reading order (publish → submission window). The - * due cell carries `emphasis` because it is the value users scan for, and an - * optional `hint` for a relative label such as "in 3 days". + * 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 buildHomeworkDetailMetaRows({ - dueHint, +export function buildHomeworkMetadataRows({ formatDate, homework, labels, }: { - dueHint?: string | null; - formatDate: (value: HomeworkDetailMetaValue) => string; - homework: HomeworkDetailDates; + formatDate: HomeworkDetailDateFormatter; + homework: { + publishedAt?: HomeworkDetailDateValue; + submissionStartAt?: HomeworkDetailDateValue; + }; labels: { publishedAt: string; - submissionDue: string; submissionStart: string; }; }): HomeworkDetailMetaRow[] { return [ { - emphasis: false, - hint: null, key: "publishedAt", label: labels.publishedAt, value: formatDate(homework.publishedAt), }, { - emphasis: false, - hint: null, key: "submissionStartAt", label: labels.submissionStart, value: formatDate(homework.submissionStartAt), }, - { - emphasis: true, - hint: optionalText(dueHint), - key: "submissionDueAt", - label: labels.submissionDue, - value: formatDate(homework.submissionDueAt), - }, ]; } /** - * Attribute chips share one variant so they read as a uniform group next to the - * completion status badge, which carries its own emphasis. + * Only non-default attributes become chips: standard homework never gets a + * "standard" badge. */ export function buildHomeworkDetailTags({ homework, labels, }: { - homework: HomeworkDetailFlags; + homework: { + isMajor?: boolean | null; + requiresTeam?: boolean | null; + }; labels: { tagMajor: string; tagTeam: string; @@ -98,10 +128,10 @@ export function buildHomeworkDetailTags({ }): HomeworkDetailTag[] { const tags: HomeworkDetailTag[] = []; if (homework.isMajor) { - tags.push({ key: "major", label: labels.tagMajor, variant: "secondary" }); + tags.push({ key: "major", label: labels.tagMajor }); } if (homework.requiresTeam) { - tags.push({ key: "team", label: labels.tagTeam, variant: "secondary" }); + 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 3033dd81c..c54f28fcd 100644 --- a/src/features/section-detail/components/SectionHomeworkActionBar.svelte +++ b/src/features/section-detail/components/SectionHomeworkActionBar.svelte @@ -4,6 +4,7 @@ import PencilIcon from "@lucide/svelte/icons/pencil"; import RotateCcwIcon from "@lucide/svelte/icons/rotate-ccw"; import Trash2Icon from "@lucide/svelte/icons/trash-2"; import { Button } from "$lib/components/ui/button/index.js"; +import { Separator } from "$lib/components/ui/separator/index.js"; import type { SectionHomeworkAction, SectionHomeworkCopy, @@ -21,36 +22,44 @@ export let toggleHomeworkCompletion: SectionHomeworkAction; -{#if canManage} - -{/if} -{#if canWrite} - - +{#if canWrite || canManage} +
+ +
+ {#if canManage} + + {/if} + {#if canWrite} +
+ + +
+ {/if} +
+
{/if} diff --git a/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte b/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte index 931ca8222..aab65c58a 100644 --- a/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte +++ b/src/features/section-detail/components/SectionHomeworkDetailDialog.svelte @@ -2,7 +2,6 @@ import type { Component } from "svelte"; import { commentTargetPermalinkBaseHref } from "@/features/comments/lib/comment-panel-controller"; import DetailDialog from "$lib/components/DetailDialog.svelte"; -import { Badge } from "$lib/components/ui/badge/index.js"; import SectionHomeworkActionBar from "./SectionHomeworkActionBar.svelte"; import SectionHomeworkAuditTrail from "./SectionHomeworkAuditTrail.svelte"; import SectionHomeworkEditForm from "./SectionHomeworkEditForm.svelte"; @@ -16,7 +15,6 @@ import type { SectionHomeworkDisplay, SectionHomeworkFormatter, SectionHomeworkMarkdownCopy, - SectionHomeworkSectionCopy, SectionHomeworkSemesterDate, SectionHomeworkSubmitHandler, SectionHomeworkTimestampAction, @@ -49,8 +47,6 @@ export let _fmtDateTime: SectionHomeworkFormatter; export let _formatMessage: FormatMessage; export let _homeworkAuditActionLabel: (action: string) => string; export let _homeworkCopy: SectionHomeworkCopy; -export let _homeworkStatus: (homework: SectionHomeworkDisplay) => string; -export let _sectionCopy: SectionHomeworkSectionCopy & { due: string }; export let _selectedHomework: SectionHomeworkDisplay | null; export let _semesterDate: SectionHomeworkSemesterDate; export let _setDeleteHomeworkTarget: SectionHomeworkAction; @@ -63,18 +59,11 @@ export let sectionJwId: number | string; {#if _selectedHomework} {@const homework = _selectedHomework} - - {#snippet badges()} - - {_homeworkStatus(homework)} - - {/snippet} - + + + {#snippet body()} {#if _editingHomework} + + {/if} {/key} {/snippet} - - {#snippet footer()} - - {/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 3bc086127..fc7506e77 100644 --- a/src/features/section-detail/components/SectionHomeworkReadOnlySummary.svelte +++ b/src/features/section-detail/components/SectionHomeworkReadOnlySummary.svelte @@ -1,9 +1,12 @@ - + {#if homework.description?.content} {#if homework.description.renderedHtml} @@ -48,6 +62,8 @@ $: tags = buildHomeworkDetailTags({ homework, labels: homeworkCopy }); - + + + 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 index 8ab3ae194..9022104e8 100644 --- a/src/lib/components/DetailDialog.svelte +++ b/src/lib/components/DetailDialog.svelte @@ -5,31 +5,26 @@ import { ScrollArea } from "$lib/components/ui/scroll-area/index.js"; import { cn } from "$lib/utils.js"; type Props = { - /** Secondary column, e.g. a discussion rail. Widens the dialog when set. */ + /** + * Discussion column. Per `docs/contracts/_ui.json` layout principles, popups + * with discussion keep details on the left and discussion on the right on + * desktop, and stack in that order on mobile. + */ aside?: Snippet; - badges?: Snippet; body: Snippet; class?: string; - footer?: Snippet; - footerClass?: string; onClose: () => void; open?: boolean; - /** Lets callers keep one footer snippet and drop the bar in some states. */ - showFooter?: boolean; subtitle?: string; title: string; }; let { aside, - badges, body, class: className = "", - footer, - footerClass = "", onClose, open = true, - showFooter = true, subtitle = "", title, }: Props = $props(); @@ -49,13 +44,10 @@ let { className, )} > - -
- - {title} - - {#if badges}{@render badges()}{/if} -
+ + + {title} + {#if subtitle} {subtitle} {/if} @@ -79,13 +71,5 @@ let { {/if}
- - {#if footer && showFooter} - - {@render footer()} - - {/if} diff --git a/tests/e2e/src/app/dashboard/homeworks/test.ts b/tests/e2e/src/app/dashboard/homeworks/test.ts index bab5c2d04..d391ddd3b 100644 --- a/tests/e2e/src/app/dashboard/homeworks/test.ts +++ b/tests/e2e/src/app/dashboard/homeworks/test.ts @@ -27,8 +27,9 @@ import { closeDetailDialog, detailDialog, detailDialogAside, - detailDialogFooter, - expectHomeworkTimelineCells, + detailDialogBody, + expectDialogActionsInBody, + expectHomeworkDetailOrder, } from "../../../../utils/detail-dialog"; import { DEV_SEED } from "../../../../utils/dev-seed"; import { cleanupHomeworksForE2e } from "../../../../utils/homeworks"; @@ -377,7 +378,7 @@ test.describe("仪表盘作业", () => { dialog.getByText(/待处理|已完成|Pending|Completed/i).first(), ).toBeVisible(); - await expectHomeworkTimelineCells(dialog); + await expectHomeworkDetailOrder(dialog); await expect( detailDialogAside(dialog).getByRole("heading", { @@ -385,15 +386,15 @@ test.describe("仪表盘作业", () => { }), ).toBeVisible(); - const footer = detailDialogFooter(dialog); await expect( - footer.getByRole("link", { name: /查看详情|View details/i }), - ).toBeVisible(); - await expect( - footer.getByRole("button", { - name: /标记为完成|取消完成|Mark as complete|Mark as incomplete/i, + 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"); diff --git a/tests/e2e/src/app/dashboard/todos/test.ts b/tests/e2e/src/app/dashboard/todos/test.ts index 0e497c3ec..349a9111d 100644 --- a/tests/e2e/src/app/dashboard/todos/test.ts +++ b/tests/e2e/src/app/dashboard/todos/test.ts @@ -24,7 +24,7 @@ import { signInAsDebugUser } from "../../../../utils/auth"; import { closeDetailDialog, detailDialog, - detailDialogFooter, + expectDialogActionsInBody, } from "../../../../utils/detail-dialog"; import { DEV_SEED } from "../../../../utils/dev-seed"; import { visibleText } from "../../../../utils/locators"; @@ -182,16 +182,9 @@ test.describe("仪表盘待办", () => { dialog.getByText(/待处理|已完成|Pending|Completed/i).first(), ).toBeVisible(); - const footer = detailDialogFooter(dialog); - await expect( - footer.getByRole("button", { name: /删除待办|Delete todo/i }), - ).toBeVisible(); - await expect( - footer.getByRole("button", { name: /编辑待办|Edit Todo/i }), - ).toBeVisible(); - await expect( - footer.getByRole("button", { name: /标记为完成|Mark as complete/i }), - ).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"); diff --git a/tests/e2e/src/app/sections/[jwId]/test.ts b/tests/e2e/src/app/sections/[jwId]/test.ts index ce6532f0c..134d9c74d 100644 --- a/tests/e2e/src/app/sections/[jwId]/test.ts +++ b/tests/e2e/src/app/sections/[jwId]/test.ts @@ -49,9 +49,9 @@ import { closeDetailDialog, detailDialog, detailDialogAside, - detailDialogFooter, expectDetailDialogFitsViewport, - expectHomeworkTimelineCells, + expectDialogActionsInBody, + expectHomeworkDetailOrder, } from "../../../../utils/detail-dialog"; import { DEV_SEED } from "../../../../utils/dev-seed"; import { getCurrentSessionUser } from "../../../../utils/e2e-db"; @@ -1186,10 +1186,10 @@ test.describe("/catalog/sections/[jwId] 班级详情页", () => { }), ).toBeVisible(); await expect( - dialog.getByText(/常规作业|已完成|Standard|Completed/i).first(), + dialog.getByText(/未完成|已完成|Incomplete|Completed/i).first(), ).toBeVisible(); - await expectHomeworkTimelineCells(dialog); + await expectHomeworkDetailOrder(dialog); await expect( detailDialogAside(dialog).getByRole("heading", { @@ -1197,15 +1197,11 @@ test.describe("/catalog/sections/[jwId] 班级详情页", () => { }), ).toBeVisible(); - const footer = detailDialogFooter(dialog); - await expect( - footer.getByRole("button", { name: /编辑信息|Edit details/i }), - ).toBeVisible(); - await expect( - footer.getByRole("button", { - name: /标记为完成|取消完成|Mark as complete|Mark as incomplete/i, - }), - ).toBeVisible(); + await expectDialogActionsInBody(dialog, /编辑信息|Edit details/i); + await expectDialogActionsInBody( + dialog, + /标记为完成|取消完成|Mark as complete|Mark as incomplete/i, + ); await captureStepScreenshot( page, @@ -1234,14 +1230,11 @@ test.describe("/catalog/sections/[jwId] 班级详情页", () => { const dialog = detailDialog(page); await expect(dialog).toBeVisible(); await expectDetailDialogFitsViewport(page, dialog); - await expectHomeworkTimelineCells(dialog); - - const primaryAction = detailDialogFooter(dialog) - .getByRole("button", { - name: /标记为完成|取消完成|Mark as complete|Mark as incomplete/i, - }) - .first(); - await expect(primaryAction).toBeVisible(); + await expectHomeworkDetailOrder(dialog); + await expectDialogActionsInBody( + dialog, + /标记为完成|取消完成|Mark as complete|Mark as incomplete/i, + ); await expect( detailDialogAside(dialog).getByRole("heading", { @@ -1332,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 index 4f3d6627d..14f4d9a41 100644 --- a/tests/e2e/utils/detail-dialog.ts +++ b/tests/e2e/utils/detail-dialog.ts @@ -14,10 +14,6 @@ export function detailDialogAside(dialog: Locator) { return dialog.locator('[data-slot="detail-dialog-aside"]'); } -export function detailDialogFooter(dialog: Locator) { - return dialog.locator('[data-slot="dialog-footer"]'); -} - /** * The shared dialog shell names its close control with `aria-label` only, so a * visible "Close" string next to the icon is a regression. @@ -56,14 +52,46 @@ export async function expectDetailDialogFitsViewport( expect(box.height).toBeLessThanOrEqual(viewport.height); } -/** Publish → submission window cells rendered by `HomeworkDetailMetaGrid`. */ -export async function expectHomeworkTimelineCells(dialog: Locator) { +/** + * 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); - await expect(body.getByText(/发布日期|Published/i).first()).toBeVisible(); - await expect( - body.getByText(/提交开始|Submission opens/i).first(), - ).toBeVisible(); - await expect( - body.getByText(/提交截止|Submission due/i).first(), - ).toBeVisible(); + + 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 index 8ea86276b..e1112026b 100644 --- a/tests/unit/homework-detail-meta.test.ts +++ b/tests/unit/homework-detail-meta.test.ts @@ -1,13 +1,19 @@ import { describe, expect, it, vi } from "vitest"; import { - buildHomeworkDetailMetaRows, buildHomeworkDetailTags, + buildHomeworkDueSummary, + buildHomeworkMetadataRows, + homeworkCompletionStatusLabel, } from "@/features/homeworks/lib/homework-detail-meta"; -const labels = { +const statusLabels = { + completedStatus: "Completed", + incompleteStatus: "Incomplete", +}; + +const metaLabels = { publishedAt: "Published", - submissionDue: "Submission due", - submissionStart: "Submission start", + submissionStart: "Submission opens", }; const tagLabels = { @@ -19,89 +25,127 @@ function formatDate(value: Date | string | null | undefined) { return value ? `formatted:${String(value)}` : "TBD"; } -describe("buildHomeworkDetailMetaRows", () => { - it("orders cells from publish date through the submission window", () => { - const rows = buildHomeworkDetailMetaRows({ - formatDate, - homework: { - publishedAt: "2026-06-01T00:00:00Z", - submissionDueAt: "2026-06-20T00:00:00Z", - submissionStartAt: "2026-06-10T00:00:00Z", - }, - labels, - }); - - expect(rows.map((row) => row.key)).toEqual([ - "publishedAt", - "submissionStartAt", - "submissionDueAt", - ]); - expect(rows.map((row) => row.label)).toEqual([ - "Published", - "Submission start", - "Submission due", - ]); - expect(rows.map((row) => row.value)).toEqual([ - "formatted:2026-06-01T00:00:00Z", - "formatted:2026-06-10T00:00:00Z", - "formatted:2026-06-20T00:00:00Z", - ]); +describe("homeworkCompletionStatusLabel", () => { + it("reports completion status rather than a homework attribute", () => { + expect(homeworkCompletionStatusLabel(true, statusLabels)).toBe("Completed"); + expect(homeworkCompletionStatusLabel(false, statusLabels)).toBe( + "Incomplete", + ); }); +}); - it("emphasizes only the due cell", () => { - const rows = buildHomeworkDetailMetaRows({ - formatDate, - homework: {}, - labels, +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", }); - - expect(rows.filter((row) => row.emphasis).map((row) => row.key)).toEqual([ - "submissionDueAt", - ]); }); - it("formats missing dates through the caller's formatter", () => { + it("formats a missing due date through the caller's formatter", () => { const formatter = vi.fn(formatDate); - const rows = buildHomeworkDetailMetaRows({ + const summary = buildHomeworkDueSummary({ + completed: true, + dueLabel: "Submission due", formatDate: formatter, - homework: { submissionDueAt: null }, - labels, + homework: {}, + statusLabel: "Completed", }); - expect(formatter).toHaveBeenCalledTimes(3); expect(formatter).toHaveBeenCalledWith(undefined); - expect(formatter).toHaveBeenCalledWith(null); - expect(rows.map((row) => row.value)).toEqual(["TBD", "TBD", "TBD"]); + expect(summary.dueValue).toBe("TBD"); + expect(summary.completed).toBe(true); }); - it("keeps a trimmed due hint and drops blank hints", () => { - const withHint = buildHomeworkDetailMetaRows({ - dueHint: " in 3 days ", + it("keeps a trimmed relative label and drops blank ones", () => { + const trimmed = buildHomeworkDueSummary({ + completed: false, + dueLabel: "Submission due", + etaLabel: " overdue ", formatDate, homework: {}, - labels, + statusLabel: "Incomplete", }); - const withBlankHint = buildHomeworkDetailMetaRows({ - dueHint: " ", + const blank = buildHomeworkDueSummary({ + completed: false, + dueLabel: "Submission due", + etaLabel: " ", formatDate, homework: {}, - labels, + statusLabel: "Incomplete", }); - const withoutHint = buildHomeworkDetailMetaRows({ + const missing = buildHomeworkDueSummary({ + completed: false, + dueLabel: "Submission due", formatDate, homework: {}, - labels, + statusLabel: "Incomplete", }); - expect(withHint.map((row) => row.hint)).toEqual([null, null, "in 3 days"]); - expect(withBlankHint.map((row) => row.hint)).toEqual([null, null, null]); - expect(withoutHint.map((row) => row.hint)).toEqual([null, null, null]); + 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 when no flag is set", () => { + it("returns nothing for standard homework", () => { expect( buildHomeworkDetailTags({ homework: { isMajor: false, requiresTeam: false }, @@ -116,15 +160,15 @@ describe("buildHomeworkDetailTags", () => { ).toEqual([]); }); - it("lists major before team as one uniform chip group", () => { + it("lists major before team", () => { expect( buildHomeworkDetailTags({ homework: { isMajor: true, requiresTeam: true }, labels: tagLabels, }), ).toEqual([ - { key: "major", label: "Major assignment", variant: "secondary" }, - { key: "team", label: "Team required", variant: "secondary" }, + { key: "major", label: "Major assignment" }, + { key: "team", label: "Team required" }, ]); }); From 7a5cf65a86499ebe525e65e59ce72d955eb9d938 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 07:33:12 +0000 Subject: [PATCH 06/12] refactor(ui): use a single-column detail popup Keep page side rails on pages. Detail popups now use one scrollable reading column with the title in a sticky header and discussion full-width after the details, instead of a desktop two-column overlay. Co-authored-by: Tiankai Ma --- docs/contracts/_ui.json | 2 +- docs/contracts/homework.json | 4 +- .../components/HomeworkDetailDialog.svelte | 4 +- .../SectionHomeworkDetailDialog.svelte | 4 +- src/lib/components/DetailDialog.svelte | 16 +++---- tests/e2e/src/app/dashboard/homeworks/test.ts | 11 ++--- tests/e2e/src/app/sections/[jwId]/test.ts | 18 +++----- tests/e2e/utils/detail-dialog.ts | 43 +++++++++++++++++-- 8 files changed, 62 insertions(+), 40 deletions(-) diff --git a/docs/contracts/_ui.json b/docs/contracts/_ui.json index 00f6f704a..d228dcb38 100644 --- a/docs/contracts/_ui.json +++ b/docs/contracts/_ui.json @@ -1,5 +1,5 @@ { - "Layout Principles": "* Put reading-first content in the left/main column: introductions, descriptions, markdown, homework content, and comments.\n* Put structured facts in the right/side column: identifiers, metadata, dates, counts, actions, mini calendars, and quick links.\n* On mobile, stack in that order.\n* Reuse existing grids, cards, borders, muted text, and buttons.\n* Popups with discussion use details on the left and discussion on the right on desktop; stack on mobile.", + "Layout Principles": "* Put reading-first content in the left/main column: introductions, descriptions, markdown, homework content, and comments.\n* Put structured facts in the right/side column: identifiers, metadata, dates, counts, actions, mini calendars, and quick links.\n* On mobile, stack in that order.\n* Reuse existing grids, cards, borders, muted text, and buttons.\n* Popups are one scrollable column at a comfortable reading width, not a two-column page. Keep the title in a sticky header; put details then discussion in that one stream. Side rails belong on pages, not in overlays.", "List Table": "* Used for discovery lists such as courses, sections, and teachers.\n* Primary names follow the active locale; an available alternate localized name is secondary text.\n* Structured fields support comparison.\n* Rows link to detail pages.\n* Across viewport widths, one prominent search remains directly available, advanced filters open in an accessible sheet, and active filters remain removable above results.\n* Compact layouts use cards that avoid page-level horizontal scrolling; wide desktop layouts keep every comparison-table column inside the results container without horizontal scrolling.\n* Pagination uses URL-driven Previous, page, and Next links while preserving filters.", "Data Table Cells": "* Desktop data-table rows keep a consistent compact height; optional secondary lines reserve space when absent.\n* Overflowing text is truncated, with its full value available from a tooltip on pointer hover and keyboard focus.\n* Course and section codes use plain monospace text, never badge styling.\n* Header labels and cell content share the same column edge; textual and categorical values align left, while numeric measures and counts align right.\n* Blank placeholders are hidden from assistive technology and do not replace meaningful labels such as unknown or TBD.\n* Numeric, status, control, and action cells do not receive truncation tooltips when their content fits.", "Detail Hero": "* Used at the top of course, section, and teacher detail pages.\n* Contains breadcrumb, h1, and an optional subtitle.\n* h1 uses the primary display name of the current object, not an internal ID.\n* At mobile widths, stable public identifiers and concise metadata precede a responsive title so long localized names do not crowd out context or actions.", diff --git a/docs/contracts/homework.json b/docs/contracts/homework.json index 93ebdbef8..e8ad8020c 100644 --- a/docs/contracts/homework.json +++ b/docs/contracts/homework.json @@ -80,7 +80,7 @@ "filter: incomplete/completed/all", "mobile compact toolbar with direct completion filters and overflow display-mode control", "cards/list view mode persisted in browser storage", - "detail popup order: description, due summary, vertical metadata excluding platform createdAt, action controls, discussion; desktop places discussion to the right of the details", + "detail popup is one scrollable column: description, due summary, vertical metadata excluding platform createdAt, action controls, then full-width discussion; do not use a two-column overlay", "create form exposes the collapsible advisory homework title and description style guide" ] } @@ -229,7 +229,7 @@ "user completion status", "edit action", "cards/list view mode persisted in browser storage", - "detail popup order: description, due summary, vertical metadata excluding platform createdAt, edit/completion controls, inline discussion; desktop places discussion to the right of the details", + "detail popup is one scrollable column: description, due summary, vertical metadata excluding platform createdAt, edit/completion controls, then full-width discussion; do not use a two-column overlay", "section cards use a responsive multi-column layout", "create and edit forms expose the same collapsible advisory homework title and description style guide" ] diff --git a/src/features/dashboard/components/HomeworkDetailDialog.svelte b/src/features/dashboard/components/HomeworkDetailDialog.svelte index 92fb1234a..1e77a9b20 100644 --- a/src/features/dashboard/components/HomeworkDetailDialog.svelte +++ b/src/features/dashboard/components/HomeworkDetailDialog.svelte @@ -31,8 +31,8 @@ export let toggleHomeworkCompletion: DashboardHomeworkCompletionToggle; {#if homework} {@const selected = homework} + in the subtitle. One scrollable column: description, due summary, + vertical metadata, action controls, then full-width discussion. --> - + {#snippet body()} {#if _editingHomework} [data-slot=dialog-close]]:top-3.5 [&>[data-slot=dialog-close]]:right-3.5", - aside ? "sm:max-w-5xl" : "sm:max-w-lg", + aside ? "sm:max-w-2xl" : "sm:max-w-lg", className, )} > @@ -54,16 +53,13 @@ let { -
-
+
+
{@render body()}
{#if aside}
{@render aside()} diff --git a/tests/e2e/src/app/dashboard/homeworks/test.ts b/tests/e2e/src/app/dashboard/homeworks/test.ts index d391ddd3b..14c535c22 100644 --- a/tests/e2e/src/app/dashboard/homeworks/test.ts +++ b/tests/e2e/src/app/dashboard/homeworks/test.ts @@ -26,10 +26,11 @@ import { signInAsDebugUser } from "../../../../utils/auth"; import { closeDetailDialog, detailDialog, - detailDialogAside, detailDialogBody, + expectComfortablePopupWidth, expectDialogActionsInBody, expectHomeworkDetailOrder, + expectSingleColumnDiscussion, } from "../../../../utils/detail-dialog"; import { DEV_SEED } from "../../../../utils/dev-seed"; import { cleanupHomeworksForE2e } from "../../../../utils/homeworks"; @@ -379,12 +380,8 @@ test.describe("仪表盘作业", () => { ).toBeVisible(); await expectHomeworkDetailOrder(dialog); - - await expect( - detailDialogAside(dialog).getByRole("heading", { - name: /作业讨论|Homework discussion/i, - }), - ).toBeVisible(); + await expectSingleColumnDiscussion(dialog); + await expectComfortablePopupWidth(page, dialog); await expect( detailDialogBody(dialog).getByRole("link", { diff --git a/tests/e2e/src/app/sections/[jwId]/test.ts b/tests/e2e/src/app/sections/[jwId]/test.ts index 134d9c74d..3e82a65d3 100644 --- a/tests/e2e/src/app/sections/[jwId]/test.ts +++ b/tests/e2e/src/app/sections/[jwId]/test.ts @@ -48,10 +48,11 @@ import { import { closeDetailDialog, detailDialog, - detailDialogAside, + expectComfortablePopupWidth, expectDetailDialogFitsViewport, expectDialogActionsInBody, expectHomeworkDetailOrder, + expectSingleColumnDiscussion, } from "../../../../utils/detail-dialog"; import { DEV_SEED } from "../../../../utils/dev-seed"; import { getCurrentSessionUser } from "../../../../utils/e2e-db"; @@ -1190,12 +1191,8 @@ test.describe("/catalog/sections/[jwId] 班级详情页", () => { ).toBeVisible(); await expectHomeworkDetailOrder(dialog); - - await expect( - detailDialogAside(dialog).getByRole("heading", { - name: /作业讨论|Homework discussion/i, - }), - ).toBeVisible(); + await expectSingleColumnDiscussion(dialog); + await expectComfortablePopupWidth(page, dialog); await expectDialogActionsInBody(dialog, /编辑信息|Edit details/i); await expectDialogActionsInBody( @@ -1231,17 +1228,12 @@ test.describe("/catalog/sections/[jwId] 班级详情页", () => { await expect(dialog).toBeVisible(); await expectDetailDialogFitsViewport(page, dialog); await expectHomeworkDetailOrder(dialog); + await expectSingleColumnDiscussion(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, diff --git a/tests/e2e/utils/detail-dialog.ts b/tests/e2e/utils/detail-dialog.ts index 14f4d9a41..ae1aa58d0 100644 --- a/tests/e2e/utils/detail-dialog.ts +++ b/tests/e2e/utils/detail-dialog.ts @@ -55,9 +55,9 @@ export async function expectDetailDialogFitsViewport( /** * 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. + * then full-width discussion in the same column. 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); @@ -84,6 +84,43 @@ export async function expectHomeworkDetailOrder(dialog: Locator) { } } +/** + * Discussion is the next block in the same reading column, not a right-hand + * rail. Side rails belong on pages (`docs/contracts/_ui.json`). + */ +export async function expectSingleColumnDiscussion(dialog: Locator) { + const body = detailDialogBody(dialog); + const aside = detailDialogAside(dialog); + await expect( + aside.getByRole("heading", { name: /作业讨论|Homework discussion/i }), + ).toBeVisible(); + + const bodyBox = await body.boundingBox(); + const asideBox = await aside.boundingBox(); + expect(bodyBox).not.toBeNull(); + expect(asideBox).not.toBeNull(); + if (!bodyBox || !asideBox) return; + + expect(asideBox.y).toBeGreaterThan(bodyBox.y); + expect(Math.abs(asideBox.x - bodyBox.x)).toBeLessThan(8); +} + +/** Overlays stay at a reading width; they must not become a two-column page. */ +export async function expectComfortablePopupWidth( + page: Page, + dialog: Locator, +) { + const viewport = page.viewportSize(); + const box = await dialog.boundingBox(); + expect(box).not.toBeNull(); + if (!box || !viewport) return; + if (viewport.width >= 1024) { + // Reading column (`sm:max-w-2xl`), not a page-width two-column overlay. + expect(box.width).toBeLessThanOrEqual(720); + expect(box.width).toBeLessThan(viewport.width * 0.7); + } +} + /** Action controls belong in the details column, ahead of the discussion. */ export async function expectDialogActionsInBody( dialog: Locator, From 3b9ed175e5cc49cad260599e82fe732700dce27d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 07:36:54 +0000 Subject: [PATCH 07/12] style(e2e): format single-column popup width helper Co-authored-by: Tiankai Ma --- tests/e2e/utils/detail-dialog.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/e2e/utils/detail-dialog.ts b/tests/e2e/utils/detail-dialog.ts index ae1aa58d0..b005ca984 100644 --- a/tests/e2e/utils/detail-dialog.ts +++ b/tests/e2e/utils/detail-dialog.ts @@ -106,10 +106,7 @@ export async function expectSingleColumnDiscussion(dialog: Locator) { } /** Overlays stay at a reading width; they must not become a two-column page. */ -export async function expectComfortablePopupWidth( - page: Page, - dialog: Locator, -) { +export async function expectComfortablePopupWidth(page: Page, dialog: Locator) { const viewport = page.viewportSize(); const box = await dialog.boundingBox(); expect(box).not.toBeNull(); From 050d61d21039d4815c0e2c7561c49901772481fd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 11:14:02 +0000 Subject: [PATCH 08/12] refactor(ui): stack due facts on homework and todo cards Keep overlay cards as one reading column: datetime and relative urgency first, then compact status/tag chips. Move homework tags off the more-details trigger, and drop the status side column. Co-authored-by: Tiankai Ma --- docs/contracts/_ui.json | 2 +- docs/contracts/homework.json | 2 +- docs/contracts/todo.json | 1 + .../components/TodoDetailDialog.svelte | 51 +++++++++++++++++-- .../dashboard/components/TodosTab.svelte | 26 +++++++--- .../components/TodosTabDialogs.svelte | 6 +++ .../dashboard/lib/todos-tab-display.ts | 15 +++++- .../components/HomeworkDeadlineSummary.svelte | 38 ++++++++------ .../HomeworkDetailSecondaryDetails.svelte | 27 +--------- tests/e2e/src/app/dashboard/todos/test.ts | 5 +- tests/e2e/src/app/sections/[jwId]/test.ts | 6 ++- 11 files changed, 118 insertions(+), 61 deletions(-) diff --git a/docs/contracts/_ui.json b/docs/contracts/_ui.json index d228dcb38..d0cb11133 100644 --- a/docs/contracts/_ui.json +++ b/docs/contracts/_ui.json @@ -1,5 +1,5 @@ { - "Layout Principles": "* Put reading-first content in the left/main column: introductions, descriptions, markdown, homework content, and comments.\n* Put structured facts in the right/side column: identifiers, metadata, dates, counts, actions, mini calendars, and quick links.\n* On mobile, stack in that order.\n* Reuse existing grids, cards, borders, muted text, and buttons.\n* Popups are one scrollable column at a comfortable reading width, not a two-column page. Keep the title in a sticky header; put details then discussion in that one stream. Side rails belong on pages, not in overlays.", + "Layout Principles": "* Put reading-first content in the left/main column: introductions, descriptions, markdown, homework content, and comments.\n* Put structured facts in the right/side column: identifiers, metadata, dates, counts, actions, mini calendars, and quick links.\n* On mobile, stack in that order.\n* Reuse existing grids, cards, borders, muted text, and buttons.\n* Popups are one scrollable column at a comfortable reading width, not a two-column page. Keep the title in a sticky header; put a stacked due/facts card (time, relative urgency, compact chips) then reading then discussion in that one stream. Do not add a status side column inside the overlay. Side rails belong on pages, not in overlays.", "List Table": "* Used for discovery lists such as courses, sections, and teachers.\n* Primary names follow the active locale; an available alternate localized name is secondary text.\n* Structured fields support comparison.\n* Rows link to detail pages.\n* Across viewport widths, one prominent search remains directly available, advanced filters open in an accessible sheet, and active filters remain removable above results.\n* Compact layouts use cards that avoid page-level horizontal scrolling; wide desktop layouts keep every comparison-table column inside the results container without horizontal scrolling.\n* Pagination uses URL-driven Previous, page, and Next links while preserving filters.", "Data Table Cells": "* Desktop data-table rows keep a consistent compact height; optional secondary lines reserve space when absent.\n* Overflowing text is truncated, with its full value available from a tooltip on pointer hover and keyboard focus.\n* Course and section codes use plain monospace text, never badge styling.\n* Header labels and cell content share the same column edge; textual and categorical values align left, while numeric measures and counts align right.\n* Blank placeholders are hidden from assistive technology and do not replace meaningful labels such as unknown or TBD.\n* Numeric, status, control, and action cells do not receive truncation tooltips when their content fits.", "Detail Hero": "* Used at the top of course, section, and teacher detail pages.\n* Contains breadcrumb, h1, and an optional subtitle.\n* h1 uses the primary display name of the current object, not an internal ID.\n* At mobile widths, stable public identifiers and concise metadata precede a responsive title so long localized names do not crowd out context or actions.", diff --git a/docs/contracts/homework.json b/docs/contracts/homework.json index c765e0961..32fcb016c 100644 --- a/docs/contracts/homework.json +++ b/docs/contracts/homework.json @@ -247,7 +247,7 @@ "user completion status", "edit action", "cards/list view mode persisted in browser storage", - "detail popup is one scrollable column: due summary, description, collapsible secondary metadata excluding platform createdAt, full-width discussion; edit/completion stay in the footer; do not use a two-column overlay", + "detail popup is one scrollable column: due summary stacks datetime then relative urgency with completion and tag chips on the same card (no status side column), then description, then collapsible publication/open times that do not repeat due date, tags, or platform createdAt, then full-width discussion; edit/completion stay in the footer; do not use a two-column overlay", "section cards use a responsive multi-column layout", "create and edit forms expose the same collapsible advisory homework title and description style guide" ] diff --git a/docs/contracts/todo.json b/docs/contracts/todo.json index 649792b91..21013f372 100644 --- a/docs/contracts/todo.json +++ b/docs/contracts/todo.json @@ -136,6 +136,7 @@ "todo.content (description)", "todo.priority", "todo.dueAt", + "detail popup stacks title, then a due card with relative urgency plus priority and completion chips, then content; completion/edit/delete stay in the footer", "completed status action button", "filter: incomplete/completed/all", "mobile compact toolbar with direct completion filters and overflow display-mode control" diff --git a/src/features/dashboard/components/TodoDetailDialog.svelte b/src/features/dashboard/components/TodoDetailDialog.svelte index 34f2388ea..39c457456 100644 --- a/src/features/dashboard/components/TodoDetailDialog.svelte +++ b/src/features/dashboard/components/TodoDetailDialog.svelte @@ -18,8 +18,12 @@ import { cn } from "$lib/utils.js"; export let deleteTodo: (todo: DashboardTodoItem) => void | Promise; export let fmtDate: (value: string | Date | null | undefined) => string; +export let isDueOverdue: (value: string | Date | null | undefined) => boolean; export let onClose: () => void; export let openTodoEditor: (todo: DashboardTodoItem) => void; +export let relativeDueLabel: ( + value: string | Date | null | undefined, +) => string; export let todo: DashboardTodoItem | null; export let todoActionLabel: (todo: DashboardTodoItem) => string; export let todoSavingById: Record; @@ -47,6 +51,12 @@ async function confirmDelete(event: MouseEvent) { deletePending = false; } } + +function priorityVariant(priority: string) { + if (priority === "high") return "destructive" as const; + if (priority === "medium") return "secondary" as const; + return "outline" as const; +} {#if todo} @@ -62,22 +72,53 @@ async function confirmDelete(event: MouseEvent) { - + {todo.title} - + {todosCopy.priority[todo.priority]} · {fmtDate(todo.dueAt)} diff --git a/src/features/dashboard/components/TodosTab.svelte b/src/features/dashboard/components/TodosTab.svelte index 2e2c8c09f..2ce371b7c 100644 --- a/src/features/dashboard/components/TodosTab.svelte +++ b/src/features/dashboard/components/TodosTab.svelte @@ -49,17 +49,25 @@ export let isCreatingTodo: boolean; export let isUpdatingTodo: boolean; let datetimeLocalValue: TodoDateFormatter; let fmtDate: TodoDateFormatter; +let isDueOverdue: (value: Date | string | null | undefined) => boolean; +let relativeDueLabel: TodoDateFormatter; let todoActionLabel: TodoAction; let todoStatus: TodoAction; -$: ({ datetimeLocalValue, fmtDate, todoActionLabel, todoStatus } = - createTodoTabDisplayActions({ - dashboardCopy, - locale, - referenceDate, - sectionCopy, - todosCopy, - })); +$: ({ + datetimeLocalValue, + fmtDate, + isDueOverdue, + relativeDueLabel, + todoActionLabel, + todoStatus, +} = createTodoTabDisplayActions({ + dashboardCopy, + locale, + referenceDate, + sectionCopy, + todosCopy, +})); $: displayTodoFilter = resolveDashboardTaskFilter( todoFilter, todoItems.some((todo) => !todo.completed), @@ -120,7 +128,9 @@ $: displayTodoFilter = resolveDashboardTaskFilter( {fmtDate} {isCreatingTodo} {isUpdatingTodo} + {isDueOverdue} {openTodoEditor} + {relativeDueLabel} bind:selectedTodo bind:showCreateTodo {todoActionLabel} diff --git a/src/features/dashboard/components/TodosTabDialogs.svelte b/src/features/dashboard/components/TodosTabDialogs.svelte index 199bbfb0a..fd3aab323 100644 --- a/src/features/dashboard/components/TodosTabDialogs.svelte +++ b/src/features/dashboard/components/TodosTabDialogs.svelte @@ -21,8 +21,12 @@ export let editTodoError: string; export let editingTodo: DashboardTodoItem | null; export let fmtDate: (value: string | Date | null | undefined) => string; export let isCreatingTodo: boolean; +export let isDueOverdue: (value: string | Date | null | undefined) => boolean; export let isUpdatingTodo: boolean; export let openTodoEditor: (todo: DashboardTodoItem) => void; +export let relativeDueLabel: ( + value: string | Date | null | undefined, +) => string; export let selectedTodo: DashboardTodoItem | null; export let showCreateTodo: boolean; export let todoActionLabel: (todo: DashboardTodoItem) => string; @@ -53,10 +57,12 @@ export let updateTodoAction: SubmitFunction; deleteTodo(todo)} {fmtDate} + {isDueOverdue} onClose={() => { selectedTodo = null; }} {openTodoEditor} + {relativeDueLabel} todo={selectedTodo} {todoActionLabel} {todoSavingById} diff --git a/src/features/dashboard/lib/todos-tab-display.ts b/src/features/dashboard/lib/todos-tab-display.ts index eaf27970b..be7734d03 100644 --- a/src/features/dashboard/lib/todos-tab-display.ts +++ b/src/features/dashboard/lib/todos-tab-display.ts @@ -5,7 +5,11 @@ import type { DashboardTodoItem, DashboardTodosCopy, } from "./dashboard-controller-types"; -import { formatDashboardDateTime } from "./date-formatters"; +import { + formatDashboardDateTime, + formatDashboardDueRelativeTime, + isDashboardDueOverdue, +} from "./date-formatters"; import { todoActionLabel as buildTodoActionLabel, todoStatus as buildTodoStatus, @@ -44,5 +48,14 @@ export function createTodoTabDisplayActions({ completed: dashboardCopy.completedStatus, pending: dashboardCopy.pendingStatus, }), + relativeDueLabel: (value: Date | string | null | undefined) => + formatDashboardDueRelativeTime( + value, + sectionCopy.dateTBD, + referenceDate, + locale, + ), + isDueOverdue: (value: Date | string | null | undefined) => + isDashboardDueOverdue(value, referenceDate), }; } diff --git a/src/features/homeworks/components/HomeworkDeadlineSummary.svelte b/src/features/homeworks/components/HomeworkDeadlineSummary.svelte index a3a526d2c..d8514d47e 100644 --- a/src/features/homeworks/components/HomeworkDeadlineSummary.svelte +++ b/src/features/homeworks/components/HomeworkDeadlineSummary.svelte @@ -27,32 +27,38 @@ $: isOverdue = deadlineState === "overdue";
{copy.submissionDue}
-
+
{dueLabel}
-
- {copy.relativeTime} - - {relativeLabel} -
+ {#if homework.submissionDueAt && relativeLabel} +
+ {relativeLabel} +
+ {/if}
- -
-
{copy.statusLabel}
-
+
+
{copy.statusLabel}
+
{homework.completed ? copy.completedLabel : copy.pendingLabel} + {#if homework.isMajor} + {copy.tagMajor} + {/if} + {#if homework.requiresTeam} + {copy.tagTeam} + {/if}
diff --git a/src/features/homeworks/components/HomeworkDetailSecondaryDetails.svelte b/src/features/homeworks/components/HomeworkDetailSecondaryDetails.svelte index 1882dc31a..fe4f89bfb 100644 --- a/src/features/homeworks/components/HomeworkDetailSecondaryDetails.svelte +++ b/src/features/homeworks/components/HomeworkDetailSecondaryDetails.svelte @@ -4,7 +4,6 @@ import type { HomeworkDateValue, HomeworkDetailModel, } from "@/features/homeworks/lib/homework-presentation"; -import { Badge } from "$lib/components/ui/badge/index.js"; import { Button } from "$lib/components/ui/button/index.js"; import * as Collapsible from "$lib/components/ui/collapsible/index.js"; import type { @@ -16,10 +15,6 @@ export let copy: HomeworkDetailCopy; export let fmtDate: HomeworkDetailDateFormatter; export let homework: HomeworkDetailModel; -$: detailTags = [ - ...(homework.isMajor ? [copy.tagMajor] : []), - ...(homework.requiresTeam ? [copy.tagTeam] : []), -]; let detailsOpen = false; function displayDate(value: HomeworkDateValue) { @@ -36,14 +31,7 @@ function displayDate(value: HomeworkDateValue) { class="h-10 w-full justify-between px-1 text-left" variant="ghost" > - - {copy.moreDetails} - {#if detailTags.length > 0} - - {detailTags.join(" · ")} - - {/if} - + {copy.moreDetails}
- {#if detailTags.length > 0} -
-
{copy.moreDetails}
-
- {#if homework.isMajor} - {copy.tagMajor} - {/if} - {#if homework.requiresTeam} - {copy.tagTeam} - {/if} -
-
- {/if} diff --git a/tests/e2e/src/app/dashboard/todos/test.ts b/tests/e2e/src/app/dashboard/todos/test.ts index a3b20fa1b..2d02b637b 100644 --- a/tests/e2e/src/app/dashboard/todos/test.ts +++ b/tests/e2e/src/app/dashboard/todos/test.ts @@ -220,8 +220,11 @@ test.describe("仪表盘待办", () => { name: new RegExp(DEV_SEED.todos.dueTodayTitle), }), ).toBeVisible(); + const summary = dialog.getByTestId("todo-detail-summary"); + await expect(summary).toBeVisible(); + await expect(summary.getByText(/高|High/i).first()).toBeVisible(); await expect( - dialog.getByText(/待处理|已完成|Pending|Completed/i).first(), + summary.getByText(/待处理|已完成|Pending|Completed/i).first(), ).toBeVisible(); await expectDialogAction(dialog, /删除待办|Delete todo/i); diff --git a/tests/e2e/src/app/sections/[jwId]/test.ts b/tests/e2e/src/app/sections/[jwId]/test.ts index beaf60d22..ca51e3c6d 100644 --- a/tests/e2e/src/app/sections/[jwId]/test.ts +++ b/tests/e2e/src/app/sections/[jwId]/test.ts @@ -1493,10 +1493,10 @@ test.describe("/catalog/sections/[jwId] 班级详情页", () => { const secondaryDetailsTrigger = secondaryDetails.getByRole("button", { name: /More details|更多信息/i, }); - await expect(secondaryDetailsTrigger).toContainText( + await expect(secondaryDetailsTrigger).not.toContainText( /Major assignment|大作业/i, ); - await expect(secondaryDetailsTrigger).toContainText( + await expect(secondaryDetailsTrigger).not.toContainText( /Team required|需要组队/i, ); @@ -1507,6 +1507,8 @@ test.describe("/catalog/sections/[jwId] 班级详情页", () => { /2026-12-31|2026\/12\/31|12\/31\/26|12月31日|Dec 31/, ); await expect(deadlineSummary).toContainText(/23:59|11:59 PM/); + await expect(deadlineSummary).toContainText(/Major assignment|大作业/i); + await expect(deadlineSummary).toContainText(/Team required|需要组队/i); await captureStepScreenshot( page, testInfo, From c742a4be4adbdc5d5348f43de197b3d0bbcaabbf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 11:32:32 +0000 Subject: [PATCH 09/12] test(e2e): count todo priority on the visible due card The dialog's sr-only description also includes the localized priority, so scanning the whole dialog text matched Medium twice. Co-authored-by: Tiankai Ma --- tests/e2e/src/app/dashboard/todos/test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/e2e/src/app/dashboard/todos/test.ts b/tests/e2e/src/app/dashboard/todos/test.ts index 2d02b637b..4c8ac395e 100644 --- a/tests/e2e/src/app/dashboard/todos/test.ts +++ b/tests/e2e/src/app/dashboard/todos/test.ts @@ -297,11 +297,13 @@ test.describe("仪表盘待办", () => { await visibleText(page, title).click(); const detailDialog = page.getByRole("dialog", { name: title }); await expect(detailDialog).toBeVisible(); - const detailText = await detailDialog.innerText(); + const summary = detailDialog.getByTestId("todo-detail-summary"); + await expect(summary).toBeVisible(); + const summaryText = await summary.innerText(); const localizedPriorityMatches = - detailText.match(/\b(?:Low|Medium|High)\b|[低中高]/g) ?? []; + summaryText.match(/\b(?:Low|Medium|High)\b|[低中高]/g) ?? []; expect(localizedPriorityMatches).toHaveLength(1); - expect(detailText).not.toMatch(/\b(?:low|medium|high)\b/); + expect(summaryText).not.toMatch(/\b(?:low|medium|high)\b/); const editButton = detailDialog.getByRole("button", { name: /编辑待办|Edit Todo/i, }); From bebb617a6917b598053f12d42ec3498ab261ed0f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 11:54:53 +0000 Subject: [PATCH 10/12] test(e2e): wait for collapsed sidebar icons to settle The catalog group label still animates margin for 200ms after icon mode starts, so an early Courses click can land on Sections instead. Co-authored-by: Tiankai Ma --- tests/e2e/src/app/test.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/e2e/src/app/test.ts b/tests/e2e/src/app/test.ts index bb9924e08..cb23b4019 100644 --- a/tests/e2e/src/app/test.ts +++ b/tests/e2e/src/app/test.ts @@ -666,11 +666,25 @@ test("/ shell 折叠桌面侧边栏后图标链接仍可跳转", async ({ page } await expect( page.locator('[data-slot="sidebar"][data-state="collapsed"]'), ).toBeVisible(); - + await expect(catalogGroup).toBeDisabled(); await expect(coursesLink).toBeVisible(); - await coursesLink.click(); + await expect(coursesLink).toHaveAttribute("href", "/catalog/courses"); + // Icon-mode labels animate margin/opacity for 200ms; clicking before that + // can land on the neighboring Sections icon instead of Courses. + await expect + .poll(async () => + Math.round( + await coursesLink.evaluate((element) => + element.getBoundingClientRect().width, + ), + ), + ) + .toBeLessThanOrEqual(40); - await page.waitForURL("**/catalog/courses"); + await Promise.all([ + page.waitForURL("**/catalog/courses"), + coursesLink.click(), + ]); await waitForUiSettled(page); await expect(page).toHaveURL(/\/catalog\/courses(?:\?.*)?$/); }); From 15afeda6d69ab787308ddd93ee0140d9410a665e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 11:55:23 +0000 Subject: [PATCH 11/12] style(e2e): format collapsed-sidebar icon width poll Co-authored-by: Tiankai Ma --- tests/e2e/src/app/test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/src/app/test.ts b/tests/e2e/src/app/test.ts index cb23b4019..9114a2ca1 100644 --- a/tests/e2e/src/app/test.ts +++ b/tests/e2e/src/app/test.ts @@ -674,8 +674,8 @@ test("/ shell 折叠桌面侧边栏后图标链接仍可跳转", async ({ page } await expect .poll(async () => Math.round( - await coursesLink.evaluate((element) => - element.getBoundingClientRect().width, + await coursesLink.evaluate( + (element) => element.getBoundingClientRect().width, ), ), ) From feded7fd885d10b906c989e891a8e5fcdd9ccdb6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 13:33:52 +0000 Subject: [PATCH 12/12] refactor(ui): lead overlays with due time, list the rest in a table Homework and todo popups keep one primary fact (due datetime) and one secondary line (relative urgency). Status, tags, priority, and other dates go in a table instead of chips or a collapsible. Co-authored-by: Tiankai Ma --- docs/contracts/_ui.json | 2 +- docs/contracts/homework.json | 4 +- docs/contracts/todo.json | 2 +- messages/en-us.json | 1 + messages/zh-cn.json | 1 + .../components/TodoDetailDialog.svelte | 86 ++++++++++--------- .../lib/dashboard-controller-types.ts | 1 + .../components/HomeworkDeadlineSummary.svelte | 55 ++++-------- .../components/HomeworkDetailDialog.svelte | 2 +- .../HomeworkDetailSecondaryDetails.svelte | 75 +++++++++------- tests/e2e/src/app/sections/[jwId]/test.ts | 25 +++--- tests/e2e/utils/detail-dialog.ts | 15 +--- 12 files changed, 126 insertions(+), 143 deletions(-) diff --git a/docs/contracts/_ui.json b/docs/contracts/_ui.json index d0cb11133..2ae346d84 100644 --- a/docs/contracts/_ui.json +++ b/docs/contracts/_ui.json @@ -1,5 +1,5 @@ { - "Layout Principles": "* Put reading-first content in the left/main column: introductions, descriptions, markdown, homework content, and comments.\n* Put structured facts in the right/side column: identifiers, metadata, dates, counts, actions, mini calendars, and quick links.\n* On mobile, stack in that order.\n* Reuse existing grids, cards, borders, muted text, and buttons.\n* Popups are one scrollable column at a comfortable reading width, not a two-column page. Keep the title in a sticky header; put a stacked due/facts card (time, relative urgency, compact chips) then reading then discussion in that one stream. Do not add a status side column inside the overlay. Side rails belong on pages, not in overlays.", + "Layout Principles": "* Put reading-first content in the left/main column: introductions, descriptions, markdown, homework content, and comments.\n* Put structured facts in the right/side column: identifiers, metadata, dates, counts, actions, mini calendars, and quick links.\n* On mobile, stack in that order.\n* Reuse existing grids, cards, borders, muted text, and buttons.\n* Popups are one scrollable column at a comfortable reading width, not a two-column page. Keep the title in a sticky header. Lead with one primary fact (due datetime) and one secondary line (relative urgency), then list remaining overlay facts in a table, then reading, then discussion. Do not add a status side column, chip row, or nested collapsible inside the overlay. Side rails belong on pages, not in overlays.", "List Table": "* Used for discovery lists such as courses, sections, and teachers.\n* Primary names follow the active locale; an available alternate localized name is secondary text.\n* Structured fields support comparison.\n* Rows link to detail pages.\n* Across viewport widths, one prominent search remains directly available, advanced filters open in an accessible sheet, and active filters remain removable above results.\n* Compact layouts use cards that avoid page-level horizontal scrolling; wide desktop layouts keep every comparison-table column inside the results container without horizontal scrolling.\n* Pagination uses URL-driven Previous, page, and Next links while preserving filters.", "Data Table Cells": "* Desktop data-table rows keep a consistent compact height; optional secondary lines reserve space when absent.\n* Overflowing text is truncated, with its full value available from a tooltip on pointer hover and keyboard focus.\n* Course and section codes use plain monospace text, never badge styling.\n* Header labels and cell content share the same column edge; textual and categorical values align left, while numeric measures and counts align right.\n* Blank placeholders are hidden from assistive technology and do not replace meaningful labels such as unknown or TBD.\n* Numeric, status, control, and action cells do not receive truncation tooltips when their content fits.", "Detail Hero": "* Used at the top of course, section, and teacher detail pages.\n* Contains breadcrumb, h1, and an optional subtitle.\n* h1 uses the primary display name of the current object, not an internal ID.\n* At mobile widths, stable public identifiers and concise metadata precede a responsive title so long localized names do not crowd out context or actions.", diff --git a/docs/contracts/homework.json b/docs/contracts/homework.json index 32fcb016c..33d76db18 100644 --- a/docs/contracts/homework.json +++ b/docs/contracts/homework.json @@ -80,7 +80,7 @@ "filter: incomplete/completed/all", "mobile compact toolbar with direct completion filters and overflow display-mode control", "cards/list view mode persisted in browser storage", - "detail popup is one scrollable column: due summary, description, collapsible secondary metadata excluding platform createdAt, full-width discussion; completion stays in the footer; do not use a two-column overlay", + "detail popup is one scrollable column: due datetime, relative urgency, remaining facts in a table excluding platform createdAt, then description, then full-width discussion; completion stays in the footer; do not use a two-column overlay", "create form exposes the collapsible advisory homework title and description style guide" ] } @@ -247,7 +247,7 @@ "user completion status", "edit action", "cards/list view mode persisted in browser storage", - "detail popup is one scrollable column: due summary stacks datetime then relative urgency with completion and tag chips on the same card (no status side column), then description, then collapsible publication/open times that do not repeat due date, tags, or platform createdAt, then full-width discussion; edit/completion stay in the footer; do not use a two-column overlay", + "detail popup is one scrollable column: due datetime is primary, relative urgency is secondary, remaining facts (status, tags, publication, submission opens) are a table that does not repeat due date or platform createdAt, then description, then full-width discussion; edit/completion stay in the footer; do not use a two-column overlay", "section cards use a responsive multi-column layout", "create and edit forms expose the same collapsible advisory homework title and description style guide" ] diff --git a/docs/contracts/todo.json b/docs/contracts/todo.json index 21013f372..8a05d87ca 100644 --- a/docs/contracts/todo.json +++ b/docs/contracts/todo.json @@ -136,7 +136,7 @@ "todo.content (description)", "todo.priority", "todo.dueAt", - "detail popup stacks title, then a due card with relative urgency plus priority and completion chips, then content; completion/edit/delete stay in the footer", + "detail popup stacks title, then due datetime, relative urgency, a table of remaining facts (priority, completion), then content; completion/edit/delete stay in the footer", "completed status action button", "filter: incomplete/completed/all", "mobile compact toolbar with direct completion filters and overflow display-mode control" diff --git a/messages/en-us.json b/messages/en-us.json index 81e3bda64..2b174ad31 100644 --- a/messages/en-us.json +++ b/messages/en-us.json @@ -2263,6 +2263,7 @@ "contentPlaceholder": "Supports Markdown formatting", "contentEmpty": "No description added.", "priorityLabel": "Priority", + "statusLabel": "Status", "dueAtLabel": "Due date", "dueAtPlaceholder": "Optional date and time", "dueLabel": "Due", diff --git a/messages/zh-cn.json b/messages/zh-cn.json index 61ed99b61..6b0d33397 100644 --- a/messages/zh-cn.json +++ b/messages/zh-cn.json @@ -2229,6 +2229,7 @@ "contentPlaceholder": "支持 Markdown 格式", "contentEmpty": "暂无内容", "priorityLabel": "优先级", + "statusLabel": "状态", "dueAtLabel": "截止时间", "dueAtPlaceholder": "选择截止时间(可选)", "dueLabel": "截止", diff --git a/src/features/dashboard/components/TodoDetailDialog.svelte b/src/features/dashboard/components/TodoDetailDialog.svelte index 39c457456..369a17195 100644 --- a/src/features/dashboard/components/TodoDetailDialog.svelte +++ b/src/features/dashboard/components/TodoDetailDialog.svelte @@ -8,12 +8,12 @@ import type { } from "@/features/dashboard/lib/dashboard-controller-helpers"; import MarkdownPreview from "$lib/components/MarkdownPreview.svelte"; import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js"; -import { Badge } from "$lib/components/ui/badge/index.js"; import { Button, buttonVariants } from "$lib/components/ui/button/index.js"; import * as Dialog from "$lib/components/ui/dialog/index.js"; import { ScrollArea } from "$lib/components/ui/scroll-area/index.js"; import { Separator } from "$lib/components/ui/separator/index.js"; import { Spinner } from "$lib/components/ui/spinner/index.js"; +import * as Table from "$lib/components/ui/table/index.js"; import { cn } from "$lib/utils.js"; export let deleteTodo: (todo: DashboardTodoItem) => void | Promise; @@ -51,12 +51,6 @@ async function confirmDelete(event: MouseEvent) { deletePending = false; } } - -function priorityVariant(priority: string) { - if (priority === "high") return "destructive" as const; - if (priority === "medium") return "secondary" as const; - return "outline" as const; -} {#if todo} @@ -80,40 +74,50 @@ function priorityVariant(priority: string) {