Commit Graph

96 Commits

Author SHA1 Message Date
jacobh2 06592df87c Say something when planning a free day changes nothing
Planning a day through the default "Planera en dag" dialog on a weekend or
red day accepted the save, returned 200, and did nothing visible: the
allocator marks a free day as 'free' before a type is chosen, the shortfall
count excludes it, and planning_service strips it before persisting — so the
sync came back accepted with an empty batch and the toast builder had nothing
to say. The click looked broken.

The modal already had the right words for this; they were only wired to the
advanced/pinned path. They now show in the default path too, so the user is
told before clicking. And an accepted sync that changed nothing now says so
instead of staying silent — opt-in at the one call site that needs it, since
the preset rail deliberately stays quiet on a genuine no-op.

No change to the allocator, to planning_service, or to the policy that free
days stay off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 09:49:22 +02:00
jacobh2 2c46cf762d Stop two stale ids reaching the API, and steer the drag test off free days
Post-merge verification of the six merged branches turned up two real
frontend defects and one stale test expectation.

Settings mounts before WorkspaceContext has populated, so PartnerInvitePanel
asked for invitations with an empty workspace id. The empty path segment
collapses in Vite's proxy, FastAPI matches the single-segment workspace route
with workspace_id="invitations", and the UUID parse 422s. Guarded the same way
useWorkspaceMembers already does.

PlanSyncProvider could sync a workspace id seeded from localStorage before
WorkspaceContext refreshed it — a workspace a reseed had since deleted. It
logged the resulting 404 and kept retrying it on every planVersion bump. A 404
now refreshes the workspace context instead, correcting the id rather than
narrating the failure forever.

The drag-create-delete expectation was simply out of date: vacation-typed saves
correctly exclude free days, and targetRange() only skipped months ending on a
Sunday, so a Monday-ending month put a Sunday in the range. It now skips any
free day, computed independently of the calendar data the backend itself reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 09:22:30 +02:00
jacobh2 c3ba580e30 Merge branch 'worktree-make-planner-explaining-itself'
Clicking a planned day raises the calendar toast with a plain-language
reason (sv + en) taken from the live in-memory proposal; the backend
field-parity walk now covers ProposedDay.

Conflicts in Calendar.tsx / Calendar.test.tsx against the day-assertions
restructure and the two earlier merges; both intents kept:

- usePlannedDayExclusions stays replaced by useDayAssertions; the branch's
  usePlanProposal watch, reasonProposal state, reasonByDay map and the
  handleBandClick reason toast are added alongside main's band logic.
- runSync keeps the security branch's background refresh and also records
  the sync's proposal for the reason toast. main's applyWishSyncResult
  (every wish save on this page) records result.sync.proposal the same way,
  so an edit's fresh reasons reach the toast — the stale-response guards
  in usePlanSync/usePlanProposal only gate hook state, never the returned
  response the reason rides in on.
- One main test ("no success toast when sync-with-wish throws") now
  dismisses the click's own reason toast first; its assertion is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
2026-09-21 07:50:44 +02:00
jacobh2 6d91f153d7 Merge branch 'vacation-rescue-and-wrap'
Vacation rescue/wrap in allocation_service, new plan models/endpoints,
VacationRescueTip, useRescueDayActions, vacationSwaps util, sv+en i18n,
Dashboard/Calendar wiring.

Conflicts came from main's "day assertions" restructure and the earlier
merges; both intents kept:

- plan.py: both import sets; main's sync-with-wish handler kept next to the
  branch's new /rescue-days/dismiss endpoint; HTTP_422_UNPROCESSABLE_CONTENT
  applied to the branch's code (and a survivor in auth.py).
