Commit Graph

21 Commits

Author SHA1 Message Date
jacobh2 d5596afe10 Share one modal shell and inline confirm across the app
Modal owns the overlay, header, close button and footer for the balance,
planned-day and QR modals, and closes on Escape (held off while the QR
fetch runs). InlineConfirm replaces the hand-built delete and
remove-member confirmations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 22:14:05 +02:00
jacobh2 e9d7cee66b Pull form fields and alert banners into shared components
FormField owns the .field markup and wires id/aria-invalid/aria-describedby
onto its control; Alert owns role=alert banners. Auth, Join and the partner
invite panel drop their one-off error classes for the shared alert style.
VacationDaysInput and ParentProfileInput move onto the .field primitives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 22:02:09 +02:00
jacobh2 1a02bdcdbf Let vacation years go back to the child's birth year
Settings only offered vacation-day rows from the current year forward,
while analytics ran its balance series from the child's birth year. Every
year in the gap had no vacation_days row, so the series assumed a full
statutory 25 earned with no use and banked earned - floor of it forward —
phantom saved days the user had no row to correct.

Settings now offers editable rows back to the birth year, and a past year
with no row assumes only the floor was earned, so it banks nothing. A past
year the user did enter is untouched by that rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:49:56 +02:00
jacobh2 782415122d Separate user intent from planner output via day assertions
The calendar mixed two kinds of truth in one table. `planned_leave_day` was
simultaneously the allocator's output cache, the user's override store
(`is_pinned`, plus ad-hoc rows with no `wish_id`), and the resource ledger that
analytics and the age-4 maths read. The planner therefore had to ask "am I
allowed to touch this row?" on every sync, and got it wrong at the edges.

Every user assertion is now one `day_assertion` row — a concrete `day_type`
replaces a pinned day, a row with no `wish_id` replaces an ad-hoc day, and
`day_type='none'` replaces `planned_day_exclusion`. `planned_leave_day` becomes
purely derived with a single writer, and `PlanningService.pinned_keys()` is the
single answer to "is this day pinned", used by the allocator's loader, by
`sync_plan` and by the read router alike.

On top of that, every calendar band is now a real event: an ad-hoc band opens
the same full editor as a wish-backed one and saving it promotes it to a
`LeaveWish`, so it can be named, repeated yearly, assigned to either or both
parents, and handed to the planner. "Planering" is a `moveable` toggle plus a
from/to month bound plus a separate lock/unlock action on the days themselves,
replacing a three-rung ladder whose "planner decides" rung set a search window
equal to the drawn dates and so could never move anything.

Bugs found and fixed along the way:

- The plan erased its own history. Fixed windows clip to today, so an elapsed
  auto-placed day fell into `stale_keys` and was deleted on the next sync —
  and a background sync runs on every workspace load. Past rows are now frozen
  and counted as spent.
- Migration 010's downgrade silently destroyed every pinned day and exclusion
  (`is_pinned` returned as DEFAULT 0, the exclusion table came back empty,
  exit 0, no warning). It now reconstructs both from `day_assertion`, and 009
  itemises what it cannot recover before dropping the table.
- Deleting a period left its assertions behind, locking those dates forever
  with no UI path to clear them. Wish deletion now clears them by `wish_id`.
- `DayAssertionService` and `LeaveWishService` committed multi-statement writes
  without a transaction; a crash mid-write diverged the tables or half-deleted
  a series. Both now run in a unit of work.
- A sibling year's shortfall was never surfaced: the toast only inspected the
  edited occurrence's outcome, so a fully unfunded later year rendered as an
  empty band with no warning.
- A flexible window whose bound had entirely elapsed was dropped from the
  proposal with no outcome at all. It now reports as `unplaced`.
- Unlocking a period deleted its days instead of returning them to the planner.
- The e2e global setup posted to a route deleted in this change, which would
  have failed the whole suite before its first test.

Gates: backend 938 tests / 91% coverage, frontend 1351 / 97.4 | 92.2 | 94.37 |
97.4. Migrations 009 and 010 audited against a hand-built realistic database:
the upgrade reconciles exactly and a constraint violation fails safe, leaving
the schema at 008.

Not complete. Step 4's toggle, month picker and lock/unlock action are verified
by browser walk and unit tests, but the partial-lock readout was not exercised.
`ideas.md` §7 adds a full plan for the vacation-forfeiture defect (the planner
detects expiring days and then does nothing about them) — not implemented here.
Remaining smaller items are listed in the session's restructure notes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
2026-09-21 07:20:14 +02:00
jacobh2 b76fb7417e Keep plan warnings in step with the plan they describe
Warnings such as "vacation days at risk of being lost" kept showing after
the planner had already resolved them. The planner itself was firing
correctly -- PlanSyncProvider syncs on every plan-input change -- so the
fault was in the warnings, in two independent places.

