// 伏笔看板纯逻辑:四泳道分组、登记/转移请求体组装、422 reason 映射。 // 对齐 C3 扩(GET/POST/PATCH .../foreshadow)。纯逻辑,便于 node 环境单测。 import type { ForeshadowRegisterRequest, ForeshadowTransitionRequest, ForeshadowView, } from "@/lib/api/types"; // 四状态泳道(UX §6.8);OVERDUE 用琥珀强调(渲染层处理)。 export type ForeshadowStatus = "OPEN" | "PARTIAL" | "CLOSED" | "OVERDUE"; export const LANES: readonly ForeshadowStatus[] = [ "OPEN", "PARTIAL", "CLOSED", "OVERDUE", ] as const; export const LANE_LABELS: Record = { OPEN: "OPEN", PARTIAL: "PARTIAL", CLOSED: "CLOSED", OVERDUE: "OVERDUE", }; export type ForeshadowLanes = Record; 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; } // 接近回收窗口判定: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")}`; } 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 | 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.reason(C3 扩)→ 用户文案。 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; }