@meshmakers/octo-meshboard 3.4.1130 → 3.4.1140

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2535,17 +2535,26 @@ class MeshBoardStateService {
2535
2535
  /**
2536
2536
  * The IANA time zone to forward to the resolution-aware series query so calendar
2537
2537
  * rollups are selected in the board's zone (AB#4190). Maps the board mode:
2538
- * `'utc'` → `'UTC'`, an IANA id → itself, and `'local'` → `undefined` (the browser's
2539
- * own zone has no server-side meaning, so the resolver stays on UTC — the same
2540
- * basis its calendar rungs use by default). The result is consistent with the zone
2541
- * `resolveCurrentTimeRange()` used to compute the `{from, to}` window.
2538
+ * `'utc'` → `'UTC'`, an IANA id → itself, and `'local'` → the browser's own IANA
2539
+ * zone. The result is therefore always the same basis `resolveCurrentTimeRange()`
2540
+ * used to compute the `{from, to}` window — in `'local'` mode that window is built
2541
+ * from browser-local wall clocks, so naming that zone is what makes the two agree.
2542
+ *
2543
+ * `'local'` used to send nothing, which left the resolver on UTC. Under the default
2544
+ * per-query comparison policy a calendar rung stored in another zone then has no
2545
+ * determinate grain and drops out of the ladder, so the board silently fell back to
2546
+ * a finer rung, reported signal `Ok`, and drew a window that was not the one asked
2547
+ * for (AB#5157 review). `undefined` now only means the browser named no zone at all.
2542
2548
  */
2543
2549
  resolveStreamDataTimeZone() {
2544
2550
  const mode = this.timeZoneMode();
2551
+ if (mode === 'utc') {
2552
+ return 'UTC';
2553
+ }
2545
2554
  if (mode === 'local') {
2546
- return undefined;
2555
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
2547
2556
  }
2548
- return mode === 'utc' ? 'UTC' : mode;
2557
+ return mode;
2549
2558
  }
2550
2559
  /**
2551
2560
  * Stores the source rtIds resolved from an entity selector's current
@@ -2945,6 +2954,7 @@ const ResolveSeriesQueryDocumentDto = gql `
2945
2954
  signal
2946
2955
  actualPoints
2947
2956
  diagnostic
2957
+ finerRungAvailableFrom
2948
2958
  }
2949
2959
  }
2950
2960
  }
@@ -3158,7 +3168,8 @@ class QueryExecutorService {
3158
3168
  reducingFunction: decision.reducingFunction,
3159
3169
  signal: decision.signal,
3160
3170
  actualPoints: decision.actualPoints ?? null,
3161
- diagnostic: decision.diagnostic ?? null
3171
+ diagnostic: decision.diagnostic ?? null,
3172
+ finerRungAvailableFrom: decision.finerRungAvailableFrom ?? null
3162
3173
  };
3163
3174
  }
3164
3175
  /**
@@ -14626,6 +14637,9 @@ class LineChartWidgetComponent {
14626
14637
  * Resolution-aware hint (AB#4290): a short badge describing the archive-selection outcome —
14627
14638
  * `null` for a clean reduction (signal OK), a warning otherwise (fewer points delivered, or no
14628
14639
  * compatible rollup so the raw archive was returned unreduced). Mirrors the resolver's signal.
14640
+ * `CoverageLimited` (AB#5157) is a distinct badge: a finer rung was skipped for holding no data
14641
+ * over the window, which is a history gap rather than a density problem — so it is always shown,
14642
+ * bypassing the {@link RESOLUTION_LIMIT_WARN_PX} suppression that mutes a merely coarse line.
14629
14643
  */
14630
14644
  resolutionHint = computed(() => {
14631
14645
  const s = this._resolutionSignal();
@@ -14633,6 +14647,20 @@ class LineChartWidgetComponent {
14633
14647
  return null;
14634
14648
  const diag = s.diagnostic ?? '';
14635
14649
  switch (s.signal) {
14650
+ case SeriesResolutionSignalDto.CoverageLimitedDto: {
14651
+ // Coverage, not density (AB#5157): the finer rung was skipped because it holds NO data
14652
+ // over the window, so the pixel-density suppression must not apply — a dense-looking
14653
+ // coarse line is still the wrong answer to "where is my history?". Always flagged.
14654
+ const availableFrom = formatInstant(s.finerRungAvailableFrom, this.stateService.timeZoneMode(), {
14655
+ day: '2-digit', month: '2-digit', year: 'numeric'
14656
+ });
14657
+ return {
14658
+ text: '⚠ no history',
14659
+ title: availableFrom
14660
+ ? `History at this resolution is available from ${availableFrom}`
14661
+ : diag || 'A finer resolution exists but holds no data for this time range.'
14662
+ };
14663
+ }
14636
14664
  case SeriesResolutionSignalDto.ResolutionLimitedDto: {
14637
14665
  const delivered = s.actualPoints ?? s.points;
14638
14666
  const requested = this._resolutionTarget();
@@ -14667,7 +14695,20 @@ class LineChartWidgetComponent {
14667
14695
  const delivered = s.actualPoints ?? s.points;
14668
14696
  const requested = this._resolutionTarget();
14669
14697
  const ofReq = requested ? ` (the chart could show ~${requested})` : '';
14698
+ const diag = s.diagnostic ?? '';
14670
14699
  switch (s.signal) {
14700
+ case SeriesResolutionSignalDto.CoverageLimitedDto: {
14701
+ const availableFrom = formatInstant(s.finerRungAvailableFrom, this.stateService.timeZoneMode(), {
14702
+ day: '2-digit', month: '2-digit', year: 'numeric'
14703
+ });
14704
+ const from = availableFrom
14705
+ ? ` That resolution only has history from ${availableFrom} onwards.`
14706
+ : '';
14707
+ return `A finer resolution exists for this series, but it holds no data for the selected time range, `
14708
+ + `so the chart fell back to a coarser one (${delivered} point(s)${ofReq}).${from} `
14709
+ + `The values are correct — narrow the range to a covered period to see finer detail.`
14710
+ + (diag ? ` ${diag}` : '');
14711
+ }
14671
14712
  case SeriesResolutionSignalDto.ResolutionLimitedDto:
14672
14713
  return `This chart auto-selects the coarsest stored resolution that still fits the view. `
14673
14714
  + `For this time range the finest matching rollup only provides ${delivered} point(s)${ofReq}, `
@@ -14925,28 +14966,23 @@ class LineChartWidgetComponent {
14925
14966
  if (!resolution) {
14926
14967
  return { rows: [], signal: null, requestedPoints: targetPoints };
14927
14968
  }
14928
- // Snap the query window to the resolver's effective-bucket grid. The downsampling engine only
14929
- // aggregates a stored point into a bin when the bin width equals the data grain AND the window
14930
- // start sits on that grid (from the epoch). An unaligned window — e.g. a relative "last N days"
14931
- // filter ending at the current wall-clock time (…:42:54) — otherwise makes every bin miss the
14932
- // hourly/interval data and yields an all-null (blank) chart. Aligning from + sizing to
14933
- // points × bucket makes each bin land exactly on a stored point.
14934
- let qFrom = from;
14935
- let qTo = to;
14936
- if (resolution.effectiveBucketMs > 0) {
14937
- const bucket = resolution.effectiveBucketMs;
14938
- const alignedFrom = Math.floor(from.getTime() / bucket) * bucket;
14939
- qFrom = new Date(alignedFrom);
14940
- qTo = new Date(alignedFrom + Math.max(1, resolution.points) * bucket);
14941
- }
14969
+ // The requested window is passed through unchanged. The widget used to snap it to the
14970
+ // resolver's effective-bucket grid, because the downsampling engine binned from whatever start
14971
+ // it was handed and an unaligned window (a relative "last N days" filter ending at the current
14972
+ // wall clock) made every bin miss the stored points. That alignment now happens where the grain
14973
+ // is actually known — the engine snaps its own axis origin — and doing it here was wrong twice
14974
+ // over for a calendar rung (AB#5157 review): `effectiveBucketMs` is an average, so anchoring it
14975
+ // to the epoch landed the axis off the calendar boundaries and outside the requested window,
14976
+ // and sizing the end as points × bucket cut the running period off. No client can compute this
14977
+ // axis: neither the grain nor the rung's alignment is part of the query contract.
14942
14978
  // Group-aggregation mode (AB#4714): one downsampling call PER GROUP, each with the whole
14943
14979
  // group's rtIds, so the transient downsampling reduces over every member (per
14944
14980
  // requiredAggregation) into one aggregate line. Default mode: one call per source rtId, keeping
14945
14981
  // each source its own series. See loadGroupAggregatedRows / loadPerRtIdRows.
14946
14982
  const downsample = (rtIds, label) => this.queryExecutor.downsampleByArchive({
14947
14983
  archiveRtId: resolution.archiveRtId,
14948
- from: qFrom,
14949
- to: qTo,
14984
+ from,
14985
+ to,
14950
14986
  limit: Math.max(1, resolution.points),
14951
14987
  sourcePath,
14952
14988
  aggregation: resolution.reducingFunction,
@@ -15731,7 +15767,7 @@ class LineChartConfigDialogComponent {
15731
15767
  [(ngModel)]="selectedPersistentQuery"
15732
15768
  (querySelected)="onQuerySelected($event)"
15733
15769
  placeholder="Select a Query..."
15734
- hint="Select a query that returns time-series data with grouping.">
15770
+ hint="Select a stream-data query on the archive you want to chart — a simple one is enough. The widget picks the rollup rung and downsamples itself; the fields below group the rows into series.">
15735
15771
  </mm-query-selector>
15736
15772
  </div>
15737
15773
 
@@ -15992,7 +16028,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
15992
16028
  [(ngModel)]="selectedPersistentQuery"
15993
16029
  (querySelected)="onQuerySelected($event)"
15994
16030
  placeholder="Select a Query..."
15995
- hint="Select a query that returns time-series data with grouping.">
16031
+ hint="Select a stream-data query on the archive you want to chart — a simple one is enough. The widget picks the rollup rung and downsamples itself; the fields below group the rows into series.">
15996
16032
  </mm-query-selector>
15997
16033
  </div>
15998
16034