@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Profile に関する派生計算ヘルパー。
|
||||
*
|
||||
* ADR-0014「shadcn 標準フォームによる Pane 4 編集 UI」で Profile から `age` を
|
||||
* 撤去し、`birthday` から派生計算する方針に変更したため、その単一ソースとしてここに集約する。
|
||||
*
|
||||
* 設計原則:
|
||||
* - 純粋関数のみ(副作用なし、`new Date()` の都度呼び出しは許容)
|
||||
* - 不正/空入力は空文字 ""(または undefined)で返す(throw しない)
|
||||
* - 日付の保存形式は ANSI / ISO 8601 短形式(YYYY-MM-DD)に統一
|
||||
*/
|
||||
|
||||
/**
|
||||
* `YYYY-MM-DD` 形式の文字列を `Date` に変換する。
|
||||
* 空文字 / 不正フォーマットは `undefined` を返す。
|
||||
*
|
||||
* 時刻は `T00:00:00` をローカルタイムで補い、タイムゾーン跨ぎを避ける。
|
||||
*/
|
||||
export function parseISODate(s: string): Date | undefined {
|
||||
if (!s) return undefined;
|
||||
const d = new Date(s + "T00:00:00");
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
/**
|
||||
* `Date` を `YYYY-MM-DD` 形式の文字列に整形する。
|
||||
* `undefined` または不正な Date は空文字を返す。
|
||||
*/
|
||||
export function formatISODate(d: Date | undefined): string {
|
||||
if (!d || Number.isNaN(d.getTime())) return "";
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生年月日から年齢を派生計算し、「N 歳」形式で返す。
|
||||
* 空 / 不正な birthday は空文字を返す(UI 側で「未表示」になる)。
|
||||
*
|
||||
* 「今日」は `new Date()`。Pane 4 は `"use client"` なのでクライアント側で都度評価される。
|
||||
* SSR / hydration mismatch は発生しない。
|
||||
*/
|
||||
export function calculateAge(birthday: string): string {
|
||||
const birth = parseISODate(birthday);
|
||||
if (!birth) return "";
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - birth.getFullYear();
|
||||
const m = today.getMonth() - birth.getMonth();
|
||||
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
|
||||
if (age < 0) return "";
|
||||
return `${age} 歳`;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Scorecard / Candidate の派生計算(評価系)SSoT。
|
||||
*
|
||||
* Pane 2 候補者行の平均スコアバッジ (★ 4.5 / —) と、
|
||||
* Pane 3 評価カードの「平均スコア」行は、同じ意味の値を表示する。
|
||||
* 同じ計算を 2 箇所で持つと「ある日 Pane 2 だけ違う値が出る」事故が発生するため、
|
||||
* 計算ロジックは本ファイルの 3 関数に集約する MUST。
|
||||
*
|
||||
* 関連:
|
||||
* - openspec/decision/0013-list-pane-lightweight-header-and-row-evaluation.md
|
||||
* - openspec/decision/0010-pane-responsibility-and-candidate-detail-mode.md
|
||||
* §11 候補者単位 vs ステージ単位の責務分離(評価はステージ単位だが、
|
||||
* 候補者代表値として「最新 done scorecard の平均」を使う)
|
||||
*/
|
||||
|
||||
import {
|
||||
type Candidate,
|
||||
type AxisScores,
|
||||
type Scorecard,
|
||||
type StageStatus,
|
||||
AXIS_ORDER,
|
||||
STAGE_ORDER,
|
||||
} from "@/lib/schema";
|
||||
|
||||
/**
|
||||
* 「最後に done 状態となった scorecard」を返す。done が存在しない場合は undefined。
|
||||
*
|
||||
* 配列の末尾に近いものを優先することで、選考フロー (書類 → 一次 → 二次 → 最終) で
|
||||
* 「最も進んだステージの評価」を代表値として取り出せる。シード `INITIAL_SCORECARDS`
|
||||
* は STAGE_ORDER と同じ順で並ぶ前提(design.md / spec.md)。
|
||||
*/
|
||||
export function getLatestDoneScorecard(
|
||||
scorecards: Scorecard[],
|
||||
): Scorecard | undefined {
|
||||
return [...scorecards]
|
||||
.reverse()
|
||||
.find((s) => deriveStageStatus(s.date, s.decision) === "done");
|
||||
}
|
||||
|
||||
/**
|
||||
* 1 つの scorecard の axisScores(4 観点)の平均を返す。null は除外。
|
||||
*
|
||||
* 全 axis が null(未入力)の場合は null を返す(呼び出し側で「—」表示)。
|
||||
* 丸めは行わず、表示側で `toFixed(1)` 等を適用する。
|
||||
*/
|
||||
export function calculateAverageScore(axisScores: AxisScores): number | null {
|
||||
const values = AXIS_ORDER.map((k) => axisScores[k]).filter(
|
||||
(v): v is number => v !== null,
|
||||
);
|
||||
if (values.length === 0) return null;
|
||||
return values.reduce((a, b) => a + b, 0) / values.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 候補者の「代表平均スコア」を返す。
|
||||
* = 最新 done scorecard の axisScores の平均。
|
||||
*
|
||||
* Pane 2 候補者行のサブテキスト (★ 4.5 / —) と、Pane 3 評価カードの「平均スコア」行で
|
||||
* 同じ意味の値を出すために使う。done scorecard が無い・全 axis が null・候補者が
|
||||
* scorecards を持たないいずれの場合も null(未評価扱い)。
|
||||
*/
|
||||
export function getCandidateAverageScore(candidate: Candidate): number | null {
|
||||
const latest = getLatestDoneScorecard(candidate.scorecards);
|
||||
if (!latest) return null;
|
||||
return calculateAverageScore(latest.axisScores);
|
||||
}
|
||||
|
||||
/**
|
||||
* 完了済み(`status === "done"`)かつコメントあり(`comment` が truthy)の
|
||||
* scorecard を STAGE_ORDER 順で返す。Pane 3「担当者コメント」Card の行リストに使う。
|
||||
*
|
||||
* 空配列が返った場合は「コメントはまだ記入されていません」の placeholder を表示する。
|
||||
*/
|
||||
export function getCommentedScorecards(scorecards: Scorecard[]): Scorecard[] {
|
||||
const stageIndex = Object.fromEntries(
|
||||
STAGE_ORDER.map((s, i) => [s, i]),
|
||||
) as Record<string, number>;
|
||||
return scorecards
|
||||
.filter(
|
||||
(s) => deriveStageStatus(s.date, s.decision) === "done" && s.comment,
|
||||
)
|
||||
.sort((a, b) => (stageIndex[a.stage] ?? 0) - (stageIndex[b.stage] ?? 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* scorecards 配列から候補者の代表平均スコアを計算する。
|
||||
* `getCandidateAverageScore` の Candidate 型を要求しない版。
|
||||
* Pane 3 ヘッダー帯の `<ScoreLabel>` で使う。
|
||||
*/
|
||||
export function getScorecardsAverageScore(
|
||||
scorecards: Scorecard[],
|
||||
): number | null {
|
||||
const latest = getLatestDoneScorecard(scorecards);
|
||||
if (!latest) return null;
|
||||
return calculateAverageScore(latest.axisScores);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scorecard の date / decision の有無から状態を派生計算する。
|
||||
*
|
||||
* ADR-0015 §17.1 追加決定 I で `Scorecard.status` フィールドを廃止したため、
|
||||
* 表示時に都度この関数で導出する MUST。
|
||||
*
|
||||
* - decision あり (truthy) → done (実施済 + 結果あり)
|
||||
* - decision なし、date あり (truthy) → planned (予定済)
|
||||
* - 両方なし → pending (未着手)
|
||||
*
|
||||
* Pane 3 選考フローカードの StageIcon、コメント抽出のフィルタ、ヘッダー帯の
|
||||
* ★ スコア表示など、status を必要とするすべての表示で使う。
|
||||
*/
|
||||
export function deriveStageStatus(
|
||||
date: string,
|
||||
decision?: string,
|
||||
): StageStatus {
|
||||
if (decision && decision.trim() !== "") return "done";
|
||||
if (date && date.trim() !== "") return "planned";
|
||||
return "pending";
|
||||
}
|
||||
Reference in New Issue
Block a user