The vacation-expiry warning was computed before allocation. It read
`vacation_forfeiting`, which the loader derives from already-committed
usage, while `_assign_tier3` deliberately spends at-risk vacation days
first precisely to avoid that forfeiture. The figure therefore counted
days the plan was in the middle of saving. `_vacation_expiry_warnings`
now subtracts the vacation days this run actually placed for that parent
and year, clamped at zero -- sound because the bank is FIFO, so a day
spent in year Y retires one expiring day one-for-one. The full
carry-forward simulation is still not duplicated inside the ledger.

The Dashboard then never refreshed them. Its proposal came from a
one-shot effect guarded by `proposal === null`, with no planVersion
dependency, so the card kept rendering the pre-replan warning until
remount -- while `syncPlan` was already returning a fresh proposal that
both call sites discarded. PlanSyncProvider now exposes that proposal
behind a monotonic counter, and usePlanProposal prefers it once any sync
has completed, keeping `propose()` for the first load.

Also adds a manual recompute button to Settings, which only bumps
planVersion and reports what the resulting sync did, and fixes partner
add/remove never bumping planVersion at all -- that gap meant gaining or
losing a parent never re-ran the allocator, though it changes who days
can be assigned to. Rename stays cosmetic.

Verified in the browser: a 2031 warning of 15 days dropped to 12 after
three vacation days were planned, without a reload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014jPFUcCJ5AKXmPVC1anhXe
2026-09-20 14:26:50 +02:00
jacobh2 61e5487078 Free days, vacation forfeiture, and global auto-replanning
Free days (Sat/Sun/red days, subject to DayOverride) can never be
vacation, and can only be parental leave when the workspace opts in via
take_pl_on_red_days. The allocator already honoured this; the hand-pin
path bypassed it entirely.

- PlanningService gains optional workspace_repo/day_override_repo deps
  and _reject_illegal_free_day, enforced in add_planned_day and update.
  Reuses allocation_service.is_free_day rather than re-deriving it, and
  no-ops when the deps are unset so existing callers are unaffected.
- update_planned_day had no ValidationError handler at all, so a
  validation failure there 500'd instead of 422'ing. Fixed.
- Calendar filters the pin write loop by the day's isFree; the modal
  explains free days stay off, and that pinning opts a day out of
  automatic re-planning.

Vacation days at risk of forfeiture were computed and sent over the
wire but never rendered: an at-risk column with the age-out/over-cap
split, and a dashboard key figure.

PlanProposal.warnings was likewise parsed but rendered nowhere, for all
three kinds. Adds PlanWarningCard in a Risks section above the tips,
with parent names resolved and dates formatted.

POST /api/plan/sync now fires from the workspace layer on every
plan-input change rather than only from Calendar, so a Settings edit or
a pinned-day save re-plans day types instead of merely refetching.
Overlapping bumps coalesce into one trailing run; a rejection cannot
wedge the in-flight guard. eviction_preview was silently dropped by the
frontend despite always being on the wire, and is now surfaced through
context.

Verified: backend 890 passed (91.49% coverage), frontend 1297 passed,
ruff/eslint/tsc clean. Browser-checked in Swedish and English.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVQx4NUvPbJhn6UcvRd2QY
2026-09-20 13:07:36 +02:00
jacobh2 0e9b20ec37 Track preschool planning days, partner invites and real vacation forfeiture
Three strands of work that grew together across the same settings screen and
API client, so they land as one change.

Per-year preschool planning days ("studiedagar"): new table, repository
(SQLite + Postgres), service, router and migration 007, plus the hooks and
Settings UI to plan and record them per child and year. Allocation now
reserves the recorded figure for a year where one exists and only falls back
to the child-level estimate otherwise, marking that fallback as assumed.

Partner invitations: workspaces issue and list invitations, registration
accepts an invite token to join the inviter's workspace instead of creating a
personal one, and a /join/:token page plus the settings invite panel drive it.