- Accepting a rescue day now goes through POST /api/day-assertions (an
  ad-hoc 'vacation' assertion, no wish) instead of /plan/apply with an
  `is_pinned` flag that no longer exists: on main a pin IS a day assertion
  (see PlanningService.add_planned_day's docstring), and an assertion is
  what keeps a later sync from retyping the day. useRescueDayActions,
  its tests, VacationRescueTip's tests and the backend integration flow
  follow that contract; applyPlan stays as a client for the live endpoint
  minus the dropped field. The hook also gets the security branch's
  useLatestRequest guard with an unconditionally cleared busy flag.
- Calendar.tsx: `locale` threaded into main's applyWishSyncResult call of
  buildSyncToastMessage (the branch's new parameter); the branch's
  swap-visibility toast tests drive the preset path through syncWithWish,
  which is how main saves a wish now.
- types/api.ts: ProposedDayApplyItem kept (used by applyPlan) without
  isPinned; the Add/UpdatePlannedDayRequest types stay removed as on main.
- fixtures/plan.ts: ResourceShortage import dropped (type removed on main).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
2026-09-21 07:48:33 +02:00
jacobh2 1a69221040 Let a planned day say why it was placed
Every funded day the allocator places already carries a machine-readable
`reason` code — the constants block in allocation_service.py even says it
exists "so the UI can explain itself" — but no user has ever seen one.
The calendar showed what was decided, never why, which is the one question
this product exists to answer.

Clicking a planned day now also raises the existing calendar toast with a
plain-Swedish (and English) sentence for that date and parent: a pinned day
says the user chose it, an allocator-placed day explains the placement, and
anything unrecognised — or a session where no proposal has landed yet —
shows nothing rather than a raw code.

The reason is deliberately not persisted: it is stripped at apply and
`planned_leave_day` has no column for it, so the source is the live
in-memory proposal, merged last-writer-wins from Calendar's own sync
response and PlanSyncProvider's latest. Before the first sync of a session
there is simply no explanation to show.

Also extends the backend's field-parity walk to ProposedDay, which it did
not cover — the reason could have been dropped from the wire without a
single test failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 07:41:44 +02:00
jacobh2 4fa79bad77 Merge branch 'worktree-check-security-audit'
Security and frontend-correctness hardening: analytics IDOR fix, scraper
log scrubbing, lowercase email normalisation (migration 009), stale-response
guards in the plan hooks, busy flags cleared unconditionally across all
useLatestRequest consumers, Calendar no longer blanks on mutation, plus
dependabot/CI config and removal of the stray frontend/out.js.

Conflicts came from main's later "day assertions" restructure, which made
usePlannedDays read-only and replaced the calendar's per-day add/update with
useDayAssertions.assertDay. Both intents kept:

- usePlannedDays: branch's fetch machinery (isRefreshing, in-flight dedup,
  unguarded busy clear); mutators dropped as on main.
- usePlanSync: branch's useLatestRequest guard also applied to main's new
  syncWithWish.
- useDayAssertions / useLatestBalance (new on main): busy flag now cleared
  unconditionally, as the branch did for every other consumer.
- useDayAssertions.assertDay gains the branch's `skipRefresh` option and a
  `refresh`, so Calendar's multi-day save keeps the batch shape (N writes,
  one trailing bumpPlanVersion + one assertions refresh) on main's
  assertDay path. Branch's M7 tests ported from the removed add/update API
  to assertDay.
- PlanSyncProvider: branch's isAuthenticated gate kept; eviction-preview
  state stays removed as on main.
