§ 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
| App | Renders <shared-side-panel> | Pop-out route | Provider | Picker 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) | ● | ◐ | ◐ |
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
| Constant | Value | Where |
|---|---|---|
MIN_WIDTH / MIN_HEIGHT | 320 / 320 | side-panel.component.ts |
VIEWPORT_MARGIN | 8 px top & bottom safety gap | side-panel.component.ts |
DEFAULT_GEOMETRY | { x: 96, y: 96, width: 420, height: 560 } | side-panel-state.service.ts |
DEFAULT_TODO_SPLIT | 0.55 (clamped to [0.15, 0.85]) | side-panel-state.service.ts |
notesColumns / companyNotesColumns | 1 | 2 | 3 | side-panel-state.service.ts |
localStorage keys
'todo')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:
widthinto[MIN_WIDTH, innerWidth]heightinto[MIN_HEIGHT, innerHeight − VIEWPORT_MARGIN]xinto[0, innerWidth − width]yinto[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 behindConfirmationModalComponent. 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;maxMonthfurther 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; legacyvalidateFinancialYearDatesrequires 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.
| Store | Owns | Reads |
|---|---|---|
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.
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.
§ 09 Shared search inputs
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.
| Input | Used by | Effect 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
{ Text, DueDate? }{ Text?, DueDate?, ClearDueDate?, Done?, Color?, Version }{ Text }{ Text?, Color?, Version }Notes: company & tenant
{ Text }{ Text?, Color?, Version }{ Text }Reports
?startdate=YYYY-MM-DD&enddate=YYYY-MM-DD?startdate=YYYY-MM-DD&enddate=YYYY-MM-DD?financialyear=UUID?startdate=…&enddate=…&accountno=…?financialyear=UUID?financialyear=UUID&journaltype=…&page=N&pagesize=300Search
?financialyear=UUID&search=…&limit=…?financialyear=UUID&journaltype=…&search=…&limit=…File download (existing)
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
| Column | Type | Notes |
|---|---|---|
id | UUID | PK, default gen_random_uuid(). |
type | VARCHAR(20) | CHECK in PERSONAL_TODO / PERSONAL_NOTE / COMPANY_NOTE / TENANT_NOTE. |
user_id | UUID | Always set. Sole filter for personal kinds. |
dbid | UUID | Required for COMPANY_NOTE and TENANT_NOTE; NULL otherwise. |
tenant_id | UUID | Required for TENANT_NOTE only. |
text | TEXT | NOT NULL. |
due_date | DATE | CHECK: only PERSONAL_TODO may have one. |
done | BOOLEAN | DEFAULT FALSE. CHECK: only PERSONAL_TODO may be true. |
color | VARCHAR(20) | DEFAULT 'sky'. CHECK in sky / amber / rose / emerald / red. |
version | BIGINT | NOT NULL DEFAULT 1. Incremented on every UPDATE. |
created_date | TIMESTAMPTZ | |
updated_date | TIMESTAMPTZ |
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_personalon(user_id, type, created_date DESC)WHEREtype IN ('PERSONAL_TODO', 'PERSONAL_NOTE').idx_note_companyon(dbid, created_date DESC)WHEREtype = 'COMPANY_NOTE'.idx_note_tenanton(dbid, tenant_id, created_date DESC)WHEREtype = 'TENANT_NOTE'.
Migrations
V2026_04_30_1000__notes_domain.sql: table + CHECK constraints + partial indexes.V2026_04_30_1100__note_color.sql: addscolorcolumn with default and value CHECK.V2026_05_01_1000__note_version.sql: addsversioncolumn 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.
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.
| Service | Lock-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. |
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
{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).
/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 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