feat(onboarding): journey PR C — flow component + /onboarding swap behind flag (#1143)

* refactor(onboarding): extract first-year defaults + shared TIC lookup client (journey PR A)

First of four PRs replacing the onboarding wizard with the journey flow
(dev_docs/onboarding_migration_plan.md, local). No UI change.

- Move deriveFirstYearDefaults + parseStartMonthDay out of
  WelcomeOnboarding into lib/company/first-year-defaults.ts and unit-test
  them (11-vs-13-months boundary, UTC month seeding, malformed input).
- Add the missing computeFiscalPeriod unit tests (calendar year, brutet
  ar, first year short/extended, EF calendar-year rule, period names,
  BFL 3 kap. 6-18 month window errors).
- New shared fetchCompanyLookup() client: the single client path to the
  Lens-backed /lookup, typed outcomes (found / not_found / disabled /
  error / aborted), never throws. Fixes the 403/404 conflation: the
  dispatcher's 404 ("Extension not found") and feature-flag 503
  (EXTENSION_DISABLED) now degrade silently instead of rendering as
  "company not found"; only the TIC handler's own 404 does.
- Step2CompanyDetails consumes the helper; identical UX otherwise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(onboarding): journey state machine reducer with full branch coverage (journey PR B, 1/3)

Pure reducer for the journey onboarding: owns every transition and every
CompanySettings write; the component layer only renders steps, runs the
single TIC lookup, and calls the server action.

Encodes the plan's invariants: entry-snapshot history (Back rolls answers
AND stations), lookupRan gates fact-vs-question per field (BankID prefill
without lookup degrades to questions), vat_registered is never defaulted
without lookup data or an explicit answer, entity change wipes downstream,
org_number_invalid bounces to the Företaget station, station jumps rewind
to a station's first step.

34 unit tests: AB/EF found, not-found manual, ceased, BankID prefill
(found + degraded + disabled), first year, brutet år, moms nej, Back from
every step, station jumps, server-error bounces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(onboarding): journey visual primitives + sandbox gallery (journey PR B, 2/3)

Ports the founder-approved concept (artifact c82c9358) to React:

- JourneyOrb: 320-particle canvas sphere with comet travel, check morph
  and the monogram finale (glyph sampled live from --font-display). Own
  component per plan, NOT thinking-orbs. rAF pauses on document.hidden;
  reduced motion renders static frames.
- JourneyTrack: five stations with inked answers; completed stations are
  keyboard-accessible jump-back buttons; answers mirrored to an aria-live
  region.
- Question primitives: Question (ink title + "?" popover, Esc closes),
  ChipRow (fly-to-orb ghost), YearBand (springy fiscal-year preview),
  JourneyDatePicker (year -> month by name -> day), AddressFields
  (Enter-chained, skippable).
- journey.css: concept stylesheet namespaced under .jny on app tokens,
  incl. the no-scroll composition (100dvh + optical-centering balance
  spacer) and the dawn layer.
- /sandbox/journey: internal primitive gallery (auth-free sandbox path),
  demo data only: this page makes ZERO TIC calls.

i18n note: primitives are copy-agnostic (strings via props); the real
flow's sv/en keys land with their consumer in PR C.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: log journey reducer location decision

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(onboarding): annotate ENTITY_PICKED settings as Partial<CompanySettings>

The wipeDownstream return narrows against the inferred initializer type;
tsc strict rejects the reassignment without the explicit annotation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(onboarding): journey flow component behind NEXT_PUBLIC_ONBOARDING_JOURNEY (journey PR C)

OnboardingJourney wires the PR B reducer and primitives into the real
flow and swaps /onboarding behind the flag (wizard remains the default).

Data + error handling parity with the wizard, structurally enforced:
- identical settings payload to createCompanyFromOnboarding (incl.
  ticLookup snapshot, derived vat_number, first-year fields through
  computeFiscalPeriod), same /api/log error logging, same
  org_number_invalid bounce (now to the Foretaget station), period
  validation before submit, generic failure -> retry on the method step.
- lookup degradation: disabled surface silent, transient error shows the
  advisory line; either way every fact the lookup could not provide is
  asked as a question (address, F-skatt, fiscal year, VAT).
- advisory dup check rides the org submit (internal endpoint, never
  blocks), rendered as a quiet fact-line note.

TIC budget: exactly ONE fetchCompanyLookup per confirmed orgnr: Enter on
the manual path, or the auto-submitted BankID deep link (which replaces
the wizard's preverified suppression per the plan addendum). No
debounce-per-keystroke; the journey strictly reduces Lens volume.

Finale per the approved concept: narrated real server steps while the
action runs, check morph, company-initial monogram, Foretagsprofil card,
conditional notes, dawn progression; sv+en strings (128 keys each).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: trigger preview with NEXT_PUBLIC_ONBOARDING_JOURNEY=true (branch-scoped)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-24 13:33:39 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent b771c1f923
commit 51ca574ca4
6 changed files with 1371 additions and 2 deletions
+57
View File
@@ -0,0 +1,57 @@
/**
* First-fiscal-year end-date options, ported from the wizard's
* Step3TaxRegistration rules (BFL 3 kap.):
* - EF always ends 31 December; the first year may run up to 18 months
* (a start after 30 June may roll into next year's December).
* - AB may end any month; 6-18 months from the start date.
*/
export interface FirstYearEndOption {
/** ISO date, last day of the month. */
date: string
months: number
year: number
month: number
day: number
}
function lastDayOf(year: number, month: number): number {
return new Date(year, month, 0).getDate()
}
function monthsSpan(startYear: number, startMonth: number, endYear: number, endMonth: number): number {
return (endYear - startYear) * 12 + (endMonth - startMonth) + 1
}
function iso(year: number, month: number, day: number): string {
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
}
export function efFirstYearEndOptions(startYear: number, startMonth: number): FirstYearEndOption[] {
const out: FirstYearEndOption[] = []
const m1 = 12 - startMonth + 1
if (m1 >= 1 && m1 <= 18) {
out.push({ date: iso(startYear, 12, 31), months: m1, year: startYear, month: 12, day: 31 })
}
const m2 = m1 + 12
if (m2 >= 1 && m2 <= 18 && startMonth > 6) {
out.push({ date: iso(startYear + 1, 12, 31), months: m2, year: startYear + 1, month: 12, day: 31 })
}
return out
}
export function abFirstYearEndOptions(
startYear: number,
startMonth: number,
endMonth: number,
): FirstYearEndOption[] {
const out: FirstYearEndOption[] = []
for (const endYear of [startYear, startYear + 1, startYear + 2]) {
const months = monthsSpan(startYear, startMonth, endYear, endMonth)
if (months >= 6 && months <= 18) {
const day = lastDayOf(endYear, endMonth)
out.push({ date: iso(endYear, endMonth, day), months, year: endYear, month: endMonth, day })
}
}
return out
}