Доска: без чередования дорожек, просрочка на карточке (#69)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 08:48:48 +04:00
parent c20e2bf14b
commit cabf8d75f4
7 changed files with 47 additions and 8 deletions

View File

@ -50,7 +50,6 @@ svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3}
.empty{padding:40px 16px;color:var(--color-text-secondary);text-align:center}
#hubState{white-space:normal;min-width:0}
.lane{position:absolute;left:0;right:0;border-top:1px solid var(--color-border-default)}
.lane:nth-child(even of .lane){background:var(--lane-alt)}
.lane.folded{background:var(--color-bg-track);border-bottom:1px solid var(--color-border-strong);border-top-color:var(--color-border-strong)}
.lane.target{box-shadow:inset 0 3px 0 var(--color-action-primary)}
.lane.target-after{box-shadow:inset 0 -3px 0 var(--color-action-primary)}
@ -134,6 +133,7 @@ svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3}
/* статус — вид Badge (neutral) */
.status{font-size:var(--fs-caption);line-height:var(--lh-caption);font-weight:600;padding:1px 8px;border:1px solid var(--color-border-default);border-radius:var(--radius-round);background:var(--color-bg-track);color:var(--color-text-secondary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.due{font-variant-numeric:tabular-nums;font-weight:600;flex:0 0 auto}
.due.late{color:var(--color-status-error)}
.who::before{content:"";display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--color-action-primary);margin-right:5px;vertical-align:1px}
.who.none{color:var(--color-text-secondary);font-style:italic}
.who.none::before{background:transparent;border:1px solid var(--color-text-secondary)}

View File

@ -190,7 +190,7 @@ export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyT
))}
{Object.entries(placed.pos).filter(([k]) => byKey[k]).map(([k, box]) => (
<Card key={k} node={byKey[k]} box={box} rel={rel[k]} multiSprint={multiSprint} sprintSel={sprintSel}
closed={closedKeys.has(k)} onPointerDown={onCardDown} onAutoSize={onAutoSize} />
closed={closedKeys.has(k)} today={today} onPointerDown={onCardDown} onAutoSize={onAutoSize} />
))}
<Edges links={vis.links} placed={placed} closedKeys={closedKeys} />
<Guides x={guides?.x ?? null} y={guides?.y ?? null} width={placed.width} height={placed.height} />

View File

@ -14,6 +14,7 @@ export interface CardProps {
multiSprint: boolean;
sprintSel: Set<string>;
closed?: boolean; // задача закрыта (статус из closed_statuses) — карточка приглушена
today?: string; // ISO-дата сегодня: незакрытая задача со сроком раньше — просрочена (#69)
onPointerDown?: (ev: PointerEvent<HTMLDivElement>, 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 (
<div className={cls} title={n.title} style={style} data-key={n.key}
@ -48,7 +50,8 @@ export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintS
{(n.status || n.due) && (
<div className="state">
{n.status && <span className="status" title={n.status}>{n.status}</span>}
{n.due && <span className="due">до {fmtDate(n.due)}</span>}
{n.due && <span className={"due" + (late ? " late" : "")} title={late ? "просрочено" : undefined}>
до {fmtDate(n.due)}{late && <span className="visually-hidden">, просрочено</span>}</span>}
</div>
)}
{!n.stub && <div className={"who" + (unassigned(n.who) ? " none" : "")} title={n.who}>{shortName(n.who)}</div>}

View File

@ -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<CardProps>) => render(
<MemoryRouter><Card node={node} box={{ x: 0, y: 0, w: 240, h: 120 }} multiSprint={false} sprintSel={new Set()} today="2026-10-22" {...p} /></MemoryRouter>,
).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);
});
});

View File

@ -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}

View File

@ -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);
}

View File

@ -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/);
});
});