Skip to content
Nest Side panel · Technical specification ← Back to index

Chapter 02 · English

Side panel — tech spec

Floating, draggable, detachable side panel shared by Flow, Nest and Home. State persistence, cross-tab sync, tab catalog, store architecture, /flow/notes, /flow/report and /flow/search REST surfaces, DB schema.

for developers Beta Updated 2026-05-13 Lib @one-nest/shared/side-panel

§ 01 Scope & goals

The side panel is a single Angular library (@one-nest/shared/side-panel) consumed by Flow, Nest and Home. It renders as a fixed icon at the top-left of every page; clicking opens a floating, draggable, resizable, detachable window. The same component re-renders full-screen inside the /side-panel-window route so the user can pop the panel into its own browser window.

The panel hosts six tabs:

  • Personlig: personal todos + personal post-it notes (split pane).
  • Företag: company-scoped post-it notes.
  • Balans: balance report for a chosen company + period.
  • Resultat: result report for a chosen company + period.
  • Konto: account analysis (kontoanalys) for a single account.
  • Verifikat: journal browser with inline file-viewer for bilagor.

All persistent state (mode, geometry, active tab, todo/notes split, notes column counts, selected company, per-company period selection) lives in localStorage and survives logout. Cross-tab updates use BroadcastChannel. There is no server-side panel state.

§ 02 Host applications

Three applications host the panel today. Each ships a concrete CompanyContextProvider that decides which company is in scope; the panel itself is identical everywhere. board-portal and Go do not host the panel.

Flow

Förvaltare · cross-co.

FlowCompanyContextService exposes companies$ via a lazy defer + shareReplay({ bufferSize: 1, refCount: false }) over FlowCompanyService.getCompanies(). Picker mode is 'open'; the user picks any company they can see.

Nest

Single co.

NestCompanyContextService returns the single current company. Picker mode is 'locked'; the picker is rendered disabled, the user cannot switch.

Home

Single co.

HomeCompanyContextService returns the single current company. Picker mode is 'locked'. Wiring matches Nest.

Wiring per app

AppRenders <shared-side-panel>Pop-out routeProviderPicker mode
Flow apps/flow/src/app/app.component.ts apps/flow/src/app/main/main.routes.ts FlowCompanyContextService 'open'
Nest apps/nest/src/app/app.component.ts apps/nest/src/app/app-routing.module.ts NestCompanyContextService 'locked'
Home apps/home/src/app/app.component.ts apps/home/src/app/app.routes.ts HomeCompanyContextService 'locked'

In all three apps the panel is hidden on /login and on /side-panel-window (the pop-out route renders the panel full-screen by itself; the floating instance would double up).

§ 03 Per-app coverage matrix

Every tab is implemented inside the shared library; the gating factor is the REST backend each host can reach. Flow has the /flow/notes, /flow/report and /flow/search endpoints; Nest and Home talk to the panel UI but their backend resources for notes and reports are not wired yet.

Legend: tab usable end-to-end · UI renders, backend not wired · · not wired.

Tab Flow Nest Home
Personlig (todos + personal notes)
Företag (company notes)
Balans
Resultat
Konto (account analysis)
Verifikat (journal browser)
Reading the matrix

