8.8 KiB
Senior Developer — Session 4 Report
Date: 2026-06-01
Baseline: SENIOR_FOLLOWUP_2.md (2 items).
Outcome: Item 1 (tips-engine rules) fully closed — all 9 stubbed rules implemented and tested. Item 2 (frontend integration review) is pending the junior's completion of the wiring work and cannot be closed yet.
Test suite: 159 passed, 0 failed, 52% coverage (above the raised 50% CI gate; was 44%/40% before this session).
Item 1 — Wire tips-engine rules (services/tips_service.py)
What was done
All 9 rules that previously returned None are now implemented. The three already-wired rules (use_vacation_before_expiry, skip_fp_first_12_months, sgi_minimum_after_age_1) were left untouched and used as the reference shape.
No new dependencies were injected into TipsService. Every rule works with data already loaded by _build_context and calls one or two predicate helpers from utils/tip_predicates.py. The pure days_left_at_age_4_projection() helper stood in for analytics_service.get_days_left_at_age_4() where the age-4 calculation was needed — this avoids coupling the tips engine to the analytics service and keeps the rules trivially unit-testable.
New imports added to tips_service.py:
from collections import defaultdict
from app.utils.tip_predicates import (
days_left_at_age_4_projection, # was missing
preschool_buffer_needed, # was missing
red_day_clusters_in_range, # was missing
weekly_intensity, # was missing
# existing four kept
)
Rule-by-rule summary
balance_between_parents
Compares days_used from ctx.leave_by_parent across all parents. Returns an info tip when the gap between the heaviest and lightest user exceeds 30 days. Guards: requires ≥2 parents each with leave data.
preserve_for_age_4
Per parent: counts planned FP days before the child's 4th birthday, calls days_left_at_age_4_projection(days_used, planned_fp_before_b4, total_days), fires a warning tip when expiring_at_b4 > 0. The 96-day savable ceiling comes from settings.rules.max_savable_days_at_age_4.
use_dubbeldagar_strategically
Builds a date → set[parent_uuid] map for all FP days within the child's first 15 months (settings.rules.dubbeldagar_months_limit). For any date where ≥2 parent UUIDs appear and none of the corresponding PlannedLeaveDay records has is_dubbeldag=True, fires an action tip pointing at that date.
lagsta_niva_coverage
Per parent: reads tier_data.sjukpenning_left and tier_data.lagsta_niva_left from the latest leave snapshot. Fires an info tip when sjukpenning is below 30 days but lägstanivå days remain. Surfaces the common case where parents exhaust the high-rate tier without realising the low-rate tier could cover additional days.
stretch_time_at_home
Per parent: computes weekly_intensity() over the next four weeks, then checks days_left_at_age_4_projection. Fires an info tip when both conditions hold: intensity ≥7 days/week AND projected days remaining at age 4 <60. The double gate avoids noise — high intensity is fine if there are plenty of days left.
wrap_weekend_with_vacation
Salary gate: monthly-salaried parents only (per ARCHITECTURE.md §6 and product intent). Only fires when workspace_planning_goal == "max_money". Looks 8 weeks ahead for FP days planned on a Friday where the following Monday is also FP. A monthly-salaried parent gets the same take-home either way, so swapping either boundary day to vacation saves an FP day at no income cost.
wrap_red_days_with_vacation
Same salary and goal gates as above. Looks 12 weeks ahead, calls red_day_clusters_in_range(today, look_ahead) (uses holidays.Sweden), and fires when a parent has an FP day immediately adjacent to a cluster (the day before cluster_start or the day after cluster_end). Tip is advisory per the ARCHITECTURE.md caveat about kollektivavtal.
long_break_parent_swap
Iterates future BreakPeriod entries. For each break, counts working days in the window and counts each parent's planned days (any type) that fall within it. If a parent covers fewer than 50% of working days, fires an info tip naming both the under-covered parent and their partner. The suggestion: one takes FP while the other uses vacation, so both are home for the break.
reserve_for_preschool_planning_days
Calls preschool_buffer_needed(child, settings.rules.preschool_planning_days_per_year) — returns 0 if the child has no preschool start date or preschool hasn't begun yet. If a buffer is needed and any parent's days_left < buffer_needed * 2, fires an info tip. The × 2 factor gives an early-warning window before the days are completely gone.
Tests — tests/unit/test_services/test_tips_service.py (new file, 34 tests)
All rules are tested by constructing TipsContext directly — no database, no fixtures, instant feedback. Helper factories (_make_child, _make_parent, _make_leave, _make_fp_day, _make_break, _make_ctx) keep individual test bodies short.
Every rule has at minimum one fire case and one no-fire case; rules with multiple guard branches have a test per guard:
| Rule | Tests |
|---|---|
balance_between_parents |
4 (fires at gap=60; no-fire gap=15; single parent; missing leave data) |
preserve_for_age_4 |
4 (fires; no-fire; no children; planned days counted correctly) |
use_dubbeldagar_strategically |
4 (fires; no-fire is_dubbeldag=True; single parent; outside window) |
lagsta_niva_coverage |
3 (fires; no-fire ample sjukpenning; no-fire lagsta exhausted) |
stretch_time_at_home |
4 (fires; no-fire low intensity; no-fire ample days; no children) |
wrap_weekend_with_vacation |
4 (fires; no-fire hourly; no-fire wrong goal; no-fire Friday only) |
wrap_red_days_with_vacation |
3 (fires; no-fire wrong goal; no-fire hourly) |
long_break_parent_swap |
4 (fires; no-fire both covered; single parent; past break) |
reserve_for_preschool_planning_days |
4 (fires; no preschool date; days sufficient; preschool future) |
The wrap_red_days_with_vacation fire-case test finds the first weekend cluster dynamically via red_day_clusters_in_range(today, today+12w) — it is robust to any run date without relying on hardcoded calendar entries. The wrap_weekend_with_vacation tests use a _next_weekday() helper to locate the next Friday/Monday pair within the look-ahead window.
tips_service.py coverage went from 0% (all stubs) to 75%. The uncovered lines are the TipsService class methods (get_tips, _build_context) which require real repositories — these are integration-level and appropriate to cover later with API tests.
Item 2 — Frontend ↔ backend integration review
Status: Pending junior completion. The junior is still working through JUNIOR_FOLLOWUP_2.md (broken build + page-stub wiring). The two seams flagged for senior review remain:
- Auth flow — register/login → JWT handling →
AuthContexttoken storage and refresh; CORS inmain.pymatching the real frontend origin. - Manual parental-leave entry path —
FetchDataServicemanual path,useParentalLeavehook, and the newgetParentalLeaveAPI client aligning with the backend contract.
When the junior signals the wiring is done: smoke-test uv run uvicorn app.main:app --reload + npm run dev, walk create-child → plan-days → view-analytics, and check CORS headers and JWT refresh in the browser network tab.
CI gate
Raised --cov-fail-under from 40 → 50. Current total is 51.67%, so there is a small positive buffer. The 90% target requires covering routers (currently 0%) and the workspace service (30%) — that needs API-level integration tests, which are a natural next step once the frontend wiring lands.
Final state
| Metric | Before this session | After |
|---|---|---|
| Tests passing | 125 | 159 (+34 tips tests) |
| Coverage | 44% | 52% |
| CI gate | 40% | 50% |
| Rules implemented | 3 / 12 | 12 / 12 |
tips_service.py coverage |
0% (all stubs) | 75% |
| Frontend integration review | Not yet | Pending junior wiring |
What remains
- Frontend integration review (Item 2) — unblocked once the junior finishes
JUNIOR_FOLLOWUP_2.md. Quick smoke test + read-through of the auth/manual-entry seams. - CI gate → 90% — requires router integration tests (API layer). Natural follow-up once the frontend is wired and there's a working end-to-end test harness to build on.
- Postgres repository impls — pattern is in place (copy SQLite impl, swap
aiosqlite→asyncpg,?→$1, $2). Deferred until SQLite hits a scaling wall. - FK scraper / BankID — blocked by legal/ToS gate;
scraper_enabledflag is the activation path.