# Design ## Context - **Charts:** `charts/chart-options.ts` builds every time-series option. - The x-axis is an ECharts `time` axis whose labels are `HH:mm`, `MM-dd HH:mm` or `MM-dd` by span, with no minimum interval, so a 3-minute range ticks every 20–30 s. - The y-axis has no minimum interval either. - Step series use `step: 'end'`: each value is drawn from its bucket start to the next point, so the last point has no segment. - **Skeletons:** `ovd-skeleton` paints its bars at once and is removed on the first response, which flashes when the response takes tens of milliseconds. - **Durations:** `formatDuration()` truncates at every unit. `ovd-duration` formats both running and stored durations with it. - **About storage:** `InfoService.storageEntry()` lists each collection's indexes and dates it only when an index leads with `ts`, `bucket` or `createdAt`. The analytics indexes of the time fields are: - `rooms.createdAt`, `ingresses.createdAt`; - `participants { joinedAt: -1, _id: -1 }`; - `egresses { startedAt: -1, _id: -1 }` and `ingress_sessions { startedAt: -1, _id: -1 }`; - `events.ts`, `analytics_5min.bucket`, `analytics_hourly.bucket`; - `analytics_daily { scope, bucket }`; - the `track_stats` time series, indexed on its metadata before `ts`; - `nodes`, with no time index and one document per node; - `tracks` and `track_subscriptions`, with their time field only behind `participantSid`, `roomSid`, `subscriberSid` or `trackSid`. - **Global search:** `exactIdTarget()` recognises `TR_` through `entityIdKind()` but drops it, because no route takes a track ID. - **Browser and OS labels:** the rollups key browsers by family, and an empty browser falls into `other` until the writer change adds `none`. Pages that show a client's OS print the reported string (`linux` from the Go SDK, `Linux` from the JS SDK). ## Goals / Non-Goals **Goals:** - One rule per chart property, applied in `timeSeriesOption()` for every chart of every edition. - No new inputs on the chart, the skeleton or the duration components. **Non-Goals:** - Changing the label formats of the axes or the retention notices. - New indexes for tracks and subscriptions. ## Decisions ### D1. The x-axis interval follows the label unit `minInterval` is 60 000 ms when the labels show minutes (`HH:mm`, `MM-dd HH:mm`) and 86 400 000 ms when they show days. It comes from the same span rule as the label format. Rejected: passing the bucket width from every chart. The labels are the constraint, and the granularity is already in the chart note. ### D2. Integer data gets integer ticks `yAxis.minInterval` is 1 when every non-null value of every series is an integer. Rejected: an `integer` input on the chart, which every count chart of every edition would have to set. ### D3. A step series is extended to `to` When the last point of a step series is not null and starts before `to`, `timeSeriesOption()` appends `[to, value]`, so `step: 'end'` draws the last bucket up to the end of the range. A dense series always ends in the bucket that holds `to`. Rejected: `step: 'start'`, which would draw every value one bucket early. ### D4. The skeleton fades in after 300 ms Every loading placeholder keeps its height from the start and carries the global class `ovd-skeleton-reveal`, whose CSS animation keeps its opacity at 0 for 300 ms and then shows it: `ovd-skeleton`, the skeleton rows of `ovd-data-table` and the KPI tile skeleton. Pages keep their loading logic. Under `prefers-reduced-motion` the global rule shortens the fade and the delay stays. Rejected: - delaying the loading flag in every loader, which touches every page and both loaders; - keeping the previous content, which does not exist on a first load. ### D5. Stored durations are rounded, running ones are not `formatDuration(ms, { mode })` rounds to the smallest unit it will show by default: - it picks the unit from the magnitude, rounds, and formats again when rounding crosses a unit (59 600 ms → 60 s → "1 m 00 s"); - `mode: 'floor'` keeps the current truncation; - `ovd-duration` passes `floor` while `running` is true, both for `liveDurationMs` and for `liveTotalMs`. Rejected: rounding live durations, which would make a counter jump to the next second halfway through. ### D6. A fixed time field per collection `InfoService` holds one map from collection to field, and optional filter, and reads `find(filter).sort({ field: 1 }) .limit(1)` with a projection of the field. This replaces the index discovery and saves a `listIndexes` per collection. | Collection | Field | | --- | --- | | `rooms`, `ingresses` | `createdAt` | | `participants` | `joinedAt` | | `egresses`, `ingress_sessions` | `startedAt` | | `events` | `ts` | | `analytics_5min`, `analytics_hourly` | `bucket` | | `analytics_daily` | `bucket` with `{ scope: 'global' }` | | `nodes` | `firstSeenAt` (one document per node, no index needed) | `tracks`, `track_subscriptions` and `track_stats` have no entry, and About explains the "—". The `track_stats` query was explained on a generated dataset: the time series has no index on its time alone, so the plan is a bounded sort over a scan of the buckets. Rejected: - new `{ publishedAt }` and `{ subscribedAt }` indexes, two more indexes on the most written collections for one About cell; - a sort without an index, a collection scan every 60 s. ### D7. Plain version text `sidenav-footer.ts` renders the version in a `span`. ### D8. The room averages say why they are missing `missingHint()` takes the reason: `roomsEnded === 0` gives "No room ended in this range", anything else "Not provided by this server version". ### D9. Total duration tile The Egress page tile is labelled "Total duration", with `INFO_TEXTS.egressTotalDuration`. ### D10. Browser labels and the OS label `format/labels.ts` gains: - `BROWSER_LABELS` for `chrome`, `firefox`, `safari`, `edge`, `none` ("No browser") and `other`, used by the browser donut, which today prints the key; - `clientOsLabel(os)`, which compares the reported string, case-insensitively and trimmed, with the family names (`windows`, `macos`, `mac os`, `linux`, `android`, `ios`) and returns the `OS_LABELS` entry, or the string as reported. The library exports both for the detail pages of every edition. Rejected: mapping by substring as the writer does, which would turn "Chrome OS" into "Linux" on a page meant to show what the client reported. ### D11. A locked track route `routes.ts` adds `tracks/:trackSid`: - with no navigation entry and `page.feature: 'participant-detail'`; - with the breadcrumb param `trackSid`, which titles the lock, as the route has no list above it; - opening a locked page that reuses the participant lock. `exactIdTarget()` maps `track` to `/tracks`. An edition with the feature replaces the route by path, as it does for the other detail routes. ## API No shape change: `storage..oldest` exists and only covers more collections. ## Performance budget About reads at most eleven point queries every 60 s, with the existing cache. Each is verified in the info integration test with `explain()`: an `IXSCAN` with one document examined, and a `COLLSCAN` only on `nodes`. `track_stats` has no entry: its plan scans the buckets of the time series. ## Risks / Trade-offs - [Rounding changes durations across the UI, by up to half a unit] → Unit tests of the formatter for both modes, and updated fixtures where a duration reads differently. - [A running duration that ends shows one second more than it last counted] → Accepted: the stored value is the measured one. - [The `none` browser key only appears in rollups written after the writer change] → Older rollups keep those sessions under `other`, which still reads "Other". ## Migration Plan Ships with the 3.10 dashboard. The browser label of `none` relies on the typings of `writer-review-fixes`, applied first.