The shared component tree is the same in every host. Flow is the only app whose host APIs (/flow/notes, /flow/report, /flow/search) currently exist. Wiring Nest and Home means standing up equivalent /nest/notes / /home/notes resources (or pointing the panel at the same Flow endpoints with the user's single dbid). See § 14.

§ 04 Geometry, mode & persistence

The floating panel is positioned and sized by a single SidePanelGeometry object held in SidePanelStateService. Mode is one of 'collapsed' | 'expanded' | 'detached'. Every signal in the service writes through to localStorage on change.

Defaults & clamps

ConstantValueWhere
MIN_WIDTH / MIN_HEIGHT320 / 320side-panel.component.ts
VIEWPORT_MARGIN8 px top & bottom safety gapside-panel.component.ts
DEFAULT_GEOMETRY{ x: 96, y: 96, width: 420, height: 560 }side-panel-state.service.ts
DEFAULT_TODO_SPLIT0.55 (clamped to [0.15, 0.85])side-panel-state.service.ts
notesColumns / companyNotesColumns1 | 2 | 3side-panel-state.service.ts

localStorage keys

side-panel:modecollapsed / expanded / detached
side-panel:geometry{ x, y, width, height }; clamped on read
side-panel:active-tabTab id (defaults to 'todo')
side-panel:todo-splitPersonlig vertical split ratio
side-panel:notes-columnsPersonal-notes masonry column count
side-panel:company-notes-columnsCompany-notes masonry column count
side-panel:selected-companyLast picked dbid (Flow only)
side-panel:report-periodPer-dbid period selection (Balans + Resultat + Konto + Verifikat share this)
side-panel:account-analysis-accountPer-dbid selected account number
side-panel:journal-selectedPer-dbid selected journal id
side-panel:journal-typePer-dbid journal-type filter
side-panel:journal-list-pagePer-dbid Verifikat list page number

Viewport clamp

Geometry restored from localStorage on a wider monitor used to leave the panel partly off-screen on a laptop, hiding the bottom-right resize handle and the top drag bar. SidePanelComponent.normalizeGeometry() runs on construct and on every window:resize. It clamps:

  • width into [MIN_WIDTH, innerWidth]
  • height into [MIN_HEIGHT, innerHeight − VIEWPORT_MARGIN]
  • x into [0, innerWidth − width]
  • y into [VIEWPORT_MARGIN, innerHeight − height]

Logout behaviour

auth.logOut() only removes sessionId, so panel state survives a logout / re-login cycle on the same browser. Stores subscribe to auth.user$ (not authenticatedUserId$) and gate every refresh() on auth.session being present so a stale broadcast does not trigger an HTTP call after sign-out.

§ 05 Tab catalog

Tab order is hard-coded in SidePanelComponent.tabs (side-panel.component.ts). Tab nav uses truncate + text-[11px] so all six labels fit at MIN_WIDTH = 320 without container queries. Each tab is a standalone component under lib/tabs/.

Personlig

Two stacked regions split by a draggable horizontal handle (ratio in side-panel:todo-split).

  • Todos. Text + optional ISO date. Lightweight delete (no confirm). Past-due check via isBefore(parseISO(deadline), startOfDay(new Date())). Backed by /flow/notes/personal-todos.
  • Personal notes (post-its). Masonry column layout (1/2/3, user-toggled). Five colours: sky (default), amber, rose, emerald, red. Per-card colour cycle button debounced 1 s. Inline textarea grows to match content. Delete behind ConfirmationModalComponent. Backed by /flow/notes/personal-notes.

Företag

Company-scoped post-its, exact same renderer (<shared-side-panel-notes>) as personal notes. Picker is a <shared-typeahead-input> fed by CompanyContextProvider.companies$; selection writes through CompanyContextProvider.setSelectedDbId so every company-scoped tab follows. Picker can be hidden or locked via pickerMode.

Balans

Shares CompanyContextProvider.selectedDbId with the Företag tab and the other three report tabs. Period picker is <shared-report-period-picker> with three modes driven by a <shared-pill-select>:

  • År. <shared-typeahead-input> over financial years descending. Defaults to the FY containing today, falling back to the latest FY.
  • Månad. <shared-form-monthpicker> clamped via [minMonth]/[maxMonth] to the FY span; maxMonth further clamped to the current calendar month so future months are blocked. Default: current month inside today's FY.
  • Datum. Two <shared-form-datepicker> (Från / Till). End-date [minDate]/[maxDate] clamps to the FY of the start date; legacy validateFinancialYearDates requires the range to stay inside one FY.

Renders via <shared-balance-report> with four numeric columns (IB / BD / PERIOD / UB). All amounts use '1.2-2' : 'sv-SE' so öre are visible. Sub-sections start expanded (state tracked as a closedKeys set). Export button downloads the same xlsx the board-portal balance report exports.

Resultat

Mirrors Balans (same company picker, same period picker, same per-dbid period in ReportPeriodStoreService). Renders via <shared-result-report> with three numeric columns (PERIOD / ACK / FÖRRA ÅR). The board-portal interleaves summary rows inside the expense section between subsections; the panel renders them as full-width summary rows between blocks because the panel is narrower and reads top-to-bottom. Backend call passes lastYearPeriod = true so the previous-year column is populated.

Konto (account analysis)

Same company picker, same period picker. Account selector is <shared-search searchType="account"> with [financialYear] and [dbId] inputs so the backend can resolve the correct per-company FY. Selection is per-dbid and is cleared when the FY changes and the stored account is no longer valid (validateStoredAccount against the per-(dbid, fyId) account list fetched from /flow/report/companies/{dbid}/accounts).

Renders via <shared-account-analysis> as a pure renderer (report / loading / error / notReadyMessage inputs only). Layout: company/period header → account number - name card → column header → optional Ingående balans row (only when this is a balance account and openingBalance != 0) → optional Ingående saldo period → one row per report.row[] (deleted rows shown strike-through, muted) → Periodens totaler row (sum of non-deleted debit/credit, computed client-side) → Utgående saldo row.

journalDate on each row is typed number (epoch millis; Jackson serialises the underlying java.sql.Date as millis) and rendered via DatePipe with 'yyyy-MM-dd'. Export downloads kontoanalys.xlsx.

Each journalNo is a <button> (disabled when the row is deleted). Click emits journalClick; the tab routes that through JournalStoreService.openJournal(journalId) which sets the selected journal id, switches the active tab to 'journal', and expands the panel if collapsed.

Verifikat

Header bar matches Konto: company picker → period picker → optional journal-type pill-select → <shared-search searchType="journal">. The journal-type filter only renders when there is at least one type for the current FY.

List view (no journal selected). Body renders a paginated list of every journal for the current (dbid, FY, journalType?), sorted by date descending, 300 rows per page. The current page is persisted per-dbid at side-panel:journal-list-page so clicking a row, viewing it, then clearing the search returns the user to the same page. Changing the journal-type pill resets the page to 0.

Detail view. Header card with ${type}${no} + date + description + total. Rows table mirrors the board-portal columns (account / description / debit / credit, '1.2-2' : 'sv-SE', tabular-nums in font-mono). Summary row at the bottom (Periodens totaler-style). Connected images render inline below the rows via <shared-file-viewer>; the tab calls FlowReportApiService.downloadFile(dbId, fileId) for each ConnectedFileIds entry and feeds the buffers in as object URLs. Object URLs are revoked when the journal changes or the tab is destroyed.

§ 06 Store architecture

Each tab pairs with a store service. Selection is held in signals; HTTP is driven reactively via toObservable(signal) → switchMap(fetch) rather than imperative setX()/fetch() pairs.

StoreOwnsReads
SidePanelStateService mode, geometry, active tab, todo split, notes columns, selected company
TodoStoreService Personal todo list. Optimistic. BroadcastChannel('side-panel:todo-sync'). /flow/notes/personal-todos
PersonalNotesStoreService Personal notes. Implements NotesSource. BroadcastChannel('side-panel:notes-sync'). /flow/notes/personal-notes
CompanyNotesStoreService Company notes. Implements NotesSource. No cache (re-fetched on dbid change). BroadcastChannel('side-panel:company-notes-sync'). /flow/notes/companies/{dbid}
ReportPeriodStoreService Per-dbid period selection (Balans, Resultat, Konto, Verifikat share this). Persisted at side-panel:report-period.
BalanceReportStoreService Reactive fetch + xlsx export. Reads ReportPeriodStoreService. /flow/report/companies/{dbid}/balance(/export)?
ResultReportStoreService Reactive fetch + xlsx export. Reads ReportPeriodStoreService. /flow/report/companies/{dbid}/result(/export)?
AccountAnalysisStoreService FY + accounts cache, per-dbid selected account, fetch + export, openAccount(accountNo) drilldown. /flow/report/companies/{dbid}/accounts, /account-analysis(/export)?
JournalStoreService FY + journal-types cache, per-dbid selected journal id and type filter, list page, journal fetch, openJournal(id) drilldown. /flow/report/companies/{dbid}/journal-types, /journals(/{id})?
FlowReportApiService Thin HTTP wrapper over /flow/report: financial-years, accounts, balance, result, account-analysis, journals, journal-types, file download. /flow/report/*
NotesApiService Thin HTTP wrapper over /flow/notes. /flow/notes/*

Optimistic concurrency on notes

Every PUT on a note carries a Version. The backend UPDATE … WHERE id = ? AND version = ? returns 0 rows on version mismatch, which NotesService raises as a ConflictException (HTTP 409). The colour-cycle button is debounced 1 s so burst clicks coalesce into one PUT per note.

§ 07 Company context

CompanyContextProvider is the abstraction that lets the shared library run in three apps with different multi-tenancy models. Each host provides a concrete subclass.

export abstract class CompanyContextProvider {
  abstract readonly companies$: Observable<CompanyOption[]>;
  abstract readonly selectedDbId: WritableSignal<string | null>;
  abstract setSelectedDbId(dbid: string | null): void;
  abstract readonly pickerMode: 'open' | 'locked' | 'hidden';
}

Stores read selectedDbId directly; there is no setter injected. Provider implementations decide whether to allow writes (Flow) or seed a fixed dbid (Nest, Home).

§ 08 Cross-tab sync & pop-out

Three BroadcastChannel instances coordinate state across tabs on the same origin:

  • side-panel:todo-sync: todo list mutations.
  • side-panel:notes-sync: personal notes mutations.
  • side-panel:company-notes-sync: company notes mutations.
Origin scope

BroadcastChannel is origin-scoped. Flow, Nest and Home run on different origins; the channels do not bridge between apps. Each app holds its own panel state and syncs to its own backend.

Pop-out window

/side-panel-window mounts SidePanelWindowComponent, which embeds the same SidePanelComponent with [embedded]="true" so the floating chrome (drag, resize, viewport clamp) is skipped and the tabs fill the browser window. The underlying signals are the same instance, so the floating panel and the pop-out window stay in sync within a tab.

The Konto and Verifikat tabs feed <shared-search> with a financial year and a dbid so the backend can resolve the right per-company FY (a Flow user has no dbid on RequestObject; the dbid must come from the URL). This required widening the shared search component without breaking existing Nest callers.

InputUsed byEffect on the HTTP call
[financialYear] Konto, Verifikat Forwarded to searchAccount / searchJournal as ?financialyear=….
[dbId] Konto, Verifikat For searchType="account": when set, routes to /search/companies/{dbid}/account; when absent, legacy Nest path /search/account is used. For searchType="journal": always routes through /search/companies/{dbid}/journal; there is no legacy fallback (the Flow side panel is the only caller).
[journalType] Verifikat only Forwarded as ?journaltype=….

SearchService.searchAccount(search, fy?, dbId?) and searchJournal(search, fy?, dbId?, journalType?) live in libs/shared/api-services/src/lib/search.service.ts. searchAccount branches on dbId so existing single-tenant Nest callers (no dbId) keep hitting /search/account. searchJournal only has the dbid-aware path; the Flow side panel is its only caller today.

§ 10 REST API

All endpoints are @FlowAuthorized. Notes are owned by FlowNotesResource; reports by FlowReportResource (delegating to FlowReportService); typed search by FlowSearchResource (delegating to FlowSearchService).

Notes: personal

GET/flow/notes/personal-todosList the caller's todos.
POST/flow/notes/personal-todos{ Text, DueDate? }
PUT/flow/notes/personal-todos/{id}{ Text?, DueDate?, ClearDueDate?, Done?, Color?, Version }
GET/flow/notes/personal-notesList the caller's personal notes.
POST/flow/notes/personal-notes{ Text }
PUT/flow/notes/personal-notes/{id}{ Text?, Color?, Version }
DELETE/flow/notes/personal/{id}Works for both PERSONAL_TODO and PERSONAL_NOTE.

Notes: company & tenant

GET/flow/notes/companies/{dbid}
POST/flow/notes/companies/{dbid}{ Text }
PUT/flow/notes/companies/{dbid}/items/{id}{ Text?, Color?, Version }
DELETE/flow/notes/companies/{dbid}/items/{id}
GET/flow/notes/companies/{dbid}/tenants/{tenantId}Not yet consumed by any panel tab.
POST/flow/notes/companies/{dbid}/tenants/{tenantId}{ Text }

Reports

GET/flow/report/companies/{dbid}/financial-yearsFY list for the picker.
GET/flow/report/companies/{dbid}/balance?startdate=YYYY-MM-DD&enddate=YYYY-MM-DD
GET/flow/report/companies/{dbid}/balance/exportSame params; returns xlsx.
GET/flow/report/companies/{dbid}/result?startdate=YYYY-MM-DD&enddate=YYYY-MM-DD
GET/flow/report/companies/{dbid}/result/exportSame params; returns xlsx.
GET/flow/report/companies/{dbid}/accounts?financialyear=UUID
GET/flow/report/companies/{dbid}/account-analysis?startdate=…&enddate=…&accountno=…
GET/flow/report/companies/{dbid}/account-analysis/exportxlsx.
GET/flow/report/companies/{dbid}/journal-types?financialyear=UUID
GET/flow/report/companies/{dbid}/journals?financialyear=UUID&journaltype=…&page=N&pagesize=300
GET/flow/report/companies/{dbid}/journals/{journalId}Single journal with rows + connected file ids.

Search

GET/flow/search/tenantusercompanyLegacy passthrough.
GET/flow/search/companies/{dbid}/account?financialyear=UUID&search=…&limit=…
GET/flow/search/companies/{dbid}/journal?financialyear=UUID&journaltype=…&search=…&limit=…

File download (existing)

GET/flow/files/{dbid}/{fileId}Used inline by Verifikat to fetch ConnectedFileIds bytes for the file viewer.

§ 11 Database schema

A single note table backs all four note kinds. Discriminator type + CHECK constraints enforce shape per kind. The report tabs introduce no new tables; they query the legacy accounting tables through the same services board-portal uses.

Table note

ColumnTypeNotes
idUUIDPK, default gen_random_uuid().
typeVARCHAR(20)CHECK in PERSONAL_TODO / PERSONAL_NOTE / COMPANY_NOTE / TENANT_NOTE.
user_idUUIDAlways set. Sole filter for personal kinds.
dbidUUIDRequired for COMPANY_NOTE and TENANT_NOTE; NULL otherwise.
tenant_idUUIDRequired for TENANT_NOTE only.
textTEXTNOT NULL.
due_dateDATECHECK: only PERSONAL_TODO may have one.
doneBOOLEANDEFAULT FALSE. CHECK: only PERSONAL_TODO may be true.
colorVARCHAR(20)DEFAULT 'sky'. CHECK in sky / amber / rose / emerald / red.
versionBIGINTNOT NULL DEFAULT 1. Incremented on every UPDATE.
created_dateTIMESTAMPTZ
updated_dateTIMESTAMPTZ

Partial indexes (one per non-shared filter combination, all date-ordered descending so the panel's most-recent-first list reads off the index):

  • idx_note_user_personal on (user_id, type, created_date DESC) WHERE type IN ('PERSONAL_TODO', 'PERSONAL_NOTE').
  • idx_note_company on (dbid, created_date DESC) WHERE type = 'COMPANY_NOTE'.
  • idx_note_tenant on (dbid, tenant_id, created_date DESC) WHERE type = 'TENANT_NOTE'.

Migrations

  1. V2026_04_30_1000__notes_domain.sql: table + CHECK constraints + partial indexes.
  2. V2026_04_30_1100__note_color.sql: adds color column with default and value CHECK.
  3. V2026_05_01_1000__note_version.sql: adds version column for optimistic concurrency.

§ 12 Flow service transaction pattern

Flow's UserContext carries no dbid — a Flow user is cross-company. The dbid comes from the URL path. Any Flow resource that calls into a legacy service which reads requestObject.getUserObject().getDbId() (for example AccountDao.retrieveAutoComplete, ReportBalanceSheetService.retrieveObject, JournalService.retrieveAll) must wrap the call in a transaction and adapt a UserContext with the explicit dbid.

return transactionTemplate.execute(status -> {
  RequestObject req = requestObjectAdapter.adapt(
      userContext.withDbId(dbId),
      dataSource);
  return reportBalanceSheetService.retrieveObject(req, startDate, endDate, "", fyId);
});

Reference implementations: FlowReportService for every report endpoint, FlowSearchService for searchAccounts / searchJournals, and FlowFileService.getUnconnectedCountsByCompany as the pre-existing prior art.

Why this matters

RequestObjectAdapter.adapt requires an active transaction (it allocates a connection from the dataSource). Calling it outside a TransactionTemplate throws at runtime, even on read-only endpoints.

§ 13 Locked-period architecture

board-portal limits reports to the user's locked periods. The side panel does not. The asymmetry is intentional and lives one layer above the shared services.

ServiceLock-period filtering?
BoardPortalReportService.retrieveAccountAnalysisReportByPeriodId(periodId, year)Yes; derives the date range from the selected LockPeriod and then calls the shared service.
ReportAccountAnalysisService.retrieveObject(...)No.
ReportBalanceSheetService.retrieveObject(...)No.
ReportResultService.retrieveObject(...)No.
Trap

Do not add lock-period filtering to the shared Report*Service classes; it would silently break the panel for any company that uses locked periods. The lock check lives in BoardPortalReportService because that is the single caller that needs it.

§ 14 Known gaps

Backend authorisation on {dbid}

FlowNotesResource, FlowReportResource and FlowSearchResource are @FlowAuthorized, but none of them verifies the requesting user has access to the {dbid} path parameter. The frontend picker only lists companies the user can see, but the API itself trusts the URL. NotesService documents the gap in a class-level comment: the caller is responsible for ensuring the user is authorised for the dbid. Closing this means adding a managed-companies check (model: how FlowFileService.getUnconnectedCountsByCompany already does it for the file count).

Tenant-notes UI not wired

/flow/notes/companies/{dbid}/tenants/{tenantId} (GET + POST) exists on the backend but no tab consumes it. The frontend has no Tenant note surface today.

Nest & Home note / report backends

Nest and Home render the panel and the picker, but their CompanyContextProvider implementations point at the single current company, and there is no /nest/notes or /home/notes / /home/report equivalent of the Flow endpoints yet. The tabs render with notReadyMessage until the host wires up its own backend or proxies through Flow. See § 03.

End of side-panel tech spec · 2026-05-13