Доска: без чередования дорожек, просрочка на карточке (#69)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c20e2bf14b
commit
cabf8d75f4
@ -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)}
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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>}
|
||||
|
||||
30
web/src/board/components/CardLate.test.tsx
Normal file
30
web/src/board/components/CardLate.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user