Vacation forfeiture: tips and the dashboard now distinguish days a parent
genuinely loses from days that merely age, so the warning stops firing on
balances that were never at risk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 11:22:07 +02:00
jacobh2 b3fe2ac645 Group settings into four panels and give balances one home
Settings becomes Barn & familj, Saldon, Kalender & språk and
Undantag & röda dagar, each saving on its own. A BalanceFlow
component owns fetching from Försäkringskassan and manual entry for
every parent, used by the Saldon panel and by the onboarding wizard,
which now runs without the app chrome. The red-days list and the
day-override form merge into one exceptions view, the vacation table
becomes wrapping rows that fit a phone, and the BankID modal speaks
translated status lines instead of emoji.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H
2026-09-20 06:30:46 +02:00
jacobh2 d14f86478a Lay the Almanacka design foundation: tokens, type, shell
New token system in global.css (near-white surfaces, one ink-blue
accent, no sub-12px type), self-hosted Schibsted Grotesk, rewritten
primitives (buttons, fields, modal shell, segmented, disclosure,
skeleton), a borderless 56px header with an SV | EN language toggle,
an Icon component replacing glyphs, page skeletons that keep the
layout during loading, and leave-type colours moved out of TS into
CSS variables. Feature blocks keep their structure for later phases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 18:25:04 +02:00
jacobh2 663d5fcd92 Flip a single holiday to a working day by clicking it
The status on the right of each public holiday is now a button. Clicking
it writes an override for that one date, and clicking it back removes the
override rather than storing a row that agrees with the almanac. Because
an override is keyed on an absolute date, the same holiday in other years
is left alone — which is what was asked for and needed no schema change.

The separate badge saying an override exists stays, so the fact that a
day has been changed is not carried by the button alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
2026-09-19 14:38:45 +02:00
jacobh2 b87ad0e7ad Let a household choose to be paid for the red days
Asking for a full week off never spent anything on the Saturday, the
Sunday or a holiday, and a test now says so outright rather than leaving
it to be rediscovered. Those days are free because vacation cannot be
taken on a day that is not a working day.

Parental benefit is a payment rather than a schedule, though, so it can
be drawn on a red day to turn an unpaid day off into a paid one — at the
cost of a day the family would otherwise keep. That is now a workspace
setting, off by default, and it never reaches for a vacation day. With it
off the planner behaves exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
2026-09-19 14:28:38 +02:00
jacobh2 ae6db7e16f Redesign the setup flow and consolidate the CSS into one primitives layer
Onboarding asks explicitly for one or two parents and shows one card per
parent for balances; the balance form is per parent with inline
validation; the partner can be filled from the FK deduction or added
from it. Settings and Dashboard reuse the same form.

CSS: bare buttons carry no colour any more; buttons, cards, choice
cards, fields, alerts, badges, eyebrow and modal chrome are defined once
in primitives.css. Media queries live only in responsive.css. A style
guard test blocks hex, !important and stray media queries. Bilingual
fixes: calendar selects size by content, header no longer wraps. Vitest
is capped at four workers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 12:27:29 +02:00
jacobh2 cc3ced905e Link planned days to their wish, fix the age-4 cap, drop the planning goal
Three changes that together unblock treating a calendar period as one
editable object.

Migration 008 adds `planned_leave_day.wish_id` (nullable, ON DELETE SET NULL)
so a persisted day knows which `leave_wish` produced it, `is_pinned` to mark a
day the user hand-edited, and `leave_wish.target_days` so a period drawn on a
calendar need not be a whole number of weeks. Apply-time identity still keys on
day_type/is_dubbeldag only, so a wish_id-only difference is a metadata update
rather than a collision.

The age-4 savable ceiling was applying the per-child figure of 96 to each
parent's pool, modelling a two-parent family as able to save 192 days past the
child's fourth birthday. `resolve_age4_savable_cap` is now the single place
that decides 48 (couple with two parents) versus 96 (everyone else), and both
AnalyticsService and the allocator read it.

Migration 009 drops `workspace.planning_goal` and `leave_wish.goal_override`.
The allocator's tier-3 policy collapses to one rule; `salary_type` still
branches, since that reflects what a vacation day actually costs a parent
rather than a user preference.
2026-09-15 20:28:06 +02:00
jacobh2 8e14ac6576 Show and manage the linked partner in Settings
Settings never called GET /workspace/{id}/members -- confirmed by network
capture, not inference. There was no member list, no loading state and no
swallowed error; the code was simply absent. A workspace with a partner
therefore looked identical to a solo one, and nothing stopped a second
partner being added.

Settings now lists both members, marks the logged-in user, and offers
rename on either row and remove on the partner. The add-form appears only
when no partner exists. Removal asks for confirmation inline rather than
via window.confirm, which this codebase does not use anywhere and which
blocks the browser tooling used to verify it.