- usePlannedDayExclusions.ts stays deleted (superseded by useDayAssertions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
2026-09-21 07:40:09 +02:00
jacobh2 9c67558877 Tell the user when vacation days move between periods
reallocate_vacation_by_value swaps vacation onto higher-value dates 1-for-1
with parental-leave days. Add an Easter period with no vacation left and the
planner quietly takes vacation off the summer period, puts foraldrapenning
there, and spends it on the Easter klamdagar. Nothing said so.

sync_plan already emitted the evidence -- paired 'retyped' changes -- but
PlanChange.kind's 'retyped' existed only as a type declaration, rendered
nowhere, with no copy in either locale.

findVacationSwaps pairs a vacation -> FP retype with an FP -> vacation retype
for the same parent, aggregating multiple pairs into one group and ignoring
any retype that is not a genuine two-sided swap. summarizeVacationSwaps names
the periods via the existing resolveWishName, so a preset wish is
re-translated rather than shown raw, and falls back to dates when no single
wish resolves -- never a uuid. It joins the existing Calendar sync toast
rather than adding a second toast system.

Frontend 1356 tests, coverage 98.22 / 94.95 / 92.31 / 98.22, above all gates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZaX6xbuMEMXuyKA1fq2Mm
2026-09-21 07:22:05 +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 014503e89b Spend vacation on the days worth having, and name the ones about to expire
The planner knew exactly how many semesterdagar a parent was about to
forfeit and never placed a single day to prevent it. It also had no
weekend-wrap logic at all: REASON_VACATION_WRAP_WEEKEND was stamped on
every tier-3 interior vacation day, including a lone Tuesday in February.

Two changes.

Spend better. A workday adjacent to a free stretch, or in the middle of a
gap of three or fewer fenced by free days, now takes vacation --
_vacation_bridge_reason, consulted for every parent type. This departs
from spec 6.1's "wrapping suppressed for hourly": the FP-preservation
argument is salary-type independent, only the pay argument was hourly-moot.
In a both-parents window, both now take vacation on such a date rather
than one paying with FP. After funding, reallocate_vacation_by_value swaps
vacation onto higher-ranked dates 1-for-1 with FP days, so which dates hold
vacation improves while how many never drops -- greedy first, re-timed
after, never withheld. An invariant test asserts the count can only rise.

Name the losses. A new pass proposes concrete dates for vacation that would
otherwise expire, current year only, ranked red-day bridge then weekend
bridge then interior, later dates first. They ride on
PlanProposal.rescue_days, kept off `days` so nothing downstream commits
them, and surface on the Dashboard with accept-one, accept-all (one
batched /api/plan/apply, not N) and dismiss. Dismissal reuses the
tip_dismissal table, keyed on (parent, year) identity rather than the
group's dates -- those are recomputed from the whole plan's residual on
every propose, so date-hashing made dismissal self-defeating. Dismiss does
not bump the plan version; it changes no data and must not trigger a write.

Measured on the seeded demo: 12 forfeiting days for one parent now yield 12
proposed dates, all Fridays or Mondays. Total vacation placed is unchanged
at 203 days, confirming the reallocation only re-times.

Three defects here were found by driving a browser, while 2273 unit tests
passed -- all three lived in the seam between layers that unit tests mock
away. Adds API-level tests over the real request sequence. Rate limiting is
now off in tests (one file consumed the whole registration budget) with the
suite's first real 429 assertion.

PROPOSE_VS_SYNC_BUG.md documents a pre-existing, unfixed issue found along
the way: Dashboard figures swing on the first sync with no user action.

Backend 946 tests, frontend 1341, coverage above all gates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZaX6xbuMEMXuyKA1fq2Mm
2026-09-21 07:00:18 +02:00
jacobh2 88197060de Close the residual gaps the 2026-09-18 audit left open
Re-checked every item of the audit's own outcome section against the tree.
Most held, but five items marked fixed had residual gaps — one of them a
live IDOR — and the deferred list was untouched.

Security:
- Analytics IDOR: /api/analytics/{ws}/overview and /projection took
  child_id as an unchecked query param, and get_overview called
  get_days_left_at_age_4 without workspace_id, so the ownership guard
  never ran. Any member could read another workspace's child projection.
  Fixed at both layers.
- Scraper: session ids no longer logged at INFO; a persist-callback
  failure's raw exception text and the FK maintenance banner's scraped
  text no longer reach the poll client.
- Email is now case-normalised at the API boundary, with migration 009
  lowercasing existing rows (aborts naming colliding rows rather than
  failing opaquely on the UNIQUE constraint).

Frontend correctness:
- usePlanProposal/usePlanSync gained the stale-response guard the other
  hooks already had.
- PlanSyncProvider no longer fires POST /plan/sync before auth resolves
  off a blindly-read localStorage workspaceId.
- Busy flags are cleared unconditionally across all 16 useLatestRequest
  consumers. setBusy(true) was unconditional while the clear was guarded
  by isCurrent, so a superseded call stranded its flag forever — under
  StrictMode this hung every in-app navigation behind the PageSkeleton.
  isCurrent decides which response's data wins, never whether a call
  releases the flag it raised.

Performance:
- Calendar no longer blanks on a mutation, and a multi-day save issues N
  writes plus one background refetch instead of 2N requests.
- take_pl_on_red_days moved onto GET /auth/context, dropping a redundant
  GET /workspace/:id from every cold load.

CI and repo hygiene: least-privilege permissions block, actions pinned to
commit SHAs, npm ci, uv sync --locked, failure-only artifact upload,
dependabot; dead frontend/out.js removed; CSP added to the nginx template
(repeated into the location blocks that set their own add_header, since
nginx does not merge them across levels).

Left deferred by decision: refresh-token revocation, the phase-2 contract
change, M4, M5, L2, and H12's cross-route cache. Details and the smaller
follow-ups browser verification turned up are in the audit report.

Backend 901 passed, coverage 92%. Frontend 1322 tests, coverage
98.17/94.79/92.14/98.17.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PngVF38MLpdkgXcByKfdW6
2026-09-21 06:16:23 +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 3fbc6b8ad8 Align year-row timelines with a shared subgrid
Each year's <summary> was its own grid, so the `auto` figures column was
sized per row and the timeline tracks ended at a different x per year.
Promote the column definition to `.year-rows` and have the rows opt in
with `grid-template-columns: subgrid`, behind an @supports guard so
engines without subgrid keep today's layout. Mobile undoes the promotion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVQx4NUvPbJhn6UcvRd2QY
2026-09-20 13:22:26 +02:00
jacobh2 74ddfcd440 Name the parent and year in vacation-expiry warnings
Several vacation_expiring warnings rendered with an identical title and a
body without the parent, so two warnings for the same year read as
duplicates. The backend already sends parent_personal_data_uuid; the
strings just never used it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 13:17:16 +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 ad0e3c0b33 Keep a repeating week on its weekday when the planner picks the dates
A yearly-repeating period kept the same calendar date instead of the same
weekday: 2026-10-05..10-11 (Mon-Sun) recurred as 2027-10-05..10-11 (Tue-Mon)
rather than 2027-10-04..10-10. The snap added in 224fe7a only ever covered
window_mode == 'fixed'; 'flexible' wishes were unaligned at both stages.

project_window now snaps both modes. A flexible bound is still a bounding
range, but it keeps its weekday alignment year over year with its span held
fixed, so the search space never shrinks and a bound that is exactly as long
as the target (its only candidate) repeats week-shaped.

_rank_candidate gains a Monday-start term between cost and start, so equal-cost
candidates break on week alignment instead of "earliest". It sits after cost
deliberately: a cheaper non-Monday start still wins, because red days outrank
alignment.

_reuse_already_funded_window would otherwise freeze a pre-existing misaligned
placement forever, so it now releases a year whose reuse winner is misaligned
when a fresh search ranks strictly better -- never when one of its days is
pinned or excluded, so a deliberate per-day override always survives.

Migration 008 realigns flexible series already materialised under the old rule;
projection runs once, at create/update, so nothing repairs them on read.

Alongside this, the planner now says when it moved a period to catch red days.
WindowOutcome carries shift_gain_days (the saving over the naive bound-start
placement) and the calendar toasts it, once, on the sync that decided it.
A fixed window is never auto-moved -- its "exact requested placement,
unconditionally" guarantee stands -- but its shift suggestion is no longer
hidden behind a shortfall, so a fully-funded one can surface a cheaper week too.

Editing a period in the popover now also sends window_mode: 'fixed' with a
matching target. Preset chips create flexible wishes; dragging one down to an
exact week left it flexible with a week-long bound, which is the degenerate
single-candidate case that produced the reported dates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
2026-09-20 12:04:20 +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 d5f33e8c7f Tell parents how to save the days expiring at age 4
The dashboard stated the loss and stopped there. With Jacob at 48/48 savable
and 24 days over the cap, and Amelie at 3/48 with 45 days of headroom, those
24 days are not lost at all — transferring them before the 4th birthday saves
every one. The app never said so.

`_rule_age4_pooling_loss` already computed exactly that advice, bounded by the
FK-reported `transferable_days_left`, in finished Swedish and English. It never
ran: `age4_pooling_tip_min_child_age_years` held it back until the child turned
2. The calendar IS the plan, so gate on the plan instead — the noise case is a
newborn with nothing planned, not a young child whose parents have planned in
detail. `age4_pooling_tip_min_planned_days` (20) replaces the age gate.

Both variants drop to `info`. A projection four years out should not read as a
verdict on the user's planning; the hero carries the alarm, this card carries
the advice. The copy now leads with "plans change, this is only a projection"
and closes by pointing at keeping balances up to date, since parents will come
back and record what they actually took.

- Suppress `preserve_for_age_4` whenever `age4_pooling_loss` fires for the same
  child. The pooling tip states the same loss plus the remedy and the correct
  per-parent cap, so emitting both gave two contradictory "you're losing N
  days" figures.
- `preserve_for_age_4` now sends `parent_cap` and its copy interpolates it. It
  claimed "only 96 days can be saved" — false for a two-parent family, where
  the cap is 48 each, as the Age4Bars card two rows below already showed.
- The hero grows a "How to save them" line derived from the tip itself, so the
  two can never disagree on the number. Dismissing the tip retires both.
- "Försäkringskassan" in that line links to FK's BankID login carrying a `goto`
  to Min föräldrapenning — the e-service showing "Dagar ni kan föra över" —
  so the transfer is one click away. `translate()` returns a plain string and
  word order differs between locales, so the anchor's position travels inside
  the template as `{fk}` and the rendered string is split around it.

Verified in the browser: hero, link target, tip severity and the absence of
`preserve_for_age_4`, in both languages and at 390px. Note the demo seed cannot
reach this shape — it gives the surplus parent no FK snapshot at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
2026-09-20 09:51:10 +02:00
jacobh2 97a7200c1a Parse transferable days from FK instead of deriving them
The scraper never read "överlåtbara dagar" from Försäkringskassan. It only
visited ansok-och-planera-foraldrapenning#/valjdagar and derived the
reserved/transferable split in build_snapshot, assuming transferable days
are spent before reserved ones. On real data that gave 49 for the user and
0 for the partner where FK states 129 and 71.

The assumption cannot be repaired: reserved-first, transferable-first,
proportional and cap-based models were all checked against real figures and
none reproduces FK's accounting (reserved-first fits the partner but is 10
days off for the user). FK publishes the real numbers per parent per tier on
the min-foraldrapenning page, which the scraper had never visited.

