update todo
This commit is contained in:
@@ -1,116 +0,0 @@
|
||||
# Align the year-row timelines in the Overview tab
|
||||
|
||||
## Context
|
||||
|
||||
In the Dashboard Overview tab each year is a `<details class="disclosure year-row">` whose
|
||||
`<summary>` is its **own** grid: `grid-template-columns: 3.5rem minmax(0, 1fr) auto var(--icon-sm)`
|
||||
(`frontend/src/styles/components.css:225`). Because every row is an independent grid, the `auto`
|
||||
figures column is sized per row by that row's own text — "· expiring 5", a wider `saved` number,
|
||||
a longer parent name, a 3-digit vs 2-digit PL value. The `minmax(0, 1fr)` track column absorbs the
|
||||
difference, so the timeline bars end at a different x per year (see `timeline-offset.png`: 2029's
|
||||
track is clearly shortest, 2025 the longest). The figures columns are ragged for the same reason.
|
||||
|
||||
Goal: every year's timeline track starts and ends at the same x, and the figure columns line up
|
||||
down the list — without hardcoding a pixel width that breaks in Swedish (sv strings are longer than
|
||||
en) or when a number gains a digit.
|
||||
|
||||
## Approach — one shared grid via `subgrid`
|
||||
|
||||
Promote the column definition from each summary to the `.year-rows` list and have the rows opt into
|
||||
it with `grid-template-columns: subgrid`. Tracks are then sized once, by the widest content in the
|
||||
whole list, and every row inherits identical column widths. Content still sizes the columns, so it
|
||||
stays bilingual-safe.
|
||||
|
||||
Split the figures cell into its three real columns (name | PL left | vacation group) at the list
|
||||
level too, so the numbers align vertically across years as well.
|
||||
|
||||
**CSS only — no TSX changes.** The existing markup already has the right elements
|
||||
(`.year-rows` → `.year-row` (`details`) → `.year-row__summary` / `.year-row__body`,
|
||||
`.year-row__figures` → `.year-row__parent { display: contents }`).
|
||||
|
||||
### Files
|
||||
|
||||
- `frontend/src/styles/components.css` — `.year-rows` (162), `.year-row__summary` (225),
|
||||
`.year-row__summary::after` (238), `.year-row__year` (244), `.year-row__track` (252),
|
||||
`.year-row__figures` (289), `.year-row__body` (322)
|
||||
- `frontend/src/styles/responsive.css` — mobile block at 93–120 (must reset subgrid)
|
||||
|
||||
### Shape
|
||||
|
||||
`.year-rows` becomes the grid owner:
|
||||
|
||||
```css
|
||||
.year-rows {
|
||||
display: grid;
|
||||
/* year | track | name | PL left | vacation group | chevron */
|
||||
grid-template-columns: 3.5rem minmax(0, 1fr) auto auto auto var(--icon-sm);
|
||||
}
|
||||
```
|
||||
|
||||
Each row spans it and re-exposes it:
|
||||
|
||||
```css
|
||||
.year-row { /* the <details> */
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
}
|
||||
.year-row__summary {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
/* keep: align-items, column-gap, row-gap, padding, font-weight */
|
||||
}
|
||||
.year-row__body { grid-column: 1 / -1; }
|
||||
```
|
||||
|
||||
Column placement inside the summary shifts to match the six tracks: year `1`, track `2`,
|
||||
`.year-row__figures` `grid-column: 3 / 6` + `grid-template-columns: subgrid` (keep
|
||||
`display: grid`, `align-items: baseline`, `white-space: nowrap`; drop its own
|
||||
`auto auto auto`), chevron `::after` `grid-column: 6`. `.year-row__parent { display: contents }`
|
||||
stays — each parent's three spans land in the three shared columns.
|
||||
|
||||
Notes:
|
||||
- `--sp-4` column-gap must be declared on `.year-rows` as well, since a subgrid inherits the
|
||||
parent's gaps unless it sets its own; keep the summary's `column-gap` so the values agree.
|
||||
- `.year-rows > .disclosure:last-child { border-bottom: none }` and the `.disclosure` hairlines
|
||||
in `primitives.css` are unaffected (borders on the `details` still paint).
|
||||
- In the solo case `.year-row__name` isn't rendered; that column collapses to `0` for every row —
|
||||
still uniform.
|
||||
- Left-over `grid-row: 1` declarations on year/track/figures stay valid.
|
||||
|
||||
### Fallback
|
||||
|
||||
Wrap the promotion in a support check so older engines keep today's (merely ragged) layout rather
|
||||
than collapsing:
|
||||
|
||||
```css
|
||||
@supports (grid-template-columns: subgrid) { /* the rules above */ }
|
||||
```
|
||||
|
||||
Keep the current `.year-row__summary` rule as the base; the `@supports` block overrides it.
|
||||
|
||||
### Mobile
|
||||
|
||||
`responsive.css` already re-lays the summary as `3rem minmax(0, 1fr) var(--icon-sm)` with the
|
||||
figures wrapped to row 2 as a flex column. Inside that media query, reset the new rules:
|
||||
`.year-rows { display: flex; flex-direction: column }` and
|
||||
`.year-row { display: block }` — so the mobile block's existing declarations apply unchanged.
|
||||
Place these resets after the `@supports` block in source order, or repeat them inside their own
|
||||
`@supports` guard, so specificity/order lets mobile win.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `cd frontend && npm run dev`, open the Dashboard Overview tab with the demo workspace
|
||||
(`demo@example.com`) — delegate the browser pass to a Sonnet subagent (never drive Chrome from
|
||||
the coordinator).
|
||||
2. Check: every year's track left and right edge align to the pixel, including 2029 (shortest
|
||||
figures) vs 2025 (no expiring) vs a row with `expiring`; the "PL left" and "Vacation left"
|
||||
labels form straight columns.
|
||||
3. Repeat in **both** Swedish and English (longer sv strings are the real stress test), and in a
|
||||
solo workspace (no parent names) as well as a couple workspace.
|
||||
4. Narrow to the mobile breakpoint and confirm figures still wrap under the track, one parent per
|
||||
line (use the same-origin iframe trick — `resize_window` is a no-op in this setup).
|
||||
5. Gates (delegate to a Haiku subagent, raw output): from `frontend/` `npm run typecheck`,
|
||||
`npm run lint`, `npm run coverage`. CSS-only change — `YearRows.test.tsx` and `Dashboard.test.tsx`
|
||||
should be untouched and green.
|
||||
@@ -1,69 +0,0 @@
|
||||
# Fix backend deprecation warnings
|
||||
|
||||
## Context
|
||||
|
||||
`uv run pytest` on `backend/` emits 26 `DeprecationWarning`s. They are noise in CI output and two of the four sources are real forward-compat debt in our own code — the Starlette 422 constant is already renamed upstream, and Alembic's `version_path_separator` is superseded. Goal: our code emits zero deprecation warnings.
|
||||
|
||||
Confirmed inventory (Python 3.14.0, fastapi 0.136.3, starlette 1.2.1, alembic 1.18.4, slowapi 0.1.9):
|
||||
|
||||
| Source | Count | Ours? |
|
||||
|---|---|---|
|
||||
| `HTTP_422_UNPROCESSABLE_ENTITY` → use `HTTP_422_UNPROCESSABLE_CONTENT` | 8 | yes — 20 call sites |
|
||||
| `alembic/config.py:612` — `No path_separator found in configuration` | 12 | yes — `backend/alembic.ini` |
|
||||
| `slowapi/extension.py:717` — `asyncio.iscoroutinefunction` | 6 | no — upstream, out of scope |
|
||||
|
||||
Decision made: fix our code only. No `filterwarnings` config is added; the slowapi warning stays visible as an upstream reminder.
|
||||
|
||||
**Baseline:** 4 tests already fail on `main` (`test_migration_004_backfill.py::test_flexible_yearly_wish_keeps_the_same_bounding_range_every_year`, `test_leave_wish_repository.py::…test_flexible_series_keeps_one_bounding_range_per_year_unshifted`, two in `test_allocation_service.py`). These come from the uncommitted in-flight work in `app/entities/leave_wish.py` / `app/services/allocation_service.py` and are **unrelated** to this change. Expected result after the change: still exactly those 4, no more.
|
||||
|
||||
## Change 1 — rename the 422 constant (20 sites, 15 files)
|
||||
|
||||
Mechanical rename, same numeric value (422), so no wire-format or frontend impact:
|
||||
|
||||
```
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY → status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||
```
|
||||
|
||||
All sites are in `backend/app/routers/`. Representative files: `workspace.py` (3 sites, lines 51/94/223 — inside conditional expressions, not plain raises), `settings.py` (3), `fetch_data.py` (2), `leave_wishes.py` (2), `plan.py` (2), `preschool_planning_days.py` (2), plus one each in `auth.py`, `calendar.py`, `day_overrides.py`, `planned_day_exclusions.py`, `planned_days.py`, `vacation_days.py`.
|
||||
|
||||
Notes:
|
||||
- `status` is already imported from `fastapi` in every one of these files — no import changes.
|
||||
- `status.HTTP_422_UNPROCESSABLE_CONTENT` is verified present in the installed starlette (evaluates to `422`).
|
||||
- No test file references the constant by name (`git grep UNPROCESSABLE -- tests` is empty), so tests need no update.
|
||||
- The warnings attributed to `fastapi/routing.py:328` are our handler frames raising through the dependency call — they disappear with this rename too.
|
||||
|
||||
## Change 2 — Alembic `path_separator`
|
||||
|
||||
`backend/alembic.ini` currently has:
|
||||
|
||||
```ini
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
```
|
||||
|
||||
Replace `version_path_separator = os` with `path_separator = os`. In Alembic ≥1.16 `path_separator` governs both `prepend_sys_path` and `version_locations`, and its absence is what triggers the legacy-splitting warning. Leave `prepend_sys_path = .` and the annotated `sqlalchemy.url` fallback untouched.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- slowapi's `asyncio.iscoroutinefunction` warning — upstream, no released fix.
|
||||
- `typing.Dict` in `app/utils/holidays.py` and `datetime.timezone.utc` in `app/services/auth_service.py` — older spellings, but not deprecated and not warning sources.
|
||||
- The 4 pre-existing test failures.
|
||||
|
||||
## Verification
|
||||
|
||||
Delegate to a **Haiku** subagent (per the cost rule); require raw verbatim output, from `backend/`:
|
||||
|
||||
```bash
|
||||
uv run ruff check .
|
||||
uv run ruff format --check .
|
||||
uv run pytest
|
||||
uv run alembic upgrade head # exercises the alembic.ini change directly
|
||||
```
|
||||
|
||||
Acceptance criteria:
|
||||
1. The warnings summary contains **only** the 6 `slowapi/extension.py:717` lines — no `UNPROCESSABLE_ENTITY`, no `path_separator`, count down from 26 to 6.
|
||||
2. Failure list is exactly the 4 baseline failures above; pass count unchanged at 860.
|
||||
3. Coverage still ≥ the `--cov-fail-under=80` gate (no gate changes).
|
||||
4. `alembic upgrade head` completes without the `path_separator` warning.
|
||||
|
||||
Then `graphify update .`.
|
||||
@@ -1,154 +0,0 @@
|
||||
# Fix number-input clearing UX + drop the redundant preschool estimate field
|
||||
|
||||
## Context
|
||||
|
||||
Two problems in Settings, both reported by the user:
|
||||
|
||||
1. **Clearing a number input traps you at `0`.** The per-year "Planned days" input
|
||||
(`Settings.tsx:186-192`) keeps its draft as a `number`, and `Number('') === 0`, so
|
||||
blanking the box writes a literal `0` to the server and snaps the display to `0`.
|
||||
Typing `5` then produces `05`. The same hazard exists in `VacationDaysTable` and
|
||||
`PeriodPopover`. `VacationDaysInput` has the mirror bug: it silently rejects a
|
||||
non-numeric value, so the field can't be blanked to retype at all.
|
||||
|
||||
2. **Two preschool controls look duplicated.** "Preschool planning days per year"
|
||||
(the child-level estimate, `Child.preschoolPlanningDaysPerYear`) sits directly above
|
||||
the "Planning days per year" heading for the per-year rows. They are not duplicates —
|
||||
the estimate seeds each year row and is the allocator's fallback for years with no
|
||||
saved row (`backend/app/services/allocation_service.py:545-573`, `:2985-3001`) — but
|
||||
the UI gives no hint of that, so it reads as dead weight.
|
||||
|
||||
**Decisions taken:** remove the estimate *input* from the UI while keeping the backend
|
||||
field (default `6`) as the seed/fallback; extract one shared number input and roll it out
|
||||
to every numeric field that still has the `Number('') === 0` hazard.
|
||||
|
||||
**Out of scope (flagged, not fixed):** `backend/app/services/tips_service.py:579` passes
|
||||
the *global* `settings.rules.preschool_planning_days_per_year` into
|
||||
`preschool_buffer_needed` instead of the child's own value, so a customised per-child
|
||||
value is ignored by that tip. Separate bug, separate change.
|
||||
|
||||
## 1. New shared component: `frontend/src/components/inputs/NumberField.tsx`
|
||||
|
||||
Lift the working pattern already used by the "Days taken" input (`Settings.tsx:199-217`).
|
||||
|
||||
```tsx
|
||||
export interface NumberFieldProps {
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
label?: string
|
||||
min?: number
|
||||
max?: number
|
||||
id?: string
|
||||
className?: string
|
||||
inputClassName?: string
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
'data-testid'?: string
|
||||
'aria-invalid'?: boolean
|
||||
'aria-describedby'?: string
|
||||
}
|
||||
```
|
||||
|
||||
Behaviour contract:
|
||||
|
||||
- Holds an internal **string** draft. `''` is a legal transient state.
|
||||
- `onChange` fires only when the draft parses to a finite number — **never on empty**, so
|
||||
the previously saved value survives while the box is blank.
|
||||
- No clamping while typing (clamping mid-keystroke is what makes small `max` values
|
||||
unusable). Clamp to `[min, max]` on blur only, and commit the clamped value.
|
||||
- On blur with an empty or unparseable draft: revert the draft to the current `value`
|
||||
prop; do not call `onChange`.
|
||||
- Sync the draft from the `value` prop only while the input is **not focused** (a ref
|
||||
flag set on focus/blur), so a debounced server round-trip can't overwrite live typing.
|
||||
- Renders `type="number" inputMode="numeric"` with `min`/`max` attributes and the existing
|
||||
`field` / `field__control` BEM classes; `label` renders as `field__label` when given.
|
||||
No hardcoded `id`/`data-testid` — both are props (the bug that makes `VacationDaysInput`
|
||||
non-reuse-safe today).
|
||||
|
||||
Co-located test `NumberField.test.tsx` covering: clear-then-retype yields `5` not `05`;
|
||||
empty draft fires no `onChange`; blur-empty restores the prior value; blur clamps to
|
||||
`min`/`max`; external `value` change while focused does not clobber the draft.
|
||||
|
||||
## 2. Remove the child-level estimate input
|
||||
|
||||
`frontend/src/pages/Settings.tsx`
|
||||
|
||||
- Delete the `VacationDaysInput` block (`:562-571`) and its import (`:21`).
|
||||
- Delete the `preschoolPlanningDays` state (`:252`), its prefill in the `childDetails`
|
||||
effect (`:285`), and `preschoolPlanningDaysPerYear` from the `updateChildDetails` call in
|
||||
`handleSaveFamily` (`:366`). The field is optional in `ChildUpdate`
|
||||
(`backend/app/models/child.py:40`), so omitting it leaves the stored value untouched.
|
||||
- Pass `defaultPlannedDays={childDetails?.preschoolPlanningDaysPerYear ?? 6}` to
|
||||
`PreschoolPlanningYearRow` (`:583`) instead of the removed state.
|
||||
- Move the `settings.preschoolHelp` paragraph (`:563`) into the per-year block, under the
|
||||
heading, and drop the now-redundant `settings.preschoolPerYearHelp` line or merge the two
|
||||
into one sentence.
|
||||
- Retitle the section so it names the subject: `settings.preschoolPerYearTitle` →
|
||||
`"Preschool planning days"` / `"Förskolans planeringsdagar"`.
|
||||
|
||||
`frontend/src/i18n/{en,sv}/settings.ts` — remove `settings.preschoolPlanningDaysLabel`
|
||||
(`:83` in both), reword `settings.preschoolHelp` (`:82`) so it reads as the intro to the
|
||||
year rows, and update `settings.preschoolPerYearTitle` (`:126`).
|
||||
|
||||
Delete `frontend/src/components/inputs/VacationDaysInput.tsx` and
|
||||
`VacationDaysInput.test.tsx` — `Settings.tsx:564` was the only usage.
|
||||
|
||||
## 3. Roll `NumberField` out
|
||||
|
||||
| File | Inputs |
|
||||
|---|---|
|
||||
| `frontend/src/pages/Settings.tsx:181-195` | per-year **Planned days** (the reported bug) |
|
||||
| `frontend/src/pages/Settings.tsx:199-217` | per-year **Days taken** — keep its `null` = "not recorded" sentinel; it needs `value: number \| null` handling, so either give `NumberField` an optional `nullable` mode or leave this one as-is and note why |
|
||||
| `frontend/src/components/vacation-days/VacationDaysTable.tsx:174-183, 188-197` | `totalDays`, `savedDaysIn` |
|
||||
| `frontend/src/components/calendar/PeriodPopover.tsx:458-470` | `lastYear` (min 2000 / max 2200 — blanking currently shows `0`) |
|
||||
|
||||
`frontend/src/components/modals/ParentBalanceForm.tsx` already handles empty correctly via
|
||||
`parseField` (`:134`, `value={x ?? ''}`) — **leave it alone**, or migrate it only if
|
||||
`NumberField` gains the nullable mode above. Do not regress its `null` semantics.
|
||||
|
||||
Existing `data-testid`s (`preschool-planned-{year}`, `vacation-total-days-{year}`,
|
||||
`period-last-year-input`, …) must be preserved so current tests keep targeting the same
|
||||
elements.
|
||||
|
||||
## 4. Tests to update
|
||||
|
||||
- `frontend/src/pages/Settings.test.tsx:416-437` — drop the `vacation-days-field` prefill
|
||||
and the "change to 10 → `updateChildDetails({ preschoolPlanningDaysPerYear: 10 })`"
|
||||
assertions; assert instead that the family save no longer sends that key and that
|
||||
year rows still prefill from `childDetails.preschoolPlanningDaysPerYear`.
|
||||
- `frontend/src/pages/Settings.test.tsx:459-500` — add a clear-then-retype case on
|
||||
`preschool-planned-{year}` asserting the committed value is `5`, and that clearing alone
|
||||
does not call `setPlanned`.
|
||||
- `frontend/src/hooks/useChildSettings.test.ts:100-113` — unchanged (the hook still
|
||||
supports the field); no edit expected, verify it still passes.
|
||||
- Backend tests unchanged: no model, service, entity or migration touched.
|
||||
|
||||
Mock only with the typed factories in `frontend/tests/fixtures/` and `vi.mocked(fn)` —
|
||||
no `as any`, no casts (CLAUDE.md anti-patterns).
|
||||
|
||||
## Verification
|
||||
|
||||
Delegate the full suites to a **Haiku** subagent, one at a time (never in parallel), with
|
||||
raw verbatim output required:
|
||||
|
||||
```bash
|
||||
# from frontend/
|
||||
npm run typecheck
|
||||
npm run lint # zero warnings
|
||||
npm run coverage # gates: lines 96 | functions 91 | branches 92 | statements 96
|
||||
```
|
||||
|
||||
Backend is untouched, so a backend run is only needed if something unexpected changes there.
|
||||
|
||||
Then a **Sonnet** subagent drives Chrome (never the coordinator) against `npm run dev`:
|
||||
|
||||
1. Settings → Family panel: confirm the standalone "Preschool planning days per year"
|
||||
input is gone and the year rows still show the seeded value with the "assumed" badge.
|
||||
2. In a **Planned days** box: select-all, delete → box goes empty (not `0`); type `5` →
|
||||
shows `5`, not `05`; blur → autosaves `5`.
|
||||
3. Blank a box and click away without typing → the previous value returns, nothing saved.
|
||||
4. Repeat 2-3 on a `VacationDaysTable` row and on the period `lastYear` input.
|
||||
5. Check the reworded section in **both Swedish and English** — the labels must not clip
|
||||
(per the bilingual-CSS rule).
|
||||
|
||||
Finish with `graphify update .`.
|
||||
@@ -1,102 +0,0 @@
|
||||
# Free days, vacation forfeiture, and auto-replanning
|
||||
|
||||
## Context
|
||||
|
||||
Three feedback points from `todo.md` (human-owned file — do not edit it; item 1 there, the yearly-repeat bug, is owned by another worker and is out of scope here):
|
||||
|
||||
1. **A full-week selection must not burn days on Sat/Sun/red days.** You can never take *vacation* on a free day; you *can* take PL on one (more money, fewer days banked) — that must be an opt-in setting defaulting to off.
|
||||
2. **Vacation days at risk of being forfeited should push the planner toward spending vacation** instead of PL days.
|
||||
3. **Every event that changes the days off must trigger a re-plan** of the day *types*, not just a refetch.
|
||||
|
||||
Exploration shows the backend already implements most of 1 and 2. The real gaps are: the hand-pin path bypasses the free-day invariant entirely, recalculation only fires from the Calendar page for some events, and vacation forfeiture is invisible in the UI.
|
||||
|
||||
### What already exists (reuse, do not rebuild)
|
||||
|
||||
| Piece | Where | State |
|
||||
|---|---|---|
|
||||
| `take_pl_on_red_days` workspace column + migration `003` + `PUT /api/workspace/:id` | `backend/app/entities/workspace.py`, `app/services/workspace_service.py` | done, defaults `False` |
|
||||
| Settings toggle + sv/en strings | `frontend/src/pages/Settings.tsx:496`, `src/i18n/{sv,en}/settings.ts:108-110` | done |
|
||||
| Allocator free-day handling (free day ⇒ `'free'`, or FP via `REASON_FP_RED_DAY` only when the flag is on; vacation **never**) | `allocation_service.py` `_fund_solo:1271`, `_fund_both:~1518` | done, tested at `tests/unit/test_services/test_allocation_service.py:2616+` |
|
||||
| `is_free_day(d, holidays_map, overrides)` — the single definition | `allocation_service.py:470` | reuse everywhere |
|
||||
| `vacation_forfeiting` bias in `_assign_tier3` | `allocation_service.py:1171-1225`, fed from `AnalyticsService._vacation_year_series` at `:2935` | done |
|
||||
| `VacationYearBalance.expiring / expiring_age / expiring_over_cap` on the wire | `analytics_service.py:47`, `frontend/src/types/entities.ts:251` | computed, **never rendered** |
|
||||
| `POST /api/plan/sync` (full re-plan + persist + diff) | `planning_service.py:662`, `usePlanSync`, `Calendar.tsx` `runSync:365` | done |
|
||||
| `WorkspaceContext.planVersion` / `bumpPlanVersion()` cross-instance invalidation | `frontend/src/context/WorkspaceContext.tsx:25-36,179` | done; bumped only by `usePlannedDays`, `useDayOverrides`, `usePlannedDayExclusions` |
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — Free days must never consume vacation (hand-pin path)
|
||||
|
||||
Selecting a range that spans Sat/Sun/red days is fine and stays fine. The planner already skips those days correctly. The hole is the **"advanced" day popover**: `Calendar.tsx` `handleSavePinnedDay:571` writes one `planned_leave_day` row per selected date, free days included, and `PlanningService.add_planned_day:241` accepts `day_type='vacation'` on a Saturday without complaint.
|
||||
|
||||
**Backend (authoritative guard).** In `backend/app/services/planning_service.py`:
|
||||
- Add optional `workspace_repo` + `day_override_repo` constructor deps, following the existing optional-dependency pattern of `_engine` / `_leave_wish_repo` (docstring the reason; keep `scripts/seed_demo.py` and test fixtures working unchanged when unset).
|
||||
- New private helper `_reject_illegal_free_day(workspace_id, date, day_type)` used by both `add_planned_day` and `update`: build the holiday map + `DayOverrideService.get_effective_map(workspace_id)`, call `is_free_day` (import from `allocation_service`, do not re-derive), then raise `ValidationError` when
|
||||
- `day_type == 'vacation'` and the day is free (never legal), or
|
||||
- `day_type in ('sjukpenning','lagsta')` and the day is free and `workspace.take_pl_on_red_days` is `False`.
|
||||
- Skip the check entirely when the deps are unset (`None`), so behaviour is byte-identical for callers that never wire them; `app/dependencies.py` wires the real ones.
|
||||
- Error goes through `app/utils/errors.py`, message keyed so the frontend can translate.
|
||||
|
||||
**Frontend.** In `Calendar.tsx` `handleSavePinnedDay`, filter the write loop by the day's `isFree` from `calendarDaysByDate` (already loaded, `CalendarDayEntry.isFree`):
|
||||
- `vacation` → skip free days always.
|
||||
- `sjukpenning`/`lagsta` → skip free days unless `workspace.takePlOnRedDays`.
|
||||
- If every selected date was skipped, show the existing modal error rather than silently doing nothing.
|
||||
- `PlannedDayModal` shows a one-line note when the selection contains free days that will be left as days off, with a pointer to the setting. New sv/en keys in `src/i18n/{sv,en}/calendar.ts`.
|
||||
|
||||
**Tests.** Backend: `add_planned_day`/`update` reject vacation on a Saturday and on a red day; reject PL on a free day with the flag off; accept PL on a free day with it on. Frontend: pinning a Mon–Sun range with `vacation` creates 5 rows, not 7; with `takePlOnRedDays: true` and `sjukpenning`, creates 7.
|
||||
|
||||
---
|
||||
|
||||
## Task 2 — Surface vacation days at risk
|
||||
|
||||
The numbers are already on the wire and unused.
|
||||
|
||||
- `frontend/src/components/vacation-days/VacationDaysTable.tsx` — add an "at risk" column rendering `VacationYearBalance.expiring`, with a danger style when `> 0` and a tooltip/help line splitting `expiringAge` (too old to carry) from `expiringOverCap` (above the carry-forward ceiling). Follow `Age4Bars.tsx` / `KeyFigures.tsx` for the existing danger-ink pattern; size text by content per the bilingual CSS rule.
|
||||
- `frontend/src/components/dashboard/KeyFigures.tsx` — a tile mirroring the existing `dashboard-figure-expiring` for vacation, so the reason the plan shifted toward vacation days is visible.
|
||||
- `PlanWarning` kind `'vacation_expiring'` already exists; confirm it renders where `days_lost_at_age_4` does and add the missing sv/en strings if not.
|
||||
- Tests: table shows the column and the danger state; zero-expiring renders the neutral state.
|
||||
|
||||
No backend change — `_vacation_year_series` and the `_assign_tier3` bias stay as they are.
|
||||
|
||||
---
|
||||
|
||||
## Task 3 — Re-plan on every plan-input change (global)
|
||||
|
||||
Today `runSync` is Calendar-only and fires once per workspace on mount plus after wish/preset/exclusion edits. Pinned-day saves and deletes, and every Settings change, do not re-plan.
|
||||
|
||||
**Move the trigger into the workspace layer** so it is route-independent:
|
||||
|
||||
- New `frontend/src/context/PlanSyncProvider.tsx` (mounted inside `WorkspaceProvider`, or folded into it — implementer's call, keep `WorkspaceContext`'s surface stable): a `useEffect` on `[workspaceId, planVersion]` that calls `api.syncPlan(workspaceId)` **silently**, guarded by an in-flight ref so overlapping bumps coalesce into one trailing run, and by a "skip the very first render if Calendar is about to sync anyway" check is *not* needed — a duplicate silent sync is idempotent and cheap, but the in-flight guard must still prevent a stampede.
|
||||
- Cycle safety: `bumpPlanVersion` is never called from a fetch effect (`usePlannedDays.ts:84-93` documents this), and `syncPlan` does not bump. Verified no loop.
|
||||
- On an `eviction_preview` coming back (nothing written, needs a user choice), the silent path must **not** discard it — surface it through context so Calendar can prompt on next visit, or at minimum leave the plan untouched and log through `src/services/errors.ts`. Never swallow it into an empty catch.
|
||||
- **Add the missing bumps** so every plan input participates. `useVacationDays.upsert`, `usePreschoolPlanningDays` (after its 800 ms debounce settles), `useChildSettings.update`, `api.updateParentSettings`' caller in `Settings.tsx`, and `handleToggleTakePlOnRedDays` all call `bumpPlanVersion()` on success — they currently do not. `usePlannedDays`, `useDayOverrides`, `usePlannedDayExclusions` already do.
|
||||
- **Calendar keeps its explicit `runSync` calls** for the paths that need a toast + undo (wish create/edit/delete, presets, exclusions). Those write via `useLeaveWishes`, which does *not* bump, so there is no double sync. Replace the `didAutoSyncRef` mount-once effect with reliance on the provider, or keep it — it becomes redundant but harmless; prefer removing it to keep one trigger.
|
||||
- `handleSavePinnedDay` / `handleDeletePlannedDay` need no new call: they write through `usePlannedDays`, which bumps, which now drives the global sync. This is what makes item 2's forfeiture bias actually retype existing unpinned days after a calendar edit.
|
||||
|
||||
**Known limit to state plainly in the UI, not silently:** a *pinned* day is excluded from re-planning by design (`sync_plan` passes `only_pinned_existing=True`). The forfeiture bias can therefore never retype a hand-pinned day. The popover note from Task 1 is the right place to say pinning opts that day out of optimization.
|
||||
|
||||
**Tests.** Frontend: a `bumpPlanVersion()` from a non-Calendar hook triggers exactly one `api.syncPlan`; two rapid bumps coalesce to one trailing call; a failing sync does not wedge the in-flight ref.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
Delegate all full-suite runs to a **Haiku** subagent (per AGENTS.md), serialized — one suite at a time, never parallel:
|
||||
|
||||
```bash
|
||||
# backend/
|
||||
uv run ruff check . && uv run ruff format --check . && uv run pytest
|
||||
# frontend/
|
||||
npm run typecheck && npm run lint && npm run coverage
|
||||
```
|
||||
Require raw verbatim output with exact pass/fail counts and coverage percentages; gates are backend `--cov-fail-under=80` and frontend lines 96 / functions 91 / branches 92 / statements 96. Never lower a gate.
|
||||
|
||||
New migration is **not** needed — no schema change.
|
||||
|
||||
End-to-end, delegated to a **Sonnet** subagent driving Chrome (never the coordinator), on `npm run dev`, checked in both Swedish and English:
|
||||
1. Select Mon–Sun in the calendar, "advanced" → vacation. Assert 5 planned days, Sat/Sun untouched.
|
||||
2. Settings → tick "Ta ut föräldrapenning på röda dagar" → return to calendar, repeat with `sjukpenning`. Assert 7 days.
|
||||
3. Settings → edit a vacation-days row → assert a background `POST /api/plan/sync` fires (network panel) without visiting the Calendar page.
|
||||
4. Enter a vacation balance that forfeits days → assert the at-risk column shows it and the plan's day mix shifts toward vacation.
|
||||
|
||||
Then `graphify update .`.
|
||||
Reference in New Issue
Block a user