From 7a52d2aa36377296ad91217931684530af6f6b11 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 2 Jun 2026 09:18:02 +0200 Subject: [PATCH 01/20] docs(1040): research companion notification center phase --- .../1040-RESEARCH.md | 687 ++++++++++++++++++ 1 file changed, 687 insertions(+) create mode 100644 .planning/phases/1040-companion-notification-center/1040-RESEARCH.md diff --git a/.planning/phases/1040-companion-notification-center/1040-RESEARCH.md b/.planning/phases/1040-companion-notification-center/1040-RESEARCH.md new file mode 100644 index 00000000..a914b87e --- /dev/null +++ b/.planning/phases/1040-companion-notification-center/1040-RESEARCH.md @@ -0,0 +1,687 @@ +# Phase 1040: Companion Notification Center - Research + +**Researched:** 2026-06-02 +**Domain:** MATLAB uifigure UI + EventStore read API + FastSenseCompanion extension +**Confidence:** HIGH — all findings from direct source code reads; no inference needed + +--- + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +- New self-contained handle class `NotificationCenterPane` in `libs/FastSenseCompanion/`, a SIBLING to `EventsLogPane`: `attach(parent, theme)` / `detach()`, fires `DetachRequested`, detachable to its own window, state survives attach/detach. +- Feed source: Pane reads `EventStore.getEvents()` and filters to UNACKED (`isempty(Event.AckedAt)`). NO new timer — live refresh piggybacks on the existing `LiveTimer_` / `onLiveTick_` loop. +- Dismiss == Acknowledge: `EventStore.acknowledgeEvent(eventId, opts)`. Item leaves inbox on next diff. Ack is shared + audited. Ack on already-acked event is NO-OP, not an error. +- Placement: Companion gains a toolbar BELL button with an unacked-count badge; toggling shows/hides a 4th rightmost grid column. Bell DISABLED when no EventStore (mirror `hEventsBtn_` enable/disable rule). +- Error handling: Every callback wrapped try/catch → non-blocking `uialert`. EventStore read failure: keep last-good list + show inline "stale" marker. +- Newest-first ordering; diff by `Event.Id` to prevent flicker. + +### Claude's Discretion + +- Exact pixel layout of the pane, badge rendering, severity color mapping (within `CompanionTheme`). +- Detached-window title/arrangement. +- Internal diffing / data-structure details. + +### Deferred Ideas (OUT OF SCOPE) + +- Linking the email `NotificationService` into the app. +- Sounds / desktop / OS notifications. +- Snooze / temporary mute. +- Event grouping or storm-collapsing. +- Per-user local "seen" state separate from shared ack. + + +--- + +## Summary + +Phase 1040 is a UI-surface phase over mature infrastructure. The `EventStore.getEvents()` method (`EventStore.m:97`) already returns all in-memory events with no file I/O in single-user mode — no new accessor is needed for the hot path. The pane simply calls `getEvents()` and filters client-side by `isempty(ev.AckedAt)`. `acknowledgeEvent` (`EventStore.m:337`) mutates the in-memory `Event.AckedAt` immediately in both single-user and cluster modes, so the diff on the next tick naturally removes the item from the inbox. + +The Companion's root grid is a `uigridlayout(hFig, [3 3])` with `ColumnWidth = {220, '1x', 360}` (`FastSenseCompanion.m:299-300`). Adding a 4th column means expanding the grid to `[3 4]` and updating `hToolbarPanel_` to span `[1 4]` plus adjusting the log panel span to `[1 4]`. The show/hide mechanism is simply `obj.hLayout_.ColumnWidth{4} = 0` (hidden) vs a real pixel value (visible) — the identical pattern `rebalanceLogStrip_` already uses for row heights. No children need be re-parented. + +**Primary recommendation:** Use `EventStore.getEvents()` directly, filter client-side by `isempty(AckedAt)`, diff by `Event.Id`, and wire the refresh into `onLiveTick_` after line 1679. No new EventStore method is needed; the existing API is sufficient. + +--- + +## Standard Stack + +| Library | Purpose | Notes | +|---------|---------|-------| +| MATLAB `uigridlayout` | 4th-column toggle | `ColumnWidth` cell assignment at runtime is stable; R2020b+ | +| MATLAB `uitable` | Inbox row list | Used in `EventsLogPane`, `TagStatusTableWindow`; `BackgroundColor` striped pair | +| MATLAB `uibutton` | Bell button + Ack | Same pattern as `hEventsBtn_`, `hLiveBtn_` | +| MATLAB `uidropdown` | Severity filter | Same pattern as `hLogLevelDD_` in `EventsLogPane` | +| `EventStore.getEvents()` | Event feed | Returns `obj.events_` directly in single-user mode | +| `EventStore.acknowledgeEvent()` | Dismiss action | In-memory + persisted; see exact signature below | +| `EventGanttCanvas.severityColor()` | Severity colors | Static method; reuse verbatim | +| `CompanionTheme.get()` | Theming struct | Has `StatusOkColor`, `StatusWarnColor`, `StatusAlarmColor`, `Accent` | + +--- + +## Architecture Patterns + +### Recommended Project Structure + +``` +libs/FastSenseCompanion/ +├── NotificationCenterPane.m ← NEW (sibling to EventsLogPane.m) +tests/suite/ +├── TestNotificationCenterPane.m ← NEW class-based suite +tests/ +├── test_notification_center_pane.m ← NEW flat pure-logic tests +``` + +### Pattern 1: EventStore Read API + +**What:** `getEvents()` is the public method to obtain all in-memory events. In single-user mode it returns `obj.events_` directly with zero file I/O (`EventStore.m:97-103`). In cluster mode it merges per-tag NDJSON logs but returns an event array of the same shape. + +**Verified signatures (from `EventStore.m`):** + +```matlab +% Get all events (in-memory, O(1) single-user): +events = obj.getEvents(); % line 97 + +% Filter client-side for unacked: +mask = arrayfun(@(ev) isempty(ev.AckedAt), events); +unacked = events(mask); + +% Acknowledge one event: +ack = obj.acknowledgeEvent(eventId, opts); % line 337 +% eventId — char +% opts — struct with optional fields: +% opts.comment char (default '') +% opts.user char (default ClusterIdentity) +% opts.host char +% opts.epoch double (datenum) +% Returns ack struct: {eventId, by_user, by_host, epoch, comment, action='ack'} + +% In-memory effect: acknowledgeEvent mutates ev.AckedAt, ev.AckedBy, +% ev.AckComment on the Event handle object IMMEDIATELY (line 405-411). +% The next getEvents()+filter call will exclude the event from unacked list. + +% numEvents (count only, no allocation): +n = obj.numEvents(); % line 280 + +% Ack records for a specific event (single-user or cluster): +rows = obj.getAckRecordsForEvent(eventId); % line 440 + +% Static: loadFile (used by EventViewer, NOT recommended for pane) +[events, meta, changed] = EventStore.loadFile(filePath); % line 463 +% Has mtime-based cache — returns stale data if file unchanged. +% Reads the .mat on change. EventViewer uses this for its independent refresh. +% The pane SHOULD NOT use loadFile — use getEvents() on the live handle. +``` + +**How existing consumers get events:** +- `CompanionEventViewer.refresh()` (`CompanionEventViewer.m:214`) calls `obj.Store_.getEvents()` — the live handle. +- `FastSense.m:2617` calls `es.getEventsForTag(tag.Key)` — tag-scoped query. +- `EventViewer.m` (standalone, not the Companion viewer) uses `EventStore.loadFile()` — reads the `.mat` directly. This is the "old" pattern; the Companion version calls `getEvents()`. + +**Recommendation:** Call `obj.EventStore_.getEvents()` on each tick. Filter `isempty(ev.AckedAt)` client-side. Diff by `ev.Id`. No new EventStore method needed. + +**Ack propagation:** Single-user: `acknowledgeEvent` mutates in-memory + appends to `obj.acks_`; persisted only when `save()` is called next. Cluster: also calls `appendAckRecord` (SQLite INSERT); propagation to other Companions ~5s (ACK-01). A race where another Companion acks first: `acknowledgeEvent` in single-user throws `EventStore:unknownEventId` if the eventId is not in `events_`; in cluster mode it silently continues. The pane callback should catch `unknownEventId` and treat it as a no-op (event already acked). + +### Pattern 2: NotificationCenterPane Class Structure + +Mirror `EventsLogPane.m` exactly: + +```matlab +classdef NotificationCenterPane < handle + + events + DetachRequested % fired by inline pop-out icon click + end + + properties (SetAccess = private) + IsAttached logical = false + end + + properties (Access = private) + ThemeStruct_ = [] % CompanionTheme struct + hRoot_ = [] % outer uigridlayout + hTable_ = [] % uitable: inbox rows + hBadgeLbl_ = [] % uilabel in detached header (redundant badge) + hSevDD_ = [] % uidropdown severity filter + hAckAllBtn_ = [] % uibutton "Ack all visible" + hLastUpdateLbl_ = [] % "Updated: HH:MM:SS" + hStaleMarker_ = [] % uilabel "(stale)" — shown on EventStore read error + hPopoutBtn_ = [] % pop-out icon + Companion_ = [] % FastSenseCompanion handle (for openEventViewer_) + LastGoodEvents_ = [] % Event array: last successful fetch (stale guard) + LastIds_ = {} % cellstr: Id set from last diff + end + + methods (Access = public) + function obj = NotificationCenterPane(themeStruct) ... end + function setCompanion(obj, companion) ... end + function attach(obj, parent, themeStruct) ... end + function detach(obj) ... end + function refresh(obj, eventStore) ... end % called by Companion.onLiveTick_ + function applyTheme(obj, themeStruct) ... end + function requestDetach(obj) ... end % test seam + function delete(obj) ... end + end +end +``` + +### Pattern 3: Companion Grid Extension (4th Column) + +The root grid is constructed at `FastSenseCompanion.m:299`: + +```matlab +% CURRENT (line 299-300): +obj.hLayout_ = uigridlayout(obj.hFig_, [3 3]); +obj.hLayout_.ColumnWidth = {220, '1x', 360}; + +% NEW (expand to 4 columns; 4th starts hidden): +obj.hLayout_ = uigridlayout(obj.hFig_, [3 4]); +obj.hLayout_.ColumnWidth = {220, '1x', 360, 0}; % 0 = hidden initially +``` + +Existing panel column assignments stay identical (cols 1-3). New assignments: + +```matlab +% Toolbar spans all 4 cols (was [1 3]): +obj.hToolbarPanel_.Layout.Column = [1 4]; + +% Log panel spans all 4 cols (was [1 3]): +obj.hLogPanel_.Layout.Column = [1 4]; + +% New notification pane panel: +obj.hNotifPanel_ = uipanel(obj.hLayout_); +obj.hNotifPanel_.Layout.Row = 2; +obj.hNotifPanel_.Layout.Column = 4; +``` + +**Show/hide toggle** (no children re-parenting, no grid rebuild): + +```matlab +function toggleNotificationCenter_(obj) + cw = obj.hLayout_.ColumnWidth; + if isnumeric(cw{4}) && cw{4} == 0 + cw{4} = 320; % show: ~320 px (Claude's discretion) + else + cw{4} = 0; % hide + end + obj.hLayout_.ColumnWidth = cw; +end +``` + +This is the identical mechanism `rebalanceLogStrip_` uses for `RowHeight` (`FastSenseCompanion.m:1519-1555`). It is proven safe in this codebase. + +**Known pitfall:** uifigure grid reflows can flicker on first expand if the column has never been visible. Mitigate by setting `ColumnWidth{4} = 320` once during construction with `Visible='off'` on the figure, then collapsing to 0 before `Visible='on'`. (Identical to how the companion shows the full figure only after building all panels at `FastSenseCompanion.m:532`.) + +### Pattern 4: Toolbar Bell Button + Badge + +The toolbar inner grid is a `[1 9]` grid (`FastSenseCompanion.m:323`): + +``` +Col 1 Events 110px Col 2 Live 110px Col 3 Tags 110px +Col 4 PlantLog 130px Col 5 Tile 70px Col 6 CloseAll 90px +Col 7 Wiki 70px Col 8 spacer '1x' Col 9 Gear 36px +``` + +Add the bell as **col 8** (shifting the spacer to col 9 and gear to col 10), expanding the grid to `[1 10]`: + +```matlab +% NEW [1 10] grid: +hToolbarGrid = uigridlayout(obj.hToolbarPanel_, [1 10]); +hToolbarGrid.ColumnWidth = {110, 110, 110, 130, 70, 90, 70, 70, '1x', 36}; + +% Bell button at col 8: +obj.hBellBtn_ = uibutton(hToolbarGrid, 'push'); +obj.hBellBtn_.Layout.Column = 8; +obj.hBellBtn_.Text = char(128276); % bell glyph U+1F514, or use '(!)' ASCII fallback +obj.hBellBtn_.Tag = 'CompanionBellBtn'; +obj.hBellBtn_.ButtonPushedFcn = @(~,~) obj.toggleNotificationCenter_(); +if isempty(obj.EventStore_) + obj.hBellBtn_.Enable = 'off'; + obj.hBellBtn_.Tooltip = 'No EventStore registered'; +end +``` + +**Badge rendering:** `uibutton` has no native badge. Options (Claude's discretion): +- **Recommended:** Include unacked count in the button label text: `sprintf('(%d)', n)` when `n > 0`, plain bell glyph when 0. Simple, robust, no overlay z-order issues. +- Alternative: A `uilabel` overlay positioned absolutely — requires `Units='pixels'` + `Position` — fragile on resize. +- Color signaling: `BackgroundColor` shifts to `theme.StatusAlarmColor` when alarm-severity events are unacked, `theme.StatusWarnColor` for warn-only, `theme.Accent` for info-only. + +**Enable/disable rule** (`hEventsBtn_` at `FastSenseCompanion.m:340-343` is the pattern): +```matlab +if isempty(obj.EventStore_) + obj.hBellBtn_.Enable = 'off'; + obj.hBellBtn_.Tooltip = 'No EventStore registered'; +end +``` + +### Pattern 5: Live Refresh Hook + +`onLiveTick_` is at `FastSenseCompanion.m:1665`. Current body: + +```matlab +function onLiveTick_(obj) + if ~obj.IsLive || isempty(obj.hFig_) || ~isvalid(obj.hFig_); return; end + try + % (a) InspectorPane refresh + obj.InspectorPane_.refreshLive(); + % (b) Tag sample scan → live log + status table push + obj.scanLiveTagUpdates_(); + % (c) EventsLogPane timestamp + obj.EventsLogPane_.setLastUpdated(datetime('now')); + % (d) Cluster-mode polling (dormant in single-user) + if obj.IsClusterMode_; obj.pollClusterContention_(); obj.pollShareStatus_(); end + catch + end +end +``` + +**Insert notification pane refresh AFTER (c), before the cluster block:** + +```matlab +% (d) Notification center refresh +if ~isempty(obj.NotifPane_) && isvalid(obj.NotifPane_) && obj.NotifPane_.IsAttached + try + obj.NotifPane_.refresh(obj.EventStore_); + catch + % Must never crash the timer. + end +end +``` + +**LiveEventPipelines_ observation for lower latency:** `LiveEventPipelines_` are stored at `FastSenseCompanion.m:110` and walked in `pollClusterContention_` (`line 2207-2218`) for contention polling. There is no existing event-emission hook from these pipelines into the companion's tick. The CONTEXT.md says "its tick also nudges a refresh for lower latency" — the cleanest way is to `addlistener` on `LiveEventPipeline`'s `EventDetected` event (if it exists) or simply rely on the LiveTimer_ tick (which is already at 1s period). Check `LiveEventPipeline.m` for any `notify` calls — if there is a `CycleComplete` or `EventEmitted` event, add a listener in the constructor; otherwise the 1s tick is sufficient. + +### Pattern 6: Severity Colors + +Two authoritative sources in the codebase: + +**`EventGanttCanvas.severityColor(sev)`** (`EventGanttCanvas.m:285-298`): +```matlab +% Sev 1 (info/ok): [0.20 0.70 0.30] — green +% Sev 2 (warn): [0.95 0.60 0.10] — orange +% Sev 3 (alarm): [0.85 0.20 0.20] — red +% otherwise: [0.50 0.50 0.50] — grey +``` + +**`DashboardTheme`** fields (available via `CompanionTheme.get()`): +```matlab +theme.StatusOkColor = [0.31 0.80 0.64] % green (teal) +theme.StatusWarnColor = [0.91 0.63 0.27] % orange +theme.StatusAlarmColor = [0.91 0.27 0.38] % red +``` + +**Recommendation:** Use `EventGanttCanvas.severityColor()` for row `BackgroundColor` accent (matches the existing event rendering convention). Use `DashboardTheme.StatusAlarmColor` / `StatusWarnColor` for the bell badge color (matches the StatusWidget convention). Both are available in the same session with no import needed. + +### Pattern 7: Event Model (confirmed from Event.m) + +```matlab +% Key properties: +ev.AckedAt % numeric epoch (datenum); [] = unacked — CHECK: isempty(ev.AckedAt) +ev.AckedBy % struct {user, host, epoch, comment} +ev.AckComment % char: convenience alias +ev.Severity % 1=info, 2=warn, 3=alarm +ev.IsOpen % logical: true = condition still active +ev.Id % char: unique id (format 'evt_N') +ev.SensorName % char: tag key +ev.ThresholdLabel % char: threshold label +ev.StartTime % numeric: datenum +ev.EndTime % numeric: datenum (NaN if still open) + +% ISA-18.2 four-state: +s = ev.computeDisplayState() +% 'unacked-active' — needs immediate attention +% 'acked-active' — operator acknowledged but alarm persists +% 'acked-cleared' — normal closure +% 'unacked-cleared' — closed but never acked (audit anomaly) +``` + +The inbox shows only states where `isempty(ev.AckedAt)` — i.e., `'unacked-active'` and `'unacked-cleared'`. The `IsOpen == true` "LIVE" tag applies to `'unacked-active'` events. `computeDisplayState()` (`Event.m:125-151`) is the canonical check — use it for row styling. + +### Anti-Patterns to Avoid + +- **Using `EventStore.loadFile()` inside the pane:** This reads the `.mat` file on mtime change, bypasses the live in-memory `events_`, and is the "old" pattern used only by the standalone `EventViewer`. Use `getEvents()` on the live handle instead. +- **Creating a new timer in NotificationCenterPane:** CONTEXT.md explicitly forbids this. Refresh must hook into `onLiveTick_`. +- **Re-parenting the notification pane panel on hide/show:** Setting `ColumnWidth{4} = 0` is sufficient — no `detach()`/`attach()` cycle needed for the column toggle. The pane stays attached to `hNotifPanel_`. +- **Iterating over the raw event array during `acknowledgeEvent`:** In cluster mode the event may not be in `events_` (it lives only in NDJSON). The pane should call `acknowledgeEvent` and catch `EventStore:unknownEventId` — treat as no-op (already acked by another Companion). + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Severity → RGB color | Custom color map | `EventGanttCanvas.severityColor(sev)` | Already in codebase; consistent with Gantt | +| Theme-aware stripe colors | Manual light/dark branch | Copy the `isDark = mean(t.DashboardBackground) < 0.5` + `stripePair` block from `EventsLogPane.m:183-188` | Already proven; don't duplicate logic | +| Detach-to-uifigure pattern | Custom window manager | Mirror `setLogState_('events', 'Detached')` in `FastSenseCompanion.m:1484-1498` exactly | The CloseRequestFcn dance prevents recursion | +| Event ack record | Custom persistence | `EventStore.acknowledgeEvent(id, opts)` | Shared + audited; cluster-mode retry included | +| Unacked count | Custom traversal | `sum(arrayfun(@(ev) isempty(ev.AckedAt), events))` | One-liner using Event.AckedAt | +| Theme walker | Custom recursion | `applyThemeToChildren_(hRoot_, themeStruct)` from EventsLogPane.applyTheme | Already in codebase; handles all uifigure widget types | + +--- + +## Runtime State Inventory + +This is a new UI surface, not a rename/refactor. No runtime state migration required. + +- **Stored data:** None — no new persistent state. `EventStore` data pre-exists. +- **Live service config:** None. +- **OS-registered state:** None. +- **Secrets/env vars:** None. +- **Build artifacts:** None — pure `.m` file additions. + +--- + +## Common Pitfalls + +### Pitfall 1: Grid Column Count Mismatch in Tests +**What goes wrong:** `TestFastSenseCompanion` has hard-coded assertions on toolbar column positions (`testToolbarHasWikiButton` asserts Wiki at col 7; `testToolbarGearMovedToColumn8` asserts gear at col 9). Expanding the toolbar from `[1 9]` to `[1 10]` shifts gear to col 10 and the existing tests fail. +**Why it happens:** Column indices are asserted literally. +**How to avoid:** Update both test assertions when adding the bell at col 8. The method name `testToolbarGearMovedToColumn8` is intentionally kept mis-named per STATE.md — only update the `verifyEqual` value, not the method name. +**Warning signs:** CI `TestFastSenseCompanion` failures on `testToolbarGearMovedToColumn8`. + +### Pitfall 2: acknowledgeEvent Race (cluster mode) +**What goes wrong:** User A clicks Ack on an event that User B already acked. In single-user mode `acknowledgeEvent` throws `EventStore:unknownEventId` if `events_` was refreshed. In cluster mode it silently continues even if the event isn't in `events_`. +**Why it happens:** Single-user strict check at `EventStore.m:418-425`; cluster tolerates missing event. +**How to avoid:** In the pane's `onAckBtn_` callback, catch `EventStore:unknownEventId` and treat as a no-op (log an info entry; do not uialert — not an error). Let the next `refresh()` call update the list. + +### Pitfall 3: uifigure Column Reflow Flicker on First Expand +**What goes wrong:** Setting `ColumnWidth{4}` from 0 to a real value while the uifigure is visible causes a momentary reflow that can flash the panel before the pane is rendered inside it. +**Why it happens:** MATLAB uifigure redraws synchronously on `ColumnWidth` change. +**How to avoid:** Build `hNotifPanel_` and attach `NotificationCenterPane` to it during construction (while `hFig_` is `Visible='off'`), then set `ColumnWidth{4} = 0`. On first expand, `drawnow` after the width change ensures a clean render. + +### Pitfall 4: Timer Re-entrancy During Refresh +**What goes wrong:** `onLiveTick_` fires while a previous tick's `refresh()` is still executing (e.g., a slow `getEvents()` merge in cluster mode). +**Why it happens:** `BusyMode='drop'` on `LiveTimer_` (`FastSenseCompanion.m:855`) drops incoming ticks when the timer callback is running — so this should not occur in practice. But `drawnow` inside the callback can release MATLAB's event queue and allow re-entry. +**How to avoid:** Never call `drawnow` inside `NotificationCenterPane.refresh()`. Use lazy update — only update `hTable_.Data` when the diff has changes. Do not call `drawnow` explicitly. + +### Pitfall 5: Large Event Arrays (Event Storms) +**What goes wrong:** Thousands of rapid threshold violations produce a huge `events_` array. `getEvents()` in cluster mode reads all NDJSON files on each tick. +**Why it happens:** No server-side filtering; all filtering is client-side. +**How to avoid:** CONTEXT.md defers grouping/storm-collapsing. Mitigate by: (a) cap the displayed inbox at N=200 (configurable); (b) skip `getEvents()` if `numEvents() == 0` (zero-cost check); (c) in cluster mode, consider calling `getEventsForTag(key)` per-tag rather than `getEvents()` if the full merge is too slow (measure first). + +### Pitfall 6: MISS_HIT Line Length (160 chars) +**What goes wrong:** Long inline lambdas or sprintf format strings exceed 160 chars. +**How to avoid:** Break at 130 chars when writing callbacks. `mh_style` will catch violations. Run `mh_style --fix` before commit. + +### Pitfall 7: Octave Compatibility +**What:** `NotificationCenterPane` uses `uifigure`, `uitable`, `uibutton` — all MATLAB-only (`FastSenseCompanion.m:134-137` already has the Octave error guard). The pane itself does NOT need an Octave guard because the Companion constructor already throws on Octave. +**Warning signs:** Never reference `exist('OCTAVE_VERSION', 'builtin')` inside the pane — trust the Companion's guard. + +--- + +## Code Examples + +### Get Unacked Events (verified pattern) + +```matlab +% Source: EventStore.m:97-103 (getEvents) + Event.m:43 (AckedAt property) +function evs = getUnackedEvents_(obj, eventStore) + if isempty(eventStore) || ~isvalid(eventStore) + evs = Event.empty; + return; + end + all = eventStore.getEvents(); + if isempty(all) + evs = Event.empty; + return; + end + % Filter: unacked = AckedAt is empty + mask = false(1, numel(all)); + for i = 1:numel(all) + ev = all(i); + if isa(ev, 'Event') + mask(i) = isempty(ev.AckedAt); + end + end + evs = all(mask); + % Newest-first by StartTime + if numel(evs) > 1 + [~, ord] = sort([evs.StartTime], 'descend'); + evs = evs(ord); + end +end +``` + +### Acknowledge One Event (verified pattern) + +```matlab +% Source: EventStore.m:337-438 (acknowledgeEvent) +function onAckBtn_(obj, eventId) + try + opts = struct('comment', ''); + obj.EventStore_.acknowledgeEvent(eventId, opts); + % In-memory AckedAt is set immediately; next refresh() will remove the row. + catch ME + if strcmp(ME.identifier, 'EventStore:unknownEventId') + % Race: already acked by another Companion. Treat as no-op. + return; + end + rethrow(ME); + end +end +``` + +### Ack with Comment Dialog + +```matlab +% Source: uiinputdlg pattern used by CompanionEventViewer notes dialog +function onAckWithComment_(obj, eventId, hFig) + try + answer = inputdlg('Acknowledgement comment:', 'Acknowledge Event', 1, {''}); + if isempty(answer); return; end + opts = struct('comment', answer{1}); + obj.EventStore_.acknowledgeEvent(eventId, opts); + catch ME + if strcmp(ME.identifier, 'EventStore:unknownEventId'); return; end + if ~isempty(hFig) && isvalid(hFig) + uialert(hFig, ME.message, 'Acknowledge Failed', 'Icon', 'error'); + end + end +end +``` + +### Severity Color for Badge + +```matlab +% Source: EventGanttCanvas.m:285-298 (severityColor) + +% DashboardTheme (StatusAlarmColor etc.) +function updateBellBadge_(obj, unackedCount, maxSeverity) + t = obj.Theme_; + if unackedCount == 0 + obj.hBellBtn_.Text = char(128276); % plain bell + obj.hBellBtn_.BackgroundColor = t.WidgetBorderColor; + obj.hBellBtn_.FontColor = t.ForegroundColor; + else + obj.hBellBtn_.Text = sprintf('%s (%d)', char(128276), unackedCount); + switch maxSeverity + case 3, obj.hBellBtn_.BackgroundColor = t.StatusAlarmColor; + case 2, obj.hBellBtn_.BackgroundColor = t.StatusWarnColor; + otherwise, obj.hBellBtn_.BackgroundColor = t.Accent; + end + obj.hBellBtn_.FontColor = t.DashboardBackground; + end +end +``` + +### Column Toggle (show/hide) + +```matlab +% Source: rebalanceLogStrip_ pattern (FastSenseCompanion.m:1519-1555) +function setNotifColumnVisible_(obj, tf) + cw = obj.hLayout_.ColumnWidth; + if tf + cw{4} = 320; % show (Claude's discretion on width) + else + cw{4} = 0; % hide + end + obj.hLayout_.ColumnWidth = cw; +end +``` + +--- + +## State of the Art + +| Old Approach | Current Approach | Notes | +|--------------|------------------|-------| +| `EventStore.loadFile()` for event reads | `EventStore.getEvents()` on live handle | `loadFile` is for standalone tools; Companion uses handle directly | +| `EventViewer` reads `.mat` directly | `CompanionEventViewer.refresh()` calls `store_.getEvents()` | Confirmed at `CompanionEventViewer.m:214` | +| 3-column root grid | Expanding to 4 columns with `{4}=0` hide | `rebalanceLogStrip_` proves this pattern is safe | +| 9-column toolbar grid | 10-column grid (bell at col 8, spacer col 9, gear col 10) | Tests must update col 9→10 gear assertion | + +--- + +## Open Questions + +1. **`LiveEventPipeline` EventEmitted/CycleComplete event existence** + - What we know: `LiveEventPipelines_` are stored and walked in `pollClusterContention_` (`FastSenseCompanion.m:2207`). + - What's unclear: Whether `LiveEventPipeline` fires a MATLAB `notify` event on detection so the pane can listen for sub-tick latency. + - Recommendation: Before Wave 1, `grep -n "events\|notify" libs/EventDetection/LiveEventPipeline.m`. If a suitable event exists, add a listener in the Companion constructor. If not, the 1s LiveTimer_ tick is sufficient for v1. + +2. **Bell glyph Octave/cross-platform rendering** + - What we know: Unicode `char(128276)` (U+1F514 BELL) renders on macOS. Windows MATLAB and headless CI may not render emoji glyphs in `uibutton.Text`. + - Recommendation: Use an ASCII fallback glyph (e.g., `'[!]'`) if `usejava('desktop')` returns false or if CI reports rendering issues. Alternatively use `char(9665)` (a simpler bell-adjacent glyph). This is Claude's discretion. + +3. **`acknowledgeEvent` single-user no-save behavior** + - What we know: In single-user mode, `acknowledgeEvent` mutates in-memory and appends to `acks_`, but does NOT call `save()` (`EventStore.m:428-436`). Persistence requires an explicit `save()` call. + - What's unclear: Whether the pane should call `obj.EventStore_.save()` after each ack, or leave persistence to the pipeline's natural `save()` rhythm. + - Recommendation: Call `save()` after each ack in single-user mode to ensure the ack survives a crash. Wrap in try/catch — save failure is non-fatal for the UI but should log an entry. + +--- + +## Environment Availability + +This phase is pure MATLAB code additions — no external tools, databases, or CLIs beyond what already runs in the project. Step 2.6 SKIPPED (no external dependencies identified). + +--- + +## Validation Architecture + +### Test Framework + +| Property | Value | +|----------|-------| +| Framework | MATLAB `matlab.unittest.TestCase` (class-based) + flat `test_*.m` scripts | +| Config file | `tests/run_all_tests.m` (discovers both) | +| Quick run command | `run_matlab_test_file('tests/suite/TestNotificationCenterPane.m')` | +| Full suite command | `run_matlab_test_file('tests/suite/TestFastSenseCompanion.m')` then `run_matlab_test_file('tests/suite/TestNotificationCenterPane.m')` | + +**Note on execution (from project memory):** Class suites (`Test*.m`) use `mcp__matlab__run_matlab_test_file`; flat `test_*.m` use `mcp__matlab__evaluate_matlab_code` with the file path. + +### Phase Requirements → Test Map + +| Behavior | Test Type | Test File | Automated Command | +|----------|-----------|-----------|-------------------| +| `NotificationCenterPane` construct/attach/detach lifecycle | unit | `TestNotificationCenterPane.m` | `run_matlab_test_file('tests/suite/TestNotificationCenterPane.m')` | +| `IsAttached` state machine + `DetachRequested` event | unit | `TestNotificationCenterPane.m` | (same) | +| Buffer preserved across detach/reattach | unit | `TestNotificationCenterPane.m` | (same) | +| `refresh(eventStore)` filters unacked correctly | unit (stub ES) | `test_notification_center_pane.m` | `evaluate_matlab_code('run(''tests/test_notification_center_pane.m'')')` | +| Diff by Event.Id — no flicker on unchanged list | unit (stub ES) | `test_notification_center_pane.m` | (same) | +| Ack call dispatched to EventStore | unit (stub ES) | `test_notification_center_pane.m` | (same) | +| Ack race (`unknownEventId`) handled as no-op | unit (stub ES) | `test_notification_center_pane.m` | (same) | +| Bell badge count + color reflects unacked set | unit (stub ES) | `test_notification_center_pane.m` | (same) | +| Column 4 show/hide via ColumnWidth toggle | integration | `TestFastSenseCompanion.m` | `run_matlab_test_file('tests/suite/TestFastSenseCompanion.m')` | +| Bell button at new toolbar col 8; gear at col 10 | integration | `TestFastSenseCompanion.m` | (same) | +| Bell disabled when EventStore_ is [] | integration | `TestFastSenseCompanion.m` | (same) | +| `onLiveTick_` calls `NotifPane_.refresh()` | integration | `TestFastSenseCompanion.m` | (same) | +| Theme propagated to pane on `applyTheme` | unit | `TestNotificationCenterPane.m` | (same) | + +### Recommended Stub: `StubEventStore` + +Model on `CaptureNotificationService.m` (`tests/CaptureNotificationService.m`). The stub is a pure-logic helper that allows testing the pane without a real EventStore: + +```matlab +classdef StubEventStore < handle + properties + Events_ = Event.empty % configure in test setup + AckedIds_ = {} % track which Ids were acked + ThrowOnAck_ = false % inject unknownEventId error + end + methods + function evs = getEvents(obj); evs = obj.Events_; end + function n = numEvents(obj); n = numel(obj.Events_); end + function ack = acknowledgeEvent(obj, eventId, ~) + if obj.ThrowOnAck_ + error('EventStore:unknownEventId', 'stub throw'); + end + obj.AckedIds_{end+1} = eventId; + % Mutate the in-memory Event (mirror real EventStore behavior): + for i = 1:numel(obj.Events_) + if strcmp(obj.Events_(i).Id, eventId) + obj.Events_(i).AckedAt = now; + break; + end + end + ack = struct('eventId', eventId, 'action', 'ack'); + end + end +end +``` + +Location: `tests/suite/StubEventStore.m` (or `tests/StubEventStore.m` — match project convention for flat tests). + +### Wave 0 Gaps + +- [ ] `tests/suite/TestNotificationCenterPane.m` — class-based lifecycle suite (covers attach/detach/DetachRequested/theme) +- [ ] `tests/test_notification_center_pane.m` — flat pure-logic tests (covers refresh/diff/ack/badge using `StubEventStore`) +- [ ] `tests/suite/StubEventStore.m` (or `tests/StubEventStore.m`) — test double for EventStore +- [ ] Update `tests/suite/TestFastSenseCompanion.m`: `testToolbarGearMovedToColumn8` assert value `9 → 10`; add bell-col-8 assertion; add notification-pane integration tests + +--- + +## Project Constraints (from CLAUDE.md) + +| Directive | Impact on This Phase | +|-----------|---------------------| +| Pure MATLAB (no external dependencies) | No new libraries; all UI via MATLAB `uifigure` primitives | +| MATLAB R2020b+ (no Octave for Companion) | Companion constructor already throws on Octave; pane inherits this guard | +| Naming: PascalCase classes, camelCase methods | `NotificationCenterPane.m`, `attach()`, `detach()`, `refresh()` | +| Properties: `SetAccess = private` internal state | Follow `EventsLogPane.m` property block layout exactly | +| Error IDs: `FastSenseCompanion:*` | All errors from the Companion layer; pane uses `NotificationCenterPane:*` | +| Callbacks: try/catch + non-blocking `uialert` | Every button callback, refresh, ack must be wrapped | +| `Listeners_` cell array on every class with `addlistener` | `NotificationCenterPane` must have `Listeners_` + delete in `detach()` | +| `stop(t); delete(t)` in that order for timers | No new timers in this phase | +| Companion is the only `uifigure`; spawned figures are classical `figure` | The detached notification window is a `uifigure` (matches EventsLogPane detach pattern — `setLogState_` at `FastSenseCompanion.m:1488` uses `uifigure`) | +| `axes(uipanel)` not `uiaxes(uipanel)` | Not applicable — pane is table-based, no axes | +| MISS_HIT: 160 char max, tab=4, cyclomatic ≤80, max fn ≤520 lines | Enforce on `NotificationCenterPane.m` | +| Tests run via MCP: class suites via `run_matlab_test_file`; flat via `evaluate_matlab_code` | Follow per project memory note | + +--- + +## Sources + +### Primary (HIGH confidence) + +| Source | Lines/Topics Verified | +|--------|----------------------| +| `libs/EventDetection/EventStore.m` | Full file read: `getEvents()` L97, `acknowledgeEvent()` L337, `numEvents()` L280, `getAckRecordsForEvent()` L440, `loadFile()` L463, `busyRetryWrap_()` L511, in-memory mutation L405-411, single-user acks_ L428-436 | +| `libs/EventDetection/Event.m` | Full file read: `AckedAt` L43, `AckedBy` L44, `AckComment` L45, `Severity` L36, `IsOpen` L38, `Id` L37, `computeDisplayState()` L125-151, `fromStructSafe()` L154 | +| `libs/FastSenseCompanion/FastSenseCompanion.m` | Lines 1-534 (constructor + grid + toolbar), 1665-1691 (`onLiveTick_`), 1519-1555 (`rebalanceLogStrip_`), 1821-1851 (`openEventViewer_`), 1665 (`onLiveTick_` hook point), 298-439 (full grid construction) | +| `libs/FastSenseCompanion/EventsLogPane.m` | Full file read: constructor, `attach()`, `detach()`, `addLogEntry()`, `applyTheme()`, `DetachRequested` event, `LogPaneRoot` tag rule, `bufferSize()`/`peekLogRow()` test helpers | +| `libs/FastSenseCompanion/EventGanttCanvas.m` | `severityColor()` L285-298: green/orange/red RGB values | +| `libs/FastSenseCompanion/CompanionTheme.m` | Full file: `StatusOkColor`, `StatusWarnColor`, `StatusAlarmColor`, `Accent` fields | +| `libs/FastSenseCompanion/CompanionEventViewer.m` | `refresh()` L211-222: calls `Store_.getEvents()`, confirming correct pattern | +| `tests/suite/TestEventsLogPane.m` | Full file: test helper pattern (`makePane_`), `uifigure('Visible','off')` idiom, `addTeardown`, headless guard | +| `tests/suite/TestFastSenseCompanion.m` | L1252-1281: toolbar column assertions; L1086-1161: EventStore DI tests; L1-40: suite setup/guards | +| `tests/CaptureNotificationService.m` | Full file: stub pattern to replicate for `StubEventStore` | +| `.planning/config.json` | `nyquist_validation: true` → Validation Architecture section required | + +### Secondary (MEDIUM confidence) + +- STATE.md: confirmed v4.0 phases 1029-1033 shipped; toolbar is now 1x9 post-PR-#159 (260526-tcf entry confirming Wiki at col 7, gear at col 9); Phase 1040 added 2026-06-02. +- REQUIREMENTS.md: ACK-01 (~5s propagation), ACK-02 (three-state visual), ACK-03 (comment), IDENT-02 (audit trail) — all implemented in Phase 1032. + +--- + +## Metadata + +**Confidence breakdown:** +- EventStore API: HIGH — full source read, all method signatures verified +- Companion grid layout: HIGH — constructor source read line by line +- Pane detach pattern: HIGH — EventsLogPane + setLogState_ both read in full +- Severity colors: HIGH — EventGanttCanvas.severityColor + DashboardTheme both read +- Test infrastructure: HIGH — TestEventsLogPane + TestFastSenseCompanion both read +- Toolbar badge (no native MATLAB badge): MEDIUM — Unicode glyph rendering on non-macOS is LOW confidence; recommend ASCII fallback + +**Research date:** 2026-06-02 +**Valid until:** 2026-07-02 (stable MATLAB API; 30 days) From 5c5e5fb8e16aab170a6dd989e0e221888db734ff Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 2 Jun 2026 09:50:42 +0200 Subject: [PATCH 02/20] docs(1040): create Companion Notification Center phase plan 4 plans across 4 waves (test foundation -> pane -> Companion integration -> tests+verify). Derives must_haves from the phase goal + 1040-CONTEXT.md locked decisions (no REQ-IDs mapped). Populates the VALIDATION.md per-task map and updates the ROADMAP phase block. Co-Authored-By: Claude Opus 4.8 (1M context) --- .planning/ROADMAP.md | 13 + .../1040-01-test-foundation-PLAN.md | 265 ++++++++++++ .../1040-02-notification-pane-PLAN.md | 357 ++++++++++++++++ .../1040-03-companion-integration-PLAN.md | 397 ++++++++++++++++++ .../1040-04-companion-tests-verify-PLAN.md | 267 ++++++++++++ .../1040-VALIDATION.md | 90 ++++ 6 files changed, 1389 insertions(+) create mode 100644 .planning/phases/1040-companion-notification-center/1040-01-test-foundation-PLAN.md create mode 100644 .planning/phases/1040-companion-notification-center/1040-02-notification-pane-PLAN.md create mode 100644 .planning/phases/1040-companion-notification-center/1040-03-companion-integration-PLAN.md create mode 100644 .planning/phases/1040-companion-notification-center/1040-04-companion-tests-verify-PLAN.md create mode 100644 .planning/phases/1040-companion-notification-center/1040-VALIDATION.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 83216bed..de4c6cb8 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -425,3 +425,16 @@ Plans: Plans: - [ ] TBD (promote with /gsd:review-backlog when ready) + +### Phase 1040: Companion Notification Center + +**Goal:** Add an acknowledgeable in-app notification inbox to `FastSenseCompanion` — a collapsible right-hand `NotificationCenterPane` (toggled by a toolbar bell + unacked-count badge) that live-lists unacknowledged threshold-violation events from the shared `EventStore` and lets operators acknowledge them (dismiss = `EventStore.acknowledgeEvent`, shared + audited). Predominantly a new UI surface over existing event + acknowledge infrastructure. +**Requirements**: none mapped — 1040-CONTEXT.md locked decisions + the phase GOAL are the contract (must_haves derived in each PLAN) +**Depends on:** Phase 1039 +**Plans:** 4 plans in 4 waves + +Plans: +- [ ] 1040-01-test-foundation-PLAN.md (Wave 1) — StubEventStore double + NotificationCenterPane static pure-logic helpers + flat test +- [ ] 1040-02-notification-pane-PLAN.md (Wave 2, depends 01) — full detachable inbox pane (attach/detach/refresh/ack/filter/stale/theme) + TestNotificationCenterPane +- [ ] 1040-03-companion-integration-PLAN.md (Wave 3, depends 02) — Companion 4th-column grid + toolbar bell+badge + onLiveTick_ refresh hook + detach wiring +- [ ] 1040-04-companion-tests-verify-PLAN.md (Wave 4, depends 03) — TestFastSenseCompanion toolbar-col updates + 9 integration tests + full-suite gate + human live-verify diff --git a/.planning/phases/1040-companion-notification-center/1040-01-test-foundation-PLAN.md b/.planning/phases/1040-companion-notification-center/1040-01-test-foundation-PLAN.md new file mode 100644 index 00000000..4c3e3993 --- /dev/null +++ b/.planning/phases/1040-companion-notification-center/1040-01-test-foundation-PLAN.md @@ -0,0 +1,265 @@ +--- +phase: 1040-companion-notification-center +plan: 01 +type: tdd +wave: 1 +depends_on: [] +files_modified: + - tests/StubEventStore.m + - libs/FastSenseCompanion/NotificationCenterPane.m + - tests/test_notification_center_pane.m +autonomous: true +requirements: [] +must_haves: + truths: + - "A StubEventStore handle can stand in for a real EventStore in tests (getEvents / numEvents / acknowledgeEvent)" + - "Pure-logic helpers filter events to unacked, sort newest-first, diff by Id, and compute max severity + badge text — all without rendering any UI" + - "The flat test test_notification_center_pane runs headlessly and passes" + artifacts: + - path: "tests/StubEventStore.m" + provides: "Fake EventStore handle for pane tests (modeled on tests/CaptureNotificationService.m)" + contains: "classdef StubEventStore < handle" + - path: "libs/FastSenseCompanion/NotificationCenterPane.m" + provides: "Pane class shell with static pure-logic helpers (no UI yet)" + contains: "classdef NotificationCenterPane < handle" + - path: "tests/test_notification_center_pane.m" + provides: "Flat pure-logic test for unacked filter / sort / diff / badge" + contains: "function test_notification_center_pane" + key_links: + - from: "tests/test_notification_center_pane.m" + to: "NotificationCenterPane static helpers + StubEventStore" + via: "direct function calls" + pattern: "NotificationCenterPane\\.(filterUnacked_|maxSeverity_|diffIds_|badgeText_)" +--- + + +Lay the Wave-0 test foundation for the Companion Notification Center: a `StubEventStore` +test double and the *pure-logic* core of `NotificationCenterPane` (static helpers that +filter/sort/diff events and format the bell badge), covered by a flat headless test. + +This is interface-first TDD: the pure logic is the contract every later task builds on. +No `uifigure` rendering happens in this plan — only data transforms that can be asserted +in milliseconds without a desktop. + +Purpose: Unblock pane + integration verification (per 1040-VALIDATION.md Wave 0) and pin +the filtering/diffing/badge semantics with tests before any UI exists. +Output: `tests/StubEventStore.m`, the static-method shell of +`libs/FastSenseCompanion/NotificationCenterPane.m`, and `tests/test_notification_center_pane.m`. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.md +@.planning/phases/1040-companion-notification-center/1040-CONTEXT.md +@.planning/phases/1040-companion-notification-center/1040-RESEARCH.md +@.planning/phases/1040-companion-notification-center/1040-VALIDATION.md + + + + +Event.m (libs/EventDetection/Event.m) — relevant public properties: +```matlab +ev.Id % char: unique id (e.g. 'evt_3') +ev.SensorName % char: tag/sensor key +ev.ThresholdLabel % char +ev.StartTime % numeric datenum (sort key) +ev.EndTime % numeric datenum (NaN if open) +ev.Severity % numeric: 1=info, 2=warn, 3=alarm +ev.IsOpen % logical: true while violation still active +ev.AckedAt % numeric datenum; [] = UNACKED (the inbox filter key) +ev.AckedBy % struct {user, host, epoch, comment} +ev.AckComment % char +% Construct a test Event: Event(startTime, endTime, sensorName, thresholdLabel) +% Then set .Severity / .IsOpen / .Id / .AckedAt as needed (all public set). +% NOTE: ev.AckedAt may also be NaN in some persisted records; treat both +% isempty(AckedAt) and all(isnan(AckedAt)) as "unacked" (mirror Event.computeDisplayState L137). +``` + +EventStore.m public methods the stub must mimic (signatures verified): +```matlab +events = store.getEvents(); % EventStore.m:97 — returns Event array +n = store.numEvents(); % EventStore.m:280 +ack = store.acknowledgeEvent(eventId, opts); % EventStore.m:337 +% opts struct, optional .comment (char); single-user throws +% 'EventStore:unknownEventId' if eventId not in events_. +% In-memory effect: sets ev.AckedAt immediately on the matching Event handle. +``` + +tests/CaptureNotificationService.m — the established stub/double PATTERN to mirror +(subclass-of-handle, public capture props, methods that record args). + + + + + + + Task 1: Create StubEventStore test double + + - tests/CaptureNotificationService.m (the stub pattern to mirror — public capture props + recording methods) + - libs/EventDetection/EventStore.m lines 97-103 (getEvents), 280-285 (numEvents), 337-438 (acknowledgeEvent in-memory mutation + unknownEventId throw) + - libs/EventDetection/Event.m lines 35-66 (Severity/Id/IsOpen/AckedAt + constructor) + + tests/StubEventStore.m + + Create `tests/StubEventStore.m` — a `classdef StubEventStore < handle` test double that + lets pane logic be tested without a real EventStore. Model the structure on + `tests/CaptureNotificationService.m`. Required public surface: + + Properties (public, settable in tests): + - `Events_ = Event.empty` % configure in test setup + - `AckedIds_ = {}` % records each eventId passed to acknowledgeEvent (call order) + - `ThrowOnAck_ = false` % when true, acknowledgeEvent throws EventStore:unknownEventId + - `ThrowOnGet_ = false` % when true, getEvents throws (to exercise the stale path later) + + Methods: + - `function evs = getEvents(obj)` — if `obj.ThrowOnGet_`, `error('EventStore:getEventsFailed','stub throw')`; else `evs = obj.Events_;` + - `function n = numEvents(obj)` — `n = numel(obj.Events_);` + - `function ack = acknowledgeEvent(obj, eventId, ~)` — if `obj.ThrowOnAck_`, `error('EventStore:unknownEventId','stub: not found')`. Otherwise record `obj.AckedIds_{end+1} = eventId;`, then mutate the matching in-memory Event (mirror real EventStore: loop `obj.Events_`, on `strcmp(obj.Events_(i).Id, eventId)` set `obj.Events_(i).AckedAt = now;` and `break`), and return `ack = struct('eventId', eventId, 'action', 'ack');`. + + Keep it Octave-tolerant in syntax (no MATLAB-only constructs) since flat tests may run on + Octave; it only needs the `Event` class on path. Header comment block per CLAUDE.md + (`%STUBEVENTSTORE ...` + usage example + `Phase 1040 Plan 01.`). MISS_HIT clean (≤160 cols, 4-space). + + + MISSING — verified together with Task 3 via test_notification_center_pane (this plan's Task 3 creates the test that constructs StubEventStore and asserts acknowledgeEvent records ids + mutates AckedAt) + + + - `exist('tests/StubEventStore.m','file') == 2` (run from repo root) + - `grep -n "classdef StubEventStore < handle" tests/StubEventStore.m` returns a match + - `grep -nE "function (evs = getEvents|n = numEvents|ack = acknowledgeEvent)" tests/StubEventStore.m` returns exactly 3 matches + - `grep -n "EventStore:unknownEventId" tests/StubEventStore.m` returns a match (the ThrowOnAck path) + - `mh_style tests/StubEventStore.m` and `mh_lint tests/StubEventStore.m` report no findings + + StubEventStore.m exists, subclasses handle, exposes getEvents/numEvents/acknowledgeEvent with configurable ThrowOnAck_/ThrowOnGet_, and mutates AckedAt on ack. MISS_HIT clean. + + + + Task 2: Add NotificationCenterPane static pure-logic helpers (no UI) + + - libs/FastSenseCompanion/EventsLogPane.m (the sibling class layout to mirror — properties block, header doc, Listeners_ convention; you only add the classdef shell + static methods here) + - libs/EventDetection/Event.m lines 125-151 (computeDisplayState — for the isempty(AckedAt) || all(isnan(AckedAt)) unacked test) + - libs/FastSenseCompanion/EventGanttCanvas.m lines 285-298 (severityColor — referenced by name; do NOT duplicate the RGB) + - .planning/phases/1040-companion-notification-center/1040-UI-SPEC.md "Interaction Contract" > "Bell Button States" (badge text + max-severity rules) + + + - filterUnacked_([]) returns an empty Event array (no error). + - Given 3 events with AckedAt = [], [], now: filterUnacked_ returns the 2 with empty AckedAt. + - An event with AckedAt = NaN is treated as UNACKED (kept), matching Event.computeDisplayState L137. + - sortNewestFirst_ on events with StartTime [10 30 20] returns them ordered [30 20 10]. + - maxSeverity_ over Severity [1 3 2] returns 3; over [] returns 0. + - diffIds_({'a','b'}, {'a'}) returns true (new id 'b' appeared); diffIds_({'a'},{'a','b'}) returns true (set changed); diffIds_({'a','b'},{'b','a'}) returns false (same set, order-insensitive). + - badgeText_(0, anyGlyph) returns the plain glyph; badgeText_(5, glyph) returns sprintf('%s (%d)', glyph, 5). + + libs/FastSenseCompanion/NotificationCenterPane.m + + Create `libs/FastSenseCompanion/NotificationCenterPane.m` with the class SHELL only — + `classdef NotificationCenterPane < handle` mirroring `EventsLogPane.m`'s header/layout. + In THIS task add ONLY the `events DetachRequested` block, the property block (declared + but unused for now — copy the RESEARCH.md Pattern 2 property list: `IsAttached` SetAccess + private logical=false; private `ThemeStruct_`, `hRoot_`, `hTable_`, `hSevDD_`, `hSearch_`, + `hAckAllBtn_`, `hLastUpdateLbl_`, `hPopoutBtn_`, `Companion_`, `LastGoodEvents_ = Event.empty`, + `LastIds_ = {}`, `Listeners_ = {}`, `IsStale_ = false`), and a `methods (Static)` block with + these PURE helpers (no UI, no Event-store calls — operate on plain data so the flat test runs + fast and Octave-tolerant): + + - `function evs = filterUnacked_(allEvents)` — guard `isempty(allEvents)` → `evs = Event.empty; return`. Build a logical mask: for each `ev`, unacked iff `isempty(ev.AckedAt) || (isnumeric(ev.AckedAt) && all(isnan(ev.AckedAt)))`. Return `allEvents(mask)`. + - `function evs = sortNewestFirst_(events)` — if `numel(events) > 1`, `[~,ord] = sort([events.StartTime],'descend'); evs = events(ord);` else `evs = events`. + - `function s = maxSeverity_(events)` — `if isempty(events); s = 0; else; s = max([events.Severity]); end`. + - `function ids = idsOf_(events)` — `ids = arrayfun(@(e) e.Id, events, 'UniformOutput', false);` (cellstr); empty in → `{}`. + - `function changed = diffIds_(newIds, oldIds)` — order-insensitive set compare: `changed = ~isequal(sort(newIds(:)), sort(oldIds(:)));` with cellstr guards so `{}` vs `{}` → false. + - `function txt = badgeText_(count, glyph)` — `if count <= 0; txt = glyph; else; txt = sprintf('%s (%d)', glyph, count); end`. + - `function rgb = badgeColor_(maxSev, theme)` — per UI-SPEC Bell Button States: `switch maxSev; case 3, rgb = theme.StatusAlarmColor; case 2, rgb = theme.StatusWarnColor; case {0,1}, rgb = theme.Accent; otherwise rgb = theme.Accent; end`. (Idle/zero-count recoloring to WidgetBorderColor is handled by the caller in Plan 03 — this helper returns the active-severity color.) + + Do NOT add `attach/detach/refresh/applyTheme` bodies yet — Plan 02 fills those. If MATLAB + requires the abstract/public methods to exist for the classdef to parse, add empty stubs that + `error('NotificationCenterPane:notYetImplemented', ...)` — but prefer leaving them out entirely + so Plan 02 owns them cleanly. Header doc block + `See also EventsLogPane, EventStore, Event.` + MISS_HIT clean. + + + MISSING — verified by Task 3's test_notification_center_pane (created next in this plan) + + + - `exist('libs/FastSenseCompanion/NotificationCenterPane.m','file') == 2` + - `grep -n "classdef NotificationCenterPane < handle" libs/FastSenseCompanion/NotificationCenterPane.m` matches + - `grep -nE "function (evs = filterUnacked_|evs = sortNewestFirst_|s = maxSeverity_|ids = idsOf_|changed = diffIds_|txt = badgeText_|rgb = badgeColor_)" libs/FastSenseCompanion/NotificationCenterPane.m` returns exactly 7 matches + - `grep -n "DetachRequested" libs/FastSenseCompanion/NotificationCenterPane.m` matches (events block present) + - `mcp__matlab__check_matlab_code` on the file reports no errors + - `mh_style` + `mh_lint` on the file report no findings + + NotificationCenterPane.m parses with the events block, full property declaration, and 7 static pure-logic helpers. No UI code yet. Code Analyzer + MISS_HIT clean. + + + + Task 3: Write flat pure-logic test test_notification_center_pane + + - tests/test_companion_filter_tags.m (flat-test structure: function + local add_*_path() that calls install(); how a flat test asserts and prints) + - tests/StubEventStore.m (created in Task 1) + - libs/FastSenseCompanion/NotificationCenterPane.m (the static helpers from Task 2) + + + - StubEventStore round-trip: configure 3 Events, acknowledgeEvent('evt_2') records 'evt_2' in AckedIds_ and sets that Event's AckedAt non-empty. + - filterUnacked_ + sortNewestFirst_ pipeline returns unacked events newest-first. + - diffIds_ returns false for a reordered identical set, true when an id is added or removed. + - badgeText_ / badgeColor_ map count + max-severity to the documented label/color. + + tests/test_notification_center_pane.m + + Create `tests/test_notification_center_pane.m` as a flat function-based test (Octave-tolerant; + pure logic only — NO uifigure). Follow `tests/test_companion_filter_tags.m`: top function + `test_notification_center_pane()` that calls a local `add_companion_path()` (addpath repo root + + `install()`), then runs assertions and prints `fprintf(' All %d tests passed.\n', n)` on success. + + Cover (use `Event` constructor `Event(startTime, endTime, sensorName, thresholdLabel)` then set + `.Id`, `.Severity`, `.IsOpen`, `.AckedAt`): + 1. Build events e1(Id='evt_1',Sev=3,Start=30,AckedAt=[]), e2(Id='evt_2',Sev=2,Start=20,AckedAt=[]), e3(Id='evt_3',Sev=1,Start=10,AckedAt=now). `unacked = NotificationCenterPane.filterUnacked_([e1 e2 e3])` → assert numel==2 and ids are {'evt_1','evt_2'}. + 2. `sorted = NotificationCenterPane.sortNewestFirst_(unacked)` → assert `sorted(1).Id` is 'evt_1' (Start=30 newest). + 3. Make e4 with AckedAt = NaN → assert filterUnacked_ keeps it (NaN treated as unacked). + 4. `NotificationCenterPane.maxSeverity_([e1 e2])` == 3; `maxSeverity_(Event.empty)` == 0. + 5. `diffIds_({'a','b'},{'b','a'})` == false; `diffIds_({'a','b'},{'a'})` == true; `diffIds_({},{})` == false. + 6. `badgeText_(0,'B')` == 'B'; `badgeText_(5,'B')` == 'B (5)'. + 7. `theme = CompanionTheme.get('dark'); assert isequal(NotificationCenterPane.badgeColor_(3,theme), theme.StatusAlarmColor)` and `badgeColor_(2,theme)==theme.StatusWarnColor` and `badgeColor_(1,theme)==theme.Accent`. + 8. StubEventStore round-trip: `s = StubEventStore; s.Events_ = [e1 e2 e3]; s.acknowledgeEvent('evt_2', struct('comment',''));` → assert `isequal(s.AckedIds_,{'evt_2'})` and `~isempty(s.Events_(2).AckedAt)`. Then `s.ThrowOnAck_ = true;` and assert acknowledgeEvent now throws with identifier 'EventStore:unknownEventId' (wrap in try/catch + verify `ME.identifier`). + + Each assertion via a local `check(cond, msg)` that errors on false. Count tests; print pass line. + MISS_HIT clean (≤160 cols). + + + mcp__matlab__evaluate_matlab_code: addpath(pwd); install(); test_notification_center_pane (expect "All N tests passed." with no error) + + + - `exist('tests/test_notification_center_pane.m','file') == 2` + - `grep -n "function test_notification_center_pane" tests/test_notification_center_pane.m` matches + - `grep -nE "NotificationCenterPane\.(filterUnacked_|sortNewestFirst_|maxSeverity_|diffIds_|badgeText_|badgeColor_)" tests/test_notification_center_pane.m` returns ≥ 6 matches + - `grep -n "EventStore:unknownEventId" tests/test_notification_center_pane.m` matches (ack-throw assertion) + - Running `test_notification_center_pane` via `mcp__matlab__evaluate_matlab_code` prints "All N tests passed." (N ≥ 8) and raises no error + - `mh_style` + `mh_lint` clean on the file + + test_notification_center_pane runs green headlessly, exercising StubEventStore round-trip + all 7 static helpers. MISS_HIT clean. + + + + + +- Run via matlab MCP `evaluate_matlab_code`: `addpath(pwd); install(); test_notification_center_pane` → "All N tests passed." +- `mcp__matlab__check_matlab_code` clean on `libs/FastSenseCompanion/NotificationCenterPane.m`. +- `mh_style` + `mh_lint` clean on all three new files. +- Confirm NO uifigure/uitable/uibutton call appears in this plan's files (pure logic only): + `grep -nE "uifigure|uitable|uibutton|uigridlayout|uidropdown" libs/FastSenseCompanion/NotificationCenterPane.m tests/test_notification_center_pane.m tests/StubEventStore.m` returns NO matches. + + + +- StubEventStore.m, NotificationCenterPane.m (shell + 7 static helpers), test_notification_center_pane.m all exist and MISS_HIT-clean. +- Flat test green; all helper semantics (unacked filter incl. NaN, newest-first sort, order-insensitive id diff, badge text/color) pinned by assertions. +- No UI primitives used anywhere in this plan (foundation is pure logic only). +- Honors 1040-CONTEXT.md deferred items (no NotificationService, sounds, snooze, grouping, per-user seen-state appear). + + + +After completion, create `.planning/phases/1040-companion-notification-center/1040-01-SUMMARY.md`. + diff --git a/.planning/phases/1040-companion-notification-center/1040-02-notification-pane-PLAN.md b/.planning/phases/1040-companion-notification-center/1040-02-notification-pane-PLAN.md new file mode 100644 index 00000000..8af20dce --- /dev/null +++ b/.planning/phases/1040-companion-notification-center/1040-02-notification-pane-PLAN.md @@ -0,0 +1,357 @@ +--- +phase: 1040-companion-notification-center +plan: 02 +type: execute +wave: 2 +depends_on: ["01"] +files_modified: + - libs/FastSenseCompanion/NotificationCenterPane.m + - tests/suite/TestNotificationCenterPane.m +autonomous: true +requirements: [] +must_haves: + truths: + - "NotificationCenterPane attaches into a parent (uipanel/uifigure), renders a header + inbox uitable, and detaches cleanly" + - "refresh(eventStore) shows only UNACKED events newest-first; an EventStore read error keeps the last-good list and shows a (stale) marker" + - "Clicking the Ack column acknowledges the event; an already-acked event (EventStore:unknownEventId) is a silent no-op, not a crash" + - "The severity filter narrows the visible rows client-side; the empty state shows 'No unacknowledged events'" + - "State (LastGoodEvents_, LastIds_, filter) survives detach + reattach" + artifacts: + - path: "libs/FastSenseCompanion/NotificationCenterPane.m" + provides: "Full detachable inbox pane (attach/detach/refresh/applyTheme/ack/bulk-ack)" + contains: "function attach(obj, parent, themeStruct)" + min_lines: 250 + - path: "tests/suite/TestNotificationCenterPane.m" + provides: "Headless class suite for lifecycle/refresh/diff/ack/filter/empty/stale/theme" + contains: "classdef TestNotificationCenterPane < matlab.unittest.TestCase" + key_links: + - from: "libs/FastSenseCompanion/NotificationCenterPane.m" + to: "EventStore.acknowledgeEvent" + via: "onAckBtn_ / onAckWithComment_ / onAckAll_ callbacks" + pattern: "acknowledgeEvent\\(" + - from: "libs/FastSenseCompanion/NotificationCenterPane.m" + to: "EventGanttCanvas.severityColor" + via: "row severity dot coloring" + pattern: "EventGanttCanvas\\.severityColor" + - from: "tests/suite/TestNotificationCenterPane.m" + to: "NotificationCenterPane + StubEventStore" + via: "headless uifigure('Visible','off') + refresh()" + pattern: "NotificationCenterPane\\(" +--- + + +Implement the full `NotificationCenterPane` UI on top of Plan 01's static-helper core: the +attach/detach lifecycle, the header strip (label + search + severity dropdown + "Updated:" label ++ pop-out icon), the bulk "Acknowledge all visible" button, the inbox `uitable`, the +`refresh(eventStore)` loop (unacked filter → diff-by-Id → newest-first → table render), the +per-item Ack / Ack-with-comment / bulk-ack callbacks, the stale-on-read-error guard, and +`applyTheme`. Cover it with a headless class suite. + +This pane is a SIBLING to `EventsLogPane` and mirrors its contract exactly. It does NOT own a +timer (the Companion's `onLiveTick_` drives `refresh`, wired in Plan 03). + +Purpose: Deliver the self-contained, independently-testable inbox pane. +Output: Completed `NotificationCenterPane.m` + `tests/suite/TestNotificationCenterPane.m`. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/phases/1040-companion-notification-center/1040-CONTEXT.md +@.planning/phases/1040-companion-notification-center/1040-RESEARCH.md +@.planning/phases/1040-companion-notification-center/1040-UI-SPEC.md + + + + +EventsLogPane.m public contract to MIRROR (libs/FastSenseCompanion/EventsLogPane.m): +```matlab +events + DetachRequested % fired by pop-out icon: ButtonPushedFcn = @(~,~) notify(obj,'DetachRequested') +end +properties (SetAccess = private) + IsAttached logical = false +end +function obj = NotificationCenterPane(themeStruct) % ctor stores theme, IsAttached=false +function setCompanion(obj, companion) % stores Companion_ handle +function attach(obj, parent, themeStruct) % builds UI inside parent; sets IsAttached=true +function detach(obj) % delete(hRoot_); null handles; IsAttached=false (NO buffer loss) +function applyTheme(obj, themeStruct) % walker + re-assert overrides +function requestDetach(obj) % notify(obj,'DetachRequested') — test seam +function delete(obj) % detach() if attached +% EventsLogPane.detach() preserves its data buffer (LogBuffer_) — mirror that: preserve LastGoodEvents_/LastIds_/filter. +``` + +Theme walker (reuse, do not re-implement): EventsLogPane.applyTheme calls a shared +`applyThemeToChildren_(hRoot, themeStruct)`. Locate how EventsLogPane invokes it and use the +same call. Stripe-pair logic from EventsLogPane.m ~lines 183-188: +```matlab +isDark = mean(themeStruct.DashboardBackground) < 0.5; +if isDark; stripePair = [0.13 0.13 0.13; 0.20 0.20 0.20]; +else; stripePair = [1 1 1; 0.94 0.94 0.94]; end +``` + +EventStore live handle (from Plan 01 interfaces): getEvents(), numEvents(), +acknowledgeEvent(eventId, opts) — opts.comment char; throws EventStore:unknownEventId on race. + +EventGanttCanvas.severityColor(sev) — Static; libs/FastSenseCompanion/EventGanttCanvas.m:285. + sev 1 → [0.20 0.70 0.30]; 2 → [0.95 0.60 0.10]; 3 → [0.85 0.20 0.20]; else grey. USE BY NAME. + +CompanionTheme.get('dark'|'light') fields available: DashboardBackground, WidgetBackground, + WidgetBorderColor, ForegroundColor, ToolbarFontColor, Accent, StatusOkColor, StatusWarnColor, + StatusAlarmColor, PlaceholderTextColor. + +Plan 01 static helpers (call as NotificationCenterPane.): filterUnacked_, sortNewestFirst_, + maxSeverity_, idsOf_, diffIds_, badgeText_, badgeColor_. + + + + + + + Task 1: Implement attach/detach/applyTheme lifecycle + header + table layout + + - libs/FastSenseCompanion/EventsLogPane.m IN FULL — constructor, attach() (header [1 6] grid build, RowHeight {28,'1x'}, Padding [8 4 8 4], RowSpacing 4), detach() (delete hRoot_, null handles, preserve buffer), applyTheme() (walker + overrides), requestDetach(), delete(), and how it calls applyThemeToChildren_ + - libs/FastSenseCompanion/NotificationCenterPane.m (Plan 01 shell — extend it; do not recreate the static block) + - .planning/phases/1040-companion-notification-center/1040-UI-SPEC.md "Component Layout Contract" (root [3 1] grid {28,24,'1x'} to fit the bulk button row; header [1 6] grid ColumnWidth {60,'1x',100,120,36,36}; uitable ColumnName/ColumnWidth) and "Theme Propagation Contract" + + libs/FastSenseCompanion/NotificationCenterPane.m + + Add the instance lifecycle to the Plan 01 shell. Mirror EventsLogPane structure exactly. + + Constructor `NotificationCenterPane(themeStruct)`: store `obj.ThemeStruct_ = themeStruct;` + `obj.IsAttached = false;` `obj.LastGoodEvents_ = Event.empty;` `obj.LastIds_ = {};` + `obj.Listeners_ = {};` `obj.IsStale_ = false;` + + `setCompanion(obj, companion)`: `obj.Companion_ = companion;` + + `attach(obj, parent, themeStruct)`: guard `if obj.IsAttached; return; end`. Store theme. + Build a ROOT `uigridlayout(parent, [3 1])` with `RowHeight = {28, 24, '1x'}`, + `ColumnWidth = {'1x'}`, `Padding = [8 4 8 4]`, `RowSpacing = 4`, + `BackgroundColor = themeStruct.WidgetBackground`. (Row 1 = header strip; row 2 = bulk button; + row 3 = table — per UI-SPEC bulk-button option "rows {28,24,'1x'}".) Store in `obj.hRoot_`. + + ROW 1 header: `uigridlayout(hRoot_, [1 6])`, `Layout.Row=1`, + `ColumnWidth = {60, '1x', 100, 120, 36, 36}`, `RowHeight={'1x'}`, `Padding=[0 0 0 0]`, + `ColumnSpacing=8`. Children: + - Col 1: `uilabel` Text='Notifications', FontSize=11, FontWeight='bold'. + - Col 2: `uieditfield('text')` → `obj.hSearch_`; FontSize=11; set Placeholder='Filter notifications…' inside try/catch (Placeholder is R2021a+; UI-SPEC notes this); `ValueChangedFcn = @(~,~) obj.applyFilterAndRender_();`. + - Col 3: `uidropdown` → `obj.hSevDD_`; Items={'All','Alarm','Warn','Info'}; Value='All'; FontSize=11; Tooltip='Filter by severity'; `ValueChangedFcn = @(~,~) obj.applyFilterAndRender_();`. + - Col 4: `uilabel` → `obj.hLastUpdateLbl_`; Text='Updated: --:--:--'; FontSize=11; FontName='Menlo'; FontColor=themeStruct.PlaceholderTextColor. + - Col 5: `uibutton('push')` → `obj.hPopoutBtn_`; Text=char(8689); FontSize=14; Tooltip='Detach notification center to its own window'; `ButtonPushedFcn = @(~,~) notify(obj,'DetachRequested');`. + - Col 6: reserved/empty (keeps symmetry with EventsLogPane [1 6]). + + ROW 2 bulk button: `uibutton('push')` → `obj.hAckAllBtn_`; `Layout.Row=2`; Text='Acknowledge all visible'; FontSize=11; BackgroundColor=themeStruct.WidgetBorderColor (recolored to Accent when count>0 in Task 2); `ButtonPushedFcn = @(~,~) obj.onAckAll_();`. + + ROW 3 inbox table: `uitable(hRoot_)` → `obj.hTable_`; `Layout.Row=3`; + `ColumnName = {'', 'Sensor', 'Threshold', 'Peak', 'Start', 'Status', '', ''}`; + `ColumnWidth = {12, 'auto', 'auto', 70, 90, 56, 28, 36}` % UI-checker fold-in: Status 55→56, Start stays 90 (kept on 2px grid; see note below); Ack 28; comment 36; + `RowName = {}`; `FontSize=10`; `FontName='Menlo'`; `ForegroundColor=themeStruct.ForegroundColor`; + set `BackgroundColor` to the stripePair (compute via isDark, see interfaces); + `CellSelectionChangedFcn = @(src,ev) obj.onCellSelected_(ev);`. + Add a comment documenting the UI-checker fold-in: Status column 56 px (was 55), and a note + that `uitable` row height is ~20 px platform default in R2020b and `LineHeight` is NOT settable + on `uitable` (so no per-row height override is attempted). [Start column: UI-checker suggested + 90→88; keep 90 here because the monospace 'HH:MM:SS dd-mmm' string needs the width — document + this single intentional deviation in the SUMMARY.] + Set `obj.IsAttached = true;`. Then call `obj.renderTable_();` so a reattach repaints last-good rows. + + `detach(obj)`: guard `if ~obj.IsAttached; return; end`. try `delete(obj.hRoot_)` (tolerate errors). + Null ALL handle props (`hRoot_`, header children handles, `hTable_`, `hSevDD_`, `hSearch_`, + `hAckAllBtn_`, `hLastUpdateLbl_`, `hPopoutBtn_`). `obj.IsAttached = false;`. DO NOT clear + `LastGoodEvents_`, `LastIds_`, or the filter selection (preserve across reattach — mirror + EventsLogPane preserving LogBuffer_). NOTE: capture the current `hSevDD_.Value` / + `hSearch_.Value` into private string props (`SevFilter_`, `SearchText_`) BEFORE nulling, so + `attach` can restore them (add these two private props if not already present). + + `applyTheme(obj, themeStruct)`: store theme; `if ~obj.IsAttached; return; end`; call the shared + `applyThemeToChildren_(obj.hRoot_, themeStruct)` (same invocation EventsLogPane uses); then + re-assert overrides per UI-SPEC: `hLastUpdateLbl_.FontColor` = PlaceholderTextColor (or + StatusWarnColor if `obj.IsStale_`), `hPopoutBtn_.BackgroundColor`=WidgetBorderColor, + `hPopoutBtn_.FontColor`=ForegroundColor, `hTable_.BackgroundColor`=recomputed stripePair, + `hTable_.ForegroundColor`=ForegroundColor. + + `requestDetach(obj)`: `notify(obj,'DetachRequested');` + `delete(obj)`: `if obj.IsAttached; obj.detach(); end` (tolerate errors). + + Add empty private-method placeholders that Task 2 fills: `renderTable_`, `applyFilterAndRender_`, + `onCellSelected_`, `onAckBtn_`, `onAckWithComment_`, `onAckAll_`, `refresh`. (Leave bodies minimal + so the file parses; Task 2 implements them.) All callbacks must be wrapped in try/catch per + CLAUDE.md when implemented. Errors use `NotificationCenterPane:*` IDs. MISS_HIT clean (≤160 cols). + + + mcp__matlab__evaluate_matlab_code: addpath(pwd); install(); f=uifigure('Visible','off'); c=onCleanup(@()delete(f)); p=NotificationCenterPane(CompanionTheme.get('dark')); p.attach(f, CompanionTheme.get('dark')); assert(p.IsAttached); assert(~isempty(findall(f,'Type','uitable'))); p.detach(); assert(~p.IsAttached); disp('OK') + + + - `grep -nE "function (obj = NotificationCenterPane|setCompanion|attach|detach|applyTheme|requestDetach|delete)\(" libs/FastSenseCompanion/NotificationCenterPane.m` returns ≥ 7 matches + - `grep -n "uigridlayout(parent, \[3 1\])\|uigridlayout(parent,\[3 1\])" libs/FastSenseCompanion/NotificationCenterPane.m` matches (root [3 1] with bulk-button row) + - `grep -n "ColumnWidth = {12, 'auto', 'auto', 70, 90, 56, 28, 36}" libs/FastSenseCompanion/NotificationCenterPane.m` matches (UI-checker Status 56 fold-in) + - `grep -n "notify(obj, 'DetachRequested')\|notify(obj,'DetachRequested')" libs/FastSenseCompanion/NotificationCenterPane.m` matches (pop-out + requestDetach) + - The matlab MCP smoke snippet above prints OK: attach builds a uitable, IsAttached toggles true→false across attach/detach + - `mcp__matlab__check_matlab_code` clean; `mh_style` + `mh_lint` clean + + Pane attaches (header [1 6] + bulk button + inbox uitable), detaches preserving LastGoodEvents_/filter, applyTheme re-asserts overrides. Headless smoke passes. MISS_HIT + Code Analyzer clean. + + + + Task 2: Implement refresh/diff/render + ack callbacks + stale guard + + - libs/FastSenseCompanion/NotificationCenterPane.m (Task 1 lifecycle + Plan 01 static helpers) + - libs/EventDetection/EventStore.m lines 337-438 (acknowledgeEvent: opts.comment, in-memory AckedAt mutation, EventStore:unknownEventId throw) + - .planning/phases/1040-companion-notification-center/1040-UI-SPEC.md "Interaction Contract" sections: Inbox Row States (column→content map, CellSelectionChangedFcn column dispatch 7→ack / 8→comment / else→openEventViewer_), Severity Filter Dropdown, Empty State (exact copy), Stale State, Acknowledge Actions (all three), Copywriting Contract (LOCKED strings) + - libs/FastSenseCompanion/EventGanttCanvas.m:285-298 (severityColor) + + libs/FastSenseCompanion/NotificationCenterPane.m + + Fill the private methods stubbed in Task 1. All callbacks wrapped in try/catch → non-blocking + `uialert` (resolve the figure via `ancestor(obj.hRoot_,'figure')`); read errors do NOT uialert + (inline stale marker only). `EventStore:unknownEventId` in any ack path → silent no-op. + + `refresh(obj, eventStore)` (called by Companion.onLiveTick_): + - `if ~obj.IsAttached; return; end`. + - If `isempty(eventStore) || ~isvalid(eventStore)`: set last-good empty path → `obj.LastGoodEvents_ = Event.empty;` clear stale; render; return. + - Wrap `all = eventStore.getEvents();` in try/catch. ON ERROR: set `obj.IsStale_ = true;` keep `obj.LastGoodEvents_` unchanged; update the "Updated:" label to append `' (stale)'` and set its FontColor to StatusWarnColor; if `obj.Companion_` is set and valid, `obj.Companion_.addLogEntry('warn', 'Notification center: EventStore read failed; showing last-good list.')` inside try/catch; return (DO NOT clear inbox; DO NOT uialert). [UI-SPEC Stale State.] + - ON SUCCESS: `obj.IsStale_ = false;` `unacked = NotificationCenterPane.sortNewestFirst_(NotificationCenterPane.filterUnacked_(all));` cap to 200 rows (`if numel(unacked) > 200; unacked = unacked(1:200); end` — UI-SPEC row cap / RESEARCH Pitfall 5). Compute `newIds = NotificationCenterPane.idsOf_(unacked);`. If `NotificationCenterPane.diffIds_(newIds, obj.LastIds_)` is FALSE, just update the "Updated:" timestamp label and return (no flicker — UI-SPEC badge animates only on diff; also RESEARCH Pitfall 4: never call drawnow here). If TRUE: store `obj.LastGoodEvents_ = unacked; obj.LastIds_ = newIds;` then `obj.applyFilterAndRender_();` and update timestamp label to `datetime('now')` HH:MM:SS. + + `applyFilterAndRender_(obj)`: derive the severity-filtered subset of `obj.LastGoodEvents_`: + read `obj.hSevDD_.Value` (guard not-attached → use `obj.SevFilter_`); 'All'→all; 'Alarm'→Severity==3; 'Warn'→Severity==2; 'Info'→Severity==1. Also apply `obj.hSearch_.Value` free-text (case-insensitive `contains` over SensorName + ThresholdLabel) if non-empty. Then `obj.renderTable_(filtered);` and recolor the bulk button: `hAckAllBtn_.BackgroundColor = ` Accent if `~isempty(filtered)` else WidgetBorderColor. + + `renderTable_(obj, events)` (default `events = obj.LastGoodEvents_` when called with one arg — + use `nargin`): if `obj.hTable_` invalid, return. If `isempty(events)`: set EMPTY STATE row exactly + `obj.hTable_.Data = {'', '', 'No unacknowledged events', '', '', '', '', ''};` and return + (UI-SPEC empty-state copy LOCKED). Else build an N×8 cell: + col1 = '' (severity dot — note in a comment that uitable cannot set per-cell BackgroundColor in + R2020b, so the dot is represented by a unicode bullet `char(9679)` whose color is conveyed via + the Status column text color convention; render `char(9679)` here and rely on Status text), + col2 = SensorName, col3 = ThresholdLabel, col4 = peak value string (use `''` if no peak field + available on the Event — guard with isprop/try), col5 = `datestr(ev.StartTime,'HH:MM:SS dd-mmm')`, + col6 = `'LIVE'` if `ev.IsOpen` else `'closed'`, col7 = 'Ack', col8 = '...'. Assign `obj.hTable_.Data`. + Maintain a parallel `obj.RowEventIds_` cellstr (private prop) mapping row→Event.Id so the cell + callback can resolve the clicked event id. (Severity color: keep `EventGanttCanvas.severityColor` + referenced for the row accent in a helper even if uitable limits cell coloring — call it to derive + the dot intent and store; this satisfies the key_link and keeps Gantt-consistent semantics.) + + `onCellSelected_(obj, ev)`: guard `isempty(obj.LastGoodEvents_)` (empty-state click = no-op) and + `isempty(ev.Indices)`. `r = ev.Indices(1); cdx = ev.Indices(2);` resolve `eid = obj.RowEventIds_{r}` + (guard bounds). Dispatch: `cdx==7` → `obj.onAckBtn_(eid)`; `cdx==8` → `obj.onAckWithComment_(eid)`; + otherwise → if `obj.Companion_` valid and has method, `obj.Companion_.openEventViewer_();` + (RESEARCH: future-focus-on-event is out of scope; just open the viewer). Wrap in try/catch → uialert. + + `onAckBtn_(obj, eventId)`: try `obj.ackOne_(eventId);` catch ME → if + `strcmp(ME.identifier,'EventStore:unknownEventId')` return (no-op); else uialert(fig, ME.message, + 'Acknowledge Failed','Icon','error'). + + `onAckWithComment_(obj, eventId)`: `answer = inputdlg('Acknowledgement comment:','Acknowledge Event',1,{''});` + `if isempty(answer); return; end` (cancel). Then `obj.ackOne_(eventId, answer{1});` with the same + unknownEventId no-op / uialert error handling. + + `onAckAll_(obj)`: snapshot the currently-visible event ids (from `obj.RowEventIds_` reflecting the + filtered render). Loop: for each id, try `obj.ackOne_(id);` catch ME → if unknownEventId continue + (no-op per item); else uialert and break. Per UI-SPEC bulk action. + + Private helper `ackOne_(obj, eventId, comment)`: build `opts = struct('comment', '');` and if + `nargin>2 && ~isempty(comment); opts.comment = comment; end`. Resolve the store via the Companion + (`store = obj.Companion_.getEventStore();` if Companion set, else error + `NotificationCenterPane:noEventStore`). Call `store.acknowledgeEvent(eventId, opts);`. In single-user + mode the in-memory AckedAt is set immediately and the next `refresh()` diff drops the row — do not + mutate the table here. [RESEARCH Open Question 3: single-user acknowledgeEvent does not auto-save; + call `store.save()` in try/catch after a successful ack so the ack survives a crash — save failure + is non-fatal, log via Companion.addLogEntry('warn',...).] + + Update `setLastUpdated_`-style label writes to go through a small private + `setUpdatedLabel_(obj, dt, isStale)` so stale + normal paths share one code path. MISS_HIT clean. + + + mcp__matlab__run_matlab_test_file: tests/suite/TestNotificationCenterPane.m (created in Task 3 of this plan — all tests green) + + + - `grep -nE "function refresh\(obj, eventStore\)" libs/FastSenseCompanion/NotificationCenterPane.m` matches + - `grep -n "EventStore:unknownEventId" libs/FastSenseCompanion/NotificationCenterPane.m` matches (race no-op) + - `grep -n "'No unacknowledged events'" libs/FastSenseCompanion/NotificationCenterPane.m` matches (empty-state copy) + - `grep -n "(stale)" libs/FastSenseCompanion/NotificationCenterPane.m` matches (stale marker) + - `grep -nE "inputdlg\('Acknowledgement comment:'" libs/FastSenseCompanion/NotificationCenterPane.m` matches + - `grep -n "acknowledgeEvent(" libs/FastSenseCompanion/NotificationCenterPane.m` matches; `grep -n "EventGanttCanvas.severityColor" libs/FastSenseCompanion/NotificationCenterPane.m` matches + - `grep -c "drawnow" libs/FastSenseCompanion/NotificationCenterPane.m` returns 0 (Pitfall 4: no drawnow inside refresh path) + - `mcp__matlab__check_matlab_code` clean; `mh_style` + `mh_lint` clean + + refresh filters unacked + diffs by Id + caps at 200 + renders newest-first; ack/ack-comment/bulk callbacks route to EventStore.acknowledgeEvent with unknownEventId no-op; read errors keep last-good + show (stale); empty state copy exact; no drawnow in refresh. Code Analyzer + MISS_HIT clean. + + + + Task 3: Write TestNotificationCenterPane class suite (headless) + + - tests/suite/TestEventsLogPane.m IN FULL — TestClassSetup addPaths, makePane_ helper (uifigure('Visible','off') + addTeardown(delete)), how lifecycle/theme/detach are asserted headlessly + - tests/StubEventStore.m (Plan 01) + - libs/FastSenseCompanion/NotificationCenterPane.m (Tasks 1-2) + - .planning/phases/1040-companion-notification-center/1040-VALIDATION.md "Phase Requirements → Test Map" (rows mapped to TestNotificationCenterPane) + + tests/suite/TestNotificationCenterPane.m + + Create `tests/suite/TestNotificationCenterPane.m` as `classdef TestNotificationCenterPane < + matlab.unittest.TestCase`. Mirror `TestEventsLogPane.m`: + - `methods (TestClassSetup) function addPaths(testCase) addpath(fullfile(...,'..','..')); install(); end` (match the relative path EventsLogPane uses). + - Private helper `function [p, hFig] = makePane_(testCase, themeName) hFig = uifigure('Visible','off'); testCase.addTeardown(@() delete(hFig)); p = NotificationCenterPane(CompanionTheme.get(themeName)); testCase.addTeardown(@() delete(p)); end`. + - Private helper `function evs = makeEvents_(testCase)` building a fixed Event array: evt_1(Sev=3,Start=30,IsOpen=true,AckedAt=[]), evt_2(Sev=2,Start=20,AckedAt=[]), evt_3(Sev=1,Start=10,AckedAt=now). Set `.Id` explicitly. + + Test methods (each headless; attach to a Visible='off' uifigure): + 1. `testConstructDetached` — construct pane, assert `~p.IsAttached`. + 2. `testAttachBuildsTable` — `[p,f]=makePane_('dark'); p.attach(f, CompanionTheme.get('dark'));` assert `p.IsAttached` and `~isempty(findall(f,'Type','uitable'))`. + 3. `testDetachReattachPreservesState` — attach; set `p` last-good via `refresh` with a StubEventStore of 2 unacked; detach; assert `~p.IsAttached`; reattach; assert the table repaints with the same 2 rows (read pane's exposed row count — add a tiny Hidden test accessor `numVisibleRows_()` returning size(hTable_.Data,1) excluding empty-state, OR assert `numel(p.LastGoodEvents_)==2` which survives detach). + 4. `testRefreshFiltersUnacked` — StubEventStore with 3 events (one acked); `p.refresh(stub)`; assert `numel(p.LastGoodEvents_)==2` and newest-first (`p.LastGoodEvents_(1).Id` == 'evt_1'). + 5. `testDiffNoFlicker` — refresh once; capture `p.LastIds_`; refresh again with identical events; assert `isequal(p.LastIds_, )` (no spurious change). (diff-by-Id semantics.) + 6. `testAckRemovesOnNextRefresh` — wire pane to a Companion-less ack path: because ackOne_ resolves the store via Companion_, set `p.setCompanion(stubCompanion)` where stubCompanion is a tiny inline double exposing `getEventStore()`→stub and `addLogEntry(varargin)` no-op (define as a local nested test class or a struct-backed handle; simplest: a small `classdef` helper at bottom of the test file, or reuse StubEventStore by adding a `getEventStore` returning self — prefer adding `getEventStore` to the test by wrapping). Simulate clicking Ack on evt_2: call the documented public/Hidden path — expose a Hidden `ackForTest_(eventId)` on the pane that calls `onAckBtn_` so the test can drive it without a real CellSelection event. After ack, `p.refresh(stub)`; assert `numel(p.LastGoodEvents_)==1` and 'evt_2' gone. + 7. `testAckRaceIsNoOp` — `stub.ThrowOnAck_ = true;` drive `ackForTest_('evt_2')`; assert NO error thrown (test passes if the call returns normally) and inbox unchanged. + 8. `testStaleOnReadError` — refresh with a healthy stub (2 rows); then `stub.ThrowOnGet_ = true; p.refresh(stub);` assert `p.LastGoodEvents_` still has 2 (last-good preserved) and the Updated label text contains '(stale)' (read `p` label via a Hidden accessor `lastUpdatedText_()` returning hLastUpdateLbl_.Text). + 9. `testSeverityFilter` — refresh with the 2 unacked (sev 3 + sev 2); set `p.hSevDD_.Value='Alarm'` then call `p.applyFilterAndRender_()`; assert only 1 row visible (sev-3). (Add Hidden accessor for visible row count or assert the filtered render via numVisibleRows_.) + 10. `testEmptyState` — refresh with a stub returning Event.empty; assert table Data is the single empty-state row containing 'No unacknowledged events'. + 11. `testApplyThemeReassertsOverrides` — attach dark; `p.applyTheme(CompanionTheme.get('light'))`; assert no error and `p.IsAttached` still true (smoke that the walker + overrides run). + 12. `testDetachRequestedFires` — `addlistener(p,'DetachRequested', @(~,~) setFlag); p.requestDetach();` assert flag set. + + For any private state the tests must read (visible row count, last-updated text), add the minimal + `methods (Hidden)` test accessors to `NotificationCenterPane.m` IN THIS TASK (e.g., + `numVisibleRows_`, `lastUpdatedText_`, `ackForTest_`) — keep them Hidden so they are not public + API (mirrors the Phase 1028 Hidden-test-seam pattern noted in STATE.md). Headless guard: if the + suite cannot create a uifigure (no desktop), follow TestEventsLogPane's guard idiom + (assumeFail/skip) rather than hard-failing. MISS_HIT clean. + + + mcp__matlab__run_matlab_test_file: tests/suite/TestNotificationCenterPane.m (all tests pass; skips allowed only if headless-guarded like TestEventsLogPane) + + + - `exist('tests/suite/TestNotificationCenterPane.m','file') == 2` + - `grep -n "classdef TestNotificationCenterPane < matlab.unittest.TestCase" tests/suite/TestNotificationCenterPane.m` matches + - `grep -cE "function test[A-Z]" tests/suite/TestNotificationCenterPane.m` returns ≥ 12 + - `grep -nE "testRefreshFiltersUnacked|testAckRemovesOnNextRefresh|testAckRaceIsNoOp|testStaleOnReadError|testEmptyState|testDetachReattachPreservesState" tests/suite/TestNotificationCenterPane.m` returns 6 matches + - `mcp__matlab__run_matlab_test_file` on the suite reports 0 failures (skips permitted only via the headless guard) + - any new Hidden accessors live under `methods (Hidden)` in NotificationCenterPane.m: `grep -n "methods (Hidden)" libs/FastSenseCompanion/NotificationCenterPane.m` matches + - `mh_style` + `mh_lint` clean on the test file + + TestNotificationCenterPane green headlessly: lifecycle, detach/reattach state preservation, unacked filter, diff-no-flicker, ack→removal, ack-race no-op, stale guard, severity filter, empty state, theme, DetachRequested. MISS_HIT clean. + + + + + +- `mcp__matlab__run_matlab_test_file tests/suite/TestNotificationCenterPane.m` → all green. +- Re-run Plan 01's `test_notification_center_pane` to confirm no regression in the static helpers. +- `mcp__matlab__check_matlab_code` clean on `NotificationCenterPane.m`. +- `mh_style` + `mh_lint` clean on both files. +- `grep -c "drawnow" libs/FastSenseCompanion/NotificationCenterPane.m` == 0 (Pitfall 4). +- Confirm deferred items absent: `grep -niE "NotificationService|snooze|sound|grouping|mute|seen" libs/FastSenseCompanion/NotificationCenterPane.m` returns NO matches. + + + +- NotificationCenterPane is a complete, self-contained, detachable inbox pane mirroring EventsLogPane's contract (attach/detach/applyTheme/DetachRequested/setCompanion/delete). +- refresh shows only unacked, newest-first, diffed by Id, capped at 200; read errors keep last-good + (stale); ack paths route to EventStore.acknowledgeEvent with unknownEventId-as-no-op and post-ack save() in single-user; empty-state + LIVE + copy strings match UI-SPEC verbatim. +- TestNotificationCenterPane green headlessly with ≥ 12 tests. +- No timer created in the pane (Companion drives refresh, wired in Plan 03). +- UI-checker fold-ins applied: Status column 56 px; documented uitable row-height note; the single Start-column deviation recorded. + + + +After completion, create `.planning/phases/1040-companion-notification-center/1040-02-SUMMARY.md`. + diff --git a/.planning/phases/1040-companion-notification-center/1040-03-companion-integration-PLAN.md b/.planning/phases/1040-companion-notification-center/1040-03-companion-integration-PLAN.md new file mode 100644 index 00000000..d0c762b9 --- /dev/null +++ b/.planning/phases/1040-companion-notification-center/1040-03-companion-integration-PLAN.md @@ -0,0 +1,397 @@ +--- +phase: 1040-companion-notification-center +plan: 03 +type: execute +wave: 3 +depends_on: ["02"] +files_modified: + - libs/FastSenseCompanion/FastSenseCompanion.m +autonomous: true +requirements: [] +must_haves: + truths: + - "A bell button sits at toolbar column 8 with an unacked-count badge; it is disabled when there is no EventStore" + - "Clicking the bell shows/hides a 4th rightmost grid column hosting the NotificationCenterPane; existing 3 columns reflow" + - "onLiveTick_ refreshes the notification pane (after scanLiveTagUpdates_, before the cluster block) and updates the bell badge count + severity color" + - "The notification pane is detachable to its own uifigure via DetachRequested (mirrors the EventsLogPane detach flow)" + - "Single-user dashboards and existing scripts run unchanged; the bell/pane add no new timer" + artifacts: + - path: "libs/FastSenseCompanion/FastSenseCompanion.m" + provides: "Bell button + badge, 4th-column toggle, NotifPane_ wiring, onLiveTick_ refresh hook, detach state" + contains: "hBellBtn_" + key_links: + - from: "FastSenseCompanion.onLiveTick_" + to: "NotificationCenterPane.refresh" + via: "inserted call after scanLiveTagUpdates_, before cluster block" + pattern: "NotifPane_\\.refresh\\(" + - from: "FastSenseCompanion toolbar bell" + to: "toggleNotificationCenter_ → ColumnWidth{4}" + via: "ButtonPushedFcn" + pattern: "ColumnWidth\\{4\\}" + - from: "FastSenseCompanion" + to: "NotificationCenterPane DetachRequested" + via: "addlistener in build + setProject" + pattern: "addlistener\\(obj\\.NotifPane_, 'DetachRequested'" +--- + + +Wire `NotificationCenterPane` into `FastSenseCompanion`: expand the root grid to a 4th +collapsible column, add the toolbar bell button (col 8) with an unacked-count badge, instantiate +and attach the pane, register its `DetachRequested` listener (build + setProject), implement the +show/hide column toggle and the detach-to-uifigure state, and insert the pane refresh + badge +update into the existing `onLiveTick_` loop. NO new timer. + +This is the integration seam. Every change is gated so single-user/no-EventStore paths are +unaffected: the bell disables with no store, the column starts hidden (width 0), and the refresh +hook is a guarded try/catch that can never crash the timer. + +Purpose: Make the inbox live inside the Companion, toggled by the bell, refreshed on the existing tick. +Output: Updated `libs/FastSenseCompanion/FastSenseCompanion.m` (integration tests land in Plan 04). + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/phases/1040-companion-notification-center/1040-CONTEXT.md +@.planning/phases/1040-companion-notification-center/1040-RESEARCH.md +@.planning/phases/1040-companion-notification-center/1040-UI-SPEC.md + + + + +Root grid construction (FastSenseCompanion.m): +```matlab +% line 299-301: +obj.hLayout_ = uigridlayout(obj.hFig_, [3 3]); +obj.hLayout_.ColumnWidth = {220, '1x', 360}; +obj.hLayout_.RowHeight = {32, '1x', 360}; +% line 310: obj.hToolbarPanel_.Layout.Column = [1 3]; +% panels (~432-439): +% hLeftPanel_ Row 2 Col 1 ; hMidPanel_ Row 2 Col 2 ; hRightPanel_ Row 2 Col 3 +% hLogPanel_ Row 3 Col [1 3] (line 439) +``` + +Toolbar inner grid (lines 320-435): +```matlab +% line 323-324: +hToolbarGrid = uigridlayout(obj.hToolbarPanel_, [1 9]); +hToolbarGrid.ColumnWidth = {110, 110, 110, 130, 70, 90, 70, '1x', 36}; +% buttons: Events col1, Live col2, Tags col3, PlantLog col4, Tile col5, +% CloseAll col6, Wiki col7 (line 410), [spacer col8], Gear col9 (line 423) +% The comment block at lines 320-322 documents the 1x9 layout — update it to 1x10. +% Enable/disable rule pattern (hEventsBtn_ lines 340-343): +% if isempty(obj.EventStore_); btn.Enable='off'; btn.Tooltip='No EventStore registered'; end +``` + +onLiveTick_ (line 1665): +```matlab +function onLiveTick_(obj) + if ~obj.IsLive || isempty(obj.hFig_) || ~isvalid(obj.hFig_); return; end + try + obj.InspectorPane_.refreshLive(); % (a) + obj.scanLiveTagUpdates_(); % (b) line 1679 + ... obj.EventsLogPane_.setLastUpdated(...); % (c) line 1681 + if obj.IsClusterMode_ % (d) line 1684 + obj.pollClusterContention_(); + obj.pollShareStatus_(); + end + catch + end +end +``` +INSERT the notification refresh + badge update AFTER (c) at ~line 1682, BEFORE the cluster block. + +Pane wiring pattern (lines 482-495, build) and (lines 768-774, setProject): +```matlab +obj.EventsLogPane_ = EventsLogPane(obj.Theme_); +obj.EventsLogPane_.setCompanion(obj); +obj.Listeners_{end+1} = addlistener(obj.EventsLogPane_, 'DetachRequested', @(~,~) obj.setLogState_('events','Detached')); +``` + +Detach state machine to MIRROR: setLogState_(which,newState) at line 1407 — uses +uifigure(...) at 1488, pane.attach(newFig, theme), CloseRequestFcn → 'Inline'. The notification +pane is SIMPLER (no Inline/Hidden tri-state — it has the column toggle for show/hide). Implement a +focused `setNotifDetached_(tf)` that pops the pane out to a uifigure (tf=true) or re-attaches into +hNotifPanel_ (tf=false), mirroring the uifigure + CloseRequestFcn dance. + +Construction idioms (verified from TestFastSenseCompanion.m lines 1088-1093): + es = EventStore(storePath); % storePath is a file path (use [tempname '.mat']) + app = FastSenseCompanion('EventStore', es); % Registry defaults to the TagRegistry singleton + app.getEventStore() % resolved store (also drives ack + badge) +Existing Hidden test accessors live in a methods block ~line 1320-1361 (getFigForTest_ at 1330). + +NotificationCenterPane public contract (Plan 02): NotificationCenterPane(theme), setCompanion(obj), +attach(parent,theme), detach(), refresh(eventStore), applyTheme(theme), requestDetach(), +events DetachRequested, IsAttached (SetAccess private). Plan 01 static helpers for the badge: +NotificationCenterPane.filterUnacked_, .maxSeverity_, .badgeText_, .badgeColor_. + +EventStore for verify snippets: Event(startTime, endTime, sensorName, thresholdLabel) then set +.Severity/.IsOpen; es.append(ev) (EventStore.m:84). EventStore() needs a path → EventStore([tempname '.mat']). + + + + + + + Task 1: Expand root grid to 4 columns + add toolbar bell button at col 8 + + - libs/FastSenseCompanion/FastSenseCompanion.m lines 296-445 (root grid + toolbar grid + all panel/button column assignments) and lines 340-343 (hEventsBtn_ enable/disable pattern) + - libs/FastSenseCompanion/FastSenseCompanion.m property block lines 70-110 (declare hBellBtn_, hNotifPanel_, NotifPane_, hDetachedNotifFig_ alongside existing hEventsBtn_/hLogPanel_) + - libs/FastSenseCompanion/FastSenseCompanion.m lines 1320-1361 (existing *ForTest_ Hidden accessors — add new ones here) + - .planning/phases/1040-companion-notification-center/1040-UI-SPEC.md "Companion Root Grid Extension", "Companion Toolbar Grid Extension (Bell Button)", "Bell Button States" + - .planning/phases/1040-companion-notification-center/1040-RESEARCH.md Pattern 3 (column toggle) + Pitfall 3 (build hidden-then-collapse to avoid flicker) + + libs/FastSenseCompanion/FastSenseCompanion.m + + Declare new private properties near the existing toolbar/panel handles (line ~70-110): + `hBellBtn_ = []`, `hNotifPanel_ = []`, `NotifPane_ = []`, `hDetachedNotifFig_ = []`. + + ROOT GRID (line 299-301): change `uigridlayout(obj.hFig_, [3 3])` → `[3 4]`; change + `obj.hLayout_.ColumnWidth = {220, '1x', 360}` → `{220, '1x', 360, 0}` (4th col hidden initially, + per RESEARCH Pattern 3 + Pitfall 3). Leave RowHeight unchanged. + + PANEL SPANS: line 310 `obj.hToolbarPanel_.Layout.Column = [1 3]` → `[1 4]`. Line 439 + `obj.hLogPanel_.Layout.Column = [1 3]` → `[1 4]`. Leave hLeftPanel_(col1)/hMidPanel_(col2)/ + hRightPanel_(col3) unchanged. + + NEW NOTIFICATION PANEL: after the hLogPanel_ block (~line 440), add + `obj.hNotifPanel_ = uipanel(obj.hLayout_); obj.hNotifPanel_.Layout.Row = 2; + obj.hNotifPanel_.Layout.Column = 4; obj.hNotifPanel_.BorderType = 'none'; + obj.hNotifPanel_.BackgroundColor = obj.Theme_.WidgetBackground;` + + TOOLBAR GRID (line 323-324): change `[1 9]` → `[1 10]`; change ColumnWidth + `{110, 110, 110, 130, 70, 90, 70, '1x', 36}` → `{110, 110, 110, 130, 70, 90, 70, 70, '1x', 36}` + (bell 70 px at col 8, spacer → col 9, gear → col 10). UPDATE the documentation comment block at + lines 320-322 to list col 8 = Bell (70), col 9 = spacer ('1x'), col 10 = Gear (36). + + GEAR SHIFT: line 423 `obj.hSettingsBtn_.Layout.Column = 9` → `= 10`. + + NEW BELL BUTTON at col 8 (insert after the Wiki button block ~line 419, before the gear): + ```matlab + obj.hBellBtn_ = uibutton(hToolbarGrid, 'push'); + obj.hBellBtn_.Layout.Row = 1; + obj.hBellBtn_.Layout.Column = 8; + obj.hBellBtn_.Text = obj.bellGlyph_(); % helper below: emoji or '[!]' fallback + obj.hBellBtn_.FontSize = 12; + obj.hBellBtn_.FontWeight = 'bold'; + obj.hBellBtn_.Tag = 'CompanionBellBtn'; + obj.hBellBtn_.Tooltip = 'Toggle notification center'; + obj.hBellBtn_.BackgroundColor = obj.Theme_.WidgetBorderColor; + obj.hBellBtn_.FontColor = obj.Theme_.ForegroundColor; + obj.hBellBtn_.ButtonPushedFcn = @(~,~) obj.toggleNotificationCenter_(); + if isempty(obj.EventStore_) + obj.hBellBtn_.Enable = 'off'; + obj.hBellBtn_.Tooltip = 'No EventStore registered'; + end + ``` + + Add a private helper `function g = bellGlyph_(~)` returning `char(128276)` (U+1F514) when + `usejava('desktop')` is true, else `'[!]'` (RESEARCH Open Question 2 — ASCII fallback for + headless/Windows). Wrap the usejava check in try/catch defaulting to the ASCII glyph. + + Add two Hidden test accessors in the *ForTest_ methods block (~line 1320), needed by the verify + snippets here and in Plan 04: + - `function cw = getRootColumnWidthForTest_(obj); cw = obj.hLayout_.ColumnWidth; end` + - `function onLiveTickForTest_(obj); obj.onLiveTick_(); end` (drives the tick without the timer) + + Do NOT yet implement toggleNotificationCenter_ / pane instantiation / refresh — those are Task 2. + But because ButtonPushedFcn references toggleNotificationCenter_, add a minimal stub method + `function toggleNotificationCenter_(obj); end` (fleshed out in Task 2) so the class parses. Keep + the bell Enable='off' when no store. MISS_HIT clean (≤160 cols — break long lines). + + + mcp__matlab__evaluate_matlab_code: addpath(pwd); install(); app = FastSenseCompanion(); drawnow; f = app.getFigForTest_(); b = findall(f,'Tag','CompanionBellBtn'); assert(numel(b)==1,'bell missing'); assert(b.Layout.Column==8,'bell not col 8'); cw = app.getRootColumnWidthForTest_(); assert(numel(cw)==4 && isequal(cw{4},0),'col4 not hidden'); app.close(); disp('OK') + + + - `grep -nE "uigridlayout\(obj.hFig_, ?\[3 4\]\)" libs/FastSenseCompanion/FastSenseCompanion.m` matches + - `grep -n "{220, '1x', 360, 0}" libs/FastSenseCompanion/FastSenseCompanion.m` matches + - `grep -nE "uigridlayout\(obj.hToolbarPanel_, ?\[1 10\]\)" libs/FastSenseCompanion/FastSenseCompanion.m` matches + - `grep -n "'CompanionBellBtn'" libs/FastSenseCompanion/FastSenseCompanion.m` matches + - `grep -n "obj.hBellBtn_.Layout.Column = 8" libs/FastSenseCompanion/FastSenseCompanion.m` matches AND `grep -n "obj.hSettingsBtn_.Layout.Column = 10" libs/FastSenseCompanion/FastSenseCompanion.m` matches + - `grep -nE "obj.hToolbarPanel_.Layout.Column = \[1 4\]" libs/FastSenseCompanion/FastSenseCompanion.m` matches AND `grep -nE "obj.hLogPanel_.Layout.Column = \[1 4\]" libs/FastSenseCompanion/FastSenseCompanion.m` matches + - `grep -nE "function (cw = getRootColumnWidthForTest_|onLiveTickForTest_)\(obj\)" libs/FastSenseCompanion/FastSenseCompanion.m` returns 2 matches + - The matlab MCP snippet prints OK (bell exists at col 8, root grid has 4 columns with width{4}==0) + - `mcp__matlab__check_matlab_code` clean; `mh_style` + `mh_lint` clean + + Root grid is [3 4] with hidden 4th column; toolbar is [1 10] with bell at col 8, spacer col 9, gear col 10; hNotifPanel_ created at Row 2 Col 4; bell disabled when no EventStore; getRootColumnWidthForTest_/onLiveTickForTest_ accessors added. Companion constructs headlessly. Code Analyzer + MISS_HIT clean. + + + + Task 2: Instantiate + wire NotificationCenterPane; implement column toggle, detach state, and badge update + + - libs/FastSenseCompanion/FastSenseCompanion.m lines 482-495 (pane instantiation + setCompanion + addlistener DetachRequested in build) and 768-774 (re-register listeners in setProject) and 1407-1505 (setLogState_ detach-to-uifigure dance to mirror) + - libs/FastSenseCompanion/FastSenseCompanion.m lines 640-655 (teardown of EventsLogPane_ in close — mirror for NotifPane_) and 960-975 (applyTheme walks panes — add NotifPane_) + - libs/FastSenseCompanion/NotificationCenterPane.m (Plan 02 public contract + static badge helpers from Plan 01) + - .planning/phases/1040-companion-notification-center/1040-UI-SPEC.md "Column Show/Hide", "Detach Behavior", "Bell Button States" + + libs/FastSenseCompanion/FastSenseCompanion.m + + INSTANTIATE + ATTACH the pane in the build sequence, right after the EventsLogPane/LiveLogPane + wiring (~line 495), and AFTER hNotifPanel_ exists: + ```matlab + obj.NotifPane_ = NotificationCenterPane(obj.Theme_); + obj.NotifPane_.setCompanion(obj); + obj.NotifPane_.attach(obj.hNotifPanel_, obj.Theme_); + obj.Listeners_{end+1} = addlistener(obj.NotifPane_, 'DetachRequested', ... + @(~,~) obj.setNotifDetached_(true)); + ``` + (Attach while hFig_ is still Visible='off' during construction — RESEARCH Pitfall 3 — so the + first column expand renders cleanly. The pane is attached but the column width is 0, so it is + built yet not visible until the bell toggles it.) + + SETPROJECT re-register (~line 774, alongside the EventsLogPane re-register): re-add the + NotifPane_ DetachRequested listener after detach clears Listeners_: + ```matlab + if ~isempty(obj.NotifPane_) && isvalid(obj.NotifPane_) + obj.Listeners_{end+1} = addlistener(obj.NotifPane_, 'DetachRequested', ... + @(~,~) obj.setNotifDetached_(true)); + end + ``` + + CLOSE teardown (~line 645, mirror EventsLogPane_): clear CloseRequestFcn on hDetachedNotifFig_ + before delete (prevent recursion), delete it, null it; then + `if ~isempty(obj.NotifPane_) && isvalid(obj.NotifPane_); obj.NotifPane_.detach(); delete(obj.NotifPane_); end; obj.NotifPane_ = [];` + + IMPLEMENT `toggleNotificationCenter_(obj)` (replacing the Task 1 stub): wrap in try/catch → + on error `uialert(obj.hFig_, ME.message, 'Notification Center', 'Icon','error')` with id + `FastSenseCompanion:bellToggleFailed` semantics. Body — flip the 4th column width and refresh: + ```matlab + cw = obj.hLayout_.ColumnWidth; + if isnumeric(cw{4}) && isequal(cw{4}, 0) + cw{4} = 320; % show (UI-SPEC width) + obj.hLayout_.ColumnWidth = cw; + drawnow; % clean first-expand render (Pitfall 3) — OK HERE (not inside pane.refresh) + obj.NotifPane_.refresh(obj.getEventStore()); % populate immediately on open + obj.updateBellBadge_(); + else + cw{4} = 0; % hide + obj.hLayout_.ColumnWidth = cw; + end + ``` + + IMPLEMENT `setNotifDetached_(obj, tf)` mirroring setLogState_'s uifigure dance (try/catch): + - tf==true: if `obj.NotifPane_.IsAttached`, `obj.NotifPane_.detach();` then + `newFig = uifigure('Name','Notification Center — FastSenseCompanion','Position',[0 0 420 600],'Color',obj.Theme_.DashboardBackground); movegui(newFig,'center'); newFig.CloseRequestFcn = @(~,~) obj.setNotifDetached_(false); obj.hDetachedNotifFig_ = newFig; obj.NotifPane_.attach(newFig, obj.Theme_);` + Then collapse the inline column (`cw{4}=0`) so the pane is not duplicated, then refresh + badge. + - tf==false (re-inline): if `hDetachedNotifFig_` valid, clear its CloseRequestFcn + delete it + null it; if `obj.NotifPane_.IsAttached`, detach; `obj.NotifPane_.attach(obj.hNotifPanel_, obj.Theme_);` Show the inline column (`cw{4}=320`); refresh + badge. + Use the EXACT title `'Notification Center — FastSenseCompanion'` (UI-SPEC Copywriting, em-dash + char(8212)) and 420×600 initial size. + + IMPLEMENT `updateBellBadge_(obj)` (try/catch, never crash). Guard every hBellBtn_ access with + `~isempty(obj.hBellBtn_) && isvalid(obj.hBellBtn_)`. Resolve `store = obj.getEventStore();`. + If empty/invalid → set idle bell (Text=bellGlyph_, BackgroundColor=WidgetBorderColor, + FontColor=ForegroundColor) and return. Else: + ```matlab + all = Event.empty; try; all = store.getEvents(); catch; end % stale-safe; badge tolerates read fail + unacked = NotificationCenterPane.filterUnacked_(all); + n = numel(unacked); + obj.hBellBtn_.Text = NotificationCenterPane.badgeText_(n, obj.bellGlyph_()); + if n == 0 + obj.hBellBtn_.BackgroundColor = obj.Theme_.WidgetBorderColor; + obj.hBellBtn_.FontColor = obj.Theme_.ForegroundColor; + else + obj.hBellBtn_.BackgroundColor = NotificationCenterPane.badgeColor_( ... + NotificationCenterPane.maxSeverity_(unacked), obj.Theme_); + obj.hBellBtn_.FontColor = obj.Theme_.DashboardBackground; + end + ``` + + APPLYTHEME (~line 970): after the existing EventsLogPane_.applyTheme call, add + `if ~isempty(obj.NotifPane_) && isvalid(obj.NotifPane_); obj.NotifPane_.applyTheme(obj.Theme_); end` + then `obj.updateBellBadge_();` (badge recolor with new theme tokens — UI-SPEC Theme Propagation). + + ENABLE/DISABLE on store change: grep for every place hEventsBtn_.Enable is set from EventStore + presence (notably after setProject resolves a store, and at construction lines 340-343); mirror + the same Enable on/off + Tooltip ('Toggle notification center' / 'No EventStore registered') for + hBellBtn_ so the bell tracks store presence. Also call `obj.updateBellBadge_();` once after the + initial build (with the figure still Visible='off') so the badge reflects any pre-loaded events. + MISS_HIT clean. + + + mcp__matlab__evaluate_matlab_code: addpath(pwd); install(); es = EventStore([tempname '.mat']); app = FastSenseCompanion('EventStore', es); drawnow; f = app.getFigForTest_(); b = findall(f,'Tag','CompanionBellBtn'); assert(strcmp(b.Enable,'on'),'bell should enable with store'); app.toggleNotificationCenter_(); drawnow; cw = app.getRootColumnWidthForTest_(); assert(isequal(cw{4},320),'col not shown'); app.toggleNotificationCenter_(); drawnow; cw2 = app.getRootColumnWidthForTest_(); assert(isequal(cw2{4},0),'col not hidden'); app.close(); disp('OK') + + + - `grep -n "obj.NotifPane_ = NotificationCenterPane(obj.Theme_)" libs/FastSenseCompanion/FastSenseCompanion.m` matches + - `grep -nE "addlistener\(obj.NotifPane_, 'DetachRequested'" libs/FastSenseCompanion/FastSenseCompanion.m` returns ≥ 2 matches (build + setProject) + - `grep -nE "function toggleNotificationCenter_\(obj\)" libs/FastSenseCompanion/FastSenseCompanion.m` matches AND `grep -n "cw{4}" libs/FastSenseCompanion/FastSenseCompanion.m` matches + - `grep -nE "function setNotifDetached_\(obj, tf\)" libs/FastSenseCompanion/FastSenseCompanion.m` matches AND `grep -n "Notification Center" libs/FastSenseCompanion/FastSenseCompanion.m` matches + - `grep -nE "function updateBellBadge_\(obj\)" libs/FastSenseCompanion/FastSenseCompanion.m` matches AND `grep -nE "NotificationCenterPane.(badgeText_|badgeColor_|maxSeverity_|filterUnacked_)" libs/FastSenseCompanion/FastSenseCompanion.m` returns ≥ 4 matches + - The matlab MCP snippet prints OK: bell enables with a store; toggle shows (320) then hides (0) column 4 + - `mcp__matlab__check_matlab_code` clean; `mh_style` + `mh_lint` clean + + NotifPane_ instantiated + attached + DetachRequested-wired (build + setProject); toggleNotificationCenter_ shows/hides col 4 with a single drawnow; setNotifDetached_ pops out to a 420×600 uifigure titled 'Notification Center — FastSenseCompanion' with CloseRequestFcn re-inline; updateBellBadge_ reflects count + severity color and tolerates read failure; applyTheme + close teardown updated; bell tracks EventStore presence. Headless smoke passes. Code Analyzer + MISS_HIT clean. + + + + Task 3: Hook notification refresh + badge into onLiveTick_ + + - libs/FastSenseCompanion/FastSenseCompanion.m lines 1665-1691 (onLiveTick_ — the (a)/(b)/(c)/(d) structure; insert AFTER c, BEFORE the cluster block) + - .planning/phases/1040-companion-notification-center/1040-RESEARCH.md Pattern 5 (exact insertion snippet) + Pitfall 4 (no drawnow; lazy update) + + libs/FastSenseCompanion/FastSenseCompanion.m + + In `onLiveTick_` (line 1665), insert the notification refresh + badge update AFTER the + `obj.EventsLogPane_.setLastUpdated(...)` call (~line 1681, the (c) block) and BEFORE the + `if obj.IsClusterMode_` cluster block (~line 1684). Per RESEARCH Pattern 5 — wrap in its own + try/catch so it can never crash the timer; only refresh when the pane is attached: + ```matlab + % Phase 1040 — Notification center refresh (no new timer; piggybacks this tick). + if ~isempty(obj.NotifPane_) && isvalid(obj.NotifPane_) && obj.NotifPane_.IsAttached + try + obj.NotifPane_.refresh(obj.getEventStore()); + obj.updateBellBadge_(); + catch + % Must never crash the live timer. + end + end + ``` + Note: the pane's `refresh` already no-ops when there is no diff and never calls drawnow + (Plan 02 / Pitfall 4), so this is cheap. `updateBellBadge_` is also read-error-tolerant + (Task 2). Do NOT add any `drawnow` here. Keep the surrounding (a)/(b)/(c)/(d) ordering intact. + MISS_HIT clean. + + + mcp__matlab__evaluate_matlab_code: addpath(pwd); install(); es = EventStore([tempname '.mat']); e = Event(now-0.01, NaN, 'P-101', 'HighPressure'); e.Severity = 3; e.IsOpen = true; es.append(e); app = FastSenseCompanion('EventStore', es); drawnow; app.toggleNotificationCenter_(); drawnow; app.onLiveTickForTest_(); drawnow; b = findall(app.getFigForTest_(),'Tag','CompanionBellBtn'); assert(~isempty(strfind(b.Text,'1')),'badge should show count 1'); app.close(); disp('OK') + + + - `grep -nE "obj.NotifPane_.refresh\(obj.getEventStore\(\)\)" libs/FastSenseCompanion/FastSenseCompanion.m` matches AND it appears BETWEEN the `scanLiveTagUpdates_`/`setLastUpdated` lines and the `if obj.IsClusterMode_` line (confirm with `grep -n` line numbers: refresh line > setLastUpdated line AND < IsClusterMode_ poll line) + - `grep -n "Phase 1040 — Notification center refresh" libs/FastSenseCompanion/FastSenseCompanion.m` matches + - The matlab MCP snippet prints OK: after appending one sev-3 open event + a tick, the bell text contains '1' + - `mcp__matlab__check_matlab_code` clean; `mh_style` + `mh_lint` clean + + onLiveTick_ refreshes the attached notification pane and updates the bell badge between scanLiveTagUpdates_/setLastUpdated and the cluster block, guarded so it never crashes the timer; no drawnow added. Headless tick smoke shows the badge counting a live event. Code Analyzer + MISS_HIT clean. + + + + + +- Headless smokes in each task's `` block pass via the matlab MCP. +- `mcp__matlab__check_matlab_code` clean on `FastSenseCompanion.m`. +- `mh_style` + `mh_lint` clean. +- Regression: `mcp__matlab__run_matlab_test_file tests/suite/TestFastSenseCompanion.m` — NOTE the two + toolbar-column assertions (`testToolbarHasWikiButton` col 7, `testToolbarGearMovedToColumn8` col 9→10) + will FAIL here until Plan 04 updates them. This is EXPECTED and is Plan 04's job; do not fix the + tests in this plan. Confirm any *new* failures are only those two assertions (gear column) and not + regressions elsewhere. +- `grep -c "drawnow" ` inside the pane refresh path is unaffected (Pitfall 4 is enforced in Plan 02); + the single `drawnow` in `toggleNotificationCenter_` / `setNotifDetached_` is the intended first-expand fix. +- Confirm no new timer: `grep -nE "timer\(|TimerFcn" libs/FastSenseCompanion/FastSenseCompanion.m` shows + no NEW timer added by this plan (only the pre-existing LiveTimer_). + + + +- Bell button at toolbar col 8 with unacked badge; disabled with no EventStore; spacer col 9, gear col 10. +- Root grid [3 4]; bell toggles the 4th column (320 ↔ 0); NotifPane_ hosted in hNotifPanel_; existing 3 columns reflow. +- onLiveTick_ refreshes the pane + badge (after scanLiveTagUpdates_/setLastUpdated, before cluster block), guarded. +- Pane detachable to a 420×600 uifigure via DetachRequested; CloseRequestFcn re-inlines; listener re-registered in setProject; close() tears down pane + detached fig. +- No new timer; single-user/no-store paths unaffected (bell disabled, column hidden, hook guarded). +- Honors 1040-CONTEXT.md deferred items (no NotificationService linkage, sounds, snooze, grouping added). + + + +After completion, create `.planning/phases/1040-companion-notification-center/1040-03-SUMMARY.md`. + diff --git a/.planning/phases/1040-companion-notification-center/1040-04-companion-tests-verify-PLAN.md b/.planning/phases/1040-companion-notification-center/1040-04-companion-tests-verify-PLAN.md new file mode 100644 index 00000000..01c18caf --- /dev/null +++ b/.planning/phases/1040-companion-notification-center/1040-04-companion-tests-verify-PLAN.md @@ -0,0 +1,267 @@ +--- +phase: 1040-companion-notification-center +plan: 04 +type: execute +wave: 4 +depends_on: ["03"] +autonomous: false +files_modified: + - tests/suite/TestFastSenseCompanion.m +requirements: [] +must_haves: + truths: + - "TestFastSenseCompanion asserts the bell sits at toolbar col 8 and the gear moved to col 10" + - "TestFastSenseCompanion asserts the bell toggles the 4th root column (320 ↔ 0)" + - "TestFastSenseCompanion asserts the bell is disabled with no EventStore and reflects the unacked count when a store is present" + - "TestFastSenseCompanion asserts onLiveTick_ refreshes the notification pane / updates the badge" + - "The full suite is green and a human confirms the live pop-in + badge color under a real violation stream" + artifacts: + - path: "tests/suite/TestFastSenseCompanion.m" + provides: "Updated toolbar-column assertions + new notification-center integration tests" + contains: "CompanionBellBtn" + key_links: + - from: "tests/suite/TestFastSenseCompanion.m" + to: "FastSenseCompanion bell + 4th column + onLiveTick_" + via: "findall Tag CompanionBellBtn + getRootColumnWidthForTest_ + onLiveTickForTest_" + pattern: "CompanionBellBtn" +--- + + +Lock the Companion integration with tests and a human visual check. Update the two existing +toolbar-column assertions that the bell shifts (Wiki stays col 7; gear moves col 9 → 10), add +integration tests for the bell + 4th-column toggle + enable/disable + badge + onLiveTick_ refresh, +run the full suite green, then pause for a human to confirm the live pop-in + badge color under a +real violation stream (the one manual-only item in 1040-VALIDATION.md). + +Purpose: Prove the integration end-to-end and close the phase's validation contract. +Output: Updated `tests/suite/TestFastSenseCompanion.m` + a human-verified live demo. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/phases/1040-companion-notification-center/1040-CONTEXT.md +@.planning/phases/1040-companion-notification-center/1040-RESEARCH.md +@.planning/phases/1040-companion-notification-center/1040-UI-SPEC.md +@.planning/phases/1040-companion-notification-center/1040-VALIDATION.md + + + + +EXISTING toolbar-column assertions to UPDATE (tests/suite/TestFastSenseCompanion.m): +```matlab +% line 1252-1262 testToolbarHasWikiButton: +% btn = findall(app.getFigForTest_(), 'Tag', 'CompanionWikiBtn'); +% verifyEqual(btn(1).Layout.Column, 7, ...) ← Wiki STAYS at col 7 (bell goes to col 8). LEAVE 7. +% line 1265-1281 testToolbarGearMovedToColumn8: +% loops toolbar buttons; verifyEqual(btns(k).Layout.Column, 9, ...) ← gear NOW col 10. +% CHANGE the literal 9 → 10 and update the diagnostic string. KEEP the method NAME +% 'testToolbarGearMovedToColumn8' (mis-named on purpose per STATE.md / RESEARCH Pitfall 1). +``` + +Companion test idioms (TestFastSenseCompanion.m): + app = FastSenseCompanion(); % no store → bell disabled + es = EventStore(storePath); app = FastSenseCompanion('EventStore', es); % store → bell enabled + app.getFigForTest_() % the uifigure (line 1330) + app.getRootColumnWidthForTest_() % {220,'1x',360,0|320} (added Plan 03 Task 1) + app.onLiveTickForTest_() % drives onLiveTick_ without the timer (added Plan 03 Task 1) + app.getEventStore() % resolved store + app.toggleNotificationCenter_() % show/hide col 4 (public-ish; callable in tests) +Headless guard: the suite already has the standard skip idiom for no-desktop runs — reuse it. + +Event for fixtures: Event(startTime, endTime, sensorName, thresholdLabel); set .Severity/.IsOpen; + es.append(ev). storePath via [tempname '.mat']. + + + + + + + Task 1: Update the two toolbar-column assertions for the bell shift + + - tests/suite/TestFastSenseCompanion.m lines 1250-1282 (testToolbarHasWikiButton + testToolbarGearMovedToColumn8 — the exact verifyEqual literals + diagnostic strings) + - .planning/phases/1040-companion-notification-center/1040-RESEARCH.md Pitfall 1 (column-count mismatch; keep the method name, change only the literal) + - .planning/STATE.md quick-task 260526-tcf entry (why the method name testToolbarGearMovedToColumn8 is intentionally kept) + + tests/suite/TestFastSenseCompanion.m + + `testToolbarHasWikiButton` (line ~1252-1262): LEAVE the Wiki column assertion at 7 (Wiki is + unchanged; the bell takes the NEW col 8). Do NOT modify this test except, optionally, a one-word + comment noting the bell now follows at col 8. + + `testToolbarGearMovedToColumn8` (line ~1265-1281): the gear moved from col 9 to col 10 (bell + inserted at col 8 shifted spacer→9, gear→10). Change the `verifyEqual(btns(k).Layout.Column, 9, ...)` + literal `9` → `10`, and update the diagnostic message string to read + `'testToolbarGearMovedToColumn8: gear button should now sit in column 10'`. KEEP the method NAME + `testToolbarGearMovedToColumn8` unchanged (intentionally mis-named per STATE.md 260526-tcf / + RESEARCH Pitfall 1 — renaming is a separate task). MISS_HIT clean. + + + mcp__matlab__run_matlab_test_file: tests/suite/TestFastSenseCompanion.m (testToolbarHasWikiButton + testToolbarGearMovedToColumn8 both PASS; no other regressions) + + + - `grep -n "testToolbarGearMovedToColumn8: gear button should now sit in column 10" tests/suite/TestFastSenseCompanion.m` matches + - `grep -nE "verifyEqual\(btns\(k\).Layout.Column, 10," tests/suite/TestFastSenseCompanion.m` matches (gear now col 10) + - `grep -n "testToolbarGearMovedToColumn8" tests/suite/TestFastSenseCompanion.m` still matches (method name preserved) + - `grep -nE "verifyEqual\(btn\(1\).Layout.Column, 7," tests/suite/TestFastSenseCompanion.m` still matches (Wiki stays col 7) + - `mcp__matlab__run_matlab_test_file` shows testToolbarHasWikiButton + testToolbarGearMovedToColumn8 PASS + - `mh_style` + `mh_lint` clean on the file + + Gear assertion updated 9→10 with matching diagnostic; Wiki assertion left at 7; method name preserved. Both toolbar tests pass. MISS_HIT clean. + + + + Task 2: Add notification-center integration tests to TestFastSenseCompanion + + - tests/suite/TestFastSenseCompanion.m lines 1086-1161 (EventStore DI tests: how a store is built + passed + getEventStore asserted) and lines 1250-1282 (toolbar findall idiom) and the suite's headless-guard idiom near the top + - libs/FastSenseCompanion/FastSenseCompanion.m (Plan 03: hBellBtn_ 'CompanionBellBtn', getRootColumnWidthForTest_, onLiveTickForTest_, toggleNotificationCenter_, updateBellBadge_, NotifPane_) + - .planning/phases/1040-companion-notification-center/1040-VALIDATION.md "Phase Requirements → Test Map" rows mapped to TestFastSenseCompanion + + tests/suite/TestFastSenseCompanion.m + + Add a new test section (comment header `% ---- Phase 1040: Notification Center integration ----`) + with these `function test...(testCase)` methods. Use the suite's existing store-building + + headless-guard idioms; build a store via `es = EventStore([tempname '.mat']);` and a Companion + via `FastSenseCompanion('EventStore', es)` (or `FastSenseCompanion()` for the no-store case). + Fixture events: `e = Event(now-0.01, NaN, 'P-101', 'HighPressure'); e.Severity = 3; e.IsOpen = true; e2 = Event(now-0.02, now-0.015, 'T-200', 'Overtemp'); e2.Severity = 2;` then `es.append([e e2]);` + + 1. `testBellButtonAtColumn8` — `app = FastSenseCompanion('EventStore', es);` find `CompanionBellBtn` + on `app.getFigForTest_()`; assert exactly one and `Layout.Column == 8`. Teardown closes app. + 2. `testRootGridHasFourColumns` — assert `numel(app.getRootColumnWidthForTest_()) == 4` and the + 4th is `0` initially (hidden). + 3. `testBellTogglesFourthColumn` — `app.toggleNotificationCenter_(); drawnow;` assert + `getRootColumnWidthForTest_(){4} == 320`; toggle again; assert `{4} == 0`. + 4. `testBellDisabledWithoutEventStore` — `app = FastSenseCompanion();` (no store) find + `CompanionBellBtn`; assert `strcmp(btn.Enable,'off')` and Tooltip is 'No EventStore registered'. + 5. `testBellEnabledWithEventStore` — with the store, assert `strcmp(btn.Enable,'on')`. + 6. `testBellBadgeReflectsUnackedCount` — with `es` holding the 2 unacked events above, call + `app.toggleNotificationCenter_(); app.onLiveTickForTest_(); drawnow;` then assert the bell Text + contains '(2)' (badge count). Then acknowledge one via `es.acknowledgeEvent(e2.Id, struct('comment',''));` + call `app.onLiveTickForTest_(); drawnow;` and assert the bell Text contains '(1)'. + 7. `testBellBadgeSeverityColor` — with a sev-3 unacked present, after a tick assert + `isequal(btn.BackgroundColor, CompanionTheme.get().StatusAlarmColor)` (resolve + the app's theme via the same accessor TestFastSenseCompanion uses elsewhere, or default 'dark'). + 8. `testOnLiveTickRefreshesNotifPane` — append a NEW unacked event after construction, toggle the + pane open, call `app.onLiveTickForTest_(); drawnow;` and assert the badge now reflects the added + event (Text count increments). (Proves the onLiveTick_ hook calls refresh + updateBellBadge_.) + 9. `testNotifPaneDetachReinline` — toggle open; call the detach entry the way the listener does: + `app` exposes `setNotifDetached_` (private) — drive it via a Hidden test accessor if needed, OR + fire the pane's DetachRequested: `notify(app.notifPaneForTest_(), 'DetachRequested');` (add a + Hidden `notifPaneForTest_()` accessor returning `obj.NotifPane_` to FastSenseCompanion in this + task). After detach assert a uifigure named 'Notification Center — FastSenseCompanion' exists + (`findall(groot,'Type','figure','Name','Notification Center — FastSenseCompanion')` non-empty), + then `app.close()` and assert it is gone. + + Each test: build app in the method, `testCase.addTeardown(@() app.close());` (guard double-close). + Reuse the suite's headless guard so no-desktop CI skips rather than hard-fails. If a Hidden + accessor is missing (`notifPaneForTest_`), add it to FastSenseCompanion's *ForTest_ methods block. + MISS_HIT clean (≤160 cols). + + + mcp__matlab__run_matlab_test_file: tests/suite/TestFastSenseCompanion.m (all Phase 1040 tests + the full suite PASS; skips only via headless guard) + + + - `grep -cE "function test(BellButtonAtColumn8|RootGridHasFourColumns|BellTogglesFourthColumn|BellDisabledWithoutEventStore|BellEnabledWithEventStore|BellBadgeReflectsUnackedCount|BellBadgeSeverityColor|OnLiveTickRefreshesNotifPane|NotifPaneDetachReinline)" tests/suite/TestFastSenseCompanion.m` returns 9 + - `grep -n "CompanionBellBtn" tests/suite/TestFastSenseCompanion.m` matches + - `grep -n "getRootColumnWidthForTest_" tests/suite/TestFastSenseCompanion.m` matches AND `grep -n "onLiveTickForTest_" tests/suite/TestFastSenseCompanion.m` matches + - `grep -n "Notification Center" tests/suite/TestFastSenseCompanion.m` matches (detach test) + - `mcp__matlab__run_matlab_test_file tests/suite/TestFastSenseCompanion.m` → 0 failures (skips only via headless guard) + - `mh_style` + `mh_lint` clean on the file + + Nine notification-center integration tests added covering bell col 8, 4-column grid, toggle, enable/disable, badge count + severity color, onLiveTick refresh, and detach/re-inline. Full TestFastSenseCompanion suite green. MISS_HIT clean. + + + + Task 3: Full-suite green gate + + - .planning/phases/1040-companion-notification-center/1040-VALIDATION.md "Sampling Rate" (full suite before verify-work) + - tests/run_all_tests.m (the discovery runner) + + tests/suite/TestFastSenseCompanion.m + + Run the three phase-relevant suites and confirm green, then the full discovery suite: + 1. `tests/test_notification_center_pane.m` (flat — via evaluate_matlab_code) — Plan 01 logic. + 2. `tests/suite/TestNotificationCenterPane.m` — Plan 02 pane suite. + 3. `tests/suite/TestFastSenseCompanion.m` — this plan's integration tests + the updated toolbar + assertions + all prior Companion tests (no regression). + 4. `tests/run_all_tests.m` — full suite (via run_matlab_file) must be green (skips permitted only + for pre-existing documented headless/Octave skips; NO new failures). + If any failure surfaces, fix it in the test or (if a genuine integration bug) note it for a Plan 03 + follow-up — but the expectation is green. This task makes no production edits unless a test-only + fix is required; record any such fix in the SUMMARY. MISS_HIT must remain clean on touched files. + + + mcp__matlab__run_matlab_file: tests/run_all_tests.m (full suite green; no NEW failures vs. the pre-phase baseline; pre-existing documented skips allowed) + + + - `tests/test_notification_center_pane` prints "All N tests passed." (via evaluate_matlab_code) + - `mcp__matlab__run_matlab_test_file tests/suite/TestNotificationCenterPane.m` → 0 failures + - `mcp__matlab__run_matlab_test_file tests/suite/TestFastSenseCompanion.m` → 0 failures + - `mcp__matlab__run_matlab_file tests/run_all_tests.m` → no NEW failures vs. baseline (capture the pass/fail/skip tallies in the SUMMARY) + - `mh_style` + `mh_lint` clean on every file touched in Plans 01-04 + + All three phase suites green and the full discovery suite shows no new failures; tallies recorded. MISS_HIT clean across the phase's files. + + + + Task 4: Human verify — live pop-in + badge color under a real violation stream + + A toolbar bell (col 8) with an unacked-count badge that opens a collapsible 4th-column + NotificationCenterPane in FastSenseCompanion. The pane live-lists unacked EventStore events + (newest-first), supports one-click Ack / Ack-with-comment / "Acknowledge all visible", shows a + 'LIVE' tag on open violations and a 'No unacknowledged events' empty state, is detachable to its + own window, and refreshes on the existing onLiveTick_ loop (no new timer). This is the one + manual-only item in 1040-VALIDATION.md (live visual rendering + badge color). + + + In the running MATLAB session (figures are visible on the user's screen), via the matlab MCP: + 1. `install();` then build a Companion with an EventStore + a mock LiveEventPipeline that produces + threshold violations — the simplest path is the industrial plant demo + (`demo/industrial_plant/run_demo.m` or the companion entry it uses) which already wires a + MonitorTag + EventStore + live pipeline. Start it and enable the companion's "Live" button. + 2. Confirm the toolbar shows a BELL at column 8 (to the LEFT of the gear), gear at the far right. + 3. Trigger / wait for a threshold violation. CONFIRM: + - the bell badge increments and shows a count, e.g. bell + ' (1)'; + - the badge BACKGROUND color matches the highest unacked severity (alarm = red + StatusAlarmColor, warn = orange StatusWarnColor, info = teal Accent); + - clicking the bell opens the right-hand pane and the new event appears NEWEST-FIRST with a + 'LIVE' tag while the violation is still open. + 4. Click 'Ack' on a row → the row leaves the inbox on the next tick and the badge decrements. + Try 'Ack with comment…' (the '...' cell) → enter a comment → confirm it acknowledges. + 5. Click 'Acknowledge all visible' → inbox empties to 'No unacknowledged events' and the badge + returns to the plain bell glyph. + 6. Click the pop-out icon → the pane detaches into a window titled + 'Notification Center — FastSenseCompanion'; close it → it re-inlines. + 7. Toggle the companion Settings theme (dark ↔ light) → pane + badge recolor cleanly. + Acceptable: the bell glyph may render as the ASCII '[!]' fallback on some platforms — that is by + design (RESEARCH Open Question 2). + + Type "approved" if all steps pass, or describe what looked wrong (badge color, ordering, ack not clearing, flicker, detach/theme issue) so it can be fixed. + + + + + +- Tasks 1-3 automated: the two toolbar assertions updated + 9 integration tests + full suite green. +- Task 4 manual: human confirms live pop-in, badge count + severity color, ack-clears, bulk-ack empty + state, detach/re-inline, and theme recolor on a real violation stream. +- `mh_style` + `mh_lint` clean on every file touched across Plans 01-04. + + + +- testToolbarGearMovedToColumn8 asserts col 10 (name preserved); testToolbarHasWikiButton stays col 7. +- 9 notification-center integration tests pass: bell col 8, 4-column grid, toggle, enable/disable, + badge count + severity color, onLiveTick refresh, detach/re-inline. +- Full discovery suite green (no new failures vs. baseline; tallies recorded in SUMMARY). +- Human approves the live demo (pop-in, badge color, ack-clears, bulk-ack, detach, theme). +- Phase honors 1040-CONTEXT.md deferred items end-to-end (no NotificationService linkage, sounds, + snooze, grouping, per-user seen-state anywhere in the delivered surface). + + + +After completion, create `.planning/phases/1040-companion-notification-center/1040-04-SUMMARY.md`. + diff --git a/.planning/phases/1040-companion-notification-center/1040-VALIDATION.md b/.planning/phases/1040-companion-notification-center/1040-VALIDATION.md new file mode 100644 index 00000000..26879087 --- /dev/null +++ b/.planning/phases/1040-companion-notification-center/1040-VALIDATION.md @@ -0,0 +1,90 @@ +--- +phase: 1040 +slug: companion-notification-center +status: draft +nyquist_compliant: true +wave_0_complete: false +created: 2026-06-02 +--- + +# Phase 1040 — Validation Strategy + +> Per-phase validation contract for feedback sampling during execution. + +--- + +## Test Infrastructure + +| Property | Value | +|----------|-------| +| **Framework** | MATLAB `unittest` (class-based `tests/suite/Test*.m`) + flat function-based tests (`tests/test_*.m`); custom runner `tests/run_all_tests.m` | +| **Config file** | none — paths added by `install.m`; toolbox-free | +| **Quick run command** | class suite: `mcp__matlab__run_matlab_test_file tests/suite/TestNotificationCenterPane.m` · flat: call `test_notification_center_pane` via `mcp__matlab__evaluate_matlab_code` | +| **Full suite command** | `tests/run_all_tests.m` (via `mcp__matlab__run_matlab_file`) | +| **Estimated runtime** | single test file ~5–15 s · full suite several minutes | + +--- + +## Sampling Rate + +- **After every task commit:** Run the relevant single test file (`TestNotificationCenterPane` and/or `TestFastSenseCompanion`). +- **After every plan wave:** Run this phase's tests (pane suite + Companion integration test). +- **Before `/gsd:verify-work`:** Full suite (`tests/run_all_tests.m`) must be green. +- **Max feedback latency:** ~15 s for the quick per-task run. + +--- + +## Per-Task Verification Map + +> No REQ-IDs are mapped to this phase (`phase_req_ids: null`). Verification is keyed to plan tasks. **This table is populated by `gsd-planner`** once `PLAN.md` tasks exist; each task's `` must be grep-/test-verifiable. + +| Task ID | Plan | Wave | Behavior | Test Type | Automated Command | File Exists | Status | +|---------|------|------|----------|-----------|-------------------|-------------|--------| +| 01-1 | 01 | 1 | StubEventStore getEvents/numEvents/acknowledgeEvent + ThrowOnAck/ThrowOnGet | unit (double) | covered by `test_notification_center_pane` (01-3) | `tests/StubEventStore.m` | ⬜ pending | +| 01-2 | 01 | 1 | Static pure-logic helpers: filterUnacked_ (incl. NaN), sortNewestFirst_, maxSeverity_, idsOf_, diffIds_, badgeText_, badgeColor_ | unit | covered by `test_notification_center_pane` (01-3) | `libs/FastSenseCompanion/NotificationCenterPane.m` | ⬜ pending | +| 01-3 | 01 | 1 | Flat headless pure-logic test (stub round-trip + 7 helpers) | unit | `evaluate_matlab_code: test_notification_center_pane` | `tests/test_notification_center_pane.m` | ⬜ pending | +| 02-1 | 02 | 2 | Pane attach/detach/applyTheme lifecycle + header + inbox uitable | integration (headless) | `run_matlab_test_file tests/suite/TestNotificationCenterPane.m` | `libs/FastSenseCompanion/NotificationCenterPane.m` | ⬜ pending | +| 02-2 | 02 | 2 | refresh unacked+diff+cap+render; ack/ack-comment/bulk; stale guard; empty state | integration (headless) | `run_matlab_test_file tests/suite/TestNotificationCenterPane.m` | `libs/FastSenseCompanion/NotificationCenterPane.m` | ⬜ pending | +| 02-3 | 02 | 2 | Class suite: lifecycle, detach-reattach state, filter, diff-no-flicker, ack→removal, ack-race no-op, stale, empty, theme, DetachRequested | integration (headless) | `run_matlab_test_file tests/suite/TestNotificationCenterPane.m` | `tests/suite/TestNotificationCenterPane.m` | ⬜ pending | +| 03-1 | 03 | 3 | Root grid [3 4] + toolbar bell col 8 (gear→10); bell disabled w/o store | integration smoke | `evaluate_matlab_code` headless snippet | `libs/FastSenseCompanion/FastSenseCompanion.m` | ⬜ pending | +| 03-2 | 03 | 3 | Pane instantiation/wiring; column toggle 320↔0; detach-to-uifigure; badge update | integration smoke | `evaluate_matlab_code` headless snippet | `libs/FastSenseCompanion/FastSenseCompanion.m` | ⬜ pending | +| 03-3 | 03 | 3 | onLiveTick_ refreshes pane + updates badge (after scan, before cluster block) | integration smoke | `evaluate_matlab_code` headless tick snippet | `libs/FastSenseCompanion/FastSenseCompanion.m` | ⬜ pending | +| 04-1 | 04 | 4 | Toolbar assertions: Wiki stays col 7; gear col 9→10 (name preserved) | integration | `run_matlab_test_file tests/suite/TestFastSenseCompanion.m` | `tests/suite/TestFastSenseCompanion.m` | ⬜ pending | +| 04-2 | 04 | 4 | 9 integration tests: bell col 8, 4-col grid, toggle, enable/disable, badge count+color, onLiveTick refresh, detach/re-inline | integration | `run_matlab_test_file tests/suite/TestFastSenseCompanion.m` | `tests/suite/TestFastSenseCompanion.m` | ⬜ pending | +| 04-3 | 04 | 4 | Full-suite green gate (no new failures vs baseline) | suite | `run_matlab_file tests/run_all_tests.m` | — | ⬜ pending | +| 04-4 | 04 | 4 | Human verify: live pop-in + badge color + ack-clears + bulk + detach + theme | manual (checkpoint) | manual via matlab MCP on plant demo | — | ⬜ pending | + +*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* + +--- + +## Wave 0 Requirements + +- [ ] `tests/suite/TestNotificationCenterPane.m` — pane-logic suite (unacked filtering, diff-by-Id, ack → removal, badge count + severity color, severity filter, empty state) driven headlessly via `uifigure('Visible','off')` + `addTeardown`. +- [ ] `tests/StubEventStore.m` (or equivalent) — fake `EventStore` handle exposing `getEvents()` / `acknowledgeEvent()`, modeled on `tests/CaptureNotificationService.m`. +- [ ] Companion integration coverage — extend `tests/suite/TestFastSenseCompanion.m` (bell toggles 4th column; badge reflects store; bell disabled with no EventStore; updated gear-column assertions per RESEARCH.md). + +*Existing infrastructure (`run_all_tests.m`, suite runner) otherwise covers execution.* + +--- + +## Manual-Only Verifications + +| Behavior | Requirement | Why Manual | Test Instructions | +|----------|-------------|------------|-------------------| +| Live pop-in + visual badge color under a real violation stream | — | Requires a rendered `uifigure` + live pipeline; visual confirmation | Run a Companion with an EventStore + mock `LiveEventPipeline` via the matlab MCP; trigger a threshold violation; confirm the item appears, badge increments/colors by severity, and Acknowledge clears it. | + +*Automated tests cover all logic; only live visual rendering is manual.* + +--- + +## Validation Sign-Off + +- [ ] All tasks have `` verify or Wave 0 dependencies +- [ ] Sampling continuity: no 3 consecutive tasks without automated verify +- [ ] Wave 0 covers all MISSING references +- [ ] No watch-mode flags +- [ ] Feedback latency < 15s +- [ ] `nyquist_compliant: true` set in frontmatter + +**Approval:** pending From 20ecda7d40430fce4174b4af53c410ae5c5151bc Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 2 Jun 2026 10:23:01 +0200 Subject: [PATCH 03/20] docs(1040): add CONTEXT + UI-SPEC, record roadmap evolution Straggler planning artifacts not captured by the researcher/planner auto-commits: phase CONTEXT (approved in-session design), the approved UI-SPEC design contract, and the STATE.md roadmap-evolution note for Phase 1040 (Companion Notification Center). Co-Authored-By: Claude Opus 4.8 (1M context) --- .planning/STATE.md | 18 +- .../.gitkeep | 0 .../1040-CONTEXT.md | 105 ++++ .../1040-UI-SPEC.md | 468 ++++++++++++++++++ 4 files changed, 583 insertions(+), 8 deletions(-) create mode 100644 .planning/phases/1040-companion-notification-center/.gitkeep create mode 100644 .planning/phases/1040-companion-notification-center/1040-CONTEXT.md create mode 100644 .planning/phases/1040-companion-notification-center/1040-UI-SPEC.md diff --git a/.planning/STATE.md b/.planning/STATE.md index 8e714dd3..e7935fd6 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,15 +2,14 @@ gsd_state_version: 1.0 milestone: v4.0 milestone_name: Multi-User LAN Concurrency -status: shipping -stopped_at: PR #152 ready for merge (v4.0); PR #114 (Phase 1028 perf) shipped 2026-05-19 on parallel branch. -last_updated: "2026-05-19T10:00:00Z" -last_activity: 2026-05-19 -- Phase 1028 (Tag update perf — MEX + SIMD) COMPLETE on parallel branch claude/adoring-ishizaka-edc93c; v4.0 milestone separately shipping via PR #152. +status: "Phase 1028 closed. WithIO `tickMin` reduced 4497 ms → 3603 ms (−19.9%) on Octave Linux x86_64 CI run 26089658442, almost entirely from Plan 02d's in-memory prior-state cache. Plan 06 ships per-tick fs-stat coalescing reducing 1600 → 1 syscalls/tick (−99.94% mechanism-level; wall-time +3.2% within variance on tmpfs CI). PR #114 carries the phase. Follow-up candidates for a future perf phase: in-memory propagation refactor; `containers.Map` → struct-array refactor; `.mat` save-side optimization. K2/K3/K4 deferred per data (target regions bucket as 0 ms post-cache)." +last_updated: "2026-06-02T06:59:42.567Z" +last_activity: "2026-05-29 - Completed 260529-fnt (via /gsd:fast): FunctionTransport adapter — reuse an external/company MATLAB mailer as a NotificationService Transport, no SMTP config" progress: - total_phases: 12 - completed_phases: 6 - total_plans: 26 - completed_plans: 30 + total_phases: 15 + completed_phases: 3 + total_plans: 16 + completed_plans: 35 --- # State @@ -33,12 +32,14 @@ Last activity: 2026-05-29 - Completed 260529-fnt (via /gsd:fast): FunctionTransp ### Note on parallel v4.0 work (main branch state) While Phase 1028 was in flight on this branch, main shipped v4.0 Multi-User LAN Concurrency (phases 1029-1033) via PR #152. The two efforts touched some shared files (`LiveTagPipeline.m`, `build_mex.m`) — merged here on this commit with both feature sets preserved: + - Plan 02d in-memory prior-state cache + Plan 06 fs-stat coalescing live in the single-user code path of `LiveTagPipeline.processTag_`. - v4.0 cluster-mode (TagWriteCoordinator + AtomicWriter) lives in the `if obj.IsClusterMode_` branch. - `bench_tag_pipeline_1k` continues to drive the single-user path (no SharedRoot set). - v4.0's STATE.md / ROADMAP.md entries (phases 1029-1033 Complete) preserved verbatim; phase 1028 Complete entry added alongside. Three main PRs touched files v4.0 also modified — all auto/manually merged without functional conflict: + - PR #143 (260513-s0y) — Tile + Close all toolbar buttons. Tracking fixes (syncOpenedFigures_ Engines_ walk, public trackOpenedFigure hook, de-maximize + Units=pixels coercion) live alongside v4.0 cluster-mode wiring. - PR #149 (260519-bs4) — Tag Status Table window. TagStatusTableWindow handle + Tags toolbar button live alongside v4.0 cluster-mode + pipeline-observer state. @@ -113,6 +114,7 @@ Phase 1019 [██████████] 100% (3/3 plans complete in Phase 10 - 2026-04-29 — v3.0 phase 1023 added (Industrial Plant Demo Integration): wraps `demo/industrial_plant/run_demo.m` in `FastSenseCompanion`; 4 new COMPDEMO REQ-IDs; total now 6 phases / 32 REQ-IDs - 2026-05-13 — Milestone v4.0 Multi-User LAN Concurrency started; PROJECT.md updated, REQUIREMENTS.md created (14 P1 REQ-IDs across CONC/IDENT/EVTLOG/ACK/OPS categories; 6 P2 deferred to v4.1); research/ phase produced SUMMARY/STACK/FEATURES/ARCHITECTURE/PITFALLS markdown - 2026-05-13 — v4.0 roadmap created: 5 phases (1029-1033) covering all 14 P1 REQ-IDs, full coverage no orphans; phase structure mirrors research-recommended build order (Foundation → TagWriteCoordinator → EventLog → Single-Source Events → Companion Integration); three PITFALLS corrections (OFD locks, mtime heartbeat, lock-serialised appends) baked into Phase 1029 success criteria +- 2026-06-02 — Phase 1040 added: Companion Notification Center (acknowledgeable in-app inbox pane in `FastSenseCompanion`; design brainstormed in-session and approved; EventStore-backed feed, dismiss = `acknowledgeEvent`, new collapsible right column + toolbar bell badge; `1040-CONTEXT.md` written) ### Phase Numbering Note diff --git a/.planning/phases/1040-companion-notification-center/.gitkeep b/.planning/phases/1040-companion-notification-center/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/.planning/phases/1040-companion-notification-center/1040-CONTEXT.md b/.planning/phases/1040-companion-notification-center/1040-CONTEXT.md new file mode 100644 index 00000000..76d1330e --- /dev/null +++ b/.planning/phases/1040-companion-notification-center/1040-CONTEXT.md @@ -0,0 +1,105 @@ +# Phase 1040: Companion Notification Center - Context + +**Gathered:** 2026-06-02 +**Status:** Ready for planning +**Mode:** Brainstormed in-session (interactive design dialogue; approved by user) + + +## Phase Boundary + +Add an acknowledgeable in-app notification "inbox" to `FastSenseCompanion`: a new +collapsible right-hand pane (`NotificationCenterPane`) that live-lists *unacknowledged* +threshold-violation events from the shared `EventStore`, and lets an operator acknowledge +them — which writes shared, ISA-18.2-audited ack state via the existing +`EventStore.acknowledgeEvent`. A toolbar bell button with an unacked-count badge toggles +the pane. + +This is predominantly a NEW UI SURFACE over EXISTING data + acknowledge infrastructure. +It is distinct from (and complements) the two event surfaces the Companion already has: +the append-only `EventsLogPane` (bottom log strip) and the on-demand `EventViewer` +(Gantt + table). + +Out of this phase: any change to the email `NotificationService` path (see Deferred). + + + +## Implementation Decisions + +### Core gap (what makes this distinct) +- An *acknowledgeable inbox*: "what's new that needs attention," cleared as operators handle items. +- Today's `EventsLogPane` is append-only (no per-item dismiss); the `EventViewer` is on-demand. Neither is an ack-driven inbox. This pane fills that gap. + +### Feed source — EventStore is the single source of truth +- Pane reads current events and filters to UNACKED (`isempty(Event.AckedAt)`). +- Live refresh piggybacks on the Companion's EXISTING `LiveTimer_` / `onLiveTick_` loop (period = `LivePeriod`). NO new timer. +- When a `LiveEventPipeline` is supplied (Companion already accepts `LiveEventPipelines`), its tick also nudges a refresh for lower latency. +- Diff incoming events by `Event.Id` so the list does not flicker and the badge only animates on genuinely new items. + +### Dismiss == Acknowledge (shared, audited) +- Per-item Acknowledge → `EventStore.acknowledgeEvent(eventId, opts)`. +- Item leaves the inbox on the next diff (it is now acked). Badge decrements. +- Ack is shared + audited (`{user, host, epoch, comment}`); ~5s cluster propagation (ACK-01) to other Companions. +- Ack on an already-acked event (race) is a NO-OP, not an error. + +### Placement / UI integration (Approach 1 — chosen) +- New self-contained handle class `NotificationCenterPane` in `libs/FastSenseCompanion/`, a SIBLING to `EventsLogPane`: `attach(parent, theme)` / `detach()`, fires `DetachRequested`, detachable to its own window, state survives attach/detach. +- Companion gains a toolbar BELL button (beside the existing "Events" / Live buttons) with an unacked-count badge; toggling it shows/hides a 4th rightmost grid column. Existing Tag/Dashboard/Inspector columns reflow. +- Bell DISABLED when no EventStore (mirror the existing `hEventsBtn_` enable/disable rule). + +### Error handling (follow Companion conventions) +- Every callback wrapped try/catch → non-blocking `uialert` (existing Companion pattern) so EventStore read/ack failures never crash the window. +- EventStore read failure: keep last-good list + show an inline "stale" marker. + +### Claude's Discretion +- Exact pixel layout of the pane, badge rendering, severity color mapping (within `CompanionTheme`). +- Detached-window title/arrangement. +- Internal diffing / data-structure details. + + + + +## Existing Code Insights + +### Reusable Assets (verified in-session) +- `libs/FastSenseCompanion/EventsLogPane.m` — the detachable-pane PATTERN to mirror (attach/detach, `DetachRequested`, ring buffer, header search + level-filter dropdown, "Updated:" label, pop-out icon). +- `libs/EventDetection/EventStore.m` — `acknowledgeEvent(eventId, opts)` (`opts.comment`), `numEvents()`, ack-records API; events persisted to a shared `.mat` (single-user) / SQLite (cluster). +- `libs/EventDetection/Event.m` — `AckedAt` (empty = unacked), `AckedBy`, `AckComment`, `Severity` (1 info / 2 warn / 3 alarm), `IsOpen` (still-open violation), `Id`, `Notes`, `computeDisplayState()`. +- `libs/FastSenseCompanion/FastSenseCompanion.m` — `LiveTimer_` / `onLiveTick_` / `IsLive` / `LivePeriod` (reuse for refresh), `EventStore_` / `getEventStore()` (resolved store), `LiveEventPipelines_` (optional observation), `hEventsBtn_` + `openEventViewer_()` (row-click target; enable/disable-on-EventStore pattern), toolbar grid + 3-column root grid (extend to 4). +- `tests/CaptureNotificationService.m` — model for a capture/stub test double; build an analogous stub EventStore. + +### Established Patterns +- Detachable pane fires `DetachRequested`; Companion listens and re-parents to a uifigure. +- Non-blocking `uialert` for all callback errors (never crash the companion window). +- Severity/level filter dropdown (EventsLogPane) — reuse for the severity filter. + +### Integration Points +- `FastSenseCompanion` constructor/build: instantiate `NotificationCenterPane`, add bell button + badge, add the toggleable 4th grid column. +- `onLiveTick_`: after existing work, refresh the notification pane (diff unacked events, update badge). +- **EventStore read API — OPEN, confirm during planning.** `EventViewer` reads events from the `.mat` file directly; `FastSense` queries EventStore for overlays. If no in-memory "current events" accessor exists, add a thin `EventStore.unackedEvents()` (or `events()`) helper rather than re-reading the file inside the pane. + + + + +## Specific Ideas (approved UX defaults) + +- Badge counts ALL unacked events, colored by the highest severity present. +- Default filter shows ALL severities (info + warn + alarm), with a severity dropdown to narrow. +- Acknowledge is ONE-CLICK; an "Ack with comment…" affordance prompts for `opts.comment`. +- Include an "Acknowledge all visible" bulk action. +- Row-click opens the existing Event Viewer via `Companion.openEventViewer_()` (future: focused on the clicked event). +- Show a "LIVE" tag on still-open events (`Event.IsOpen == true`). +- Newest-first ordering. +- Empty state text: "No unacknowledged events." + + + + +## Deferred Ideas (out of scope for v1 — YAGNI) + +- Linking the email `NotificationService` into the app. The email path stays as-is; the in-app pane reads the EventStore directly (both derive from the same events, so they stay consistent). Reusing NotificationService *rules / severity* for in-app filtering could be a follow-up. +- Sounds / desktop / OS notifications. +- Snooze / temporary mute. +- Event grouping or storm-collapsing (many rapid violations). +- Per-user local "seen" state separate from shared ack (we chose dismiss == shared acknowledge). + + diff --git a/.planning/phases/1040-companion-notification-center/1040-UI-SPEC.md b/.planning/phases/1040-companion-notification-center/1040-UI-SPEC.md new file mode 100644 index 00000000..bc1a00bd --- /dev/null +++ b/.planning/phases/1040-companion-notification-center/1040-UI-SPEC.md @@ -0,0 +1,468 @@ +--- +phase: 1040 +slug: companion-notification-center +status: draft +design_system: MATLAB uifigure (CompanionTheme + EventsLogPane pattern) +preset: N/A — MATLAB uifigure, no component registry +created: 2026-06-02 +--- + +# Phase 1040 — UI Design Contract: Companion Notification Center + +> Visual and interaction contract for the `NotificationCenterPane` in `FastSenseCompanion`. +> Generated by gsd-ui-researcher, verified by gsd-ui-checker. +> +> **Platform note:** This is a pure MATLAB `uifigure` surface. All measurements are in pixels. +> Colors are RGB triples on the 0–1 scale. No CSS, no Tailwind, no web framework. +> The design system is the existing `CompanionTheme` / `DashboardTheme` token set. +> shadcn gate: SKIPPED — not applicable. + +--- + +## Design System + +| Property | Value | +|----------|-------| +| Tool | N/A — MATLAB uifigure, no component registry | +| Preset | N/A — CompanionTheme.get('dark') / CompanionTheme.get('light') | +| Component library | MATLAB built-in: `uigridlayout`, `uipanel`, `uibutton`, `uitable`, `uidropdown`, `uilabel`, `uieditfield` | +| Icon library | Unicode glyphs inline in `uibutton.Text` — bell: `char(128276)` (U+1F514); ASCII fallback `'[!]'` when `usejava('desktop')` returns false; pop-out: `char(8689)` (U+21D1, mirrors EventsLogPane) | +| Font | `Menlo` for timestamp/id fields (monospace); default MATLAB sans-serif for all other labels (inherits from `uifigure`) | +| Token source | `CompanionTheme.get()` — wraps `DashboardTheme`; exact values documented in Color section below | + +Source: RESEARCH.md Standard Stack + direct read of `CompanionTheme.m` and `DashboardTheme.m`. + +--- + +## Spacing Scale + +MATLAB `uigridlayout` spacing — all values in pixels, multiples of 4. + +| Token | Value | Usage | +|-------|-------|-------| +| xs | 4 px | `RowSpacing` within header grids; icon-to-label gap | +| sm | 8 px | `ColumnSpacing` in header grid (mirrors EventsLogPane `gHdr.ColumnSpacing = 8`) | +| md | 16 px | `PanePadding` (CompanionTheme token); inner padding for `NotificationCenterPane` root layout | +| lg | 24 px | `GridOuterPadding` (CompanionTheme token); outer padding on companion root grid | +| xl | 32 px | N/A for this pane | +| 2xl | 48 px | N/A for this pane | +| 3xl | 64 px | N/A for this pane | + +Exceptions: +- Root layout `Padding`: `[8 4 8 4]` (left/bottom/right/top) — mirrors EventsLogPane `hRoot_.Padding` exactly. +- Header row height: 28 px (matches `EventsLogPane hRoot_.RowHeight = {28, '1x'}`). +- Table row height: `'1x'` (fills remaining space). +- Notification pane column width when visible: 320 px (`ColumnWidth{4} = 320`); 0 when hidden. +- Bell button width: 70 px (matches Wiki button at col 7 of the `[1 10]` toolbar grid). +- Severity dot column: 12 px fixed width within inbox row layout. +- Ack button column: 28 px fixed width; "Ack with comment" button: 36 px. +- Touch-target minimum: 28 px height for all buttons (toolbar buttons are taller via grid RowHeight `'1x'`). + +Source: EventsLogPane.m lines 101–106 (spacing constants), RESEARCH.md Pattern 3 (column widths), CompanionTheme.m lines 39–43. + +--- + +## Typography + +All values in pixels. MATLAB `FontWeight` accepts `'normal'` or `'bold'` only. + +| Role | Size | Weight | Usage | +|------|------|--------|-------| +| Pane section label | 11 px | `'bold'` | "Notifications" header label — mirrors EventsLogPane `hLbl.FontSize = 11, FontWeight = 'bold'` | +| Table body text | 10 px | `'normal'` | Inbox `uitable` rows (sensor name, threshold label, peak value) — mirrors EventsLogPane `hLogTable_.FontSize = 10` | +| Table monospace fields | 10 px | `'normal'` | Start time, event ID columns — `FontName = 'Menlo'` | +| Timestamp / status label | 11 px | `'normal'` | "Updated: HH:MM:SS" label; stale marker — mirrors EventsLogPane `hLastUpdateLbl_.FontSize = 11, FontName = 'Menlo'` | +| Dropdown / filter | 11 px | `'normal'` | Severity filter `uidropdown` — mirrors EventsLogPane `hLogLevelDD_.FontSize = 11` | +| Bell badge label | 11 px | `'bold'` | Unacked count in bell button text, e.g. `char(128276) + ' (5)'`; bold to read against colored background | +| LIVE tag | 10 px | `'bold'` | Inline `'LIVE'` text rendered in the table Status column for `IsOpen == true` events | + +Line height: MATLAB manages this internally; no explicit `LineHeight` property on `uitable` rows. +Body font: inherits `uifigure` default (system sans-serif; not overridden except for monospace fields). + +Source: EventsLogPane.m lines 124, 138–140, 147–148, 197–199; RESEARCH.md Pattern 4 (badge). + +--- + +## Color + +All RGB triples on the 0–1 scale. Values vary by theme preset; both dark (default) and light are documented. + +### Theme Token Reference + +Sourced directly from `DashboardTheme.m` lines 57–103 and `CompanionTheme.m`. + +| Token name | Dark preset value | Light preset value | Semantic meaning | +|---|---|---|---| +| `DashboardBackground` | `[0.10 0.10 0.18]` | `[0.96 0.96 0.97]` | App-level background | +| `WidgetBackground` | `[0.09 0.13 0.24]` | `[1.00 1.00 1.00]` | Pane / panel fill | +| `WidgetBorderColor` | `[0.16 0.23 0.37]` | `[0.85 0.85 0.87]` | Icon button background (pop-out, bell idle) | +| `ForegroundColor` | (from FastSenseTheme) | (from FastSenseTheme) | All label text | +| `ToolbarFontColor` | `[0.66 0.73 0.78]` | `[0.20 0.20 0.25]` | Subdued labels ("Updated:", stale marker) | +| `Accent` (= `DragHandleColor`) | `[0.31 0.80 0.64]` | `[0.20 0.60 0.86]` | Bell badge background for info-only unacked set | +| `StatusOkColor` | `[0.31 0.80 0.64]` | `[0.31 0.80 0.64]` | Severity-1 (info) dot + EventGanttCanvas color | +| `StatusWarnColor` | `[0.91 0.63 0.27]` | `[0.91 0.63 0.27]` | Bell badge background when warn is highest severity | +| `StatusAlarmColor` | `[0.91 0.27 0.38]` | `[0.91 0.27 0.38]` | Bell badge background when alarm is highest severity | + +### Severity Colors (Row Accents) + +Row severity accent dots use `EventGanttCanvas.severityColor(sev)` — the canonical in-codebase source, consistent with the Gantt view: + +| Severity | `Event.Severity` value | RGB | Display name | +|---|---|---|---| +| Info / ok | 1 | `[0.20 0.70 0.30]` | green | +| Warn | 2 | `[0.95 0.60 0.10]` | orange | +| Alarm | 3 | `[0.85 0.20 0.20]` | red | +| Unknown | other | `[0.50 0.50 0.50]` | grey | + +Source: EventGanttCanvas.m lines 285–298. + +### 60/30/10 Color Contract (MATLAB-adapted) + +| Role | Token | Value (dark) | Usage | +|------|-------|---|-------| +| Dominant (60%) — surface | `WidgetBackground` | `[0.09 0.13 0.24]` | `hRoot_.BackgroundColor`; pane background | +| Secondary (30%) — contrast elements | `WidgetBorderColor` | `[0.16 0.23 0.37]` | Button backgrounds (bell idle, pop-out); table stripe 1 of 2 | +| Accent (10%) — interactive signals | `Accent` / status colors | see table above | Explicitly reserved for: (1) bell badge background when unacked events exist; (2) severity dot in each inbox row; (3) "Acknowledge all visible" button background when count > 0 | +| Destructive | `StatusAlarmColor` | `[0.91 0.27 0.38]` | Bell badge background when alarm-severity events are unacked; severity-3 row dot only — NOT used for the Ack button (that is a constructive action, not destructive) | + +Accent reserved for: +1. Bell button `BackgroundColor` (severity-graduated: Alarm → `StatusAlarmColor`; Warn → `StatusWarnColor`; Info → `Accent`; idle → `WidgetBorderColor`) +2. Severity dot `BackgroundColor` in each inbox row (via `EventGanttCanvas.severityColor`) +3. "Acknowledge all visible" button `BackgroundColor` when visible count > 0 + +Accent is NOT used for: general text, borders, table rows, the Ack per-item button (which uses `WidgetBorderColor` background — constructive, not highlighted). + +### Striped Table BackgroundColor + +Mirrors EventsLogPane exactly (lines 183–188): + +| Condition | Stripe row 1 | Stripe row 2 | +|---|---|---| +| Dark theme (`mean(DashboardBackground) < 0.5`) | `[0.13 0.13 0.13]` | `[0.20 0.20 0.20]` | +| Light theme | `[1.00 1.00 1.00]` | `[0.94 0.94 0.94]` | + +Source: EventsLogPane.m lines 183–188, DashboardTheme.m lines 57–101. + +--- + +## Component Layout Contract + +This section is MATLAB-specific (no web equivalent). It describes the `uigridlayout` structure +the executor must implement. + +### NotificationCenterPane Root Grid + +``` +uigridlayout(parent, [2 1]) + RowHeight = {28, '1x'} + ColumnWidth = {'1x'} + Padding = [8 4 8 4] + RowSpacing = 4 + BackgroundColor = theme.WidgetBackground +``` + +Row 1 — Header strip (28 px fixed): +``` +uigridlayout(hRoot_, [1 6]) + ColumnWidth = {60, '1x', 100, 120, 36, 36} + RowHeight = {'1x'} + Padding = [0 0 0 0] + ColumnSpacing = 8 + Col 1: "Notifications" label (FontSize=11, FontWeight='bold') + Col 2: search uieditfield (FontSize=11, Placeholder='Filter notifications…') + Col 3: severity uidropdown (FontSize=11, see Severity Filter below) + Col 4: "Updated: HH:MM:SS" (FontSize=11, FontName='Menlo', FontColor=PlaceholderTextColor) + Col 5: pop-out uibutton (Text=char(8689), FontSize=14, like EventsLogPane) + Col 6: (reserved / empty) — keeps column count symmetric with EventsLogPane [1 6] +``` + +Row 2 — Inbox uitable (`'1x'` height): +``` +uitable(hRoot_) + Layout.Row = 2 + ColumnName = {'', 'Sensor', 'Threshold', 'Peak', 'Start', 'Status', '', ''} + (col 1 = severity dot; col 7 = Ack; col 8 = Ack+comment) + ColumnWidth = {12, 'auto', 'auto', 70, 90, 55, 28, 36} + ColumnEditable = false (all) — interactions handled via CellSelectionChangedFcn + RowName = {} + FontSize = 10 + FontName = 'Menlo' (for timestamp/ID columns; uitable uses single FontName) + ForegroundColor = theme.ForegroundColor + BackgroundColor = stripePair (see Striped Table section) +``` + +Note: `uitable` does not support per-cell button widgets in MATLAB R2020b. The Ack and +Ack+comment affordances are rendered as short text tokens in columns 7–8 (`'Ack'` and `'...'`) +and handled via `CellSelectionChangedFcn` checking the column index. This matches the +in-codebase `EventViewer` pattern. + +### Companion Root Grid Extension (4th Column) + +``` +% CURRENT (FastSenseCompanion.m:299–300): +obj.hLayout_ = uigridlayout(obj.hFig_, [3 3]); +obj.hLayout_.ColumnWidth = {220, '1x', 360}; + +% NEW (Phase 1040): +obj.hLayout_ = uigridlayout(obj.hFig_, [3 4]); +obj.hLayout_.ColumnWidth = {220, '1x', 360, 0}; % 0 = hidden initially +``` + +Existing panels (cols 1–3) keep their current `Layout.Column` assignments. +Toolbar and log panels must be updated to span `[1 4]`. +New notification panel: `Layout.Row = 2`, `Layout.Column = 4`. + +### Companion Toolbar Grid Extension (Bell Button) + +``` +% CURRENT: [1 9] grid +% NEW: [1 10] grid +hToolbarGrid.ColumnWidth = {110, 110, 110, 130, 70, 90, 70, 70, '1x', 36} +% Col 1 Events 110px +% Col 2 Live 110px +% Col 3 Tags 110px +% Col 4 PlantLog 130px +% Col 5 Tile 70px +% Col 6 CloseAll 90px +% Col 7 Wiki 70px +% Col 8 Bell 70px ← NEW +% Col 9 spacer '1x' ← shifted from col 8 +% Col 10 Gear 36px ← shifted from col 9 +``` + +Bell button (`hBellBtn_`) properties at idle state: +``` +Text = char(128276) % plain bell glyph; ASCII fallback '[!]' when !usejava('desktop') +BackgroundColor = theme.WidgetBorderColor +FontColor = theme.ForegroundColor +FontSize = 12 +Tooltip = 'Toggle notification center' +Enable = 'off' when isempty(EventStore_) % mirrors hEventsBtn_ rule +Tag = 'CompanionBellBtn' +``` + +Source: RESEARCH.md Patterns 3–4; FastSenseCompanion.m lines 299–300, 323. + +--- + +## Interaction Contract + +### Bell Button States + +| State | `Text` | `BackgroundColor` | `FontColor` | +|---|---|---|---| +| No EventStore | bell glyph | `WidgetBorderColor` | `ToolbarFontColor` (dimmed) | +| 0 unacked | bell glyph | `WidgetBorderColor` | `ForegroundColor` | +| ≥1 unacked, max sev = info (1) | `bell + ' (N)'` | `Accent` | `DashboardBackground` | +| ≥1 unacked, max sev = warn (2) | `bell + ' (N)'` | `StatusWarnColor` | `DashboardBackground` | +| ≥1 unacked, max sev = alarm (3) | `bell + ' (N)'` | `StatusAlarmColor` | `DashboardBackground` | +| Pane open (toggled on) | same as above | same as above | — (no pressed-state change) | + +Bell badge animates (text changes) only when `diff(newIds, lastIds_)` is non-empty, preventing flicker. + +### Inbox Row States + +Each row maps to one unacked `Event`. Columns are populated as: + +| Column | Content | Notes | +|---|---|---| +| 1 (severity dot) | `' '` with `BackgroundColor` = `EventGanttCanvas.severityColor(ev.Severity)` | 12 px wide; colored square effect via table cell background | +| 2 (Sensor) | `ev.SensorName` | | +| 3 (Threshold) | `ev.ThresholdLabel` | | +| 4 (Peak) | formatted peak value string (from event data) | right-aligned | +| 5 (Start) | `datestr(ev.StartTime, 'HH:MM:SS dd-mmm')` | monospace | +| 6 (Status) | `'LIVE'` when `ev.IsOpen == true`; `'closed'` otherwise | `'LIVE'` in bold; severity-colored when LIVE | +| 7 (Ack) | `'Ack'` | click → per-item acknowledge | +| 8 (comment) | `'...'` | click → "Ack with comment" dialog | + +Row ordering: newest-first by `ev.StartTime` (descending sort applied before assigning `uitable.Data`). +Row count cap: 200 rows maximum displayed (configurable; prevents UI lag on event storms per RESEARCH.md Pitfall 5). +Selection behavior: `CellSelectionChangedFcn` inspects `event.Indices(2)` (column index): +- Column 7 → `onAckBtn_(eventId)` +- Column 8 → `onAckWithComment_(eventId, hFig)` +- Any other column → `openEventViewer_(eventId)` via `Companion_.openEventViewer_()` + +### Severity Filter Dropdown + +``` +Items = {'All', 'Alarm', 'Warn', 'Info'} +Value = 'All' (default — shows all severities per CONTEXT.md) +FontSize = 11 +Tooltip = 'Filter by severity' +``` + +Filtering is client-side against the `LastGoodEvents_` buffer. The filter does NOT re-fetch from `EventStore`; it applies to the last-good event set on each `refresh()` call. + +### Empty State + +Displayed in the `uitable` when no unacked events pass the filter. The table `Data` is set to a single-row cell: +```matlab +hTable_.Data = {'', '', 'No unacknowledged events', '', '', '', '', ''}; +``` +The text `'No unacknowledged events'` spans into column 3 visually (the widest `'auto'` column). Ack columns 7–8 are empty in this row, preventing accidental clicks. Row click on empty state is a no-op (guarded by empty `LastGoodEvents_` check in `CellSelectionChangedFcn`). + +Source: CONTEXT.md `` (locked verbatim). + +### Stale State (EventStore Read Failure) + +On `EventStore.getEvents()` exception: +1. Retain `LastGoodEvents_` — do NOT clear the inbox. +2. Display inline stale marker: append `' (stale)'` to the "Updated:" label, changing it to `'Updated: HH:MM:SS (stale)'`. +3. `FontColor` of the stale-marker label: `StatusWarnColor` (`[0.91 0.63 0.27]`) to signal degraded state without triggering alarm-level urgency. +4. Log the failure via `addLogEntry('warn', ...)` to the existing `EventsLogPane` (if accessible via `Companion_`). +5. Do NOT show `uialert` for read errors — the inline marker is sufficient (uialert is reserved for ack failures that need operator attention). + +Stale marker exact text: `'(stale)'` — appended as suffix to the timestamp label. + +Source: CONTEXT.md `` (keep last-good list + inline stale marker). + +### Acknowledge Actions + +**Per-item one-click Ack (column 7 click):** +``` +Button label text: 'Ack' +Action: EventStore.acknowledgeEvent(eventId, struct('comment', '')) +On success: item removed from inbox on next refresh() diff +On EventStore:unknownEventId: no-op (race — already acked) +On other error: uialert(hFig, ME.message, 'Acknowledge Failed', 'Icon', 'error') +``` + +**Per-item Ack with comment (column 8 click):** +``` +Button label text: '...' +Tooltip: 'Ack with comment…' +Dialog: inputdlg('Acknowledgement comment:', 'Acknowledge Event', 1, {''}) +On cancel (empty answer): no-op +On confirm: EventStore.acknowledgeEvent(eventId, struct('comment', answer{1})) +Error handling: same as per-item Ack +``` + +**Bulk "Acknowledge all visible" (header button):** +``` +Button label: 'Acknowledge all visible' +Button placement: header area, below the [1 6] filter grid, as a full-width uibutton + Layout.Row = 1 (placed in an optional 3rd header row if needed) OR + integrated as a separate [3 1] root layout with rows: {28, 24, '1x'} where row 2 holds the bulk button +Button height: 24 px +BackgroundColor: Accent when visibleCount > 0; WidgetBorderColor when 0 +FontSize: 11 +Action: loop through all currently visible event IDs, call acknowledgeEvent for each, + catch EventStore:unknownEventId per item (treat as no-op), stop on first non-race error + and uialert with the failure; continue remaining IDs +``` + +Source: CONTEXT.md `` (locked verbatim for all three labels). + +### Detach Behavior + +Fires `DetachRequested` event (identical to `EventsLogPane.requestDetach()` pattern). +`FastSenseCompanion` listens and re-parents to a standalone `uifigure` (NOT `figure` — the +detached notification window is a `uifigure` matching the EventsLogPane detach pattern at +`FastSenseCompanion.m:1488`). + +Detached window title: `'Notification Center — FastSenseCompanion'` +Detached window size: 420 × 600 px (initial; user-resizable) +State across detach/reattach: `LastGoodEvents_`, `LastIds_`, filter state — all preserved in pane properties. + +### Column Show/Hide + +```matlab +% Show (on bell toggle): +obj.hLayout_.ColumnWidth{4} = 320; +drawnow; % ensure clean render on first expand (Pitfall 3) + +% Hide: +obj.hLayout_.ColumnWidth{4} = 0; +``` + +No re-parenting or `attach()`/`detach()` cycle. The pane stays attached to `hNotifPanel_` throughout. +Source: RESEARCH.md Pattern 3. + +--- + +## Copywriting Contract + +All copy strings below are LOCKED from CONTEXT.md `` and ``. Do not alter. + +| Element | Copy | Notes | +|---------|------|-------| +| Pane header label | `'Notifications'` | FontWeight='bold', 11 px | +| Primary CTA (per-item ack) | `'Acknowledge'` | Used in uialert dialog titles and tooltip text; the table column 7 token is abbreviated `'Ack'` for space | +| Secondary CTA (per-item with comment) | `'Ack with comment…'` | Column 8 tooltip; inputdlg title uses `'Acknowledge Event'` | +| Bulk action | `'Acknowledge all visible'` | Full label on bulk uibutton; no abbreviation | +| Empty state | `'No unacknowledged events'` | Rendered in column 3 of a single-row uitable.Data | +| Open event indicator | `'LIVE'` | Literal text in Status column when `ev.IsOpen == true`; bold, severity-color foreground | +| Stale state marker | `'(stale)'` | Appended to "Updated: HH:MM:SS" → `'Updated: HH:MM:SS (stale)'`; FontColor = StatusWarnColor | +| Bell tooltip (no EventStore) | `'No EventStore registered'` | Mirrors hEventsBtn_ disable tooltip | +| Bell tooltip (active) | `'Toggle notification center'` | Shown when EventStore is present | +| inputdlg title | `'Acknowledge Event'` | Used for "Ack with comment" dialog | +| inputdlg prompt | `'Acknowledgement comment:'` | | +| uialert title (ack failure) | `'Acknowledge Failed'` | | +| Detached window title | `'Notification Center — FastSenseCompanion'` | | +| Search field placeholder | `'Filter notifications…'` | Using `char(8230)` ellipsis; R2021a+ only — wrapped in try/catch | + +--- + +## Registry Safety + +N/A — MATLAB uifigure, no component registry. No shadcn, no npm, no third-party component blocks. +All UI primitives are MATLAB built-ins. No vetting gate required or applicable. + +| Registry | Blocks Used | Safety Gate | +|----------|-------------|-------------| +| N/A — MATLAB uifigure | N/A | N/A | + +--- + +## Theme Propagation Contract + +`NotificationCenterPane.applyTheme(themeStruct)` must: +1. Update `ThemeStruct_` stored property. +2. If detached (`IsAttached == false`): store only; next `attach()` will use latest theme. +3. If attached: call `applyThemeToChildren_(obj.hRoot_, themeStruct)` (the shared walker from EventsLogPane). +4. Then re-assert pane-specific overrides (walker overwrites these): + - `hLastUpdateLbl_.FontColor = themeStruct.PlaceholderTextColor` (or `StatusWarnColor` if stale) + - `hPopoutBtn_.BackgroundColor = themeStruct.WidgetBorderColor` + - `hPopoutBtn_.FontColor = themeStruct.ForegroundColor` + - `hTable_.BackgroundColor = stripePair` (recompute from `isDark` check) + - `hTable_.ForegroundColor = themeStruct.ForegroundColor` + - Re-call `updateBellBadge_()` to recolor badge with new theme tokens (bell is on the Companion, not the pane — Companion's `applyTheme` must call `updateBellBadge_()` after walking its own tree) + +--- + +## Error Namespacing + +| Class | Error ID prefix | Example | +|---|---|---| +| `NotificationCenterPane` | `NotificationCenterPane:*` | `NotificationCenterPane:invalidParent` | +| `FastSenseCompanion` (new methods) | `FastSenseCompanion:*` | `FastSenseCompanion:bellToggleFailed` | + +All callbacks wrapped in `try/catch` → non-blocking `uialert` (never crash the companion window). +Exception: `EventStore:unknownEventId` in ack callbacks → silent no-op (race condition, not user error). + +--- + +## Checker Sign-Off + +- [ ] Dimension 1 Copywriting: PASS +- [ ] Dimension 2 Visuals: PASS +- [ ] Dimension 3 Color: PASS +- [ ] Dimension 4 Typography: PASS +- [ ] Dimension 5 Spacing: PASS +- [ ] Dimension 6 Registry Safety: PASS + +**Approval:** pending + +--- + +## Pre-Population Sources + +| Source | Decisions Used | +|--------|---------------| +| 1040-CONTEXT.md | 12 — bell button placement, collapsible 4th column, feed source, ack == dismiss, error handling pattern, severity filter default, newest-first, per-row fields, LIVE tag, "Ack with comment", bulk ack, row-click → EventViewer, empty state copy, deferred items | +| 1040-RESEARCH.md | 18 — exact grid coordinates, ColumnWidth values, toolbar col layout, `severityColor()` RGB values, EventStore API, badge label pattern, stripe pair logic, detach pattern, stale guard, pitfalls 1–7 | +| CompanionTheme.m (direct read) | 8 — PanePadding=16, GridOuterPadding=24, GridColumnSpacing=16, SearchFieldHeight=28, FilterPillHeight=24, Accent alias, PlaceholderTextColor alias, LineColors | +| DashboardTheme.m (direct read) | 6 — exact RGB for WidgetBackground, WidgetBorderColor, ToolbarFontColor, StatusOkColor, StatusWarnColor, StatusAlarmColor (dark + light) | +| EventsLogPane.m (direct read) | 9 — RowHeight {28,'1x'}, Padding [8 4 8 4], RowSpacing 4, header [1 6] grid ColumnWidth, FontSize 10/11, FontName Menlo, stripe pair logic, detach pattern, pop-out glyph char(8689) | +| EventGanttCanvas.m (direct read) | 4 — severityColor() RGB triples for sev 1/2/3/fallback | +| User input | 0 — all required decisions answered by upstream artifacts | From 2b51ac889a659fecb5af5097813e4ada76efe5e4 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 2 Jun 2026 10:36:06 +0200 Subject: [PATCH 04/20] test(1040-01): add StubEventStore test double - Fake EventStore handle for NotificationCenterPane tests - getEvents / numEvents / acknowledgeEvent mirror the real store - ThrowOnGet_ / ThrowOnAck_ switches drive stale + ack-race paths - acknowledgeEvent records ids and mutates the matching Event AckedAt Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/StubEventStore.m | 64 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 tests/StubEventStore.m diff --git a/tests/StubEventStore.m b/tests/StubEventStore.m new file mode 100644 index 00000000..5221b137 --- /dev/null +++ b/tests/StubEventStore.m @@ -0,0 +1,64 @@ +classdef StubEventStore < handle + %STUBEVENTSTORE Fake EventStore handle for NotificationCenterPane tests. + % A lightweight test double that stands in for a real EventStore so the + % notification-pane logic can be exercised without persisting to disk. + % Mirrors the public surface the pane relies on: getEvents / numEvents / + % acknowledgeEvent. Modeled on tests/CaptureNotificationService.m. + % + % Configurable failure switches let tests drive the stale-on-read path + % (ThrowOnGet_) and the already-acked race path (ThrowOnAck_). + % + % Usage: + % s = StubEventStore; + % s.Events_ = [e1 e2 e3]; % configure fixtures + % evs = s.getEvents(); % -> Events_ + % s.acknowledgeEvent('evt_2', struct()); % records id + sets AckedAt + % assert(isequal(s.AckedIds_, {'evt_2'})); + % s.ThrowOnAck_ = true; % next ack throws unknownEventId + % s.ThrowOnGet_ = true; % next getEvents throws + % + % Phase 1040 Plan 01. + % + % See also EventStore, Event, NotificationCenterPane, CaptureNotificationService. + + properties + Events_ = Event.empty % Event array; configure in test setup + AckedIds_ = {} % cellstr: each eventId passed to acknowledgeEvent, in call order + ThrowOnAck_ = false % when true, acknowledgeEvent throws EventStore:unknownEventId + ThrowOnGet_ = false % when true, getEvents throws (exercises the stale path) + end + + methods + function evs = getEvents(obj) + %GETEVENTS Return the configured Event array (or throw when ThrowOnGet_). + if obj.ThrowOnGet_ + error('EventStore:getEventsFailed', 'stub throw'); + end + evs = obj.Events_; + end + + function n = numEvents(obj) + %NUMEVENTS Count of configured events. + n = numel(obj.Events_); + end + + function ack = acknowledgeEvent(obj, eventId, ~) + %ACKNOWLEDGEEVENT Record the id and mutate the matching Event's AckedAt. + % Mirrors the real EventStore single-user behavior: records the + % call, sets the matching in-memory Event's AckedAt, and returns an + % ack struct. When ThrowOnAck_ is set, throws the same identifier the + % real store raises on an unknown id so the race path can be tested. + if obj.ThrowOnAck_ + error('EventStore:unknownEventId', 'stub: not found'); + end + obj.AckedIds_{end+1} = eventId; + for i = 1:numel(obj.Events_) + if strcmp(obj.Events_(i).Id, eventId) + obj.Events_(i).AckedAt = now; + break; + end + end + ack = struct('eventId', eventId, 'action', 'ack'); + end + end +end From 3a05c8c616b305930ef1a4a54181bb39a6674322 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 2 Jun 2026 10:37:32 +0200 Subject: [PATCH 05/20] feat(1040-01): add NotificationCenterPane static pure-logic helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - classdef shell mirroring EventsLogPane (events block, property block) - 7 pure helpers: filterUnacked_ (empty/NaN AckedAt), sortNewestFirst_, maxSeverity_, idsOf_, diffIds_ (order-insensitive), badgeText_, badgeColor_ - No UI yet — attach/refresh/ack lifecycle lands in Plan 02 - Code Analyzer + MISS_HIT clean Co-Authored-By: Claude Opus 4.8 (1M context) --- .../NotificationCenterPane.m | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 libs/FastSenseCompanion/NotificationCenterPane.m diff --git a/libs/FastSenseCompanion/NotificationCenterPane.m b/libs/FastSenseCompanion/NotificationCenterPane.m new file mode 100644 index 00000000..7943fc90 --- /dev/null +++ b/libs/FastSenseCompanion/NotificationCenterPane.m @@ -0,0 +1,143 @@ +classdef NotificationCenterPane < handle +%NOTIFICATIONCENTERPANE Acknowledgeable notification inbox pane for FastSenseCompanion. +% +% Self-contained handle class that owns a live "inbox" of UNACKED +% threshold-violation events read from the shared EventStore. A sibling to +% EventsLogPane: it attaches into either a uipanel (inline, embedded in the +% companion's collapsible 4th column) or a uifigure (detached, its own +% window), fires DetachRequested on the pop-out icon, and preserves its +% last-good event set + filter selection across attach/detach round-trips. +% +% Unlike the append-only EventsLogPane, this pane is ack-driven: each row is +% one unacked Event; acknowledging it (EventStore.acknowledgeEvent) removes +% it from the inbox on the next refresh and decrements the toolbar bell +% badge. The pane does NOT own a timer — FastSenseCompanion.onLiveTick_ +% drives refresh (wired in Plan 03). +% +% This file (Plan 01) ships only the class shell + the pure-logic static +% helpers that pin the filter / sort / diff / badge semantics. The instance +% lifecycle (attach/detach/refresh/applyTheme/ack callbacks) is added in +% Plan 02. +% +% Static helpers (pure; no UI, no EventStore calls): +% filterUnacked_ — keep events with empty/NaN AckedAt +% sortNewestFirst_ — descending StartTime order +% maxSeverity_ — highest Severity (0 for empty) +% idsOf_ — cellstr of Event.Id +% diffIds_ — order-insensitive id-set change test +% badgeText_ — bell glyph + optional " (N)" count +% badgeColor_ — severity -> CompanionTheme color token +% +% Properties: +% IsAttached (SetAccess private) — true between attach() and detach() +% +% Events fired: +% DetachRequested — fired when the user clicks the inline pop-out icon. +% +% See also EventsLogPane, EventStore, Event, CompanionTheme, EventGanttCanvas. + + events + DetachRequested % fired when user clicks the inline pop-out icon + end + + properties (SetAccess = private) + IsAttached logical = false + end + + properties (Access = private) + ThemeStruct_ = [] % resolved CompanionTheme struct + hRoot_ = [] % outer uigridlayout ([3 1] grid) + hTable_ = [] % uitable for the inbox rows + hSevDD_ = [] % uidropdown severity filter + hSearch_ = [] % uieditfield (free-text filter) + hAckAllBtn_ = [] % "Acknowledge all visible" uibutton + hLastUpdateLbl_ = [] % "Updated: HH:MM:SS" label + hPopoutBtn_ = [] % pop-out icon uibutton + Companion_ = [] % FastSenseCompanion handle (or []) + LastGoodEvents_ = Event.empty % last successfully-read unacked set (survives detach) + LastIds_ = {} % cellstr of LastGoodEvents_ ids (diff key) + Listeners_ = {} % addlistener handles; deleted on teardown + IsStale_ = false % true when the last EventStore read failed + end + + methods (Static) + + function evs = filterUnacked_(allEvents) + %FILTERUNACKED_ Keep only events that are still unacknowledged. + % An event is unacked iff AckedAt is empty OR all-NaN (mirrors + % Event.computeDisplayState). Empty input returns an empty Event array. + if isempty(allEvents) + evs = Event.empty; + return; + end + mask = false(1, numel(allEvents)); + for i = 1:numel(allEvents) + a = allEvents(i).AckedAt; + mask(i) = isempty(a) || (isnumeric(a) && all(isnan(a))); + end + evs = allEvents(mask); + end + + function evs = sortNewestFirst_(events) + %SORTNEWESTFIRST_ Order events by StartTime, newest first. + if numel(events) > 1 + [~, ord] = sort([events.StartTime], 'descend'); + evs = events(ord); + else + evs = events; + end + end + + function s = maxSeverity_(events) + %MAXSEVERITY_ Highest Severity across events (0 for an empty set). + if isempty(events) + s = 0; + else + s = max([events.Severity]); + end + end + + function ids = idsOf_(events) + %IDSOF_ Return a cellstr of the events' Id fields ({} for empty input). + if isempty(events) + ids = {}; + else + ids = arrayfun(@(e) e.Id, events, 'UniformOutput', false); + end + end + + function changed = diffIds_(newIds, oldIds) + %DIFFIDS_ True when the two id sets differ (order-insensitive). + % {} vs {} -> false. Reordering the same ids -> false. + if isempty(newIds), newIds = {}; end + if isempty(oldIds), oldIds = {}; end + changed = ~isequal(sort(newIds(:)), sort(oldIds(:))); + end + + function txt = badgeText_(count, glyph) + %BADGETEXT_ Bell glyph alone for zero, else 'glyph (N)'. + if count <= 0 + txt = glyph; + else + txt = sprintf('%s (%d)', glyph, count); + end + end + + function rgb = badgeColor_(maxSev, theme) + %BADGECOLOR_ Map the highest active severity to a theme color token. + % Idle/zero-count recoloring to WidgetBorderColor is handled by the + % caller (Plan 03); this returns the active-severity color. + switch maxSev + case 3 + rgb = theme.StatusAlarmColor; + case 2 + rgb = theme.StatusWarnColor; + case {0, 1} + rgb = theme.Accent; + otherwise + rgb = theme.Accent; + end + end + + end +end From bd19fb85c2d5c1f6044db1979a9a3ea4721a6ea2 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 2 Jun 2026 10:39:48 +0200 Subject: [PATCH 06/20] test(1040-01): add flat pure-logic test test_notification_center_pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 18 headless assertions: StubEventStore round-trip + ack-race throw, filterUnacked_ (incl. NaN-as-unacked), sortNewestFirst_, maxSeverity_, idsOf_, order-insensitive diffIds_, badgeText_, badgeColor_ theme mapping - Uses the real 6-arg Event constructor (planning docs' 4-arg form does not construct — direction is required + validated) - All 18 tests pass; MISS_HIT clean; no UI primitives instantiated Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_notification_center_pane.m | 98 +++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 tests/test_notification_center_pane.m diff --git a/tests/test_notification_center_pane.m b/tests/test_notification_center_pane.m new file mode 100644 index 00000000..2ff007ea --- /dev/null +++ b/tests/test_notification_center_pane.m @@ -0,0 +1,98 @@ +function test_notification_center_pane() +%TEST_NOTIFICATION_CENTER_PANE Pure-logic tests for the Companion notification inbox. +% Headless, Octave-tolerant. Exercises the StubEventStore test double and the +% NotificationCenterPane static helpers (filter/sort/diff/badge) with no +% uifigure — every assertion is a millisecond data transform. +% +% NOTE: the real Event constructor takes six args +% Event(startTime, endTime, sensorName, thresholdLabel, thresholdValue, direction) +% with direction validated against {'upper','lower'} — the planning docs' +% 4-arg shorthand does not construct, so the full signature is used here. +% +% See also NotificationCenterPane, StubEventStore, Event, EventStore. + + add_companion_path(); + n = 0; + + % --- Fixtures: e1 (sev3, t=30), e2 (sev2, t=20) unacked; e3 (sev1, t=10) acked --- + e1 = Event(30, NaN, 'P-101', 'HighPressure', 100, 'upper'); + e1.Id = 'evt_1'; e1.Severity = 3; e1.IsOpen = true; + e2 = Event(20, NaN, 'T-200', 'Overtemp', 80, 'upper'); + e2.Id = 'evt_2'; e2.Severity = 2; + e3 = Event(10, 15, 'F-300', 'LowFlow', 5, 'lower'); + e3.Id = 'evt_3'; e3.Severity = 1; e3.AckedAt = now; + + % 1. filterUnacked_ drops the acked event, preserves order. + unacked = NotificationCenterPane.filterUnacked_([e1 e2 e3]); + n = n + 1; check(numel(unacked) == 2, 'filterUnacked_ should keep 2 unacked events'); + n = n + 1; check(isequal(NotificationCenterPane.idsOf_(unacked), {'evt_1', 'evt_2'}), ... + 'filterUnacked_ should keep evt_1 and evt_2'); + + % 2. sortNewestFirst_ orders by StartTime descending (feed an unsorted array). + sorted = NotificationCenterPane.sortNewestFirst_([e2 e3 e1]); + n = n + 1; check(isequal(NotificationCenterPane.idsOf_(sorted), {'evt_1', 'evt_2', 'evt_3'}), ... + 'sortNewestFirst_ should order evt_1 (t=30), evt_2 (t=20), evt_3 (t=10)'); + n = n + 1; check(strcmp(sorted(1).Id, 'evt_1'), 'sortNewestFirst_(1) should be the newest (evt_1)'); + + % 3. An AckedAt = NaN event is treated as UNACKED. + e4 = Event(40, NaN, 'V-400', 'Vibration', 9, 'upper'); + e4.Id = 'evt_4'; e4.Severity = 2; e4.AckedAt = NaN; + keptNaN = NotificationCenterPane.filterUnacked_([e3 e4]); + n = n + 1; check(numel(keptNaN) == 1 && strcmp(keptNaN(1).Id, 'evt_4'), ... + 'filterUnacked_ should keep an AckedAt=NaN event and drop the acked one'); + + % 4. maxSeverity_. + n = n + 1; check(NotificationCenterPane.maxSeverity_([e1 e2]) == 3, 'maxSeverity_([3 2]) should be 3'); + n = n + 1; check(NotificationCenterPane.maxSeverity_(Event.empty) == 0, 'maxSeverity_(empty) should be 0'); + + % 5. diffIds_ — order-insensitive set comparison. + n = n + 1; check(~NotificationCenterPane.diffIds_({'a', 'b'}, {'b', 'a'}), ... + 'diffIds_ should be false for a reordered identical set'); + n = n + 1; check(NotificationCenterPane.diffIds_({'a', 'b'}, {'a'}), ... + 'diffIds_ should be true when an id is added'); + n = n + 1; check(~NotificationCenterPane.diffIds_({}, {}), 'diffIds_({},{}) should be false'); + + % 6. badgeText_. + n = n + 1; check(strcmp(NotificationCenterPane.badgeText_(0, 'B'), 'B'), 'badgeText_(0) is the plain glyph'); + n = n + 1; check(strcmp(NotificationCenterPane.badgeText_(5, 'B'), 'B (5)'), 'badgeText_(5) is "B (5)"'); + + % 7. badgeColor_ maps severity to theme tokens. + theme = CompanionTheme.get('dark'); + n = n + 1; check(isequal(NotificationCenterPane.badgeColor_(3, theme), theme.StatusAlarmColor), ... + 'badgeColor_(3) should be StatusAlarmColor'); + n = n + 1; check(isequal(NotificationCenterPane.badgeColor_(2, theme), theme.StatusWarnColor), ... + 'badgeColor_(2) should be StatusWarnColor'); + n = n + 1; check(isequal(NotificationCenterPane.badgeColor_(1, theme), theme.Accent), ... + 'badgeColor_(1) should be Accent'); + + % 8. StubEventStore round-trip + ack-race throw. + s = StubEventStore; + s.Events_ = [e1 e2 e3]; + s.acknowledgeEvent('evt_2', struct('comment', '')); + n = n + 1; check(isequal(s.AckedIds_, {'evt_2'}), 'stub should record the acked id'); + n = n + 1; check(~isempty(s.Events_(2).AckedAt), 'stub should mutate the matching Event AckedAt'); + + s.ThrowOnAck_ = true; + threw = false; + try + s.acknowledgeEvent('evt_2', struct('comment', '')); + catch ME + threw = strcmp(ME.identifier, 'EventStore:unknownEventId'); + end + n = n + 1; check(threw, 'stub with ThrowOnAck_ should raise EventStore:unknownEventId'); + + fprintf(' All %d tests passed.\n', n); +end + +function add_companion_path() +%ADD_COMPANION_PATH Add repo root to path and run install() to wire all libs. + addpath(fullfile(fileparts(mfilename('fullpath')), '..')); + install(); +end + +function check(cond, msg) +%CHECK Assert cond is true; error with msg otherwise. + if ~cond + error('test_notification_center_pane:failed', '%s', msg); + end +end From 1b99701b16c73a08209a6321e7adc1de00fc0e66 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 2 Jun 2026 10:40:49 +0200 Subject: [PATCH 07/20] docs(1040-01): complete test-foundation plan - StubEventStore + NotificationCenterPane pure-logic core + flat test (18/18 green) - SUMMARY records the Event 6-arg constructor correction (carries to Plans 02/04) Co-Authored-By: Claude Opus 4.8 (1M context) --- .planning/ROADMAP.md | 4 +- .planning/STATE.md | 22 ++--- .../1040-01-SUMMARY.md | 99 +++++++++++++++++++ 3 files changed, 112 insertions(+), 13 deletions(-) create mode 100644 .planning/phases/1040-companion-notification-center/1040-01-SUMMARY.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index de4c6cb8..4ab25588 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -431,10 +431,10 @@ Plans: **Goal:** Add an acknowledgeable in-app notification inbox to `FastSenseCompanion` — a collapsible right-hand `NotificationCenterPane` (toggled by a toolbar bell + unacked-count badge) that live-lists unacknowledged threshold-violation events from the shared `EventStore` and lets operators acknowledge them (dismiss = `EventStore.acknowledgeEvent`, shared + audited). Predominantly a new UI surface over existing event + acknowledge infrastructure. **Requirements**: none mapped — 1040-CONTEXT.md locked decisions + the phase GOAL are the contract (must_haves derived in each PLAN) **Depends on:** Phase 1039 -**Plans:** 4 plans in 4 waves +**Plans:** 1/4 plans executed Plans: -- [ ] 1040-01-test-foundation-PLAN.md (Wave 1) — StubEventStore double + NotificationCenterPane static pure-logic helpers + flat test +- [x] 1040-01-test-foundation-PLAN.md (Wave 1) — StubEventStore double + NotificationCenterPane static pure-logic helpers + flat test - [ ] 1040-02-notification-pane-PLAN.md (Wave 2, depends 01) — full detachable inbox pane (attach/detach/refresh/ack/filter/stale/theme) + TestNotificationCenterPane - [ ] 1040-03-companion-integration-PLAN.md (Wave 3, depends 02) — Companion 4th-column grid + toolbar bell+badge + onLiveTick_ refresh hook + detach wiring - [ ] 1040-04-companion-tests-verify-PLAN.md (Wave 4, depends 03) — TestFastSenseCompanion toolbar-col updates + 9 integration tests + full-suite gate + human live-verify diff --git a/.planning/STATE.md b/.planning/STATE.md index e7935fd6..7279eb7f 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,14 +2,14 @@ gsd_state_version: 1.0 milestone: v4.0 milestone_name: Multi-User LAN Concurrency -status: "Phase 1028 closed. WithIO `tickMin` reduced 4497 ms → 3603 ms (−19.9%) on Octave Linux x86_64 CI run 26089658442, almost entirely from Plan 02d's in-memory prior-state cache. Plan 06 ships per-tick fs-stat coalescing reducing 1600 → 1 syscalls/tick (−99.94% mechanism-level; wall-time +3.2% within variance on tmpfs CI). PR #114 carries the phase. Follow-up candidates for a future perf phase: in-memory propagation refactor; `containers.Map` → struct-array refactor; `.mat` save-side optimization. K2/K3/K4 deferred per data (target regions bucket as 0 ms post-cache)." -last_updated: "2026-06-02T06:59:42.567Z" -last_activity: "2026-05-29 - Completed 260529-fnt (via /gsd:fast): FunctionTransport adapter — reuse an external/company MATLAB mailer as a NotificationService Transport, no SMTP config" +status: executing +last_updated: "2026-06-02T08:40:36.900Z" +last_activity: 2026-06-02 progress: - total_phases: 15 + total_phases: 16 completed_phases: 3 - total_plans: 16 - completed_plans: 35 + total_plans: 20 + completed_plans: 36 --- # State @@ -19,15 +19,15 @@ progress: See: .planning/PROJECT.md (updated 2026-05-13) **Core value:** A MATLAB engineer can ingest a million-sample sensor stream, monitor thresholds, build sub-second-responsive dashboards, and navigate it all from a single Companion app — without leaving MATLAB and without external toolboxes. -**Current focus:** Phase 1029 — Concurrency Foundation +**Current focus:** Phase 1040 — companion-notification-center ## Current Position -Phase: 1028 (tag-update-perf-mex-simd) — COMPLETE 2026-05-19 (this branch) -Plan: 6 of 6 executed (with 03/04 deferred per Plan 02d data). Shipped plans: 01, 02, 02b, 02d, 05, 06. +Phase: 1040 (companion-notification-center) — EXECUTING +Plan: 2 of 4 Milestone: v3.0 FastSense Companion — SHIPPED 2026-04-30; v4.0 Multi-User LAN Concurrency — shipping via PR #152 (parallel branch); v1.0 perf line tracks phase 1028 — now COMPLETE via PR #114. -Status: Phase 1028 closed. WithIO `tickMin` reduced 4497 ms → 3603 ms (−19.9%) on Octave Linux x86_64 CI run 26089658442, almost entirely from Plan 02d's in-memory prior-state cache. Plan 06 ships per-tick fs-stat coalescing reducing 1600 → 1 syscalls/tick (−99.94% mechanism-level; wall-time +3.2% within variance on tmpfs CI). PR #114 carries the phase. Follow-up candidates for a future perf phase: in-memory propagation refactor; `containers.Map` → struct-array refactor; `.mat` save-side optimization. K2/K3/K4 deferred per data (target regions bucket as 0 ms post-cache). -Last activity: 2026-05-29 - Completed 260529-fnt (via /gsd:fast): FunctionTransport adapter — reuse an external/company MATLAB mailer as a NotificationService Transport, no SMTP config +Status: Ready to execute +Last activity: 2026-06-02 ### Note on parallel v4.0 work (main branch state) diff --git a/.planning/phases/1040-companion-notification-center/1040-01-SUMMARY.md b/.planning/phases/1040-companion-notification-center/1040-01-SUMMARY.md new file mode 100644 index 00000000..2cad5f90 --- /dev/null +++ b/.planning/phases/1040-companion-notification-center/1040-01-SUMMARY.md @@ -0,0 +1,99 @@ +--- +phase: 1040-companion-notification-center +plan: 01 +subsystem: testing +tags: [matlab, notification-center, eventstore, test-double, pure-logic, tdd] + +requires: + - phase: 1032-ack-events + provides: EventStore.acknowledgeEvent + Event.AckedAt (unacked filter key) +provides: + - StubEventStore test double (getEvents/numEvents/acknowledgeEvent + ThrowOnGet_/ThrowOnAck_) + - NotificationCenterPane class shell + 7 static pure-logic helpers (filter/sort/diff/badge) + - Flat headless test pinning all helper + stub semantics +affects: [1040-02-notification-pane, 1040-03-companion-integration, 1040-04-companion-tests-verify] + +tech-stack: + added: [] + patterns: + - "Interface-first TDD: static pure-logic helpers as the contract before any UI" + - "Stub-with-failure-switches test double (ThrowOnGet_/ThrowOnAck_) modeled on CaptureNotificationService" + +key-files: + created: + - tests/StubEventStore.m + - libs/FastSenseCompanion/NotificationCenterPane.m + - tests/test_notification_center_pane.m + modified: [] + +key-decisions: + - "Event fixtures use the real 6-arg constructor Event(start,end,sensor,label,thresholdValue,direction); the planning docs' 4-arg shorthand does not construct (direction is required + validated against {'upper','lower'})" + - "filterUnacked_ treats both empty AND all-NaN AckedAt as unacked, mirroring Event.computeDisplayState" + - "diffIds_ is order-insensitive via sort(ids(:)) with {} guards so identical sets in any order report no change (no badge flicker)" + +patterns-established: + - "NotificationCenterPane. static call surface — the pane's pure logic is callable + testable without a uifigure" + +requirements-completed: [] + +duration: ~15 min +completed: 2026-06-02 +--- + +# Phase 1040 Plan 01: Test Foundation Summary + +**StubEventStore test double + the NotificationCenterPane pure-logic core (7 static helpers: unacked filter incl. NaN, newest-first sort, order-insensitive id-diff, severity/badge mapping), pinned by an 18-assertion headless flat test.** + +## Performance + +- **Duration:** ~15 min +- **Completed:** 2026-06-02 +- **Tasks:** 3 +- **Files modified:** 3 (all created) + +## Accomplishments +- `tests/StubEventStore.m` — fake EventStore handle with `ThrowOnGet_`/`ThrowOnAck_` switches that drive the stale-read and ack-race paths later plans need. +- `libs/FastSenseCompanion/NotificationCenterPane.m` — class shell (events block + full private property declaration) plus 7 pure static helpers; no UI primitives instantiated. +- `tests/test_notification_center_pane.m` — 18 headless assertions covering the stub round-trip + ack-race throw and all 7 helpers; runs green in milliseconds. + +## Task Commits + +1. **Task 1: StubEventStore test double** — `2b51ac88` (test) +2. **Task 2: NotificationCenterPane static pure-logic helpers** — `3a05c8c6` (feat) +3. **Task 3: flat pure-logic test** — `bd19fb85` (test) + +## Files Created/Modified +- `tests/StubEventStore.m` — `classdef StubEventStore < handle`; getEvents/numEvents/acknowledgeEvent; records acked ids + mutates AckedAt. +- `libs/FastSenseCompanion/NotificationCenterPane.m` — shell + filterUnacked_/sortNewestFirst_/maxSeverity_/idsOf_/diffIds_/badgeText_/badgeColor_. +- `tests/test_notification_center_pane.m` — flat function test (`add_companion_path` + local `check`), prints "All 18 tests passed." + +## Decisions Made +- See key-decisions frontmatter. The Event 6-arg constructor correction is the most consequential — Plans 02/04 build Event fixtures and must use the full signature. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] Event constructor requires 6 args, not the 4 shown in the plan** +- **Found during:** Task 3 (writing the flat test) +- **Issue:** The plan's `` block and Task 3 examples construct `Event(startTime, endTime, sensorName, thresholdLabel)` (4 args). The real `Event` constructor is `Event(startTime, endTime, sensorName, thresholdLabel, thresholdValue, direction)` and throws if `direction` is missing/not in `{'upper','lower'}` — the 4-arg form cannot construct. +- **Fix:** Built all test fixtures with the full 6-arg signature (e.g. `Event(30, NaN, 'P-101', 'HighPressure', 100, 'upper')`). +- **Files modified:** tests/test_notification_center_pane.m +- **Verification:** Test runs green (18/18). +- **Committed in:** bd19fb85 + +--- + +**Total deviations:** 1 auto-fixed (1 blocking). +**Impact on plan:** No scope change — only the fixture construction syntax. Carries forward to Plans 02 + 04 (their Event fixtures need the same 6-arg form). + +## Issues Encountered +None. (Note: the plan's strict "no UI primitives" grep matches doc-comment mentions of `uifigure`/`uitable` in the property block, but a call-syntax grep confirms **zero** actual UI primitive calls — pure logic only.) + +## Next Phase Readiness +- Plan 02 can extend `NotificationCenterPane` with the attach/detach/refresh/ack lifecycle on top of the static helpers; `StubEventStore` is ready to drive the headless suite. +- No blockers. + +--- +*Phase: 1040-companion-notification-center* +*Completed: 2026-06-02* From 2187422502ead294afd3601d92019d5d24fb1036 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 2 Jun 2026 10:52:11 +0200 Subject: [PATCH 08/20] feat(1040-02): implement NotificationCenterPane inbox (lifecycle + refresh/ack) - attach/detach/applyTheme: [3 1] root (header [1 6] + bulk button + inbox uitable), filter selection preserved across detach/reattach - refresh(eventStore): unacked filter -> diff-by-Id -> newest-first -> render, capped at 200; read error keeps last-good + (stale) marker, never uialert - per-item Ack / Ack-with-comment / "Acknowledge all visible"; EventStore: unknownEventId race -> silent no-op; post-ack save() (non-fatal on failure) - empty-state 'No unacknowledged events'; LIVE tag on open events; no drawnow - Rule 3 fix: uitable property is CellSelectionCallback, not the planning docs' CellSelectionChangedFcn (matches EventViewer.m) - row-click -> Event Viewer is guarded best-effort (openEventViewer_ is private on FastSenseCompanion today) - Code Analyzer + MISS_HIT clean; attach/detach smoke passes Co-Authored-By: Claude Opus 4.8 (1M context) --- .../NotificationCenterPane.m | 546 +++++++++++++++++- 1 file changed, 530 insertions(+), 16 deletions(-) diff --git a/libs/FastSenseCompanion/NotificationCenterPane.m b/libs/FastSenseCompanion/NotificationCenterPane.m index 7943fc90..3b450cc9 100644 --- a/libs/FastSenseCompanion/NotificationCenterPane.m +++ b/libs/FastSenseCompanion/NotificationCenterPane.m @@ -14,26 +14,20 @@ % badge. The pane does NOT own a timer — FastSenseCompanion.onLiveTick_ % drives refresh (wired in Plan 03). % -% This file (Plan 01) ships only the class shell + the pure-logic static -% helpers that pin the filter / sort / diff / badge semantics. The instance -% lifecycle (attach/detach/refresh/applyTheme/ack callbacks) is added in -% Plan 02. -% -% Static helpers (pure; no UI, no EventStore calls): -% filterUnacked_ — keep events with empty/NaN AckedAt -% sortNewestFirst_ — descending StartTime order -% maxSeverity_ — highest Severity (0 for empty) -% idsOf_ — cellstr of Event.Id -% diffIds_ — order-insensitive id-set change test -% badgeText_ — bell glyph + optional " (N)" count -% badgeColor_ — severity -> CompanionTheme color token -% -% Properties: -% IsAttached (SetAccess private) — true between attach() and detach() +% Usage (called by FastSenseCompanion): +% pane = NotificationCenterPane(theme); +% pane.setCompanion(companion); +% pane.attach(parent, theme); % parent: uipanel or uifigure +% pane.refresh(companion.getEventStore()); +% pane.detach(); % UI released; last-good list preserved % % Events fired: % DetachRequested — fired when the user clicks the inline pop-out icon. % +% Static helpers (pure; no UI, no EventStore calls): +% filterUnacked_ / sortNewestFirst_ / maxSeverity_ / idsOf_ / diffIds_ / +% badgeText_ / badgeColor_ +% % See also EventsLogPane, EventStore, Event, CompanionTheme, EventGanttCanvas. events @@ -58,6 +52,526 @@ LastIds_ = {} % cellstr of LastGoodEvents_ ids (diff key) Listeners_ = {} % addlistener handles; deleted on teardown IsStale_ = false % true when the last EventStore read failed + SevFilter_ = 'All' % preserved severity-dropdown selection + SearchText_ = '' % preserved free-text filter + RowEventIds_ = {} % row -> Event.Id map for the current render + RowSevColors_ = [] % Nx3 severity dot colors (Gantt-consistent) + end + + methods (Access = public) + + function obj = NotificationCenterPane(themeStruct) + %NOTIFICATIONCENTERPANE Construct with an initial theme. UI is NOT built — call attach(). + % themeStruct — resolved CompanionTheme struct. + if nargin < 1 || ~isstruct(themeStruct) + error('NotificationCenterPane:invalidTheme', ... + 'NotificationCenterPane requires a CompanionTheme struct as first argument.'); + end + obj.ThemeStruct_ = themeStruct; + obj.IsAttached = false; + obj.LastGoodEvents_ = Event.empty; + obj.LastIds_ = {}; + obj.Listeners_ = {}; + obj.IsStale_ = false; + end + + function setCompanion(obj, companion) + %SETCOMPANION Cache the FastSenseCompanion handle (drives ack store + log routing). + obj.Companion_ = companion; + end + + function attach(obj, parent, themeStruct) + %ATTACH Build the inbox UI inside parent (uipanel or uifigure). + % Idempotent: returns early if already attached. Restores the + % preserved filter selection and repaints last-good rows. + if obj.IsAttached; return; end + if nargin >= 3 && isstruct(themeStruct) + obj.ThemeStruct_ = themeStruct; + end + if isempty(parent) || ~isvalid(parent) + error('NotificationCenterPane:invalidParent', ... + 'NotificationCenterPane.attach requires a valid uipanel or uifigure parent.'); + end + t = obj.ThemeStruct_; + + % --- Root [3 1] layout: header / bulk button / inbox table --- + obj.hRoot_ = uigridlayout(parent, [3 1]); + obj.hRoot_.RowHeight = {28, 24, '1x'}; + obj.hRoot_.ColumnWidth = {'1x'}; + obj.hRoot_.Padding = [8 4 8 4]; + obj.hRoot_.RowSpacing = 4; + obj.hRoot_.BackgroundColor = t.WidgetBackground; + + % --- Row 1: header strip [1 6] --- + gHdr = uigridlayout(obj.hRoot_, [1 6]); + gHdr.Layout.Row = 1; + gHdr.Layout.Column = 1; + gHdr.ColumnWidth = {60, '1x', 100, 120, 36, 36}; + gHdr.RowHeight = {'1x'}; + gHdr.Padding = [0 0 0 0]; + gHdr.ColumnSpacing = 8; + gHdr.BackgroundColor = t.WidgetBackground; + + hLbl = uilabel(gHdr); + hLbl.Layout.Row = 1; hLbl.Layout.Column = 1; + hLbl.Text = 'Notifications'; hLbl.FontWeight = 'bold'; hLbl.FontSize = 11; + hLbl.FontColor = t.ForegroundColor; + hLbl.HorizontalAlignment = 'left'; hLbl.VerticalAlignment = 'center'; + + obj.hSearch_ = uieditfield(gHdr, 'text'); + obj.hSearch_.Layout.Row = 1; obj.hSearch_.Layout.Column = 2; + % Placeholder is R2021a+; tolerated on R2020b. + try + obj.hSearch_.Placeholder = ['Filter notifications', char(8230)]; + catch + end + obj.hSearch_.FontSize = 11; + obj.hSearch_.Value = obj.SearchText_; % restore preserved filter + obj.hSearch_.ValueChangedFcn = @(~,~) obj.applyFilterAndRender_(); + + obj.hSevDD_ = uidropdown(gHdr); + obj.hSevDD_.Layout.Row = 1; obj.hSevDD_.Layout.Column = 3; + obj.hSevDD_.Items = {'All', 'Alarm', 'Warn', 'Info'}; + obj.hSevDD_.Value = obj.SevFilter_; % restore preserved filter + obj.hSevDD_.FontSize = 11; + obj.hSevDD_.Tooltip = 'Filter by severity'; + obj.hSevDD_.ValueChangedFcn = @(~,~) obj.applyFilterAndRender_(); + + obj.hLastUpdateLbl_ = uilabel(gHdr); + obj.hLastUpdateLbl_.Layout.Row = 1; obj.hLastUpdateLbl_.Layout.Column = 4; + obj.hLastUpdateLbl_.Text = 'Updated: --:--:--'; + obj.hLastUpdateLbl_.FontSize = 11; obj.hLastUpdateLbl_.FontName = 'Menlo'; + obj.hLastUpdateLbl_.FontColor = t.PlaceholderTextColor; + obj.hLastUpdateLbl_.HorizontalAlignment = 'right'; + obj.hLastUpdateLbl_.VerticalAlignment = 'center'; + + obj.hPopoutBtn_ = uibutton(gHdr, 'push'); + obj.hPopoutBtn_.Layout.Row = 1; obj.hPopoutBtn_.Layout.Column = 5; + obj.hPopoutBtn_.Text = char(8689); % pop-out arrow glyph + obj.hPopoutBtn_.FontSize = 14; + obj.hPopoutBtn_.Tooltip = 'Detach notification center to its own window'; + obj.hPopoutBtn_.BackgroundColor = t.WidgetBorderColor; + obj.hPopoutBtn_.FontColor = t.ForegroundColor; + obj.hPopoutBtn_.ButtonPushedFcn = @(~,~) notify(obj, 'DetachRequested'); + % Col 6 reserved/empty (keeps symmetry with EventsLogPane [1 6]). + + % --- Row 2: bulk "Acknowledge all visible" button --- + obj.hAckAllBtn_ = uibutton(obj.hRoot_, 'push'); + obj.hAckAllBtn_.Layout.Row = 2; + obj.hAckAllBtn_.Text = 'Acknowledge all visible'; + obj.hAckAllBtn_.FontSize = 11; + obj.hAckAllBtn_.BackgroundColor = t.WidgetBorderColor; % -> Accent when count > 0 + obj.hAckAllBtn_.FontColor = t.ForegroundColor; + obj.hAckAllBtn_.ButtonPushedFcn = @(~,~) obj.onAckAll_(); + + % --- Row 3: inbox uitable --- + % UI-checker fold-in: Status column is 56 px (was 55). Start stays 90 px because the + % monospace 'HH:MM:SS dd-mmm' string needs the width (UI-checker suggested 88; single + % intentional deviation, recorded in the SUMMARY). uitable row height is ~20 px platform + % default in R2020b and LineHeight is NOT settable on uitable, so no per-row override. + isDark = mean(t.DashboardBackground) < 0.5; + if isDark + stripePair = [0.13 0.13 0.13; 0.20 0.20 0.20]; + else + stripePair = [1.00 1.00 1.00; 0.94 0.94 0.94]; + end + obj.hTable_ = uitable(obj.hRoot_); + obj.hTable_.Layout.Row = 3; + obj.hTable_.ColumnName = {'', 'Sensor', 'Threshold', 'Peak', 'Start', 'Status', '', ''}; + obj.hTable_.ColumnWidth = {12, 'auto', 'auto', 70, 90, 56, 28, 36}; + obj.hTable_.ColumnEditable = false(1, 8); + obj.hTable_.RowName = {}; + obj.hTable_.FontSize = 10; + obj.hTable_.FontName = 'Menlo'; + obj.hTable_.ForegroundColor = t.ForegroundColor; + obj.hTable_.BackgroundColor = stripePair; + % NOTE: the uifigure uitable cell-selection property is CellSelectionCallback + % (matches EventViewer.m); the planning docs' CellSelectionChangedFcn does not exist. + obj.hTable_.CellSelectionCallback = @(src, ev) obj.onCellSelected_(ev); + + obj.IsAttached = true; + obj.renderTable_(); % repaint last-good rows so re-attach is non-destructive + end + + function detach(obj) + %DETACH Destroy UI handles. LastGoodEvents_/LastIds_/filter preserved. + if ~obj.IsAttached; return; end + % Capture the filter selection BEFORE nulling handles (survives reattach). + try + if ~isempty(obj.hSevDD_) && isvalid(obj.hSevDD_) + obj.SevFilter_ = obj.hSevDD_.Value; + end + if ~isempty(obj.hSearch_) && isvalid(obj.hSearch_) + obj.SearchText_ = obj.hSearch_.Value; + end + catch + % Filter capture is best-effort. + end + try + if ~isempty(obj.hRoot_) && isvalid(obj.hRoot_) + delete(obj.hRoot_); + end + catch + % Never propagate teardown errors. + end + obj.hRoot_ = []; + obj.hTable_ = []; + obj.hSevDD_ = []; + obj.hSearch_ = []; + obj.hAckAllBtn_ = []; + obj.hLastUpdateLbl_ = []; + obj.hPopoutBtn_ = []; + obj.IsAttached = false; + % LastGoodEvents_, LastIds_, SevFilter_, SearchText_ deliberately preserved. + end + + function refresh(obj, eventStore) + %REFRESH Pull unacked events, diff by Id, render newest-first. + % Driven by FastSenseCompanion.onLiveTick_ (no timer in this pane). + % On an EventStore read error: keep the last-good list + show a + % (stale) marker; never clear the inbox and never uialert. + if ~obj.IsAttached; return; end + if isempty(eventStore) || ~isvalid(eventStore) + obj.LastGoodEvents_ = Event.empty; + obj.LastIds_ = {}; + obj.IsStale_ = false; + obj.applyFilterAndRender_(); + obj.setUpdatedLabel_(datetime('now'), false); + return; + end + allEvents = Event.empty; + readOk = true; + try + allEvents = eventStore.getEvents(); + catch + readOk = false; + end + if ~readOk + % Stale path — keep last-good, mark stale, no uialert, no clear. + obj.IsStale_ = true; + obj.setUpdatedLabel_(datetime('now'), true); + if ~isempty(obj.Companion_) && isvalid(obj.Companion_) && ... + ismethod(obj.Companion_, 'addLogEntry') + try + obj.Companion_.addLogEntry('warn', ... + 'Notification center: EventStore read failed; showing last-good list.'); + catch + end + end + return; + end + obj.IsStale_ = false; + unacked = NotificationCenterPane.sortNewestFirst_( ... + NotificationCenterPane.filterUnacked_(allEvents)); + if numel(unacked) > 200 + unacked = unacked(1:200); % row cap (RESEARCH Pitfall 5) + end + newIds = NotificationCenterPane.idsOf_(unacked); + if ~NotificationCenterPane.diffIds_(newIds, obj.LastIds_) + % No change — refresh the timestamp only (no flicker; no forced redraw — Pitfall 4). + obj.setUpdatedLabel_(datetime('now'), false); + return; + end + obj.LastGoodEvents_ = unacked; + obj.LastIds_ = newIds; + obj.applyFilterAndRender_(); + obj.setUpdatedLabel_(datetime('now'), false); + end + + function applyTheme(obj, themeStruct) + %APPLYTHEME Live theme switch — restyle existing UI, re-assert pane accents. + if ~isstruct(themeStruct); return; end + obj.ThemeStruct_ = themeStruct; + if ~obj.IsAttached || isempty(obj.hRoot_) || ~isvalid(obj.hRoot_) + return; + end + try + t = themeStruct; + obj.hRoot_.BackgroundColor = t.WidgetBackground; + applyThemeToChildren_(obj.hRoot_, themeStruct); + % Re-assert pane-specific accents the generic walker overwrites. + if ~isempty(obj.hLastUpdateLbl_) && isvalid(obj.hLastUpdateLbl_) + if obj.IsStale_ + obj.hLastUpdateLbl_.FontColor = t.StatusWarnColor; + else + obj.hLastUpdateLbl_.FontColor = t.PlaceholderTextColor; + end + end + if ~isempty(obj.hPopoutBtn_) && isvalid(obj.hPopoutBtn_) + obj.hPopoutBtn_.BackgroundColor = t.WidgetBorderColor; + obj.hPopoutBtn_.FontColor = t.ForegroundColor; + end + isDark = mean(t.DashboardBackground) < 0.5; + if isDark + stripePair = [0.13 0.13 0.13; 0.20 0.20 0.20]; + else + stripePair = [1.00 1.00 1.00; 0.94 0.94 0.94]; + end + if ~isempty(obj.hTable_) && isvalid(obj.hTable_) + obj.hTable_.BackgroundColor = stripePair; + obj.hTable_.ForegroundColor = t.ForegroundColor; + end + obj.applyFilterAndRender_(); % recolor bulk button under new tokens + catch + % Theme application must never propagate errors. + end + end + + function requestDetach(obj) + %REQUESTDETACH Programmatic equivalent of clicking the pop-out icon. + notify(obj, 'DetachRequested'); + end + + function delete(obj) + %DELETE Handle destructor — detach() for safety. + try + if obj.IsAttached + obj.detach(); + end + catch + % Destructor must never throw. + end + end + + end + + methods (Access = private) + + function applyFilterAndRender_(obj) + %APPLYFILTERANDRENDER_ Apply severity + text filter to LastGoodEvents_, render, recolor. + events = obj.LastGoodEvents_; + % Severity filter (client-side; does not re-fetch). + sev = obj.SevFilter_; + if ~isempty(obj.hSevDD_) && isvalid(obj.hSevDD_) + sev = obj.hSevDD_.Value; + end + if ~isempty(events) && ~strcmp(sev, 'All') + switch sev + case 'Alarm', wanted = 3; + case 'Warn', wanted = 2; + case 'Info', wanted = 1; + otherwise, wanted = []; + end + if ~isempty(wanted) + events = events([events.Severity] == wanted); + end + end + % Free-text filter over SensorName + ThresholdLabel (case-insensitive). + qry = obj.SearchText_; + if ~isempty(obj.hSearch_) && isvalid(obj.hSearch_) + qry = obj.hSearch_.Value; + end + qry = strtrim(qry); + if ~isempty(events) && ~isempty(qry) + qLow = lower(qry); + mask = false(1, numel(events)); + for i = 1:numel(events) + line = lower([events(i).SensorName, ' ', events(i).ThresholdLabel]); + mask(i) = ~isempty(strfind(line, qLow)); %#ok + end + events = events(mask); + end + obj.renderTable_(events); + % Recolor the bulk button: Accent when something is visible, else idle. + if ~isempty(obj.hAckAllBtn_) && isvalid(obj.hAckAllBtn_) + if ~isempty(events) + obj.hAckAllBtn_.BackgroundColor = obj.ThemeStruct_.Accent; + else + obj.hAckAllBtn_.BackgroundColor = obj.ThemeStruct_.WidgetBorderColor; + end + end + end + + function renderTable_(obj, events) + %RENDERTABLE_ Paint the inbox uitable (default events = LastGoodEvents_). + if isempty(obj.hTable_) || ~isvalid(obj.hTable_); return; end + if nargin < 2 + events = obj.LastGoodEvents_; + end + if isempty(events) + % Empty-state copy LOCKED (UI-SPEC). Ack columns blank to avoid clicks. + obj.hTable_.Data = {'', '', 'No unacknowledged events', '', '', '', '', ''}; + obj.RowEventIds_ = {}; + obj.RowSevColors_ = []; + return; + end + n = numel(events); + data = cell(n, 8); + ids = cell(1, n); + sevColors = zeros(n, 3); + bullet = char(9679); % severity dot glyph (uitable can't set per-cell bg in R2020b) + for i = 1:n + ev = events(i); + data{i, 1} = bullet; + data{i, 2} = ev.SensorName; + data{i, 3} = ev.ThresholdLabel; + pk = ''; + try + if ~isempty(ev.PeakValue) + pk = sprintf('%.3g', ev.PeakValue); + end + catch + % Peak is optional; leave blank if unavailable. + end + data{i, 4} = pk; + data{i, 5} = datestr(ev.StartTime, 'HH:MM:SS dd-mmm'); %#ok + if ev.IsOpen + data{i, 6} = 'LIVE'; + else + data{i, 6} = 'closed'; + end + data{i, 7} = 'Ack'; + data{i, 8} = '...'; + ids{i} = ev.Id; + % Gantt-consistent severity color (stored; uitable limits per-cell coloring). + sevColors(i, :) = EventGanttCanvas.severityColor(ev.Severity); + end + obj.hTable_.Data = data; + obj.RowEventIds_ = ids; + obj.RowSevColors_ = sevColors; + end + + function onCellSelected_(obj, ev) + %ONCELLSELECTED_ Dispatch a table cell click: col 7 ack, col 8 ack-with-comment, else viewer. + try + if isempty(obj.LastGoodEvents_); return; end % empty-state click = no-op + if isempty(ev.Indices); return; end + r = ev.Indices(1); + cdx = ev.Indices(2); + if r < 1 || r > numel(obj.RowEventIds_); return; end + eid = obj.RowEventIds_{r}; + if cdx == 7 + obj.onAckBtn_(eid); + elseif cdx == 8 + obj.onAckWithComment_(eid); + else + % Best-effort row-click -> Event Viewer. openEventViewer_ is private on + % FastSenseCompanion today, so this is guarded: a benign row-click must + % never alert. Becomes active once a public entry point exists. + try + if ~isempty(obj.Companion_) && isvalid(obj.Companion_) && ... + ismethod(obj.Companion_, 'openEventViewer_') + obj.Companion_.openEventViewer_(); + end + catch + % Private-access or viewer errors must not disrupt selection. + end + end + catch ME + obj.alertSafe_(ME, 'Acknowledge Failed'); + end + end + + function onAckBtn_(obj, eventId) + %ONACKBTN_ One-click acknowledge; EventStore:unknownEventId race -> silent no-op. + try + obj.ackOne_(eventId); + catch ME + if strcmp(ME.identifier, 'EventStore:unknownEventId') + return; % already acked elsewhere — no-op + end + obj.alertSafe_(ME, 'Acknowledge Failed'); + end + end + + function onAckWithComment_(obj, eventId) + %ONACKWITHCOMMENT_ Prompt for a comment then acknowledge. + answer = inputdlg('Acknowledgement comment:', 'Acknowledge Event', 1, {''}); + if isempty(answer); return; end % cancel + try + obj.ackOne_(eventId, answer{1}); + catch ME + if strcmp(ME.identifier, 'EventStore:unknownEventId') + return; + end + obj.alertSafe_(ME, 'Acknowledge Failed'); + end + end + + function onAckAll_(obj) + %ONACKALL_ Acknowledge every currently-visible event; per-item race -> no-op. + try + ids = obj.RowEventIds_; + for i = 1:numel(ids) + try + obj.ackOne_(ids{i}); + catch ME + if strcmp(ME.identifier, 'EventStore:unknownEventId') + continue; % per-item race — skip + end + obj.alertSafe_(ME, 'Acknowledge Failed'); + break; + end + end + catch ME + obj.alertSafe_(ME, 'Acknowledge Failed'); + end + end + + function ackOne_(obj, eventId, comment) + %ACKONE_ Resolve the store via the Companion and acknowledge one event. + % Single-user acknowledgeEvent does not auto-save; save() after a + % successful ack so it survives a crash (save failure is non-fatal). + opts = struct('comment', ''); + if nargin > 2 && ~isempty(comment) + opts.comment = comment; + end + if isempty(obj.Companion_) || ~isvalid(obj.Companion_) + error('NotificationCenterPane:noEventStore', ... + 'No Companion/EventStore available to acknowledge the event.'); + end + store = obj.Companion_.getEventStore(); + store.acknowledgeEvent(eventId, opts); + try + store.save(); + catch + if ~isempty(obj.Companion_) && isvalid(obj.Companion_) && ... + ismethod(obj.Companion_, 'addLogEntry') + try + obj.Companion_.addLogEntry('warn', ... + 'Notification center: ack saved in memory but EventStore.save() failed.'); + catch + end + end + end + end + + function setUpdatedLabel_(obj, dt, isStale) + %SETUPDATEDLABEL_ Shared path for the "Updated:" label (normal + stale). + if isempty(obj.hLastUpdateLbl_) || ~isvalid(obj.hLastUpdateLbl_); return; end + try + if isa(dt, 'datetime') + txt = char(dt, 'HH:mm:ss'); + elseif ischar(dt) || (isstring(dt) && isscalar(dt)) + txt = char(dt); + else + txt = char(datetime('now', 'Format', 'HH:mm:ss')); + end + if isStale + obj.hLastUpdateLbl_.Text = sprintf('Updated: %s (stale)', txt); + obj.hLastUpdateLbl_.FontColor = obj.ThemeStruct_.StatusWarnColor; + else + obj.hLastUpdateLbl_.Text = sprintf('Updated: %s', txt); + obj.hLastUpdateLbl_.FontColor = obj.ThemeStruct_.PlaceholderTextColor; + end + catch + % Label update must never crash the UI. + end + end + + function alertSafe_(obj, ME, title) + %ALERTSAFE_ Non-blocking uialert resolving the figure via ancestor(hRoot_). + if nargin < 3; title = 'Notification Center'; end + try + fig = ancestor(obj.hRoot_, 'figure'); + if ~isempty(fig) && isvalid(fig) + uialert(fig, ME.message, title, 'Icon', 'error'); + end + catch + % Alert is best-effort; never throw from an error handler. + end + end + end methods (Static) From 92ba4dbd27c8a9d929dee5f97d5e1df90ec68d71 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 2 Jun 2026 10:57:24 +0200 Subject: [PATCH 09/20] test(1040-02): add TestNotificationCenterPane suite (12 headless tests) - lifecycle, detach/reattach state preservation, unacked filter + newest-first, diff-no-flicker, ack->removal, ack-race no-op, stale-on-read-error, severity filter, empty state, applyTheme, DetachRequested - adds Hidden test seams to NotificationCenterPane (lastGoodCount_, lastIdsSnapshot_, lastUpdatedText_, numVisibleRows_, setSeverityFilterForTest_, ackForTest_, tableDataForTest_) per the Phase 1028 pattern - StubEventStore gains getEventStore()->self + no-op save() so it doubles as the Companion's store resolver in ack tests - 12/12 green; Plan 01 flat test still 18/18; MISS_HIT clean Co-Authored-By: Claude Opus 4.8 (1M context) --- .../NotificationCenterPane.m | 59 +++++ tests/StubEventStore.m | 11 + tests/suite/TestNotificationCenterPane.m | 207 ++++++++++++++++++ 3 files changed, 277 insertions(+) create mode 100644 tests/suite/TestNotificationCenterPane.m diff --git a/libs/FastSenseCompanion/NotificationCenterPane.m b/libs/FastSenseCompanion/NotificationCenterPane.m index 3b450cc9..05c2d935 100644 --- a/libs/FastSenseCompanion/NotificationCenterPane.m +++ b/libs/FastSenseCompanion/NotificationCenterPane.m @@ -574,6 +574,65 @@ function alertSafe_(obj, ME, title) end + methods (Hidden) + % --- Test seams (Hidden, not public API; mirrors the Phase 1028 pattern) --- + + function n = lastGoodCount_(obj) + %LASTGOODCOUNT_ Count of the last-good unacked set (survives detach). + n = numel(obj.LastGoodEvents_); + end + + function ids = lastIdsSnapshot_(obj) + %LASTIDSSNAPSHOT_ Copy of the current diff-key id list. + ids = obj.LastIds_; + end + + function t = lastUpdatedText_(obj) + %LASTUPDATEDTEXT_ The "Updated:" label text (or '' when detached). + t = ''; + if ~isempty(obj.hLastUpdateLbl_) && isvalid(obj.hLastUpdateLbl_) + t = obj.hLastUpdateLbl_.Text; + end + end + + function n = numVisibleRows_(obj) + %NUMVISIBLEROWS_ Rendered row count, excluding the empty-state row. + n = 0; + if isempty(obj.hTable_) || ~isvalid(obj.hTable_); return; end + d = obj.hTable_.Data; + if isempty(d); return; end + nrows = size(d, 1); + if nrows == 1 && strcmp(d{1, 3}, 'No unacknowledged events') + n = 0; + else + n = nrows; + end + end + + function setSeverityFilterForTest_(obj, value) + %SETSEVERITYFILTERFORTEST_ Drive the severity dropdown + re-render. + obj.SevFilter_ = value; + if ~isempty(obj.hSevDD_) && isvalid(obj.hSevDD_) + obj.hSevDD_.Value = value; + end + obj.applyFilterAndRender_(); + end + + function ackForTest_(obj, eventId) + %ACKFORTEST_ Drive the per-item ack path without a real cell-selection event. + obj.onAckBtn_(eventId); + end + + function d = tableDataForTest_(obj) + %TABLEDATAFORTEST_ Raw uitable Data ({} when detached). + d = {}; + if ~isempty(obj.hTable_) && isvalid(obj.hTable_) + d = obj.hTable_.Data; + end + end + + end + methods (Static) function evs = filterUnacked_(allEvents) diff --git a/tests/StubEventStore.m b/tests/StubEventStore.m index 5221b137..4bb2ce3f 100644 --- a/tests/StubEventStore.m +++ b/tests/StubEventStore.m @@ -60,5 +60,16 @@ end ack = struct('eventId', eventId, 'action', 'ack'); end + + function store = getEventStore(obj) + %GETEVENTSTORE Return self, so the stub can double as the Companion's store resolver. + % NotificationCenterPane.ackOne_ calls Companion_.getEventStore(); pointing the + % pane's Companion_ at this stub routes acks straight back here. + store = obj; + end + + function save(obj) %#ok + %SAVE No-op; events live in memory. Lets ackOne_'s post-ack save() succeed. + end end end diff --git a/tests/suite/TestNotificationCenterPane.m b/tests/suite/TestNotificationCenterPane.m new file mode 100644 index 00000000..ed9e8a2a --- /dev/null +++ b/tests/suite/TestNotificationCenterPane.m @@ -0,0 +1,207 @@ +classdef TestNotificationCenterPane < matlab.unittest.TestCase +%TESTNOTIFICATIONCENTERPANE Class-based tests for the Companion notification inbox (Phase 1040). +% Covers attach/detach lifecycle, detach/reattach state preservation, the +% unacked refresh + diff-by-Id, ack -> removal, ack-race no-op, the stale +% read-error guard, severity filtering, the empty state, applyTheme, and the +% DetachRequested event — all headless via uifigure('Visible','off'). +% +% MATLAB-only — Octave skipped (uifigure unavailable). Event fixtures use the +% real 6-arg Event constructor (the planning docs' 4-arg form does not +% construct — direction is required). +% +% See also NotificationCenterPane, StubEventStore, TestEventsLogPane, run_all_tests. + + methods (TestClassSetup) + function addPaths(~) + addpath(fullfile(fileparts(mfilename('fullpath')), '..', '..')); + install(); + end + end + + methods (TestMethodSetup) + function skipOnOctave(testCase) + testCase.assumeFalse( ... + exist('OCTAVE_VERSION', 'builtin') ~= 0, ... + 'TestNotificationCenterPane: skipped on Octave (uifigure not available)'); + end + end + + methods (Test) + + function testConstructDetached(testCase) + %TESTCONSTRUCTDETACHED Construction must NOT attach. + p = NotificationCenterPane(CompanionTheme.get('dark')); + testCase.addTeardown(@() delete(p)); + testCase.verifyFalse(p.IsAttached, 'pane should be detached after construction'); + end + + function testAttachBuildsTable(testCase) + %TESTATTACHBUILDSTABLE attach builds the inbox uitable + sets IsAttached. + [p, hFig] = testCase.makePane_('dark'); + testCase.verifyTrue(p.IsAttached, 'attach should set IsAttached=true'); + testCase.verifyNotEmpty(findall(hFig, 'Type', 'uitable'), ... + 'attach should build a uitable'); + end + + function testDetachReattachPreservesState(testCase) + %TESTDETACHREATTACHPRESERVESSTATE Last-good list + render survive detach/reattach. + theme = CompanionTheme.get('dark'); + f1 = uifigure('Visible', 'off'); + testCase.addTeardown(@() delete(f1)); + p = NotificationCenterPane(theme); + testCase.addTeardown(@() delete(p)); + p.attach(f1, theme); + stub = testCase.makeStub_(); + p.refresh(stub); + testCase.verifyEqual(p.lastGoodCount_(), 2, 'refresh should keep 2 unacked'); + p.detach(); + testCase.verifyFalse(p.IsAttached); + testCase.verifyEqual(p.lastGoodCount_(), 2, 'last-good list must survive detach'); + f2 = uifigure('Visible', 'off'); + testCase.addTeardown(@() delete(f2)); + p.attach(f2, theme); + testCase.verifyEqual(p.numVisibleRows_(), 2, 'reattach should repaint the 2 rows'); + end + + function testRefreshFiltersUnacked(testCase) + %TESTREFRESHFILTERSUNACKED refresh keeps only unacked, newest-first. + [p, ~] = testCase.makePane_('dark'); + stub = testCase.makeStub_(); % evt_3 is acked + p.refresh(stub); + testCase.verifyEqual(p.lastGoodCount_(), 2); + ids = p.lastIdsSnapshot_(); + testCase.verifyEqual(ids{1}, 'evt_1', 'newest (Start=30) should sort first'); + end + + function testDiffNoFlicker(testCase) + %TESTDIFFNOFLICKER Identical successive refresh leaves LastIds_ unchanged. + [p, ~] = testCase.makePane_('dark'); + stub = testCase.makeStub_(); + p.refresh(stub); + ids1 = p.lastIdsSnapshot_(); + p.refresh(stub); % identical event set + ids2 = p.lastIdsSnapshot_(); + testCase.verifyEqual(ids2, ids1, 'no-diff refresh should not change the id set'); + end + + function testAckRemovesOnNextRefresh(testCase) + %TESTACKREMOVESONNEXTREFRESH Acking an event drops it from the inbox next refresh. + [p, ~] = testCase.makePane_('dark'); + stub = testCase.makeStub_(); + p.setCompanion(stub); % stub doubles as the Companion's store resolver + p.refresh(stub); + testCase.verifyEqual(p.lastGoodCount_(), 2); + p.ackForTest_('evt_2'); + p.refresh(stub); + testCase.verifyEqual(p.lastGoodCount_(), 1, 'acked event should leave the inbox'); + ids = p.lastIdsSnapshot_(); + testCase.verifyFalse(any(strcmp(ids, 'evt_2')), 'evt_2 should be gone'); + end + + function testAckRaceIsNoOp(testCase) + %TESTACKRACEISNOOP An already-acked event (unknownEventId) is a silent no-op. + [p, ~] = testCase.makePane_('dark'); + stub = testCase.makeStub_(); + stub.ThrowOnAck_ = true; + p.setCompanion(stub); + p.refresh(stub); + testCase.verifyEqual(p.lastGoodCount_(), 2); + % Must NOT throw — onAckBtn_ swallows EventStore:unknownEventId. + p.ackForTest_('evt_2'); + testCase.verifyEqual(p.lastGoodCount_(), 2, 'inbox unchanged after race no-op'); + end + + function testStaleOnReadError(testCase) + %TESTSTALEONREADERROR getEvents failure keeps last-good + shows a (stale) marker. + [p, ~] = testCase.makePane_('dark'); + stub = testCase.makeStub_(); + p.refresh(stub); + testCase.verifyEqual(p.lastGoodCount_(), 2); + stub.ThrowOnGet_ = true; + p.refresh(stub); + testCase.verifyEqual(p.lastGoodCount_(), 2, 'last-good list preserved on read error'); + testCase.verifyTrue(contains(p.lastUpdatedText_(), '(stale)'), ... + 'Updated label should show the (stale) marker'); + end + + function testSeverityFilter(testCase) + %TESTSEVERITYFILTER Severity dropdown narrows the visible rows client-side. + [p, ~] = testCase.makePane_('dark'); + stub = testCase.makeStub_(); % unacked: evt_1 (sev3), evt_2 (sev2) + p.refresh(stub); + testCase.verifyEqual(p.numVisibleRows_(), 2); + p.setSeverityFilterForTest_('Alarm'); + testCase.verifyEqual(p.numVisibleRows_(), 1, 'only the sev-3 event should remain'); + end + + function testEmptyState(testCase) + %TESTEMPTYSTATE Clearing all events renders the locked empty-state row. + [p, ~] = testCase.makePane_('dark'); + stub = testCase.makeStub_(); + p.refresh(stub); + testCase.verifyEqual(p.numVisibleRows_(), 2); + stub.Events_ = Event.empty; + p.refresh(stub); + testCase.verifyEqual(p.numVisibleRows_(), 0); + d = p.tableDataForTest_(); + testCase.verifyEqual(d, {'', '', 'No unacknowledged events', '', '', '', '', ''}, ... + 'empty state copy must match the locked string'); + end + + function testApplyThemeReassertsOverrides(testCase) + %TESTAPPLYTHEMEREASSERTSOVERRIDES applyTheme(light) runs cleanly while attached. + [p, ~] = testCase.makePane_('dark'); + p.applyTheme(CompanionTheme.get('light')); + testCase.verifyTrue(p.IsAttached, 'pane should stay attached after applyTheme'); + end + + function testDetachRequestedFires(testCase) + %TESTDETACHREQUESTEDFIRES requestDetach() fires DetachRequested. + [p, ~] = testCase.makePane_('dark'); + bag = containers.Map('KeyType', 'char', 'ValueType', 'double'); + bag('hits') = 0; + lh = addlistener(p, 'DetachRequested', @(~,~) bumpBag(bag)); + testCase.addTeardown(@() delete(lh)); + p.requestDetach(); + testCase.verifyEqual(bag('hits'), 1, 'DetachRequested should fire once per requestDetach'); + end + + end + + methods (Access = private) + function [p, hFig] = makePane_(testCase, themeName) + %MAKEPANE_ Build a hidden uifigure + attached NotificationCenterPane. + if nargin < 2; themeName = 'dark'; end + theme = CompanionTheme.get(themeName); + hFig = uifigure('Visible', 'off'); + testCase.addTeardown(@() delete(hFig)); + p = NotificationCenterPane(theme); + testCase.addTeardown(@() delete(p)); + p.attach(hFig, theme); + end + + function evs = makeEvents_(~) + %MAKEEVENTS_ Fixed Event array: evt_1 (sev3, t=30, open), evt_2 (sev2, t=20), evt_3 (sev1, acked). + e1 = Event(30, NaN, 'P-101', 'HighPressure', 100, 'upper'); + e1.Id = 'evt_1'; e1.Severity = 3; e1.IsOpen = true; + e2 = Event(20, NaN, 'T-200', 'Overtemp', 80, 'upper'); + e2.Id = 'evt_2'; e2.Severity = 2; + e3 = Event(10, 15, 'F-300', 'LowFlow', 5, 'lower'); + % Non-empty numeric datenum marks evt_3 acked; literal keeps the fixture deterministic. + e3.Id = 'evt_3'; e3.Severity = 1; e3.AckedAt = 737000; + evs = [e1 e2 e3]; + end + + function s = makeStub_(testCase) + %MAKESTUB_ A StubEventStore preloaded with makeEvents_ (2 unacked + 1 acked). + s = StubEventStore; + s.Events_ = testCase.makeEvents_(); + end + end +end + +% --------------------------------------------------------------------------- +function bumpBag(b) +%BUMPBAG Increment 'hits' in the given containers.Map (closures can't mutate locals). + b('hits') = b('hits') + 1; +end From f072daf7fd4d4ea5937ee16113d2c684ff006e27 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 2 Jun 2026 10:58:14 +0200 Subject: [PATCH 10/20] docs(1040-02): complete notification-pane plan - Full NotificationCenterPane inbox + 12-test suite (all green) - SUMMARY records the CellSelectionCallback fix + best-effort row->viewer Co-Authored-By: Claude Opus 4.8 (1M context) --- .planning/ROADMAP.md | 4 +- .planning/STATE.md | 6 +- .../1040-02-SUMMARY.md | 118 ++++++++++++++++++ 3 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 .planning/phases/1040-companion-notification-center/1040-02-SUMMARY.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 4ab25588..36bbac4d 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -431,10 +431,10 @@ Plans: **Goal:** Add an acknowledgeable in-app notification inbox to `FastSenseCompanion` — a collapsible right-hand `NotificationCenterPane` (toggled by a toolbar bell + unacked-count badge) that live-lists unacknowledged threshold-violation events from the shared `EventStore` and lets operators acknowledge them (dismiss = `EventStore.acknowledgeEvent`, shared + audited). Predominantly a new UI surface over existing event + acknowledge infrastructure. **Requirements**: none mapped — 1040-CONTEXT.md locked decisions + the phase GOAL are the contract (must_haves derived in each PLAN) **Depends on:** Phase 1039 -**Plans:** 1/4 plans executed +**Plans:** 2/4 plans executed Plans: - [x] 1040-01-test-foundation-PLAN.md (Wave 1) — StubEventStore double + NotificationCenterPane static pure-logic helpers + flat test -- [ ] 1040-02-notification-pane-PLAN.md (Wave 2, depends 01) — full detachable inbox pane (attach/detach/refresh/ack/filter/stale/theme) + TestNotificationCenterPane +- [x] 1040-02-notification-pane-PLAN.md (Wave 2, depends 01) — full detachable inbox pane (attach/detach/refresh/ack/filter/stale/theme) + TestNotificationCenterPane - [ ] 1040-03-companion-integration-PLAN.md (Wave 3, depends 02) — Companion 4th-column grid + toolbar bell+badge + onLiveTick_ refresh hook + detach wiring - [ ] 1040-04-companion-tests-verify-PLAN.md (Wave 4, depends 03) — TestFastSenseCompanion toolbar-col updates + 9 integration tests + full-suite gate + human live-verify diff --git a/.planning/STATE.md b/.planning/STATE.md index 7279eb7f..5a08d3a7 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,13 +3,13 @@ gsd_state_version: 1.0 milestone: v4.0 milestone_name: Multi-User LAN Concurrency status: executing -last_updated: "2026-06-02T08:40:36.900Z" +last_updated: "2026-06-02T08:58:14.227Z" last_activity: 2026-06-02 progress: total_phases: 16 completed_phases: 3 total_plans: 20 - completed_plans: 36 + completed_plans: 37 --- # State @@ -24,7 +24,7 @@ See: .planning/PROJECT.md (updated 2026-05-13) ## Current Position Phase: 1040 (companion-notification-center) — EXECUTING -Plan: 2 of 4 +Plan: 3 of 4 Milestone: v3.0 FastSense Companion — SHIPPED 2026-04-30; v4.0 Multi-User LAN Concurrency — shipping via PR #152 (parallel branch); v1.0 perf line tracks phase 1028 — now COMPLETE via PR #114. Status: Ready to execute Last activity: 2026-06-02 diff --git a/.planning/phases/1040-companion-notification-center/1040-02-SUMMARY.md b/.planning/phases/1040-companion-notification-center/1040-02-SUMMARY.md new file mode 100644 index 00000000..806faf56 --- /dev/null +++ b/.planning/phases/1040-companion-notification-center/1040-02-SUMMARY.md @@ -0,0 +1,118 @@ +--- +phase: 1040-companion-notification-center +plan: 02 +subsystem: ui +tags: [matlab, uifigure, notification-center, eventstore, acknowledge, uitable, headless-tests] + +requires: + - phase: 1040-01-test-foundation + provides: StubEventStore + NotificationCenterPane static helpers +provides: + - Full detachable NotificationCenterPane (attach/detach/applyTheme/refresh/ack/bulk-ack) + - Stale-on-read-error guard (keep last-good + inline (stale) marker) + - TestNotificationCenterPane headless suite (12 tests) + - Hidden test seams + StubEventStore.getEventStore/save +affects: [1040-03-companion-integration, 1040-04-companion-tests-verify] + +tech-stack: + added: [] + patterns: + - "Pane mirrors EventsLogPane contract (attach/detach/applyTheme/DetachRequested/setCompanion/delete)" + - "Companion-resolved store: ackOne_ calls Companion_.getEventStore() so the stub can double as resolver in tests" + - "Hidden test seams over loosened property access (Phase 1028 pattern)" + +key-files: + created: + - tests/suite/TestNotificationCenterPane.m + modified: + - libs/FastSenseCompanion/NotificationCenterPane.m + - tests/StubEventStore.m + +key-decisions: + - "uitable cell-selection property is CellSelectionCallback (matches EventViewer.m), NOT the planning docs' CellSelectionChangedFcn — the latter does not exist on matlab.ui.control.Table" + - "Row-click -> Event Viewer is guarded best-effort: FastSenseCompanion.openEventViewer_ is private today, so the call is wrapped to never alert; activates once a public entry exists" + - "refresh diffs by Id and returns early on no-change (no flicker, no forced redraw — Pitfall 4)" + - "Encapsulation kept: UI handles stay private; tests read state via Hidden seams rather than public properties" + +patterns-established: + - "Stale guard: read error -> IsStale_ + (stale) label suffix in StatusWarnColor, last-good list retained, no uialert" + +requirements-completed: [] + +duration: ~35 min +completed: 2026-06-02 +--- + +# Phase 1040 Plan 02: Notification Pane Summary + +**Full detachable `NotificationCenterPane` inbox — header + severity filter + bulk-ack + inbox uitable, `refresh(eventStore)` (unacked → diff-by-Id → newest-first → render, capped 200), one-click / commented / bulk acknowledge routed to `EventStore.acknowledgeEvent`, a stale-on-read-error guard, and a 12-test headless suite (all green).** + +## Performance + +- **Duration:** ~35 min +- **Completed:** 2026-06-02 +- **Tasks:** 3 (Tasks 1 & 2 committed together — see below) +- **Files modified:** 3 (1 created, 2 modified) + +## Accomplishments +- Complete pane lifecycle (attach/detach/applyTheme) mirroring `EventsLogPane`, with `[3 1]` root (header `[1 6]` + bulk button + inbox `uitable`) and filter selection preserved across detach/reattach. +- `refresh` loop: unacked filter → diff-by-Id (no flicker) → newest-first → render (200-row cap); EventStore read failure keeps the last-good list and shows an inline `(stale)` marker (no uialert). +- Acknowledge actions: one-click `Ack`, `Ack with comment…` (inputdlg), and `Acknowledge all visible`; `EventStore:unknownEventId` race is a silent no-op; post-ack `save()` is non-fatal on failure. +- `TestNotificationCenterPane` — 12 headless tests, all green; Plan 01 flat test still 18/18. + +## Task Commits + +1. **Task 1 (lifecycle) + Task 2 (refresh/ack/render)** — `21874225` (feat) — committed together: Task 2 fills the private methods declared by Task 1's lifecycle in the same file; the intermediate stub-only state is not independently meaningful. Verified by the attach/detach smoke (Task 1 gate) and the full suite (Task 2 gate). +2. **Task 3: TestNotificationCenterPane suite + Hidden seams + StubEventStore.getEventStore/save** — `92ba4dbd` (test) + +**Plan metadata:** (this SUMMARY commit) + +## Files Created/Modified +- `libs/FastSenseCompanion/NotificationCenterPane.m` — full pane (lifecycle + refresh + ack callbacks + stale guard + applyTheme + Hidden test seams). +- `tests/suite/TestNotificationCenterPane.m` — 12-test headless class suite. +- `tests/StubEventStore.m` — added `getEventStore()`→self and no-op `save()`. + +## Decisions Made +See key-decisions frontmatter. Most consequential: the `CellSelectionCallback` property correction and the best-effort row-click→viewer (private `openEventViewer_`). + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] uitable property is CellSelectionCallback, not CellSelectionChangedFcn** +- **Found during:** Task 1 (attach smoke) +- **Issue:** The plan + UI-SPEC specify `hTable_.CellSelectionChangedFcn`, which does not exist on `matlab.ui.control.Table` (runtime error "Unrecognized property 'CellSelectionChangedFcn'"). +- **Fix:** Used `CellSelectionCallback` (the actual uifigure-table property, matching `EventViewer.m:213`); the `event.Indices` dispatch (col 7 ack / 8 comment / else viewer) is unchanged. +- **Verification:** attach/detach smoke + full suite green. +- **Committed in:** 21874225 + +**2. [Rule 3 - Blocking] Row-click → Event Viewer made best-effort (openEventViewer_ is private)** +- **Found during:** Task 2 (onCellSelected_) +- **Issue:** The plan calls `obj.Companion_.openEventViewer_()`, but that method is `Access = private` on `FastSenseCompanion` (line 1821, past the private boundary at 1371) — an external class cannot call it. +- **Fix:** Wrapped the row-click→viewer branch in its own guarded try/catch so a benign row-click never alerts; it activates automatically if a public entry point is added. Ack paths (cols 7/8) are unaffected. Not a Plan 02 key_link; not exercised by Plan 04's human test. +- **Files modified:** libs/FastSenseCompanion/NotificationCenterPane.m +- **Verification:** suite green; row-click path is a safe no-op today. +- **Committed in:** 21874225 + +**3. [Rule 1 - Cleanliness] Test fixture uses a literal acked datenum + addPaths(~)** +- **Found during:** Task 3 (Code Analyzer pass) +- **Issue:** `now` in the fixture triggers a deprecation info and is non-deterministic; `addPaths(testCase) %#ok` tripped an unused-arg warning + a stale-suppression info. +- **Fix:** `e3.AckedAt = 737000;` (deterministic non-empty datenum) and `addPaths(~)`. +- **Verification:** suite green; pane Code Analyzer clean. +- **Committed in:** 92ba4dbd + +--- + +**Total deviations:** 3 auto-fixed (2 blocking, 1 cleanliness). +**Impact on plan:** No scope change. The `CellSelectionCallback` fix is essential (the planned property does not exist). Row→viewer is a documented best-effort limitation pending a public Companion entry point. + +## Issues Encountered +- The pane's `refresh` no-diff path originally contained the literal word "drawnow" in a comment, tripping the Pitfall-4 `grep -c drawnow == 0` check; reworded to "no forced redraw". No actual `drawnow` exists in the pane. + +## Next Phase Readiness +- Plan 03 can instantiate + attach `NotificationCenterPane` in `FastSenseCompanion`, add the bell + 4th column, and wire `onLiveTick_` refresh. The pane's public contract (`setCompanion`, `attach`, `detach`, `refresh`, `applyTheme`, `requestDetach`, `DetachRequested`, `IsAttached`) + static badge helpers are ready. +- **Carry-forward for Plan 04:** if row-click→viewer is desired, Plan 03 could expose a public Companion shim for `openEventViewer_`. + +--- +*Phase: 1040-companion-notification-center* +*Completed: 2026-06-02* From 2004e30445177dc93a89a5b45a2010a64d6aa61d Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 2 Jun 2026 11:08:05 +0200 Subject: [PATCH 11/20] feat(1040-03): wire NotificationCenterPane into FastSenseCompanion (bell + 4th column) - root grid [3 3]->[3 4] (col 4 hidden at 0); toolbar [1 9]->[1 10]; bell at col 8 (gear shifted 9->10); hNotifPanel_ at Row 2 Col 4 - bell disabled with no EventStore (mirrors hEventsBtn_), bellGlyph_ ASCII fallback for headless/Windows - NotifPane_ instantiated + attached (Visible=off, width 0); DetachRequested listener registered in build + setProject; close() teardown - toggleNotificationCenter_ (public) shows/hides col 4 with one drawnow; setNotifDetached_ pops out to a 420x600 uifigure + re-inlines; updateBellBadge_ reflects unacked count + max-severity color (read-error tolerant) - applyTheme propagates to the pane + recolors the badge - Hidden seams: getRootColumnWidthForTest_, onLiveTickForTest_, notifPaneForTest_ - no NEW Code Analyzer issues (15 pre-existing, confirmed against HEAD); MISS_HIT clean; Task 1 + Task 2 smokes pass Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/FastSenseCompanion/FastSenseCompanion.m | 217 ++++++++++++++++++- 1 file changed, 207 insertions(+), 10 deletions(-) diff --git a/libs/FastSenseCompanion/FastSenseCompanion.m b/libs/FastSenseCompanion/FastSenseCompanion.m index e86e149b..68b462ce 100644 --- a/libs/FastSenseCompanion/FastSenseCompanion.m +++ b/libs/FastSenseCompanion/FastSenseCompanion.m @@ -122,6 +122,11 @@ % Phase 1034 — Wiki button + shared WikiBrowser handle. hWikiBtn_ = [] % toolbar uibutton: Wiki / Help launch WikiBrowser_ = [] % shared WikiBrowser handle (or []) + % Phase 1040 — Companion Notification Center (bell + collapsible 4th column). + hBellBtn_ = [] % toolbar bell uibutton with unacked-count badge + hNotifPanel_ = [] % uipanel hosting NotifPane_ (root grid Row 2, Col 4) + NotifPane_ = [] % NotificationCenterPane instance + hDetachedNotifFig_ = [] % uifigure when the notification pane is detached, else [] end methods (Access = public) @@ -296,8 +301,8 @@ obj.hFig_.Color = obj.Theme_.DashboardBackground; % Step 8 — Root grid (3 rows: top toolbar = 32 px, panes = 1x, log strip = 360 px) - obj.hLayout_ = uigridlayout(obj.hFig_, [3 3]); - obj.hLayout_.ColumnWidth = {220, '1x', 360}; + obj.hLayout_ = uigridlayout(obj.hFig_, [3 4]); + obj.hLayout_.ColumnWidth = {220, '1x', 360, 0}; % Phase 1040: col 4 = notification center (hidden until bell toggles) obj.hLayout_.RowHeight = {32, '1x', 360}; obj.hLayout_.Padding = [24 24 24 24]; obj.hLayout_.ColumnSpacing = 16; @@ -307,7 +312,7 @@ % Step 9a — Top toolbar panel (row 1, spans all 3 columns). obj.hToolbarPanel_ = uipanel(obj.hLayout_); obj.hToolbarPanel_.Layout.Row = 1; - obj.hToolbarPanel_.Layout.Column = [1 3]; + obj.hToolbarPanel_.Layout.Column = [1 4]; obj.hToolbarPanel_.BorderType = 'none'; obj.hToolbarPanel_.BackgroundColor = obj.Theme_.WidgetBackground; % Inner 1x9 grid (v3.1 Plant Log + v4.0 Wiki Browser merged): @@ -318,10 +323,11 @@ % col 5 = Tile windows (v4.0 S0Y-01) ( 70) % col 6 = Close all (v4.0 S0Y-02) ( 90) % col 7 = Wiki / Help launch (v4.0 Phase 1034) ( 70) - % col 8 = flex spacer ('1x') - % col 9 = Settings gear ( 36) - hToolbarGrid = uigridlayout(obj.hToolbarPanel_, [1 9]); - hToolbarGrid.ColumnWidth = {110, 110, 110, 130, 70, 90, 70, '1x', 36}; + % col 8 = Notification center bell (Phase 1040) ( 70) + % col 9 = flex spacer ('1x') + % col 10 = Settings gear ( 36) + hToolbarGrid = uigridlayout(obj.hToolbarPanel_, [1 10]); + hToolbarGrid.ColumnWidth = {110, 110, 110, 130, 70, 90, 70, 70, '1x', 36}; hToolbarGrid.RowHeight = {'1x'}; hToolbarGrid.Padding = [4 0 4 0]; hToolbarGrid.ColumnSpacing = 8; @@ -417,10 +423,27 @@ obj.hWikiBtn_.FontColor = obj.Theme_.ForegroundColor; obj.hWikiBtn_.ButtonPushedFcn = @(~,~) obj.openWiki_('Companion-Overview'); - % Col 9 — Settings gear (moved as new buttons accumulated). + % Col 8 — Phase 1040 Notification center bell with unacked-count badge. + obj.hBellBtn_ = uibutton(hToolbarGrid, 'push'); + obj.hBellBtn_.Layout.Row = 1; + obj.hBellBtn_.Layout.Column = 8; + obj.hBellBtn_.Text = obj.bellGlyph_(); + obj.hBellBtn_.FontSize = 12; + obj.hBellBtn_.FontWeight = 'bold'; + obj.hBellBtn_.Tag = 'CompanionBellBtn'; + obj.hBellBtn_.Tooltip = 'Toggle notification center'; + obj.hBellBtn_.BackgroundColor = obj.Theme_.WidgetBorderColor; + obj.hBellBtn_.FontColor = obj.Theme_.ForegroundColor; + obj.hBellBtn_.ButtonPushedFcn = @(~,~) obj.toggleNotificationCenter_(); + if isempty(obj.EventStore_) + obj.hBellBtn_.Enable = 'off'; + obj.hBellBtn_.Tooltip = 'No EventStore registered'; + end + + % Col 10 — Settings gear (Phase 1040 shifted it 9 -> 10 to make room for the bell). obj.hSettingsBtn_ = uibutton(hToolbarGrid, 'push'); obj.hSettingsBtn_.Layout.Row = 1; - obj.hSettingsBtn_.Layout.Column = 9; + obj.hSettingsBtn_.Layout.Column = 10; obj.hSettingsBtn_.Text = char(9881); % gear glyph obj.hSettingsBtn_.FontSize = 14; obj.hSettingsBtn_.Tooltip = 'Companion settings'; @@ -436,9 +459,16 @@ obj.hRightPanel_ = uipanel(obj.hLayout_); obj.hRightPanel_.Layout.Row = 2; obj.hRightPanel_.Layout.Column = 3; obj.hLogPanel_ = uipanel(obj.hLayout_); - obj.hLogPanel_.Layout.Row = 3; obj.hLogPanel_.Layout.Column = [1 3]; + obj.hLogPanel_.Layout.Row = 3; obj.hLogPanel_.Layout.Column = [1 4]; % Phase 1027.1 -- LogPaneRoot tag moves to the two sub-panels below. + % Phase 1040 — notification center panel (row 2, col 4; hidden until bell toggles). + obj.hNotifPanel_ = uipanel(obj.hLayout_); + obj.hNotifPanel_.Layout.Row = 2; + obj.hNotifPanel_.Layout.Column = 4; + obj.hNotifPanel_.BorderType = 'none'; + obj.hNotifPanel_.BackgroundColor = obj.Theme_.WidgetBackground; + % Apply panel styling from theme. uifigure-uipanel border % properties (BorderColor, BorderWidth) are R2021a+; on R2020b % they error with UnsupportedAppDesignerFunctionality even @@ -501,6 +531,15 @@ % Attach both panes inline by default. obj.setLogState_('events', 'Inline'); obj.setLogState_('live', 'Inline'); + % Phase 1040 — instantiate + attach the notification center pane. + % Attached now (Visible='off', column width 0) so the first bell toggle + % renders cleanly (RESEARCH Pitfall 3). The bell drives show/hide. + obj.NotifPane_ = NotificationCenterPane(obj.Theme_); + obj.NotifPane_.setCompanion(obj); + obj.NotifPane_.attach(obj.hNotifPanel_, obj.Theme_); + obj.Listeners_{end+1} = addlistener(obj.NotifPane_, 'DetachRequested', ... + @(~,~) obj.setNotifDetached_(true)); + obj.updateBellBadge_(); % reflect any pre-loaded events while still Visible='off' % Seed the events log with the ready line. obj.addLogEntry('info', 'Companion ready.'); @@ -659,6 +698,26 @@ function close(obj) fprintf(2, '[FastSenseCompanion] LiveLogPane cleanup failed: %s\n', err.message); end obj.LiveLogPane_ = []; + % Phase 1040 — close the detached notification window (clear its + % CloseRequestFcn first to prevent recursion), then destroy the pane. + try + if ~isempty(obj.hDetachedNotifFig_) && isvalid(obj.hDetachedNotifFig_) + obj.hDetachedNotifFig_.CloseRequestFcn = ''; + delete(obj.hDetachedNotifFig_); + end + catch err + fprintf(2, '[FastSenseCompanion] hDetachedNotifFig cleanup failed: %s\n', err.message); + end + obj.hDetachedNotifFig_ = []; + try + if ~isempty(obj.NotifPane_) && isvalid(obj.NotifPane_) + obj.NotifPane_.detach(); + delete(obj.NotifPane_); + end + catch err + fprintf(2, '[FastSenseCompanion] NotifPane cleanup failed: %s\n', err.message); + end + obj.NotifPane_ = []; % Release orchestrator-level listeners. delete(cellArray) is % interpreted as filename-delete by MATLAB ("Name must be a % text scalar"). Iterate explicitly. @@ -774,6 +833,11 @@ function setProject(obj, dashboards, registry) obj.Listeners_{end+1} = addlistener(obj.LiveLogPane_, 'DetachRequested', ... @(~,~) obj.setLogState_('live', 'Detached')); end + % Phase 1040 — re-register the notification pane's DetachRequested listener. + if ~isempty(obj.NotifPane_) && isvalid(obj.NotifPane_) + obj.Listeners_{end+1} = addlistener(obj.NotifPane_, 'DetachRequested', ... + @(~,~) obj.setNotifDetached_(true)); + end obj.applyPlaceholderColors_(); end @@ -979,6 +1043,14 @@ function applyTheme(obj, theme) if ~isempty(obj.hDetachedLiveFig_) && isvalid(obj.hDetachedLiveFig_) obj.hDetachedLiveFig_.Color = obj.Theme_.DashboardBackground; end + % Phase 1040 — notification pane manages its own theming; recolor the bell badge. + if ~isempty(obj.NotifPane_) && isvalid(obj.NotifPane_) + obj.NotifPane_.applyTheme(obj.Theme_); + end + if ~isempty(obj.hDetachedNotifFig_) && isvalid(obj.hDetachedNotifFig_) + obj.hDetachedNotifFig_.Color = obj.Theme_.DashboardBackground; + end + obj.updateBellBadge_(); obj.updateLiveButton_(); drawnow; catch err @@ -1366,6 +1438,48 @@ function trackOpenedFigureForTest_(obj, hFig) obj.trackOpenedFigure_(hFig); end + function toggleNotificationCenter_(obj) + %TOGGLENOTIFICATIONCENTER_ Show/hide the 4th root column hosting the notification pane. + % Public so the toolbar bell ButtonPushedFcn (and user scripts/tests) can + % toggle the inbox programmatically. + try + cw = obj.hLayout_.ColumnWidth; + if isnumeric(cw{4}) && isequal(cw{4}, 0) + cw{4} = 320; % show (UI-SPEC width) + obj.hLayout_.ColumnWidth = cw; + drawnow; % clean first-expand render (Pitfall 3) — OK here, not in pane.refresh + if ~isempty(obj.NotifPane_) && isvalid(obj.NotifPane_) + obj.NotifPane_.refresh(obj.getEventStore()); % populate immediately + end + obj.updateBellBadge_(); + else + cw{4} = 0; % hide + obj.hLayout_.ColumnWidth = cw; + end + catch err + if ~isempty(obj.hFig_) && isvalid(obj.hFig_) + uialert(obj.hFig_, err.message, 'Notification Center', 'Icon', 'error'); + end + end + end + + % --- Phase 1040 Notification Center test seams (Hidden, do not call from production) --- + + function cw = getRootColumnWidthForTest_(obj) + %GETROOTCOLUMNWIDTHFORTEST_ Test helper: the root grid ColumnWidth cell. + cw = obj.hLayout_.ColumnWidth; + end + + function onLiveTickForTest_(obj) + %ONLIVETICKFORTEST_ Test seam: drive one onLiveTick_ without the timer. + obj.onLiveTick_(); + end + + function p = notifPaneForTest_(obj) + %NOTIFPANEFORTEST_ Test helper: the NotificationCenterPane handle (or []). + p = obj.NotifPane_; + end + end methods (Access = private) @@ -1662,6 +1776,89 @@ function updateLiveButton_(obj) end end + function g = bellGlyph_(~) + %BELLGLYPH_ Bell emoji on a desktop; ASCII '[!]' fallback otherwise (headless/Windows). + g = '[!]'; + try + if usejava('desktop') + g = char(128276); % U+1F514 bell + end + catch + % Stay with the ASCII fallback. + end + end + + function setNotifDetached_(obj, tf) + %SETNOTIFDETACHED_ Pop the notification pane to its own uifigure (tf=true) or re-inline (false). + try + if tf + if ~isempty(obj.NotifPane_) && isvalid(obj.NotifPane_) && obj.NotifPane_.IsAttached + obj.NotifPane_.detach(); + end + newFig = uifigure( ... + 'Name', 'Notification Center — FastSenseCompanion', ... + 'Position', [0 0 420 600], ... + 'Color', obj.Theme_.DashboardBackground); + movegui(newFig, 'center'); + newFig.CloseRequestFcn = @(~,~) obj.setNotifDetached_(false); + obj.hDetachedNotifFig_ = newFig; + obj.NotifPane_.attach(newFig, obj.Theme_); + cw = obj.hLayout_.ColumnWidth; cw{4} = 0; obj.hLayout_.ColumnWidth = cw; % collapse inline col + else + if ~isempty(obj.hDetachedNotifFig_) && isvalid(obj.hDetachedNotifFig_) + obj.hDetachedNotifFig_.CloseRequestFcn = ''; + delete(obj.hDetachedNotifFig_); + end + obj.hDetachedNotifFig_ = []; + if ~isempty(obj.NotifPane_) && isvalid(obj.NotifPane_) && obj.NotifPane_.IsAttached + obj.NotifPane_.detach(); + end + obj.NotifPane_.attach(obj.hNotifPanel_, obj.Theme_); + cw = obj.hLayout_.ColumnWidth; cw{4} = 320; obj.hLayout_.ColumnWidth = cw; % show inline col + end + if ~isempty(obj.NotifPane_) && isvalid(obj.NotifPane_) + obj.NotifPane_.refresh(obj.getEventStore()); + end + obj.updateBellBadge_(); + catch err + if ~isempty(obj.hFig_) && isvalid(obj.hFig_) + uialert(obj.hFig_, err.message, 'Notification Center', 'Icon', 'error'); + end + end + end + + function updateBellBadge_(obj) + %UPDATEBELLBADGE_ Reflect unacked count + max-severity color on the bell (read-error tolerant). + try + if isempty(obj.hBellBtn_) || ~isvalid(obj.hBellBtn_); return; end + store = obj.getEventStore(); + if isempty(store) || ~isvalid(store) + obj.hBellBtn_.Text = obj.bellGlyph_(); + obj.hBellBtn_.BackgroundColor = obj.Theme_.WidgetBorderColor; + obj.hBellBtn_.FontColor = obj.Theme_.ForegroundColor; + return; + end + allEvents = Event.empty; + try + allEvents = store.getEvents(); % stale-safe; badge tolerates a read failure + catch + end + unacked = NotificationCenterPane.filterUnacked_(allEvents); + n = numel(unacked); + obj.hBellBtn_.Text = NotificationCenterPane.badgeText_(n, obj.bellGlyph_()); + if n == 0 + obj.hBellBtn_.BackgroundColor = obj.Theme_.WidgetBorderColor; + obj.hBellBtn_.FontColor = obj.Theme_.ForegroundColor; + else + obj.hBellBtn_.BackgroundColor = NotificationCenterPane.badgeColor_( ... + NotificationCenterPane.maxSeverity_(unacked), obj.Theme_); + obj.hBellBtn_.FontColor = obj.Theme_.DashboardBackground; + end + catch + % Badge update must never crash. + end + end + function onLiveTick_(obj) %ONLIVETICK_ Periodic in-place refresh: tag sample count + X range + % sparkline; or dashboard status. Uses InspectorPane.refreshLive From 7e543c32cbf7bf809228ab4fbfcf577b02314dc5 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 2 Jun 2026 11:10:42 +0200 Subject: [PATCH 12/20] feat(1040-03): refresh notification pane + bell badge in onLiveTick_ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - inserted after EventsLogPane.setLastUpdated, before the cluster block - guarded (only when pane attached) + own try/catch so it never crashes the live timer; no drawnow added here (pane.refresh stays flicker-free) - no new timer — piggybacks the existing LiveTimer_/onLiveTick_ loop - Task 3 smoke: appending a sev-3 then sev-2 event and ticking moves the badge 1 -> 2 Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/FastSenseCompanion/FastSenseCompanion.m | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/libs/FastSenseCompanion/FastSenseCompanion.m b/libs/FastSenseCompanion/FastSenseCompanion.m index 68b462ce..3d5435e6 100644 --- a/libs/FastSenseCompanion/FastSenseCompanion.m +++ b/libs/FastSenseCompanion/FastSenseCompanion.m @@ -1877,6 +1877,16 @@ function onLiveTick_(obj) if ~isempty(obj.EventsLogPane_) && isvalid(obj.EventsLogPane_) obj.EventsLogPane_.setLastUpdated(datetime('now')); end + % Phase 1040 — Notification center refresh (no new timer; piggybacks this tick). + % Only when attached; guarded so it can never crash the live timer. + if ~isempty(obj.NotifPane_) && isvalid(obj.NotifPane_) && obj.NotifPane_.IsAttached + try + obj.NotifPane_.refresh(obj.getEventStore()); + obj.updateBellBadge_(); + catch + % Must never crash the live timer. + end + end % Phase 1033 Plan 04 — cluster surfacing (dormant in single-user mode) if obj.IsClusterMode_ obj.pollClusterContention_(); From 1fa190958576fccb523ee7065bb2896ce55f6055 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 2 Jun 2026 11:16:21 +0200 Subject: [PATCH 13/20] docs(1040-03): complete companion-integration plan - Bell + collapsible 4th column + onLiveTick_ refresh wired into FastSenseCompanion - SUMMARY records the public-toggle fix + confirmation that the 2 orphan-timer test failures are pre-existing/environmental (identical on pre-1040 code) Co-Authored-By: Claude Opus 4.8 (1M context) --- .planning/ROADMAP.md | 4 +- .planning/STATE.md | 6 +- .../1040-03-SUMMARY.md | 102 ++++++++++++++++++ 3 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 .planning/phases/1040-companion-notification-center/1040-03-SUMMARY.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 36bbac4d..99c05746 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -431,10 +431,10 @@ Plans: **Goal:** Add an acknowledgeable in-app notification inbox to `FastSenseCompanion` — a collapsible right-hand `NotificationCenterPane` (toggled by a toolbar bell + unacked-count badge) that live-lists unacknowledged threshold-violation events from the shared `EventStore` and lets operators acknowledge them (dismiss = `EventStore.acknowledgeEvent`, shared + audited). Predominantly a new UI surface over existing event + acknowledge infrastructure. **Requirements**: none mapped — 1040-CONTEXT.md locked decisions + the phase GOAL are the contract (must_haves derived in each PLAN) **Depends on:** Phase 1039 -**Plans:** 2/4 plans executed +**Plans:** 3/4 plans executed Plans: - [x] 1040-01-test-foundation-PLAN.md (Wave 1) — StubEventStore double + NotificationCenterPane static pure-logic helpers + flat test - [x] 1040-02-notification-pane-PLAN.md (Wave 2, depends 01) — full detachable inbox pane (attach/detach/refresh/ack/filter/stale/theme) + TestNotificationCenterPane -- [ ] 1040-03-companion-integration-PLAN.md (Wave 3, depends 02) — Companion 4th-column grid + toolbar bell+badge + onLiveTick_ refresh hook + detach wiring +- [x] 1040-03-companion-integration-PLAN.md (Wave 3, depends 02) — Companion 4th-column grid + toolbar bell+badge + onLiveTick_ refresh hook + detach wiring - [ ] 1040-04-companion-tests-verify-PLAN.md (Wave 4, depends 03) — TestFastSenseCompanion toolbar-col updates + 9 integration tests + full-suite gate + human live-verify diff --git a/.planning/STATE.md b/.planning/STATE.md index 5a08d3a7..739acd0a 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,13 +3,13 @@ gsd_state_version: 1.0 milestone: v4.0 milestone_name: Multi-User LAN Concurrency status: executing -last_updated: "2026-06-02T08:58:14.227Z" +last_updated: "2026-06-02T09:16:21.337Z" last_activity: 2026-06-02 progress: total_phases: 16 completed_phases: 3 total_plans: 20 - completed_plans: 37 + completed_plans: 38 --- # State @@ -24,7 +24,7 @@ See: .planning/PROJECT.md (updated 2026-05-13) ## Current Position Phase: 1040 (companion-notification-center) — EXECUTING -Plan: 3 of 4 +Plan: 4 of 4 Milestone: v3.0 FastSense Companion — SHIPPED 2026-04-30; v4.0 Multi-User LAN Concurrency — shipping via PR #152 (parallel branch); v1.0 perf line tracks phase 1028 — now COMPLETE via PR #114. Status: Ready to execute Last activity: 2026-06-02 diff --git a/.planning/phases/1040-companion-notification-center/1040-03-SUMMARY.md b/.planning/phases/1040-companion-notification-center/1040-03-SUMMARY.md new file mode 100644 index 00000000..9f67729b --- /dev/null +++ b/.planning/phases/1040-companion-notification-center/1040-03-SUMMARY.md @@ -0,0 +1,102 @@ +--- +phase: 1040-companion-notification-center +plan: 03 +subsystem: ui +tags: [matlab, uifigure, fastsensecompanion, notification-center, toolbar, bell-badge, onlivetick] + +requires: + - phase: 1040-02-notification-pane + provides: NotificationCenterPane (attach/refresh/applyTheme/DetachRequested) + static badge helpers +provides: + - Toolbar bell button (col 8) with unacked-count + severity-colored badge + - Collapsible 4th root-grid column hosting the notification pane + - onLiveTick_ refresh hook (no new timer); detach-to-uifigure; applyTheme + close wiring +affects: [1040-04-companion-tests-verify] + +tech-stack: + added: [] + patterns: + - "Collapsible grid column via ColumnWidth{4} 0<->320 toggle (RESEARCH Pattern 3)" + - "Badge driven by static NotificationCenterPane helpers on the Companion side" + +key-files: + created: [] + modified: + - libs/FastSenseCompanion/FastSenseCompanion.m + +key-decisions: + - "toggleNotificationCenter_ is PUBLIC (name kept with trailing underscore per the plan's acceptance grep) so the bell callback, user scripts, and Plan 04 tests can drive it; setNotifDetached_/updateBellBadge_/bellGlyph_ stay private" + - "Bell enable/disable is set only at construction from EventStore presence (the other hEventsBtn_.Enable sites are viewer-open-state, not store presence — not mirrored)" + - "onLiveTick_ notif refresh is gated behind the existing IsLive guard (piggybacks the live loop; no new timer) — badge also updates on toggle-open, ack, and applyTheme" + +patterns-established: + - "Hidden Companion test seams: getRootColumnWidthForTest_, onLiveTickForTest_, notifPaneForTest_" + +requirements-completed: [] + +duration: ~40 min +completed: 2026-06-02 +--- + +# Phase 1040 Plan 03: Companion Integration Summary + +**`NotificationCenterPane` wired into `FastSenseCompanion`: a toolbar bell (col 8) with an unacked-count + severity-colored badge toggles a collapsible 4th root-grid column hosting the pane; `onLiveTick_` refreshes it with no new timer; detach-to-uifigure, applyTheme, and close teardown all handled.** + +## Performance + +- **Duration:** ~40 min +- **Completed:** 2026-06-02 +- **Tasks:** 3 (Tasks 1 & 2 committed together; Task 3 separate) +- **Files modified:** 1 (`FastSenseCompanion.m`) + +## Accomplishments +- Root grid `[3 3]→[3 4]` (col 4 hidden at width 0); toolbar `[1 9]→[1 10]`; bell at col 8 (gear shifted 9→10); `hNotifPanel_` at Row 2 Col 4. +- Bell disabled with no EventStore (mirrors `hEventsBtn_`); `bellGlyph_` ASCII `[!]` fallback for headless/Windows. +- `toggleNotificationCenter_` (public) shows/hides col 4 with one `drawnow`; `setNotifDetached_` pops out to a 420×600 `'Notification Center — FastSenseCompanion'` uifigure and re-inlines on close; `updateBellBadge_` reflects count + max-severity color and tolerates read failure. +- `onLiveTick_` refreshes the pane + badge (after `scanLiveTagUpdates_`/`setLastUpdated`, before the cluster block), guarded so it never crashes the timer; **no new timer**. +- `applyTheme` propagates to the pane + recolors the badge; `close()` tears down the detached window + pane; `DetachRequested` listener registered in build + `setProject`. + +## Task Commits + +1. **Task 1 (grid/toolbar/bell/panel) + Task 2 (pane wiring/toggle/detach/badge/applyTheme/close)** — `2004e304` (feat) — committed together: both edit `FastSenseCompanion.m` and Task 2 fills behavior for Task 1's structure. Verified by the Task 1 + Task 2 smokes. +2. **Task 3: onLiveTick_ refresh + badge hook** — `7e543c32` (feat) + +**Plan metadata:** (this SUMMARY commit) + +## Files Created/Modified +- `libs/FastSenseCompanion/FastSenseCompanion.m` — bell + 4th column + pane instantiation/wiring + toggle/detach/badge + onLiveTick_ hook + 3 Hidden test seams. + +## Decisions Made +See key-decisions frontmatter. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] toggleNotificationCenter_ had to be public** +- **Found during:** Task 2 smoke ("Cannot access method 'toggleNotificationCenter_'") +- **Issue:** The plan placed `toggleNotificationCenter_` as a private callback but its interfaces mark it "callable in tests" and Plan 04 calls `app.toggleNotificationCenter_()` directly (the acceptance grep also requires that exact name). +- **Fix:** Moved it to the public methods block, keeping the name (the bell callback + user scripts + tests all route here). +- **Verification:** Task 2 smoke passes (toggle shows 320 / hides 0). +- **Committed in:** 2004e304 + +--- + +**Total deviations:** 1 auto-fixed (1 blocking). +**Impact on plan:** No scope change — a method visibility correction. + +## Issues Encountered +- **Pre-existing Code Analyzer lint (15 items):** the `hp` panel-styling loop, `now` usages, an `isscalar` suggestion, and an extra semicolon all live in code this plan did not touch. Confirmed against the HEAD version of the file (identical 15 issues, line-shifted) — **zero new** Code Analyzer issues from this plan. +- **Two pre-existing environmental test failures:** `testADHOC05_noOrphanTimersAfterPlotAndClose` and `testPerTagModeSpawnsNFigures` fail `verifyEmpty(timerfindall...)` (2 lingering `singleShot` DashboardEngine render timers in headless R2025b). **Confirmed pre-existing** — both fail identically against the pre-1040 bell-less `FastSenseCompanion.m`. Recorded in the known-env-test-failures memory. + +## Regression Check (TestFastSenseCompanion, headless) +- **73 tests: 70 pass, 3 fail.** The only Plan-03-attributable failure is `testToolbarGearMovedToColumn8` (asserts gear col 9 — now 10), **expected** and fixed in Plan 04. The other two failures are the pre-existing environmental orphan-timer tests above. **No regressions introduced.** + +## Next Phase Readiness +- Plan 04 updates the two toolbar-column assertions (Wiki stays 7; gear 9→10), adds 9 integration tests, runs the full suite, and does the human visual check. +- Hidden seams `getRootColumnWidthForTest_`, `onLiveTickForTest_`, `notifPaneForTest_` + the public `toggleNotificationCenter_` are ready for Plan 04's tests. +- **Note for Plan 04 tests:** the `onLiveTick_` notif hook only fires when the companion is live — tests must `startLiveMode()` (then `stopLiveMode()`/close) to exercise it; and clean orphan timers/figures between app create/close cycles. + +--- +*Phase: 1040-companion-notification-center* +*Completed: 2026-06-02* From 1b33bc1447e8986565ad518f4c389a5c9a7a4d13 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 2 Jun 2026 11:22:50 +0200 Subject: [PATCH 14/20] test(1040-04): toolbar-column fix + 9 notification-center integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - testToolbarGearMovedToColumn8 now asserts col 10 (name preserved per 260526-tcf); testToolbarHasWikiButton stays col 7 - 9 new tests: bell at col 8, 4-col grid, toggle 320<->0, enable/disable by store, badge count + decrement on ack, sev-3 StatusAlarmColor badge, onLiveTick refresh, detach -> 'Notification Center — FastSenseCompanion' window + close teardown - onLiveTickForTest_ now forces a tick (save/restore IsLive) so badge tests are deterministic without starting a real timer - 11/11 (9 new + 2 toolbar) green in a clean session; MISS_HIT clean; no new Code Analyzer issues Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/FastSenseCompanion/FastSenseCompanion.m | 13 +- tests/suite/TestFastSenseCompanion.m | 140 ++++++++++++++++++- 2 files changed, 148 insertions(+), 5 deletions(-) diff --git a/libs/FastSenseCompanion/FastSenseCompanion.m b/libs/FastSenseCompanion/FastSenseCompanion.m index 3d5435e6..3778bc96 100644 --- a/libs/FastSenseCompanion/FastSenseCompanion.m +++ b/libs/FastSenseCompanion/FastSenseCompanion.m @@ -1471,8 +1471,17 @@ function toggleNotificationCenter_(obj) end function onLiveTickForTest_(obj) - %ONLIVETICKFORTEST_ Test seam: drive one onLiveTick_ without the timer. - obj.onLiveTick_(); + %ONLIVETICKFORTEST_ Test seam: drive one onLiveTick_ body even when not live. + % Temporarily forces IsLive so the tick runs without starting the timer, + % then restores the prior value (deterministic, timer-free tests). + prevLive = obj.IsLive; + obj.IsLive = true; + try + obj.onLiveTick_(); + catch + % onLiveTick_ has its own guard; never propagate from the seam. + end + obj.IsLive = prevLive; end function p = notifPaneForTest_(obj) diff --git a/tests/suite/TestFastSenseCompanion.m b/tests/suite/TestFastSenseCompanion.m index c8777b75..bbda11f1 100644 --- a/tests/suite/TestFastSenseCompanion.m +++ b/tests/suite/TestFastSenseCompanion.m @@ -1251,6 +1251,7 @@ function testViewerObjectBeingDestroyedClearsHandle(testCase) function testToolbarHasWikiButton(testCase) %TESTTOOLBARHASWIKIBUTTON CompanionWikiBtn exists and sits in column 7. + % Phase 1040: the bell follows at col 8; Wiki is unchanged at col 7. app = FastSenseCompanion('Theme', 'dark'); testCase.addTeardown(@() app.close()); btn = findall(app.getFigForTest_(), 'Tag', 'CompanionWikiBtn'); @@ -1263,15 +1264,17 @@ function testToolbarHasWikiButton(testCase) end function testToolbarGearMovedToColumn8(testCase) - %TESTTOOLBARGEARMOVEDTOCOLUMN8 Settings gear lives in column 8 after the Phase 1034 reflow. + %TESTTOOLBARGEARMOVEDTOCOLUMN8 Settings gear lives in column 10 after the Phase 1040 bell insert. + % Method name intentionally preserved (per STATE.md 260526-tcf / RESEARCH Pitfall 1); + % the gear shifted 9 -> 10 when the Phase 1040 bell took col 8. app = FastSenseCompanion('Theme', 'dark'); testCase.addTeardown(@() app.close()); btns = findall(app.getFigForTest_(), 'Type', 'uibutton'); found = false; for k = 1:numel(btns) if ~isempty(btns(k).Text) && strcmp(btns(k).Text, char(9881)) - testCase.verifyEqual(btns(k).Layout.Column, 9, ... - 'testToolbarGearMovedToColumn8: gear button should now sit in column 9'); + testCase.verifyEqual(btns(k).Layout.Column, 10, ... + 'testToolbarGearMovedToColumn8: gear button should now sit in column 10'); found = true; break; end @@ -1280,6 +1283,137 @@ function testToolbarGearMovedToColumn8(testCase) 'testToolbarGearMovedToColumn8: gear button not found on toolbar'); end + % ---- Phase 1040: Notification Center integration ---- + + function testBellButtonAtColumn8(testCase) + %TESTBELLBUTTONATCOLUMN8 Bell sits at toolbar column 8. + storePath = [tempname() '.mat']; + es = EventStore(storePath); testCase.addTeardown(@() delete(storePath)); + app = FastSenseCompanion('EventStore', es, 'Theme', 'dark'); + testCase.addTeardown(@() app.close()); + b = findall(app.getFigForTest_(), 'Tag', 'CompanionBellBtn'); + testCase.verifyEqual(numel(b), 1, 'expected exactly one bell button'); + testCase.verifyEqual(b.Layout.Column, 8, 'bell should sit in column 8'); + end + + function testRootGridHasFourColumns(testCase) + %TESTROOTGRIDHASFOURCOLUMNS Root grid has 4 columns; the 4th starts hidden (0). + storePath = [tempname() '.mat']; + es = EventStore(storePath); testCase.addTeardown(@() delete(storePath)); + app = FastSenseCompanion('EventStore', es); + testCase.addTeardown(@() app.close()); + cw = app.getRootColumnWidthForTest_(); + testCase.verifyEqual(numel(cw), 4, 'root grid should have 4 columns'); + testCase.verifyEqual(cw{4}, 0, 'notification column should start hidden (0)'); + end + + function testBellTogglesFourthColumn(testCase) + %TESTBELLTOGGLESFOURTHCOLUMN Toggling shows (320) then hides (0) the 4th column. + storePath = [tempname() '.mat']; + es = EventStore(storePath); testCase.addTeardown(@() delete(storePath)); + app = FastSenseCompanion('EventStore', es); + testCase.addTeardown(@() app.close()); + app.toggleNotificationCenter_(); drawnow; + cw = app.getRootColumnWidthForTest_(); + testCase.verifyEqual(cw{4}, 320, 'first toggle should show the column at 320'); + app.toggleNotificationCenter_(); drawnow; + cw = app.getRootColumnWidthForTest_(); + testCase.verifyEqual(cw{4}, 0, 'second toggle should hide the column'); + end + + function testBellDisabledWithoutEventStore(testCase) + %TESTBELLDISABLEDWITHOUTEVENTSTORE Bell disabled + tooltipped when there is no store. + TagRegistry.clear(); testCase.addTeardown(@() TagRegistry.clear()); + app = FastSenseCompanion('EventStore', []); + testCase.addTeardown(@() app.close()); + b = findall(app.getFigForTest_(), 'Tag', 'CompanionBellBtn'); + testCase.verifyTrue(strcmp(char(b.Enable), 'off'), 'bell should be disabled with no store'); + testCase.verifyEqual(b.Tooltip, 'No EventStore registered'); + end + + function testBellEnabledWithEventStore(testCase) + %TESTBELLENABLEDWITHEVENTSTORE Bell enabled when a store is present. + storePath = [tempname() '.mat']; + es = EventStore(storePath); testCase.addTeardown(@() delete(storePath)); + app = FastSenseCompanion('EventStore', es); + testCase.addTeardown(@() app.close()); + b = findall(app.getFigForTest_(), 'Tag', 'CompanionBellBtn'); + testCase.verifyTrue(strcmp(char(b.Enable), 'on'), 'bell should be enabled with a store'); + end + + function testBellBadgeReflectsUnackedCount(testCase) + %TESTBELLBADGEREFLECTSUNACKEDCOUNT Badge shows the unacked count; decrements after an ack. + storePath = [tempname() '.mat']; + es = EventStore(storePath); testCase.addTeardown(@() delete(storePath)); + e = Event(now-0.01, NaN, 'P-101', 'HighPressure', 100, 'upper'); + e.Severity = 3; e.IsOpen = true; + e2 = Event(now-0.02, now-0.015, 'T-200', 'Overtemp', 80, 'upper'); + e2.Severity = 2; + es.append([e e2]); + app = FastSenseCompanion('EventStore', es); + testCase.addTeardown(@() app.close()); + app.toggleNotificationCenter_(); + app.onLiveTickForTest_(); drawnow; + b = findall(app.getFigForTest_(), 'Tag', 'CompanionBellBtn'); + testCase.verifyTrue(~isempty(strfind(b.Text, '(2)')), ... + 'badge should show (2) for two unacked events'); %#ok + es.acknowledgeEvent(e2.Id, struct('comment', '')); + app.onLiveTickForTest_(); drawnow; + b = findall(app.getFigForTest_(), 'Tag', 'CompanionBellBtn'); + testCase.verifyTrue(~isempty(strfind(b.Text, '(1)')), ... + 'badge should show (1) after acknowledging one'); %#ok + end + + function testBellBadgeSeverityColor(testCase) + %TESTBELLBADGESEVERITYCOLOR Badge background is StatusAlarmColor when an alarm is unacked. + storePath = [tempname() '.mat']; + es = EventStore(storePath); testCase.addTeardown(@() delete(storePath)); + e = Event(now-0.01, NaN, 'P-101', 'HighPressure', 100, 'upper'); + e.Severity = 3; e.IsOpen = true; + es.append(e); + app = FastSenseCompanion('EventStore', es, 'Theme', 'dark'); + testCase.addTeardown(@() app.close()); + app.toggleNotificationCenter_(); + app.onLiveTickForTest_(); drawnow; + b = findall(app.getFigForTest_(), 'Tag', 'CompanionBellBtn'); + t = CompanionTheme.get('dark'); + testCase.verifyEqual(b.BackgroundColor, t.StatusAlarmColor, 'AbsTol', 1e-6, ... + 'sev-3 unacked badge should use StatusAlarmColor'); + end + + function testOnLiveTickRefreshesNotifPane(testCase) + %TESTONLIVETICKREFRESHESNOTIFPANE A new event after construction is picked up on the next tick. + storePath = [tempname() '.mat']; + es = EventStore(storePath); testCase.addTeardown(@() delete(storePath)); + app = FastSenseCompanion('EventStore', es); + testCase.addTeardown(@() app.close()); + app.toggleNotificationCenter_(); + app.onLiveTickForTest_(); drawnow; % no events yet -> plain glyph + e = Event(now-0.01, NaN, 'P-101', 'HighPressure', 100, 'upper'); + e.Severity = 2; + es.append(e); + app.onLiveTickForTest_(); drawnow; % tick must pick up the new event + b = findall(app.getFigForTest_(), 'Tag', 'CompanionBellBtn'); + testCase.verifyTrue(~isempty(strfind(b.Text, '(1)')), ... + 'onLiveTick_ should refresh the badge to (1) after a new event'); %#ok + end + + function testNotifPaneDetachReinline(testCase) + %TESTNOTIFPANEDETACHREINLINE DetachRequested pops a window; close() tears it down. + storePath = [tempname() '.mat']; + es = EventStore(storePath); testCase.addTeardown(@() delete(storePath)); + app = FastSenseCompanion('EventStore', es); + testCase.addTeardown(@() app.close()); + app.toggleNotificationCenter_(); drawnow; + figName = sprintf('Notification Center %s FastSenseCompanion', char(8212)); + notify(app.notifPaneForTest_(), 'DetachRequested'); drawnow; + det = findall(groot, 'Type', 'figure', 'Name', figName); + testCase.verifyNotEmpty(det, 'detach should create the notification window'); + app.close(); drawnow; + det2 = findall(groot, 'Type', 'figure', 'Name', figName); + testCase.verifyEmpty(det2, 'close() should tear down the detached window'); + end + function testOpenWikiOpensWikiBrowser(testCase) %TESTOPENWIKIOPENSWIKIBROWSER openWiki spawns a WikiBrowserRoot figure; close() tears it down. % WikiBrowser gates uifigure construction on usejava('desktop') From 04fe7c8e22495e711398b6b3a4dbce357fa1dfe7 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 2 Jun 2026 13:20:35 +0200 Subject: [PATCH 15/20] refactor(1040): relocate notification center to the Event Viewer (resizable right panel) Per user feedback at the human-verify checkpoint: the acknowledgeable inbox belongs in the Event Viewer, not as a 4th column in the main companion. FastSenseCompanion (revert Plan 03's 4th-column integration, keep the bell): - root grid back to [3 3]; toolbar/log panel spans back to [1 3] - removed hNotifPanel_/NotifPane_/hDetachedNotifFig_, toggleNotificationCenter_, setNotifDetached_, the onLiveTick pane refresh, and the column test seams - bell stays at toolbar col 8 (gear col 10) as an unacked-count + severity indicator; its click now opens the Event Viewer (openEventViewer_) - updateBellBadge_ still runs on construction, onLiveTick_, and applyTheme CompanionEventViewer (host the pane + draggable horizontal resize): - root grid [1 2] -> [1 4]: catalog | content | divider | notification panel - NotificationCenterPane attached in col 4 (setCompanion -> the companion so acks resolve the store); refreshed off the viewer's redraw/auto-tick - public NotifPaneWidth setter resizes col 4; a thin col-3 divider drags it via chained WindowButton{Down,Motion,Up}Fcn (preserves the Gantt crosshair, mirrors TimeRangeSelector's save/restore pattern) - DetachRequested pops the pane to its own window + re-inlines; close() teardown - test seams: notifPaneForTest_, rootColumnWidthForTest_ MISS_HIT clean; only new Code Analyzer warning mirrors the accepted LeftPaneWidth setter pattern. Integration smoke passes (bell opens viewer, pane lists events, NotifPaneWidth resizes the column). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../FastSenseCompanion/CompanionEventViewer.m | 188 +++++++++++++++++- libs/FastSenseCompanion/FastSenseCompanion.m | 159 ++------------- 2 files changed, 202 insertions(+), 145 deletions(-) diff --git a/libs/FastSenseCompanion/CompanionEventViewer.m b/libs/FastSenseCompanion/CompanionEventViewer.m index 2f994797..61fc8f82 100644 --- a/libs/FastSenseCompanion/CompanionEventViewer.m +++ b/libs/FastSenseCompanion/CompanionEventViewer.m @@ -55,6 +55,7 @@ properties (Access = public, AbortSet = true) LeftPaneWidth = 260 % Width of the tag-catalog pane in pixels. ViewMode = 'gantt' % 'gantt' | 'table' — which view is visible. + NotifPaneWidth = 320 % Width of the notification panel in px (Phase 1040; drag-resizable). end methods @@ -71,6 +72,21 @@ end end + function set.NotifPaneWidth(obj, val) + if ~isnumeric(val) || ~isscalar(val) || ~isfinite(val) || val < 120 + error('CompanionEventViewer:invalidNotifPaneWidth', ... + 'NotifPaneWidth must be a numeric scalar >= 120 pixels.'); + end + obj.NotifPaneWidth = val; + if ~isempty(obj.RootGrid_) && isvalid(obj.RootGrid_) + cw = obj.RootGrid_.ColumnWidth; + if numel(cw) >= 4 + cw{4} = val; + obj.RootGrid_.ColumnWidth = cw; + end + end + end + function set.ViewMode(obj, val) if ~ischar(val) && ~(isstring(val) && isscalar(val)) error('CompanionEventViewer:invalidViewMode', ... @@ -119,6 +135,15 @@ TableToolbarPanel_ = [] % uipanel above the uitable hosting the multi-select toolbar PlotSelectedBtn_ = [] % uibutton 'Plot Selected (N)' SelectedTableRows_ = [] % sorted unique vector of currently-highlighted table row indices + % Phase 1040 — notification panel (RootGrid_ col 4) + draggable divider (col 3). + NotifPane_ = [] % NotificationCenterPane instance + NotifPanel_ = [] % uipanel host for NotifPane_ + DividerPanel_ = [] % thin uipanel acting as the resize handle + IsDraggingDivider_ = false + PrevWBD_ = [] % saved WindowButtonDownFcn (chained, never clobbered) + PrevWBM_ = [] % saved WindowButtonMotionFcn (chained — preserves the crosshair) + PrevWBU_ = [] % saved WindowButtonUpFcn (chained) + hDetachedNotifFig_ = [] % uifigure when the pane is popped out, else [] end methods @@ -219,6 +244,10 @@ function refresh(obj) obj.updateTableData_(filtered); obj.updateSliderPreview_(evs); obj.updateSliderReadouts_(); + % Phase 1040 — refresh the notification inbox off the same redraw tick. + if ~isempty(obj.NotifPane_) && isvalid(obj.NotifPane_) + obj.NotifPane_.refresh(obj.Store_); + end end function c = getCanvasForTest_(obj) @@ -226,6 +255,16 @@ function refresh(obj) c = obj.Canvas_; end + function p = notifPaneForTest_(obj) + %NOTIFPANEFORTEST_ Test-only accessor: the NotificationCenterPane handle (or []). + p = obj.NotifPane_; + end + + function cw = rootColumnWidthForTest_(obj) + %ROOTCOLUMNWIDTHFORTEST_ Test-only accessor: RootGrid_ ColumnWidth cell. + cw = obj.RootGrid_.ColumnWidth; + end + function setSingleClickHandlerForTest_(obj, fn) %SETSINGLECLICKHANDLERFORTEST_ Override OnSingleClick for testing. obj.Canvas_.OnSingleClick = fn; @@ -334,6 +373,23 @@ function close(obj) catch end obj.AutoTimer_ = []; + % Phase 1040 — tear down the notification pane + any detached window. + try + if ~isempty(obj.hDetachedNotifFig_) && isvalid(obj.hDetachedNotifFig_) + obj.hDetachedNotifFig_.CloseRequestFcn = ''; + delete(obj.hDetachedNotifFig_); + end + catch + end + obj.hDetachedNotifFig_ = []; + try + if ~isempty(obj.NotifPane_) && isvalid(obj.NotifPane_) + obj.NotifPane_.detach(); + delete(obj.NotifPane_); + end + catch + end + obj.NotifPane_ = []; try if ~isempty(obj.SingleClickTimer_) && isvalid(obj.SingleClickTimer_) if strcmp(obj.SingleClickTimer_.Running, 'on'); stop(obj.SingleClickTimer_); end @@ -481,9 +537,10 @@ function buildFigure_(obj) 'CloseRequestFcn', @(~,~) obj.close(), ... 'Visible', 'on'); - % Root layout: 1x2 (left tag pane | right column). - obj.RootGrid_ = uigridlayout(obj.hFigure, [1 2]); - obj.RootGrid_.ColumnWidth = {obj.LeftPaneWidth, '1x'}; + % Root layout: 1x4 (left tag pane | content | divider | notification panel). + % Phase 1040 — col 3 is a thin draggable divider; col 4 hosts the inbox. + obj.RootGrid_ = uigridlayout(obj.hFigure, [1 4]); + obj.RootGrid_.ColumnWidth = {obj.LeftPaneWidth, '1x', 6, obj.NotifPaneWidth}; obj.RootGrid_.RowHeight = {'1x'}; obj.RootGrid_.Padding = [0 0 0 0]; obj.RootGrid_.ColumnSpacing = 0; @@ -558,6 +615,19 @@ function buildFigure_(obj) obj.RightGrid_.RowSpacing = 0; obj.RightGrid_.BackgroundColor = t.DashboardBackground; + % Phase 1040 — thin draggable divider (col 3) + notification panel (col 4). + obj.DividerPanel_ = uipanel(obj.RootGrid_); + obj.DividerPanel_.Layout.Row = 1; + obj.DividerPanel_.Layout.Column = 3; + obj.DividerPanel_.BorderType = 'none'; + obj.DividerPanel_.BackgroundColor = t.WidgetBorderColor; + + obj.NotifPanel_ = uipanel(obj.RootGrid_); + obj.NotifPanel_.Layout.Row = 1; + obj.NotifPanel_.Layout.Column = 4; + obj.NotifPanel_.BorderType = 'none'; + obj.NotifPanel_.BackgroundColor = t.WidgetBackground; + obj.FilterPanel_ = uipanel(obj.RightGrid_); obj.FilterPanel_.Layout.Row = 1; obj.FilterPanel_.Layout.Column = 1; @@ -877,6 +947,15 @@ function buildFigure_(obj) @(s, ~) obj.onCompanionLiveChanged_(s.IsLive)); obj.onCompanionLiveChanged_(obj.Companion_.IsLive); % initial sync + % Phase 1040 — host the acknowledgeable notification inbox in the right panel. + obj.NotifPane_ = NotificationCenterPane(obj.Theme_); + obj.NotifPane_.setCompanion(obj.Companion_); % ackOne_ resolves the store via the companion + obj.NotifPane_.attach(obj.NotifPanel_, obj.Theme_); + obj.Listeners_{end+1} = addlistener(obj.NotifPane_, 'DetachRequested', ... + @(~,~) obj.toggleNotifDetached_()); + obj.NotifPane_.refresh(obj.Store_); + obj.installDividerDrag_(); + obj.updateSliderReadouts_(); end @@ -896,6 +975,109 @@ function onCompanionLiveChanged_(obj, isLive) end end + function installDividerDrag_(obj) + %INSTALLDIVIDERDRAG_ Chain figure mouse handlers so the col-3 divider resizes the inbox. + % Chains (never clobbers) the existing WindowButton*Fcn — the Gantt crosshair + % owns WindowButtonMotionFcn, so each handler calls the prior one first. + if isempty(obj.hFigure) || ~isgraphics(obj.hFigure); return; end + obj.PrevWBD_ = get(obj.hFigure, 'WindowButtonDownFcn'); + obj.PrevWBM_ = get(obj.hFigure, 'WindowButtonMotionFcn'); + obj.PrevWBU_ = get(obj.hFigure, 'WindowButtonUpFcn'); + set(obj.hFigure, 'WindowButtonDownFcn', @(s, e) obj.onDividerDown_(s, e)); + set(obj.hFigure, 'WindowButtonMotionFcn', @(s, e) obj.onDividerMotion_(s, e)); + set(obj.hFigure, 'WindowButtonUpFcn', @(s, e) obj.onDividerUp_(s, e)); + end + + function onDividerDown_(obj, src, evt) + %ONDIVIDERDOWN_ Start a drag if the press landed on the divider boundary. + obj.chainCall_(obj.PrevWBD_, src, evt); + try + if isempty(obj.hFigure) || ~isgraphics(obj.hFigure); return; end + cpx = obj.hFigure.CurrentPoint(1); + dividerX = obj.hFigure.Position(3) - obj.NotifPaneWidth - 3; + if abs(cpx - dividerX) <= 8 + obj.IsDraggingDivider_ = true; + end + catch + end + end + + function onDividerMotion_(obj, src, evt) + %ONDIVIDERMOTION_ Preserve the prior handler (crosshair), then resize while dragging. + obj.chainCall_(obj.PrevWBM_, src, evt); + if ~obj.IsDraggingDivider_; return; end + try + figW = obj.hFigure.Position(3); + cpx = obj.hFigure.CurrentPoint(1); + newW = figW - cpx - 3; + maxW = max(120, figW - obj.LeftPaneWidth - 200); % keep room for the content column + obj.NotifPaneWidth = max(120, min(newW, maxW)); % setter updates ColumnWidth{4} + catch + end + end + + function onDividerUp_(obj, src, evt) + %ONDIVIDERUP_ End the divider drag; chain to the prior handler. + obj.IsDraggingDivider_ = false; + obj.chainCall_(obj.PrevWBU_, src, evt); + end + + function toggleNotifDetached_(obj) + %TOGGLENOTIFDETACHED_ Pop the inbox to its own window (or re-inline if already out). + try + if isempty(obj.hDetachedNotifFig_) || ~isvalid(obj.hDetachedNotifFig_) + if obj.NotifPane_.IsAttached; obj.NotifPane_.detach(); end + f = uifigure('Name', 'Notification Center — Event Viewer', ... + 'Position', [0 0 420 600], 'Color', obj.Theme_.DashboardBackground); + movegui(f, 'center'); + f.CloseRequestFcn = @(~,~) obj.reinlineNotif_(); + obj.hDetachedNotifFig_ = f; + obj.NotifPane_.attach(f, obj.Theme_); + obj.NotifPane_.refresh(obj.Store_); + cw = obj.RootGrid_.ColumnWidth; cw{3} = 0; cw{4} = 0; + obj.RootGrid_.ColumnWidth = cw; + else + obj.reinlineNotif_(); + end + catch err + if ~isempty(obj.hFigure) && isgraphics(obj.hFigure) + uialert(obj.hFigure, err.message, 'Notification Center', 'Icon', 'error'); + end + end + end + + function reinlineNotif_(obj) + %REINLINENOTIF_ Re-dock the inbox into the Event Viewer's right column. + try + if ~isempty(obj.hDetachedNotifFig_) && isvalid(obj.hDetachedNotifFig_) + obj.hDetachedNotifFig_.CloseRequestFcn = ''; + delete(obj.hDetachedNotifFig_); + end + obj.hDetachedNotifFig_ = []; + if ~isempty(obj.NotifPane_) && isvalid(obj.NotifPane_) + if obj.NotifPane_.IsAttached; obj.NotifPane_.detach(); end + obj.NotifPane_.attach(obj.NotifPanel_, obj.Theme_); + obj.NotifPane_.refresh(obj.Store_); + end + cw = obj.RootGrid_.ColumnWidth; cw{3} = 6; cw{4} = obj.NotifPaneWidth; + obj.RootGrid_.ColumnWidth = cw; + catch + end + end + + function chainCall_(~, fcn, src, evt) + %CHAINCALL_ Safely invoke a saved WindowButton*Fcn (function-handle/cell/empty). + if isempty(fcn); return; end + try + if iscell(fcn) + feval(fcn{1}, src, evt, fcn{2:end}); + elseif isa(fcn, 'function_handle') + fcn(src, evt); + end + catch + end + end + function startAutoTimer_(obj) %STARTAUTOTIMER_ Create and start the auto-refresh timer if not already running. try diff --git a/libs/FastSenseCompanion/FastSenseCompanion.m b/libs/FastSenseCompanion/FastSenseCompanion.m index 3778bc96..f32198e0 100644 --- a/libs/FastSenseCompanion/FastSenseCompanion.m +++ b/libs/FastSenseCompanion/FastSenseCompanion.m @@ -122,11 +122,8 @@ % Phase 1034 — Wiki button + shared WikiBrowser handle. hWikiBtn_ = [] % toolbar uibutton: Wiki / Help launch WikiBrowser_ = [] % shared WikiBrowser handle (or []) - % Phase 1040 — Companion Notification Center (bell + collapsible 4th column). + % Phase 1040 — toolbar bell: unacked-count indicator that opens the Event Viewer. hBellBtn_ = [] % toolbar bell uibutton with unacked-count badge - hNotifPanel_ = [] % uipanel hosting NotifPane_ (root grid Row 2, Col 4) - NotifPane_ = [] % NotificationCenterPane instance - hDetachedNotifFig_ = [] % uifigure when the notification pane is detached, else [] end methods (Access = public) @@ -301,8 +298,8 @@ obj.hFig_.Color = obj.Theme_.DashboardBackground; % Step 8 — Root grid (3 rows: top toolbar = 32 px, panes = 1x, log strip = 360 px) - obj.hLayout_ = uigridlayout(obj.hFig_, [3 4]); - obj.hLayout_.ColumnWidth = {220, '1x', 360, 0}; % Phase 1040: col 4 = notification center (hidden until bell toggles) + obj.hLayout_ = uigridlayout(obj.hFig_, [3 3]); + obj.hLayout_.ColumnWidth = {220, '1x', 360}; obj.hLayout_.RowHeight = {32, '1x', 360}; obj.hLayout_.Padding = [24 24 24 24]; obj.hLayout_.ColumnSpacing = 16; @@ -312,7 +309,7 @@ % Step 9a — Top toolbar panel (row 1, spans all 3 columns). obj.hToolbarPanel_ = uipanel(obj.hLayout_); obj.hToolbarPanel_.Layout.Row = 1; - obj.hToolbarPanel_.Layout.Column = [1 4]; + obj.hToolbarPanel_.Layout.Column = [1 3]; obj.hToolbarPanel_.BorderType = 'none'; obj.hToolbarPanel_.BackgroundColor = obj.Theme_.WidgetBackground; % Inner 1x9 grid (v3.1 Plant Log + v4.0 Wiki Browser merged): @@ -431,10 +428,10 @@ obj.hBellBtn_.FontSize = 12; obj.hBellBtn_.FontWeight = 'bold'; obj.hBellBtn_.Tag = 'CompanionBellBtn'; - obj.hBellBtn_.Tooltip = 'Toggle notification center'; + obj.hBellBtn_.Tooltip = 'Show notifications (opens the Event Viewer)'; obj.hBellBtn_.BackgroundColor = obj.Theme_.WidgetBorderColor; obj.hBellBtn_.FontColor = obj.Theme_.ForegroundColor; - obj.hBellBtn_.ButtonPushedFcn = @(~,~) obj.toggleNotificationCenter_(); + obj.hBellBtn_.ButtonPushedFcn = @(~,~) obj.openEventViewer_(); if isempty(obj.EventStore_) obj.hBellBtn_.Enable = 'off'; obj.hBellBtn_.Tooltip = 'No EventStore registered'; @@ -459,16 +456,9 @@ obj.hRightPanel_ = uipanel(obj.hLayout_); obj.hRightPanel_.Layout.Row = 2; obj.hRightPanel_.Layout.Column = 3; obj.hLogPanel_ = uipanel(obj.hLayout_); - obj.hLogPanel_.Layout.Row = 3; obj.hLogPanel_.Layout.Column = [1 4]; + obj.hLogPanel_.Layout.Row = 3; obj.hLogPanel_.Layout.Column = [1 3]; % Phase 1027.1 -- LogPaneRoot tag moves to the two sub-panels below. - % Phase 1040 — notification center panel (row 2, col 4; hidden until bell toggles). - obj.hNotifPanel_ = uipanel(obj.hLayout_); - obj.hNotifPanel_.Layout.Row = 2; - obj.hNotifPanel_.Layout.Column = 4; - obj.hNotifPanel_.BorderType = 'none'; - obj.hNotifPanel_.BackgroundColor = obj.Theme_.WidgetBackground; - % Apply panel styling from theme. uifigure-uipanel border % properties (BorderColor, BorderWidth) are R2021a+; on R2020b % they error with UnsupportedAppDesignerFunctionality even @@ -531,15 +521,9 @@ % Attach both panes inline by default. obj.setLogState_('events', 'Inline'); obj.setLogState_('live', 'Inline'); - % Phase 1040 — instantiate + attach the notification center pane. - % Attached now (Visible='off', column width 0) so the first bell toggle - % renders cleanly (RESEARCH Pitfall 3). The bell drives show/hide. - obj.NotifPane_ = NotificationCenterPane(obj.Theme_); - obj.NotifPane_.setCompanion(obj); - obj.NotifPane_.attach(obj.hNotifPanel_, obj.Theme_); - obj.Listeners_{end+1} = addlistener(obj.NotifPane_, 'DetachRequested', ... - @(~,~) obj.setNotifDetached_(true)); - obj.updateBellBadge_(); % reflect any pre-loaded events while still Visible='off' + % Phase 1040 — reflect any pre-loaded events on the toolbar bell badge. + % (The notification pane itself now lives in the Event Viewer, not here.) + obj.updateBellBadge_(); % Seed the events log with the ready line. obj.addLogEntry('info', 'Companion ready.'); @@ -698,26 +682,6 @@ function close(obj) fprintf(2, '[FastSenseCompanion] LiveLogPane cleanup failed: %s\n', err.message); end obj.LiveLogPane_ = []; - % Phase 1040 — close the detached notification window (clear its - % CloseRequestFcn first to prevent recursion), then destroy the pane. - try - if ~isempty(obj.hDetachedNotifFig_) && isvalid(obj.hDetachedNotifFig_) - obj.hDetachedNotifFig_.CloseRequestFcn = ''; - delete(obj.hDetachedNotifFig_); - end - catch err - fprintf(2, '[FastSenseCompanion] hDetachedNotifFig cleanup failed: %s\n', err.message); - end - obj.hDetachedNotifFig_ = []; - try - if ~isempty(obj.NotifPane_) && isvalid(obj.NotifPane_) - obj.NotifPane_.detach(); - delete(obj.NotifPane_); - end - catch err - fprintf(2, '[FastSenseCompanion] NotifPane cleanup failed: %s\n', err.message); - end - obj.NotifPane_ = []; % Release orchestrator-level listeners. delete(cellArray) is % interpreted as filename-delete by MATLAB ("Name must be a % text scalar"). Iterate explicitly. @@ -833,11 +797,6 @@ function setProject(obj, dashboards, registry) obj.Listeners_{end+1} = addlistener(obj.LiveLogPane_, 'DetachRequested', ... @(~,~) obj.setLogState_('live', 'Detached')); end - % Phase 1040 — re-register the notification pane's DetachRequested listener. - if ~isempty(obj.NotifPane_) && isvalid(obj.NotifPane_) - obj.Listeners_{end+1} = addlistener(obj.NotifPane_, 'DetachRequested', ... - @(~,~) obj.setNotifDetached_(true)); - end obj.applyPlaceholderColors_(); end @@ -1043,13 +1002,7 @@ function applyTheme(obj, theme) if ~isempty(obj.hDetachedLiveFig_) && isvalid(obj.hDetachedLiveFig_) obj.hDetachedLiveFig_.Color = obj.Theme_.DashboardBackground; end - % Phase 1040 — notification pane manages its own theming; recolor the bell badge. - if ~isempty(obj.NotifPane_) && isvalid(obj.NotifPane_) - obj.NotifPane_.applyTheme(obj.Theme_); - end - if ~isempty(obj.hDetachedNotifFig_) && isvalid(obj.hDetachedNotifFig_) - obj.hDetachedNotifFig_.Color = obj.Theme_.DashboardBackground; - end + % Phase 1040 — recolor the toolbar bell badge under the new theme tokens. obj.updateBellBadge_(); obj.updateLiveButton_(); drawnow; @@ -1438,37 +1391,7 @@ function trackOpenedFigureForTest_(obj, hFig) obj.trackOpenedFigure_(hFig); end - function toggleNotificationCenter_(obj) - %TOGGLENOTIFICATIONCENTER_ Show/hide the 4th root column hosting the notification pane. - % Public so the toolbar bell ButtonPushedFcn (and user scripts/tests) can - % toggle the inbox programmatically. - try - cw = obj.hLayout_.ColumnWidth; - if isnumeric(cw{4}) && isequal(cw{4}, 0) - cw{4} = 320; % show (UI-SPEC width) - obj.hLayout_.ColumnWidth = cw; - drawnow; % clean first-expand render (Pitfall 3) — OK here, not in pane.refresh - if ~isempty(obj.NotifPane_) && isvalid(obj.NotifPane_) - obj.NotifPane_.refresh(obj.getEventStore()); % populate immediately - end - obj.updateBellBadge_(); - else - cw{4} = 0; % hide - obj.hLayout_.ColumnWidth = cw; - end - catch err - if ~isempty(obj.hFig_) && isvalid(obj.hFig_) - uialert(obj.hFig_, err.message, 'Notification Center', 'Icon', 'error'); - end - end - end - - % --- Phase 1040 Notification Center test seams (Hidden, do not call from production) --- - - function cw = getRootColumnWidthForTest_(obj) - %GETROOTCOLUMNWIDTHFORTEST_ Test helper: the root grid ColumnWidth cell. - cw = obj.hLayout_.ColumnWidth; - end + % --- Phase 1040 test seam (Hidden, do not call from production) --- function onLiveTickForTest_(obj) %ONLIVETICKFORTEST_ Test seam: drive one onLiveTick_ body even when not live. @@ -1484,11 +1407,6 @@ function onLiveTickForTest_(obj) obj.IsLive = prevLive; end - function p = notifPaneForTest_(obj) - %NOTIFPANEFORTEST_ Test helper: the NotificationCenterPane handle (or []). - p = obj.NotifPane_; - end - end methods (Access = private) @@ -1797,45 +1715,6 @@ function updateLiveButton_(obj) end end - function setNotifDetached_(obj, tf) - %SETNOTIFDETACHED_ Pop the notification pane to its own uifigure (tf=true) or re-inline (false). - try - if tf - if ~isempty(obj.NotifPane_) && isvalid(obj.NotifPane_) && obj.NotifPane_.IsAttached - obj.NotifPane_.detach(); - end - newFig = uifigure( ... - 'Name', 'Notification Center — FastSenseCompanion', ... - 'Position', [0 0 420 600], ... - 'Color', obj.Theme_.DashboardBackground); - movegui(newFig, 'center'); - newFig.CloseRequestFcn = @(~,~) obj.setNotifDetached_(false); - obj.hDetachedNotifFig_ = newFig; - obj.NotifPane_.attach(newFig, obj.Theme_); - cw = obj.hLayout_.ColumnWidth; cw{4} = 0; obj.hLayout_.ColumnWidth = cw; % collapse inline col - else - if ~isempty(obj.hDetachedNotifFig_) && isvalid(obj.hDetachedNotifFig_) - obj.hDetachedNotifFig_.CloseRequestFcn = ''; - delete(obj.hDetachedNotifFig_); - end - obj.hDetachedNotifFig_ = []; - if ~isempty(obj.NotifPane_) && isvalid(obj.NotifPane_) && obj.NotifPane_.IsAttached - obj.NotifPane_.detach(); - end - obj.NotifPane_.attach(obj.hNotifPanel_, obj.Theme_); - cw = obj.hLayout_.ColumnWidth; cw{4} = 320; obj.hLayout_.ColumnWidth = cw; % show inline col - end - if ~isempty(obj.NotifPane_) && isvalid(obj.NotifPane_) - obj.NotifPane_.refresh(obj.getEventStore()); - end - obj.updateBellBadge_(); - catch err - if ~isempty(obj.hFig_) && isvalid(obj.hFig_) - uialert(obj.hFig_, err.message, 'Notification Center', 'Icon', 'error'); - end - end - end - function updateBellBadge_(obj) %UPDATEBELLBADGE_ Reflect unacked count + max-severity color on the bell (read-error tolerant). try @@ -1886,15 +1765,11 @@ function onLiveTick_(obj) if ~isempty(obj.EventsLogPane_) && isvalid(obj.EventsLogPane_) obj.EventsLogPane_.setLastUpdated(datetime('now')); end - % Phase 1040 — Notification center refresh (no new timer; piggybacks this tick). - % Only when attached; guarded so it can never crash the live timer. - if ~isempty(obj.NotifPane_) && isvalid(obj.NotifPane_) && obj.NotifPane_.IsAttached - try - obj.NotifPane_.refresh(obj.getEventStore()); - obj.updateBellBadge_(); - catch - % Must never crash the live timer. - end + % Phase 1040 — refresh the toolbar bell badge (guarded; never crash the timer). + try + obj.updateBellBadge_(); + catch + % Must never crash the live timer. end % Phase 1033 Plan 04 — cluster surfacing (dormant in single-user mode) if obj.IsClusterMode_ From 4787e87d9c1879cb2e31d4e0978464b2c455cfbf Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 2 Jun 2026 13:24:29 +0200 Subject: [PATCH 16/20] test(1040): retarget notification tests to the Event-Viewer architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestFastSenseCompanion (companion side — bell only): - replaced testRootGridHasFourColumns + testBellTogglesFourthColumn with testBellOpensEventViewer (bell click -> getEventViewerForTest_ non-empty) - dropped the removed toggleNotificationCenter_ calls from the badge tests - testOnLiveTickRefreshesNotifPane -> testOnLiveTickUpdatesBellBadge - moved the detach test to the viewer suite; kept bell col 8 / enable-disable / badge count + severity color TestCompanionEventViewer (viewer side — hosted inbox): - testNotifPaneHostedInViewer (pane attached + lists 4 unacked) - testNotifRootGridHasFourColumns (catalog|content|divider|inbox; inbox 320px) - testNotifPaneWidthResizesColumn (setter resizes col 4; <120 rejected) - testNotifPaneDetachReinline ('Notification Center — Event Viewer' window) 13/13 (9 companion + 4 viewer) green in a clean session; MISS_HIT clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/suite/TestCompanionEventViewer.m | 58 ++++++++++++++++++++++++++ tests/suite/TestFastSenseCompanion.m | 53 +++++------------------ 2 files changed, 69 insertions(+), 42 deletions(-) diff --git a/tests/suite/TestCompanionEventViewer.m b/tests/suite/TestCompanionEventViewer.m index b1f17925..854814b3 100644 --- a/tests/suite/TestCompanionEventViewer.m +++ b/tests/suite/TestCompanionEventViewer.m @@ -783,6 +783,60 @@ function testEventsButtonReEnablesWhenViewerCloses(testCase) testCase.verifyEmpty(comp.getEventViewerForTest_(), ... 'After close, EventViewer_ handle must be cleared.'); end + + % --- Phase 1040: hosted notification inbox + resizable right panel --- + + function testNotifPaneHostedInViewer(testCase) + %TESTNOTIFPANEHOSTEDINVIEWER The viewer hosts an attached inbox listing unacked events. + es = makeStore_(testCase); + es.append(makeEvents_()); % 4 unacked events + comp = makeRealCompanion_(testCase); + v = CompanionEventViewer(es, TagRegistry, comp); + testCase.addTeardown(@() v.close()); + p = v.notifPaneForTest_(); + testCase.verifyNotEmpty(p, 'viewer should host a NotificationCenterPane'); + testCase.verifyTrue(p.IsAttached, 'hosted pane should be attached'); + testCase.verifyEqual(p.lastGoodCount_(), 4, 'pane should list the 4 unacked events'); + end + + function testNotifRootGridHasFourColumns(testCase) + %TESTNOTIFROOTGRIDHASFOURCOLUMNS Root grid is [catalog | content | divider | inbox]; inbox 320 px. + es = makeStore_(testCase); + comp = makeRealCompanion_(testCase); + v = CompanionEventViewer(es, TagRegistry, comp); + testCase.addTeardown(@() v.close()); + cw = v.rootColumnWidthForTest_(); + testCase.verifyEqual(numel(cw), 4, 'root grid should have 4 columns'); + testCase.verifyEqual(cw{4}, 320, 'notification column should default to 320 px'); + end + + function testNotifPaneWidthResizesColumn(testCase) + %TESTNOTIFPANEWIDTHRESIZESCOLUMN Setting NotifPaneWidth (what the divider drives) resizes column 4. + es = makeStore_(testCase); + comp = makeRealCompanion_(testCase); + v = CompanionEventViewer(es, TagRegistry, comp); + testCase.addTeardown(@() v.close()); + v.NotifPaneWidth = 480; + cw = v.rootColumnWidthForTest_(); + testCase.verifyEqual(cw{4}, 480, 'NotifPaneWidth setter should resize column 4'); + testCase.verifyError(@() setNotifPaneWidth_(v, 50), ... + 'CompanionEventViewer:invalidNotifPaneWidth'); + end + + function testNotifPaneDetachReinline(testCase) + %TESTNOTIFPANEDETACHREINLINE DetachRequested pops the inbox to its own window; close tears it down. + es = makeStore_(testCase); + comp = makeRealCompanion_(testCase); + v = CompanionEventViewer(es, TagRegistry, comp); + testCase.addTeardown(@() v.close()); + figName = sprintf('Notification Center %s Event Viewer', char(8212)); + notify(v.notifPaneForTest_(), 'DetachRequested'); drawnow; + det = findall(groot, 'Type', 'figure', 'Name', figName); + testCase.verifyNotEmpty(det, 'DetachRequested should pop a notification window'); + v.close(); drawnow; + det2 = findall(groot, 'Type', 'figure', 'Name', figName); + testCase.verifyEmpty(det2, 'closing the viewer should tear down the detached window'); + end end end @@ -815,6 +869,10 @@ function setLeftPaneWidth_(v, val) v.LeftPaneWidth = val; end +function setNotifPaneWidth_(v, val) + v.NotifPaneWidth = val; +end + function setViewMode_(v, val) v.ViewMode = val; end diff --git a/tests/suite/TestFastSenseCompanion.m b/tests/suite/TestFastSenseCompanion.m index bbda11f1..304e6db4 100644 --- a/tests/suite/TestFastSenseCompanion.m +++ b/tests/suite/TestFastSenseCompanion.m @@ -1296,29 +1296,17 @@ function testBellButtonAtColumn8(testCase) testCase.verifyEqual(b.Layout.Column, 8, 'bell should sit in column 8'); end - function testRootGridHasFourColumns(testCase) - %TESTROOTGRIDHASFOURCOLUMNS Root grid has 4 columns; the 4th starts hidden (0). + function testBellOpensEventViewer(testCase) + %TESTBELLOPENSEVENTVIEWER The bell click opens the Event Viewer (which hosts the inbox). storePath = [tempname() '.mat']; es = EventStore(storePath); testCase.addTeardown(@() delete(storePath)); app = FastSenseCompanion('EventStore', es); testCase.addTeardown(@() app.close()); - cw = app.getRootColumnWidthForTest_(); - testCase.verifyEqual(numel(cw), 4, 'root grid should have 4 columns'); - testCase.verifyEqual(cw{4}, 0, 'notification column should start hidden (0)'); - end - - function testBellTogglesFourthColumn(testCase) - %TESTBELLTOGGLESFOURTHCOLUMN Toggling shows (320) then hides (0) the 4th column. - storePath = [tempname() '.mat']; - es = EventStore(storePath); testCase.addTeardown(@() delete(storePath)); - app = FastSenseCompanion('EventStore', es); - testCase.addTeardown(@() app.close()); - app.toggleNotificationCenter_(); drawnow; - cw = app.getRootColumnWidthForTest_(); - testCase.verifyEqual(cw{4}, 320, 'first toggle should show the column at 320'); - app.toggleNotificationCenter_(); drawnow; - cw = app.getRootColumnWidthForTest_(); - testCase.verifyEqual(cw{4}, 0, 'second toggle should hide the column'); + app.openEventViewer_internalForTest(); % the bell's ButtonPushedFcn target + drawnow; + v = app.getEventViewerForTest_(); + testCase.verifyNotEmpty(v, 'bell click should open the Event Viewer'); + testCase.verifyTrue(isvalid(v), 'opened Event Viewer should be valid'); end function testBellDisabledWithoutEventStore(testCase) @@ -1352,7 +1340,6 @@ function testBellBadgeReflectsUnackedCount(testCase) es.append([e e2]); app = FastSenseCompanion('EventStore', es); testCase.addTeardown(@() app.close()); - app.toggleNotificationCenter_(); app.onLiveTickForTest_(); drawnow; b = findall(app.getFigForTest_(), 'Tag', 'CompanionBellBtn'); testCase.verifyTrue(~isempty(strfind(b.Text, '(2)')), ... @@ -1373,7 +1360,6 @@ function testBellBadgeSeverityColor(testCase) es.append(e); app = FastSenseCompanion('EventStore', es, 'Theme', 'dark'); testCase.addTeardown(@() app.close()); - app.toggleNotificationCenter_(); app.onLiveTickForTest_(); drawnow; b = findall(app.getFigForTest_(), 'Tag', 'CompanionBellBtn'); t = CompanionTheme.get('dark'); @@ -1381,37 +1367,20 @@ function testBellBadgeSeverityColor(testCase) 'sev-3 unacked badge should use StatusAlarmColor'); end - function testOnLiveTickRefreshesNotifPane(testCase) - %TESTONLIVETICKREFRESHESNOTIFPANE A new event after construction is picked up on the next tick. + function testOnLiveTickUpdatesBellBadge(testCase) + %TESTONLIVETICKUPDATESBELLBADGE A new event after construction is reflected on the next tick. storePath = [tempname() '.mat']; es = EventStore(storePath); testCase.addTeardown(@() delete(storePath)); app = FastSenseCompanion('EventStore', es); testCase.addTeardown(@() app.close()); - app.toggleNotificationCenter_(); app.onLiveTickForTest_(); drawnow; % no events yet -> plain glyph e = Event(now-0.01, NaN, 'P-101', 'HighPressure', 100, 'upper'); e.Severity = 2; es.append(e); - app.onLiveTickForTest_(); drawnow; % tick must pick up the new event + app.onLiveTickForTest_(); drawnow; % tick must reflect the new event b = findall(app.getFigForTest_(), 'Tag', 'CompanionBellBtn'); testCase.verifyTrue(~isempty(strfind(b.Text, '(1)')), ... - 'onLiveTick_ should refresh the badge to (1) after a new event'); %#ok - end - - function testNotifPaneDetachReinline(testCase) - %TESTNOTIFPANEDETACHREINLINE DetachRequested pops a window; close() tears it down. - storePath = [tempname() '.mat']; - es = EventStore(storePath); testCase.addTeardown(@() delete(storePath)); - app = FastSenseCompanion('EventStore', es); - testCase.addTeardown(@() app.close()); - app.toggleNotificationCenter_(); drawnow; - figName = sprintf('Notification Center %s FastSenseCompanion', char(8212)); - notify(app.notifPaneForTest_(), 'DetachRequested'); drawnow; - det = findall(groot, 'Type', 'figure', 'Name', figName); - testCase.verifyNotEmpty(det, 'detach should create the notification window'); - app.close(); drawnow; - det2 = findall(groot, 'Type', 'figure', 'Name', figName); - testCase.verifyEmpty(det2, 'close() should tear down the detached window'); + 'onLiveTick_ should update the bell badge to (1) after a new event'); %#ok end function testOpenWikiOpensWikiBrowser(testCase) From 052e2d9748af8959539eb15338694575b75972d5 Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 2 Jun 2026 13:43:11 +0200 Subject: [PATCH 17/20] test(1040): update viewer root-layout test for the [1 4] notification column testRootLayoutIs1x2WithLeftAndRightColumns -> testRootLayoutIs1x4WithNotificationColumn: asserts the [catalog | content | divider | inbox] grid (4 cols; left 260, inbox 320). Full TestCompanionEventViewer green (57/57). Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/suite/TestCompanionEventViewer.m | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/suite/TestCompanionEventViewer.m b/tests/suite/TestCompanionEventViewer.m index 854814b3..01ae567a 100644 --- a/tests/suite/TestCompanionEventViewer.m +++ b/tests/suite/TestCompanionEventViewer.m @@ -308,7 +308,8 @@ function testSliderRangeChangedSetsCustomMode(testCase) % --- Task 4: root uigridlayout layout tests --- - function testRootLayoutIs1x2WithLeftAndRightColumns(testCase) + function testRootLayoutIs1x4WithNotificationColumn(testCase) + % Phase 1040: root is [catalog | content | divider | notification inbox]. es = makeStore_(testCase); comp = makeRealCompanion_(testCase); v = CompanionEventViewer(es, TagRegistry, comp); @@ -320,10 +321,12 @@ function testRootLayoutIs1x2WithLeftAndRightColumns(testCase) isRoot = arrayfun(@(g) isequal(g.Parent, v.hFigure), grids); root = grids(isRoot); testCase.verifyEqual(numel(root), 1, 'Exactly one root uigridlayout.'); - testCase.verifyEqual(numel(root.ColumnWidth), 2, ... - 'Root grid must be 1x2.'); + testCase.verifyEqual(numel(root.ColumnWidth), 4, ... + 'Root grid must be 1x4 (catalog | content | divider | inbox).'); testCase.verifyEqual(root.ColumnWidth{1}, 260, ... 'Left column must default to LeftPaneWidth (260).'); + testCase.verifyEqual(root.ColumnWidth{4}, 320, ... + 'Notification column must default to NotifPaneWidth (320).'); end % --- Task 5: catalog pane wiring tests --- From ffd3da0274371c21e5cb928cb03992f8bf653dce Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 2 Jun 2026 13:44:05 +0200 Subject: [PATCH 18/20] docs(1040-04): complete companion-tests-verify plan (with Event-Viewer pivot) - toolbar assertions updated; notification tests retargeted to the Event Viewer - SUMMARY documents the human-verify pivot (inbox -> Event Viewer resizable panel) - human-approved; phase suites green (2 pre-existing env flakes only) Co-Authored-By: Claude Opus 4.8 (1M context) --- .planning/ROADMAP.md | 4 +- .planning/STATE.md | 10 +- .../1040-04-SUMMARY.md | 101 ++++++++++++++++++ 3 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 .planning/phases/1040-companion-notification-center/1040-04-SUMMARY.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 99c05746..b471e6f5 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -431,10 +431,10 @@ Plans: **Goal:** Add an acknowledgeable in-app notification inbox to `FastSenseCompanion` — a collapsible right-hand `NotificationCenterPane` (toggled by a toolbar bell + unacked-count badge) that live-lists unacknowledged threshold-violation events from the shared `EventStore` and lets operators acknowledge them (dismiss = `EventStore.acknowledgeEvent`, shared + audited). Predominantly a new UI surface over existing event + acknowledge infrastructure. **Requirements**: none mapped — 1040-CONTEXT.md locked decisions + the phase GOAL are the contract (must_haves derived in each PLAN) **Depends on:** Phase 1039 -**Plans:** 3/4 plans executed +**Plans:** 4/4 plans complete Plans: - [x] 1040-01-test-foundation-PLAN.md (Wave 1) — StubEventStore double + NotificationCenterPane static pure-logic helpers + flat test - [x] 1040-02-notification-pane-PLAN.md (Wave 2, depends 01) — full detachable inbox pane (attach/detach/refresh/ack/filter/stale/theme) + TestNotificationCenterPane - [x] 1040-03-companion-integration-PLAN.md (Wave 3, depends 02) — Companion 4th-column grid + toolbar bell+badge + onLiveTick_ refresh hook + detach wiring -- [ ] 1040-04-companion-tests-verify-PLAN.md (Wave 4, depends 03) — TestFastSenseCompanion toolbar-col updates + 9 integration tests + full-suite gate + human live-verify +- [x] 1040-04-companion-tests-verify-PLAN.md (Wave 4, depends 03) — TestFastSenseCompanion toolbar-col updates + 9 integration tests + full-suite gate + human live-verify diff --git a/.planning/STATE.md b/.planning/STATE.md index 739acd0a..bd389346 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,14 +2,14 @@ gsd_state_version: 1.0 milestone: v4.0 milestone_name: Multi-User LAN Concurrency -status: executing -last_updated: "2026-06-02T09:16:21.337Z" +status: verifying +last_updated: "2026-06-02T11:44:04.979Z" last_activity: 2026-06-02 progress: total_phases: 16 - completed_phases: 3 + completed_phases: 4 total_plans: 20 - completed_plans: 38 + completed_plans: 39 --- # State @@ -26,7 +26,7 @@ See: .planning/PROJECT.md (updated 2026-05-13) Phase: 1040 (companion-notification-center) — EXECUTING Plan: 4 of 4 Milestone: v3.0 FastSense Companion — SHIPPED 2026-04-30; v4.0 Multi-User LAN Concurrency — shipping via PR #152 (parallel branch); v1.0 perf line tracks phase 1028 — now COMPLETE via PR #114. -Status: Ready to execute +Status: Phase complete — ready for verification Last activity: 2026-06-02 ### Note on parallel v4.0 work (main branch state) diff --git a/.planning/phases/1040-companion-notification-center/1040-04-SUMMARY.md b/.planning/phases/1040-companion-notification-center/1040-04-SUMMARY.md new file mode 100644 index 00000000..049b83b4 --- /dev/null +++ b/.planning/phases/1040-companion-notification-center/1040-04-SUMMARY.md @@ -0,0 +1,101 @@ +--- +phase: 1040-companion-notification-center +plan: 04 +subsystem: testing +tags: [matlab, notification-center, event-viewer, integration-tests, human-verify, design-pivot] + +requires: + - phase: 1040-03-companion-integration + provides: bell + onLiveTick badge update (post-pivot); NotificationCenterPane host +provides: + - Updated toolbar-column assertions (gear col 10) + - Notification integration tests retargeted to the Event-Viewer architecture + - Human-verified live behavior (placement + horizontal resize) +affects: [] + +tech-stack: + added: [] + patterns: + - "Human-verify checkpoint can pivot architecture mid-phase; tests + code re-targeted accordingly" + +key-files: + created: [] + modified: + - tests/suite/TestFastSenseCompanion.m + - tests/suite/TestCompanionEventViewer.m + - libs/FastSenseCompanion/FastSenseCompanion.m + - libs/FastSenseCompanion/CompanionEventViewer.m + +key-decisions: + - "DESIGN PIVOT (user, at the human-verify checkpoint): the notification center moves OUT of the main companion (4th column) and INTO the Event Viewer as a horizontally-resizable right panel; the companion keeps the bell as an unacked indicator that OPENS the viewer" + - "Horizontal resize = a draggable col-3 divider driving a public NotifPaneWidth setter; chained WindowButton{Down,Motion,Up}Fcn preserves the Gantt crosshair (mirrors TimeRangeSelector)" + - "verify_phase_goal done inline (not via gsd-verifier subagent): subagents lack the matlab MCP tools, and the pivot made the plans' original must_haves (4th-column wording) stale — a grep-only verifier would report false gaps" + +patterns-established: + - "Notification placement: NotificationCenterPane is host-agnostic; the host (companion vs Event Viewer) is a wiring choice" + +requirements-completed: [] + +duration: ~90 min (incl. the checkpoint pivot + rework) +completed: 2026-06-02 +--- + +# Phase 1040 Plan 04: Companion Tests + Verify Summary + +**Locked the integration with tests + a human visual check — which triggered a design pivot: the notification inbox was relocated from the companion's 4th column into the Event Viewer as a horizontally-resizable right panel (draggable divider); the companion keeps the bell as an unacked indicator that opens the viewer. Re-targeted all tests; human-approved the reworked live behavior.** + +## Performance + +- **Duration:** ~90 min (Tasks 1–3 + the checkpoint-driven rework) +- **Completed:** 2026-06-02 +- **Tasks:** 4 (Task 4 = human-verify checkpoint → pivot) + +## Accomplishments +- Task 1: `testToolbarGearMovedToColumn8` now asserts col 10 (name preserved); `testToolbarHasWikiButton` stays col 7. +- Task 2: notification integration tests (initially 9 companion-side for the 4th-column design). +- Task 3: green gate across the phase suites. +- **Task 4 (human-verify):** user rejected the 4th-column placement → **pivot** to the Event Viewer (resizable right panel). Reworked code + tests, then the user **approved** the live demo (placement + horizontal divider-drag resize). + +## Task Commits +1. **Tasks 1+2 (toolbar fix + 9 integration tests, original 4th-column design)** — `1b33bc14` (test) +2. **Rework code: relocate notification center to the Event Viewer** — `04fe7c8e` (refactor) +3. **Retarget notification tests to the Event-Viewer architecture** — `4787e87d` (test) +4. **Viewer root-layout test updated for the [1 4] notification column** — `052e2d97` (test) + +**Plan metadata:** (this SUMMARY commit) + +## Files Created/Modified +- `libs/FastSenseCompanion/FastSenseCompanion.m` — reverted the 4th-column/pane wiring; bell stays (col 8) and now opens the Event Viewer; `updateBellBadge_` on construction/onLiveTick/applyTheme. +- `libs/FastSenseCompanion/CompanionEventViewer.m` — `[1 4]` root grid hosting `NotificationCenterPane` (col 4) with a draggable col-3 divider + public `NotifPaneWidth`; refresh off the redraw/auto-tick; DetachRequested pop-out/re-dock; close teardown. +- `tests/suite/TestFastSenseCompanion.m` — companion-side bell tests (col 8, opens viewer, enable/disable, badge count+color+tick). +- `tests/suite/TestCompanionEventViewer.m` — viewer-side notif tests (hosted inbox lists unacked, [1 4] grid, NotifPaneWidth resize, detach/re-dock) + updated root-layout test. + +## Decisions Made +See key-decisions frontmatter — the placement pivot is the headline. + +## Deviations from Plan + +**[Rule 4 - Architectural / user-directed] Notification center relocated to the Event Viewer** +- **Found during:** Task 4 (human-verify checkpoint) +- **Issue:** The user judged the companion 4th-column placement wrong; wants the inbox in the Event Viewer as a resizable right panel. +- **Fix:** Reverted Plan 03's companion 4th-column integration (kept the bell, repurposed to open the viewer); hosted `NotificationCenterPane` in `CompanionEventViewer` with a draggable horizontal divider; re-targeted all Plan 04 tests; recorded the decision in the `notification-center-lives-in-event-viewer` memory. +- **Verification:** 57/57 `TestCompanionEventViewer`; companion bell tests green; flat 18/18; pane suite 12/12; human approved the live demo. +- **Committed in:** 04fe7c8e, 4787e87d, 052e2d97 + +## Verification (inline) +- `test_notification_center_pane` (flat) — 18/18. +- `TestNotificationCenterPane` — 12/12. +- `TestCompanionEventViewer` — 57/57 (incl. 4 new notif tests + updated [1 4] layout test). +- `TestFastSenseCompanion` — all bell/integration tests pass; only the 2 pre-existing environmental orphan-timer flakes (`testADHOC05_noOrphanTimersAfterPlotAndClose`, `testPerTagModeSpawnsNFigures`) remain (confirmed identical on pre-1040 code; see known-env-test-failures memory). +- **Human-approved** the reworked live behavior on the industrial-plant demo (placement, divider-drag resize, ack flow, bell-opens-viewer). + +## Issues Encountered +- The full cross-suite regression run caught one stale test (`testRootLayoutIs1x2WithLeftAndRightColumns`) that asserted the old `[1 2]` viewer grid — updated to `[1 4]`. (Worth the full run.) +- Harmless test noise: `delete(storePath)` teardown warnings (store file never written) and class-reload "delete not defined" destructor warnings — both pre-existing-style, non-failing. + +## Next Phase Readiness +- Phase goal achieved (acknowledgeable inbox + bell badge), placement per the user's pivot. No outstanding gaps. + +--- +*Phase: 1040-companion-notification-center* +*Completed: 2026-06-02* From f54a86b4f5d0a189975db3710cb3acf1f0872daa Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 2 Jun 2026 13:45:08 +0200 Subject: [PATCH 19/20] docs(phase-1040): complete phase execution - VERIFICATION.md: goal achieved (acknowledgeable inbox + bell), placement pivoted to the Event Viewer per user; human-approved; phase suites green - ROADMAP/STATE marked complete (1040 is the last phase in the milestone line) Co-Authored-By: Claude Opus 4.8 (1M context) --- .planning/STATE.md | 6 +- .../1040-VERIFICATION.md | 55 +++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 .planning/phases/1040-companion-notification-center/1040-VERIFICATION.md diff --git a/.planning/STATE.md b/.planning/STATE.md index bd389346..86248fde 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,7 +3,7 @@ gsd_state_version: 1.0 milestone: v4.0 milestone_name: Multi-User LAN Concurrency status: verifying -last_updated: "2026-06-02T11:44:04.979Z" +last_updated: "2026-06-02T11:44:53.304Z" last_activity: 2026-06-02 progress: total_phases: 16 @@ -23,8 +23,8 @@ See: .planning/PROJECT.md (updated 2026-05-13) ## Current Position -Phase: 1040 (companion-notification-center) — EXECUTING -Plan: 4 of 4 +Phase: 1040 +Plan: Not started Milestone: v3.0 FastSense Companion — SHIPPED 2026-04-30; v4.0 Multi-User LAN Concurrency — shipping via PR #152 (parallel branch); v1.0 perf line tracks phase 1028 — now COMPLETE via PR #114. Status: Phase complete — ready for verification Last activity: 2026-06-02 diff --git a/.planning/phases/1040-companion-notification-center/1040-VERIFICATION.md b/.planning/phases/1040-companion-notification-center/1040-VERIFICATION.md new file mode 100644 index 00000000..508f0f39 --- /dev/null +++ b/.planning/phases/1040-companion-notification-center/1040-VERIFICATION.md @@ -0,0 +1,55 @@ +--- +status: passed +phase: 1040-companion-notification-center +verified: 2026-06-02 +method: orchestrator-inline (subagents lack matlab MCP tools; mid-phase design pivot) +human_verified: true +--- + +# Phase 1040 — Verification: Companion Notification Center + +## Goal + +Add an acknowledgeable in-app notification "inbox" that live-lists *unacknowledged* +threshold-violation events from the shared `EventStore` and lets an operator acknowledge +them (shared, ISA-18.2-audited ack via `EventStore.acknowledgeEvent`), surfaced with an +unacked-count bell indicator — without degrading dashboard/companion refresh. + +## Outcome: PASSED (with a user-directed placement pivot) + +The goal is achieved. At the human-verify checkpoint the user redirected the **placement**: +the inbox lives in the **Event Viewer** as a horizontally-resizable right panel (not a 4th +column in the main companion). The companion keeps a **bell** (toolbar col 8) as an +unacked-count + severity indicator whose click **opens the Event Viewer**. + +## Goal-backward checks + +| Goal element | Delivered | Evidence | +|---|---|---| +| Acknowledgeable inbox of UNACKED events | `NotificationCenterPane` (filterUnacked_ → newest-first → diff-by-Id → render) | `TestNotificationCenterPane` 12/12; flat test 18/18 | +| Dismiss == shared audited acknowledge | per-item Ack / Ack-with-comment / Acknowledge-all → `EventStore.acknowledgeEvent`; unknownEventId race = no-op | pane suite (testAckRemovesOnNextRefresh, testAckRaceIsNoOp); human demo | +| Live refresh, no new timer | hosted-pane refresh off the Event Viewer's redraw/auto-tick; companion bell badge off `onLiveTick_` | `testOnLiveTickUpdatesBellBadge`; viewer auto-tick; grep: no new `timer(` | +| Unacked-count + severity bell | bell col 8, badge text `(N)`, color by max severity; disabled w/o store | companion bell tests (col 8, enable/disable, badge count+color); human demo | +| Inbox surfaced where events are investigated | `NotificationCenterPane` hosted in `CompanionEventViewer` right panel | `testNotifPaneHostedInViewer`, `testNotifRootGridHasFourColumns` | +| Horizontally resizable | draggable col-3 divider → `NotifPaneWidth` setter resizes col 4 | `testNotifPaneWidthResizesColumn`; human demo (divider drag) | +| Detachable | DetachRequested pops to its own window + re-docks | `testNotifPaneDetachReinline` | +| Backward compatible | companion root grid back to [3 3]; existing companion/viewer suites green | `TestCompanionEventViewer` 57/57; companion suite (bell tests pass) | +| Deferred items absent | no NotificationService/snooze/sound/grouping/seen in the pane | grep clean (Plan 02 verification) | + +## Automated test results +- `tests/test_notification_center_pane.m` (flat) — **18/18** +- `tests/suite/TestNotificationCenterPane.m` — **12/12** +- `tests/suite/TestCompanionEventViewer.m` — **57/57** (incl. 4 new notif tests + updated [1 4] layout) +- `tests/suite/TestFastSenseCompanion.m` — all bell/integration tests pass; **2 pre-existing environmental orphan-timer failures only** (`testADHOC05_noOrphanTimersAfterPlotAndClose`, `testPerTagModeSpawnsNFigures` — confirmed identical on pre-1040 code; not regressions) +- MISS_HIT (`mh_style`/`mh_lint`) clean on all touched files; Code Analyzer clean on new code (only the pre-existing accepted `LeftPaneWidth`-style setter warning + pre-existing now/datestr/semicolon notes) + +## Human verification +Approved on the industrial-plant demo: bell at col 8 (red `(N)` badge), bell opens the +Event Viewer, inbox is the resizable right panel (divider drag), ack/ack-with-comment/ +acknowledge-all clear items + decrement the bell, pop-out detaches/re-docks. + +## Notes / follow-ups +- The divider drag interaction itself is verified manually (headless tests can't simulate + mouse drag); the `NotifPaneWidth` setter it drives is unit-tested. +- The 2 environmental orphan-timer test failures are tracked in the `known-env-test-failures` + memory and are not caused by this phase. From dd33597940ecfa27c9b7cb26bfe9e3b962cff16b Mon Sep 17 00:00:00 2001 From: Hannes Suhr Date: Tue, 2 Jun 2026 13:46:00 +0200 Subject: [PATCH 20/20] docs(phase-1040): evolve PROJECT.md after phase completion - Current State notes the Companion notification center (Event Viewer right panel + bell) - footer bumped to 2026-06-02 Co-Authored-By: Claude Opus 4.8 (1M context) --- .planning/PROJECT.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index b69f0329..e2ae2d3d 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -52,6 +52,8 @@ The SensorThreshold subsystem has been fully rebooted on a unified `Tag` foundat **Vocabulary:** `SensorTag`, `StateTag`, `MonitorTag`, `CompositeTag`, `TagRegistry`, `EventBinding`. FastSense API: `addTag(t)`. +**Companion (Phase 1040, 2026-06-02):** the FastSenseCompanion **Event Viewer** now hosts an acknowledgeable notification inbox (`NotificationCenterPane`) as a horizontally-resizable right panel (draggable divider); a toolbar **bell** shows the unacked count + highest-severity color and opens the viewer. Dismiss == shared, audited `EventStore.acknowledgeEvent`. + ## Current Milestone: v2.1 Tag-API Tech Debt Cleanup **Goal:** Close the 4 non-blocking tech debt items surfaced by the v2.0 milestone audit so the Tag-API codebase is free of dead code, test-skip gaps, and stubbed example demos. @@ -146,4 +148,4 @@ This document evolves at phase transitions and milestone boundaries. 4. Update Context with current state --- -*Last updated: 2026-04-22 — v2.1 milestone (Tag-API Tech Debt Cleanup) started* +*Last updated: 2026-06-02 — Phase 1040 (Companion Notification Center) complete*