, key: string) => void;
onAutoSize?: (key: string) => void;
}
@@ -21,13 +22,14 @@ export interface CardProps {
// T11.1: при перетаскивании одной карточки place() создаёт новые box у всех — сравниваем box по значению
export function cardPropsEqual(a: CardProps, b: CardProps): boolean {
const sameBox = a.box === b.box || (!!a.box && !!b.box && a.box.x === b.box.x && a.box.y === b.box.y && a.box.w === b.box.w && a.box.h === b.box.h);
- return sameBox && a.node === b.node && a.rel === b.rel && a.multiSprint === b.multiSprint && a.sprintSel === b.sprintSel && !!a.closed === !!b.closed &&
+ return sameBox && a.node === b.node && a.rel === b.rel && a.multiSprint === b.multiSprint && a.sprintSel === b.sprintSel && !!a.closed === !!b.closed && a.today === b.today &&
a.onPointerDown === b.onPointerDown && a.onAutoSize === b.onAutoSize;
}
-export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintSel, closed, onPointerDown, onAutoSize }: CardProps) {
+export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintSel, closed, today, onPointerDown, onAutoSize }: CardProps) {
const cls = "card" + (n.dim || n.stub ? " out" : "") + (closed ? " closed" : "") + (n.crit ? " crit" : "") + (n.focus ? " focus" : "");
const style = box ? { left: box.x, top: box.y, width: box.w, height: box.h } : { width: CW };
+ const late = !closed && !!n.due && !!today && n.due < today;
const showTag = !n.stub && (multiSprint || n.dim); // метка спринта — если спринтов на доске больше одного или это сосед
return (
{n.status && {n.status}}
- {n.due && до {fmtDate(n.due)}}
+ {n.due &&
+ до {fmtDate(n.due)}{late && , просрочено}}
)}
{!n.stub && {shortName(n.who)}
}
diff --git a/web/src/board/components/CardLate.test.tsx b/web/src/board/components/CardLate.test.tsx
new file mode 100644
index 0000000..7468968
--- /dev/null
+++ b/web/src/board/components/CardLate.test.tsx
@@ -0,0 +1,30 @@
+import { render } from "@testing-library/react";
+import { MemoryRouter } from "react-router";
+import { describe, expect, it } from "vitest";
+import { Card, cardPropsEqual, type CardProps } from "./Card.tsx";
+
+const node = { key: "A-1", title: "Т", kind: "разработка", prio: "Средний", status: "В работе", due: "2026-10-20", who: "Иванов И. И." } as CardProps["node"];
+const show = (p: Partial) => render(
+ ,
+).container.querySelector(".due")!;
+
+describe("просрочка на карточке доски (#69, design 10)", () => {
+ it("незакрытая задача с прошедшим сроком — дата цветом ошибки, «просрочено» в подсказке и тексте", () => {
+ const due = show({});
+ expect(due.classList.contains("late")).toBe(true);
+ expect(due.getAttribute("title")).toBe("просрочено");
+ expect(due.textContent).toContain("просрочено");
+ });
+ it("закрытая задача не просрочена", () => {
+ expect(show({ closed: true }).classList.contains("late")).toBe(false);
+ });
+ it("срок сегодня — ещё не просрочено", () => {
+ const due = show({ node: { ...node, due: "2026-10-22" } });
+ expect(due.classList.contains("late")).toBe(false);
+ expect(due.textContent).not.toContain("просрочено");
+ });
+ it("смена дня перерисовывает карточку", () => {
+ const base: CardProps = { node, multiSprint: false, sprintSel: new Set(), today: "2026-10-20" };
+ expect(cardPropsEqual(base, { ...base, today: "2026-10-21" })).toBe(false);
+ });
+});
diff --git a/web/src/styles/base.css b/web/src/styles/base.css
index 54a8a42..202ce5c 100644
--- a/web/src/styles/base.css
+++ b/web/src/styles/base.css
@@ -8,3 +8,5 @@ h1{font-size:var(--fs-section);line-height:var(--lh-section);font-weight:700;mar
@media (max-width:639px){.page{padding-inline:16px}}
:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:2px}
@media (prefers-reduced-motion: reduce){*{transition:none!important;animation:none!important}}
+/* текст только для экранного чтения */
+.visually-hidden{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}
diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css
index 4590601..32179c2 100644
--- a/web/src/styles/tokens.css
+++ b/web/src/styles/tokens.css
@@ -43,7 +43,6 @@
--p-low: #a3a39b;
--edge: #a3a39b;
--guide: #c2410c;
- --lane-alt: #f5f5f2;
--edge-strong: var(--color-action-primary);
--space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px;
--space-5: 20px; --space-6: 24px; --space-7: 28px; --space-8: 32px;
@@ -100,7 +99,6 @@
--p-low: #6c726b;
--edge: #6c726b;
--guide: #f08a5a;
- --lane-alt: #252525;
--edge-strong: var(--color-action-primary);
}
}
@@ -147,6 +145,5 @@
--p-low: #6c726b;
--edge: #6c726b;
--guide: #f08a5a;
- --lane-alt: #252525;
--edge-strong: var(--color-action-primary);
}
diff --git a/web/src/styles/tokens.test.ts b/web/src/styles/tokens.test.ts
index ab0e13b..024d0c4 100644
--- a/web/src/styles/tokens.test.ts
+++ b/web/src/styles/tokens.test.ts
@@ -63,3 +63,10 @@ describe("токены AURA", () => {
expect(block(css, ':root[data-theme="dark"]')).toContain("color-scheme: dark");
});
});
+
+describe("доска без чередования дорожек (#69, design 9)", () => {
+ it("токена --lane-alt нет, правила чётной дорожки нет", () => {
+ expect(readFileSync(TOKENS, "utf8")).not.toContain("--lane-alt");
+ expect(readFileSync(resolve(SRC, "board/board.css"), "utf8")).not.toMatch(/\.lane:nth-child/);
+ });
+});