Skip to content

Workbook Component Architecture

How the Sheets-tab component (apps/hr-system/src/sheets/) is structured. The component is registered as Alpine.data("_workbook", workbookSheetComponent) in apps/hr-system/src/main.ts and drives the entire spreadsheet UI (sheet list, grid render, cell editing, payroll commit, import/export, …).

Shape

workbook.sheet.ts is a ~70-line composition manifest — it imports the slices and assembles them:

ts
export function workbookSheetComponent() {
  return compose(createStateSlice(), createCoreSlice(), createMemoSlice(), /* … */);
}

All behaviour lives in concern slices under apps/hr-system/src/sheets/workbook/. Each slice is a factory returning a plain object of state + methods + getters for one concern. There is no shared closure scope; slices only ever talk to each other through this.

The three load-bearing pieces

1. compose(...slices)workbook/compose.ts. Merges the freshly-created slice objects into one component instance using Object.getOwnPropertyDescriptors + Object.defineProperties.

⚠️ It must NOT use object spread or Object.assign. The component has ~70 getters; spreading invokes each getter at merge time and copies a frozen value, silently breaking Alpine reactivity (computed columns, filtered sheets, row counts stop updating). Descriptor-merge preserves the live get/set accessors. Guarded by workbook/compose.test.ts and workbook/component.test.ts.

2. WorkbookComponentworkbook/types.ts. The standalone interface every slice types this against, so this.openSheet(), this.engine, etc. type-check across files. It was generated from the original component via the TypeScript compiler API, not derived from the slices.

Do NOT try type WorkbookComponent = typeof slices or ThisType<typeof-self> — it cycles (TS2456 / TS7023). WorkbookComponent is a concrete interface; the build is the safety net (any cross-slice typo is a compile error).

3. Slice factories — workbook/compose.ts workbookSlice(). Each slice is createXSlice = () => workbookSlice({...}). workbookSlice<S>(s: S & ThisType<WorkbookComponent>): S is an identity wrapper that pins this to the full component. Factories (not singletons) give each component instance fresh state.

Adding / changing code

  • Find a method/getter: names are unchanged from the pre-split component — grep -rn '<name>' apps/hr-system/src/sheets/workbook/.
  • Add a member to a slice: add it to the slice object and add its signature to the WorkbookComponent interface in types.ts (else this.<new> won't type-check).
  • New concern: add workbook/<concern>.ts exporting createXSlice, import + add createXSlice() to the compose(...) call in workbook.sheet.ts.
  • State lives together in state.ts (reactive fields); it is not distributed per-slice. Where state lives doesn't affect correctness — it's all on this.

Slices

GroupFiles
Infrastructurecompose.ts, types.ts, state.ts, core.ts (lifecycle: init/destroy, loadSheets, openSheet, renderToBody, setPeriod, core getters)
Sheet list / navsidebar.ts, groups.ts, sheet-crud.ts, templates.ts
Grid interactionselection.ts, clipboard.ts, search.ts, filters-sort.ts, fill-handle.ts, menus.ts, cell-styles.ts
Cell editingcell-edit.ts, cell-edit-autocomplete.ts
Columns / formulascolumns.ts, header-formula.ts
Rows / recordsrows.ts, drafts.ts, bulk-add.ts, carry-forward.ts, memos.ts
Payroll / sourcespayroll.ts, commit-compute.ts, import-export.ts
Domain-specifictreatment.ts, incentive.ts, attendance-summary.ts

Cell editing

onCellDblClick_isCellEditableForRow gate → _resolveCellEditDispatch (enum options + delegation to specialised editors) → _beginEdit, which builds the in-cell editor via one of eight per-kind builders returning { input, mountNode }:

_buildPlainTextEditor, _buildEnumSelectEditor, _buildDateEditor, _buildTimeEditor, _buildTimePeriodEditor, _buildMultiselectEditor, _buildEmployeeSelectEditor, _buildTreatmentCountEditor.

The commit/cancel wiring (blur/Enter/Escape) and _saveCellEdit (the SDK write + engine reload) stay in _beginEdit / cell-edit.ts.


History: this layout came from the 2026-06 refactor that split an 18,010-line workbook.sheet.ts into these slices (PR #360).