Commit Graph

90 Commits

Author SHA1 Message Date
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 224fe7ae3e Keep a repeating week on the weekday it was taken on
A week off is a week off — Monday to Sunday this year means Monday to
Sunday next year, not the same two calendar dates landing mid-week. A
fixed yearly wish now snaps each later year's start to the nearest date
carrying the first year's weekday and holds the span, so 5-11 October
2026 recurs as 4-10 October 2027.

Flexible wishes are untouched: their four date fields are a bounding
range the allocator searches inside, not a placement, so a weekday shift
there would mean nothing. The first year is always returned unshifted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
2026-09-19 14:03:21 +02:00
jacobh2 2d40e4caa6 Explain what the profile fields do, and correct the dubbeldagar premise
how-it-works.md answers what "Your profile" in the settings is for: salary
type is the real lever (monthly spends vacation first, hourly spends
parental-benefit days first), employment status only ever warns about SGI,
and the vacation floor bounds carry-forward rather than capping spending.

dubbeldagar.md answers the question that was asked. The 60 are combined per
child, and the code already treats them that way. They are not a separate
pot, though: on a doubled date each parent spends one of their own days, so
leaving them out of the total would overstate what the family has. The
per-parent dubbeldagar_left the scraper stores is never read by the
allocator — flagged, not changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
2026-09-19 13:51:37 +02:00
jacobh2 d2bc2e98d2 Update todo 2026-09-19 12:31:07 +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 e4719cb9ae Rework balances around per-parent days left and deduce the partner from FK
Manual entry now takes one parent's sjukpenningnivå, lägstanivå and
dubbeldagar left (plus optional reserved days) and derives the rest in
build_snapshot, shared with the scraper. The scraper also reads the
family panel and exposes a partner deduction; with two parents it is
saved as a 'deduced' snapshot (migration 002). Parent count is the single
source of entitlement: one member gets 480 days and 96 savable at age 4,
two get 240 and 48 each. Workspaces are capped at two parents.

Also lands the in-flight squash of migrations 002–011 into the initial
schema and the removal of planning_mode.

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 937690f486 Record the audit findings and what remains
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CsXCCcDfmV4Sps4ZQSvNEY
2026-09-18 07:55:13 +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 ae16d71970 Keep verification screenshots out of the repository
Browser checks leave screenshots behind, and they had been accumulating in the
same directory as the Försäkringskassan captures, which are reference material
for the scraper and belong in the repository. The two are now separated by
where they sit rather than by what they happen to be called: verification
output goes in an ignored subdirectory, and the note saying so sits next to
the layout it describes.
2026-09-18 06:20:35 +02:00
jacobh2 2d85c2a78a Write down what is worth doing next
Eight proposals from the state of the app after the rework, with reasoning,
and a shorter list of things that look worth changing but are not — the
repetition among the fetch hooks, the two band-builders that share a name but
not a problem, and the propose and apply endpoints that remain the testable
primitives beneath the combined one.

The first entry is the one that matters: a parent with sole custody has no way
to say so, and is quietly told they have half the days they are entitled to.
2026-09-18 01:40:01 +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 d245390886 Derive the rule constants that are arithmetic on other constants
The per-parent day split was written out twice, once in the allocator's input
loader and once in the persistence layer, each with a comment recalling the
time the two disagreed. They now share one derivation, so they cannot drift
apart again without the shared function moving.

Three constants that were stored and happened to equal arithmetic on their
neighbours — the child's total, the per-parent share, and the per-parent
ceiling at age four — are now computed from them. The figures are unchanged;
what changes is that a future adjustment to a tier total carries into them
instead of quietly leaving them behind.

