Карточка задачи: без статуса по факту, «просрочено» по статусу Jira (#57)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-17 11:41:47 +04:00
parent dbda1ad77c
commit c5e33a9cc4
8 changed files with 44 additions and 51 deletions

View File

@ -28,10 +28,11 @@ export interface IssueRow {
neighbor: boolean; fact_status?: string | null; work_kind?: string | null;
}
export const NOTE_FIELDS = ["fact_status", "work_kind", "done_text", "rest_text", "owner_note"] as const;
// поля заметки, которые приложение показывает и правит; fact_status хаб ещё отдаёт, приложение его не читает (hide-closed)
export const NOTE_FIELDS = ["work_kind", "done_text", "rest_text", "owner_note"] as const;
export type NoteField = (typeof NOTE_FIELDS)[number];
export type NoteFields = Partial<Record<NoteField, string | null>>;
export type Note = Record<NoteField, string | null> & { key: string; updated_at: string; updated_by: string };
export type Note = Record<NoteField, string | null> & { key: string; fact_status?: string | null; updated_at: string; updated_by: string };
export interface IssueCommand {
id: number; batch_id: number; type: string; status: string; finished_at: string | null; error: string | null;

View File

@ -40,14 +40,15 @@ function overflow(scroll: number, client: number) {
describe("карточка задачи", () => {
beforeEach(() => setToken("tok"));
it("шапка: ключ, статусы, тема, исполнитель · срок · спринт · эпик по имени; ссылка в Jira", async () => {
it("шапка: ключ, статус Jira, тема, исполнитель · срок · спринт · эпик по имени; ссылка в Jira", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { planned_end: "2020-01-10" })));
open("/issues/AURORA-9001");
expect(await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9001" })).toBeInTheDocument();
const head = screen.getByTestId("issue-head");
expect(within(head).getByText("AURORA-9001")).toBeInTheDocument();
expect(within(head).getByText("В работе", { selector: ".badge.status" })).toBeInTheDocument();
expect(within(head).getByText("факт: В работе", { selector: ".badge.fact" })).toHaveClass("fact-in-progress"); // как в макете раздела 1
expect(head.querySelector(".badge.fact")).toBeNull(); // статуса по факту в карточке нет (hide-closed)
expect(within(head).queryByText(/факт:/)).toBeNull();
expect(within(head).getByTestId("issue-facts")).toHaveTextContent("Иванов Иван Иванович · срок 10.01 просрочено · AWPCA_Спринт 2 · эпик: Тестовый эпик");
expect(within(head).getByText("просрочено")).toHaveClass("overdue");
expect(screen.getByRole("link", { name: "Открыть в Jira ↗" })).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-9001");
@ -383,14 +384,24 @@ describe("карточка задачи", () => {
expect(screen.queryByRole("columnheader", { name: "Команда" })).toBeNull();
});
it("T14.3: id подсказок «статуса по факту» уникален и связан с полем", async () => {
it("закрытая задача со сроком в прошлом — без пометки «просрочено»", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { planned_end: "2020-01-10", status: "Готово" })));
open("/issues/AURORA-9001");
const facts = await screen.findByTestId("issue-facts");
expect(facts).toHaveTextContent("срок 10.01");
expect(within(facts).queryByText("просрочено")).toBeNull();
});
it("статуса по факту нет ни в чтении заметок, ни в форме", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
open("/issues/AURORA-9001");
await userEvent.click(await screen.findByRole("button", { name: "Править" }));
const input = await screen.findByLabelText("Статус по факту");
const id = input.getAttribute("list")!;
expect(id).not.toBe("fact-statuses");
expect(document.getElementById(id)?.tagName).toBe("DATALIST");
const view = await screen.findByRole("region", { name: "Служебные заметки" });
expect(view).not.toHaveTextContent("Статус по факту");
expect(view).toHaveTextContent("Вид работы");
await userEvent.click(screen.getByRole("button", { name: "Править" }));
await screen.findByLabelText("Осталось");
expect(screen.queryByLabelText("Статус по факту")).toBeNull();
expect(document.querySelector("datalist")).toBeNull();
});
it("заметки в режиме чтения: пустые — «—», время и автор; «Отмена» без правок — сразу", async () => {
@ -425,7 +436,7 @@ describe("карточка задачи", () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { note: null })));
open("/issues/AURORA-9001");
const view = await screen.findByRole("region", { name: "Служебные заметки" });
expect(within(view).getAllByText("—")).toHaveLength(5);
expect(within(view).getAllByText("—")).toHaveLength(4); // вид работы, сделано, осталось, заметка владельца
expect(within(view).getByRole("button", { name: "Править" })).toBeInTheDocument();
});

View File

@ -1,10 +1,11 @@
import { type RefObject, useLayoutEffect, useRef, useState } from "react";
import { ApiError } from "../api/client.ts";
import { useClosedStatuses } from "../api/closed.ts";
import { useIssue, useSettings } from "../api/queries.ts";
import type { IssueComment, IssueDetail } from "../api/types.ts";
import { JIRA_URL } from "../board/constants.ts";
import { fmtDate, fmtStamp } from "../board/model/util.ts";
import { factClass, groupLinks, isOverdue, todayISO } from "./card.ts";
import { groupLinks, isOverdue, todayISO } from "./card.ts";
import { useCommentsHidden } from "./commentsPref.ts";
import { IssueLink } from "./IssueLink.tsx";
import { Notes } from "./Notes.tsx";
@ -17,6 +18,7 @@ const DESC_LINES = 8;
export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClose: () => void }) {
const issue = useIssue(key);
const settings = useSettings();
const closed = useClosedStatuses();
const close = <button className="close" type="button" aria-label="Закрыть" onClick={onClose}>✕</button>;
if (issue.isPending) return <div className="issue" data-testid="issue-card"><div className="issue-head"><p className="hint">загрузка {key}…</p>{close}</div></div>;
if (issue.error instanceof ApiError && issue.error.status === 404) {
@ -32,13 +34,10 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
);
}
const i = issue.data!;
const labels = settings.data?.status_labels as Record<string, string> | undefined;
const fact = i.note?.fact_status ?? null;
const cls = factClass(fact, labels);
const due = dueOf(i);
const facts = [
i.assignee && <span key="who">{i.assignee}</span>,
due && <span key="due">срок {fmtDate(due)}{isOverdue(due, fact, labels, todayISO()) && <> <span className="overdue">просрочено</span></>}</span>,
due && <span key="due">срок {fmtDate(due)}{isOverdue(due, i.status, closed, todayISO()) && <> <span className="overdue">просрочено</span></>}</span>,
i.sprint && <span key="sprint">{i.sprint}</span>,
i.epic_key && <span key="epic">эпик: {i.epic_summary ?? i.epic_key}</span>,
].filter(Boolean);
@ -48,7 +47,6 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
<div className="row1">
<span className="key">{i.key}</span>
{i.status && <span className="badge status">{i.status}</span>}
{fact && <span className={"badge fact" + (cls ? " " + cls : "")}>факт: {fact}</span>}
<span className="spacer" />
<a href={`${JIRA_URL}/browse/${i.key}`} target="_blank" rel="noopener">Открыть в Jira ↗</a>
{close}

