Files
writer-work-flow/apps/web/lib/foreshadow/board.ts
2026-06-28 07:31:20 +02:00

181 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 伏笔看板纯逻辑:四泳道分组、登记/转移请求体组装、422 reason 映射。
// 对齐 C3 扩GET/POST/PATCH .../foreshadow。纯逻辑便于 node 环境单测。
import type {
ForeshadowRegisterRequest,
ForeshadowTransitionRequest,
ForeshadowView,
} from "@/lib/api/types";
// 四状态泳道UX §6.8OVERDUE 用琥珀强调(渲染层处理)。
export type ForeshadowStatus = "OPEN" | "PARTIAL" | "CLOSED" | "OVERDUE";
export const LANES: readonly ForeshadowStatus[] = [
"OPEN",
"PARTIAL",
"CLOSED",
"OVERDUE",
] as const;
export const LANE_LABELS: Record<ForeshadowStatus, string> = {
OPEN: "待推进",
PARTIAL: "推进中",
CLOSED: "已回收",
OVERDUE: "已逾期",
};
export type ForeshadowLanes = Record<ForeshadowStatus, ForeshadowView[]>;
export type ForeshadowLaneCounts = Record<ForeshadowStatus, number>;
function emptyLanes(): ForeshadowLanes {
return { OPEN: [], PARTIAL: [], CLOSED: [], OVERDUE: [] };
}
function isStatus(v: string): v is ForeshadowStatus {
return v === "OPEN" || v === "PARTIAL" || v === "CLOSED" || v === "OVERDUE";
}
// 按 status 分四泳道(未知 status 落 OPEN 兜底);保持 repo 已排序的 code 顺序。
export function groupByStatus(
items: readonly ForeshadowView[] | undefined,
): ForeshadowLanes {
const lanes = emptyLanes();
for (const item of items ?? []) {
const status = isStatus(item.status) ? item.status : "OPEN";
lanes[status] = [...lanes[status], item];
}
return lanes;
}
export function countByStatus(
items: readonly ForeshadowView[] | undefined,
): ForeshadowLaneCounts {
const lanes = groupByStatus(items);
return {
OPEN: lanes.OPEN.length,
PARTIAL: lanes.PARTIAL.length,
CLOSED: lanes.CLOSED.length,
OVERDUE: lanes.OVERDUE.length,
};
}
// 接近回收窗口判定current 在 [from, to] 内,或已超 from 但尚未 CLOSED提示安排回收
// 仅对 OPEN/PARTIAL 提示CLOSED 已回收、OVERDUE 已逾期另有强调。
export function isWindowApproaching(
item: ForeshadowView,
currentChapter: number,
): boolean {
if (item.status === "CLOSED" || item.status === "OVERDUE") return false;
const to = item.expected_close_to;
if (typeof to !== "number") return false;
const from = item.expected_close_from ?? to;
return currentChapter >= from && currentChapter <= to;
}
// 为「新埋待确认」建议预填一个建议代号:优先用建议自带 code否则 FS-<两位序号>。
// 纯函数(可单测);作者可在登记表单里改。
export function suggestForeshadowCode(
code: string | null | undefined,
index: number,
): string {
const trimmed = code?.trim();
if (trimmed) return trimmed;
return `FS-${String(index + 1).padStart(2, "0")}`;
}
// 在已占用代号taken区分大小写按原样为预填代号挑一个不冲突的值
// preferred 未被占用就用它;否则从 FS-01 起取首个空闲的 FS-<两位序号>。
// 纯函数(可单测)。避免登记时与库里已存代号撞码 → 422 duplicate。
export function nextFreeForeshadowCode(
preferred: string,
taken: ReadonlySet<string>,
): string {
if (preferred && !taken.has(preferred)) return preferred;
for (let n = 1; n < 1000; n++) {
const candidate = `FS-${String(n).padStart(2, "0")}`;
if (!taken.has(candidate)) return candidate;
}
return preferred; // 理论不可达1000 个代号全占用时退回原值
}
export interface RegisterInput {
projectId: string;
code: string;
title: string;
plantedAt?: number | null;
content?: string | null;
expectedCloseFrom?: number | null;
expectedCloseTo?: number | null;
importance?: string | null;
}
// 组装登记请求体(去空白;空可选字段省略)。
export function buildRegisterRequest(
input: RegisterInput,
): ForeshadowRegisterRequest {
const body: ForeshadowRegisterRequest = {
code: input.code.trim(),
title: input.title.trim(),
};
if (typeof input.plantedAt === "number") body.planted_at = input.plantedAt;
const content = input.content?.trim();
if (content) body.content = content;
if (typeof input.expectedCloseFrom === "number")
body.expected_close_from = input.expectedCloseFrom;
if (typeof input.expectedCloseTo === "number")
body.expected_close_to = input.expectedCloseTo;
const importance = input.importance?.trim();
if (importance) body.importance = importance;
return body;
}
export interface TransitionInput {
projectId: string;
toStatus?: ForeshadowStatus | null;
// 一条进展记录(自由 dict如 {chapter, note})。
progressEntry?: Record<string, unknown> | null;
}
// 组装转移/进展请求体(两字段皆可选;至少给一项由调用方校验)。
export function buildTransitionRequest(
input: TransitionInput,
): ForeshadowTransitionRequest {
const body: ForeshadowTransitionRequest = {};
if (input.toStatus) body.to_status = input.toStatus;
if (input.progressEntry && Object.keys(input.progressEntry).length > 0)
body.progress_entry = input.progressEntry;
return body;
}
// 422 VALIDATION 信封 details.reasonC3 扩)→ 用户文案。
export type ValidationReason =
| "duplicate"
| "invalid_transition"
| "empty_update"
| "invalid_status";
export function validationReasonMessage(reason: string | undefined): string {
switch (reason) {
case "duplicate":
return "该伏笔代号已存在,请换一个。";
case "invalid_transition":
return "非法状态转移CLOSED 为终态,不可再改)。";
case "empty_update":
return "请填写目标状态或一条进展。";
case "invalid_status":
return "无效的筛选状态。";
default:
return "操作未通过校验,请检查输入。";
}
}
// 从错误信封安全提取 details.reason。
export function extractReason(error: unknown): string | undefined {
if (typeof error !== "object" || error === null) return undefined;
const env = error as {
error?: { details?: { reason?: unknown } | null };
};
const reason = env.error?.details?.reason;
return typeof reason === "string" ? reason : undefined;
}