summaryrefslogtreecommitdiff
path: root/doc/DESIGN_HISTORY.md
diff options
context:
space:
mode:
Diffstat (limited to 'doc/DESIGN_HISTORY.md')
-rw-r--r--doc/DESIGN_HISTORY.md200
1 files changed, 200 insertions, 0 deletions
diff --git a/doc/DESIGN_HISTORY.md b/doc/DESIGN_HISTORY.md
new file mode 100644
index 0000000..5eb664b
--- /dev/null
+++ b/doc/DESIGN_HISTORY.md
@@ -0,0 +1,200 @@
1## The head / draft / autosave / translation lifecycle
2
3### Era 1 — the original design (2009, `doc/README_FOR_APP`).
4
5Two layers only: `head` (the published, public revision) and `draft`
6(a full copy of head's content, created the moment someone starts
7editing).
8
9No separate lock concept. The draft's *author* was the lock.
10Pessimistic, one editor at a time, enforced by ownership rather than a
11distinct column. An author could withdraw authorship (the draft
12becomes author-less, open for someone else to pick up) or discard the
13draft entirely, reverting to head.
14
15Admins could override any lock or remove any stuck draft on another
16editor's behalf. No autosave.
17
18Globalize wasn't part of the permission model (`Permission` grants
19admin rights to steal a lock a node.
20
21### Why a third layer got added.
22
23Under the original model, the moment someone started typing, a real,
24numbered revision already existed: `acts_as_list` assigns revision
25numbers at creation, scoped to `node_id`.
26
27A speculative edit (open the editor, type a few words, close the tab)
28still counted as a real Draft, and a real future revision if it were
29ever published.
30
31The rebuild introduced `autosave` as a genuinely separate layer: the
32same `Page` model, but created with `node_id: nil` specifically so
33it's excluded from `Node#pages`, the actual revision list.
34
35Typing progress survives a crash or a closed tab, but nothing becomes
36a real revision until the editor deliberately saves, which promotes
37the autosave's full content, wholesale, into the draft layer via
38`clone_attributes_from`.
39
40A canonical six-state reference table (head / draft / autosave present
41or absent, and what each combination permits) lives alongside the
42model code as the authority for any future work on this lifecycle.
43
44### Translations (Globalize), layered on top
45
46The governing principle for admin views from that rebuild:
47
48*presentation locale* (what language the admin chrome renders in) and
49*content-target locale* (which translation a given screen reads or
50writes) are separate concerns that must never share a mechanism.
51
52The route's `:locale` segment governs only the former. A locale is
53either the default one — edited exclusively through the primary node
54editor, pinned via `Globalize.with_locale` regardless of what the
55route happens to say, so a stray `/en/` URL can't silently edit the
56German content (as has been the case before the rewrite in 2026), or
57one of the non-default locales, edited exclusively through a
58Translations sub-resource under a deliberately distinct route
59parameter (`translation_locale`), chosen specifically because it must
60never leak into `default_url_options` the way the chrome locale does.
61Never both paths for the same locale.
62
63For now, admin chrome language stays tied to the route locale for now.
64A per-editor preference column was considered and set aside as
65revisit-later, not built.
66
67Translations carry no independent publish state: a translation goes
68live exactly when the draft containing it is promoted to head, same as
69everything else in that `Page` row. Both a per-translation
70`published_at` and full per-locale draft/head/autosave parity with
71`Node` were considered and rejected as unneeded machinery absent a
72real editorial need for staggered release timing.
73
74## Events and the calendar
75
76### Problem
77
78Calendar entries required an internal Node — every event needed a page
79somewhere in the tree. Editors avoided this: either the updates stream
80absorbed non-news content, or a page got created with no natural place
81in the navigation. The calendar widget went dormant rather than get
82used under those terms.
83
84### Rebuild.
85
86The Event model was rewritten around the RRULE humanizer
87(`app/models/concerns/rrule_humanizer.rb`) and a picker UI bound to it
88by a deliberate scope rule: the picker only generates or reads back
89RRULE shapes the humanizer can also render as prose. A picker that
90could express more than the humanizer can describe would let an editor
91create a schedule that renders as blank text on the public page —
92worse than a raw string the editor had to type by hand.
93`Event#node_id` became optional, so an event can point outward (an
94external URL) instead of requiring a page. This is what made reviving
95the calendar possible at all.
96
97### The "where to put an event" problem
98
99Once events no longer needed a node, turning the calendar on directly
100would have meant every chapter's regular open evening becoming an
101entry — a handful of one-off items buried under dozens of recurring,
102low-signal rows.
103
104### Resolution:
105
106Two widgets, not one:
107
108- `open_erfas_today` took the recurring, date-filtered case — which
109 chapters are open today. Its label went through rejected drafts
110 before landing on phrasing that admits it's a curated sample, not a
111 complete listing.
112- `div#frontpage_calendar` was reserved for the one-off case —
113 conferences, memorial gatherings. Its CSS carries an unconditional
114 `display: none` with no override anywhere in the stylesheet — not a
115 bug, a widget staged for a UI that hasn't been built yet.
116
117## The action log (`NodeAction`)
118
119### Origin
120
121The action log grew directly out of an audit of what timestamps
122actually mean: Two things were missing in the old model: no record of
123who actually clicked Publish (`page.editor` reflects the last content
124save, not necessarily that specific act), and the dashboard's
125recent-changes widget was actively wrong, showing an unrelated
126in-progress draft's byline next to a timestamp that didn't correspond
127to why the node appeared on the list at all.
128
129The log was designed to fill the gap and to answer "what did people
130do," a question the existing timestamp fields were never designed to
131answer.
132
133### Governing principle
134
135A derived log is only as reliable as the discipline that maintains it.
136
137Named explicitly before any code was written, not discovered afterward.
138Three ways it can drift:
139
1401. A future code path can simply forget to write to it — this project
141 had already found that exact shape twice (`wipe_draft!`'s ungated
142 branch; `Page.aggregate` silently ignoring a `conditions=` argument).
1432. If the log write and the actual mutation aren't in the same
144 transaction, they can diverge on a crash. Resolved by writing from
145 *inside* the model methods themselves (`autosave!`, `save_draft!`,
146 `publish_draft!`, `trash!`, `destroy!`) rather than from the
147 controllers that call them — every future caller is covered for
148 free, the same reasoning already proven by the JS-autosave work.
1493. It can never retroactively reconstruct anything from before it
150 existed — addressed deliberately by the backfill (below), not
151 ignored.
152
153### Avoiding noise
154
155A draft save or publish that produces no visible difference shouldn't
156create a visible log entry. Solved by reusing
157`Page#diff_against` / `has_changes_to?` — comparisons that already
158existed and were already tested — rather than inventing new diffing
159logic for the log to own.
160
161### The verb vocabulary
162
163Firstly, the log deliberately excludes locks (transient, no history
164value), autosave and draft-saving itself (exactly the noise the log
165exists to filter out), and tag/asset/event edits made *on a draft*
166(those will later surface as changed-flags at publish time, which
167is when they become real).
168
169Deliberately included, on reflection: reverts and discards (arguably
170more worth tracking than promotions: "who discarded this work, and
171when" is the fact someone would actually go looking for), shared
172preview link generation and revocation (security-relevant, handing
173unpublished content to an outsider), and slug/path changes
174(link-breaking, earns its own record separate from ordinary edits).
175
176### Rollback isn't a separate verb.
177
178`restore_revision!` writes a `publish` entry with a discriminator
179(`via: "revision"` instead of `via: "draft"`) rather than its own
180vocabulary. One verb, one meaning ("a page got promoted to head"),
181distinguished by how it got there.
182
183### Backfill
184
185Historical entries mirror the live vocabulary exactly. Diff content
186(what actually changed) is computed from the real historical revision
187data, since consecutive `Page` rows already existed to compare — only
188the actor and the timestamp are ever inferred, each backfilled entry
189carrying an `inferred_from` field naming the specific heuristic used
190(e.g. "from a page revision," "from `published_at`"). A `null`
191`inferred_from` means the entry was witnessed live, not reconstructed —
192so the log can always answer, for any entry, how much to trust it.
193
194### The full metadata contract per verb
195
196`create`, `publish`, `move`, `trash`, `restore_from_trash`, `destroy`
197— lives as a code comment directly above `record!` in
198`app/models/node.rb`. That's the authoritative, current version; this
199entry explains why it's shaped the way it is, not what it says field
200by field.