The shortfall remedies were also split into one method per remedy, matching
the surplus advice alongside them, which had already been written that way.
2026-09-18 01:12:00 +02:00
jacobh2 6a79435baf Delete date helpers nothing calls
A hand-rolled month-arithmetic module survived the move to dateutil with no
caller left but its own tests, and two predicates alongside it were never
wired up at all. An error import was also being deferred inside a function
for a circular dependency that does not exist.
2026-09-18 01:09:42 +02:00
jacobh2 4fa93c4985 Write a front door for the repository
There was no README at the root, and the backend's own still pointed at an
onboarding guide that had been deleted. The new one says what the app is, how
to run it, and where the code lives, and states the test gates as they
actually are rather than as an older note claimed.

It records the two things that waste a newcomer's afternoon: the dev server
answers on localhost but not on the loopback address, because Vite binds IPv6
first, and the two design documents are largely superseded and say so inline.
2026-09-18 00:56:11 +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 18c742c16a Let one parent cover what the other cannot afford
A period shared between two parents was cut at its calendar midpoint and each
half handed over regardless of whether that parent could pay for it. A window
crossing New Year could therefore lose its entire first half to a parent whose
budget for that year was spent, while the other parent's budget for the very
same days went untouched. In the case that prompted this, eight days were
reported as unfundable when none of them were.

The cut now falls where the cost is even rather than where the calendar is,
since weekends and red days are free and spread unevenly through a period.
Whatever one parent still cannot cover is offered to the other before any of
it is written off. Each parent keeps one unbroken stretch at home.
2026-09-17 22:43:26 +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
jacobh2 c15cd44cb4 Warn when saving unevenly costs the family days
Only ninety-six days survive a child's fourth birthday, and with two parents
that is forty-eight each rather than a shared pool. A parent sitting on sixty
days while the other has ten therefore forfeits twelve that the family could
have kept, and nothing said so.

The advice leads with taking the days as leave before the birthday, which
turns them into actual time at home, and offers transferring only as the
fallback that merely preserves them. The transferable figure is bounded by
what Försäkringskassan actually reports as transferable, since the reserved
days cannot move, and the copy says plainly that a transfer happens there and
not in this app. Without the partner's figures the tip hedges rather than
inventing a number.
2026-09-17 22:31:37 +02:00
jacobh2 a4fce16dd1 Give a new account a first run, and drop to three tabs
Registering used to land you on a bare form asking for a child, with no hint
what came next. A short wizard now walks through the child, whether you are
planning alone or together, and where the day balances come from: fetched from
Försäkringskassan, entered by hand, or skipped — and if skipped, the figures
carry a badge saying they are estimates until real ones arrive.

Fetching is the first option rather than a hidden one, so the scraper is on by
default and the script that used to force it on no longer needs to.

Planning and Tips lose their tabs. Authoring moved to the calendar and the
figures moved to the overview, so both pages were only a second way to reach
what is already on screen; their routes now redirect rather than dead-end. The
dead translations went with them.

Dates also follow the chosen language now instead of always formatting as
Swedish.
2026-09-17 22:05:54 +02:00
jacobh2 064bcc2b3a Cover the mappers and labels nothing was watching
The day-override mapper was the only one in the client with no test feeding
it the shape the server actually sends, which is how a feature once shipped
rendering blank cards to everyone while the suite stayed green. The apply
response's collision fields were mapped but only ever exercised with an empty
list, and the leave band's accessible label, composed from translated text and
two dates, was asserted nowhere.

All three were correct; none was guarded. The day-override hook's fixtures now
go through the typed factory rather than hand-written objects.
2026-09-17 22:05:21 +02:00
jacobh2 8e1cf3524c Lead the overview with the days about to be lost
The page opened with generic tiles and a bar reading forty-eight of ninety-six
weeks, which was neither weeks nor connected to the age-four ceiling it appeared
to describe. It is replaced by one headline and a bar per parent, in days,
against each parent's own savable limit as the server reports it.

The headline has two framings on the same arithmetic. Before anything is
planned it says what there is to plan and what will be lost without a plan,
because on the first day almost the whole entitlement is technically at risk
and saying so bluntly reads as an accusation. Once a plan exists it warns about
the days genuinely at stake.

