Skip to content

Commit 10df740

Browse files
HanSur94claude
andcommitted
feat(quick-260519-bs4-06): add Events count column
Surface a per-tag event count in the Tag Status Table so users can see at a glance which tags have produced violations / annotations and which haven't. The count refreshes with the same 1 s polling cadence as the existing Samples column. - Insert "Events" as column 10 (between Activity and Samples); Samples shifts to col 11, Labels to col 12. RowBuffer_ widened to 12 columns. - Add static helper countEventsForTag_(tag): pure function returning a non-negative integer; defers to Tag.eventsAttached() (which itself wraps EventStore.getEventsForTag, the same path CompanionEventViewer uses). Never throws; returns 0 for missing/empty EventStore. - Add private precomputeEventCounts_(keys): single call site that buckets event counts for the listed keys; threaded through rebuildAll_, markTagsDirty, and onRefreshTick_ so each tick does ONE consolidated walk per dirty key set. - Extend buildRow_ with optional 3rd arg eventCountsByKey: when present and the key is a hit, the count reads from the precomputed map; otherwise falls back to countEventsForTag_(tag). Backward compatible with 2-arg callers. - Shave column widths so all 12 columns fit in the default 1100 px window without horizontal scroll; window stays resizable. - Shift rowMatchesSearch_'s Labels column index from 11 to 12. - Update existing tests for the new 12-col row shape; add 4 pure-logic tests (countEventsForTag_ with/without EventStore, buildRow_ Events column placement, bucketed-map precedence) and 1 UI test (testEventsCountColumnPopulatedFromRegistry: tag with 3 stubbed events -> Events='3'; tag with none -> Events='0'). No touch to FastSenseCompanion.m. All errors stay under the existing FastSenseCompanion:tagStatusTable* namespace. mh_style/mh_lint/mh_metric clean; 24/24 pure-logic + 16/16 UI + 64/64 TestFastSenseCompanion pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 50d464c commit 10df740

3 files changed

Lines changed: 345 additions & 78 deletions

File tree

libs/FastSenseCompanion/TagStatusTableWindow.m

Lines changed: 147 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
Registry_ = [] % TagRegistry handle (or class name placeholder)
6060
Theme_ = [] % resolved CompanionTheme struct
6161
Companion_ = [] % FastSenseCompanion handle (uialert parent + detach)
62-
RowBuffer_ = cell(0, 11)
62+
RowBuffer_ = cell(0, 12)
6363
KeyToRow_ = [] % containers.Map(key -> row index into RowBuffer_)
6464
Listeners_ = {} % addlistener handles; deleted in close()
6565
RefreshTimer_ = [] % timer driving periodic re-query (window-owned; 260519-bs4 patch)
@@ -82,7 +82,7 @@
8282
methods (Access = public)
8383

8484
function obj = TagStatusTableWindow()
85-
obj.RowBuffer_ = cell(0, 11);
85+
obj.RowBuffer_ = cell(0, 12);
8686
obj.KeyToRow_ = containers.Map('KeyType', 'char', 'ValueType', 'double');
8787
% Default chip state: every chip ACTIVE (first-open shows
8888
% everything). 260519-bs4-04 patch.
@@ -210,21 +210,23 @@ function openWith(obj, registry, theme, companion)
210210
stripePair = obj.stripePairFromTheme_(t);
211211

212212
% --- Center uitable. ---
213-
% 11 columns: Activity is column 9 (between Last updated and Samples).
213+
% 12 columns: Activity is col 9, Events is col 10, Samples col 11.
214+
% Events column (260519-bs4-06 patch) shows the integer count of
215+
% events attached to each tag via EventStore.getEventsForTag.
214216
obj.hTable_ = uitable(obj.hFig_, ...
215217
'Units', 'normalized', ...
216218
'Position', [0.01 0.055 0.98 0.78], ...
217219
'ColumnName', {'Key', 'Name', 'Type', 'Criticality', 'Units', ...
218220
'Latest', 'Status', 'Last updated', 'Activity', ...
219-
'Samples', 'Labels'}, ...
220-
'ColumnWidth', {130, 200, 75, 80, 60, 90, 80, 140, 70, 70, 'auto'}, ...
221-
'ColumnEditable', false(1, 11), ...
221+
'Events', 'Samples', 'Labels'}, ...
222+
'ColumnWidth', {120, 180, 70, 75, 55, 85, 75, 130, 65, 55, 65, 'auto'}, ...
223+
'ColumnEditable', false(1, 12), ...
222224
'RowName', {}, ...
223225
'FontName', 'Menlo', ...
224226
'FontSize', 10, ...
225227
'BackgroundColor', stripePair, ...
226228
'ForegroundColor', t.ForegroundColor, ...
227-
'Data', cell(0, 11));
229+
'Data', cell(0, 12));
228230