View File

@ -1,4 +1,4 @@
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useBlocker } from "react-router";
import { useCloseAbort } from "./closeAbort.ts";
import { ApiError } from "../api/client.ts";
@ -41,8 +41,6 @@ export function NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () =>
const kinds = Object.keys(settings.data?.kind_labels ?? {});
const kindOptions = values.work_kind && !kinds.includes(values.work_kind) ? [values.work_kind, ...kinds] : kinds;
const facts = Object.keys(settings.data?.status_labels ?? {});
const listId = useId();
const set = (f: NoteField) => (e: { target: { value: string } }) => setValues((v) => ({ ...v, [f]: e.target.value }));
const submit = () => save.mutate(changed, {
@ -60,10 +58,6 @@ export function NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () =>
{kindOptions.map((k) => <option key={k} value={k}>{k}</option>)}
</select>
</label>
<label>Статус по факту
<input list={listId} value={values.fact_status} onChange={set("fact_status")} />
<datalist id={listId}>{facts.map((f) => <option key={f} value={f} />)}</datalist>
</label>
{TEXTS.map(([f, label]) => (
<label key={f}>{label}<textarea rows={3} value={values[f]} onChange={set(f)} /></label>
))}

View File

@ -3,7 +3,7 @@ import type { IssueDetail, NoteField } from "../api/types.ts";
import { fmtStamp } from "../board/model/util.ts";
import { NoteForm } from "./NoteForm.tsx";
const ROWS: [NoteField, string][] = [["work_kind", "Вид работы"], ["fact_status", "Статус по факту"], ["done_text", "Сделано"],
const ROWS: [NoteField, string][] = [["work_kind", "Вид работы"], ["done_text", "Сделано"],
["rest_text", "Осталось"], ["owner_note", "Заметка владельца"]];
// служебные заметки (design 20): по умолчанию чтение, «Править» — форма; после сохранения или отмены — снова чтение

View File

@ -1,9 +1,9 @@
import { describe, expect, it } from "vitest";
import settings from "../board/__fixtures__/settings.json";
import { factClass, groupLinks, isOverdue, todayISO } from "./card.ts";
import { groupLinks, isOverdue, todayISO } from "./card.ts";
const types = settings.link_types as Record<string, string>;
const labels = { ...(settings.status_labels as Record<string, string>), "Снято": "fact-cancelled" };
const closed = new Set(["Готово", "Снято"]);
const out = (k: string, t: string, s: string | null = `Тема ${k}`, st: string | null = "В работе") => ({ target_key: k, link_type: t, summary: s, status: st });
const inn = (k: string, t: string, s: string | null = `Тема ${k}`, st: string | null = "В работе") => ({ source_key: k, link_type: t, summary: s, status: st });
@ -35,19 +35,16 @@ describe("groupLinks", () => {
});
});
describe("просрочка и класс статуса по факту", () => {
it("класс из status_labels", () => {
expect(factClass("Готово — закрыть", labels)).toBe("fact-done");
expect(factClass("свой статус", labels)).toBeNull();
expect(factClass(null, labels)).toBeNull();
describe("просрочка", () => {
it("срок прошёл, статус Jira не закрытый — просрочено", () => {
expect(isOverdue("2026-10-01", "В работе", closed, "2026-10-17")).toBe(true);
expect(isOverdue("2026-10-01", null, closed, "2026-10-17")).toBe(true);
});
it("срок раньше сегодня и не закрыта по факту — просрочено (R-2)", () => {
expect(isOverdue("2026-10-14", "В работе", labels, "2026-10-15")).toBe(true);
expect(isOverdue("2026-10-14", null, labels, "2026-10-15")).toBe(true);
expect(isOverdue("2026-10-15", "В работе", labels, "2026-10-15")).toBe(false);
expect(isOverdue("2026-10-14", "Готово — закрыть", labels, "2026-10-15")).toBe(false);
expect(isOverdue("2026-10-14", "Снято", labels, "2026-10-15")).toBe(false);
expect(isOverdue("", "В работе", labels, "2026-10-15")).toBe(false);
it("закрытая задача или срок не прошёл — не просрочено", () => {
expect(isOverdue("2026-10-01", "Готово", closed, "2026-10-17")).toBe(false);
expect(isOverdue("2026-10-01", "Снято", closed, "2026-10-17")).toBe(false);
expect(isOverdue("2026-10-17", "В работе", closed, "2026-10-17")).toBe(false);
expect(isOverdue("", "В работе", closed, "2026-10-17")).toBe(false);
});
it("todayISO — местная дата YYYY-MM-DD", () => {
expect(todayISO(new Date(2026, 0, 5, 23, 30))).toBe("2026-01-05");

View File

@ -1,3 +1,4 @@
import { isClosed } from "../api/closed.ts";
import type { IssueDetail } from "../api/types.ts";
export type LinkGroupId = "depends" | "blocks" | "related" | "other";
@ -23,15 +24,9 @@ export function groupLinks(d: Pick<IssueDetail, "links_out" | "links_in">, linkT
return GROUPS.filter(([id]) => groups[id].length).map(([id, title]) => [id, title, groups[id].sort(byKey)]);
}
const CLOSED_FACT = new Set(["fact-done", "fact-cancelled"]);
export const factClass = (fact: string | null | undefined, statusLabels: Record<string, string> | undefined): string | null =>
(fact && statusLabels?.[fact]) || null;
// R-2: просрочено — срок раньше сегодня, а статус по факту не «закрыт» (классы fact-done, fact-cancelled)
export function isOverdue(due: string, fact: string | null | undefined, statusLabels: Record<string, string> | undefined, today: string): boolean {
if (!due || due >= today) return false;
const cls = factClass(fact, statusLabels);
return !(cls && CLOSED_FACT.has(cls));
// просрочено — срок раньше сегодня, а статус Jira не из closed_statuses (hide-closed; было — по статусу по факту, R-2 #37)
export function isOverdue(due: string, status: string | null | undefined, closed: Set<string>, today: string): boolean {
return !!due && due < today && !isClosed(status, closed);
}
export const todayISO = (d = new Date()): string =>

View File

@ -16,9 +16,6 @@
.issue .overdue{color:var(--p-crit);font-weight:600}
.issue .close{font-size:18px;line-height:1;background:none;border:0;color:var(--muted);cursor:pointer;padding:4px 6px}
.badge{display:inline-block;font-size:12px;line-height:18px;padding:0 8px;border-radius:9px;background:var(--soft);color:var(--fg);white-space:nowrap}
.badge.fact{background:transparent;border:1px solid var(--accent);color:var(--accent)}
.badge.fact.fact-done{border-color:var(--k-test);color:var(--k-test)}
.badge.fact.fact-cancelled{border-color:var(--muted);color:var(--muted);text-decoration:line-through}
.issue .cols{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:24px;align-items:start}
.issue .side{display:flex;flex-direction:column;gap:16px;min-width:0}
.issue .side .note{align-self:stretch} /* .note{align-self:start} — с прежней страницы; в колонке окна — на всю ширину */