Payroll Sheet Tests — PR-A (Money + Adapters) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add frontend unit tests for the payroll-settlement sheet's money/date/sort/summary logic and the read-only domain adapters, fixing the adapter enum-drift bugs found during investigation and pinning correct behavior.
Architecture: Small behavior-preserving refactors first (export private pure fns; extract pure cores from Alpine slice methods so they are directly testable; consolidate the duplicated date normalizer where it's safe to do so). Then co-located Vitest tests. Adapter label maps are aligned to the real @hereby/core enums and asserted enum-driven so future drift fails the build.
Tech Stack: TypeScript (strict), Vitest + happy-dom (apps/hr-system/vitest.config.ts), Testing Library (available), Biome. Package @hereby/hr-system.
Revision note (2026-07-03): This plan was re-verified against current develop HEAD (commit 6907d610) before execution. develop had moved 423 commits past test/unit-coverage-batch1 since the original plan was written two days earlier, so the base-branch strategy, several enum contracts, and one refactor were re-checked and corrected. See the per-task notes below for what changed and why. Two structural changes vs. the 2026-07-01 version: buildColumnsFromLayout is promoted from "deferred to PR-B" into this PR's Tier 1 (Task 6), and a new Task 9 fixes a confirmed money-preview bug in commit-compute.ts's overtime category routing.
Global Constraints
- Runner AND build must both be green. Vitest does not type-check. Every task ends with
pnpm --filter @hereby/hr-system exec vitest run <file>andpnpm --filter @hereby/hr-system build. - cellRenderers signature is
(row, col, rowIdx) => string. Always call renderers with all 3 args in tests (passundefined as never/0for unused), or the build breaks even when the runner is green. - Assert against real enums. Adapter label maps assert against
@hereby/coreenums (SubsidyStatus,ResignationStatus,SettlementStatus,IncentiveCategory), never hand-rolled strings. - Test behavior, not implementation. Assert return values / rendered strings. No private-state or "was called" assertions.
- Refactors are behavior-preserving. Existing tests + build stay green after each refactor, before new tests are added.
- Commit style:
type(scope): subject(English), optional Korean body. Match existing commits (no co-author line seen on recent commits — omit). - Branch: all work on
test/batch2-sheetoffdevelop(see Task 1 — not offtest/unit-coverage-batch1; that plan was corrected). - Formatting: this repo's pre-commit hook runs
biome check --writevia lint-staged on every commit (verified live during Task 1 execution) — it auto-wraps most over-100-char lines in the code blocks below. The one line that can't be auto-wrapped (abiome-ignorecomment in Task 3) is already written under 100 chars in this plan.
Task 1: Branch + baseline
Status: COMPLETED 2026-07-03 — done directly during plan review because the originally-planned base branch turned out to be unusable. Recorded here so the recipe is reproducible and so later tasks' "confirm baseline green" step has an accurate starting point.
Files: apps/hr-system/src/sheets/overtime.adapter.ts, apps/hr-system/src/sheets/overtime.adapter.test.ts (both modified as part of baseline repair — see Step 3 below).
What changed vs. the original 2026-07-01 plan: that version said to branch off test/unit-coverage-batch1. Re-verification found test/unit-coverage-batch1 is 423 commits behind develop and entirely missing the apps/hr-system/src/sheets/workbook/ submodule split that Tasks 4/6/7/9 below depend on (it still has the pre-split monolithic workbook.sheet.ts). Branching from it would have made every line-number reference in this plan wrong and hidden real work behind a stale base.
- [x] Step 1: Create the batch2 branch off develop, not off batch1
cd /Users/kimjuhyeon/Documents/GitHub/hereby-dhub
git fetch origin
git switch -c test/batch2-sheet origin/develop- [x] Step 2: Cherry-pick only the sheet-relevant commit from batch1
test/unit-coverage-batch1 has exactly 2 commits ahead of its merge-base with develop:
9c1f4a7b—fix(hr-system): align overtime adapter labels to real enums + bind tests to API contract(touchesapps/hr-system/src/sheets/overtime.adapter.ts+.test.ts— this is the one to bring forward)a82dbcec— unrelated backend/code-modules test infra +pnpm-lock.yamlchanges — do NOT cherry-pick this one; it's out of this batch's frontend/e2e scope and risks a stale lockfile.
git cherry-pick 9c1f4a7bExpect a conflict on apps/hr-system/src/sheets/overtime.adapter.test.ts (git reports "modify/delete" because the file doesn't exist on develop yet — the batch1 commit that created it, a82dbcec, is being skipped). This is not a real conflict: the version left in the tree by git IS the file you want.
git add apps/hr-system/src/sheets/overtime.adapter.ts apps/hr-system/src/sheets/overtime.adapter.test.ts
git cherry-pick --continue --no-edit- [x] Step 3: Fix baseline drift — the cherry-picked test targets a
developthat no longer exists
develop moved 423 commits since batch1 forked, and two of the things that changed are exactly what the cherry-picked overtime adapter/test touch:
OvertimeDepartmentwas removed entirely (migration1823000000000-DropOvertimeDepartment: "vestige of the removed Department concept... not settable anywhere in the app... hard-defaulted to admin"). The cherry-picked test still imports it and asserts adepartmentcellRenderer/searchPredicate that no longer exists on the adapter (thedevelop-side auto-merge already dropped the column fromovertime.adapter.tsitself — only the test file, which was a pure add, is stale). Fix: delete everyOvertimeDepartment/departmentreference fromovertime.adapter.test.ts(the import, theexpectedDepartmentmap, the "matches by the localized department LABEL" test, the "renders the exact Korean label for EVERY OvertimeDepartment" test, the "falls back to a dash for an empty department" test).OvertimeCategory.HOLIDAY_NIGHTwas added (packages/core/src/enums/overtime.enum.ts:18,= "holiday_night"; doc-comment: "휴일야간연장 휴일 야간 초과"). Also note from the same file:SATURDAYis@deprecatedsince 2026-06 ("토·일을 휴일로 통합"), kept only for legacy data. The cherry-picked adapter'sCAT_LABELand the test'sexpectedCategory: Record<OvertimeCategory, string>don't have aHOLIDAY_NIGHTentry, which failstsc(Property '[OvertimeCategory.HOLIDAY_NIGHT]' is missing). Fix: addholiday_night: "휴일야간"to bothCAT_LABEL(overtime.adapter.ts) andexpectedCategory(overtime.adapter.test.ts), matching the terse style of the existing평일/토요일/휴일/야간entries.
These fixes were applied and committed as 433fe910 (fix(hr-system): rebase overtime adapter fix onto develop, drop dead department coverage, add missing HOLIDAY_NIGHT label).
- [x] Step 4: Confirm the baseline is green
Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/ src/sheet/ Result: PASS — 9 test files, 56 tests.
Run: pnpm --filter @hereby/hr-system build Result: PASS (tsc + vite build).
Task 2: Export the pure compute fns from workbook-adapter.ts
Files:
- Modify:
apps/hr-system/src/sheets/workbook-adapter.ts(addexportto 3 fns)
Interfaces:
- Produces:
export function evalColumnFilter(row, col, rule),export function pasteNormalizeIsoDate(s): string | null,export function projectRowAmount(row: WorkbookRow, hooks: WorkbookAdapterHooks): number.WorkbookRow(line 34) andWorkbookAdapterHooks(line 1163) are already exported.
Re-verified 2026-07-03 against current develop HEAD: all three line numbers below are exact — workbook-adapter.ts itself was untouched by the 423 commits that moved other sheet files, so nothing drifted here.
- [ ] Step 1: Add
exportto the three fns (no body change)
workbook-adapter.ts:176 → export function evalColumnFilter(workbook-adapter.ts:335 → export function pasteNormalizeIsoDate(s: string): string | null {workbook-adapter.ts:1349 → export function projectRowAmount(row: WorkbookRow, hooks: WorkbookAdapterHooks): number {
- [ ] Step 2: Verify nothing else broke
Run: pnpm --filter @hereby/hr-system build Expected: PASS.
- [ ] Step 3: Commit
git add apps/hr-system/src/sheets/workbook-adapter.ts
git commit -m "refactor(hr-system): export sheet filter/date/amount fns for unit testing"Task 3: evalColumnFilter tests
Files:
Create:
apps/hr-system/src/sheets/workbook-adapter.filter.test.ts[ ] Step 1: Write the failing test
import { describe, expect, it } from "vitest";
import type { ColumnDef } from "../sheet/types";
import { evalColumnFilter } from "./workbook-adapter";
const col = (p: Partial<ColumnDef>): ColumnDef => p as ColumnDef;
// biome-ignore lint/suspicious/noExplicitAny: minimal row fixtures for a dot-path read
const row = (o: Record<string, unknown>): any => o;
// biome-ignore lint/suspicious/noExplicitAny: minimal ColumnFilterRule fixture (op/value/valueTo)
const rule = (op: string, value?: unknown, valueTo?: unknown): any => ({ op, value, valueTo });
describe("evalColumnFilter — guards", () => {
it("passes the row when the column is missing (stale rule for a deleted column)", () => {
expect(evalColumnFilter(row({}), undefined, rule("equals", "x"))).toBe(true);
});
it("isEmpty / isNotEmpty short-circuit before coercion", () => {
const c = col({ key: "a", render: "text" });
expect(evalColumnFilter(row({ a: "" }), c, rule("isEmpty"))).toBe(true);
expect(evalColumnFilter(row({ a: "x" }), c, rule("isEmpty"))).toBe(false);
expect(evalColumnFilter(row({ a: "x" }), c, rule("isNotEmpty"))).toBe(true);
expect(evalColumnFilter(row({ a: "" }), c, rule("isNotEmpty"))).toBe(false);
});
it("a null value fails every non-presence op", () => {
const c = col({ key: "a", render: "amount" });
expect(evalColumnFilter(row({ a: null }), c, rule("gt", 0))).toBe(false);
});
});
describe("evalColumnFilter — amount", () => {
const c = col({ key: "amount", field: "amount", render: "amount" });
it("compares numerically", () => {
expect(evalColumnFilter(row({ amount: 1000 }), c, rule("equals", 1000))).toBe(true);
expect(evalColumnFilter(row({ amount: 1000 }), c, rule("gt", 500))).toBe(true);
expect(evalColumnFilter(row({ amount: 1000 }), c, rule("lt", 500))).toBe(false);
});
it("between is inclusive and order-independent", () => {
expect(evalColumnFilter(row({ amount: 5 }), c, rule("between", 1, 10))).toBe(true);
expect(evalColumnFilter(row({ amount: 5 }), c, rule("between", 10, 1))).toBe(true);
expect(evalColumnFilter(row({ amount: 11 }), c, rule("between", 1, 10))).toBe(false);
});
it("a non-finite cell fails; a non-finite rule passes", () => {
expect(evalColumnFilter(row({ amount: "abc" }), c, rule("gt", 1))).toBe(false);
expect(evalColumnFilter(row({ amount: 1000 }), c, rule("gt", "x"))).toBe(true);
});
});
describe("evalColumnFilter — date (normalizes both sides to ISO)", () => {
const c = col({ key: "d", field: "d", render: "date" });
it("matches a slash cell against an ISO rule", () => {
expect(evalColumnFilter(row({ d: "4/10/2009" }), c, rule("equals", "2009-04-10"))).toBe(true);
});
it("orders chronologically via ISO string compare", () => {
expect(evalColumnFilter(row({ d: "2009-05-01" }), c, rule("gt", "2009-04-10"))).toBe(true);
});
it("an unparseable cell fails", () => {
expect(evalColumnFilter(row({ d: "garbage" }), c, rule("equals", "2009-04-10"))).toBe(false);
});
});
describe("evalColumnFilter — string (case-insensitive)", () => {
const c = col({ key: "name", field: "name", render: "text" });
it("contains / startsWith / equals ignore case", () => {
expect(evalColumnFilter(row({ name: "Kim" }), c, rule("contains", "kim"))).toBe(true);
expect(evalColumnFilter(row({ name: "Kim" }), c, rule("startsWith", "ki"))).toBe(true);
expect(evalColumnFilter(row({ name: "Kim" }), c, rule("equals", "kim"))).toBe(true);
});
});- [ ] Step 2: Run — expect PASS (the fn already exists; this pins behavior)
Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/workbook-adapter.filter.test.ts Expected: PASS. If any case fails, it is a real bug — stop and report, do not edit the test to match.
- [ ] Step 3: Type-check
Run: pnpm --filter @hereby/hr-system build Expected: PASS.
- [ ] Step 4: Commit
git add apps/hr-system/src/sheets/workbook-adapter.filter.test.ts
git commit -m "test(hr-system): cover evalColumnFilter operators + date/amount coercion"Task 4: Consolidate date normalization + tests
Revised 2026-07-03: the original version of this task delegated BOTH pasteNormalizeIsoDate (workbook-adapter.ts) AND _normalizeToIsoDate (workbook/drafts.ts) to one shared helper. Re-verification found they are not behaviorally identical: in the ISO branch, pasteNormalizeIsoDate interpolates the raw regex-captured year string (iso[1]), while drafts.ts's _normalizeToIsoDate first does Number(iso[1]) and interpolates the number. These diverge for any leading-zero 4-digit year (e.g. "0024-01-05" → "0024-01-05" vs "24-01-05"). This is a narrow edge case (leading-zero years never occur in real payroll dates), but delegating both to a single helper as originally planned would silently change drafts.ts's behavior for that edge case — not behavior-preserving as specified. Fix: only pasteNormalizeIsoDate delegates to the shared helper in this task. drafts.ts is left untouched. The duplication note below is updated to reflect two copies remain (this file's, drafts.ts's), not three-down-to-one.
Files:
- Create:
apps/hr-system/src/sheets/workbook/date-normalize.ts - Create:
apps/hr-system/src/sheets/workbook/date-normalize.test.ts - Modify:
apps/hr-system/src/sheets/workbook-adapter.ts(pasteNormalizeIsoDatedelegates)
Interfaces:
Produces:
export function normalizeToIsoDate(s: string): string | null[ ] Step 1: Create the shared helper (verbatim logic from
pasteNormalizeIsoDate)
// apps/hr-system/src/sheets/workbook/date-normalize.ts
/**
* Normalize a supported date string to canonical ISO yyyy-mm-dd, or null.
* Extracted from workbook-adapter's pasteNormalizeIsoDate. NOT shared with
* drafts.ts's _normalizeToIsoDate or the resolver's toIsoDate — both have a
* confirmed behavioral difference in ISO-year handling (see Task 4 notes in
* the implementation plan) and were left as separate copies on purpose.
* Accepts: yyyy-mm-dd | yyyy/m/d | m/d/yyyy (flips to d/m/yyyy when the
* first segment >12) | m/d/yy (00-69 -> 2000s, 70-99 -> 1900s).
* NOTE: does NOT validate calendar validity (e.g. "2/30/2025" passes through).
*/
export function normalizeToIsoDate(s: string): string | null {
if (!s) {
return null;
}
const trimmed = s.trim();
const iso = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(trimmed);
if (iso) {
const mm = String(Number(iso[2])).padStart(2, "0");
const dd = String(Number(iso[3])).padStart(2, "0");
return `${iso[1]}-${mm}-${dd}`;
}
const slash = /^(\d{1,4})\/(\d{1,2})\/(\d{1,4})$/.exec(trimmed);
if (!slash) {
return null;
}
const a = Number(slash[1]);
const b = Number(slash[2]);
const c = Number(slash[3]);
let y: number;
let m: number;
let d: number;
if (slash[1].length === 4) {
y = a;
m = b;
d = c;
} else {
const yr = slash[3].length === 4 ? c : c <= 69 ? 2000 + c : 1900 + c;
if (a > 12 && b <= 12) {
y = yr;
m = b;
d = a;
} else {
y = yr;
m = a;
d = b;
}
}
return `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
}- [ ] Step 2: Write the test
// apps/hr-system/src/sheets/workbook/date-normalize.test.ts
import { describe, expect, it } from "vitest";
import { normalizeToIsoDate } from "./date-normalize";
describe("normalizeToIsoDate", () => {
it("zero-pads an ISO date", () => {
expect(normalizeToIsoDate("2025-1-5")).toBe("2025-01-05");
});
it("reads yyyy/m/d as year-first", () => {
expect(normalizeToIsoDate("2025/1/5")).toBe("2025-01-05");
});
it("reads m/d/yyyy as month-first", () => {
expect(normalizeToIsoDate("3/4/2009")).toBe("2009-03-04");
});
it("flips to d/m/yyyy when the first segment cannot be a month", () => {
expect(normalizeToIsoDate("13/1/2025")).toBe("2025-01-13");
});
it("pivots a 2-digit year: 00-69 -> 2000s", () => {
expect(normalizeToIsoDate("1/5/25")).toBe("2025-01-05");
});
it("pivots a 2-digit year: 70-99 -> 1900s", () => {
expect(normalizeToIsoDate("1/5/70")).toBe("1970-01-05");
});
it("returns null for unparseable / empty input", () => {
expect(normalizeToIsoDate("garbage")).toBeNull();
expect(normalizeToIsoDate("")).toBeNull();
});
it("does NOT validate calendar validity (characterization — locks current behavior)", () => {
// characterization: locks current behavior, not proven-correct output
expect(normalizeToIsoDate("2/30/2025")).toBe("2025-02-30");
});
});- [ ] Step 3: Run — expect PASS
Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/workbook/date-normalize.test.ts Expected: PASS.
- [ ] Step 4: Point
pasteNormalizeIsoDateat the shared helper (do NOT touchdrafts.ts— see the revision note above)
In workbook-adapter.ts: add import { normalizeToIsoDate } from "./workbook/date-normalize"; and replace the body of pasteNormalizeIsoDate with return normalizeToIsoDate(s); (keep the exported signature from Task 2).
- [ ] Step 5: Verify runner + build (existing filter test still green proves behavior preserved)
Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/workbook-adapter.filter.test.ts src/sheets/workbook/date-normalize.test.ts Expected: PASS.
Run: pnpm --filter @hereby/hr-system build Expected: PASS.
- [ ] Step 6: Commit
git add apps/hr-system/src/sheets/workbook/date-normalize.ts apps/hr-system/src/sheets/workbook/date-normalize.test.ts apps/hr-system/src/sheets/workbook-adapter.ts
git commit -m "refactor(hr-system): extract pasteNormalizeIsoDate into a tested date-normalize helper"Task 5: projectRowAmount tests
Files:
Create:
apps/hr-system/src/sheets/workbook-adapter.amount.test.ts[ ] Step 1: Write the test (exercises hook vs fallback)
import { describe, expect, it } from "vitest";
import { projectRowAmount } from "./workbook-adapter";
// biome-ignore lint/suspicious/noExplicitAny: minimal WorkbookRow fixtures
const row = (o: Record<string, unknown>): any => o;
describe("projectRowAmount", () => {
it("overtime uses the preview hook when it returns a number", () => {
expect(
projectRowAmount(row({ recordKind: "overtime", amount: 100 }), {
previewOvertimeAmount: () => 500,
}),
).toBe(500);
});
it("overtime falls back to row.amount when the hook returns null", () => {
expect(
projectRowAmount(row({ recordKind: "overtime", amount: 100 }), {
previewOvertimeAmount: () => null,
}),
).toBe(100);
});
it("overtime falls back to row.amount when no hook is provided", () => {
expect(projectRowAmount(row({ recordKind: "overtime", amount: 100 }), {})).toBe(100);
});
it("deduction uses the insurance preview hook", () => {
expect(
projectRowAmount(row({ recordKind: "deduction", amount: 100 }), {
previewInsuranceAmount: () => -30,
}),
).toBe(-30);
});
it("other kinds use row.amount, coercing non-numeric to 0", () => {
expect(projectRowAmount(row({ recordKind: "allowance", amount: 2500 }), {})).toBe(2500);
expect(projectRowAmount(row({ recordKind: "allowance", amount: "abc" }), {})).toBe(0);
});
});- [ ] Step 2: Run — expect PASS
Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/workbook-adapter.amount.test.ts Expected: PASS.
- [ ] Step 3: Type-check + commit
Run: pnpm --filter @hereby/hr-system build → PASS.
git add apps/hr-system/src/sheets/workbook-adapter.amount.test.ts
git commit -m "test(hr-system): cover projectRowAmount hook vs fallback"Task 6: Export buildColumnsFromLayout + tests
New task, promoted from "deferred to PR-B" (2026-07-03 revision). The 2026-07-01 plan deferred this function citing "body not yet read." It has since been read in full (workbook-adapter.ts:946-1161, confirmed unchanged on current develop): it is closure-free (zero this references — verified by grep), depends only on module-level FALLBACK_LAYOUT/DEFAULT_COL_META constants and its layout parameter, and its job is exactly the kind of silent-breakage money-classification logic this batch targets (it decides each persisted column's render/field/width from a layout entry — a wrong classification means a money column silently renders as plain text, or vice versa). It belongs in Tier 1 with the rest of the money-compute functions, not deferred.
Files:
- Modify:
apps/hr-system/src/sheets/workbook-adapter.ts(addexporttobuildColumnsFromLayout, no body change) - Create:
apps/hr-system/src/sheets/workbook-adapter.columns.test.ts
Interfaces:
Produces:
export function buildColumnsFromLayout(layout: SheetViewLite["columnLayout"]): ColumnDef[][ ] Step 1: Add
export(no body change)
workbook-adapter.ts:946 → export function buildColumnsFromLayout(layout: SheetViewLite["columnLayout"]): ColumnDef[] {
- [ ] Step 2: Write the test
Read apps/hr-system/src/sheets/workbook-adapter.ts lines 946-1161 and the DEFAULT_COL_META/FALLBACK_LAYOUT consts it reads from (search above line 946 in the same file) before writing assertions — the exact per-key metadata (which keys default to render: "amount" vs "text", which are frozen, minWidth defaults) must be read from the real consts, not guessed. Write at minimum:
// apps/hr-system/src/sheets/workbook-adapter.columns.test.ts
import { describe, expect, it } from "vitest";
import { buildColumnsFromLayout } from "./workbook-adapter";
describe("buildColumnsFromLayout", () => {
it("force-includes an index column at the start when the persisted layout lacks one", () => {
const cols = buildColumnsFromLayout([{ key: "employeeName", width: 90, visible: true }]);
expect(cols[0]?.key).toBe("index");
expect(cols[0]?.frozen).toBe(true);
});
it("falls back to FALLBACK_LAYOUT when the persisted layout is empty", () => {
const withEmpty = buildColumnsFromLayout([]);
const withUndefined = buildColumnsFromLayout(undefined as never);
expect(withEmpty.length).toBeGreaterThan(1);
expect(withEmpty.map((c) => c.key)).toEqual(withUndefined.map((c) => c.key));
});
it("classifies a known amount-bearing key with render: 'amount'", () => {
const cols = buildColumnsFromLayout([{ key: "amount", width: 100, visible: true }]);
const amountCol = cols.find((c) => c.key === "amount");
expect(amountCol?.render).toBe("amount");
});
it("preserves the persisted width and visibility from the layout entry", () => {
const cols = buildColumnsFromLayout([{ key: "employeeName", width: 123, visible: false }]);
const col = cols.find((c) => c.key === "employeeName");
expect(col?.width).toBe(123);
expect(col?.visible).toBe(false);
});
});Adjust the exact expected render/metadata values in tests 3-4 to match what DEFAULT_COL_META actually contains once read — do not guess field names blind.
- [ ] Step 3: Run — expect PASS
Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/workbook-adapter.columns.test.ts Expected: PASS (this pins existing behavior — if a case fails, the assertion was wrong about DEFAULT_COL_META's real content; fix the test, not the source, since Step 1 made no body change).
- [ ] Step 4: Build + commit
Run: pnpm --filter @hereby/hr-system build → PASS.
git add apps/hr-system/src/sheets/workbook-adapter.ts apps/hr-system/src/sheets/workbook-adapter.columns.test.ts
git commit -m "test(hr-system): export + cover buildColumnsFromLayout column classification"Task 7: Extract compareByColumn + tests
Re-verified 2026-07-03 against current develop HEAD: filters-sort.ts's inline sort is still at lines 119-135 exactly, logic unchanged.
Files:
- Create:
apps/hr-system/src/sheets/workbook/sort-compare.ts - Create:
apps/hr-system/src/sheets/workbook/sort-compare.test.ts - Modify:
apps/hr-system/src/sheets/workbook/filters-sort.ts:119-135(delegate)
Interfaces:
Produces:
export function compareByColumn(a: unknown, b: unknown, sortKey: string, dir: "asc" | "desc"): number[ ] Step 1: Create the pure comparator (verbatim from
sortByColumn's inline sort)
// apps/hr-system/src/sheets/workbook/sort-compare.ts
/**
* Pure comparator extracted from filters-sort's sortByColumn. Null values
* sort last regardless of direction (Excel convention). Strings use the
* Korean locale; other values coerce via Number() (non-numeric -> 0).
*/
export function compareByColumn(
a: unknown,
b: unknown,
sortKey: string,
dir: "asc" | "desc",
): number {
const multiplier = dir === "asc" ? 1 : -1;
const va = (a as Record<string, unknown> | null | undefined)?.[sortKey];
const vb = (b as Record<string, unknown> | null | undefined)?.[sortKey];
if (va == null && vb == null) {
return 0;
}
if (va == null) {
return 1;
}
if (vb == null) {
return -1;
}
if (typeof va === "string" && typeof vb === "string") {
return va.localeCompare(vb, "ko") * multiplier;
}
return ((Number(va) || 0) - (Number(vb) || 0)) * multiplier;
}- [ ] Step 2: Write the test
// apps/hr-system/src/sheets/workbook/sort-compare.test.ts
import { describe, expect, it } from "vitest";
import { compareByColumn } from "./sort-compare";
const r = (v: unknown) => ({ v });
describe("compareByColumn", () => {
it("orders numbers ascending and descending", () => {
expect(compareByColumn(r(1), r(2), "v", "asc")).toBeLessThan(0);
expect(compareByColumn(r(1), r(2), "v", "desc")).toBeGreaterThan(0);
});
it("sorts nulls last regardless of direction", () => {
expect(compareByColumn(r(null), r(1), "v", "asc")).toBe(1);
expect(compareByColumn(r(null), r(1), "v", "desc")).toBe(1);
expect(compareByColumn(r(null), r(null), "v", "asc")).toBe(0);
});
it("uses Korean locale collation for strings", () => {
expect(compareByColumn(r("나"), r("가"), "v", "asc")).toBeGreaterThan(0);
});
it("coerces non-numeric values to 0", () => {
expect(compareByColumn(r("abc"), r(5), "v", "asc")).toBeLessThan(0);
});
});- [ ] Step 3: Run — expect PASS
Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/workbook/sort-compare.test.ts Expected: PASS.
- [ ] Step 4: Delegate from
sortByColumn— replace the inline.sort((a, b) => { ... })body (filters-sort.ts:119-135) with:
(this.engine.rows as unknown[]).sort((a, b) => compareByColumn(a, b, sortKey, dir));and add import { compareByColumn } from "./sort-compare"; at the top of filters-sort.ts.
- [ ] Step 5: Verify build (behavior preserved)
Run: pnpm --filter @hereby/hr-system build → PASS.
- [ ] Step 6: Commit
git add apps/hr-system/src/sheets/workbook/sort-compare.ts apps/hr-system/src/sheets/workbook/sort-compare.test.ts apps/hr-system/src/sheets/workbook/filters-sort.ts
git commit -m "refactor(hr-system): extract compareByColumn; cover null-last + ko locale sort"Task 8: Extract summaryValue + tests
Re-verified 2026-07-03 against current develop HEAD: payroll.ts's summaryFor is still at lines 633-713 exactly, every aggregation rule below re-confirmed against current source line-by-line.
Files:
- Modify:
apps/hr-system/src/sheets/workbook/payroll.ts(add exportedsummaryValue;summaryFordelegates) - Create:
apps/hr-system/src/sheets/workbook/payroll-summary.test.ts
Interfaces:
Consumes:
WorkbookRowfrom../workbook-adapter,ColumnDeffrom../../sheet/types.Produces:
export function summaryValue(col: ColumnDef, rows: WorkbookRow[]): string[ ] Step 1: Add the pure fn to
payroll.ts(verbatim body fromsummaryFor, minus theengineTick/engineplumbing) and export it. Ensure imports forColumnDefandWorkbookRowexist in the file.
export function summaryValue(col: ColumnDef, rows: WorkbookRow[]): string {
const visible = rows.filter((r) => !r.isSubtotal);
if (col.render === "amount") {
const field = (col.field ?? col.key) as keyof WorkbookRow;
let sum = 0;
for (const r of visible) {
const v = r[field] as number | null | undefined;
if (v != null) {
sum += Number(v) || 0;
}
}
return Math.round(sum).toLocaleString("ko-KR");
}
if (col.key === "overtimeHours" || col.key === "mealBreakHours") {
const field = col.field as keyof WorkbookRow;
let sum = 0;
for (const r of visible) {
const v = r[field] as number | null | undefined;
if (v != null) {
sum += Number(v) || 0;
}
}
return sum.toFixed(1);
}
if (col.key === "unitCount") {
let sum = 0;
for (const r of visible) {
if (r.unitCount != null) {
sum += Number(r.unitCount) || 0;
}
}
return sum.toLocaleString("ko-KR");
}
if (col.key === "therapistCount") {
let sum = 0;
for (const r of visible) {
if (r.therapistCount != null) {
sum += Number(r.therapistCount) || 0;
}
}
return sum.toLocaleString("ko-KR");
}
if (col.key === "extendNet") {
let sum = 0;
for (const r of visible) {
const ot = Number(r.overtimeHours ?? 0);
const meal = Number(r.mealBreakHours ?? 0);
sum += Math.max(ot - meal, 0);
}
return sum.toFixed(1);
}
if (col.key === "employeeNumber" || col.key === "employeeName") {
const seen = new Set<string>();
for (const r of visible) {
if (r.employeeId) {
seen.add(r.employeeId);
}
}
return `${seen.size}명`;
}
return "";
}Then make the slice method delegate — replace the body of summaryFor(col) (after the engineTick read + if (!this.engine) return "";) with:
return summaryValue(col, this.engine.filteredSortedRows as WorkbookRow[]);- [ ] Step 2: Write the test
// apps/hr-system/src/sheets/workbook/payroll-summary.test.ts
import { describe, expect, it } from "vitest";
import type { ColumnDef } from "../../sheet/types";
import { summaryValue } from "./payroll";
const col = (p: Partial<ColumnDef>): ColumnDef => p as ColumnDef;
// biome-ignore lint/suspicious/noExplicitAny: minimal WorkbookRow[] fixtures
const rows = (...rs: Record<string, unknown>[]): any => rs;
describe("summaryValue", () => {
it("sums amount columns with a Korean thousands separator", () => {
const c = col({ render: "amount", key: "amount", field: "amount" });
expect(summaryValue(c, rows({ amount: 1000 }, { amount: 2500 }))).toBe("3,500");
});
it("excludes subtotal rows from the footer sum", () => {
const c = col({ render: "amount", key: "amount", field: "amount" });
expect(
summaryValue(c, rows({ amount: 1000 }, { amount: 9999, isSubtotal: true })),
).toBe("1,000");
});
it("sums overtimeHours to one decimal", () => {
const c = col({ key: "overtimeHours", field: "overtimeHours" });
expect(summaryValue(c, rows({ overtimeHours: 1.5 }, { overtimeHours: 2 }))).toBe("3.5");
});
it("sums unitCount as an integer", () => {
const c = col({ key: "unitCount" });
expect(summaryValue(c, rows({ unitCount: 3 }, { unitCount: 4 }))).toBe("7");
});
it("computes extendNet as sum of max(ot - meal, 0)", () => {
const c = col({ key: "extendNet" });
expect(summaryValue(c, rows({ overtimeHours: 5, mealBreakHours: 1 }))).toBe("4.0");
});
it("counts distinct employees under identity columns", () => {
const c = col({ key: "employeeName" });
expect(
summaryValue(c, rows({ employeeId: "e1" }, { employeeId: "e1" }, { employeeId: "e2" })),
).toBe("2명");
});
it("returns '' for columns with no summary rule", () => {
expect(summaryValue(col({ key: "foo", render: "text" }), rows({ foo: "x" }))).toBe("");
});
});- [ ] Step 3: Run — expect PASS
Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/workbook/payroll-summary.test.ts Expected: PASS.
- [ ] Step 4: Build + commit
Run: pnpm --filter @hereby/hr-system build → PASS.
git add apps/hr-system/src/sheets/workbook/payroll.ts apps/hr-system/src/sheets/workbook/payroll-summary.test.ts
git commit -m "refactor(hr-system): extract summaryValue; cover per-column footer aggregation"Task 9: Fix overtime category routing in commit-compute.ts (confirmed money-preview bug)
New task (2026-07-03 revision). _previewOvertimeAmount in apps/hr-system/src/sheets/workbook/commit-compute.ts (lines 754-800) computes the live amount preview shown before a commit. Its category routing (lines 787-796) only special-cases OvertimeCategory.HOLIDAY and .NIGHT; every other category — including .SATURDAY (deprecated legacy data) and .HOLIDAY_NIGHT — falls into a generic else branch that treats the hours as plain weekdayHours.
This was traced end-to-end and confirmed as a real underpayment bug, not a false alarm. The rate module it calls (packages/code-modules/src/modules/overtime-record.ts) has independently-configurable multipliers per category — overtimeMultiplier (weekday, default 1.5x), nightMultiplier (night, default 0.5x — an additive premium, not a full rate), holidayMultiplier (holiday, default 1.5x) — and its compute() function sums weekday_base + night_base + holiday_base + weekend_base independently, meaning setting both holidayHours and nightHours to the same net hours correctly stacks the two premiums. This matches the enum's own documented Korean-law semantics (overtime.enum.ts:9: HOLIDAY_NIGHT 휴일야간연장 휴일 야간 초과, and docs/ko/policy/01_Domains/employee/page.md:169: "휴일 근무 시간을 초과한 근무이면서... 야간 시간대... 해당하는 근무"). Concretely, at default rates: HOLIDAY_NIGHT hours currently preview at 1.5x (routed to weekdayHours) but should preview at 1.5x + 0.5x = 2.0x — a guaranteed 0.5×hourlyRate×hours underpayment preview on every holiday-night overtime row. SATURDAY (legacy, @deprecated since the 2026-06 Saturday/Sunday-into-Holiday unification) is also mis-routed to weekdayHours instead of holidayHours, which is wrong whenever an org's overtimeMultiplier and holidayMultiplier differ (a common Korean labor-compliance setup).
This is frontend preview logic (apps/hr-system, not packages/api), so it stays in scope for this batch per the earlier scope decision — backend packages/api/src/routers/sheet-rows/ commit-time computation is untouched (it has its own equivalent logic and is explicitly out of scope; see spec Section 8-adjacent handoff note. If this same category-routing pattern exists in the backend calculator, that is a separate finding for the backend owner — do not fix it here.).
Files:
- Create:
apps/hr-system/src/sheets/workbook/overtime-category.ts - Create:
apps/hr-system/src/sheets/workbook/overtime-category.test.ts - Modify:
apps/hr-system/src/sheets/workbook/commit-compute.ts:787-796(delegate)
Interfaces:
Produces:
export interface OvertimeCategoryHours { weekdayHours: number; nightHours: number; nightDays: number; holidayHours: number; holidayDays: number; },export function categorizeOvertimeHours(cat: OvertimeCategory | null | undefined, netHours: number): OvertimeCategoryHours[ ] Step 1: Write the failing test
// apps/hr-system/src/sheets/workbook/overtime-category.test.ts
import { OvertimeCategory } from "@hereby/core";
import { describe, expect, it } from "vitest";
import { categorizeOvertimeHours } from "./overtime-category";
describe("categorizeOvertimeHours", () => {
it("routes WEEKDAY to weekdayHours", () => {
expect(categorizeOvertimeHours(OvertimeCategory.WEEKDAY, 3)).toEqual({
weekdayHours: 3,
nightHours: 0,
nightDays: 0,
holidayHours: 0,
holidayDays: 0,
});
});
it("routes HOLIDAY to holidayHours", () => {
expect(categorizeOvertimeHours(OvertimeCategory.HOLIDAY, 3)).toEqual({
weekdayHours: 0,
nightHours: 0,
nightDays: 0,
holidayHours: 3,
holidayDays: 1,
});
});
it("routes NIGHT to nightHours", () => {
expect(categorizeOvertimeHours(OvertimeCategory.NIGHT, 3)).toEqual({
weekdayHours: 0,
nightHours: 3,
nightDays: 1,
holidayHours: 0,
holidayDays: 0,
});
});
it("stacks HOLIDAY_NIGHT into holiday+night hours (regression: fell to weekdayHours)", () => {
expect(categorizeOvertimeHours(OvertimeCategory.HOLIDAY_NIGHT, 3)).toEqual({
weekdayHours: 0,
nightHours: 3,
nightDays: 1,
holidayHours: 3,
holidayDays: 1,
});
});
it("routes deprecated SATURDAY to holidayHours, not weekdayHours (org rate)", () => {
expect(categorizeOvertimeHours(OvertimeCategory.SATURDAY, 3)).toEqual({
weekdayHours: 0,
nightHours: 0,
nightDays: 0,
holidayHours: 3,
holidayDays: 1,
});
});
it("never sets a *Days flag for zero net hours", () => {
expect(categorizeOvertimeHours(OvertimeCategory.HOLIDAY, 0).holidayDays).toBe(0);
});
it("falls back to weekdayHours for a null or undefined category", () => {
expect(categorizeOvertimeHours(null, 3).weekdayHours).toBe(3);
expect(categorizeOvertimeHours(undefined, 3).weekdayHours).toBe(3);
});
});- [ ] Step 2: Run — expect FAIL (module doesn't exist yet)
Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/workbook/overtime-category.test.ts Expected: FAIL — cannot find module ./overtime-category.
- [ ] Step 3: Write the implementation
// apps/hr-system/src/sheets/workbook/overtime-category.ts
import { OvertimeCategory } from "@hereby/core";
export interface OvertimeCategoryHours {
weekdayHours: number;
nightHours: number;
nightDays: number;
holidayHours: number;
holidayDays: number;
}
/**
* Maps an overtime category to its rate-calculation hour buckets, consumed by
* packages/code-modules' overtimeRecordModule.compute (weekday/night/holiday
* multipliers are independently additive there). HOLIDAY_NIGHT stacks BOTH
* holiday and night buckets for the same hours — 휴일야간연장 legally earns the
* holiday premium AND the night premium together, not one or the other.
* SATURDAY is a @deprecated legacy category (Sat/Sun unified into HOLIDAY
* since 2026-06); still routed to holidayHours so old data previews at the
* current policy's rate rather than the plain weekday rate.
*/
export function categorizeOvertimeHours(
cat: OvertimeCategory | null | undefined,
netHours: number,
): OvertimeCategoryHours {
const hours: OvertimeCategoryHours = {
weekdayHours: 0,
nightHours: 0,
nightDays: 0,
holidayHours: 0,
holidayDays: 0,
};
switch (cat) {
case OvertimeCategory.HOLIDAY:
case OvertimeCategory.SATURDAY:
hours.holidayHours = netHours;
hours.holidayDays = netHours > 0 ? 1 : 0;
break;
case OvertimeCategory.NIGHT:
hours.nightHours = netHours;
hours.nightDays = netHours > 0 ? 1 : 0;
break;
case OvertimeCategory.HOLIDAY_NIGHT:
hours.holidayHours = netHours;
hours.holidayDays = netHours > 0 ? 1 : 0;
hours.nightHours = netHours;
hours.nightDays = netHours > 0 ? 1 : 0;
break;
default:
hours.weekdayHours = netHours;
}
return hours;
}- [ ] Step 4: Run — expect PASS
Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/workbook/overtime-category.test.ts Expected: PASS.
- [ ] Step 5: Delegate from
_previewOvertimeAmount— replace lines 787-796 ofcommit-compute.ts:
const cat = row.overtimeCategory as OvertimeCategory | null | undefined;
if (cat === OvertimeCategory.HOLIDAY) {
inputs.holidayHours = netHours;
inputs.holidayDays = netHours > 0 ? 1 : 0;
} else if (cat === OvertimeCategory.NIGHT) {
inputs.nightHours = netHours;
inputs.nightDays = netHours > 0 ? 1 : 0;
} else {
inputs.weekdayHours = netHours;
}with:
const cat = row.overtimeCategory as OvertimeCategory | null | undefined;
Object.assign(inputs, categorizeOvertimeHours(cat, netHours));and add import { categorizeOvertimeHours } from "./overtime-category"; at the top of commit-compute.ts.
- [ ] Step 6: Verify build + full sheet test suite
Run: pnpm --filter @hereby/hr-system build → PASS. Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/ src/sheet/ → PASS (confirms this didn't regress anything else touching commit-compute.ts).
- [ ] Step 7: Commit
git add apps/hr-system/src/sheets/workbook/overtime-category.ts apps/hr-system/src/sheets/workbook/overtime-category.test.ts apps/hr-system/src/sheets/workbook/commit-compute.ts
git commit -m "fix(hr-system): stack holiday+night premiums for HOLIDAY_NIGHT overtime preview, route legacy SATURDAY to holiday rate"Task 10: Education adapter — align to SubsidyStatus, test
Re-verified 2026-07-03: STATUS_LABEL still has the fictional draft key, still not exported, current develop content unchanged from what this task assumes.
Files:
- Modify:
apps/hr-system/src/sheets/education.adapter.ts(export + alignSTATUS_LABEL) - Create:
apps/hr-system/src/sheets/education.adapter.test.ts
Real enum: SubsidyStatus = pending, approved, paid, cancelled. Current map has a fictional draft.
- [ ] Step 1: Write the failing test
import { SubsidyStatus } from "@hereby/core";
import { describe, expect, it } from "vitest";
import { buildEducationAdapter, STATUS_LABEL } from "./education.adapter";
// biome-ignore lint/suspicious/noExplicitAny: sdk unused on the pure render/search paths
const a = buildEducationAdapter({} as any);
// biome-ignore lint/suspicious/noExplicitAny: partial EducationRow fixtures
const row = (o: Record<string, unknown>): any => o;
describe("education adapter — STATUS_LABEL matches SubsidyStatus", () => {
const real = new Set<string>(Object.values(SubsidyStatus));
it("labels every real status", () => {
for (const v of real) {
expect(STATUS_LABEL[v], `missing label for ${v}`).toBeTruthy();
}
});
it("carries no fictional status keys", () => {
for (const k of Object.keys(STATUS_LABEL)) {
expect(real.has(k), `${k} is not a real SubsidyStatus`).toBe(true);
}
});
});
describe("education adapter — renderers (3-arg) + search", () => {
it("badge renders the Korean label and escapes the raw status", () => {
const html = a.cellRenderers?.badge(row({ status: "approved" }), {} as never, 0);
expect(html).toContain("승인");
});
it("check renders a tick only when verified", () => {
expect(a.cellRenderers?.check(row({ receiptVerified: true }), {} as never, 0)).toContain("✓");
expect(
a.cellRenderers?.check(row({ receiptVerified: false }), {} as never, 0),
).not.toContain("✓");
});
it("searchPredicate matches number, name, and course", () => {
expect(a.searchPredicate?.(row({ courseName: "CPR 교육" }), "cpr")).toBe(true);
expect(a.searchPredicate?.(row({ employeeName: "김간호" }), "김")).toBe(true);
});
});- [ ] Step 2: Run — expect FAIL (fictional
draftkey)
Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/education.adapter.test.ts Expected: FAIL on "carries no fictional status keys" (draft).
- [ ] Step 3: Fix the adapter — export the map and drop the fictional key:
export const STATUS_LABEL: Record<string, string> = {
pending: "대기",
approved: "승인",
paid: "지급",
cancelled: "취소",
};- [ ] Step 4: Run — expect PASS, then build.
Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/education.adapter.test.ts → PASS. Run: pnpm --filter @hereby/hr-system build → PASS.
- [ ] Step 5: Commit
git add apps/hr-system/src/sheets/education.adapter.ts apps/hr-system/src/sheets/education.adapter.test.ts
git commit -m "fix(hr-system): align education adapter status labels to SubsidyStatus + test"Task 11: Resignation adapter — align to ResignationStatus, test
Re-verified 2026-07-03: STATUS_LABEL still has all 6 fictional keys, current develop content unchanged from what this task assumes.
Files:
- Modify:
apps/hr-system/src/sheets/resignation.adapter.ts - Create:
apps/hr-system/src/sheets/resignation.adapter.test.ts
Real enum: ResignationStatus = pending, processing, settled, completed. Current map has 6 fictional keys (draft, approved, confirmed, paid, cancelled, corrected).
- [ ] Step 1: Write the failing test (same enum-driven shape, enum swapped)
import { ResignationStatus } from "@hereby/core";
import { describe, expect, it } from "vitest";
import { buildResignationAdapter, STATUS_LABEL } from "./resignation.adapter";
// biome-ignore lint/suspicious/noExplicitAny: sdk unused on pure paths
const a = buildResignationAdapter({} as any);
// biome-ignore lint/suspicious/noExplicitAny: partial ResignationRow fixtures
const row = (o: Record<string, unknown>): any => o;
describe("resignation adapter — STATUS_LABEL matches ResignationStatus", () => {
const real = new Set<string>(Object.values(ResignationStatus));
it("labels every real status", () => {
for (const v of real) {
expect(STATUS_LABEL[v], `missing label for ${v}`).toBeTruthy();
}
});
it("carries no fictional status keys", () => {
for (const k of Object.keys(STATUS_LABEL)) {
expect(real.has(k), `${k} is not a real ResignationStatus`).toBe(true);
}
});
});
describe("resignation adapter — badge + search", () => {
it("badge renders the Korean label", () => {
const html = a.cellRenderers?.badge(row({ status: "completed" }), {} as never, 0);
expect(html).toContain("완료");
});
it("searchPredicate matches number and name", () => {
expect(a.searchPredicate?.(row({ employeeNumber: "E-9" }), "e-9")).toBe(true);
});
});- [ ] Step 2: Run — expect FAIL (fictional keys).
Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/resignation.adapter.test.ts Expected: FAIL on fictional keys.
- [ ] Step 3: Fix the adapter
export const STATUS_LABEL: Record<string, string> = {
pending: "대기",
processing: "처리중",
settled: "정산",
completed: "완료",
};- [ ] Step 4: Run PASS + build PASS + commit
git add apps/hr-system/src/sheets/resignation.adapter.ts apps/hr-system/src/sheets/resignation.adapter.test.ts
git commit -m "fix(hr-system): align resignation adapter status labels to ResignationStatus + test"Task 12: Year-end adapter — align to SettlementStatus, test
Re-verified 2026-07-03: STATUS_LABEL still missing calculated/applied and still carries all 6 fictional keys, current develop content unchanged from what this task assumes. This remains the highest-severity adapter drift (two real, reachable statuses currently render as raw enum text in the Korean UI).
Files:
- Modify:
apps/hr-system/src/sheets/yearend.adapter.ts - Create:
apps/hr-system/src/sheets/yearend.adapter.test.ts
Real enum: SettlementStatus = pending, calculated, approved, applied, completed. Current map is missing calculated and applied and carries fictional draft/confirmed/paid/cancelled/processing/settled.
- [ ] Step 1: Write the failing test
import { SettlementStatus } from "@hereby/core";
import { describe, expect, it } from "vitest";
import { buildYearEndAdapter, STATUS_LABEL } from "./yearend.adapter";
// biome-ignore lint/suspicious/noExplicitAny: sdk unused on pure paths
const a = buildYearEndAdapter({} as any, 2025);
// biome-ignore lint/suspicious/noExplicitAny: partial YearEndRow fixtures
const row = (o: Record<string, unknown>): any => o;
describe("yearend adapter — STATUS_LABEL matches SettlementStatus", () => {
const real = new Set<string>(Object.values(SettlementStatus));
it("labels every real status (regression: 'calculated' + 'applied' were missing)", () => {
for (const v of real) {
expect(STATUS_LABEL[v], `missing label for ${v}`).toBeTruthy();
}
});
it("carries no fictional status keys", () => {
for (const k of Object.keys(STATUS_LABEL)) {
expect(real.has(k), `${k} is not a real SettlementStatus`).toBe(true);
}
});
});
describe("yearend adapter — badge", () => {
it("renders the Korean label for a previously-unlabelled status", () => {
const html = a.cellRenderers?.badge(row({ status: "calculated" }), {} as never, 0);
expect(html).not.toContain("calculated");
});
});[ ] Step 2: Run — expect FAIL (missing
calculated/applied+ fictional keys).[ ] Step 3: Fix the adapter
export const STATUS_LABEL: Record<string, string> = {
pending: "대기",
calculated: "산출",
approved: "승인",
applied: "반영",
completed: "완료",
};- [ ] Step 4: Run PASS + build PASS + commit
git add apps/hr-system/src/sheets/yearend.adapter.ts apps/hr-system/src/sheets/yearend.adapter.test.ts
git commit -m "fix(hr-system): add missing SettlementStatus labels (calculated/applied) to yearend adapter + test"Task 13: Sports adapter — dead code confirmed; flag + pure-logic test
Re-verified 2026-07-03 — reachability is now DEFINITIVE, not conditional. The original plan's Step 1 was a grep-and-decide branch ("if live, fix; if dead, flag"). Re-verification ran that grep against current develop and found:
sportsSheetComponent(built frombuildSportsAdapter) IS registered with Alpine inapps/hr-system/src/main.ts(Alpine.data("_sportsSheet", sportsSheetComponent))...- ...but
rg -n "_sportsSheet" apps/hr-system -g '*.html'returns zero matches — no template anywhere mountsx-data="_sportsSheet"— and no tab-config array inapps/hr-system/srclists"sports"as an active tab id. sdk.sportsIncentiveis confirmed absent fromHerebyClient(packages/sdk/src/client/HerebyClient.tshas no such property;fixedIncentiveis the real registered service). This exact gap is independently corroborated by two pre-existingFIXME(타입 복구 중 발견)comments already inapps/hr-system/src/app.ts(lines 2065, 2275) for the same missing-SDK-method issue in unrelated code.
Conclusion: the sports tab is registered but unreachable from any live view — confirmed dead code. Skip the "fix the SDK binding" branch entirely; do not attempt to repoint loadRows to fixedIncentive, since that would be speculative behavior for a tab nobody can navigate to. Flag it and move on.
Files:
Modify:
apps/hr-system/src/sheets/sports.adapter.ts(addFIXMEcomment only — no functional change)Create:
apps/hr-system/src/sheets/sports.adapter.test.ts[ ] Step 1: Add the dead-code flag
Above loadRows in sports.adapter.ts, add:
// FIXME(타입 복구 중 발견): sports 탭은 Alpine 등록만 있고(_sportsSheet)
// 마운트하는 HTML도, 탭 설정도 없음 — 도달 불가능한 죽은 코드.
// sdk.sportsIncentive도 HerebyClient에 없음(fixedIncentive만 존재,
// app.ts:2065/2275의 FIXME와 동일 문제). PR에서 탭 제거를 제안.
// 제거 결정 전까지 SDK 바인딩은 손대지 않음(존재하지 않는 서비스를
// 추측으로 다른 서비스에 재연결하지 않음).- [ ] Step 2: Write the pure-logic test (reachability-independent — the functions still exist and are worth pinning even for dead code, in case removal is deferred)
import { describe, expect, it } from "vitest";
import { buildSportsAdapter } from "./sports.adapter";
// biome-ignore lint/suspicious/noExplicitAny: sdk unused on pure paths
const a = buildSportsAdapter({} as any, { year: 2025, month: 3 });
// biome-ignore lint/suspicious/noExplicitAny: partial SportsRow fixtures
const row = (o: Record<string, unknown>): any => o;
describe("sports adapter — pure rendering", () => {
it("yearMonth renders zero-padded YYYY/MM", () => {
expect(a.cellRenderers?.yearMonth(row({ year: 2025, month: 3 }), {} as never, 0)).toContain(
"2025/03",
);
});
it("buildDefaultColumns binds amount to the amount field with render 'amount'", () => {
const amountCol = a.buildDefaultColumns().find((c) => c.key === "amount");
expect(amountCol?.field).toBe("amount");
expect(amountCol?.render).toBe("amount");
});
it("searchPredicate matches employee number", () => {
expect(a.searchPredicate?.(row({ employeeNumber: "S-1" }), "s-1")).toBe(true);
});
});- [ ] Step 3: Run PASS + build PASS + commit
git add apps/hr-system/src/sheets/sports.adapter.ts apps/hr-system/src/sheets/sports.adapter.test.ts
git commit -m "chore(hr-system): flag sports adapter as unreachable dead code + pin pure rendering logic"Task 14: PR-A close
- [ ] Step 1: Full verification
Run: pnpm lint:check Expected: PASS (run pnpm lint:fix if it reports fixable issues, then re-run).
Run: pnpm build Expected: PASS.
Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/ src/sheet/ Expected: PASS.
- [ ] Step 2: Push + open PR-A
git push -u origin test/batch2-sheetPR body (template .github/pull_request_template.md): ## Summary (money/adapter unit tests + adapter enum-drift fixes + the HOLIDAY_NIGHT/SATURDAY overtime-preview money fix), ## 요약, ## Test plan (한글 체크리스트). Note in the body: the base-branch correction (develop, not stale batch1); sports tab reachability finding (confirmed dead code, propose removal separately); and that the multi-tenancy leak is handed off separately (see spec Section 8).
Deferred to follow-up plans
- PR-B (Tier 3 interaction + Tier 4 engine):
_detectFillSeries→fill-series.ts,_parseTsvImport,_isCellEditableForRow,_resolveEmployeeForPaste, treatment transposed/matrix;virtualizer,render/formatAmount,selection(extend),engine.moveSelection/fillSelection; subtotal-generation money tests. Separate plan file. (buildColumnsFromLayout— previously listed here — moved into this PR as Task 6;commit-compute.ts's overtime routing — not previously listed anywhere — moved into this PR as Task 9.) - PR-C (Playwright e2e 21–26): payroll confirm/cancel, filter+sort, formula-commit-amount, paste-fill, carry-forward apply, export.
- Security (backend, separate owner): multi-tenancy leak fix + regression — spec Section 8. If the backend's own overtime commit-compute has the same HOLIDAY_NIGHT/SATURDAY category-routing gap as the frontend preview fixed in Task 9, that is a separate backend finding to hand off — not verified or fixed here (backend
packages/apiis out of this batch's scope).
Self-review notes (2026-07-03 revision)
- Every task's file paths and line numbers were re-verified against current
developHEAD (commit6907d610) via parallel independent verification agents plus directrg/sedchecks, not carried over unchecked from the 2026-07-01 draft. Two real drifts were found and fixed (Task 1's base branch; Task 4's date-normalize scope). One real gap was found and closed (Task 6,buildColumnsFromLayoutpromoted from a vague "deferred" bucket into a concrete Tier-1 task). One real, previously-undocumented money bug was found, traced to its actual rate-multiplier config, and given its own fix+test task (Task 9). - Spec coverage: PR-A now covers spec §5 (refactors — with Task 4's scope corrected), §6 Tier 1 (money — filter/date/amount/sort/summary/columns, all six) and Tier 2 (adapters + E1 fixes; overtime rebased onto develop in Task 1). Tier 3/4 + e2e + multi-tenancy tracked under "Deferred". The
commit-compute.tsmoney bug was not in the original spec at all — it surfaced during plan review, not during the original investigation; the spec doc itself does not need edits since Task 9 is a natural extension of the "Fix the adapter bugs found during investigation" goal statement in spec §1, not a scope departure. - Placeholder scan: none — every step has real code or an exact command.
- Type consistency:
normalizeToIsoDate,compareByColumn,summaryValue,categorizeOvertimeHours/OvertimeCategoryHours,STATUS_LABEL/TYPE_LABELexports referenced consistently across tasks;WorkbookRow/WorkbookAdapterHooksare already-exported fromworkbook-adapter.ts. - Biome line-width: fenced code blocks in this document were checked line-by-line against the 100-char limit; the one line that cannot be auto-fixed by the repo's pre-commit
biome check --writehook (abiome-ignorecomment, since formatters don't reflow comments) is written at 98 chars in Task 3. Multi-lineexpect(...)calls that would exceed 100 chars on one line are pre-wrapped in this document; the pre-commit hook will still reformat exact wrapping to Biome's own style on first commit — that is expected and not a plan defect.