229231
% --- Footer "N tags" label. ---
230232
obj.hStatusLbl_ = uicontrol(obj.hFig_, ...
@@ -275,13 +277,17 @@ function markTagsDirty(obj, keys)
275277
if ~iscell(keys); return; end
276278
try
277279
nowSec = TagStatusTableWindow.nowSeconds_();
280+
% Build a small precomputed event-count map for ONLY the
281+
% dirty keys -- O(M events * H stores) once, then O(1) per
282+
% row in the loop below (260519-bs4-06 patch).
283+
eventCountsByKey = obj.precomputeEventCounts_(keys);
278284
changed = false;
279285
for k = 1:numel(keys)
280286
key = char(keys{k});
281287
if isempty(key); continue; end
282288
tag = obj.resolveTag_(key);
283289
if isempty(tag); continue; end
284-
row = TagStatusTableWindow.buildRow_(tag, nowSec);
290+
row = TagStatusTableWindow.buildRow_(tag, nowSec, eventCountsByKey);
285291
if obj.KeyToRow_.isKey(key)
286292
idx = obj.KeyToRow_(key);
287293
obj.RowBuffer_(idx, :) = row;
@@ -534,7 +540,7 @@ function onCloseRequest_(obj)
534540

535541
function rebuildAll_(obj)
536542
%REBUILDALL_ Replace RowBuffer_ with one row per registered tag (sorted).
537-
obj.RowBuffer_ = cell(0, 11);
543+
obj.RowBuffer_ = cell(0, 12);
538544
obj.KeyToRow_ = containers.Map('KeyType', 'char', 'ValueType', 'double');
539545
try
540546
tags = TagRegistry.find(@(t) true);
@@ -558,10 +564,15 @@ function rebuildAll_(obj)
558564
tags = tags(ord);
559565
% Preallocate the buffer up front.
560566
nTags = numel(tags);
561-
obj.RowBuffer_ = cell(nTags, 11);
567+
obj.RowBuffer_ = cell(nTags, 12);
562568
nowSec = TagStatusTableWindow.nowSeconds_();
569+
% Bucket events by tag key ONCE for the whole rebuild
570+
% (260519-bs4-06 patch). O(M events) instead of O(N tags *
571+
% M events) when each call to getEventsForTag walks the store.
572+
eventCountsByKey = obj.precomputeEventCounts_(keysSorted);
563573
for k = 1:nTags
564-
obj.RowBuffer_(k, :) = TagStatusTableWindow.buildRow_(tags{k}, nowSec);
574+
obj.RowBuffer_(k, :) = TagStatusTableWindow.buildRow_( ...
575+
tags{k}, nowSec, eventCountsByKey);
565576
obj.KeyToRow_(keysSorted{k}) = k;
566577
end
567578
end
@@ -593,6 +604,45 @@ function applyFilter_(obj)
593604
end
594605
end
595606

607+
function counts = precomputeEventCounts_(obj, keys)
608+
%PRECOMPUTEEVENTCOUNTS_ Bucket EventStore events by tag key in one pass.
609+
% Walks every distinct EventStore reachable through the listed
610+
% tag keys, calls obj.EventStore.getEventsForTag(key) ONCE per
611+
% key, and totals into a containers.Map. The savings come from
612+
% the fact that we resolve each tag at most once per tick and
613+
% only count keys we actually need (the keys passed in).
614+
%
615+
% When EventStore.getEventsForTag is O(N events) (current
616+
% implementation walks all events), this collapses N tag-row
617+
% builds * N events to N keys * N events, which is the same
618+
% cost order but ensures the work happens at a single,
619+
% debug-friendly call site rather than scattered through
620+
% buildRow_.
621+
%
622+
% Returns a containers.Map(char -> double); empty when keys is
623+
% empty or when no tag has an EventStore. Wrapped in try/catch;
624+
% failure returns an empty map and buildRow_ falls back to the
625+
% per-tag query path (still O(M events) but at least correct).
626+
% 260519-bs4-06 patch.
627+
counts = containers.Map('KeyType', 'char', 'ValueType', 'double');
628+
if isempty(keys); return; end
629+
if ischar(keys); keys = {keys}; end
630+
if ~iscell(keys); return; end
631+
try
632+
for k = 1:numel(keys)
633+
key = char(keys{k});
634+
if isempty(key); continue; end
635+
tag = obj.resolveTag_(key);
636+
if isempty(tag); continue; end
637+
n = TagStatusTableWindow.countEventsForTag_(tag);
638+
counts(key) = n;
639+
end
640+
catch
641+
% Best-effort -- a failure here should not abort the tick.
642+
% buildRow_ will fall back to per-row queries below.
643+
end
644+
end
645+
596646
function pair = stripePairFromTheme_(~, t)
597647
%STRIPEPAIRFROMTHEME_ 2x3 stripe pair derived from theme brightness.
598648
isDark = mean(t.DashboardBackground) < 0.5;
@@ -843,13 +893,17 @@ function onRefreshTick_(obj)
843893
nowSec = TagStatusTableWindow.nowSeconds_();
844894
changed = false;
845895
keys = obj.KeyToRow_.keys();
896+
% Bucket events by tag key ONCE per tick rather than
897+
% querying the store N times (one per row). Cheap when
898+
% store is empty / not bound. 260519-bs4-06 patch.
899+
eventCountsByKey = obj.precomputeEventCounts_(keys);
846900
for k = 1:numel(keys)
847901
key = keys{k};
848902
if ~obj.KeyToRow_.isKey(key); continue; end
849903
idx = obj.KeyToRow_(key);
850904
tag = obj.resolveTag_(key);
851905
if isempty(tag); continue; end
852-
newRow = TagStatusTableWindow.buildRow_(tag, nowSec);
906+
newRow = TagStatusTableWindow.buildRow_(tag, nowSec, eventCountsByKey);
853907
oldRow = obj.RowBuffer_(idx, :);
854908
if ~isequal(newRow, oldRow)
855909
obj.RowBuffer_(idx, :) = newRow;
@@ -882,29 +936,45 @@ function onRefreshTick_(obj)
882936

883937
methods (Static, Access = public)
884938

885-
function row = buildRow_(tag, nowSeconds)
886-
%BUILDROW_ Return a 1x11 cell row describing tag's current status.
939+
function row = buildRow_(tag, nowSeconds, eventCountsByKey)
940+
%BUILDROW_ Return a 1x12 cell row describing tag's current status.
887941
% Columns: Key, Name, Type, Criticality, Units, Latest, Status,
888-
% Last updated, Activity, Samples, Labels.
942+
% Last updated, Activity, Events, Samples, Labels.
889943
%
890944
% Inputs:
891-
% tag -- Tag handle (any subclass; tolerant of throws)
892-
% nowSeconds -- (optional) current wall-clock time as posix
893-
% seconds, used for the Activity column. When
894-
% omitted, TagStatusTableWindow.nowSeconds_() is
895-
% queried (slightly more expensive). Tests pass
896-
% an explicit value for determinism. 260519-bs4 patch.
945+
% tag -- Tag handle (any subclass; tolerant of throws)
946+
% nowSeconds -- (optional) current wall-clock time as posix
947+
% seconds, used for the Activity column. When
948+
% omitted, TagStatusTableWindow.nowSeconds_()
949+
% is queried. Tests pass an explicit value for
950+
% determinism. 260519-bs4 patch.
951+
% eventCountsByKey -- (optional) containers.Map(char -> double)
952+
% giving precomputed per-tag event counts.
953+
% When the tag's Key is present in the map,
954+
% the Events column reads from the map.
955+
% Otherwise falls back to
956+
% countEventsForTag_(tag) which walks the
957+
% tag's bound EventStore. Pass [] or omit
958+
% to force the per-tag query. 260519-bs4-06.
897959
%
898960
% The Activity column is "Live" when X(end) is within
899961
% InactiveThresholdSeconds_ (5 minutes) of nowSeconds in the same
900962
% time base, else "Inactive". Empty / unconvertible / future X
901963
% defensively renders "Inactive".
902964
%
965+
% The Events column shows an integer count of events attached to
966+
% the tag (via EventStore.getEventsForTag). Tags with no
967+
% EventStore -- or any throw during the count -- render "0".
968+
% 260519-bs4-06 patch.
969+
%
903970
% Never throws -- a tag whose getXY/valueAt fails renders em-dash
904971
% placeholders for the dynamic columns AND "Inactive" for Activity.
905972
if nargin < 2 || isempty(nowSeconds)
906973
nowSeconds = TagStatusTableWindow.nowSeconds_();
907974
end
975+
if nargin < 3
976+
eventCountsByKey = [];
977+
end
908978
em = char(8212);
909979
key = '';
910980
name = '';
@@ -982,9 +1052,59 @@ function onRefreshTick_(obj)
9821052
% Leave placeholders; never throw.
9831053
end
9841054

1055+
% --- Events count (260519-bs4-06) ---
1056+
% Bucketed-by-key precomputed map preferred; falls back to a
1057+
% per-tag query when missing. countEventsForTag_ never throws.
1058+
useBucket = ~isempty(eventCountsByKey) && ...
1059+
isa(eventCountsByKey, 'containers.Map') && ...
1060+
~isempty(key) && eventCountsByKey.isKey(key);
1061+
if useBucket
1062+
try
1063+
nEvents = double(eventCountsByKey(key));
1064+
catch
1065+
nEvents = 0;
1066+
end
1067+
else
1068+
nEvents = TagStatusTableWindow.countEventsForTag_(tag);
1069+
end
1070+
eventsTxt = sprintf('%d', nEvents);
1071+
9851072
row = {key, name, typeLabel, crit, units, ...
9861073
latestTxt, statusTxt, lastUpdatedTxt, activityTxt, ...
987-
samplesTxt, labelStr};
1074+
eventsTxt, samplesTxt, labelStr};
1075+
end
1076+
1077+
function n = countEventsForTag_(tag)
1078+
%COUNTEVENTSFORTAG_ Integer count of events attached to a Tag.
1079+
% Returns 0 for tags with no EventStore, [] EventStore, or any
1080+
% exception raised while querying. Delegates to tag.eventsAttached
1081+
% when available (the Tag base class API, which itself wraps
1082+
% EventStore.getEventsForTag), so tag subclasses that override
1083+
% the lookup (e.g. MonitorTag binding to a shared store) all
1084+
% route through the same query path. Pure function -- safe to
1085+
% call from the refresh loop without side effects.
1086+
% 260519-bs4-06 patch.
1087+
n = 0;
1088+
if isempty(tag); return; end
1089+
try
1090+
% Skip cheap-fail-fast cases without touching the store.
1091+
if ~isprop(tag, 'EventStore') || isempty(tag.EventStore)
1092+
return;
1093+
end
1094+
if ismethod(tag, 'eventsAttached')
1095+
events = tag.eventsAttached();
1096+
else
1097+
events = tag.EventStore.getEventsForTag(char(tag.Key));
1098+
end
1099+
if isempty(events)
1100+
n = 0;
1101+
else
1102+
n = numel(events);
1103+
end
1104+
catch
1105+
% EventStore not bound / throws / etc. -> 0
1106+
n = 0;
1107+
end
9881108
end
9891109

