Imported from GiuseppeDM98/net-worth-tracker (
AGENTS.md). Install upstream withnpx skills add GiuseppeDM98/net-worth-tracker. Copyright stays with the author.
AI Agent Guidelines — Net Worth Tracker
Conventions and recurring pitfalls. Rules only: how each one was learned lives in git log, and what each feature
is lives in CLAUDE.md — this file says only what an agent can get wrong.
Companion documents — do not duplicate their content into this file:
| File | Owns |
|---|---|
CLAUDE.md |
Architecture snapshot, feature index, Known Issues (open debt) |
DESIGN.md |
The aesthetic spec (normative frontmatter + narrative). Never regenerate it |
PRODUCT.md |
Users, positioning, accessibility posture |
SETUP.md |
Env vars, Firebase, emulators, Playwright, local-verification troubleshooting |
WORKFLOW.md |
Standing session rules + the guided-verification protocol (portable across repos) |
doc/guide/*.md |
One file per domain — a page, a tab, an integration, a subsystem: the full rules for that area. Read the one you are about to touch |
COMMENTS.md · DEVELOPMENT_GUIDELINES.md |
How to write code and comments here |
0. How this file is organised
This file is the core every session loads: every rule that holds repo-wide — conventions, data/state patterns,
UI patterns, testing, workflow. A rule about one area's behaviour lives in doc/guide/<tema>.md, one file per
page, tab, integration or subsystem, read only by whoever touches that area: the pages (panoramica, patrimonio,
cashflow*, rendimenti, …), the cross-cutting subsystems since 2026-09-06 (stati, dialog, temi,
account-condiviso-demo, Settings inside impostazioni), the test harness since 2026-09-20 (e2e-emulatori), the
shell and the persisted cache since 2026-09-30 (shell, cache-persistita), the first-screen census since 2026-10-10
(prima-schermata; its record — baselines, fixture table, widened entries — since 2026-10-17, prima-schermata-misure), and since 2026-10-10 the charts (grafici) and the server layer (server), which left this file
whole, the two-step dialogs and the form reset joining dialog the same day. How to use them: section 3 is the index — for each guide the 3–4 things to know BEFORE opening it, then the
pointer; the three subsystem guides born from sections 2 and 4 keep their stub under the heading the code cites
(§ Server Layer and API Authorization, § Dialog Form Reset, § Two-Step Create Dialogs, § Recharts). A stub that grows
past 3–4 points is a guide leaking back (2026-09-20: ten had, up to 2700 characters each; 2026-09-30: four more, up to
2970). Sizes (owner, 2026-10-10): this file ~1000 lines, a guide ~500 — past that a guide splits by sub-topic in a
session of its own, never trimmed at a session's close (prima-schermata.md left velocita.md at 514 lines). Each
guide opens with a scope line, lists its files and the suites to run after a change there (§ Files), and ends with
its Per-page blind spots — behaviours that look like bugs and are not. A session-closing lesson about a domain goes
in that domain's guide, never here. Here a lesson is RULE + DATE + WHERE IT IS PINNED (the test or the file that
holds it): how it was learned is git log's.
1. Conventions
Italian Localization
- UI text Italian, code comments English.
formatCurrency(),formatDate()(DD/MM/YYYY),Sottocategoria(no hyphen),Buongiorno Giuseppe(no comma). English on purpose:Hall of Fame,FIRE e Simulazioni,Cashflow,Assistente AIand the standard metric names;Current Yield→Rendimento Corrente. formatPercentageexists TWICE and the two disagree:lib/utils/formatters' istoFixed(40.71%), the it-IT one isIntl('it-IT')(40,71%) —formatPercentageIt/formatNumberItinlib/utils/formatters.ts, to whichchartServiceDELEGATES (2026-08-31);formatNumberIttakesdecimalsand pins the width,formatNumberdoes not. Import from the module the surrounding component uses, or one surface prints both separators. A narrative module the server reads imports fromformatters, never fromchartService(which top-level-imports the client Firebase SDK):cashflowNarrative,patrimonioNarrativeandexpenseSplitNarrativeare verified SDK-free; a pure module feeding a screen throughchartServicestill mocks the Firebase chain in its tests.- Curly apostrophes break
.tsx(TS1127): delimit with double quotes. JSX eats the space next to an inline tag or wrapped expression once Prettier breaks the line:{' '}on both sides of<strong>/{expr}. Aninline-flexchip drops the leading space of a text-node child («69,7%verso FI»): give the words their own<span>and letgap-1space them. - Italian
Intlbreaks naive matching: four-digit amounts print ungrouped (1821,01 €but29.800,00 €) and the€carries a non-breaking space — anchor as/^821,01[\s ]*€$/, never concatenateamount + ' €'. And in the DOM the space is gone (2026-10-10, owner):NarrativeSegmentsdraws a figure's unit («€», «pt», «pp») as its own span atml-[0.2em](splitFigureUnit,components/ui/narrative-text.tsx; the span carriesdata-figure-unit, which is how the first-screen census still counts the pair as ONE figure — doc/guide/prima-schermata.md), because in Geist Mono the no-break space is a whole cell. The segment's TEXT keeps it (narrativeToText, accessible names, identity tests),textContentdoes not: a spec reading a verdict, a reading or a strip cell anchors[\s ]*€, never' €'(ten rewritten that day); figures printed outsideNarrativeSegments(RecordRows, the heroes, the tables) still carry the space.
Firebase Dates and Timezone
toDate()to convert;getItalyMonth()/getItalyYear()/getItalyMonthYear()for domain grouping, neverDate.getMonth()/getFullYear(). Server "today" window (cron):getItalyDayBoundsUtc().- A reader typed
Expense[]may still hand out raw Timestamps — convert in the service (2026-09-18:getExpensesForCostCenterdid,getAllExpensesconverted, andExpenseDialogthrew «Invalid time value» on a row from the other reader). - Inclusive month upper bound:
endOfMonthBound(year, month)— the 1st at midnight drops the whole closing month.<input type="date">defaults takegetItalyDateIso():toISOString()proposes yesterday from 22:00.
Tailwind Breakpoints and Responsive Layout
desktop:= 1440px, neverlg:. Dialog-internal layouts usesm:; portrait wrappersmax-desktop:portrait:pb-20.min-widthis inclusive:h-11 desktop:h-9measures 36px at exactly 1440, the Playwright desktop width (2026-09-21).- NEVER mix arbitrary
min-[px]:with named breakpoints on the same property — named ones compile to rem and v4 emits them last (sm:grid-cols-2 min-[960px]:grid-cols-3is 2 columns everywhere ≥ 640px). Betweentablet:(768) anddesktop:(1440) use a container query (@container+@[640px]:, all px). Container queries when one component renders at several widths: column count = container query, drawer-vs-inline = viewport; per-cell@containerscales a monetary value to the CELL width. Tailwind 4 emits@containerAFTER@media(2026-10-16): a container query on a property that also has adesktop:class (columns, spans, grid rows,display) carriesmax-desktop:— the landscape stage'smax-desktop:@[880px]:contentson a grid that isdesktop:grid-cols-12—, or it wins at 1440 (the cascade, not the width, decides); one inside a tile that holds at every width on purpose (@[760px]:table-cell) does not. The landscape stage (2026-10-16; DESIGN.md → The First-Screen and Closed-Row Rules): the tablet composition's classes live inlib/utils/tabletComposition.tsalone, as literals (__tests__/tabletComposition.test.tsholds the rules over its constants and the nineteen composed grids: the verdict's boxVERDICT_CELL_CLASS, the 768 gridCOMPACT_GRID_WRAPPER_CLASS/COMPACT_GRID_CLASSwithWIDE_CELL_CLASSplaced bylayoutTabletSections, «Il resto»REST_CELL_CLASS, the scope aside yielding to the answerASIDE_YIELDS_TO_ANSWER_CLASS, the measured heights throughstageCustomPropertiesandleftStackSticks). A page wraps its verdict and its grid in a NEWdiv(STAGE_WRAPPER_CLASS=@container; neverPageContainer,container-typewould anchor afixeddescendant) aroundSTAGE_CLASS, withuseTabletStage()'s ref; the verdict's boxdata-stage="verdict", THE tile's celldata-stage="lead", «Il resto» and the rows inside ONEINDEX_CLASSbox (a box of several rows inside it,INDEX_GROUP_CLASS). Below a content box of 880px nothing moves (the wrapper and the grid are what they were, the indexcontents); from it, below 1440, the grid turnscontentsand the stage is a1fr | 360pxgrid — the answer on the left, sticky when it fits the screen (data-stick, measured), the index on the right at least as tall (--left-h); the heights CSS cannot know are measured by the hook into custom properties. A grid composed in some states only takes THE tile's cell throughleadCellClass(composed)and the rest-less statesPLAIN_GRID_CLASS, never a bareLEAD_CELL_CLASS— on a plain grid its landscape classes still answer the stage's container and leave the right column empty (Obiettivi with no goal, 2026-10-16). A page handsuseTabletCompositionthe rows it RENDERS, in DOM order (768's pairing). The closed rows ANSWER in the index (2026-10-16, DESIGN.md → The Closed-Row Rule): ONE puredescribe*ClosedAnswersper page in its narrative/summary module, given by context (RowAnswers,components/ui/row-answers.tsx) and read byTileRowShellthroughuseRowAnswer— never a prop through the tiles, never on an open or failed row,nullto keep the scope aside; each guide's § Composizione mobile lists them. A tile that can be a row lays out its inside by its CONTAINER, never by the viewport (2026-10-16): in landscape every row opens in a 360px index, a phone's width at a tablet's viewport — atablet:inside such a tile draws the wide layout in 320px (Mutuo's KPIs, Coast's Afflussi timeline, Storico's Composizione legend:@container+@[480|560px]:; Analisi's Flusso picks bar-and-rows by its cell width,useElementWidth< 608). And a container on a row tile's ownsectionanswers the page's queries too: it becomes the nearest container of the trigger, so the index's@[880px]:answer measured the 360px row and stayed hidden (BaseDiCalcoloTile, 2026-10-16) — put@containeron an inner box. Checked by opening every row in landscape and at 768 on every surface and measuring each tile's right edge, not onlymain's. The SHELL keys on a named viewport variant, the content on its container:tablet-landscape:(@custom-variantinapp/globals.css,orientation: landscapeand 1000–1439px) is for the shell only — the icon rail, the one-row header, the tab bars with labels (doc/guide/shell.md § Navigation) — because the shell has no container; it is declared after the core variants so it is emitted aftermax-desktop:landscape:and beats it on the same property. A composed page's content never uses it. - A grid item stretches to the row height, a normal-flow child does not inherit it without its own
h-full: both the grid-item wrapper and the carddiv. sticky, three rules: it travels only inside its containing block — put it on the box whose PARENT is the tall one and prove it by scrollingmainin a spec (2026-09-22, the compactPageHeader's mobile navbar,e2e/settings.mobile.spec.ts); on a grid item it needsself-start, or a stretchedsticky top-6column has no room to travel and silently behaves as static; its offset is measured from the scroller's CONTENT edge, padding excluded (2026-09-14, Strumenti's actions column:sticky right-5mirroring a-mx-5 px-5wrapper sat 20px over the last cell) —right-0, and draw the edge rule only whilescrollWidth > clientWidth, measured.- Horizontal page scroll on mobile: an implicit-
auto-track grid expands to its widest child — explicitgrid-cols-1andmin-w-0on flex/grid children (they default tomin-width:auto); to center one flex child useself-center, notitems-center.mainis the horizontal scroll container, not the document:<main class="flex-1 overflow-y-auto">'s non-visibleoverflow-ycomputesoverflow-xtoauto, sodocument.scrollWidth - clientWidthreads 0 while the page scrolls sideways — assertmain.scrollWidth === main.clientWidth, and measure the elements, not the container: walkmain *and flagrect.right > main.getBoundingClientRect().left + main.clientWidth(at 1440mainstarts 256px in), the culpable node is the fix,.sr-onlydescendants excluded from the walk (2026-09-20,e2e/history.mobile.spec.ts: a visually hidden TABLE is clipped to 1px but its cells keep their rectangles). Reference guard:e2e/fire.mobile.spec.ts. - One scroll container per region: a nested scrollable captures the wheel (desktop-only symptom), and
overflow-x-hiddenon an ancestor CLIPS a descendant'soverflow-x:auto. An overflow INSIDE a tile never reachesmain(2026-09-14, «Entrate per categoria»: 37px past the tile's border, 0 onmain): measure a list against its ownsection(e2e/cashflow.tracciamento.spec.ts), let a column yield under a container query.max-won atddoes not bind an auto-layout table (2026-09-14, the armed row of the Movimenti table): constrain the BLOCK inside the cell.
shadcn Card and Dialog Surface
CardHeaderisflex flex-col: aflex justify-betweenrow inside it makes aflex-1grandchild act vertically (truncatedies,shrink-0siblings get pushed off-screen) — use a plain<div className="px-4 py-3 flex items-start gap-2">.ResponsiveModalis the ONE modal (2026-08-31): every surface with a form, a list or a report goes through it; the only plain primitives areLogoutDialog(anAlertDialogbecause it interrupts,role="alertdialog", focus on «Annulla») and the popovers (doc/guide/dialog.md).DialogDescription/DrawerDescriptionis required in everyDialogContent/DrawerContent(sr-onlyif it should not show), never silenced witharia-describedby={undefined}:ResponsiveModalmakes thereadingthe Description throughasChild, or rendersdescriptionsr-only.- A shadcn wrapper's own classes ride through
asChildand win the merge (2026-09-14,ModalStatusLine):DialogDescriptionhandstext-sm text-muted-foregrounddown, a child runningcn(own…, className)letstailwind-mergekeep the wrapper's, and a font-size utility also drops aleading-*— put the incomingclassNameBEFORE the classes that must win, and read the result withgetComputedStyle.
Layout and Color Tokens
- Never hardcode structural colors in shell components —
bg-background,text-foreground,border-border. - Sign colors are tokens:
text-positive/text-destructive, chipsbg-positive/10, resolved viagetMetricValueColor()— dropdark:variants (the token swaps itself); the function returns neutral for thecurrencyformat by design, signed currency usessignChipClass/signTextClass. No legacytext-emerald-*is left (the Tracciamento feed 2026-08-22;ExpenseTable, the dividend dialogs and table 2026-09-14;budgetProgressStylespeaks tokens). An expense type has ONE colour map (lib/constants/expenseTypeColors.ts, 2026-09-14): the feed's dot, the table's badge and the hero's legend; a row'sincomeis the sign tokenpositive, each outflow a chart slot, the SERIES (bars) take chart slots, never the sign token. Sign tokens mean gain and loss, and nothing else: a neutral delta (a class gaining share) staystext-muted-foreground. A figure that PRINTS as zero takes no sign and no sign token, whatever the raw value (2026-10-10, the phone composition: a strip cell and the tile beside it must print the same zero — DESIGN.md → The Lifted-Figure Rule; on the tiles since 2026-10-12): decide the «−» on the formatted text withwithTypographicMinus/formatEuroMinus(lib/utils/formatters.ts, whereisPrintedZerolives, pinned by__tests__/formatters.test.ts), never onvalue < 0; a percent or a gap in points takesprintedSign/formatSignedPercentIt/formatSignedPointsfrom the same module (the Rendimento (TWR) tile since 2026-10-14: its hero and chips repeat a strip cell), a signed euroformatSignedEuro(Patrimonio's G/P and Storico's «ultimi 12 mesi» chip since 2026-10-14, the chip's percentage since 2026-10-15,__tests__/evoluzioneTileChips.test.ts). The sites outside the composed surfaces (the rest of Rendimenti and Storico, email, PDF) still decide on the raw value, on purpose — count them withgrep -rnE "< 0 \? ['\"]−" lib components app, never quote a number (the helper itself is among the hits). --warningis near-white in light mode: text on abg-warningfill MUST betext-warning-foreground; a caution reading and the verdict's dot usetext-warning-foregroundtoo.- A chart slot is not a text colour —
--chart-1..8target ~3:1 against a plot area (text-[var(--chart-3)]measured 1.02:1 on one theme; the 2026-08-30 tail audited the twelve blocks to 3.38:1 worst case, so the range is 1..8; but on 2026-09-18--chart-3light measured 2,74:1 and--chart-1dark 2,62:1 ON A CARD — re-measure before leaning on that floor, doc/guide/temi.md § Per-page blind spots). The semantic amber is--warning-foreground; onlyExpenseTable's chips are exempt. A chart slot used as TEXT is held to 4,5:1:__tests__/actionColorContrast.test.tsmeasures COMPRA/VENDI/OK across all twelve theme blocks, on--cardand on the chip's owncolor-mixfill (the HARDER surface). - A computed custom property comes back as
lab(), never as theoklch()you authored: anything that READS a colour token parseslab()(and#hex) too and never asserts/^oklch\(/— throughlib/utils/colorParse.ts, the ONE parser since 2026-10-08. Two readers were DEAD CODE from the day they were written:useActionColors' legibility clamp (until 2026-09-21, raw chart slots as 10-18px text at 2,39-4,02:1) anduseChartColors' luminance filter (until 2026-10-08,__tests__/chartColorsContext.test.ts). Worked example:lib/utils/actionColor.ts, whose test feeds it the browser's own serialisation. - Sidebar tokens:
--sidebar-accentis a background,--sidebar-accent-foregroundtext ON it; hover on inactive itemshover:text-sidebar-foreground. Inlinestyleblocks Tailwind hover variants: migrate to classes first. - CSS custom properties never reach emails or the PDF (both render outside the DOM): the sign hexes there are permanently out of sync (doc/guide/email-pdf.md § Per-page blind spots).
2. Data and State Patterns
React Query and Derived State
- Query + invalidation, never realtime — and Firestore is not the limit (measured 2026-09-26, the speed dossier:
with the emulator answering in ~1 ms Cashflow still took 2,1 s and Storico 2,3 s to show a figure — browser CPU and
the boot chain, not the database; in production a round trip is 50–150 ms, so what counts is round trips IN SERIES).
No
onSnapshot, no migration (Postgres/Supabase/Convex would rewrite ~40 services, the rules and the harness without touching the measured causes): the two Firestore levers are the function region (SETUP.md § Function region) and the materialized per-page summaries (§ Caching). - A page or a component never calls a read service: it reads a HOOK (2026-09-29). One key per collection in
lib/query/queryKeys.ts, one hook apiece inlib/hooks/:useAssets,useSnapshots,useExpenses(and its windows),useExpenseCategories,useSettings(the ONE['settings', ownerId]),usePensionContributions,useAssetTransactions,useCostCenters,useGoalData,useDividendReceipts,useHallOfFame,useMortgageInstalments,useDashboardOverview. A service that computes from collections takes them as an argument (2026-10-04,getAllPerformanceData(userId, forceRefresh, inputs?)). An imperative read (a refresh, a dialog reading once after a write, the CSV import before it commits) goes through the hook's exported…QueryOptions(ownerId)withqueryClient.fetchQuery— same key, same reader, the global staleTime;staleTime: 0where the CURRENT document is the point («Annulla» on Impostazioni, the pre-read of its «Salva», the import's commit). A form seeded from a document OBSERVES its hook and settles the seed during render (Impostazioni since 2026-10-08): the owner's first document seeds the draft, a fresher read re-seeds only while nothing is dirty, «Annulla» is a RE-READ (fetchQuery,staleTime: 0) then a reset — never a copy kept in memory (doc/guide/impostazioni.md). The closing grep of a session:grep -rn "getSettings(\|getAllAssets(\|getUserSnapshots(\|getAllExpenses(\|getAllCategories(" app components— hooks and services only. Every writer invalidates the key its readers read (queryKeys.settings.allfrom the seven places of doc/guide/impostazioni.md § Settings — the FIVE places,goals.allfrom Obiettivi AND the assistant's goal card). - The expenses are read by WINDOW, and
lib/utils/expenseWindows.tsis the ONE source of the windows (2026-09-30): Tracciamento and Divisione (trackingWindow), Budget (budgetWindow,budgetSuggestionWindow) and FIRE (fireWindows) readuseExpensesInRange(ownerId, window), one key per window UNDERexpenses.all; Storico, Analisi (owner's decision: a window left out only 48 rows of 1547) and the cost centres read the whole list. A window is the UNION of what its page reads — a new reader widens it in the same commit and joins__tests__/expenseWindows.test.ts; the bounds are calendar days in both calendars, never UTC; neverplaceholderDataon a window (doc/guide/cashflow.md § Expenses by window). - A page over several keys composes ONE read state (
composeReadState,lib/utils/readState.ts):loadingwhile any query reads,loadFailedwhen any failed — thenresolveSurfaceState. Storico reads six keys this way. - Invalidate all related caches after a mutation; asset mutations need a dual invalidation (
queryKeys.assets.allqueryKeys.dashboard.overview: the Patrimonio hero reads the overview).
useMemofor derived state, neveruseEffect + setState.forceMounttabs deriving from a sibling's data MUST use React Query (a mount-time loader runs once and the tab goes stale until reload); invalidate unconditionally on expense save/delete.initialDataon a query with a globalstaleTimesilently disables its fetch (5min +refetchOnWindowFocus: false): useplaceholderData. Lazy-gate expensive panels withenabled: !!userId && isOpenand readisLoading, notisPending, on a disabled query (isPendingstays true forever).- An async view must gate on EVERY query it reads (queries defaulting to
[]short-circuit into "nothing tracked yet" on a cold load) and a failed fetch is not an empty set: routeisErrorto arole="alert"notice first. - State belonging to a subject must be stored WITH its subject, not reset by an effect (banned by
react-hooks/set-state-in-effect):useState<{ scopeKey, value } | null>and derive, so a stale key falls back to the default with no effect and no extra render.
Dialog Form Reset
- The reset
useEffecttakesopenin its deps, starts withif (!open) returnand holds ONLY the react-hook-form calls (reset,setValue,replaceTiers); the dialog's ownuseState(step, status, toggles, a selected id) is settled during render keyed on(open, record)(2026-09-06,react-hooks/set-state-in-effect). The new-record branch enumerates every field and callsreplaceTiers([]);useWatch()for render,getValues()for handlers, neverwatch(). - Il resto — the fields the new-record branch must enumerate and
replaceTiers([])— in doc/guide/dialog.md § Dialog Form Reset.
Two-Step Create Dialogs (AssetDialog, ExpenseDialog)
- A form whose fields depend on a discriminant is a two-step dialog (
AssetDialog,ExpenseDialog, kept in step): create opens on step 1, edit skips to step 2, andsetStep(record ? 2 : 1)is settled during render, never inuseState's initializer, even where the host mounts the dialog only while open (Patrimonio since 2026-10-07; guarde2e/assets.rows.spec.ts«Nuovo after Modifica opens on step 1»). - A marker on a label is a claim the validation honours:
*/(opzionale), the zod schema and anyonSubmitguard agree (2026-08-30, Sottocategoria). - Il resto — when a discriminant earns a step, the optional back-link callback as the
Select's switch, the module-level picker withIconas a component, step 1 re-pointing the category — in doc/guide/dialog.md § Two-Step Create Dialogs
Firestore Writes
updateDoconly touches fields present in the object andremoveUndefinedDeepstripsundefined, so clearing an optional field needsdeleteField()— not allowed withsetDoc()withoutmerge:true;removeUndefinedDeepmust stay deep, preservingDate/Timestamp/FieldValue. The clear-guard depends on whether partial callers exist:averageCost/taxRate/displayTickerare written only byAssetDialogwith a complete form, so=== undefined → deleteField()is safe;leverageRatioalso rides on plainupdateAssetand needs the'leverageRatio' in updatesguard, or a price refresh wipes it.runTransaction: ALLtx.get()before ANY write — aget→updateloop breaks on the second doc and is invisible when the function is mocked; aggregate deltas per docId first (template__tests__/updateCashAssetBalancesAtomic.test.ts), success toasts AFTER the reconcile returns. Firestore rejectsundefinedinside an array element:assetAllocationService.tsbuildsdocDataby hand, so its array fields need a whitelisting serializer with conditional spreads.- A bare
.set()on a server-owned doc DELETES every field its object omits, and the daily cron re-runs them all. A value that disappears "sometimes" is a periodic write over a window, never a TTL (2026-09-07: the snapshot cron rewrites only the CURRENT month). Keep the replace and carry the hand-written fields across it (preserveUserAuthoredSnapshotFields, pinned by__tests__/apiAuthRoutes.test.ts→ keeps the Storico note of the snapshot it overwrites);merge: trueis the wrong fix whenever the doc holds a MAP the pipeline recomputes.
Firestore Queries and the Rules
- A
listmust carry the constraint the rule needs, or it is refused entirely: every collection guarded byallow read: if canAccess(resource.data.userId)rejects a query that does not already filter onuserId—permission-deniedat ANY result size, and a batch built from the empty result silently does nothing (deleteExpensesByImportBatchis the correct shape). Unit suites cannot see this: only an emulator exercise driving the CLIENT SDK evaluates the rules. Max 3.where()calls on a chain that will be unit-tested; a 4th breaks the mock chain.
Caching
- A per-user pre-computed cache keys EVERY determining input (
performance-cache/{userId}: a hash of the whole snapshot series, the base signature, the risk-free rate, the dividend category; a 6h TTL covers what the key cannot; reads/writestry/catchfire-and-forget;Date↔Timestampfield-by-field, never JSON). A figure whose inputs the key does not cover stays OUT of the document (2026-10-04, Rendimenti's dividend yields — doc/guide/rendimenti.md). A changed FORMULA is the one input no signature sees — bumpCACHE_MATH_VERSION(v8); verifying by hand, press Aggiorna (forceRefresh) first; a new optional field needs no bump if paired with?force=true; wire «Aggiorna» torefresh(), never to a barerefetch(). - Global shared cache (benchmark, FX, ECB): natural key as doc id, no
userId,read: isAuthenticated(); write: false; clientstaleTime= server TTL minus headroom. A shared per-ticker cache the client NEVER reads (instrument-profile-cache, 2026-09-28): rulesif false, Admin SDK only, ONLY the external source's answers (mergeFieldsper module) — doc/guide/allocazione.md § Esposizione. - A server-owned materialized summary is fresh only while every write to an input invalidates it
(
dashboardOverviewSummaries/{userId}, 2026-10-03): a new writer owes its invalidation in the same commit; fresh = noinvalidatedAt, samesourceVersion, same Italian day, ≤ 6 h; rebuilt inafter()under a precondition — doc/guide/panoramica.md § The materialized summary. - The React Query cache is persisted to IndexedDB (2026-09-29) →
doc/guide/cache-persistita.md: only thePERSISTED_QUERY_PREFIXESkeys (lib/constants/persistCache.ts), only successful reads, never the demo uid;gcTimeper prefix (applyPersistedQueryDefaults). BumpPERSIST_CACHE_VERSIONwhen a persisted payload's field is renamed, removed or retyped. Three traps: a surface outsideProtectedRoutegates onuseIsRestoring(); the cache is forgotten at sign-out by the provider (SignOutCacheGuard); the persister's first write is immediate (e2e/freshness.spec.ts). RollbackNEXT_PUBLIC_PERSIST_QUERIES=false.
Server Layer and API Authorization → doc/guide/server.md
- Route = auth → validate → fetch → ownership check → delegate → return, with no Firestore queries or business logic in the handler. The Admin SDK bypasses Firestore rules, so ownership is checked after the doc loads. Owner-scoped routes call
assertCanAccessAccount(decodedToken, ownerUserId)and never fall back todecodedToken.uid. - A date in a request is a STRING first (2026-10-04): every request date goes through
isoDateSchema+parseOr400inlib/server/validation.ts, never anas { … }cast. On its own,z.coerce.date()turnsnullinto 1970-01-01, which gives a 200 instead of a 400. Pinned by__tests__/performanceYieldsRoute.test.tsandserverValidation.test.ts. - A Vercel Function never
require()s an ESM-only package, on any Node version (2026-10-08, PR #436):package.jsonoverrides→jwks-rsa ^3.2.2andengines.node"24.x", held by__tests__/vercelConfig.test.ts. Grep a new server dependency'srequire(calls. The emulators cannot see this; only a real token on a deploy shows it. tscdoes not protect aserver-onlymodule (2026-09-06): importing one from a client page type-checks and only fails in the browser. Client pages read through a client reader (lib/services/dividendReceiptsService.ts), and tests mock it withvi.mock('server-only', () => ({})).- Il resto — the cron routes and
CRON_SECRET(/api/portfolio/snapshotkeepscronSecret), theServer-Timingheader (lib/server/serverTiming.ts, its stages, the recorder, counts beside the body),REGISTRATION_WHITELISTwithoutNEXT_PUBLIC_, the files and the suites — indoc/guide/server.md.
Dynamic Imports and Module Hygiene
- Components must be at module level — one defined in a render body is a new type every render (remount,
AnimatePresenceenter never plays,useEffect([])re-fires, the React Compiler throws).react-hooks/static-componentsflags ANY component obtained from a call during render,useMemo(() => lazy(…))included (probed 2026-09-06): only a property read of a module constant passes — the icon pickers' ONE mapLAZY_CATEGORY_ICONS(IconPickerPopover), one chunk per icon since 2026-09-30 through the 121 loaders ofcomponents/expenses/categoryIconLoaders.ts, neverimport('lucide-react')by a runtime name norlucide-react/dynamicIconImports(+49 KB gz, measured); a new curated name needs its loader (__tests__/categoryIcons.test.ts) — doc/guide/cashflow.md. - A library several pages use goes behind ONE module of real code (2026-09-30): recharts only from
components/ui/charts/recharts.ts, which bindsexport const X = RechartsX— Turbopack batches a library by where it is ENTERED (four identical 350 KB copies, one per page) and a barrel of bare re-exports is transparent to it;perf:budget'slibraryCopiesis red at two copies. - A chart inside something closed by default is a
lazyComponentat MODULE level (2026-09-30,components/ui/lazy-component.tsx) with afallbackof the chart's OWN height (layout-shift0). Notnext/dynamic: React.lazy + Suspense suspends on every first render and React 19 holds a committed fallback ~300 ms. Preload withusePreloadWhenIdle(ARRAY, enabled)— a module-level array,enabled= the page's data is in (an «idle» browser waiting on Firestore is not idle: +130 ms on Analisi) and, where only one width draws the chart, that width (Patrimonio's sparkline, phone only, 2026-10-07). Lazy the PLOT, never the section that owns the trigger; decide an empty series outside the lazy module; import types only from it. A lazy TAB's fallback is the tab's own loading state, control rows included (2026-10-06,components/cashflow/CashflowTabSkeletons.tsx,e2e/lazyTabLanding.ts). Worked examples:PerformanceDettaglio,ConfrontoAnnualeSection,FlussoTile, the four lazy FIRE tabs, Cashflow's four non-default tabs,AssetRow's sparkline. Next prefetches every shell link's route, client chunks included: only a chunk reached solely by animport()stays unfetched (doc/guide/velocita.md, «Il prefetch dei link della shell»). - Pure
lib/utilsmodules reachcalculateAssetValueinjected as avalueOfparam (allocationUtils,pensionFire) or imported directly with the test mocking@/lib/firebase/config+firebase/firestore+authFetch+dashboardOverviewInvalidation— check the precedent. Functions that callnew Date()internally are untestable: passnow: Date. shadcn vendored surface policy:components/ui/**is knip-ignored and standard shadcn API stays even at zero references; only custom additions made in this repo get deleted. - CSS custom property liveness — the 5-check sweep. A token is live if ANY holds:
var(--namein.ts/.tsx/.css; if mapped via@theme, the generated utility name appears (grepbg-X, not the variable);getPropertyValue; an internal chain; the vendored-surface contract. A confirmed-dead token leaves every theme block in one commit.
Shared Constants and Fixed Hooks
- Rule of Three: a map used in 3+ files lives in
lib/constants/<domain>.ts. The canonical symptom of a duplicatedRecord<Type, string>is one copy missing itsdark:variants — illegible in dark mode with a cleantsc. - Declare N fixed hook instances with
enabled: falsefor the inactive ones — never loop over hooks. - Yahoo module asymmetry — funds
topHoldings→sectorWeightings, stocksassetProfile→sector, the two maps inlib/constants/exposureSectors.ts, two MODULES of one per-ticker document since 2026-09-28: doc/guide/allocazione.md § Esposizione — the per-ticker cache and Yahoo's two modules.
3. Domain guides
The per-area rules live in doc/guide/. Each entry below is the stub — the 3–4 things to
know before opening the guide — then the pointer. In code comments and the other docs,
doc/guide/<f>.md § <name> resolves to a ## heading kept verbatim from the section name this
file used to carry.
Panoramica → doc/guide/panoramica.md
- Overview data flows through
GET /api/dashboard/overview+useDashboardOverview()only — no page-level fan-out, no full-history expense queries;dashboardOverviewSummaries/{userId}is server-owned, fresh for the Italian day, and every write to an input invalidates it — a new writer owes its invalidation (§ The materialized summary). Both endpoints owner-scoped. topMovers/marketEffectare MARKET return, never the user's flows:[]when the previous snapshot has nobyAsset,nullwhen not attributable (≠ measured 0).- Every sentence comes from
overviewNarrative.ts; a falling month blames the market only whenmarketEffect < 0.resolveDeclineCause(lib/utils/periodSales.ts) is the ONE decision for Panoramica, Patrimonio and the email (the tax in the headline, 2026-09-13),resolveTaxedGrowthits twin for a month that did NOT fall (2026-09-19). - A driver subject missing from
CLASS_SUBJECTSdrops the clause, never prints itself; the page has no «Dettaglio» of its own. - Il resto — the mobile composition (§ Composizione mobile),
salesNarrative.ts(the shared words), the tax headlines, the month's split and the user's money outside the cashflow it names (monthFlows: pension contributions, principal repaid per debt — 2026-10-14), the trade-aware market, the lived savings rate, the category deep link, the hero step-down, the tile grid, the superseded-pattern rule, the Italian-copy test trap — indoc/guide/panoramica.md.
Patrimonio · Asset Pricing, FX and Assets → doc/guide/patrimonio.md
- "Does this asset have a market price?" is ONE rule in
assetPricing.ts(hasMarketPrice/requiresManualPricing); a new hand-valued type goes inMANUALLY_VALUED_TYPESand nowhere else. - A debt is a
loanASSET worth minus its principal (2026-10-10,types/assets.ts→LOAN_ASSET_TYPE, the sign incalculateAssetValue): the property stays GROSS, the loan sits in the class of what it finances (realestatewhenfinancedAssetId, elsecash= negative liquidity), out of every Rendimenti base, out of FIRE with the primary residence it finances. In the Allocazione a loan FOLLOWS what it finances (2026-10-17,resolveAllocationRoleWithin): a mortgage takes its property's role (excluded home → nets the excluded figure; a property in the allocation → nets Immobili in the base), a personal loan nets Liquidità in the base; never a plan's leg, its storedallocationRoleinert. Its «Intestatario» follows the property too (resolveAssetOwnerId, lib/utils/assetOwnership.ts). Where the debt lives on the document isdebtBalanceField's alone (a loan'squantity, a legacy property'soutstandingDebt, read-only since the one-shot migrationloanMigration.ts). - GBp (pence) ≠ GBP — normalize
price / 100before any FX; never call Frankfurter from the browser;quantity = 0marks a sold asset. - A Borsa Italiana bond quote is
% of par, always, andlib/utils/bondPricing.tsis the ONE conversion (quote / 100 × nominal × coefficient, nominal 1 € by default). Never re-implement it; never guard it onnominal > 1again (issue #340). - Every G/P, tax estimate, YOC and PMC cell stands EUR against EUR through
lib/utils/costBasisEur.ts(costBasisPerUnitEur, fees included);undefinedwithout a EUR PMC — print nothing, never dollars against euros. - Δ columns are UNIT-PRICE variations, not P&L;
isHeld(quantity > 0) gates every count/share/sum; numbers not in the payload are born inpatrimonioSummary.ts; the page owns every dialog. - Il resto — the mobile composition (§ Composizione mobile), the «Mutuo» tile, one per LOAN (interest measured only from the settled instalments,
mortgageSummary.ts), the loan migration and its blind spots, the split class chip of a composite instrument (describeAssetClassChip), BTP€i, the «Andamento» view,hasCostBasis,MIN_ANNUALIZABLE_DAYS, the instrument driver,suggestIsLiquid, the cash-account picker rule, the article helpers, the failed-overview branch, theaverageCostEurbackfill — indoc/guide/patrimonio.md.
Asset Trade Ledger → doc/guide/registro-operazioni.md
- ALL trade money-math (replay, PMC, realized P&L, XIRR, invested capital) lives in
assetTransactionUtils.ts, pure; the service/route layer is a thin atomic writer. A newAssetTransactionTypeupdates the replay switch, the zod schema ANDTransactionDialog. - Writes are Admin-API-only, all reads before any writes, derived fields written in-tx (never via
updateAsset); ledger-type edits go throughupdateAssetMetadata. A settlement moves CENTS (lib/utils/cents.ts), and a sell credits proceeds − fees −withheldTaxEur(lib/utils/saleTax.ts, 2026-09-20); realized P&L and XIRR stay gross of the tax. - The migration baseline (
isBaselineBUY) NEVER stampsholdingStartDate; a replay returningholdingStartDate: undefinedmeans leave the doc untouched (neverdeleteField()). - A trade date has ONE floor — the asset's OWN baseline (
BASELINE_NOT_FIRST) — and the future as its only ceiling (2026-09-13);assetTransactionsMeta.baselineDateis NOT a floor. - Per-transaction derived data comes from
replayTransactionsWithEffects(one pass), never a replay per prefix. - Il resto — settlement timing,
buildDerivedAssetFieldsand theaverageCostEurbackfill,resolveBondPricereuse, the twototalReturnAssetspaths, the static-copy audit rule — indoc/guide/registro-operazioni.md.
Cashflow — expense mechanics → doc/guide/cashflow.md
- Category names are NOT unique: group by
getCategoryKey/getSubCategoryKey, display viaresolveDisplayLabels. - Income positive, expenses negative,
net = sum(income) + sum(expenses); classification ALWAYS bytype, never by the sign ofamount. Crossing the transfer boundary flips the sign and the BATCH paths refuse it; atransferIS its two accounts (the schema refuses it otherwise). - A recurring expense is N real future-dated rows sharing
recurringParentId, not a rule;canTypeRecur=fixed/variable/debtonly;MAX_RECURRENCE_OCCURRENCESkeeps the batch under 500. - A linked row moves its account ON ITS OWN DATE (
lib/utils/cashSettlement.ts, 2026-09-19):balancePendinguntilsettleDueBalancesruns in/api/portfolio/snapshot; the flag ABSENT means applied; edits and deletes move only what was applied. - CSV import: MANDATORY preview, undo by
importBatchId, category identity is (name, type). ONE drill destination (handleEntitySelect); Sankey ids are built from ids. - A row's fee is a row of its own, on ANY row and on every occurrence of a series (2026-10-10,
lib/utils/expenseFee.ts; a transfer's alone until then — the stored field namestransferFeeExpenseId/feeOfTransferIdkeep their birth name): created in the same batch as its parent, edited from it, deleted with it — a SERIES delete gives the fees' balances back too (getFeesOf), from the tab AND the table. Adebtrow repays a LOAN (debtAssetId); «Estinzione anticipata» (isDebtPayoff) is all principal. A transfer may land on a property priced 1 (a deposit). - Il resto — le sei regole per esteso,
linkedCashAssetIdsu ogni occorrenza, «Collega la serie», lo schema del transfer e la sua spec, la commissione come riga propria (expenseFee.ts), la rata che riduce il debito per la QUOTA CAPITALE (mortgageRepayment.ts), l'estinzione, il trasferimento su un immobile — indoc/guide/cashflow.md.
Cashflow › Tracciamento → doc/guide/cashflow-tracciamento.md
- ONE period axis, two slices:
expensesfeeds the verdict and every tile;filteredExpensesfeeds ONLY the Movimenti list. Never route a tile throughfilteredExpenses; the phone bar's picker is a second HANDLE on the sameperiod, never a second axis. - A period is its WHOLE calendar span; what has not happened is DECLARED (chip «In calendario», sign colour dropped).
isScheduledRow= after today by Italian calendar DAY (isItalyDayAfter), shared withbudgetUtilsandcostCenterSummary. - The verdict judges what has HAPPENED (
settleTotals, 2026-09-14), then says where the calendar takes it. The month in progress is compared with the SAME DAYS of the previous month (currentComparisonWindow/previousComparisonWindow); a previous period is honest or absent. - «Da inizio anno» (
Period.kind = 'ytd') and «Anno corrente» ('current', full-year delta since 2026-08-30) are different windows, never one. - Every number from
tracciamentoSummary.ts, every sentence fromcashflowNarrative.ts. - Il resto — the mobile composition (§ Composizione mobile; the five tabs' shared contract —
forceMount, prefixed ids, a memory per tab,active— indoc/guide/cashflow.md§ The tabs load their code on demand), the verdict's two sentences,previousPeriod, the two windows anchored to today, the month-end projection, the feed, the mobile filters, the Movimenti reading, «Intestatario» (lib/utils/movementsOwnerFilter.ts,memberNames) — indoc/guide/cashflow-tracciamento.md.
Analisi — a verdict over tiles → doc/guide/cashflow-analisi.md
- FOUR axis modes (
Da inizio anno | Anno corrente | Anno | Storico);ytdandcurrentare not the same window. - A running year is NOT clipped (
periodExpensestakes the whole calendar year); the pacing compares year vs year−1 on the period's own span (resolveComparisonScope), the running MONTH on the same days of its baseline (throughDay, rows throughdayOf), plus the sharedscheduledSentence. The history CLOSES on the current year (availableYears, the Storico slice, the two charts'BucketCeiling): a plan's rows in 2043 are a calendar, not years. - The Scheda is a tile of the grid; every entry point lands through
handleEntitySelect; URL focus is three FLAT params (?focusType&focusCat&focusSub); closing it returns the focus to its opener. Its pace divides the LIVED total by the months lived (computeEntityRunRate:livedTotal/livedMonths), the projection takes the calendar as a FLOOR under the pace, and its period carries the page'sthroughMonthcut. - Every number has one source (
analisiSummary.ts,comparisonDeltas.ts); every sentence fromanalisiNarrative.ts/cashflowNarrative.ts, never a component. - Il resto — the mobile composition (§ Composizione mobile), «Fuori scala», the Periodo pacing,
EntityDossier, the Sankey rules, the optional Flusso by 50/30/20 role (spendingRoles.ts), the Flusso's drawing below 640px (a share bar and rows, a legibility threshold of the chart), Playwright — indoc/guide/cashflow-analisi.md.
Cashflow › Budget → doc/guide/cashflow-budget.md
- Opt-in (
reconcileBudgetItemsnever auto-creates); NO period axis (always the current Italian month; annual budgets are year-to-date on their own Off-Axis tile). - ONE projection rule, the app's:
buildSpendingForecastover the month's spending SPLIT at today (spendingProjection.ts, shared with Panoramica/Tracciamento); a FIXED category never follows the pace;MIN_FORECAST_DAYS(4). - Risk vs fact: «Categorie a rischio» = projection over amount AND not over yet; a budget already over is a fact for «Avvisi». No row in two tiles. A threshold is a fact of what is BOOKED (
BudgetAlert.spent= up to today), read against the calendar of its own window (calendarPct,aheadOfCalendar): the amber is for the rows ahead of it only. «Speso» isspentToDate; the scheduled rows are their own clause in every sentence (2026-09-14). - The ceiling IS historicised by the cron (phase 8, one doc per month,
budgetHistory/{uid}/months/{YYYY-MM},allow write: if false). The crossing day is a fact of the EXPENSE DATES, never a cron's memory. - Il resto — the mobile composition (§ Composizione mobile),
summarizeCeiling, the two-face KPIs onexceeded,BudgetTrack, thecashflow:add-budgetevent — indoc/guide/cashflow-budget.md.
Centri di Costo → doc/guide/centri-di-costo.md
- NO period axis, by decision (2026-08-23): a project's cost is its whole cost; every figure is lifetime («in totale») unless the tile names its window.
summarizeCentersplits rows at today: booked ones are the cost; scheduled ones get an «in calendario» chip, feed a window's end and a ceiling'sspent, and are NEVER summed into the total.- A CENTER HAS NO PACE (2026-09-18): a window's end is booked + calendar (
ytd + yearScheduled), neverprojectWindowEndWithScheduled. Budget keeps its pace — do not unify; a monthly ceiling reads Budget'ssummarizeCeilingbut re-derivesexceeded. - The open center lives in the URL (
?tab=cost-centers¢er=<id>); a new center opens onfirstFreeColorKey, and existing documents are never re-coloured. - Every number from
costCenterSummary.ts, every sentence fromcostCenterNarrative.ts. Any count next to a destructive action comes from the same read the mutation runs (the rows are grouped in memory from the expenses key since 2026-09-29). - Il resto — the mobile composition (§ Composizione mobile), risk vs fact (
exceeded/atRisk) and the detail's ranking, the retired picker, the backdated row, how the detail lands and refocuses (data-center-row),CenterStackBars, «Collega spese…», session-only lenses — indoc/guide/centri-di-costo.md.
Cashflow › Divisione → doc/guide/cashflow-divisione.md
- Opt-in, on Tracciamento's period axis. ONE field carries the feature:
Expense.personalMemberId; absent (ornull) MEANS «in comune» (so no migration). Members are Previdenza'sFamilyMembers, never a second list. Writing it is a FOUR-place fan-out. - The share is NEVER invented:
resolveSplitBasisreturnsunavailable(withmissingNames) below two people or when one person has no income attributed in the period; every split figure is thennull. The base is the PERIOD's attributed income, WHATEVER its category (owner's decision, 2026-09-27; labor-only from 2026-08-31 until then) — do not «stabilise» it silently. - Income left «in comune» pays the pool FIRST (2026-09-27): the shares divide
CommonSpending.toSplit = max(0, total − income), asurplusis declared and never distributed. A residual is of money that has MOVED (2026-09-21): the page prints and coloursremainingBooked;remainingis the whole period's. allocateBySharecharges the rounding residual to the LARGEST share and re-rounds — untestable on two shares (they cancel), test on three.- Il resto — the mobile composition (§ Composizione mobile),
unattributedIncome, the booked pool net of the income received and the «Da dividere» row, the calendar as a separate clause,laborIncomeCategoryIdsno longer read, the readers outside the tab (movementsOwnerFilter.ts), the deleted-member bucket, the dialog control,effectiveTab, the one-cell people row, the verdict-explains/tile-instructs split, «Attribuisci spese» — indoc/guide/cashflow-divisione.md.
Cashflow › Dividendi · Dividends and Coupons → doc/guide/cashflow-dividendi.md
- RECEIVED AND ANNOUNCED ARE NEVER ONE FIGURE — counted, totalled and coloured apart on every surface;
summarizePaymentsreturns two halves and no sum. - ONE period axis (
resolvePeriodBounds, upper bound = end of the period's own unit, NOT today), the announced money ON it; filters narrow only the list. TWO POPULATIONS, BOTH NAMED (2026-09-14): verdict and inventory read the REGISTRY (sold included); Affidabilità and Chi paga di più measure the HELD portfolio (heldAssetIds). - A coupon's cashflow expense is created only by the daily cron on payment date (
!isAutoGenerated, idempotent viaexpenseId), and it credits the instrument's account, else the default, ONLY when the payment is not an arrear (lib/utils/dividendAccount.ts, 2026-09-20) — row, balance andexpenseIdin one transaction. - Two inflation mechanisms, ONE field (
inflationIndexation, read viaresolveInflationIndexation): BTP Italia ADDS the FOI rate, a BTP€i MULTIPLIES by the coefficient. Adding aDividendTypeis a six-file fan-out. ACouponFrequency 'maturity'is ONE coupon with the redemption (2026-10-10, the BTP Valore Insieme):getPeriodsPerYearis 0 there and every divider asksisSingleCouponAtMaturityfirst; the coupon is the rate COMPOUNDED over the term (calculateMaturityCouponPerShare). - Il resto — the mobile composition (§ Composizione mobile), the Rendimento tile off the axis,
useDividendStatswithout date bounds, the tab's ONE request (the stats answer carries the list,useDividendRegistry;stats: nullkeeps the list when a measure fails), every number fromdividendAnalytics.ts, a scraped dividend's ONE floor (lib/utils/dividendEligibility.ts, 2026-09-13), the zero coupon (hasCouponPayments), the calendar, the form (taxRateproposal, picker) and the armed row delete,computeDividendYieldMetrics, the legacyisInflationLinked, provisional coupons, the running-window rule,couponUtils— indoc/guide/cashflow-dividendi.md.
Storico · History and Snapshot Baselines → doc/guide/storico.md
- The snapshot cron runs DAILY (the name lies) and both writers REPLACE the document: a new
MonthlySnapshotfield no pipeline recomputes goes inSNAPSHOT_USER_AUTHORED_FIELDSor the cron erases it (§ Firestore Writes). - Reuse
byAsset.totalValuefor per-instrument history, never recompute;byAsset.priceis RAW NATIVE currency (EUR unit value =totalValue / quantity). - The page's CAGR is WEALTH growth (
(endNW/startNW)^(12/months)−1, «versamenti inclusi»), never Rendimenti's return; ONE linear pace (summarizeGrowthPace), do not compound it. - The Driver is SIX parts (
lib/utils/growthDrivers.ts, 2026-09-19), its market MEASURED per instrument (marketEffect.ts) — never «Δ − risparmio»; Lavoro takes the Driver's windows AND parts. «Mutuo» reads the LOAN rows as a total since 2026-10-10 (a loan'sbyAsset.totalValueis minus its principal), the legacy property pairing beside it: the two cross the migration month without a jump. - The parts are a LEDGER (
buildDriverLedger, 2026-09-20) that closes TO THE EURO (reconcileRemainder); a flow is signed and uncoloured (isFlowDominated). - Il resto — the mobile composition (§ Composizione mobile), baselines, attribution (
buildMonthAssetBreakdown), each Driver part,summarizeLaborMetrics/laborWindowsOf, the manual-snapshot cross-validation, Recharts in a flex tile, the container-query table, the two-column grid — indoc/guide/storico.md.
Hall of Fame — a verdict over tiles → doc/guide/hall-of-fame.md
- The page has NO axis and re-derives nothing:
hall-of-fame/{userId}holds the rankings; "today" is a PARAMETER, nevernew Date()inside the module. hallOfFameRecords.tsis the ONE definition of a record AND a ranking — both writers and the periodic email callbuildHallOfFameRankings; never re-implement a ranking.- A stale document heals only from the page's «Aggiorna» button when the account has no assets (the cron gates on
snapshotResult.success); disabled in demo. Never document a field as "the cron will fill it in". - A savings record needs income (
totalIncome > 0guard);statsand the two savings rankings are OPTIONAL on pre-2026-08-25 documents —getBoardreturnsnull(≠ empty board), never?? []. Same forrankingsUpdatedAt,stats.sinceWorstMonthandYearlyRecord.monthsCovered(2026-09-24): stored byupdateHallOfFame, never derived, their clause dropped when absent. - Il resto — the mobile composition (§ Composizione mobile), the podium-vs-chronology split, the tile-never-repeats-the-verdict rule,
NoteTriggerand the prefilled note, the section-key fan-out, the Playwright locators — indoc/guide/hall-of-fame.md.
Rendimenti → doc/guide/rendimenti.md
- The base is resolved ONCE by
resolvePerformanceBasefor its THREE call sites (service, page, PDF). An exclusion read frombyAssetMUST be backfilled across the pre-byAssetmonths (constantE₀) or it becomes a phantom crash. - The pension toggle WINS over a fund's
allocationRole. A contribution is a flow iff it crosses the base's boundary; flows ride their own channels (externalFlowOf),netCashFlowstays the cashflow's savings. - The flows follow the base (2026-09-07): with anything out of it, the months with
byAsseton both snapshots neutralise the MEASURED boundary flows (lib/utils/portfolioFlows.ts); the ledger speaks for an instrument only once the base has SEEN it (2026-09-13). - The first snapshot of a period is the starting valuation, never a measured month; the page must NEVER re-derive the window from
new Date()(metrics.nominalPeriodStart). - No silent filters inside a metric (
nullwith a reason); the attribution (performanceAttribution.ts) is EURO and reconciled to the TWR numerator. - Il resto — the mobile composition (§ Composizione mobile),
buildCacheKey, the flow channels and the entry month,performanceExcludesCash, the hero below a YEAR andresolveCompanionReturnChip, Contributi's ONE answer (summarizeCapitalEntered; a migration baseline is never a purchase), the heatmap read by keyboard and tap, the residual guard (RESIDUAL_ALERT_SHARE), EUR benchmarks, drawdown, IRR signs, the verdict-over-tiles rules, the heatmap — indoc/guide/rendimenti.md.
Allocazione → doc/guide/allocazione.md
Asset.allocationRoleis ONE field, THREE values:tradable(default),frozen(in the denominator, never in the plans),excluded(out of the page entirely); no role is ever inferred at read time — save a LOAN's, which is DERIVED from the asset it finances (resolveAllocationRoleWithin, 2026-10-17; a personal loan is in the base) so every class and group sums NET of its debts, a class below zero declared, never clamped. THE RULE: partition upstream ofcompareAllocations, never downstream (filtering the output breakstargetValue = target% × totalValueand the Σ(current − target) = 0 invariant), and do NOT push the filter intocalculateCurrentAllocation(it also serves/api/portfolio/snapshot).- Every euro lands in a bucket: the subcategory is OPTIONAL (
NO_SUBCATEGORY_LABEL), the orphaned target is the trap (findOrphanedTargets/stripOrphanedSubTargets), andASSET_CLASS_SEQUENCEis the ONE enumeration of the union — a hand-listed class drops its EUROS, not just its label. - "Versa" and "Preleva" are ONE tree with the sign flipped, and Ribilancia descends to the INSTRUMENT through those same splits (
RebalanceDescent, 2026-09-21) — never a second algorithm. «Prelevare 1000 €» means 1000 € IN HAND:solveWithdrawalGrossis a FIXED POINT, never a division by (1 − rate), and a plan that sells prices the withholding (estimatePlanSaleTax),nullWITH a reason when a leg has no EUR basis or rate. - Esposizione (2026-09-28): the engine runs in the BROWSER on the page's assets; the route answers only Yahoo's profiles from ONE cache per ticker, owner-scoped (
assertCanAccessAccount), and nothing of the user's goes in a profile. Every euro of the Allocazione base has ONE of four destinies and the list adds up to 100 on screen. - Il resto — the mobile composition (§ Composizione mobile), THE ASYMMETRY of the two plans and the band-independent score, the dormant class (
isDormantClass,activeClassGapsand never the rawsummarizeClassGaps),collapseRepeatedLevelsand the node's ownisInstrument, the leverage engine and its tree, what Titoli, Settori and Emittenti weigh (hasMarketPrice+ a ticker), the per-ticker cache and Yahoo's two modules, the Bull's formula, the six label maps, the action colours' measured lightness band and thelab()trap that made th
Truncated - read the full file at https://github.com/GiuseppeDM98/net-worth-tracker/blob/9a4eb8a591c1f1a3c68e573b35e33c33a08dad6d/AGENTS.md.