- Scrape the "Dagar ni kan föra över" panel after the valjdagar extraction,
  summing sjukpenningnivå + lägstanivå per parent. The totals are not
  literals on the page. Parse only direct-child <p> elements: the first
  group embeds a tooltip with decoy "45 dagar"/"90 dagar" text.
- build_snapshot accepts the reported value and derives reserved days from
  it; the old arithmetic remains only as a fallback. An implausible FK value
  degrades to that fallback rather than failing the fetch.
- Record provenance in transferable_days_reported (migration 006) and badge
  derived values as estimates in the dashboard breakdown. Drop the footnote's
  claim that at most 150 days are transferable — real data contradicts it as
  a per-family figure.
- Select the child's section by name when FK lists several; never guess.

Verified against a live BankID scrape: scraped 240/101/139 with 129
transferable, deduced partner 240/169/71 with 71, both flagged as reported.

Incidental fixes found along the way:

- The fetch router never forwarded the partner's transferable figure to
  persist_partner_deduction, so the partner's stored snapshot would have kept
  the flawed split even once the parse landed.
- 'deduced' snapshots were labelled "Manually entered" by a two-way ternary
  over a three-value union. Replaced with a compiler-checked exhaustive map.
- Alembic resolved its database from alembic.ini alone, ignoring
  APP_SQLITE_PATH, so migrations silently targeted the developer's real
  database even when pointed elsewhere. env.py now resolves it, preferring
  -x sqlalchemy.url, then a real APP_SQLITE_PATH, then the ini value.
