Compare commits

..
Author SHA1 Message Date
xavierk d894ae290f docs: draft TUI polish companion spec 2026-09-14 02:13:34 +05:30
15 changed files with 390 additions and 2074 deletions
+23 -3
View File
@@ -32,13 +32,25 @@ _Avoid_: Data directory, history.jsonl, the database (generic)
The condition where the observation store is present but cannot be read or trusted — unreadable, corrupt, or written by a newer Fenris — degrading every view that depends on it rather than crashing or guessing.
_Avoid_: Database error, corruption, broken data
**Usage interval**:
The elapsed span between two compatible counter observations, with a measured usage total whose distribution within that span may be unknown.
_Avoid_: Estimated hourly usage, interpolated sample
**Unallocated usage**:
Measured usage whose share in a particular hour, calendar day, or monitoring period cannot be established from the available evidence.
_Avoid_: Zero usage, evenly distributed writes
**Hour observation**:
One row per UTC hour in the observation store, recording that hour's usage-habit split into active, idle, powered-off, and unknown seconds, plus write/read deltas, thermal evidence, and coverage.
The usage-habit evidence for a UTC hour's represented elapsed span: active, idle, powered-off, and unknown time, measured usage, thermal evidence, and coverage. A partial hour does not describe future time.
_Avoid_: Hourly record, hourly.jsonl entry
**Day aggregate**:
One row per UTC day derived from hour observations; the grain at which usage-habit evidence is judged.
_Avoid_: Daily summary, daily stats
The UTC-day summary at which usage-habit evidence is judged; distinct from a local display day.
_Avoid_: Local daily total, daily stats
**Local display day**:
A calendar day in the user's current system timezone, used to browse observation history; its elapsed length can vary with timezone transitions.
_Avoid_: UTC evidence day, fixed 24-hour day
**Controller segment**:
A span of observation history within which the drive's controller identity is unchanged and counters are monotonic; write deltas are never computed across a segment boundary.
@@ -76,6 +88,14 @@ _Avoid_: Confidence interval, error bar
The share of wall-clock seconds inside monitoring periods whose usage-habit classification is known rather than unknown.
_Avoid_: Uptime, sample count
**Byte-allocation completeness**:
Whether the available evidence establishes all monitored writes attributable to a specified span, without missing counter evidence or unknown boundary shares; distinct from usage-habit classification coverage.
_Avoid_: Coverage, estimated allocation
**Qualifying day**:
A UTC date whose represented monitored time meets the coverage requirement for projection evidence. Qualification is provisional while the date is in progress and does not establish byte-allocation completeness.
_Avoid_: Completed day, supported day, calibration day
**Collection run**:
One scheduled or on-demand execution of the collector that interrogates the drive and extends the observation history.
_Avoid_: Poll, daemon tick
+16
View File
@@ -144,3 +144,19 @@ Decided in [Write the dashboard clarity acceptance criteria](https://git.bongbet
- **DC-6** (A) CHANGELOG.md shape (Keep a Changelog 1.1): `## [Unreleased]` always present at top, even empty; version headings `## [X.Y.Z] - YYYY-MM-DD` with strict ISO date; categories Added/Changed/Fixed only, security folding into Fixed; entries are single `- ` bullets, imperative mood, user-facing, no commit hashes or issue numbers.
- **DC-7** (A) Extraction fails closed: `scripts/extract_changelog.py` slices the requested version's section verbatim and never reads `[Unreleased]`; a missing or empty section or a malformed date produces `::error::` and a nonzero exit; the release workflow fails when the pushed tag ≠ `v{version from pyproject.toml}` (guard skipped on `workflow_dispatch`).
- **DC-8** (A/P) Release body: the body is the extracted section verbatim plus the standing footer from `packaging/release-footer.md`; a re-run against an existing release PATCHes the body (re-sync is a feature) while uploaded assets skip idempotently. A covers assembly/PATCH-logic unit tests; P is one scripted `workflow_dispatch` verification of body assembly.
## TUI polish and hourly history ([Fenris TUI polish and hourly history](https://git.bongbetic.com/xavierk/Fenris/issues/65))
Proposed by [Approve the Fenris TUI polish specification and handoff](https://git.bongbetic.com/xavierk/Fenris/issues/70), from the companion specification [`fenris-tui-polish-hourly-history.md`](fenris-tui-polish-hourly-history.md). This section amends the frozen redesign and prior dashboard-clarity criteria without editing their historical source specs. Where these criteria conflict with older TUI/header/history criteria, these newer criteria win. In particular, **TPH-1** supersedes **DC-1**'s header/credit placement; **TPH-2** and **TPH-3** refine **CI-2**, **LC-10**, and **TUI-4** status rendering; **TPH-4** through **TPH-7** refine **ST-5**, **PR-2** through **PR-9**, and **FL-1** through **FL-4** with the approved hourly-history and UTC-accounting contracts.
- **TPH-1** (A) *Titlebox and maker credit*: the TUI renders a top titlebox exactly `🐺 Fenris by Bongbetic`, falling back exactly to `Fenris by Bongbetic` when the wolf glyph is unsupported or width-unstable; no replacement-box glyph is shown; the old service-strip `by Bongbetic` credit is absent; `fenris status` renders no titlebox. Continuity, paused, quit, auth, parity, and release-notes behavior from **DC-2** through **DC-8** remains unchanged.
- **TPH-2** (A) *Status lattice and precedence*: fixture-driven TUI status rendering covers Monitoring, Collecting, Paused, Waiting, Interrupted, Error, Stale, and Unknown with the approved glyphs/text, semantic colours, and reason lines; only Monitoring's dot blinks, never text; reduced motion makes it steady; precedence is Error > Interrupted > Paused > Stale > Waiting > Monitoring > Unknown, with Collecting as an overlay except over store fault.
- **TPH-3** (A/P) *CLI/status parity and timing*: `fenris status` renders the same status vocabulary, glyphs, precedence, and reason lines statically; freshness, last outcome, boot enablement, and collection activity remain separate facts; a lightweight 5 s unit-state poll can move cached freshness boundaries without a store read, while new samples appear only after the normal store refresh; store faults render `observation store unreadable — see journal` and suppress store-dependent views.
- **TPH-4** (A) *Warm-up and withheld estimates*: projection warm-up shows `Building evidence — N of 14 days observed · Q qualifying` plus `First lifespan estimate after 12 qualifying days`; the gate is 14 represented UTC dates in the current controller segment with at least 12 qualifying, while Supported separately requires 14 qualifying dates and all existing prerequisites. Missing baseline, unsupported write counters, warm-up, stale evidence, paused days, and unavailable numerators render explicit reason lines, never blank or misleading zero; first graph-data availability is independent of lifespan-estimate availability.
- **TPH-5** (A) *Collector-owned history publication and first data*: collection publishes validated sample → usage interval → hour observation/day aggregate results consistently before reporting success; the TUI remains read-only. Zero samples show awaiting-first-sample; one sample shows `Awaiting another sample`; the first compatible sample pair can show measured partial-hour `so far`; measured zero is `0 B`; missing, unsupported, invalid, or unavailable evidence is never converted to zero.
- **TPH-6** (A) *Local display days, attribution, gaps, pauses, and repair*: history browsing groups retained evidence by the current system timezone with the timezone label visible, including DST and fractional-offset cases; timestamped usage intervals are retained indefinitely alongside hour/day summaries, while raw samples keep the 14-day policy except needed boundary anchors. Measured interval totals are preserved once; cross-boundary shares render as unallocated usage rather than interpolation or endpoint assignment; gaps remain distinct from zero; future time is not counted; deliberate-disable time is excluded; pause-crossing bytes are not counted as monitored totals; repair is transactional/idempotent and cannot overwrite valid older history with incomplete reconstruction.
- **TPH-7** (A) *UTC projection accounting and evaluability*: 7/28/90-day scenario windows end at the latest published usage-evidence endpoint `T` and start exactly 7/28/90 × 86,400 seconds earlier; denominators are monitored wall-clock seconds in the same span; rates are withheld when the monitored numerator cannot be established. Byte-allocation completeness and coverage are independent. Current partial UTC dates can qualify provisionally using elapsed monitored time; habit changes require completed consecutive UTC days with evaluable totals; unknown daily totals block burst/habit checks and Supported confidence; resets/replacements and legacy summaries obey the approved segment and actual-precision rules without changing lifespan math or numeric thresholds.
- **TPH-8** (A/M) *Writes-only graph and drill-down*: the TUI renders a writes-only daily bar graph, default 14 days, selectable 7/14/28/90 days, labelled `usage history · Local · UTC±HH:MM · <tz name>`; graph focus supports `←`/`→`, `Enter`, `Esc`/`Backspace`, and `1`/`2`/`3`/`4`, with mouse equivalents for select/drill/back where Textual support is available. Daily bars drill into hourly bars and back. The legend distinguishes allocated `█`, unallocated `▒`, gap `░`, measured zero `·`, partial `┄`, and selection `▼`; selected readout states totals, evidenced hours, unallocated usage, coverage, and partial elapsed facts. Reads graphing is out of scope.
- **TPH-9** (A) *Terminal size and graph implementation*: at 80×24 the default range graph and hourly drill-down fit; below 80×24 the graph region hides and shows a one-line textual history summary plus exactly `graph needs ≥80×24`, while titlebox, status reason, drive health, service facts, quit rail/action affordances, and selected-day context survive. The graph uses a custom block-glyph renderable; no new plotting dependency is added for this graph.
- **TPH-10** (A/M) *Colour presets, persistence, and reduced motion*: Amber, Nord, and High Contrast presets are available; Amber is the default and keeps the graph amber by default; status semantic colours/glyphs/text outrank theme styling. Preset and reduced-motion choices persist per unprivileged user at `${XDG_CONFIG_HOME:-~/.config}/fenris/tui.json`, not in `/etc/fenris/fenris.conf`, the observation store, helper state, package config, or collector/device configuration; missing preferences default to Amber and normal motion; `t preset` and `m motion` controls plus accessible clickable equivalents are available; preferences never affect collection, projection, history evidence, or `fenris status`.
- **TPH-11** (A) *Drive health and settings grouping*: vendor wear renders under Drive health with temperature, spare, media errors, unsafe shutdowns, power-on hours, cycles, capacity, and written-total context, and remains context rather than a second projection. Settings is read-only and limited to device selector, endurance baseline/provenance, retention facts, and TUI display preferences; no custom colour editor exists.
@@ -0,0 +1,351 @@
# Fenris TUI polish and hourly history companion specification
**Status:** approval-ready draft for [Approve the Fenris TUI polish specification and handoff](https://git.bongbetic.com/xavierk/Fenris/issues/70). It becomes implementation-ready only when that ticket records human approval. This is a planning asset: no production code, release gate, package, installation change, or runtime diagnosis is made here.
**Canonical sources.** This companion integrates the closed decisions on [Fenris TUI polish and hourly history](https://git.bongbetic.com/xavierk/Fenris/issues/65): [Define trustworthy hourly history and first-data availability](https://git.bongbetic.com/xavierk/Fenris/issues/66), [Choose daily graph encoding and hourly drill-down](https://git.bongbetic.com/xavierk/Fenris/issues/68), [Reconcile unallocated usage with UTC projection evidence](https://git.bongbetic.com/xavierk/Fenris/issues/71), [Define monitoring signals and honest warm-up estimates](https://git.bongbetic.com/xavierk/Fenris/issues/67), and [Approve titlebox, health layout and colour presets](https://git.bongbetic.com/xavierk/Fenris/issues/69). Terminology follows [`CONTEXT.md`](../../CONTEXT.md), including *Usage interval*, *Unallocated usage*, *Local display day*, *Byte-allocation completeness*, and *Qualifying day*.
**Relationship to existing specs.** [`fenris-redesign.md`](fenris-redesign.md) stays frozen. This companion amends it without editing it. [`dashboard-clarity.md`](dashboard-clarity.md) remains binding except where this document explicitly supersedes its header/credit placement. In the tracker, [Implement dashboard clarity and release notes](https://git.bongbetic.com/xavierk/Fenris/issues/61) is currently closed; this companion is a new follow-on handoff, not a rewrite or reopening of that shipped umbrella.
**Binding language.** *Must*, *exactly*, and *never* are normative.
## 1. Supersession and preserved requirements
This companion supersedes only these dashboard-clarity identity placements:
- the old header `Fenris — NVMe endurance monitor`;
- the old dimmed `by Bongbetic` credit in the service strip.
The new identity contract is the top titlebox in §2. The titlebox is the sole maker-credit surface.
Everything else from [Chart Fenris dashboard clarity](https://git.bongbetic.com/xavierk/Fenris/issues/55) remains intact unless a more specific clause below amends its placement: the continuity wording, paused block and Deliberate-disable semantics, `q QUIT TUI` rail, footer action ownership, polkit-accurate auth banner, TUI/CLI wording parity where binding, changelog-driven release notes, and the quit-versus-pause distinction. Polkit wording remains polkit-accurate; no new user-facing TUI/status string says "sudo".
## 2. Product identity, health layout, and settings
### 2.1 Titlebox
The TUI's top titlebox must read exactly:
```text
🐺 Fenris by Bongbetic
```
If the wolf glyph is unsupported or would disturb titlebox width, the fallback is exactly:
```text
Fenris by Bongbetic
```
Do not render tofu, replacement boxes, or an unstable emoji-width layout. The lifespan headline remains a drive/projection data surface, not the application title. `fenris status` does not render this titlebox.
### 2.2 Maker credit
Remove the duplicate `by Bongbetic` service-strip credit in the polished layout. The titlebox is the only maker-credit surface. This is the explicit amendment to the prior dashboard-clarity header/credit placement; it does not weaken the preserved continuity, pause, quit, auth, parity, or release-notes requirements.
### 2.3 Drive health and settings grouping
Move vendor wear under **Drive health**, alongside temperature, spare, media errors, unsafe shutdowns, power-on hours, cycles, capacity, and written-total context. Vendor wear remains context, never a second projection.
**Settings** remains read-only and limited to the device selector, endurance baseline/provenance, retention facts, and TUI display preferences. No custom colour editor is added.
## 3. Monitoring status, collection activity, and warm-up messaging
### 3.1 Status lattice
Every status line carries a glyph and text label. Colour is never the sole carrier. Text never blinks.
| State | Colour | Glyph | Motion | Exact status / explanation contract |
|---|---|---|---|---|
| Monitoring | green | `●` | blink 750 ms on / 750 ms off, status dot only | `● Monitoring` |
| Collecting | green | `◐` | steady | `◐ Collecting` — run in flight, bounded by the 90 s collection timeout |
| Paused | amber | `‖` | steady | `‖ Paused` — `monitoring paused — paused time excluded from your usage habit` |
| Waiting | amber | `○` | steady | `○ Waiting` — `last sample X ago`, `awaiting first sample`, or `awaiting another sample` |
| Interrupted | red | `⊘` | steady | `⊘ Interrupted` — `collection stopped outside Fenris — monitoring period still open` |
| Error | red | `✖` | steady | `✖ Error` — `last run failed (exit N)` plus `last good sample X ago` when data is still fresh, or `observation store unreadable — see journal` |
| Stale | red | `◌` | steady | `◌ Stale` — `last sample X days ago` when the timer is active and no failure is recorded |
| Unknown | grey | `?` | steady | `? Unknown` — `service state unavailable` |
Reduced motion, either from Textual's reduced-motion signal or the user's preference, renders Monitoring as a steady `● Monitoring`. The CLI form is always steady.
### 3.2 Precedence
Base-state precedence is:
```text
Error > Interrupted > Paused > Stale > Waiting > Monitoring > Unknown
```
Additional rules:
- Paused outranks Stale. A drive paused for 49 days is amber Paused with `last sample 49 days ago` as a fact line, not a stale alarm.
- Error outranks Interrupted; the external stop rides in the explanation line when both facts exist.
- Unknown is used only when the service query fails and no store-derived fact, such as an open monitoring period, freshness, or deliberate-pause row, places the state higher.
- Collecting is an overlay, not a base rung. While the oneshot is in flight it overrides every base state except a store fault. While overlaying Paused, Interrupted, or retry-after-failure, the explanation line names the underlying state: for example, `run in flight — paused` or `run in flight — retry`. It reverts within the 90 s collection timeout to whatever state the outcome earns.
### 3.3 Separate facts
Do not fold these facts into the status word:
- `Last sample: X ago` freshness;
- last outcome: ok, failed with exit code, or none;
- `Start at boot: on/off` boot enablement;
- collection activity, which is represented by the Collecting overlay.
Blink means exactly "monitoring enabled and data fresh". It never means that collection just succeeded; collection activity is explicit Collecting.
### 3.4 Timing and refresh
A lightweight 5 s `systemctl show` poll of the timer and service units drives the status strip. The full store refresh remains at the 5-minute cadence. Status transitions are re-evaluated every poll tick from cached newest-sample timestamp plus current clock, so freshness aging boundaries cross within roughly 5 s without a store read. A new sample requires the normal store refresh.
The existing constants remain unchanged: fresh is newest sample within 2 × cadence + `AccuracySec` + 60 s, stale is at 48 h, collection timeout is 90 s, and default cadence is 5 min.
### 3.5 Canonical transitions
The implementation must make these states directly observable:
- failed run, data 4 min old: `✖ Error` — `last run failed (exit 3) · last good sample 4 min ago`;
- next successful run: `● Monitoring` after the status poll/refresh clears the failure;
- failed run with retry in flight: `◐ Collecting` — `run in flight — retry`, then Error or Monitoring;
- suspend for 3 h, wake, timer fires: `○ Waiting` → `◐ Collecting` → `● Monitoring`;
- external stop with fresh data: `⊘ Interrupted` — `collection stopped outside Fenris`;
- external stop 3 days later: still `⊘ Interrupted`, with `last sample 3 days ago` visible;
- fresh install with zero samples: `○ Waiting` — `awaiting first sample`;
- one sample but no compatible pair: `○ Waiting` — `awaiting another sample`;
- unreadable observation store: `✖ Error` — `observation store unreadable — see journal`, with store-dependent views suppressed.
### 3.6 Warm-up and withheld estimates
Projection warm-up uses the UTC accounting rules in §5. The progress block during warm-up is:
```text
Building evidence — N of 14 days observed · Q qualifying
First lifespan estimate after 12 qualifying days
```
`N` counts distinct represented UTC dates in the current controller segment, capped at 14 for the display. `Q` counts dates whose represented monitored span has at least 50% coverage. The gate for the first lifespan estimate is 14 represented UTC dates with at least 12 qualifying; Supported confidence still separately requires at least 14 qualifying dates plus every other confidence prerequisite.
Do not promise a countdown by hours. Days are the grain, and a provisional day can still fail qualification. Graph-data availability is independent: the graph can render from first usable evidence while the lifespan estimate remains withheld.
Withheld-estimate reason lines are explicit, never blank and never zero-filled:
- `No endurance baseline — set a rated TBW to see an estimate`;
- `Drive does not report write counters`;
- the warm-up progress block above;
- stale evidence: show the estimate frozen at the latest published usage-evidence endpoint with `estimate not updating — last sample X ago`;
- paused days are excluded from the day count, and the Paused status carries that fact.
Once warm-up clears, the existing lifespan line and Limited/Supported label from the frozen redesign specification render unchanged. This companion adds the progress block, reason lines, status precedence, and frozen-note behavior; it does not invent a new steady-state projection format.
### 3.7 CLI parity
`fenris status` adopts the same status vocabulary, precedence, glyphs, and reason lines, rendered statically. It shows Collecting only if a run is in flight at query time. Display preferences and TUI themes never affect CLI facts.
## 4. History evidence, local browsing, and publication
### 4.1 Ownership and publication
The collector owns sample → usage interval → hour observation/day aggregate derivation. It must publish a consistent validated result before reporting collection success. Readers must not see a new sample advertised as fully derived while dependent history is missing.
The TUI is read-only. Its next successful refresh sees whatever the collector has durably published, regardless of whether the TUI was running during collection. There is no hourly batch wait and no projection-confidence gate on usage history. Failure preserves prior valid history and remains explicit.
### 4.2 First visible data
One successful sample establishes counter, health, and freshness evidence, but not a usage delta; display `Awaiting another sample`. The first usable sample pair may display measured partial-hour usage labelled `so far` when attribution supports it. A usable pair has valid ordered timestamps and supported, nonnegative monotonic counters in the same controller segment; monitored totals additionally require an interval fully inside one monitoring period.
A cross-hour pair is a real usage interval total, not two invented hour values. Measured zero is visible `0 B`. Missing, unsupported, invalid, or absent evidence is never converted to zero.
### 4.3 Local display days
Storage timestamps and projection evidence days remain UTC. History browsing uses the user's current system timezone, visibly labelled. If the system timezone changes, the same retained evidence regroups into the new local display days. Use real calendar boundaries: DST days may be 23 or 25 hours, repeated local hours have distinct offsets, and fractional UTC offsets must work without synthetic splitting.
### 4.4 Retention and repair
Retain timestamped usage intervals indefinitely alongside hour observations and day aggregates. Full raw samples retain the 14-day policy, with a boundary-anchor exception: do not prune a raw sample that is still needed to durably derive an unfinished interval/hour/day representation.
Repair derives only what surviving raw evidence supports, transactionally and idempotently. It preserves original evidence and valid historical summaries. Incomplete reconstruction must not overwrite valid older history. Unsupported historical precision stays unavailable; it is not repaired by interpolation or waiting. A failed repair remains visible and retryable.
### 4.5 Attribution and gaps
Preserve measured usage interval totals. Never divide them proportionally across hours or calendar days, never assign them to an endpoint as if timing were observed, and never double-count interval totals and summaries derived from the same evidence.
An interval entirely inside a local display day and one monitoring period may contribute its total to that local-day total even if individual hour shares are unknown. Otherwise the total is shown separately as unallocated usage. An incomplete allocated subtotal must not be presented as a complete total.
Missing samples reduce usage-habit coverage where classification is unknown, but they do not erase a compatible measured gap total. Byte-allocation completeness and usage-habit classification coverage are distinct. Do not infer zero writes from missing samples.
Partial summaries represent elapsed time only. Future time is neither zero nor unknown. Deliberately disabled time is excluded, not an hour state. Intervals crossing a deliberate pause cannot distinguish monitored from paused writes: preserve the original evidence, exclude ambiguous bytes from monitored totals, and explain why. External service stops remain unexplained in-period gaps, not Deliberate disables.
### 4.6 Controller segments in browsing
The default history view is the current controller segment. Older segments remain browsable with explicit reset/replacement boundaries. Never form a delta across a controller-segment boundary or silently combine different drives.
## 5. UTC projection accounting and confidence amendments
This section amends the projection contract without changing lifespan mathematics, numeric thresholds, or confidence categories.
### 5.1 Measured totals and requested spans
For any requested span, count a compatible interval's total exactly once when its complete span is inside the requested span, inside one monitoring period, and has eligible controller provenance. It may supply a complete window total even when individual UTC-hour/day shares are unknown.
A positive interval crossing a requested boundary cannot supply that window's unknown share. Preserve its measured total separately as unallocated usage. Do not split proportionally, assign to the ending day, silently omit possible bytes, or label an incomplete subtotal as complete.
Withhold a rate whenever its monitored numerator cannot be established: unresolved boundary shares, missing initial/resume/reset counter support, and legacy eligibility ambiguity are not zero. Do not shorten the requested window or remove unknown monitored seconds merely to obtain a number. A compatible monotonic interval with zero counter delta proves zero writes throughout its represented span, including a requested subspan; that is direct counter evidence, not interpolation.
### 5.2 Projection endpoints and denominator
Let `T` be the latest published usage-evidence endpoint. The 7/28/90-day scenario windows end at `T` and start exactly 7/28/90 × 86,400 seconds earlier. Do not round starts to UTC midnight, and do not dilute rates as the TUI read clock advances without new published usage evidence. Evidence age remains a separate fact.
The default sustained regime is eligible observation history capped at 90 days, ending at `T`. A detected habit-change regime starts at the first divergence day's UTC midnight. Any unresolved share at those boundaries invokes the unavailable-rate rule; there is no silent fallback to a more convenient start.
For the chosen span, the rate denominator is wall-clock seconds inside monitoring periods, including powered-off and unknown time, excluding deliberate-disable time. Numerator and denominator describe the same requested span. Pause-crossing intervals cannot establish which writes were monitored and therefore cannot supply affected monitored totals. No bridge crosses controller-segment boundaries.
### 5.3 UTC evidence dates, warm-up, and confidence
Projection evidence remains UTC; local graph regrouping never changes projection eligibility. Coverage uses known-classified seconds divided by represented elapsed monitored seconds, excluding deliberately disabled and future time.
A qualifying day is a distinct UTC date whose represented monitored span has coverage at least 50%. A current partial date counts provisionally and can lose qualification as unknown time accumulates. Count a date once, not once per hour, interval, or controller fragment. Entirely paused dates have no represented monitored span and do not count.
Warm-up clears when the current controller segment has at least 14 distinct represented UTC dates, at least 12 of which qualify. Supported confidence still requires at least 14 qualifying dates plus every other existing condition. Thus 12 qualifying plus 2 poor dates clears warm-up but remains Limited. Same-day segment breaks use only the current segment's eligible portion for its new-segment warm-up; a shared UTC date does not import old-segment qualification.
Habit-change comparisons require completed, consecutive UTC days with evaluable daily write totals. Do not compress missing calendar dates into adjacent-row windows or treat missing/disabled time as zero. Unknown daily totals cannot establish habit change and cannot pass the burst/concentration guard.
An affected scenario rate is withheld while other independently computable rates remain visible. If the headline regime lacks a complete monitored numerator, no lifespan number renders; show the specific evidence-unavailable reason. If a complete positive headline rate exists while daily shares remain unknown, the lifespan may render as Limited, but Supported is blocked wherever a required confidence check cannot be established.
### 5.4 Segment and legacy evidence
Never compute a delta across a reset or replacement boundary, even within one UTC hour/day. Same-identity reset preserves prior eligible habit evidence, subject to the existing current-segment re-warm gate before a lifespan number can render. Re-warming does not reconstruct missing counter support.
A controller-identity change, including to or from a blank key, quarantines previous identity from every projection horizon. Returning to a previously seen key does not undo the intervening quarantine. History browsing may still show older segments explicitly.
Trusted legacy day-only summaries remain usable at their actual represented precision: only in a window fully containing their represented span and only when monitoring-period/controller eligibility is known. They cannot supply subday detail, local-midnight splits, or partial rolling-window totals. Mixed legacy summaries that cannot separate eligible from ineligible writes remain browsable but cannot supply affected projection totals.
### 5.5 Projection acceptance examples
Future implementation must make these cases observable without using fabricated history:
1. 100 MB from 23:55 to 00:05 UTC in one period/segment: retain 100 MB once. Neither UTC-day share is known. A containing window can consume 100 MB; a window starting at midnight cannot claim an exact share or rate.
2. Sparse three-day recovery interval with compatible counters: a measured 900 MB total remains real and can supply a containing window. There is no 300 MB/day allocation.
3. A 7-day boundary cuts a positive interval and the 28-day boundary does not: withhold the 7-day rate, preserve the independently computable 28-day rate, and do not omit the unresolved old-enough horizon to manufacture Supported confidence.
4. The same boundary with zero measured delta: exact zero contribution is permitted for the represented subspan; missing time outside it remains unknown.
5. `T` at UTC noon: a 7-day window spans exactly 604,800 wall-clock seconds before subtracting deliberate-disable time. Refreshing the TUI without new evidence does not change the endpoint.
6. First sample arrives after monitoring starts: preceding monitored time lacks a write total. Keep its time, do not invent zero bytes, and withhold affected rates.
7. Pause/resume crossed by one counter interval: preserve total as original evidence, but do not count ambiguous paused writes as monitored.
8. 12 qualifying dates plus 2 poor dates: warm-up clears, Supported still fails the 14-qualifying-date requirement.
9. Good coverage with unknown daily shares: day progress can qualify; burst and habit checks remain unknown, not passed or zero-filled.
10. Only 14 days of eligible history: do not require 28/90-day horizons yet, but evaluate the burst guard over observed eligible history and keep unknown daily totals blocking.
11. Reset/replacement at 10:30 UTC: no cross-break delta or whole-date shortcut. Same-key reset preserves eligible prior habit evidence but requires new-segment re-warm; replacement excludes prior identity from scenarios too.
12. Trusted old UTC-day summary with raw samples gone: usable only in a window fully containing its represented span and known eligibility, once only; no partial local-day/hour split.
## 6. Usage history graph and interaction
### 6.1 Encoding
Adopt a writes-only daily bar graph with hourly drill-down. Reads are out of scope. Candles are rejected: they bury the daily total, import price-chart semantics that usage data does not have, and distort gap/partial evidence. A rolling hourly strip is rejected as the default because it lacks day totals without reintroducing the day level.
The graph's value is bytes written. The daily range view shows one bar per local display day. Activating a day drills into hourly bars for that selected local display day. Back returns to the range view.
### 6.2 Ranges, labels, and readout
Default range: 14 days. Selectable ranges: 7, 14, 28, and 90 days. Ranges limit the viewport only; they never delete retained history or hide older controller segments from browsing.
The header line is:
```text
usage history · Local · UTC±HH:MM · <tz name>
```
The day row uses day-of-month labels. The selected-day readout uses `Wed 09 Sep` style. Hourly detail labels every third hour `00…21` plus `midnight → 23:00 local`. DST and repeated local hours follow the Local display day contract.
The selected-item readout states totals, evidenced hours, unallocated usage separately, coverage, and partial-state text such as `partial · N h elapsed · so far`.
### 6.3 Controls
The graph pane is focusable. Keyboard controls:
- `←` / `→` select day or hour;
- `Enter` drills into the selected day;
- `Esc` / `Backspace` returns from hourly detail;
- `1` / `2` / `3` / `4` switch 7 / 14 / 28 / 90 days.
Mouse controls use widget-local coordinates to select bars. Where Textual mouse support is available, clickable equivalents must cover select, drill, and back. Global `p` / `r` / `c` / `d` / `q` remain unchanged, and the footer shows graph keys while the graph is focused.
### 6.4 State rendering
The legend is always visible when the graph is visible. Distinct glyphs:
| Glyph | Meaning |
|---|---|
| `█` | allocated measured writes |
| `▒` | unallocated measured writes, stacked separately |
| `░` | gap / no evidence, never zero |
| `·` | measured zero bytes |
| `┄` | partial-day cap |
| `▼` | selection marker |
Unallocated usage is measured, not fabricated; it is never spread into hours to make the graph look complete. Gaps remain distinct from measured zero. Deliberate-disable annotations remain visible.
### 6.5 Minimum terminal
At 80×24, the range view must fit the default 14-day graph using 4 columns per day, and hourly drill-down must fit 24 single-column hourly bars. Below 80×24, hide the graph region and show a one-line textual history summary plus exactly:
```text
graph needs ≥80×24
```
This is not an error. The titlebox, status label/reason, drive health, service facts, quit rail/action affordances, and selected-day readout or equivalent textual context must survive. Resizing must not leave stale graph state visible.
### 6.6 Framework obligation
Current Textual facts verified during charting: Textual 8.2.8 has no BarChart widget, Sparkline is non-interactive, and `textual-plotext` is not an installed dependency. Implement the graph as a custom block-glyph renderable in the usage-history pane, with click mapping from widget-local coordinates. Do not add a plotting dependency for the adopted graph.
## 7. Colour presets, persistence, and accessibility
### 7.1 Presets
Approved presets: Amber, Nord, and High Contrast. Amber is the default and keeps the graph amber by default. Other presets may use theme-appropriate graph/accent colours.
Themes style chrome, graph, borders, accents, and muted text. They do not override status semantics. The status lattice keeps semantic colours: green Monitoring/Collecting, amber Paused/Waiting, red Interrupted/Error/Stale, grey Unknown, always with glyph and text.
Preset palettes must maintain readable contrast in normal and focused states. High Contrast is a first-class preset, not merely a lightened Amber.
### 7.2 Persistence scope
Preset and reduced-motion choices are user-scoped TUI display preferences. Persist them at:
```text
${XDG_CONFIG_HOME:-~/.config}/fenris/tui.json
```
Do not store these preferences in `/etc/fenris/fenris.conf`, the observation store, helper state, package config, or collector/device configuration. Missing preferences default to Amber and normal motion. This preference file never affects collection, projection, history evidence, or CLI `status` facts.
### 7.3 Controls and reduced motion
Add accessible TUI controls for `t preset` and `m motion`, with clickable equivalents where Textual mouse support is available. Focusable graph/settings panes are acceptable as long as status text and global actions remain reachable.
Normal motion: only the Monitoring status dot blinks at the approved 750 ms on / 750 ms off cadence. Text never blinks, and no other state animates. Reduced motion: Monitoring renders steady as `● Monitoring`. No animation may imply successful collection.
Long reasons, paused states, degraded/error states, missing data, and warm-up/withheld-estimate lines must remain text-visible. Do not collapse them into colour, blank space, or a misleading zero.
### 7.4 Framework facts
The charting prototype verified current Textual documentation: `App.register_theme(theme)` and `App.theme` support theme registration/activation; Textual theme variables plus `$text` / `color: auto` support legibility; mouse events provide screen/widget-relative coordinates and focusable widgets can be resolved/clicked for keyboard+mouse proof paths. The existing lockfile still pins Textual 8.2.8; prototype branches remain throwaway assets and add no runtime dependency.
## 8. Future implementation proof paths
These are direct observable probes the execution effort can derive tests from; they are not a build-only checklist.
- Synthetic first-run store with zero samples: TUI and `fenris status` show Waiting/awaiting-first-sample; graph has no zero-filled bars; estimate is withheld with the correct reason.
- One sample followed by a compatible pair inside one hour: first state says `Awaiting another sample`; after the pair, graph shows a partial `so far` value. If the counter is unchanged, the evidenced interval is visible `0 B`.
- Cross-hour and cross-local-midnight intervals: totals are retained once; hour/day shares remain unallocated unless evidence supports them; gaps and zeros use distinct glyphs.
- Pause/resume-crossing interval: paused time is excluded, ambiguous bytes do not enter monitored totals, Paused status explains the consequence, and quitting the TUI does not pause monitoring.
- Failed collection with fresh data, retry in flight, external stop, and store fault: status precedence matches §3 and store fault suppresses store-dependent views.
- UTC projection horizon cut by a positive interval: affected rate is unavailable with a specific reason while independent horizons remain; refreshing without new evidence does not shift `T`.
- Warm-up fixtures for 12 qualifying + 2 poor dates and 14 qualifying dates: the first clears the progress gate but remains Limited; the second can be Supported only if all other prerequisites pass.
- Graph keyboard and mouse path: range switch → selected day → hourly detail → back, with focus/footer behavior and no conflict with global actions.
- 80×24 and narrower terminal captures: 80×24 keeps the graph; below 80×24 shows the exact fallback and preserves status/health/action context.
- Theme fixture: Amber default, switch to Nord/High Contrast, restart TUI under the same unprivileged user and see preference persist; `fenris status` and collection behavior are unchanged.
## 9. Out of scope
- Production implementation, release gates, package publishing, installation changes, and closing or reopening prior implementation umbrellas.
- Changing lifespan mathematics, evidence/confidence thresholds, collector cadence, or controller-segment semantics except for the explicit accounting/evaluability amendments in §5.
- Fabricating, interpolating, proportionally splitting, or endpoint-assigning missing history.
- Read-throughput graph selector, custom colour editor, web GUI, notifications, alerting, unrelated release-workflow changes, or a general application redesign beyond the named TUI requirements.
-45
View File
@@ -1,45 +0,0 @@
# PROTOTYPE — graph encoding & hourly drill-down (throwaway)
Answers wayfinder ticket **Choose daily graph encoding and hourly drill-down**
on map **Fenris TUI polish and hourly history**.
Not production code. Do not merge onto main.
## Question
Which graph encoding best communicates hourly writes and a day's usage
without misleading: daily bars vs one candle per day vs rolling hourly strip,
with range -> selected day -> hourly detail -> back navigation.
## Run
./run # or: python3 tui_prototype.py
## Variants (switch with [ and ])
- **A — Daily bars**: one bar per local display day (bytes written), drill
into 24 hourly bars with Enter / click.
- **B — Daily candles**: one candle per day derived from hourly values
(open = first evidenced hour, close = last, high/low = max/min hourly).
Same drill-down.
- **C — Rolling hourly strip**: continuous last-72-hours columns with day
separators; click an hour for its readout (no day level).
## Data states encoded (synthetic)
measured zero (0 B) · gap / no evidence · unallocated usage (measured,
attribution unknown) · partial day ("so far") · deliberate-disable pause band ·
day before monitoring began.
Gaps never render as zero. Unallocated is a separate visual segment, never
spread into hours.
## Framework facts verified
Textual 8.2.8 (installed): no BarChart widget (roadmap-only), Sparkline is
non-interactive. Bars/candles here are a custom Static renderable; mouse
clicks map via widget-local x. No new dependencies.
## Screenshots
screenshots/ holds SVG exports at 80x24 and 140x40 per variant.
Regenerate with: python3 capture.py
-31
View File
@@ -1,31 +0,0 @@
#!/usr/bin/env python3
# PROTOTYPE helper — regenerate screenshots/*.svg via headless Textual run.
import asyncio
from tui_prototype import GraphPrototype
async def shot(app, size, presses, path):
async with app.run_test(size=size) as pilot:
for p in presses:
await pilot.press(p)
svg = app.export_screenshot()
with open(path, "w") as fh:
fh.write(svg)
print("wrote", path)
async def main():
for w, h in ((80, 24), (140, 40)):
for v in "ABC":
presses = ["]"] * "ABC".index(v)
await shot(GraphPrototype(), (w, h), presses,
"screenshots/v%s_%dx%d_range.svg" % (v, w, h))
# drill-down detail (variant A) and a mid-range selection (variant B)
await shot(GraphPrototype(), (80, 24), ["left", "enter"],
"screenshots/vA_80x24_hourly-detail.svg")
await shot(GraphPrototype(), (140, 40), ["]", "left", "left"],
"screenshots/vB_140x40_selected-gap-day.svg")
if __name__ == "__main__":
asyncio.run(main())
-3
View File
@@ -1,3 +0,0 @@
#!/bin/sh
# PROTOTYPE launcher
exec python3 "$(dirname "$0")/tui_prototype.py" "$@"
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 39 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 46 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 35 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 39 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 39 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 35 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 84 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 78 KiB

-488
View File
@@ -1,488 +0,0 @@
#!/usr/bin/env python3
# PROTOTYPE — throwaway. Wayfinder ticket "Choose daily graph encoding and
# hourly drill-down" on map "Fenris TUI polish and hourly history".
# Synthetic data only. Not production code; no tests by design.
#
# Variants (switch with [ / ]):
# A daily bars (day -> Enter -> hourly bars -> Esc back)
# B daily candles (OHLC derived from hourly values, same drill-down)
# C rolling 72-hour strip with day separators (no day level)
#
# Encoding contract under test (accepted hourly-history decision):
# measured zero = 0 B, distinct from gap (no evidence), distinct from
# unallocated usage (measured total, hour attribution unknown).
from __future__ import annotations
import time
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from textual import events
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Vertical
from textual.widgets import Static
# ---------------------------------------------------------------------------
# Synthetic evidence
# ---------------------------------------------------------------------------
@dataclass
class Hour:
gb: float = 0.0 # measured bytes written; 0.0 == measured zero
coverage: float = 1.0 # share of hour classified known
@dataclass
class Day:
hours: list = field(default_factory=list) # list[Hour | None]; None = gap
unallocated: float = 0.0 # GB measured, attribution unknown
elapsed_h: float = 24.0 # elapsed span (partial < 24)
note: str = ""
pause: str = "" # deliberate-disable band
@property
def evidenced(self):
return [h for h in self.hours if h is not None]
@property
def allocated_gb(self):
return sum(h.gb for h in self.evidenced)
@property
def gap_hours(self):
return sum(1 for h in self.hours if h is None)
def _profile(mult=1.0, zero=False):
shape = [0.3, 0.2, 0.1, 0.1, 0.2, 0.5, 1.4, 2.6, 3.1, 2.2,
1.8, 2.0, 2.4, 3.0, 3.4, 2.8, 2.1, 1.9, 2.3, 1.6,
1.1, 0.8, 0.5, 0.4]
return [Hour(0.0 if zero else round(v * mult, 2), 1.0) for v in shape]
def synth_days(now):
"""14 local display days ending today (partial). Every ticket state present."""
days = []
days.append(Day([None] * 24, note="no evidence — before monitoring began"))
days.append(Day(_profile(1.0)))
g = _profile(0.9)
for i in range(10, 16):
g[i] = None
days.append(Day(g, note="gap 10:00–16:00 (no evidence, not zero)"))
days.append(Day(_profile(0.0), note="measured zero day"))
days.append(Day(_profile(1.2), unallocated=8.5,
note="8.5 GB unallocated (interval attribution unknown)"))
days.append(Day(_profile(4.0), note="heavy write day"))
days.append(Day(_profile(0.7),
pause="paused 09:00–11:00 (deliberate disable — excluded)"))
days.append(Day(_profile(1.1)))
days.append(Day(_profile(0.9)))
m = _profile(1.0)
m[3] = Hour(0.0, 1.0)
m[17] = None
days.append(Day(m, note="zero hour 03:00, gap hour 17:00"))
days.append(Day(_profile(1.3), unallocated=1.9))
days.append(Day(_profile(1.0)))
days.append(Day(_profile(0.8)))
days.append(Day(_profile(1.5)[:14], elapsed_h=14,
note="today — 14 h elapsed, so far"))
assert len(days) == 14
return days
# ---------------------------------------------------------------------------
# Rendering (Rich markup into Static)
# ---------------------------------------------------------------------------
AMBER = "#e0a458" # default-preset graph colour (approved input)
UNALLOC = "#8a7f70" # measured, attribution unknown
GAP = "#5f6b73"
ZERO = "#9bb0bf"
SELECT = "#f2d5a0"
FILL = "█"
EIGHTHS = " ▁▂▃▄▅▆▇█"
def _fmt_gb(v):
if v >= 100:
return "%d GB" % v
return "%s GB" % ("%.1f" % v if v < 10 else "%.0f" % v)
def render_days_bars(days, height, sel):
"""Variant A: one bar per day. Unallocated stacked on top, visually separate."""
n = len(days)
mx = max((d.allocated_gb + d.unallocated) for d in days) or 1.0
grid = [[" "] * n for _ in range(height)]
style = [[None] * n for _ in range(height)]
for i, d in enumerate(days):
if not d.evidenced and not d.unallocated:
for r in range(height):
grid[r][i], style[r][i] = "·", GAP
continue
rows = []
ua_cells = d.unallocated / mx * height
for _ in range(int(ua_cells + 1e-9)):
rows.append(("▒", UNALLOC))
if ua_cells - int(ua_cells) > 0.12:
rows.append((EIGHTHS[int((ua_cells % 1) * 8)], UNALLOC))
cells = (d.allocated_gb + d.unallocated) / mx * height
for _ in range(int(cells + 1e-9)):
rows.append((FILL, AMBER))
if cells - int(cells) > 0.12:
rows.append((EIGHTHS[int((cells % 1) * 8)], AMBER))
if not rows:
rows = [("·", ZERO)]
rr = height - 1
for ch, st in rows:
if rr < 0:
break
grid[rr][i], style[rr][i] = ch, st
rr -= 1
if d.elapsed_h < 24:
rr = height - len(rows)
if 0 <= rr < height:
grid[rr][i], style[rr][i] = "┄", SELECT
lines = []
for r in range(height):
parts = []
for i in range(n):
ch, st = grid[r][i], style[r][i]
parts.append(ch if st is None else "[%s]%s[/]" % (st, ch))
parts.append(" ")
lines.append("".join(parts).rstrip())
marks = [" "] * (2 * n)
marks[2 * sel] = "▼"
lines.insert(0, "".join(marks).rstrip())
return lines
def render_days_candles(days, height, sel):
"""Variant B: one candle per day. open = first evidenced hour, close = last,
high/low = max/min hourly GB. Body solid when close >= open, hollow otherwise;
partial day renders dashed. 3-wide slot + 1 space."""
n = len(days)
stats = []
for d in days:
ev = [h.gb for h in d.evidenced]
stats.append(None if not ev else
dict(open=ev[0], close=ev[-1], hi=max(ev), lo=min(ev)))
mx = max((s["hi"] for s in stats if s), default=1.0) or 1.0
def row(v):
return height - 1 - min(int(v / mx * (height - 1)), height - 1)
cols = [] # per day: list[height] of 3-char cell strings + styles
for i, s in enumerate(stats):
d = days[i]
if s is None:
cols.append([("╎╎╎", GAP)] * height)
continue
top, bot = row(s["hi"]), row(s["lo"])
body_hi, body_lo = row(max(s["open"], s["close"])), row(min(s["open"], s["close"]))
solid = s["close"] >= s["open"]
partial = d.elapsed_h < 24
body_fill = "▒" if partial else (FILL if solid else "░")
body_st = AMBER if (solid or partial) else GAP
col = []
for r in range(height):
if body_lo <= r <= body_hi:
col.append((body_fill * 3, body_st))
elif top <= r <= bot:
col.append((" │ ", AMBER if solid else GAP))
else:
col.append((" ", None))
cols.append(col)
lines = [(" " * (4 * sel)) + "▼"]
for r in range(height):
parts = []
for i in range(n):
cells, st = cols[i][r]
parts.append(cells if st is None else "[%s]%s[/]" % (st, cells))
parts.append(" ")
lines.append("".join(parts).rstrip())
return lines
def render_hours_bars(day, height, sel):
"""Day detail: one bar per hour. Gaps never zero."""
n = len(day.hours)
mx = max([h.gb for h in day.evidenced] + [day.unallocated, 1e-9])
grid = [[" "] * n for _ in range(height)]
style = [[None] * n for _ in range(height)]
for i, h in enumerate(day.hours):
if h is None:
grid[height - 1][i], style[height - 1][i] = "░", GAP
continue
if h.gb == 0:
grid[height - 1][i], style[height - 1][i] = "·", ZERO
continue
cells = h.gb / mx * height
rr = height - 1
for _ in range(int(cells + 1e-9)):
if rr < 0:
break
grid[rr][i], style[rr][i] = FILL, AMBER
rr -= 1
if cells % 1 > 0.12 and rr >= 0:
grid[rr][i], style[rr][i] = EIGHTHS[int((cells % 1) * 8)], AMBER
lines = []
for r in range(height):
parts = []
for i in range(n):
ch, st = grid[r][i], style[r][i]
parts.append(ch if st is None else "[%s]%s[/]" % (st, ch))
lines.append("".join(parts).rstrip())
marks = [" "] * n
marks[sel % n] = "▼"
lines.insert(0, "".join(marks).rstrip())
return lines
def render_strip(days, height, sel):
"""Variant C: rolling hourly strip, last 72 h, day separators."""
flat = [] # (day_idx, hour_idx, Hour|None|'future')
for di, d in enumerate(days[-4:]):
real = len(days) - 4 + di
for hi in range(24):
h = d.hours[hi] if hi < len(d.hours) else "future"
flat.append((real, hi, h))
flat = flat[-72:]
mx = max([f[2].gb for f in flat if f[2] not in (None, "future")] + [1e-9])
cols = []
for _, _, h in flat:
col = [(" ", None)] * height
if h == "future":
col[height - 1] = ("·", None)
elif h is None:
col[height - 1] = ("░", GAP)
elif h.gb == 0:
col[height - 1] = ("·", ZERO)
else:
cells = h.gb / mx * height
rr = height - 1
for _ in range(int(cells + 1e-9)):
if rr < 0:
break
col[rr] = (FILL, AMBER)
rr -= 1
if cells % 1 > 0.12 and rr >= 0:
col[rr] = (EIGHTHS[int((cells % 1) * 8)], AMBER)
cols.append(col)
lines = []
for r in range(height):
parts = []
prev = None
for i, f in enumerate(flat):
if prev is not None and f[0] != prev:
parts.append("[%s]│[/]" % SELECT)
prev = f[0]
ch, st = cols[i][r]
parts.append(ch if st is None else "[%s]%s[/]" % (st, ch))
lines.append("".join(parts).rstrip())
marks = []
prev = None
for i, f in enumerate(flat):
if prev is not None and f[0] != prev:
marks.append(" ")
prev = f[0]
marks.append("▼" if i == sel else " ")
lines.insert(0, "".join(marks).rstrip())
return lines, flat
# ---------------------------------------------------------------------------
# App
# ---------------------------------------------------------------------------
VARIANT_NAMES = {"A": "Daily bars", "B": "Daily candles", "C": "Rolling 72 h strip"}
GH = 10 # graph rows
class GraphPrototype(App):
TITLE = "PROTOTYPE — usage-history graph encoding"
CSS = """
#col { height: 100%; }
#hdr { height: 2; color: $text-muted; }
#graph { height: auto; padding: 0 1; }
#readout { height: auto; padding: 0 1; }
#legend { height: 3; padding: 0 1; color: $text-muted; }
#footer { dock: bottom; height: 2; background: $panel; padding: 0 1; }
"""
BINDINGS = [
Binding("[", "prev_variant", "variant ←"),
Binding("]", "next_variant", "variant →"),
Binding("left", "left", "←"),
Binding("right", "right", "→"),
Binding("enter", "drill", "open"),
Binding("escape,backspace", "back", "back"),
Binding("q", "quit", "quit"),
]
def __init__(self):
super().__init__()
self.now = datetime.now().astimezone()
self.days = synth_days(self.now)
self.variant = "A"
self.view = "days" # days | hours (A/B); strip (C)
self.sel_day = len(self.days) - 1
self.sel_hour = 13
self.sel_strip = 71
def day_label(self, i):
d = (self.now - timedelta(days=len(self.days) - 1 - i)).date()
return d, d.strftime("%a %d %b")
def tz_label(self):
off = self.now.strftime("%z")
return "Local · UTC%s · %s" % (off[:3] + ":" + off[3:], time.tzname[0])
def day_readout(self, d, i):
_, lab = self.day_label(i)
if not d.evidenced and not d.unallocated:
return "%s — no evidence (not zero): %s" % (lab, d.note or "")
bits = ["%s — %s written" % (lab, _fmt_gb(d.allocated_gb))]
span = 24 if d.elapsed_h == 24 else len(d.hours)
bits.append("%d/%d h evidenced" % (span - d.gap_hours, span))
if d.unallocated:
bits.append("+ %s unallocated" % _fmt_gb(d.unallocated))
if d.elapsed_h < 24:
bits.append("partial · %d h elapsed · so far" % int(d.elapsed_h))
cov = sum(h.coverage for h in d.evidenced) / max(len(d.hours), 1)
bits.append("coverage %.0f%%" % (100 * cov))
txt = " · ".join(bits)
if d.note:
txt += " — %s" % d.note
if d.pause:
txt += " — %s" % d.pause
return txt
def compose(self) -> ComposeResult:
with Vertical(id="col"):
yield Static("", id="hdr")
yield Static("", id="graph")
yield Static("", id="readout")
yield Static("", id="legend")
yield Static("", id="footer")
def on_mount(self) -> None:
self.refresh_all()
def refresh_all(self):
self.query_one("#hdr", Static).update(
"🐺 Fenris · usage history · %s · writes per hour" % self.tz_label())
g = self.query_one("#graph", Static)
ro = self.query_one("#readout", Static)
if self.view == "hours":
d = self.days[self.sel_day]
lines = render_hours_bars(d, GH, self.sel_hour)
lines.append("".join(("%02d" % i) if i % 3 == 0 else " " for i in range(len(d.hours))))
lines.append(" midnight → 23:00 local")
g.update("\n".join(lines))
h = d.hours[self.sel_hour]
if h is None:
dtxt = "hour %02d:00 — no evidence (not zero)" % self.sel_hour
elif h.gb == 0:
dtxt = "hour %02d:00 — 0 B measured" % self.sel_hour
else:
dtxt = "hour %02d:00 — %s written · coverage %.0f%%" % (
self.sel_hour, _fmt_gb(h.gb), 100 * h.coverage)
_, lab = self.day_label(self.sel_day)
extra = ""
if d.unallocated:
extra = " · %s unallocated (shown separately, never spread into hours)" % _fmt_gb(d.unallocated)
ro.update("%s ▸ %s%s" % (lab, dtxt, extra))
elif self.variant in ("A", "B"):
lines = (render_days_bars(self.days, GH, self.sel_day) if self.variant == "A"
else render_days_candles(self.days, GH, self.sel_day))
_, last = self.day_label(len(self.days) - 1)
lines.append(" " + " ".join(
self.day_label(i)[1].split()[1] for i in range(len(self.days))))
g.update("\n".join(lines))
ro.update(self.day_readout(self.days[self.sel_day], self.sel_day))
else:
lines, flat = render_strip(self.days, GH, self.sel_strip)
g.update("\n".join(lines))
txt = ""
if 0 <= self.sel_strip < len(flat):
di, hi, h = flat[self.sel_strip]
_, lab = self.day_label(di)
if h == "future":
txt = "future — not counted"
elif h is None:
txt = "no evidence (not zero)"
elif h.gb == 0:
txt = "0 B measured"
else:
txt = "%s written · coverage %.0f%%" % (_fmt_gb(h.gb), 100 * h.coverage)
ro.update("%s %02d:00 — %s" % (lab, hi, txt))
self.query_one("#legend", Static).update(
"legend: [%(a)s]█ allocated[/] [%(u)s]▒ unallocated (attribution unknown)[/] "
"[%(g)s]░ gap · no evidence — never zero[/] [%(z)s]· measured 0 B[/] "
"[%(s)s]┄ partial day (so far)[/]" % dict(a=AMBER, u=UNALLOC, g=GAP, z=ZERO, s=SELECT))
v = "%s: %s" % (self.variant, VARIANT_NAMES[self.variant])
mode = "hourly strip" if self.variant == "C" else (
"day detail" if self.view == "hours" else "range view")
self.query_one("#footer", Static).update(
"PROTOTYPE ▸ %s ▸ %s [ / ] variant · ←/→ select · Enter open · Esc back · click" % (v, mode))
def action_prev_variant(self):
order = "ABC"
self.variant = order[(order.index(self.variant) - 1) % 3]
self.view = "strip" if self.variant == "C" else "days"
self.refresh_all()
def action_next_variant(self):
order = "ABC"
self.variant = order[(order.index(self.variant) + 1) % 3]
self.view = "strip" if self.variant == "C" else "days"
self.refresh_all()
def action_left(self):
if self.view == "hours":
self.sel_hour = max(0, self.sel_hour - 1)
elif self.variant == "C":
self.sel_strip = max(0, self.sel_strip - 1)
else:
self.sel_day = max(0, self.sel_day - 1)
self.refresh_all()
def action_right(self):
if self.view == "hours":
self.sel_hour = min(23, self.sel_hour + 1)
elif self.variant == "C":
self.sel_strip = min(71, self.sel_strip + 1)
else:
self.sel_day = min(len(self.days) - 1, self.sel_day + 1)
self.refresh_all()
def action_drill(self):
if self.variant in ("A", "B") and self.view == "days":
self.view = "hours"
self.sel_hour = 13
self.refresh_all()
def action_back(self):
if self.view == "hours":
self.view = "days"
self.refresh_all()
def on_click(self, event: events.Click) -> None:
w = event.widget
if w is None or w.id != "graph":
return
x = event.x
if self.view == "hours":
self.sel_hour = max(0, min(23, x))
elif self.variant == "C":
self.sel_strip = max(0, min(71, x))
else:
self.sel_day = max(0, min(len(self.days) - 1, x // 2))
self.refresh_all()
if __name__ == "__main__":
GraphPrototype().run()