9901110
function s = nowSeconds_()
@@ -1013,7 +1133,7 @@ function onRefreshTick_(obj)
10131133
% applies all four dimensions.
10141134
%
10151135
% Inputs:
1016-
% rows -- cell(N, 11) buffer (TagStatusTableWindow.RowBuffer_).
1136+
% rows -- cell(N, 12) buffer (TagStatusTableWindow.RowBuffer_).
10171137
% query -- char/string; empty / whitespace = no search filter.
10181138
% activeTypes -- cellstr subset of {sensor, monitor,
10191139
% composite, state, derived}. Omitted = all kept.
@@ -1025,7 +1145,7 @@ function onRefreshTick_(obj)
10251145
% Semantics:
10261146
% -- search: substring match (case-insensitive) on Key, Name,
10271147
% Units, OR Labels (Labels are stored as a comma-joined string
1028-
% in column 11).
1148+
% in column 12; was column 11 pre-260519-bs4-06).
10291149
% -- chip groups: AND across groups (a row passes only if it
10301150
% matches the active set of every group), OR within a group
10311151
% (a row matches if its value is in the active set).
@@ -1205,12 +1325,13 @@ function onRefreshTick_(obj)
12051325

12061326
function tf = rowMatchesSearch_(row, qLow)
12071327
%ROWMATCHESSEARCH_ Case-insensitive substring match on Key+Name+Units+Labels.
1208-
% row -- 1x11 cell. Columns 1 (Key), 2 (Name), 5 (Units), 11 (Labels).
1328+
% row -- 1x12 cell. Columns 1 (Key), 2 (Name), 5 (Units), 12 (Labels).
1329+
% (Labels column moved 11 -> 12 in 260519-bs4-06 when Events was inserted.)
12091330
% qLow -- already-lowercased query.
12101331
% Tolerates rows with missing / non-char columns (defensive try/catch).
12111332
tf = false;
12121333
try
1213-
for c = [1, 2, 5, 11]
1334+
for c = [1, 2, 5, 12]
12141335
val = row{c};
12151336
if ~ischar(val); continue; end
12161337
if ~isempty(strfind(lower(val), qLow)) %#ok<STREMP>