- backend/captures/ was not ignored: the root pattern was anchored as
  /captures/ despite a comment saying scraper dumps must never be tracked.
- CLAUDE.md quoted test counts and coverage percentages that had gone stale,
  which made a correct verification report look suspect. Gate thresholds kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
2026-09-20 09:11:53 +02:00
jacobh2 411612c86c Pluralise counts properly instead of writing dag(ar)
The translate function learns a {count|singular|plural} form, and the
24 strings that hedged with "dag(ar)", "day(s)" or "week(s)" now pick
the right word from the number they interpolate, in both languages.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H
2026-09-20 08:06:27 +02:00
jacobh2 981b951847 Hide the birthday legend when it names a single field
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H
2026-09-20 07:26:18 +02:00
jacobh2 0081f60b06 Finish the redesign's edges: auth page, modals, dead code, docs
The sign-in page becomes a plain centred column with the brand mark
and a segmented login/register switch, modals lose their last inner
boxes, the red-days list collapses every year but the current one,
and the retired components' CSS, an unused list component and 23
unreferenced translation keys are removed. ARCHITECTURE.md documents
the Almanacka styling conventions and the per-year balance rule.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H
2026-09-20 07:00:22 +02:00
jacobh2 eafa32e6b0 Add the app icon as favicon, touch icon and header mark
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 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 80b4f1cb52 Make the overview answer the year-by-year question
The dashboard now leads with a typographic headline, three key
figures, per-parent age-4 bars and one row per year until the fourth
birthday. Each year row shows the timeline bands beside each parent's
parental-leave days left at year end and vacation days left, carried
over or expiring, and opens into a per-parent table with that year's
wish outcomes. Tips become a plain list and absorb the planner's
surplus advice; "Detaljer" keeps the metrics and a balance snapshot
for every parent. The separate plan-proposal fold and the
type-count table are gone.

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 4b802ba0be Give the analytics overview a per-parent, per-year balance series
Each parent projection now carries one entry per calendar year from
birth to the fourth birthday: vacation earned, carried in, planned,
left, carried out and expiring under Semesterlagen's 20-day floor and
5-year carry-over, plus parental-leave days used and left at year end
with the age-4 clamp applied in the birthday year. The overview sums
them into per-year totals. Frontend types, mapper and fixtures follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H
2026-09-19 19:02:02 +02:00
jacobh2 dbef997da4 Redraw the calendar as an almanac: gridless cells, typed bands
Day cells lose their borders and fills; red days, klämdagar and
Saturdays are told apart by the numeral's ink with a 12px R/K mark,
today is a filled circle, overrides and exclusions mark the numeral.
Bands carry their funding type by fill, ink and hatching and their
owner by a left cap. The preset rail becomes a chip row, the legend a
help popover, the period popover's three delete buttons fold behind
one "Ta bort…", and solo users pick a type directly in the day modal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H
2026-09-19 19:02:02 +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 e39ec9a77b Stop the test runner tripping the rate limiter, and level the rows
The end-to-end suite had never been run. Its first run failed six ways,
all of them the page sitting on the login screen: every browser page load
re-obtains an access token, the refresh endpoint allows thirty a minute
per address, and a twenty-test run makes about fifty of them from one
address. Half came back 429, and a failed refresh is indistinguishable
from being logged out. Raising the ceiling would only move it and make a
green suite depend on how fast the machine is, so the limiter is switched
off for the test backend alone — it measures a client, and the runner is
not one. It stays on everywhere else.

