diff --git a/src/features/dashboard/components/HomeworkDetailActions.svelte b/src/features/dashboard/components/HomeworkDetailActions.svelte index 804c49220..ae9b60a84 100644 --- a/src/features/dashboard/components/HomeworkDetailActions.svelte +++ b/src/features/dashboard/components/HomeworkDetailActions.svelte @@ -1,5 +1,6 @@
-
- + -
- - -
diff --git a/src/features/dashboard/components/HomeworkDetailCommentsAside.svelte b/src/features/dashboard/components/HomeworkDetailCommentsAside.svelte index 73f827ef5..8ce66a70a 100644 --- a/src/features/dashboard/components/HomeworkDetailCommentsAside.svelte +++ b/src/features/dashboard/components/HomeworkDetailCommentsAside.svelte @@ -1,6 +1,5 @@ - +{#key `comments:homework:${homework.id}`} + +{/key} diff --git a/src/features/dashboard/components/HomeworkDetailDescription.svelte b/src/features/dashboard/components/HomeworkDetailDescription.svelte index 9e15895cd..c714ca3a9 100644 --- a/src/features/dashboard/components/HomeworkDetailDescription.svelte +++ b/src/features/dashboard/components/HomeworkDetailDescription.svelte @@ -11,7 +11,7 @@ export let homework: DashboardHomeworkDetailItem; export let homeworksCopy: DashboardHomeworkDetailCopy; - + {#if homework.description} -import CheckCircleIcon from "@lucide/svelte/icons/check-circle"; -import RefreshCw from "@lucide/svelte/icons/refresh-cw"; -import type { DashboardMyHomeworksCopy } from "@/features/dashboard/lib/dashboard-controller-types"; -import * as Dialog from "$lib/components/ui/dialog/index.js"; -import { ScrollArea } from "$lib/components/ui/scroll-area/index.js"; +import DetailDialog from "$lib/components/DetailDialog.svelte"; import type { DashboardHomeworkCommentsPanel, DashboardHomeworkCompletionToggle, @@ -26,68 +22,49 @@ export let homeworkDetailHref: DashboardHomeworkDetailAction; export let homeworkEtaLabel: DashboardHomeworkDetailFormatter; export let homeworkCourseLabel: DashboardHomeworkDetailAction; export let homeworkSavingById: Record; -export let homeworkSectionHref: DashboardHomeworkDetailAction; export let homeworksCopy: DashboardHomeworkDetailCopy; -export let homeworkCopy: DashboardMyHomeworksCopy; export let homeworkStatus: DashboardHomeworkDetailAction; export let onClose: () => void; export let toggleHomeworkCompletion: DashboardHomeworkCompletionToggle; {#if homework} - { - if (!open) onClose(); - }} + {@const selected = homework} + + - - {@const selectedCourseLabel = homeworkCourseLabel(homework)} - {@const SelectedCompletionIcon = homework.completion ? RefreshCw : CheckCircleIcon} - - {homework.title} - - {selectedCourseLabel} · {homeworkCopy.due}: - {fmtDate(homework.submissionDueAt)} - - - -
-
- + {#snippet body()} + - + - -
- -
-
-
-
+ + {/snippet} + + {#snippet aside()} + + {/snippet} + {/if} diff --git a/src/features/dashboard/components/HomeworkDetailMetadata.svelte b/src/features/dashboard/components/HomeworkDetailMetadata.svelte index 567442183..7bc2684d6 100644 --- a/src/features/dashboard/components/HomeworkDetailMetadata.svelte +++ b/src/features/dashboard/components/HomeworkDetailMetadata.svelte @@ -1,7 +1,12 @@ - - - - - {homeworksCopy.submissionDue} - - {fmtDate(homework.submissionDueAt)} - - - - {homeworkStatus(homework)} - - - - {homeworkEtaLabel(homework.submissionDueAt)} - - - - - {homeworksCopy.submissionStart} - {fmtDate(homework.submissionStartAt)} - - - - - {homeworksCopy.homeworkPublishedAt} - {fmtDate(homework.publishedAt)} - - - - + + + -
- {#if homework.isMajor} - - {homeworksCopy.tagMajor} - - {/if} - {#if homework.requiresTeam} - - {homeworksCopy.tagTeam} - - {/if} -
+ diff --git a/src/features/dashboard/components/HomeworksTab.svelte b/src/features/dashboard/components/HomeworksTab.svelte index d2c2aaf23..0c938838c 100644 --- a/src/features/dashboard/components/HomeworksTab.svelte +++ b/src/features/dashboard/components/HomeworksTab.svelte @@ -197,13 +197,11 @@ $: ({ bind:createHomeworkSubmissionStartAt {fmtDate} {homeworkCompletionActionLabel} - {homeworkCopy} {homeworkCourseLabel} {homeworkDetailHref} {homeworkEtaLabel} {homeworksCopy} {homeworkSavingById} - {homeworkSectionHref} {homeworkSectionLabel} {homeworkStatus} {isCreatingHomework} diff --git a/src/features/dashboard/components/HomeworksTabDialogs.svelte b/src/features/dashboard/components/HomeworksTabDialogs.svelte index 17d204027..9aa314f89 100644 --- a/src/features/dashboard/components/HomeworksTabDialogs.svelte +++ b/src/features/dashboard/components/HomeworksTabDialogs.svelte @@ -36,13 +36,11 @@ export let createHomeworkSubmissionDueAt: string; export let createHomeworkSubmissionStartAt: string; export let fmtDate: DashboardHomeworkDetailFormatter; export let homeworkCompletionActionLabel: HomeworkAction; -export let homeworkCopy: DashboardMyHomeworksCopy; export let homeworkCourseLabel: HomeworkAction; export let homeworkDetailHref: HomeworkAction; export let homeworkEtaLabel: DashboardHomeworkDetailFormatter; export let homeworksCopy: DashboardHomeworksCopy; export let homeworkSavingById: Record; -export let homeworkSectionHref: HomeworkAction; export let homeworkSectionLabel: ( section: DashboardHomeworkCreateSection, ) => string; @@ -92,9 +90,7 @@ export let toggleHomeworkCompletion: ( {homeworkEtaLabel} {homeworkCourseLabel} {homeworkSavingById} - {homeworkSectionHref} {homeworksCopy} - {homeworkCopy} {homeworkStatus} onClose={() => { selectedHomework = null; diff --git a/src/features/dashboard/components/TodoDetailDialog.svelte b/src/features/dashboard/components/TodoDetailDialog.svelte index 6a1b6a5dd..593ab85e7 100644 --- a/src/features/dashboard/components/TodoDetailDialog.svelte +++ b/src/features/dashboard/components/TodoDetailDialog.svelte @@ -1,15 +1,18 @@ {#if todo} - { - if (!open) onClose(); - }} - > - - - {todo.title} - - {todo.priority} · {fmtDate(todo.dueAt)} - - -
- {#if todo.content} - - {:else} -

{todosCopy.contentPlaceholder}

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