Six substantive issues ranked by their direct tie to the v1.0 production release goal. Every issue here directly removes a blocker or eliminates a class of bugs that would prevent a reliable production launch.
Title: WebSocket connection is never opened — all real-time sensor and alert features are silently dead
Why this matters now:
The roadmap's primary remaining gap is "WebSocket live data in UI". But the problem is deeper than wiring: WebsocketService.connect(token, userId) — the method that opens the Socket.IO connection — is never called anywhere in the application. SensorsEffects.receiveSensorReading$, SensorsEffects.receiveSensorAlert$, and the dashboard's live alert feed subscribe to sensorReadings$ and sensorAlerts$ observables that are backed by this.socket.on(...), but this.socket is always null. Every subscriber gets zero events and no error. This is the single root cause blocking the entire real-time pillar of the app.
Problem / What:
WebsocketService has a connect(token: string, userId: string) method that creates the Socket.IO connection, but there is no call site. The correct place to call it is in AuthEffects, after loginSuccess / rehydrateSession succeeds — at that point both the JWT and the user ID are available. It must also be torn down on logout / forceLogout (the disconnect() call is already partially wired in AuthEffects.logout$ via walletService.disconnect(), but WebsocketService.disconnect() is not called there).
Two secondary problems exist in WebsocketService itself:
sensorReadings$andsensorAlerts$are getters that callthis.on<T>(event)on each access.on<T>()creates anew Observableeach time, callingsocket.on(event, cb)without ever callingsocket.off(event, cb)on teardown — the teardown in the Observable only callssocket.off(event)with no callback argument, which removes all handlers for that event. If two subscribers ever call the getter simultaneously this creates a handler leak.SensorsDashboardmanually callswsService.on<SensorReading>('sensor:reading')and dispatchesaddRealtimeReadingdirectly, bypassing the NgRx effect entirely (SensorsEffects.receiveSensorReading$does the same thing, so readings are processed twice if the socket were ever open).
Key Challenges:
- Determining the correct Observable operator for the connect-on-login lifecycle: the auth effect must use
tap(side-effect only) insideloginSuccess$/rehydrateSession$, and must not re-connect if already connected (guard withconnected$state). - Cleaning up the double-subscription in
SensorsDashboardwithout breaking its localrecentReadingsupdate, since the component currently partially owes its state to a direct WS subscription rather than a selector. - Fixing the
socket.off(event)teardown to pass the specific callback reference so only the registered handler is removed. - Handling the reconnect-after-token-refresh case: after a
forceLogout+ newloginSuccess, the socket must reconnect with the fresh token.
Acceptance Criteria:
WebsocketService.connect()is called exactly once fromAuthEffectsafter a successful login or session rehydration; the JWT and userId are passed correctly.WebsocketService.disconnect()is called from the existinglogout$effect alongsidewalletService.disconnect().SensorsDashboardreads real-time readings exclusively via the NgRxselectRecentReadings/selectRealTimeBufferselectors; it no longer subscribes towsService.on()directly.sensorReadings$andsensorAlerts$are converted from getters to stableObservablefields (created once inconnect(), completed indisconnect()), eliminating the handler-leak on repeated access.- Unit test for
SensorsEffectsverifies thatreceiveSensorReading$dispatchesSensorsActions.receiveSensorReadingwhen the observable emits. - Integration smoke test: opening the sensor dashboard while authenticated shows live readings flowing into the store.
Relevant files/functions:
src/app/core/services/websocket.service.ts—connect(),on<T>(),sensorReadings$,sensorAlerts$src/app/core/store/auth/auth.effects.ts—loginSuccess$,logout$,rehydrateSession$src/app/core/store/sensors/sensors.effects.ts—receiveSensorReading$,receiveSensorAlert$src/app/features/sensors/sensors-dashboard/sensors-dashboard.ts—ngOnInitdirect WS subscription (lines ~326–340),loadData()src/app/features/dashboard/dashboard/dashboard.ts—sensorAlerts$Observable constructor
Out of scope: Changes to the Socket.IO backend namespace or auth handshake protocol; WebSocket reconnect-on-token-refresh (that's a follow-on issue); SensorsEffects.routeSubscription$ refactoring.
Labels: type: bug, difficulty: advanced, area: websocket, area: realtime, priority: v1.0
Self-check: If solved, this issue moves the real-time data pillar of v1.0 forward because it removes the root cause (socket never opened) that makes every live sensor reading, every dashboard alert, and every WS-backed store effect permanently dead.
Title: isUserDeclined Freighter error guard is copy-pasted across three effects — extract shared wallet-tx utility and add edge-case coverage for Freighter v6/v7 error shapes
Why this matters now:
Three on-chain transaction flows — retire credits, marketplace buy, governance vote — each contain an identical isUserDeclined(err) function. The comment in marketplace.effects.ts even names this explicitly: "Duplicated from RetirementEffects — see that file for the same helper; no shared util exists yet for this check." This is the most fragile part of the wallet integration: if Freighter changes its rejection error message format (as it did between v5 and v6), all three flows must be patched in parallel, and a missed patch means users see an "error" toast when they simply cancelled — degrading trust in on-chain operations at the worst possible moment. Beyond the duplication, the current string matching covers declined, rejected, cancelled/canceled but not User rejected the request (the standard EIP-1193 phrasing increasingly used by multi-chain wallet SDKs) or the case where Freighter throws a structured object rather than an Error instance.
Problem / What:
Create src/app/core/utils/wallet-tx.utils.ts containing:
isUserDeclined(err: unknown): boolean— single authoritative implementation with full error shape coverage (Error instance check, plain object with.message, string throw, the EIP-1193 phrasing, and Freighter-specific variants).extractSigningError(err: unknown): string— replaces the inlineerr instanceof Error ? err.message : 'Signing failed'ternaries scattered across all three effects.
Remove the three local copies from retirement.effects.ts, marketplace.effects.ts, and governance.effects.ts and import from the shared util. Add a wallet-tx.utils.spec.ts that covers every error shape variant.
Key Challenges:
- The util must handle:
Errorwith message, plain{ message: string }object, bare string thrown,null/undefined, and Freighter structured rejection objects (where the error payload may be nested under.error.messageor.data). - Must not change any action dispatch paths — the refactor is purely extraction, zero behaviour change. Tests must confirm this.
- TypeScript
unknownnarrowing must be exhaustive with noanyleakage (strict mode is enabled).
Acceptance Criteria:
src/app/core/utils/wallet-tx.utils.tsexported withisUserDeclinedandextractSigningError.- All three effects import from the shared util; the local function definitions are deleted.
wallet-tx.utils.spec.tscovers:Error('User declined'),Error('User rejected the request'),Error('cancelled'), plain object{ message: 'rejected' }, bare string'declined',null,undefined, legitimate errorError('network timeout')→ returnsfalse.ng lintpasses with zero warnings after the change.
Relevant files/functions:
src/app/core/store/retirement/retirement.effects.tslines 23–32 (isUserDeclined)src/app/core/store/marketplace/marketplace.effects.tslines 19–28 (isUserDeclined)src/app/core/store/governance/governance.effects.tslines 14–25 (isUserDeclined)src/app/core/utils/— target directory for new util
Out of scope: Changes to the action structure, retry logic, or Freighter API call signatures; adding a shared signTx wrapper (separate concern).
Labels: type: refactor, type: bug, difficulty: intermediate, area: wallet, area: store
Self-check: If solved, this issue moves wallet-transaction reliability forward because it eliminates a class of silent error-classification bugs that will surface when Freighter updates its rejection message format, and removes a maintenance trap that requires triple-patching every wallet error fix.
Title: SensorsDashboard bypasses the NgRx store to load devices and dispatches success/failure actions directly — breaks the unidirectional data-flow contract and makes the component untestable
Why this matters now:
The roadmap's largest remaining task is "Backend API wiring — replace remaining mock/stub data with real NgRx dispatch + selector bindings." The sensor dashboard is the one feature component that regressed past this goal: it dispatches loadDevicesSuccess and loadDevicesFailure directly from a private async loadData() method (calling SensorsService.getDevices() itself), completely bypassing SensorsEffects.loadDevices$. This means: (a) the effect's switchMap cancellation on repeated calls doesn't apply, (b) error retry logic lives in two places, (c) the loading spinner is driven by a local this.loading boolean, not selectSensorsLoading, and (d) it is impossible to test the component in isolation without also mocking the service, which is not the NgRx contract. There is also a redundant selector access via (state as any).sensors — an unsafe cast that breaks type safety and will silently fail if the store slice is renamed.
Problem / What:
Refactor SensorsDashboard to follow the same pattern as every other connected component in the project:
ngOnInitdispatchesSensorsActions.loadDevices({ projectId })— the effect handles the HTTP call.- All data is read via typed selectors (
selectSensorDevices,selectSensorsLoading,selectSensorsError,selectRecentReadings) — not via(state as any).sensors. - The local
private async loadData()method and the localthis.loadingboolean are deleted. - The direct
SensorsServiceinjection is removed from the constructor (the component should not hold an HTTP service reference).
Also: add a missing effect in SensorsEffects — loadSensorHistory$ — that handles SensorsActions.loadReadings (the action exists in the actions file and the reducer handles it, but sensors.effects.ts has no corresponding effect for it).
Key Challenges:
- The component currently uses both the store's
recentReadings(for real-time) and callsSensorsService.getDevices()imperatively; untangling these without losing the existing UI behaviour requires careful analysis of what data each template binding reads. - The
(state as any).sensorscast must be replaced with a typedselectSensorsStatefeature selector — must verify the feature key matches theActionReducerMapkey inapp.state.ts. loadReadingsinsensors.effects.tshas no effect handler — this must be added alongside theSensorsService.getReadings(deviceId)call, which doesn't exist yet inSensorsService(requires adding the method).
Acceptance Criteria:
SensorsDashboardhas no directSensorsServiceinjection; all data loads are dispatched as actions.(state as any).sensorsis gone; replaced with typedselectSensorDevices,selectRecentReadingsselectors.SensorsEffectshas a workingloadReadings$effect backed by aSensorsService.getReadings(deviceId)method.SensorsEffectsspec coversloadDevicessuccess and failure paths (currently missing).ng buildproduces no TypeScript errors.
Relevant files/functions:
src/app/features/sensors/sensors-dashboard/sensors-dashboard.ts—loadData(),ngOnInit(lines ~318–365), constructorsrc/app/core/store/sensors/sensors.effects.ts— missingloadReadings$effectsrc/app/core/services/sensors.service.ts— needsgetReadings(deviceId: string)methodsrc/app/core/store/sensors/sensors.selectors.ts—selectSensorDevices,selectRecentReadingssrc/app/core/store/sensors/sensors.effects.spec.ts— addloadDevicessuccess/failure tests
Out of scope: The WebSocket connect lifecycle (Issue 1); SensorConfig component; adding historical chart data loading.
Labels: type: bug, type: refactor, difficulty: intermediate, area: sensors, area: store, priority: v1.0
Self-check: If solved, this issue moves the API-wiring milestone forward because it fixes the one feature component that actively breaks the store contract, and adds the missing HTTP effect for device readings that no other issue covers.
Title: Service worker data-cache config caches authenticated API responses with a 1-day TTL and no cache-busting — will serve stale or wrong-user data after logout
Why this matters now:
The roadmap lists PWA service worker as a v1.0 deliverable, and ngsw-config.json is already present with provideServiceWorker active in app.config.ts. But the current dataGroups configuration in ngsw-config.json will cause a critical security/correctness defect in production: it caches /analytics/**, /projects/**, and /credits/** with a freshness strategy, maxAge: "1d", and timeout: "5s". These are authenticated endpoints — the cache is keyed on URL only, not on the JWT or user identity. When a user logs out and another user logs in on the same device (or the same user logs in on a different Stellar account), the service worker will return the previous user's credit portfolio, retirement history, and analytics data from the Cache Storage API for up to 24 hours, bypassing the network entirely if the 5-second timeout elapses. There is also no Vary: Authorization awareness in the Angular service worker — it does not vary cache entries by request headers.
Problem / What: This requires a two-part fix:
Part 1 — Purge authenticated data cache on logout:
The Angular service worker exposes a SwUpdate service, but cache invalidation requires directly calling caches.delete() on the relevant Cache Storage entries. Add a PwaService (or extend AuthEffects) that calls caches.keys() and deletes all ngsw:db:* and ngsw:cache:* entries scoped to authenticated data groups when AuthActions.logout or AuthActions.forceLogout is dispatched.
Part 2 — Scope the data cache to non-sensitive, truly public endpoints only:
Remove /credits/** and /analytics/** from ngsw-config.json — these are user-specific and must never be cached across sessions. Keep only /projects/** (project list is public-facing and safe to serve stale) and map-tiles (already correct). Add cache-busting headers (Cache-Control: no-store) to sensitive endpoints at the ApiService level so the SW never caches them even if the config is extended in future.
Key Challenges:
- The Angular service worker's Cache Storage key naming convention (
ngsw:db:<hash>:data) must be inspected at runtime — the hash changes per build, so deletion must use prefix matching (keys().filter(k => k.startsWith('ngsw:'))), which requires care to not delete the app-shell cache (which would cause a blank screen on the next load). cachesis a browser global not available in SSR/test environments — thePwaServicemust guard withtypeof caches !== 'undefined'.- Adding
Cache-Control: no-storeat theApiServicelayer must not conflict with the Axios instance's default headers; it should be injected per-request via the request interceptor based on a request config flag, not globally. - The
ngsw-config.jsondataGroupschange must be validated against the Angular SW schema — the$schemafield is already present, making this verifiable.
Acceptance Criteria:
ngsw-config.jsondataGroupsno longer includes/analytics/**or/credits/**.- A
PwaService(or equivalent inAuthEffects) callscaches.delete()on allngsw:*data-group cache entries when logout/forceLogout fires; the app-shell asset cache (ngsw:*:assets) is not deleted. ApiServiceattachesCache-Control: no-storeon requests to/retirements/**,/credits/**,/analytics/**, and/marketplace/**.- Unit test for
PwaServiceverifies cache deletion is called on logout actions and is a no-op in environments wherecachesis undefined. - Manual test: log in as user A, load dashboard (data cached), log out, log in as user B, verify dashboard fetches fresh data from network (not SW cache).
Relevant files/functions:
src/ngsw-config.json—dataGroupssectionsrc/app/app.config.ts—provideServiceWorkerregistrationsrc/app/core/services/api.service.ts— request interceptor,setTokenProvidersrc/app/core/store/auth/auth.effects.ts—logout$effect- New file:
src/app/core/services/pwa.service.ts
Out of scope: Push notification support; background sync; offline form submission; changing the app-shell caching strategy.
Labels: type: security, type: bug, difficulty: advanced, area: pwa, area: auth, priority: v1.0
Self-check: If solved, this issue moves the v1.0 PWA deliverable forward because it turns the service worker from a cross-user data-leak vector into a correctly scoped offline cache that is safe to ship in production.
Title: CacheInvalidationEffects dispatches blind loadListings({ params: {} }) and loadRetirements({ page: 1 }) on every success action — always resets pagination state and re-fetches data the current view never requested
Why this matters now:
CacheInvalidationEffects is the cross-slice cache invalidation backbone used after every on-chain operation. Its current implementation always dispatches load actions with hardcoded, reset parameters: loadListings({ params: { page: 1, limit: 20 } }), loadRetirements({ page: 1, limit: 20 }), loadProposals({ params: { page: 1, limit: 20 } }). This creates three distinct bugs: (1) If the user is on page 3 of the retirement history when a retirement completes, the invalidation resets them to page 1 and triggers a visible list jump. (2) If the user is on the sensor dashboard and a marketplace listing is filled (triggering buyConfirmed → CacheInvalidationEffects → loadListings), the entire marketplace slice is re-fetched even though no marketplace component is mounted — wasting bandwidth on every transaction. (3) loadActionsForSlice('farmers') always dispatches loadParcels(), but the farmers slice also has loadFarmerOverview() — the overview is never invalidated, leaving it stale after parcel registration. This class of bugs will become more visible as API wiring completes and real network traffic flows.
Problem / What:
Replace the hardcoded loadActionsForSlice function with a context-aware invalidation strategy:
-
Add a
staleness flagper slice instead of immediately re-fetching. Each affected reducer gains astale: booleanfield (similar toportfolioStalealready present inCreditsState). The invalidation effect sets these flags via dedicatedmarkStaleactions rather than dispatching load actions directly. -
Feature components read the
staleflag and re-fetch only if they are currently mounted and the flag is set for their slice. This moves the "should I reload?" decision to the component that actually owns the view, not the global effect. -
The
CacheInvalidationService.CACHE_INVALIDATION_MAPmaps action types toCacheSlice[]— this stays as-is since it's well-tested; only the effect's response changes. -
The
retirementandmarketplaceload actions dispatched by effects should pass through the current pagination state from the store (viawithLatestFrom) rather than hardcoding page 1 — as a minimum fix if the full stale-flag approach is deferred.
Key Challenges:
- The stale-flag approach requires adding
staleto multiple reducer interfaces and correspondingmarkStaleaction creators for each slice — a broad but mechanical change. - The existing
CacheInvalidationEffectsspec tests must be updated to expectmarkStaledispatches rather thanloadXdispatches. withLatestFromfor current pagination state requires injecting theStoreintoCacheInvalidationEffects— it currently only injectsActionsandCacheInvalidationService. This must not create a circular dependency.- The
farmersslice must also dispatchFarmersActions.loadFarmerOverview()on invalidation, not justloadParcels()— this is a correctness bug independent of the approach chosen.
Acceptance Criteria:
CacheInvalidationEffectsno longer hardcodespage: 1in any dispatched load action.- Retiring credits while on retirement history page 3 does not reset pagination to page 1.
- Completing a marketplace buy while on the sensor dashboard does not trigger a network request to
/marketplace/listings. FarmersActions.loadFarmerOverview()is included in the invalidation set for the'farmers'slice.- Updated
cache-invalidation.effects.spec.tscovers the "stale flag set but component not mounted → no network request" scenario, and "component mounted + stale flag → re-fetch on next view init".
Relevant files/functions:
src/app/core/store/cache-invalidation.effects.ts—loadActionsForSlice(),invalidateDependentSlices$src/app/core/store/cache-invalidation.service.ts—CACHE_INVALIDATION_MAPsrc/app/core/store/cache-invalidation.effects.spec.tssrc/app/core/store/retirement/retirement.reducer.ts—lastFetchedalready present; addstalesrc/app/core/store/marketplace/marketplace.reducer.tssrc/app/core/store/farmers/farmers.effects.ts—loadParcelsinvalidation missingloadFarmerOverview
Out of scope: Switching to a reactive query library (NgRx Data, TanStack Query); changes to the CACHE_INVALIDATION_MAP trigger actions.
Labels: type: bug, type: architecture, difficulty: advanced, area: store, area: cache, priority: v1.0
Self-check: If solved, this issue moves the API-wiring milestone forward because it makes the cache invalidation system correct under real user workflows — currently it creates visible pagination resets and silent stale data on every on-chain operation.
Title: WalletState does not persist the connected address across page reloads — session rehydration restores the JWT but leaves the wallet store empty, breaking every component that reads selectWalletAddress
Why this matters now:
The roadmap explicitly flags this in Known Limitations: "the wallet store's effects (connect/disconnect) dispatch actions but do not yet persist the wallet address across page reloads." This is not cosmetic — it breaks the header wallet display, any component using selectWalletAddress to construct Soroban calls or display the connected account, and the retirement certificate page which shows cert.retireeAddress. On hard refresh: AuthEffects.rehydrateSession$ correctly calls authService.fetchCurrentUser() and dispatches loginSuccess({ user, token }), but the wallet address is never restored to WalletState. The user appears logged in (auth state is populated) but the wallet appears disconnected. The WalletService.checkConnection() method exists specifically for this scenario but is never called during rehydration.
Problem / What:
AuthEffects.rehydrateSession$ currently only checks localStorage for the JWT and calls fetchCurrentUser(). It must be extended to also restore the wallet state:
-
After a successful
fetchCurrentUser()call, callwalletService.checkConnection(). If it returnstrue(Freighter is still connected to the same account), dispatchconnectWalletSuccess({ address })alongsideloginSuccess. -
If
checkConnection()returnsfalse(Freighter disconnected or extension unavailable), the session is still valid — dispatchloginSuccesswithout the wallet action. The user is authenticated but will need to re-connect their wallet to sign transactions. The header must handle this state (authenticated but wallet not connected) without showing a broken address. -
WalletStatecurrently has nonetworkfield — butWalletService.checkConnection()could also read the network. Addnetwork: 'testnet' | 'public' | nulltoWalletStateand populate it during both login and rehydration. -
Register
WalletService.onAddressChange()andWalletService.onNetworkChange()callbacks somewhere in the auth/wallet lifecycle (currently they exist in the service but are never registered) — these should dispatchconnectWalletSuccessordisconnectWalletwhen Freighter's state changes externally.
Key Challenges:
walletService.checkConnection()isasync— therehydrateSession$effect is alreadyasync(usesasync/awaitinsideswitchMap), so this fits naturally, but it must not causerehydrateSession$to block login completion if Freighter is slow or unavailable.- The
onAddressChange/onNetworkChangecallbacks must be registered once and cleaned up; the best location isWalletEffectsusingOnInitEffects(same pattern asAuthEffects), butWalletEffectsdoes not currently exist — it must be created and registered inapp.config.ts. - The
WalletStatenetwork field addition requires updating thewalletReducer,wallet.actions.ts(connectWalletSuccessprops), and all call sites ofconnectWalletSuccess. selectWalletAddressis used in feature components; none of them currently guard against a null address post-rehydration — the null case must be verified to not cause template errors.
Acceptance Criteria:
- Hard refresh on any authenticated route restores both
AuthState.tokenandWalletState.addressin a single rehydration pass. - Header correctly shows the connected wallet address after hard refresh without requiring a manual reconnect.
- If Freighter is unavailable during rehydration, the app remains functional (authenticated, wallet shown as disconnected).
WalletStateincludesnetwork: 'testnet' | 'public' | null; it is populated during login and rehydration.WalletEffectsregistersonAddressChange/onNetworkChangeand dispatches the appropriate actions.WalletEffectshas a spec covering address-change dispatch and the rehydration-with-no-freighter path.ng build --configuration productionpasses with no errors after theconnectWalletSuccessprops change.
Relevant files/functions:
src/app/core/store/auth/auth.effects.ts—rehydrateSession$(extend to callcheckConnection)src/app/core/services/wallet.service.ts—checkConnection(),onAddressChange(),onNetworkChange()src/app/core/store/wallet/wallet.reducer.ts— addnetworkfieldsrc/app/core/store/wallet/wallet.actions.ts—connectWalletSuccesspropssrc/app/core/store/wallet/wallet.selectors.ts— addselectWalletNetworksrc/app/app.config.ts— register newWalletEffects- New file:
src/app/core/store/wallet/wallet.effects.ts - New file:
src/app/core/store/wallet/wallet.effects.spec.ts
Out of scope: Multi-wallet support (LOBSTR, xBull); token refresh on network change; changes to the Freighter API wrapper beyond calling existing methods.
Labels: type: bug, difficulty: advanced, area: wallet, area: auth, priority: v1.0
Self-check: If solved, this issue moves the v1.0 production-ready goal forward because it eliminates a broken post-refresh state that makes every wallet-dependent UI element (address display, transaction signing, certificate view) appear broken on every page load in production.
Title: Toast notification renderer is missing — every effect action result is silently swallowed with no user feedback
Why this matters now:
NotificationService is called by every NgRx effect in the codebase (success, error, warning, info) to communicate the outcome of every user action — retire credits, buy listing, cast vote, register parcel, login failure, session expiry. But there is no component anywhere in the DOM that subscribes to NotificationService.notifications$ and renders those messages. The DefaultLayoutComponent renders only <app-header>, <app-sidebar>, and <router-outlet>. The result: the app appears completely unresponsive to user actions — no confirmation after a retirement, no error when a network request fails, no "session expired" warning on force-logout. This is a v1.0 blocker regardless of backend wiring status.
Problem / What:
Create src/app/shared/components/toast-container/toast-container.component.ts — a standalone component that:
- Injects
NotificationServiceand subscribes tonotifications$usingAsyncPipe. - Renders each
ToastNotificationas a dismissible toast card positioned fixed bottom-right (or top-right), stacked vertically. - Uses
@angular/animationsfor enter/leave transitions (slide + fade) that respectprefers-reduced-motion. - Auto-dismisses after
notification.durationms (default 5000);duration: 0means persistent until manually dismissed. - Calls
notificationService.remove(id)on manual close or auto-timeout. - Type-maps
'success' | 'error' | 'info' | 'warning'to distinct colour/icon treatments using existing design tokens (environmental-green,retirement-red,stellar-blue,credit-gold).
Add <app-toast-container> to DefaultLayoutComponent's template. The UIState notifications slice already exists in the store and is populated by addNotification actions — the ToastContainerComponent should read exclusively from NotificationService.notifications$ (the service-level BehaviorSubject), not from the NgRx store, since the service is already the source of truth and all effects already call it directly.
Key Challenges:
- Two parallel notification systems exist:
NotificationService(BehaviorSubject, used by all effects) andUIState.notifications(NgRx store, populated byUIActions.addNotificationbut never dispatched by any effect). These must be reconciled — the correct path is to keepNotificationServiceas the sole source and remove or ignore the unused store slice, not duplicate state. - Angular animations require
BrowserAnimationsModuleorprovideAnimationsAsync()inapp.config.ts— check whether it is already provided before adding. - The toast stack must be accessible:
role="status"/aria-live="polite"forinfo/success,role="alert"/aria-live="assertive"forerror/warning. Focus must not be stolen on toast appearance. prefers-reduced-motion: reducemust disable the slide/fade and show toasts instantly.- The component must be covered by a spec: verify that a
successnotification appears in the DOM and is removed afterremove()is called.
Acceptance Criteria:
<app-toast-container>is present inDefaultLayoutComponent.- Calling
notificationService.success('Title', 'Message')renders a visible green toast within one change-detection cycle. - Calling
notificationService.error(...)renders a red toast withrole="alert". - Auto-dismiss fires after the configured duration; manual close via ✕ button calls
notificationService.remove(id). prefers-reduced-motionmedia query disables animation.toast-container.component.spec.tscovers: success renders, error renders withrole="alert", manual close callsremove, auto-dismiss timing.ng build --configuration productionpasses; noanyusage.
Relevant files/functions:
src/app/core/services/notification.service.ts—notifications$,remove(id)src/app/shared/layouts/default-layout/default-layout.ts— add<app-toast-container>src/app/app.config.ts— check/addprovideAnimationsAsync()src/app/core/store/ui/ui.actions.ts—addNotification(currently unused by effects; reconcile or remove)- New:
src/app/shared/components/toast-container/toast-container.component.ts+ spec
Out of scope: The in-app notification centre / bell panel (that's a v1.1 feature); email notification preferences; changes to NotificationService method signatures.
Labels: type: feature, difficulty: intermediate, area: ui, priority: v1.0
Self-check: If solved, this issue moves v1.0 forward because it unblocks every user-facing feedback loop — without it, the app is functionally opaque after every action regardless of how well the backend is wired.
Title: environment.prod.ts does not exist — production build falls back to development placeholders and REPLACE_WITH_DEPLOYED_ADDRESS contract stubs
Why this matters now:
environment.prod.ts is listed as a v1.0 deliverable and is completely absent from the repository. The CI pipeline explicitly works around this: cp src/environments/environment.ts.example src/environments/environment.ts. The angular.json fileReplacements for the production configuration references environment.prod.ts — if that entry exists and the file doesn't, production builds fail. If it doesn't exist in angular.json, the production build uses the development config with production: false, apiUrl: 'http://localhost:3000/api/v1', and REPLACE_WITH_DEPLOYED_ADDRESS for all four contract addresses. Either way, there is no deployable production configuration today.
Problem / What:
- Create
src/environments/environment.prod.tswithproduction: true, the correct Stellar mainnet RPC URL, placeholder structure for all four contract addresses (documented clearly for the deployer), andstellarNetwork: 'public'. - Create
src/environments/environment.staging.tswithproduction: false, testnet config, and documented placeholders — used for CI preview deployments. - Verify
angular.jsonhas correctfileReplacementsentries pointing to these files for theproductionandstagingconfigurations. - Update the CI workflow to stub
environment.prod.ts(not justenvironment.ts) when building with--configuration productionin the build job — usingenvironment.ts.exampleas the stub so the CI build continues to pass without real contract addresses. - Update
CONTRIBUTING.mdand the repoREADME.md"Environment Configuration" section to document the expected values and how to obtain Stellar contract addresses from the backend deployment.
Key Challenges:
- The
fileReplacementsentry inangular.jsonmust be checked against the actual Angular build configuration name (productionvsproduction-buildetc.) — Angular 17+ uses@angular/build:applicationwhich may have different config key names than the older@angular-devkit/build-angular:browser. environment.prod.tsmust never be committed with real contract addresses or secrets; a.gitignoreentry or clearREPLACE_WITH_DEPLOYED_ADDRESSsentinel must enforce this.- The staging environment must use testnet Soroban RPC (
https://soroban-testnet.stellar.org) while production uses mainnet (https://soroban-mainnet.stellar.org) — verify the correct mainnet endpoint from Stellar documentation. nginx.confdoes not proxy the backend — API calls go directly toenvironment.apiUrl. Document the expected value for production deployments (either a load-balancer URL or the same-host/api/v1path).
Acceptance Criteria:
src/environments/environment.prod.tsexists withproduction: true,stellarNetwork: 'public', mainnet Soroban RPC URL, and all four contract address fields set to documentedREPLACE_WITH_DEPLOYED_ADDRESSsentinels.src/environments/environment.staging.tsexists with testnet config.ng build --configuration productionsucceeds locally (using the.examplestub or actual values).- CI
buildjob uses the correct stub file for--configuration production. README.md"Environment Configuration" section documents the three environment files, their intended use, and how to fill in contract addresses.environment.prod.tsandenvironment.staging.tsare listed in.gitignore(only the.examplefiles are tracked).
Relevant files/functions:
src/environments/— new filesenvironment.prod.ts,environment.staging.tssrc/environments/environment.ts.example— reference for structureangular.json—fileReplacementsunderconfigurations.production.github/workflows/ci.yml—Prepare environment filestep inbuildjobREADME.md— "Environment Configuration" section.gitignore
Out of scope: Deploying the contracts; setting up deployment pipelines beyond CI build; adding runtime environment variable injection (that would require a separate server-side config approach).
Labels: type: feature, type: devops, difficulty: intermediate, area: config, priority: v1.0
Self-check: If solved, this issue moves v1.0 forward because it creates the missing artifact that separates a deployable production build from a development build — without it there is no production configuration to deploy.
Title: Implement environment.prod.ts-aware virtual scrolling for sensor data tables and retirement history using @angular/cdk/scrolling
Why this matters now:
The roadmap lists virtual scrolling as a v1.0 performance deliverable. Two specific views accumulate unbounded DOM nodes under real usage: the sensor readings raw data table (new rows pushed every few seconds from the WebSocket real-time buffer, potentially thousands per session) and the retirement history list (could be hundreds of records for active credit buyers). Both currently render all rows into the DOM. @angular/cdk is already a transitive dependency via Angular Material — it does not add bundle weight.
Problem / What: There are two distinct cases that require different CDK approaches:
Case 1 — RetirementHistoryComponent (paginated list, finite data):
Use ScrollingModule's <cdk-virtual-scroll-viewport> with *cdkVirtualFor as a drop-in replacement for *ngFor on the retirement rows. The fixed item size variant (itemSize in pixels) is appropriate here since row height is uniform. The existing PaginationControlsComponent can remain — virtual scroll handles within-page rendering; pagination handles page fetching.
Case 2 — SensorsDashboard real-time buffer (unbounded, auto-growing):
The realTimeBuffer in SensorsState is already capped at 100 items in the reducer (slice(0, 100)). The table that renders recentReadings should use <cdk-virtual-scroll-viewport> with *cdkVirtualFor. More importantly, the buffer cap strategy should be made explicit and configurable via a constant in app.constants.ts rather than a magic 100 in the reducer.
Key Challenges:
cdkVirtualForrequires a fixeditemSize(pixels). If row heights vary (e.g. a retirement with a longpurposestring), the standard fixed-size viewport will miscalculate scroll position. Either enforce a minimum fixed height via CSS (min-height,overflow: hidden) or use theAutoSizeVirtualScrollStrategyfrom@angular/cdk-experimental/scrolling— document the tradeoff.DataTableComponent(the shared reusable table) uses a standard*ngForinternally. Virtual scrolling cannot be added to it generically without changing its API — it is better applied at the feature component level where the scroll viewport is owned. Do not modifyDataTableComponentitself.- The retirement history component uses
AsyncPipewith an Observable of paginated results. The*cdkVirtualFor[cdkVirtualForOf]input must receive the array synchronously or via a resolved observable — integrate correctly with the existingselectRetirementsselector. - Change detection: both components use or should use
ChangeDetectionStrategy.OnPush.cdkVirtualForis compatible with OnPush but requiresmarkForCheck()discipline — verify no stale view arises when new real-time readings arrive.
Acceptance Criteria:
RetirementHistoryComponentrenders rows via<cdk-virtual-scroll-viewport>+*cdkVirtualFor; DOM node count stays constant as more retirements load (verified via browser DevTools Elements panel).SensorsDashboardreal-time readings table uses<cdk-virtual-scroll-viewport>+*cdkVirtualFor; DOM node count stays constant as the buffer fills.- The real-time buffer cap (
100) is extracted toREALTIME_BUFFER_MAXinsrc/app/core/constants/app.constants.tsand referenced from the reducer. ScrollingModuleis imported only in the two affected feature components (not globally).- Both components pass their existing
should createspec after the change; no new spec regressions. ng build --configuration productionstays within bundle budgets.
Relevant files/functions:
src/app/features/retirement/retirement-history/retirement-history.tssrc/app/features/sensors/sensors-dashboard/sensors-dashboard.tssrc/app/core/store/sensors/sensors.reducer.ts—slice(0, 100)magic numbersrc/app/core/constants/app.constants.ts— addREALTIME_BUFFER_MAXsrc/app/shared/components/data-table/data-table.component.ts— read only; do not modify
Out of scope: Virtual scrolling in DataTableComponent itself; virtual scrolling for projects list (paginated at API level, no unbounded growth); @angular/cdk-experimental AutoSizeVirtualScrollStrategy (document as a follow-on if row heights prove variable).
Labels: type: feature, type: performance, difficulty: intermediate, area: ui, area: sensors, priority: v1.0
Self-check: If solved, this issue moves v1.0 performance forward because it prevents the two views most likely to accumulate thousands of DOM nodes under real usage — sensor streaming and retirement history — from degrading into unresponsive tables.
Title: Light mode preference is not persisted — resets to dark on every page reload; [data-theme="light"] CSS overrides incomplete across components
Why this matters now:
The roadmap lists light mode as a v1.1 deliverable. The infrastructure is 80% there: setDarkMode action, isDarkMode in UIState, selectIsDarkMode selector, document.documentElement.classList.toggle('dark') in HeaderComponent.ngOnInit, and [data-theme="light"] CSS custom property overrides in _variables.scss. The only missing piece is persistence — UIState always initialises with isDarkMode: true, so every hard refresh resets to dark regardless of what the user chose. This makes the toggle functionally useless: users cannot maintain a light mode session.
Problem / What: Two things need to happen:
Part 1 — Persistence:
Add a UIEffects class (new file) with two effects:
persistTheme$— listens forsetDarkModeand writesisDarktolocalStorageunder a key defined inSTORAGE_KEYS(app.constants.ts).- Read-on-init — in
ngrxOnInitEffects()(same pattern asAuthEffects), readlocalStoragefor the saved theme and dispatchsetDarkMode({ isDark })before any component renders. This must fire before the initialClassList.toggle('dark')inHeaderComponent.ngOnInitto avoid a flash of the wrong theme.
Part 2 — CSS completeness:
Audit every feature component and shared component for hardcoded dark: Tailwind variants that do not have a corresponding [data-theme="light"] SCSS override, or bg-dark-bg / text-dark-* custom classes that only exist in dark mode. The goal is that document.documentElement.setAttribute('data-theme', 'light') (alongside removing the dark class) produces a legible, non-broken UI across all pages — not pixel-perfect, just no white-on-white or invisible elements.
Key Challenges:
- The theme flash (FOUC) on hard refresh:
ngrxOnInitEffectsfires after Angular bootstraps, which is after the first render. The only way to fully eliminate flash is to inline a tiny script inindex.htmlthat reads localStorage and sets thedarkclass synchronously before Angular loads — a standard pattern. This is a<script>in<head>, not an Angular effect. UIEffectsmust be registered inapp.config.ts'sprovideEffectsarray — it does not yet exist.STORAGE_KEYSinapp.constants.tsalready hasAUTH_TOKEN; addTHEMEalongside it.- The CSS audit is broad but mechanical — use the browser's element inspector on each page in light mode to identify breakage, then fix in the relevant component SCSS or global
styles.scss.
Acceptance Criteria:
- Setting light mode, hard-refreshing the page, and returning preserves the light mode preference.
- No theme flash on hard refresh (inline script in
index.htmlreads localStorage and sets class synchronously). UIEffectsis registered inapp.config.ts; has a spec covering the persist and rehydrate paths.- All authenticated routes render with legible contrast in light mode — no white text on white background, no invisible icons.
ng lintpasses;ng buildpasses.
Relevant files/functions:
src/app/core/store/ui/ui.actions.ts—setDarkModesrc/app/core/store/ui/ui.reducer.ts—initialState.isDarkModesrc/app/core/constants/app.constants.ts— addSTORAGE_KEYS.THEMEsrc/app/shared/layouts/header/header.ts—toggleDarkMode(),ngOnInitclass togglesrc/index.html— add inline<script>for flash preventionsrc/styles.scss,src/theme/_variables.scss—[data-theme="light"]overrides- New:
src/app/core/store/ui/ui.effects.ts+ui.effects.spec.ts src/app/app.config.ts— registerUIEffects
Out of scope: Per-component theming beyond what's needed for legibility; system prefers-color-scheme detection (document as a follow-on); the notification centre bell panel (Issue 11).
Labels: type: feature, difficulty: intermediate, area: ui, area: theme, priority: v1.1
Self-check: If solved, this issue moves the v1.1 light mode deliverable forward because it makes the existing toggle actually work end-to-end — persistence + flash prevention + legible rendering — rather than being a stateless button that resets on every reload.
Title: Implement the in-app notifications centre — bell icon panel with read/unread state, history, and markNotificationsRead dispatch
Why this matters now:
The roadmap lists "Notifications centre" as a v1.1 feature. The infrastructure is entirely ready: UIState.notifications (array of Notification objects with read, timestamp, notificationType), addNotification / removeNotification / markNotificationsRead actions, selectUnreadNotificationCount selector, and a bell icon in HeaderComponent with a hardcoded red dot. None of this is wired to any UI. This is a self-contained, high-value feature that meaningfully improves the UX of oracle operators and project developers who need to track events that happened while they weren't watching.
Problem / What:
Build a slide-out notification panel triggered by clicking the bell icon in HeaderComponent:
-
NotificationPanelComponent(new standalone component,shared/components/notification-panel/):- Renders
UIState.notificationsviaselectNotificationsselector, sorted newest-first. - Groups by date (Today / Yesterday / Earlier).
- Each row: type icon (colour-coded), title, message, timestamp (
DurationPipefor relative time), and a dismiss button (removeNotification). - "Mark all read" button dispatches
markNotificationsRead. - Empty state when
notifications.length === 0. - Keyboard-accessible:
role="dialog",aria-label="Notifications", focus-trapped while open (ClickOutsideDirectivefor mouse dismiss,Escapekey for keyboard dismiss).
- Renders
-
Wire
addNotificationintoNotificationService— every call tonotificationService.success/error/info/warningshould also dispatchUIActions.addNotificationto the store so events are persisted inUIStateand visible in the panel history. This is additive —NotificationServicekeeps itsBehaviorSubjectfor the transient toast layer (Issue 7); the store receives a permanent copy. -
HeaderComponent— clicking the bell opens/closes the panel (toggleUIStateor local boolean); the red dot becomes theselectUnreadNotificationCountselector value displayed as a badge (hidden when count is 0).
Key Challenges:
NotificationServicecurrently has no access to the NgRxStore— injecting it directly creates a potential DI issue sinceNotificationServiceisprovidedIn: 'root'andStoremay not be available in some test contexts. Use a safe injection:private store = inject(Store, { optional: true })and guard the dispatch withif (this.store).- The
Notificationinterface inui.reducer.tsusesnotificationType(to avoid collision with the browser's nativeNotificationglobal) — all code must use this field name consistently. - Focus trapping in the panel requires
@angular/cdk/a11yFocusTraporFocusTrapFactory— verify this is available as a transitive CDK dependency before importing. - The
DurationPipe("2h ago") updates only on pipe evaluation — if the panel stays open, timestamps become stale. Either re-evaluate every 60s viainterval+AsyncPipe, or accept that timestamps are accurate on open and stale while the panel is open (document the tradeoff). - The store
notificationsarray must be bounded —MAX_NOTIFICATIONS = 50is already defined in the reducer; verify the cap is enforced correctly when notifications exceed 50.
Acceptance Criteria:
- Clicking the bell opens the notification panel; clicking again or pressing
Escapecloses it. - All notifications dispatched by effects appear in the panel with correct type icon and relative timestamp.
- "Mark all read" dispatches
markNotificationsRead; the badge disappears. - Individual dismiss (
removeNotification) removes the item from both the panel and the store. - Panel is keyboard-navigable; focus is trapped when open;
role="dialog"andaria-labelare present. notification-panel.component.spec.tscovers: renders notifications from store, mark-all-read dispatch, dismiss dispatch, empty state, badge count reflectsselectUnreadNotificationCount.ng buildpasses;ng lintpasses with zero warnings.
Relevant files/functions:
src/app/core/store/ui/ui.actions.ts—addNotification,removeNotification,markNotificationsReadsrc/app/core/store/ui/ui.reducer.ts—Notificationinterface,MAX_NOTIFICATIONSsrc/app/core/store/ui/ui.selectors.ts—selectUnreadNotificationCount,selectNotifications(add if missing)src/app/core/services/notification.service.ts— addStoredispatch alongside existingBehaviorSubjectsrc/app/shared/layouts/header/header.ts— wire bell click, badge count- New:
src/app/shared/components/notification-panel/notification-panel.component.ts+ spec
Out of scope: Email/push notification preferences (v1.1 follow-on); persisting notifications across browser sessions (localStorage); WebSocket-pushed notifications from the server (separate to the client-side toast/history pipeline built here).
Labels: type: feature, difficulty: intermediate, area: ui, priority: v1.1
Self-check: If solved, this issue moves the v1.1 notifications centre deliverable forward because it turns a non-functional bell icon and a fully-implemented store slice into a real, accessible notification history that gives operators visibility into past events.