Own-name rename is offered too: the backend documents this route as the
only place a name can be corrected, so hiding it would leave that
capability dead.

Also fixes apiFetch's error parsing, which handled only string and array
detail bodies. A structured {message_key, data} object fell through and
was assigned to error.message, rendering as [object Object] or leaking
the raw key -- latent until this session's backend work started
returning exactly that shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 18:54:43 +02:00
jacobh2 28c632ca36 Fix calendar window overshooting the cached year range
Found by probing the running API; both suites were green and missed it.

CalendarService validated only start_year against the cache, never the window
end. GET /api/calendar/days?start_year=2035&years=4 returned 200 with
end_year=2038, pulling 2036-2038 from the live holidays-library fallback in
app/utils/holidays.py. That fallback does not know the de facto free days, so
Julafton, Nyarsafton and Midsommarafton were reported as ordinary working days
(18 red days per cached year vs 13 per uncached year). Julafton being charged
as a working day was fixed once before; it had reappeared past 2035.

- clamp end_year to max_year; the response now always reports what it returned
- add strict=True to get_swedish_holidays/get_red_days plus UncachedYearError,
  checked before the memo cache, so a strict caller fails loudly rather than
  silently degrading. Non-strict default behaviour is unchanged.
- RedDaysList derived endYear locally as startYear + years - 1, independent of
  the response, so a clamped window rendered phantom empty years. It now takes
  endYear from the backend.

Tests: clamping at and before max_year (service + router), Julafton/Nyarsafton/
Midsommarafton red for every cached year, red-day count consistent across the
served range, and strict-mode raise/pass-through.

Backend 404 -> 414 tests, frontend 704 -> 705.

Known, deliberately not fixed here: AllocationInputLoader builds its holiday map
the same non-strict way over the planning horizon, so it has the same exposure
once the horizon passes 2035. Clamping would silently truncate planning and
raising would break real plans, so it needs a product decision first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 02:46:47 +02:00
jacobh2 76b1e712b9 Complete NextSteps roadmap: planning engine UI, calendar cluster, flexible wishes
Works through every numbered item in NextSteps260913.md section 3, plus the
three known verification issues in section 4.1.

Surface what the backend already computed:
- 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies
- 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible
- 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing
  the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt)

Calendar cluster (one pass over the holiday data):
- new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides,
  paginated four years at a time
- 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal
- 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact
- 3.7 click month/year to advance
- 3.8 per-year red-days list in Settings, statutory days plus overrides

i18n:
- 3.5 five backend-generated Swedish surfaces converted to message_key + data,
  following the tips_service pattern; old string fields replaced, not kept

Flexible wishes (3.9):
- PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function,
  deterministic tie-break, two-pass ordering, test matrix rows 15-24
- backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator
- frontend: season-preset toggle in WishEditor, unplaced outcome rendering

Fixes (4.1):
- pin typecheck to the local tsc rather than npx resolving a newer TypeScript
- ignore generated coverage/ and dist/ in the ESLint flat config
- fix the flaky usePlanApply test properly, with waitFor rather than a retry

Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines.

Not yet verified in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 02:33:03 +02:00
jacobh2 dead5e4897 Update calendar with multi-select 2026-06-14 22:20:32 +02:00
jacobh2 6ed2a3ae0c Fix calendar style 2026-06-14 13:05:53 +02:00
jacobh2 772d853e37 MVP frontend+backend 2026-06-14 09:58:30 +02:00
jacobh2 719da83827 Phase 3: Wire page stubs into working screens with full integration
Implemented 4 page stubs with hooks and components:
- Dashboard: displays overview metrics, planned days breakdown, progress chart, break periods
- Calendar: month-based calendar grid with event badges and navigation
- Tips: list of optimization tips with dismiss functionality
- Settings: workspace configuration with child info, parent profile, vacation days, break periods

Added utility exports:
- src/context/index.ts: re-exports useAuth, useWorkspace hooks
- src/hooks/index.ts: re-exports all domain hooks

All pages:
- Use Layout wrapper with header configuration
- Load data via hooks with loading/error states
- Render presentational components (no business logic)
- Pass callbacks for user actions
- Type-safe with full @types coverage

Frontend test coverage: 167 tests, all passing
TypeScript: zero errors, lint: zero errors

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-01 17:53:10 +02:00
jacobh2 53d157535a Initial commit 2026-06-01 16:48:21 +02:00