With that gone, one real failure was left, and it belonged to the commit
that claimed to have fixed it: cell widths were held, but a week whose
cell carries a holiday name still stood taller than its neighbours,
because each week is its own grid and none can see another's height. The
weeks now share one grid whose rows are all a single fraction, so the
tallest sets the rest and no cell can grow its own row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
2026-09-19 16:35:23 +02:00
jacobh2 8cfb9c82c6 Let a day removed from a period stay removed
Taking one day out of a period the planner placed did nothing that
lasted: the period still asked for a full week, so the next sync put the
day straight back. The option was disabled rather than lying about it.

A removed day is now recorded as its own thing — no leave for this
parent, this child, this date — and the planner honours it on every
later sync. It is kept apart from the working-day overrides, which
answer a different question about the calendar rather than about the
plan.

The day belongs to the parent whose band was clicked, so removing one
parent's day leaves their partner's alone; where both parents were to be
off on one funding arrangement, the partner is simply funded on their
own instead. A removed day shortens what the period is asking for rather
than counting as a day the plan failed to find, so the advice about
closing a shortfall does not start arguing for a day nobody wants.

Nothing is removed without a way back: the toast undoes it, and the day
itself is marked in the calendar and offers to restore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
2026-09-19 16:05:17 +02:00
jacobh2 6d92b87bf3 Give every year of a repeating period its own row
A repeating period was a single row that the planner expanded into one
window per year at read time, so no year had anywhere to differ from the
others. Editing one year rewrote them all, and removing a year in the
middle had to split the wish in two. Neither survives contact with the
thing this app is for: Easter moves, and the cheapest week is not the
same week every year.

Each year is now its own row, linked by a series id. Editing one year
touches one row; editing the series re-projects the rest from it.
Removing a year is one delete. The weekday snap is worked out once, when
the rows are made, and stored, so a row now says what it means rather
than being re-derived on every read. Recurrence stops being a stored
field and becomes what it always was in practice — an instruction at
create and update time; whether a period repeats is whether it belongs
to a series.

Migration 004 rebuilds the table and expands the existing rows, keeping
each original row as its first year so the days already planned against
it still point somewhere. A Feb 29 anchor recurring into a common year
used to raise out of the allocator; it clamps now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
2026-09-19 15:53:25 +02:00
jacobh2 32cde13964 Give the leave-wish test fixture its new field
A local literal that predates the typed factories still had to be
updated by hand when wants_dubbeldag reached the frontend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
2026-09-19 15:17:48 +02:00
jacobh2 35e479a005 Let the planner fund a hand-drawn period, and remove one at a time
Three changes to calendar editing, too interleaved in the same files to
land separately.

Giving the same days to the second parent rewrote the first parent's
days instead of adding their own. The save looked the existing day up
against whoever was logged in rather than the parent being saved for, so
parent A's leave turned into parent B's vacation. The parent choice now
lives with the lookup, and two parents on the same dates produce two
rows and two bands.

Drawing a period by hand meant naming a day type up front. It now asks
who is off and lets the planner fund it, as a one-off period the sync
places, with double days offered when both parents are off and priced
honestly in the hint. Pinning an exact day type is still there, moved
behind Advanced, and editing a pinned day goes straight back to it.

Removing a period offered only the two extremes. There are now three
choices: this day, this year's period, or every year. Taking one year
out of a repeating period narrows it, or splits it in two when the year
is in the middle; where a scope cannot be expressed it is disabled with
a reason rather than doing something surprising.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
2026-09-19 15:14:24 +02:00
jacobh2 11f2a17b43 Say what would close a shortfall, and where the free days are
A year that cannot afford the whole week was already funded as far as it
goes rather than dropped; there is now a test that says so, across four
years of a repeating wish where only three and a half are affordable.

The advice to move a window onto the red days was looking three days
either side, which can never find Easter. It now searches four weeks
either way — enough for Easter's drift plus the weekday snap — and ranks
placements with the same cost model flexible periods are already placed
with, naming the dates and the days gained.