A timeline of the years to the fourth birthday answers what the next four years
look like, which a month-at-a-time calendar cannot. Shortfalls, surplus advice
and balances sit below the fold, read-only: applying is automatic now, so a
second apply button would be a way to get two different answers.

Rejection messages move to one shared place that resolves a parent's name, so
an identifier can no longer surface in their place.
2026-09-17 21:32:15 +02:00
jacobh2 c2e64b504d Author leave on the calendar itself
Planning used to mean filling in a wish list on one tab, pressing a button to
see a suggestion, then pressing another to accept it — with nothing on screen
telling you whether a plan was active. Dragging a range on the calendar now
creates the period directly, clicking a band opens it for editing, and each
change is recomputed and saved in one step. A period repeats never or yearly,
matching what the planner can actually express.

Clicking a day edits that day: its funding type, which pins it so the planner
leaves it alone, and whether the day counts as worked or off. That switch used
to be a mode covering the whole grid, which is what made the calendar confusing.

The season presets move to a rail beside the grid. Picking one lets the planner
choose the placement, then jumps there and flashes it, rather than asking the
user to guess dates the engine already knows.

A sync that funds nothing now says so, naming the period and how many days it
fell short, instead of leaving the screen unchanged and looking broken.
2026-09-17 21:31:54 +02:00
jacobh2 5e96c352fb Persist the wish and pinned flag a planned day is created with
The create and update handlers for a planned day accepted both fields in
their request models and passed neither to the service, so a day hand-edited
in the calendar was never marked as pinned and never kept its link back to
the period that produced it. The allocator would then feel free to retype it
on the next sync, which is precisely what pinning exists to prevent.

Two investigations alongside this found no defect. A period that occurs once
rather than yearly does get placed, and a window that wraps past New Year
keeps the half that falls after today; both are now covered by tests, since
neither case had any. Where those looked broken, the wish had simply run out
of budget for that year, which the interface does not yet explain.
2026-09-17 21:04:44 +02:00
jacobh2 9e27fef78a Fold the leave-wish rejection tests into one parametrised case
Nine tests each rebuilt the same ten-line create call to vary one argument,
which buried the rule every case was actually protecting. They are now one
parametrised test over a shared base, with an id per case so a failure names
the rule rather than an index.

The count of assertions is unchanged: the month and day cases were already
parametrised and are kept as separate entries rather than compressed, since
losing a case to tidy the file would defeat the point. The tests asserting a
successful create stay separate, because each checks a distinct resulting
value rather than merely that nothing raised.
2026-09-17 19:56:52 +02:00
jacobh2 ed5680253e Check in a browser what the unit tests cannot see
Every serious defect in this project was found by opening the app, most
recently a feature that rendered blank cards to every user while its tests
passed, and a progress bar labelled in the wrong unit. Five checks now run
without a person: that the age-four figures on screen match what the API
computed, that a multi-week period draws as one bar rather than fragments,
that Sundays and holidays are red where Saturdays are not, that no identifier
leaks into visible text, and that no page logs an error.

Three of them were confirmed by reintroducing the original bug and watching
the right test fail. Dates are derived rather than written down: Easter is
computed independently instead of asked of the system under test, and the
fixture period is placed relative to today, so the suite does not expire.

It runs as its own job and does not block a merge until it has proved itself
stable.
2026-09-17 19:46:17 +02:00
jacobh2 0fc71101ae Cover the balance entry path and three unguarded rules
Entering a parent's Försäkringskassan balances had no tests at all, despite
being how every number in the planner originates and despite it becoming the
first thing a new user does. The scraper endpoints are pinned per test rather
than read from the ambient configuration, because a local .env sets the flag
opposite to the shipped default and a test that trusts it would pass here and
fail in CI.