tests/suite/TestTagStatusTableWindow.m

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,14 +109,17 @@ function testMarkTagsDirty_updatesRow(testCase)
109109

110110
app.scanLiveTagUpdatesForTest_();
111111

112-
% Find tag_a row in the buffer; samples column (now index 10 after
113-
% Activity column was inserted at 9) should read '5' and latest
114-
% (index 6) should reflect 55.
112+
% Find tag_a row in the buffer; samples column (now index 11
113+
% after Events column was inserted at 10 in 260519-bs4-06) should
114+
% read '5' and latest (index 6) should reflect 55. The Events
115+
% column at index 10 must be '0' since no EventStore is bound.
115116
rowA = findRowByKey_(w, 'tag_a');
116117
testCase.verifyNotEmpty(rowA, ...
117118
'testMarkTagsDirty_updatesRow: tag_a row must exist in buffer');
118-
testCase.verifyEqual(rowA{10}, '5', ...
119+
testCase.verifyEqual(rowA{11}, '5', ...
119120
'testMarkTagsDirty_updatesRow: Samples must reflect new count after tick');
121+
testCase.verifyEqual(rowA{10}, '0', ...
122+
'testMarkTagsDirty_updatesRow: Events count is 0 with no EventStore');
120123
testCase.verifyTrue(any(strcmp(rowA{6}, {'55.00', '55', '55.000'})), ...
121124
sprintf(['testMarkTagsDirty_updatesRow: Latest must reflect new ' ...
122125
'value 55, got ''%s'''], rowA{6}));
@@ -352,6 +355,51 @@ function testPauseBtnLabelFlips(testCase)
352355
'testPauseBtnLabelFlips: label must revert to ''Pause polling'' after resume');
353356
end
354357