None of this reached the screen before. The overview now lists each short
window with its gap and the ranked remedies underneath, one line each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
2026-09-19 15:12:32 +02:00
jacobh2 f76de7ba04 Take the time off together, in long stretches
A season preset in a two-parent household now means both parents, not
either one — being off together is the point of the period.

Within such a period the planner used to pick whoever had the most
parental-benefit days left, date by date, so spending kept moving the
lead and the funding flipped almost daily. Six weeks came out as a
patchwork that costs nothing to hold but a great deal to request. The
payer is now chosen once per half of the window, reusing the same split
the swap strategy already uses: one parent on parental benefit while the
other takes vacation, then they change over. Both are still off every
day, and if one runs out mid-stretch the roles swap for the rest rather
than day by day.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
2026-09-19 14:38:59 +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 1837154225 Jump to today, and open a period on the dates it actually has
The month can now be reached in one click instead of paging back through
the year. The button sits left of the month dropdown and sizes to its
label, so Idag and Today both fit.

Opening a period showed the wish's first year rather than the occurrence
that was clicked: a 2028 week of a yearly wish opened on 2026, and a
flexible period opened on its bounding range instead of where the planner
put it. The popover now prefills from the clicked band's own dates.
Saving a yearly wish keeps its anchor year, since the edited dates
describe the recurring pattern and not one occurrence — moving a single
year is a separate choice that does not exist yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
2026-09-19 14:18:14 +02:00
jacobh2 f0f537c95f Hold every calendar cell to the same size
A calendar whose boxes resize is not a calendar. Each week row is its own
grid, and a bare 1fr track takes its minimum from its content, so the one
week containing "Alla helgons dag" had its seven cells squeezed while
every other week's stayed wide. The tracks are now minmax(0, 1fr) and the
cell, the day and the holiday name each refuse to contribute an intrinsic
width; the name clips to an ellipsis and keeps its full text in the title.

Row height follows the same rule: the band lanes are reserved from the
month's tallest week rather than each row's own, so a week with no leave
in it stands as tall as the week beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
2026-09-19 14:08:04 +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 375bc64f2f Add container builds for the backend and the frontend
Backend: Alpine runtime without Chromium by default, opt-in Debian
scraper target with Playwright. Frontend: Vite build served by
nginx-unprivileged with the API upstream resolved at start.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 12:27:29 +02:00
jacobh2 02db5430eb Harden the API and the data layer after a security and performance audit
Security
- Every route now requires a login and refuses ids outside the caller's
  workspace with 404: plan, leave-wishes, day-overrides and vacation-days
  were fully anonymous; every other router trusted client-supplied
  workspace/child/parent ids. Scraper sessions are bound to their owner.
- Invitations can no longer be created for a foreign workspace, and a
  redeemer who is already a member (or a full workspace) is refused.
- Production refuses the default JWT secret; API docs are hidden in
  production; a failed migration aborts startup instead of serving.
- Login and register no longer reveal whether an email exists; inactive
  accounts are rejected; /auth/refresh is rate limited.
- The FK scraper writes debug artefacts only when explicitly enabled,
  logs no scraped values or page text, clears the BankID QR as soon as it
  is scanned and purges finished sessions after five minutes.
- Real FK page captures are removed from the tree (history rewrite
  follows); the parser tests use a synthetic fixture.

Performance and correctness
- Plan apply/sync run inside one locked transaction with batched writes
  instead of one commit per row, so a failure mid-batch leaves nothing
  half-applied and concurrent syncs no longer corrupt each other.
- SQLite runs in WAL mode with a busy timeout; 14 indexes cover the
  filters that were full scans; argon2 hashing runs off the event loop;
  responses are gzipped.
- Frontend: a 401 now refreshes the token once and retries, so sessions
  no longer die silently after 15 minutes; every data hook discards
  stale responses; logout clears state even when the server call fails.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CsXCCcDfmV4Sps4ZQSvNEY
2026-09-18 07:54:21 +02:00
jacobh2 9c08317265 Read the colours under test from the stylesheet
The red-day checks asserted literal colour values copied out of the calendar
stylesheet, so any restyle broke them whether or not it broke the behaviour
they exist to protect. They now resolve the same custom properties the page
uses, and check that a Sunday and a Saturday are styled differently rather
than that they are two particular shades.
2026-09-18 01:39:48 +02:00
jacobh2 296b596778 Settle on one visual language
The interface had drifted into two of everything. The calendar and its
popovers were built around a blue accent the rest of the app had long since
replaced with green, five different sizes served the same class of small
label, and page sections were a stack of identical bordered cards that gave
no sense of what mattered most. Sections now separate by space and a hairline,
the overview leads with its headline figure at a size that says so, and the
palette, type scale and spacing live in one place as variables rather than
scattered literals.