Also covered: the hard expiry at twelve, whose rejection path nothing reached
and whose boundary turns out to be inclusive; and the dubbeldagar count cap,
which now drives past the limit and asserts the days beyond it fall back to a
single parent. Two weak assertions were strengthened, including one that
derived its expectation from the same response it was checking and so could
not have failed on a shared error.
2026-09-17 19:46:03 +02:00
jacobh2 9d548da7d6 Guard against schema drift and fields dropped on the wire
Two mistakes have recurred in this codebase: the hand-maintained schema in
db/connection.py drifting from the Alembic chain, and a field quietly going
missing from the hand-written mapping onto the plan response. Tests existed
for individual instances of the second, written after each one was found.

Both are now checked structurally. One test builds a database from the
migrations and another from the hand-written schema and compares their real
shape, ignoring cosmetic column ordering but failing on a missing column,
wrong nullability, absent foreign key or index. The other walks the proposal
dataclass and asserts every field reaches the response, so a field added
later is covered without anyone remembering to extend the test. Anything
deliberately kept off the wire goes in an explicit allow-list.

Both were confirmed by breaking what they guard and watching them name it.
2026-09-17 19:45:50 +02:00
jacobh2 0aeec9c820 Track the frontend lockfile so npm ci can work
The ignore rule for package-lock.json had no leading slash, so it matched at
any depth and kept frontend/package-lock.json out of the repository. The
frontend CI job meanwhile installs with npm ci and caches against that exact
path, neither of which can work when the file is absent from a clean checkout.
The rule is now anchored to the repository root and the lockfile is tracked.
2026-09-17 19:45:37 +02:00
jacobh2 fd39bd9748 Map the tips payload onto the shape the client declares
Tips rendered as blank cards with unresolved placeholder text in their
accessibility labels. getTips was the one entity fetch in the client with no
translation from the wire's snake_case, so every tip arrived without the key
that selects its title and body, and the lookup silently missed.

The mapper now matches its siblings in that file. A test drives a card through
all eleven message keys the tips engine emits, so a twelfth added later fails
loudly instead of rendering an empty card, and two older tests that stubbed the
tips response as an empty object rather than an empty list have been corrected.
2026-09-17 19:24:26 +02:00
jacobh2 b5321c5e8e Correct the coverage gates the guide advertises
The documented gates had drifted well below the real ones: the backend gate
reads 80 in the workflow, not 50, and the frontend thresholds are 96 lines
and statements, 91 functions, 92 branches, not the 80/85 pair recorded here.
Anyone trusting the old numbers would think they had headroom that CI does
not grant. The frontend type-check command is also named correctly now, since
invoking tsc through npx picks up a newer compiler and fails spuriously.
2026-09-17 19:24:11 +02:00
jacobh2 c368042c7e Require a planned day to carry its wish and pinned state
Both fields were made optional to avoid editing test files that were out of
scope at the time, but the API populates them on every planned day, so the
optionality described nothing real and quietly loosened the contract between
the wire format and the client — which is where this codebase has historically
gone wrong.

The inline objects that forced the compromise now go through the typed test
factory instead, so the next field added to a planned day is a one-line change
rather than another round of this.
2026-09-17 18:33:30 +02:00
jacobh2 6a7c0ef255 Draw a leave period as one band, and make red days look red
A six-week summer used to render as a row of disconnected bars, because bars
were derived per week row and grouped by parent and day type. They carried no
identity and ignored pointer events, so there was nothing to click. Bands now
group by the wish that produced them, bridge the weekends and red days inside
a period, and round only the ends that are genuinely the first and last day.
Each day still shows what pays for it, and a hand-edited day is marked as one
the planner will leave alone.

Sundays and public holidays now render red and Saturdays a neutral grey,
following the printed almanacka people compare against, and a holiday's name
is visible in the cell rather than hidden in a tooltip. This also explains the
report that months looked unshaded: weekends had no styling at all, since the
day component handled only red days and klämdagar.

The year selector follows the range the calendar API actually serves instead
of a hardcoded span.
2026-09-17 18:28:16 +02:00
jacobh2 d06c8c8449 Check vacation days against the entitlement before persisting them
Applied batches were validated against the remaining föräldrapenning tiers
but never against semester. That held only while the allocator was the sole
writer, since its ledger spends vacation against a per-year budget. The
calendar is about to let a user force any day to semester and pin it, which
writes straight past that ledger, so nothing would have stopped forty pinned
vacation days against a twenty-five day entitlement.

