Files
headroom/openspec/changes/archive/2026-03-08-capacity-expert-mode/design.md
Santhosh Janardhanan b8262bbcaf docs(openspec): archive completed changes and sync main specs
Archive three completed changes to archive/:
- api-resource-standard (70 tasks, 14 resource classes)
- capacity-expert-mode (68 tasks, expert mode planning grid)
- enhanced-allocation (62 tasks, planning fidelity + reporting)

Sync all delta specs to main specs/:
- api-resource-standard: API response standardization
- capacity-expert-mode: Expert mode toggle, grid, KPIs, batch API
- resource-allocation: Month execution comparison, bulk, untracked
- untracked-allocation: Null team member support
- allocation-indicators: Variance indicators (red/amber/neutral)
- monthly-budget: Explicit project-month planning

All changes verified and tested (157 tests passing).
2026-03-08 19:13:28 -04:00

231 lines
11 KiB
Markdown
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.
## Context
Capacity Planning currently uses a per-member calendar card view: one team member selected at a time, one dropdown per day. This is readable but slow — unsuitable for a daily standup where a manager needs to review and adjust the whole team's availability in under a minute.
The sample spreadsheet (`test-results/Sample-Capacity-Expert.xlsx`) demonstrates the target UX: a dense matrix with members as rows and days as columns, using short tokens (`H`, `O`, `0`, `.5`, `1`) for fast keyboard entry, with live KPI totals at the top.
Expert Mode is additive — the existing calendar, summary, holidays, and PTO tabs remain unchanged.
## Goals / Non-Goals
**Goals:**
- Spreadsheet-style planning grid: all team members × all working days in one view
- Token-based cell input: `H`, `O`, `0`, `.5`, `0.5`, `1` only
- Live KPI bar: Capacity (person-days) and Projected Revenue update as cells change
- Batch save: single Submit commits all pending changes in one API call
- Toggle persisted in `localStorage` so standup users stay in Expert Mode
- Auto-render `0` as `O` on weekend columns, `H` on holiday columns
- Invalid token → red cell on blur, Submit globally disabled
**Non-Goals:**
- Arbitrary decimal availability values (e.g. `0.25`, `0.75`) — v1 stays at `0/0.5/1`
- Scenario planning / draft versioning
- Multi-month grid view
- Import/export to Excel/CSV (deferred to Phase 2)
- Real-time multi-user collaboration / conflict resolution
- Role-based access control for Expert Mode (all authenticated users can use it)
## Decisions
### D1: Token model — display vs. storage
**Decision**: Separate `rawToken` (display) from `numericValue` (math/storage).
```
cell = {
rawToken: "H" | "O" | "0" | ".5" | "0.5" | "1" | <invalid string>,
numericValue: 0 | 0.5 | 1 | null, // null = invalid
dirty: boolean, // changed since last save
valid: boolean
}
```
Normalization table:
| Input | numericValue | Display |
|-------|-------------|---------|
| `H` | `0` | `H` |
| `O` | `0` | `O` |
| `0` | `0` | `0` (or `O`/`H` if weekend/holiday — see D3) |
| `.5` | `0.5` | `0.5` |
| `0.5` | `0.5` | `0.5` |
| `1` | `1` | `1` |
| other | `null` | raw text (red) |
**Rationale**: Keeps math clean, preserves user intent in display, avoids ambiguity in totals.
**Alternative considered**: Store `H`/`O` as strings in DB — rejected because it would require schema changes and break existing capacity math.
---
### D2: Batch API endpoint
**Decision**: Add `POST /api/capacity/availability/batch` accepting an array of availability updates.
```json
// Request
{
"month": "2026-02",
"updates": [
{ "team_member_id": "uuid", "date": "2026-02-03", "availability": 1 },
{ "team_member_id": "uuid", "date": "2026-02-04", "availability": 0.5 }
]
}
// Response 200
{
"data": {
"saved": 12,
"month": "2026-02"
}
}
```
Validation: each `availability` must be in `[0, 0.5, 1]`. Month-level cache flushed once after all upserts (not per-row).
**Rationale**: Single-cell endpoint (`POST /api/capacity/availability`) would require N calls for N dirty cells — too chatty for standup speed. Batch endpoint reduces to 1 call and 1 cache flush.
**Alternative considered**: WebSocket streaming — overkill for v1, deferred.
---
### D3: Auto-render `0` as contextual marker
**Decision**: On blur, if `numericValue === 0` and the column is a weekend → display `O`; if column is a holiday → display `H`; otherwise display `0`.
**Rationale**: Keeps the grid visually scannable (weekends/holidays obvious at a glance) without forcing users to type `O`/`H` manually.
**Alternative considered**: Always show `0` — simpler but loses the visual shorthand that makes standups fast.
---
### D4: localStorage persistence for toggle
**Decision**: Persist Expert Mode preference as `headroom.capacity.expertMode` (boolean) in `localStorage`. Default `false`.
**Rationale**: No backend dependency, instant, consistent with existing sidebar/layout persistence pattern in the app. Multi-device sync is not a requirement for v1.
---
### D5: Toggle placement
**Decision**: Expert Mode toggle (label + switch) sits right-aligned on the same row as the Calendar/Summary/Holidays/PTO tabs.
```
[Calendar] [Summary] [Holidays] [PTO] Expert mode [OFF●ON]
```
**Rationale**: Tabs are content sections; Expert Mode is a view behavior. Placing it as a tab would imply it's a separate section rather than a mode overlay. Right-aligned toggle is a common pattern (e.g. "Compact view", "Dark mode") that communicates "this changes how you see things, not what you see."
---
### D6: Submit gating
**Decision**: The Submit button is disabled if:
1. Any cell has `valid === false`, OR
2. No cells are `dirty` (nothing to save)
On submit: collect all dirty+valid cells, POST to batch endpoint, clear dirty flags on success, show toast.
**Rationale**: Prevents partial saves with invalid data. Dirty check avoids unnecessary API calls.
---
### D7: Grid data loading
**Decision**: Expert Mode loads all team members' individual capacity in parallel on mount (one request per member). Results are merged into a single grid state object keyed by `{memberId, date}`.
**Rationale**: Existing `GET /api/capacity?month=&team_member_id=` endpoint already returns per-member details. Reusing it avoids a new read endpoint. Parallel fetching keeps load time acceptable for typical team sizes (820 members).
**Alternative considered**: New `GET /api/capacity/team/details` returning all members in one call — valid future optimization, deferred.
---
### D8: Timezone normalization for weekend/holiday detection
**Decision**: All date calculations for weekend/holiday detection MUST use Eastern Time (America/New_York) as the canonical timezone. Both frontend and backend must use the same timezone normalization to ensure consistent cell styling and prefill behavior.
**Implementation**:
- Frontend: Use `new Date(dateStr + 'T00:00:00')` or parse date components manually to avoid local timezone drift
- Backend: Use Carbon with `timezone('America/New_York')` for all weekend/holiday checks
- Future: Make timezone configurable per-team or per-user (deferred to v2)
**Rationale**: Browser `new Date('YYYY-MM-DD')` interprets the date in local timezone, causing weekend columns to shift by one day for users in timezones behind UTC (e.g., a Saturday showing as Friday in Pacific Time). Eastern Time provides consistent business-day alignment for US-based teams.
**Example bug fixed**: April 2026 — 4th and 5th are Saturday/Sunday. Without timezone normalization, users in UTC-6 or later saw weekend shading on 5th and 6th instead.
---
### D9: Accessibility-enhanced weekend/holiday styling
**Decision**: Weekend and holiday cells must use high-contrast visual treatments that work for colorblind users:
| Cell Type | Background | Border | Additional Indicator |
|-----------|------------|--------|---------------------|
| Weekend | `bg-base-300` (solid) | `border-base-400` | — |
| Holiday | `bg-warning/40` | `border-warning` | Bold `H` marker in cell |
**Rationale**: Previous implementation used subtle opacity (`bg-base-200/30`, `bg-warning/10`) which was nearly invisible for colorblind users. Solid backgrounds with borders provide sufficient contrast without relying solely on color differentiation.
---
### D10: Prefill weekends with `O`, holidays with `H`
**Decision**: When loading a month in Expert Mode, cells for weekend days must be prefilled with `O` (Off) and cells for holidays must be prefilled with `H`. This applies to:
1. Initial grid load (no existing availability data)
2. Days that would otherwise default to `1` (full availability)
**Frontend behavior**:
```typescript
function getDefaultValue(date: string, isWeekend: boolean, isHoliday: boolean): NormalizedToken {
if (isHoliday) return { rawToken: 'H', numericValue: 0, valid: true };
if (isWeekend) return { rawToken: 'O', numericValue: 0, valid: true };
return { rawToken: '1', numericValue: 1, valid: true };
}
```
**Backend behavior**: When seeding availability for a new month, the backend must also use this logic to ensure consistency. Frontend and backend MUST stay in sync.
**Rationale**: Users expect weekends and holidays to be "off" by default. Prefilling communicates this immediately and reduces manual entry. Synchronizing frontend/backend prevents confusion where one shows defaults the other doesn't recognize.
---
### D11: Frontend/Backend sync when seeding months
**Decision**: Any logic that determines default availability values (weekend = O, holiday = H, weekday = 1) must be implemented identically in both frontend and backend. Changes to this logic require updating both sides simultaneously.
**Enforcement**:
- Shared documentation of the prefill rules (this design.md)
- Unit tests on both sides that verify the same inputs produce the same outputs
- Consider extracting to a shared configuration file or API endpoint in v2
**Rationale**: Divergent defaults cause confusing UX where the grid shows one thing but the backend stores another, or where refresh changes values unexpectedly.
---
## Risks / Trade-offs
| Risk | Mitigation |
|------|-----------|
| Large teams (50+ members) make grid slow to load | Parallel fetch + loading skeleton; virtual scrolling deferred to v2 |
| Cache invalidation storm on batch save | `CapacityService::batchUpsertAvailability()` flushes month-level cache once after all upserts, not per-row |
| User switches mode with unsaved dirty cells | Warn dialog: "You have unsaved changes. Discard?" before switching away |
| Two users editing same month simultaneously | Last-write-wins (acceptable for v1; no concurrent editing requirement) |
| Wide grid overflows on small screens | Horizontal scroll on grid container; toggle hidden on mobile (< md breakpoint) |
| `.5` normalization confusion | Normalize on blur, not on keystroke — user sees `.5` become `0.5` only after leaving cell |
## Migration Plan
- No database migrations required.
- No breaking API changes — new batch endpoint is additive.
- Feature flag: Expert Mode toggle defaults to `false`; users opt in.
- Rollback: remove toggle + grid component; existing calendar mode unaffected.
## Open Questions
- *(Resolved)* Token set: `H`, `O`, `0`, `.5`, `0.5`, `1` only.
- *(Resolved)* `H` and `O` are interchangeable (both = `0`).
- *(Resolved)* `0` on weekend auto-renders `O`; on holiday auto-renders `H`.
- *(Resolved)* Persist toggle in `localStorage`.
- Should Expert Mode be accessible to all roles, or only Superuser/Manager? → **v1: all authenticated users** (RBAC deferred).
- Should the KPI bar show per-member revenue breakdown inline, or only team totals? → **v1: team totals only** (matches sample sheet header).