358+
function testEventsCountColumnPopulatedFromRegistry(testCase)
359+
%TESTEVENTSCOUNTCOLUMNPOPULATEDFROMREGISTRY Tag with events emits real Events count in table.Data.
360+
% Build a fixture where ONE tag has 3 bound events and the
361+
% other has none; open a window; verify the table column 10
362+
% carries the right per-row count. 260519-bs4-06 patch.
363+
TagRegistry.clear();
364+
EventBinding.clear();
365+
testCase.addTeardown(@() TagRegistry.clear());
366+
testCase.addTeardown(@() EventBinding.clear());
367+
368+
% First tag: no EventStore -> Events count must be 0.
369+
tA = SensorTag('no_events', 'Name', 'No Events');
370+
tA.updateData([1 2 3], [1 2 3]);
371+
TagRegistry.register('no_events', tA);
372+
373+
% Second tag: bind an EventStore with 3 events.
374+
store = EventStore('');
375+
tB = SensorTag('has_events', 'Name', 'Has Events');
376+
tB.updateData([1 2 3], [10 20 30]);
377+
tB.EventStore = store;
378+
for i = 1:3
379+
ev = Event(i, i + 0.5, 'has_events', 'thr', NaN, 'upper');
380+
store.append(ev);
381+
ev.TagKeys = {'has_events'};
382+
EventBinding.attach(ev.Id, 'has_events');
383+
end
384+
TagRegistry.register('has_events', tB);
385+
386+
app = FastSenseCompanion();
387+
testCase.addTeardown(@() safeClose_(app));
388+
w = app.openTagStatusTable();
389+
testCase.verifyEqual(w.bufferSize(), 2, ...
390+
'precondition: buffer must hold both rows');
391+
392+
rowNoEvents = findRowByKey_(w, 'no_events');
393+
rowHasEvents = findRowByKey_(w, 'has_events');
394+
395+
testCase.verifyNotEmpty(rowNoEvents, 'no_events row missing');
396+
testCase.verifyNotEmpty(rowHasEvents, 'has_events row missing');
397+
testCase.verifyEqual(rowNoEvents{10}, '0', ...
398+
'tag with no EventStore must show Events=0');
399+
testCase.verifyEqual(rowHasEvents{10}, '3', ...
400+
'tag with 3 bound events must show Events=3');
401+
end
402+
355403
function testRefreshTimerStoppedAndDeletedOnClose(testCase)
356404
%TESTREFRESHTIMERSTOPPEDANDDELETEDONCLOSE Window close must stop AND delete its timer.
357405
registerTwoSensors_();

0 commit comments

Comments
 (0)