The check mirrors the allocator's own derivation rather than restating it:
available days are total plus days saved in, and a year with no entered row
falls back to the statutory entitlement instead of reading as zero. The
semesterlagen floor stays out of it, being a bound on what may be carried
forward rather than a cap on what may be spent. Vacation is budgeted per
calendar year, so a period crossing New Year is charged to both.
2026-09-17 18:22:55 +02:00
jacobh2 c1aa4ae0b0 Credit days freed by the same sync before checking the balance
A sync deleted the rows a re-plan had dropped only after apply_proposed_days
had already validated, so capacity being freed in the very same call was
never counted. Shortening one period and lengthening another while near the
pool cap read as pure net-new demand and was refused outright, writing
nothing and losing the edit silently.

apply_proposed_days now takes the keys it should delete, credits those rows
back to their tier and to the dubbeldagar quota during the up-front checks,
and performs the deletions in its own write phase. Validation still runs
fully before any write, which is what SQLite's lack of a cross-request
transaction requires.
2026-09-17 18:12:31 +02:00
jacobh2 6cd60887d5 Add a plan sync endpoint that recomputes, persists and reports the delta
The calendar is becoming the plan: a user edits a period and the schedule
updates immediately, with no separate propose and apply steps. POST
/api/plan/sync does both and returns a per-date diff against a snapshot taken
before any write, so the interface can say what actually moved rather than
guessing from coarse counters.

Pinned days now mean something. A day the user hand-edited keeps its type and
its parent: the loader hides unpinned wish-attributed rows from the allocator
so a re-plan is free to move them, while pinned rows stay visible and the
existing occupancy check refuses to touch them. Their cost is debited off the
pool before any window is funded, following the precedent already set for
preschool planning days.

Propose and apply are untouched. The allocator stays pure.
2026-09-17 17:58:47 +02:00
jacobh2 0e14b4dac8 Flag the superseded sections of the design docs
ARCHITECTURE.md and PLANNING_ENGINE_SPEC.md still described the planning
goal, the per-wish goal override, and a per-parent age-4 ceiling of 96 days
as current. Rather than rewrite two long documents that remain useful as
history, each stale section now carries a SUPERSEDED banner naming what is
true instead. The banners sit above the code fences they annotate, not
inside them.
2026-09-15 21:18:38 +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 7f516f2cab Retire the NextSteps handoff notes and pin the model tiers
The three NextSteps*.md files were per-session handoff notes that had gone
stale; the plan they described is superseded. CLAUDE.md gains a MODEL TIERS
section recording who is allowed to run as what: Opus coordinates, Sonnet
implements, Haiku verifies, and no subagent runs on Opus.
2026-09-15 20:27:48 +02:00
jacobh2 5d9ac5024a Replace the shared MOCK_* fixtures with factories
A frozen shared constant forces a test needing one different field to
either mutate shared state, leaking into every other test, or spread it
by hand. The make*(overrides) factories cover both cases and were already
doing the same job, so the constants were redundant.

MOCK_PARENT_A and MOCK_PARENT_B were distinct identities that partner
tests tell apart, so their uuid and name are now passed explicitly at
each call site rather than left to a factory default -- the values those
tests depend on stay visible where they are used.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-15 07:47:34 +02:00
jacobh2 46e89a4359 Build plan-shape test data with the typed factories
PlanResults, Plan and usePlanProposal still hand-rolled proposal objects
inline -- roughly 29 nested literals in PlanResults alone. Hand-writing a
deep shape is the pressure that produced 219 `as any` casts across this
suite, so the factories existing but going unused left the trap armed.

Values an assertion depends on stay inline as overrides; the factories
supply only the scaffolding a reader does not need to see. A type gaining
a field now breaks one factory instead of silently passing thirty tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-15 07:47:25 +02:00