Two things were not styled at all. The sign-in screen was an unstyled form in
the top-left corner, and the tip cards had no rules whatsoever despite their
markup already carrying a severity, so every tip rendered identically with a
plain green square for its dismiss control.

The calendar was also unusable on a narrow screen: the preset rail never
collapsed and covered the last three columns of the grid.
2026-09-18 01:39:34 +02:00
jacobh2 f2b3920cbc Share one date formatter, and stop describing files that are gone
Two components each carried the same four lines turning a date into an ISO
string from local parts. They now share one helper, deliberately separate
from the one that formats in UTC: substituting one for the other shifts the
day by one anywhere east of Greenwich, Sweden included, so a test pins the
difference rather than a comment asking nicely.

Creating a period ran its own copy of the sync-and-report sequence purely so
it could name a wish the lookup could not yet see; it now passes that wish to
the ordinary path instead.

Two comments still explained their relationship to components deleted some
time ago, which would send the next reader looking for files that are not
there, and some styling outlived the markup it described.
2026-09-18 01:12:58 +02:00
jacobh2 9ac67933d4 Drop the direct apply path the calendar no longer uses
Applying a plan by itself stopped being something the interface does once
every edit began recomputing and saving in one call. The hook that posted
straight to the apply endpoint had no caller left, nor did its request type
or its fixture.

The response decoder stays, since the sync response embeds the same object,
and the two tests being deleted were the only ones checking how its collision
and rejection fields are renamed — that coverage moved onto the sync test
rather than leaving with the code it happened to live next to.
2026-09-18 01:12:26 +02:00
jacobh2 bcc93e5c03 Test the two gestures the rework is actually about
The browser suite checked what the app renders but nothing about authoring,
which is the part that changed. It now registers a fresh account and walks
the wizard, including the case where a partner added mid-wizard has to appear
by name without navigating away first, and it drags a range on the calendar,
deletes the period, and reloads to confirm the days really went. Both are
regressions that were found by hand within the last day and would have been
found here instead.

Doing this surfaced that the age-four spec had been asserting on figures that
moved inside a collapsed section, so it had stopped checking anything. The
stale note about the retired tips page went too.
2026-09-18 00:50:01 +02:00
jacobh2 c5587c5b46 Ask before deleting without leaving the app
Deleting a period or a day went through the browser's own confirmation box.
It halts the page while it is open, which meant the delete path could not be
driven by anything automated, and it looks nothing like the rest of the app.
Both now ask inline: the first click offers the choice, and only the second
one deletes. That matters more than it did, since deleting a period now takes
its days with it.
2026-09-18 00:49:39 +02:00
jacobh2 ac3b6a41d7 Remove a period's days along with the period
Deleting a period left every day it had funded behind. The link from a day
back to its period was set to null the instant the period was deleted, so the
days became untraceable before anything could clean them up, and the next
recalculation had no way to know they no longer belonged to anything. Undo had
the same fault for the same reason: the period went, the days stayed, and a
reload showed them still there.

The days are now removed with the period, before the link can be broken. A day
pinned by hand goes too — pinning keeps the planner from quietly retyping a
day, not from being deleted when you delete the period it belongs to, which is
already how an unplanned band behaves.
2026-09-18 00:23:34 +02:00
jacobh2 8d2f658061 Show the partner's name, and count in the unit that was asked for
A partner added during onboarding appeared as "Parent 2" on the overview
until you navigated away and back: the members list had been fetched before
that partner existed and nothing refetched when the wizard finished. The
fallback was doing its job; it was covering for stale data.

A period drawn on the calendar is measured in days, but a shortfall was
reported in the weeks the engine had rounded it to, so seven days came back
as three weeks. It now reports what was actually asked for.
2026-09-18 00:23:16 +02:00
jacobh2 fdbfb7d580 Give tips a date, a scope per child, and a dismissal that sticks
Three faults, all invisible while the tests passed. Advice was built for the
first child only, so a second child got none. The planned days feeding every
count were fetched once per parent and appended to one list, so a couple's
days were counted twice and every intensity and projection derived from them
was wrong. And dismissing a tip forgot the moment the page reloaded.

Tips now carry the date they concern, lifted from figures the rules already
computed, so a view of a month can show only what applies to it. They are
built per child and identified per child, so the same rule firing for two
children is two separate pieces of advice.

A dismissal is stored against a fingerprint of the facts that were dismissed.
When those facts change the advice returns with its new numbers, rather than
one click silencing a warning about a forfeiture that keeps growing.
2026-09-17 22:32:03 +02:00