|
1 | 1 | defmodule LatchkeyWeb.Inspector.EventLog do |
2 | 2 | @moduledoc """ |
3 | | - The read-only **event-log pane** (`LatchkeyWeb.InspectorLive`, spec |
4 | | - `docs/spec/developer-view.md`, issue #81, decisions D3/D7): for a selected |
5 | | - stream it renders the raw, immutable events in commit order with their **full |
6 | | - stored payloads**. |
7 | | -
|
8 | | - It doubles as the **Accounts edge** view (D3): the single `accounts` stream |
9 | | - renders through this same pane, **events-only**, captioned as an edge context |
10 | | - that folds no aggregate state (no aggregate / read-model panes, by design). |
11 | | -
|
12 | | - Each row is **self-describing out of context** (the tribunal-evidence goal): it |
13 | | - names the **property** (leading — the primary PM identifier) and **tenant**, and |
14 | | - shows **both envelope dates** (`occurred_on` / `recorded_on`) with a visible |
15 | | - **divergence flag** when they differ (D7 — for `RentFellDue`, an imported/rebuilt |
16 | | - tenancy (#117), since organic accrual books same-day; or a forward-dated fact). |
17 | | - Identity is resolved upstream in `LatchkeyWeb.InspectorLive`; accounts |
18 | | - rows honestly show an `UNKNOWN` sentinel when the payment holder is unresolvable. |
19 | | -
|
20 | | - Presentational only. It renders the pre-built rows it is handed — it reads no |
21 | | - store, folds no state, and exposes **no** create/update/delete affordance. The |
22 | | - log is **append-only / immutable** (never "tamper-evident" — issue #16). |
| 3 | + The read-only **vertical evidence log** — the full-detail lens on a stream's raw, |
| 4 | + immutable events (`LatchkeyWeb.InspectorLive`, spec `docs/spec/developer-view.md` |
| 5 | + D3/D7, issue #81). Rendered in the editorial "stream-detail" visual language. |
| 6 | +
|
| 7 | + It is the **evidence** lens: unlike the horizontal filmstrip (the narrative lens, |
| 8 | + which shows only type + date + amount), each row here is **self-describing out of |
| 9 | + context** for the tribunal-evidence goal — the **property** (leading) and |
| 10 | + **tenant**, **both envelope dates** (`occurred_on` / `recorded_on`) with a visible |
| 11 | + **divergence flag** when they differ (D7), and the **full stored payload**. |
| 12 | +
|
| 13 | + It serves two callers: |
| 14 | +
|
| 15 | + - the **deep** (tenancy) stream, as the vertical toggle of the filmstrip — rows are |
| 16 | + dimmed past the current prefix and the row at `k` is highlighted + `aria-current` |
| 17 | + (so the fold-flow animation pulses it), and each row is a scrub target; |
| 18 | + - the **Accounts edge** (D3), events-only, captioned as an edge context that folds |
| 19 | + no aggregate state (no filmstrip, no scrub — the *absence* is the teaching point). |
| 20 | +
|
| 21 | + Presentational only. It renders the pre-built rows it is handed — it reads no store, |
| 22 | + folds no state, and exposes **no** create/update/delete affordance. The log is |
| 23 | + **append-only / immutable** (never "tamper-evident" — issue #16). |
23 | 24 | """ |
24 | 25 | use LatchkeyWeb, :html |
25 | 26 |
|
26 | | - import LatchkeyWeb.InspectorComponents, only: [caption: 1, read_more: 1] |
27 | | - |
28 | 27 | @doc """ |
29 | | - The event-log pane for one stream. `rows` are the pre-resolved event rows (see |
30 | | - `LatchkeyWeb.InspectorLive`), `kind` is `:deep` (a tenancy stream) or `:edge` |
31 | | - (the `accounts` stream), and `docs` carries the canonical "read more" URLs. |
| 28 | + The vertical evidence log for one stream. `rows` are pre-resolved event rows in |
| 29 | + commit order. When `scrubbable?` (the deep stream), `k` dims rows past the prefix, |
| 30 | + `highlight_version` marks the current row, and rows are scrub targets; the edge |
| 31 | + passes `scrubbable?: false` (no dim, no highlight, no click). |
32 | 32 | """ |
33 | 33 | attr :stream_id, :string, required: true |
34 | 34 | attr :context_name, :string, required: true |
35 | 35 | attr :kind, :atom, required: true, doc: ":deep (tenancy) or :edge (accounts)" |
36 | 36 | attr :rows, :list, required: true, doc: "pre-resolved event rows in commit order" |
37 | 37 | attr :docs, :map, required: true, doc: "canonical doc URLs for read-more links" |
| 38 | + attr :scrubbable?, :boolean, default: false, doc: "deep streams scrub; the edge does not" |
| 39 | + attr :k, :integer, default: nil, doc: "current prefix length — dims rows past it (deep)" |
38 | 40 |
|
39 | 41 | attr :highlight_version, :integer, |
40 | 42 | default: nil, |
41 | | - doc: "stream_version of the replay scrubber's current event (D4), or nil when unscrubbed" |
| 43 | + doc: "stream_version of the row at the current prefix (deep), else nil" |
42 | 44 |
|
43 | | - def events_pane(assigns) do |
| 45 | + def vertical_log(assigns) do |
44 | 46 | ~H""" |
45 | | - <section id="event-log" class="max-w-3xl"> |
46 | | - <div id="stream-view"> |
47 | | - <div id={"stream-view-#{@stream_id}"}> |
48 | | - <header class="mb-3"> |
49 | | - <p class="text-[11px] font-semibold uppercase tracking-widest text-base-content/50"> |
50 | | - {@context_name} · events |
51 | | - </p> |
52 | | - <h2 class="mt-1 text-lg font-semibold font-mono">{@stream_id}</h2> |
53 | | - </header> |
54 | | -
|
55 | | - <%!-- Thin, interaction-anchored teaching captions (spec D2 altitude split). --%> |
56 | | - <div class="mb-4 space-y-2"> |
57 | | - <.caption :if={@kind == :edge} id="accounts-edge-caption"> |
58 | | - <b>Edge context.</b> |
59 | | - Accounts emits payment facts and <b>folds no aggregate state</b> |
60 | | - — |
61 | | - so there is no aggregate or read-model pane here, only this raw log. The <i>absence</i> |
62 | | - of those panes is the teaching point. |
63 | | - </.caption> |
64 | | -
|
65 | | - <.caption id="bitemporal-caption"> |
66 | | - Every event carries two dates — <b>occurred_on</b> |
67 | | - (when the fact became true) and <b>recorded_on</b> |
68 | | - (when it was booked). When they diverge it is flagged: an |
69 | | - imported/rebuilt tenancy or a forward-dated fact — organic accrual |
70 | | - books same-day. |
71 | | - <.read_more href={"#{@docs.domain_model}#3-events-producers"}> |
72 | | - domain-model.md §3 |
73 | | - </.read_more> |
74 | | - </.caption> |
75 | | -
|
76 | | - <p id="immutability-note" class="text-xs leading-relaxed text-base-content/60"> |
77 | | - This log is <b class="text-base-content/80">append-only / immutable</b> — events are |
78 | | - never edited or deleted; corrections are compensating appends. |
79 | | - </p> |
80 | | - </div> |
81 | | -
|
82 | | - <ol id="event-log-rows" class="relative border-l border-base-300 ml-1.5"> |
83 | | - <li |
84 | | - :if={@rows == []} |
85 | | - id="event-log-empty" |
86 | | - class="pl-5 py-3 text-xs text-base-content/50 italic" |
87 | | - > |
88 | | - No events on this stream yet. |
89 | | - </li> |
90 | | -
|
91 | | - <li |
92 | | - :for={row <- @rows} |
93 | | - id={"event-row-#{@stream_id}-#{row.version}"} |
94 | | - aria-current={row.version == @highlight_version && "step"} |
95 | | - class={[ |
96 | | - "relative pl-5 pb-5", |
97 | | - row.version == @highlight_version && |
98 | | - "-ml-px rounded-r-md border-l-2 border-accent bg-accent/5" |
99 | | - ]} |
100 | | - > |
| 47 | + <section id="event-log"> |
| 48 | + <p :if={@kind == :edge} id="accounts-edge-caption" class="sd-note"> |
| 49 | + <b>Edge context.</b> |
| 50 | + Accounts emits payment facts and <b>folds no aggregate state</b> |
| 51 | + — so there is no aggregate or read-model pane here, only this raw log. The <i>absence</i> |
| 52 | + of those panes is the teaching point. |
| 53 | + </p> |
| 54 | +
|
| 55 | + <p id="bitemporal-caption" class="sd-note"> |
| 56 | + Every event carries two dates — <b>occurred_on</b> |
| 57 | + (when the fact became true) and <b>recorded_on</b> |
| 58 | + (when it was booked). When they diverge it is flagged: an imported/rebuilt |
| 59 | + tenancy or a forward-dated fact — organic accrual books same-day. |
| 60 | + <.link |
| 61 | + navigate={"#{@docs.domain_model}#3-events-producers"} |
| 62 | + class="sd-readmore" |
| 63 | + > |
| 64 | + domain-model.md §3 |
| 65 | + </.link> |
| 66 | + </p> |
| 67 | +
|
| 68 | + <p id="immutability-note" class="sd-note"> |
| 69 | + This log is <b>append-only / immutable</b> — events are never edited or |
| 70 | + deleted; corrections are compensating appends. |
| 71 | + </p> |
| 72 | +
|
| 73 | + <div id={"stream-view-#{@stream_id}"} class="sd-vlog" phx-hook=".LogScroll"> |
| 74 | + <script :type={Phoenix.LiveView.ColocatedHook} name=".LogScroll"> |
| 75 | + // Keep the folded-to row in view as the prefix moves, scrolling this log |
| 76 | + // pane (deep vertical lens); on the edge there is no current row, so no-op. |
| 77 | + export default { |
| 78 | + mounted() { this.follow() }, |
| 79 | + updated() { this.follow() }, |
| 80 | + follow() { |
| 81 | + const cur = this.el.querySelector('[aria-current="step"]') |
| 82 | + if (cur) cur.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" }) |
| 83 | + } |
| 84 | + } |
| 85 | + </script> |
| 86 | + <p :if={@rows == []} id="event-log-empty" class="sd-note"> |
| 87 | + No events on this stream yet. |
| 88 | + </p> |
| 89 | +
|
| 90 | + <%= for row <- @rows do %> |
| 91 | + <% dimmed? = @scrubbable? and @k != nil and row.version > @k %> |
| 92 | + <% here? = row.version == @highlight_version %> |
| 93 | + <div |
| 94 | + id={"event-row-#{@stream_id}-#{row.version}"} |
| 95 | + aria-current={here? && "step"} |
| 96 | + phx-click={@scrubbable? && "scrub"} |
| 97 | + phx-value-k={@scrubbable? && row.version} |
| 98 | + role={@scrubbable? && "button"} |
| 99 | + tabindex={@scrubbable? && "0"} |
| 100 | + class={[ |
| 101 | + "sd-vrow", |
| 102 | + @scrubbable? && "cursor-pointer", |
| 103 | + here? && "sd-here", |
| 104 | + dimmed? && "sd-dim" |
| 105 | + ]} |
| 106 | + > |
| 107 | + <div class="sd-vhead"> |
| 108 | + <span class="sd-vtype">{row.type}</span> |
| 109 | + <span class="sd-vver">#{row.version}</span> |
101 | 110 | <span |
102 | | - class={[ |
103 | | - "absolute -left-[5px] top-1.5 size-2.5 rounded-full", |
104 | | - if(row.version == @highlight_version, do: "bg-accent", else: "bg-primary") |
105 | | - ]} |
106 | | - aria-hidden="true" |
107 | | - /> |
108 | | -
|
109 | | - <div class="flex flex-wrap items-baseline gap-x-2 gap-y-1"> |
110 | | - <span class="font-mono text-sm font-semibold">{row.type}</span> |
111 | | - <span class="font-mono text-[11px] text-base-content/40">#{row.version}</span> |
112 | | -
|
113 | | - <span |
114 | | - :if={row.divergent?} |
115 | | - id={"event-divergence-#{@stream_id}-#{row.version}"} |
116 | | - class="badge badge-sm badge-warning gap-1" |
117 | | - title="occurred_on and recorded_on differ (bitemporal divergence)" |
118 | | - > |
119 | | - occurred ≠ recorded |
120 | | - </span> |
121 | | - </div> |
122 | | -
|
123 | | - <%!-- Property-leading identity line: self-describing out of context (#81). --%> |
124 | | - <p |
125 | | - id={"event-identity-#{@stream_id}-#{row.version}"} |
126 | | - class="mt-0.5 text-[11px] text-base-content/60" |
| 111 | + :if={row.divergent?} |
| 112 | + id={"event-divergence-#{@stream_id}-#{row.version}"} |
| 113 | + class="sd-flag" |
| 114 | + title="occurred_on and recorded_on differ (bitemporal divergence)" |
127 | 115 | > |
128 | | - <span class="font-medium text-base-content/80">{row.identity.property}</span> |
129 | | - <span aria-hidden="true">·</span> |
130 | | - <span>{row.identity.tenant}</span> |
131 | | - <span class="font-mono text-base-content/40">{row.identity.ref}</span> |
132 | | - </p> |
| 116 | + occurred ≠ recorded |
| 117 | + </span> |
| 118 | + </div> |
| 119 | +
|
| 120 | + <p id={"event-identity-#{@stream_id}-#{row.version}"} class="sd-vident"> |
| 121 | + <b>{row.identity.property}</b> |
| 122 | + <span aria-hidden="true">·</span> |
| 123 | + <span>{row.identity.tenant}</span> |
| 124 | + <span class="sd-mono" style="color:var(--sd-muted)">{row.identity.ref}</span> |
| 125 | + </p> |
133 | 126 |
|
134 | | - <%!-- Two-date bitemporal display (D7). --%> |
135 | | - <dl class="mt-1.5 grid grid-cols-[auto_1fr] gap-x-3 text-[11px]"> |
136 | | - <dt class="text-base-content/50">occurred_on</dt> |
137 | | - <dd class="font-mono">{fmt(row.occurred_on)}</dd> |
138 | | - <dt class="text-base-content/50">recorded_on</dt> |
139 | | - <dd class={["font-mono", row.divergent? && "text-warning"]}> |
140 | | - {fmt(row.recorded_on)} |
141 | | - </dd> |
142 | | - </dl> |
| 127 | + <dl class="sd-payload"> |
| 128 | + <dt>occurred_on</dt> |
| 129 | + <dd>{fmt(row.occurred_on)}</dd> |
| 130 | + <dt>recorded_on</dt> |
| 131 | + <dd class={[row.divergent? && "sd-diverge"]}>{fmt(row.recorded_on)}</dd> |
143 | 132 |
|
144 | | - <%!-- Full stored payload. --%> |
145 | | - <dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 rounded-lg bg-base-200/60 p-2.5 text-[11px]"> |
146 | | - <div :for={{key, value} <- row.payload} class="contents"> |
147 | | - <dt class="font-mono text-base-content/50">{key}</dt> |
148 | | - <dd class="font-mono break-all">{fmt(value)}</dd> |
149 | | - </div> |
150 | | - </dl> |
151 | | - </li> |
152 | | - </ol> |
153 | | - </div> |
| 133 | + <div :for={{key, value} <- row.payload} class="contents"> |
| 134 | + <dt>{key}</dt> |
| 135 | + <dd>{fmt(value)}</dd> |
| 136 | + </div> |
| 137 | + </dl> |
| 138 | + </div> |
| 139 | + <% end %> |
154 | 140 | </div> |
155 | 141 | </section> |
156 | 142 | """ |
|
0 commit comments