Skip to main content

max / makenotwork

Render the last two self-fetching regions in the response that carries them The no-hydration rule's remaining violations, both on item tabs. A region that ships empty and fetches its own contents on load costs a second round trip, shows a loading line on the way, and gives a reader with no script nothing at all. The overview tab carries its analytics and the details tab its tag suggestions. Both endpoints stay: the range selector and the refresh after a tag is added are later answers rather than first paint. `item_analytics` comes out of the handler so the selector's endpoint and the inline render share one function and cannot answer differently for the same item. That also fixes a thing it was doing quietly: it took the settlement currency off its `AuthUser` for the chart bars and read it again from the database for the revenue string, which is one fact fetched twice and free to disagree. `hx-trigger="revealed"` on the embed disclosure is deliberately untouched. A closed disclosure that fetches when it opens is describable and is not hydration; `Slot::showing_at_most_one`'s doc names that case.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-09-01 22:25 UTC
Signed with PGP, not checked
Commit: 4887b6f8ffce4a6039d66be76943a267cce8214c
Parent: a77912a
7 files changed, +129 insertions, -49 deletions
M server/Cargo.lock +19 -23
@@ -5390,7 +5390,9 @@
5390 5390
5391 5391 [[package]]
5392 5392 name = "makeover-build"
5393 - version = "0.64.0"
5393 + version = "0.65.0"
5394 + source = "registry+https://github.com/rust-lang/crates.io-index"
5395 + checksum = "a4118bf04192ff4fcfd4c8fa4ac7d956a1f9bb37fc204a56c9e9be4668c88491"
5394 5396 dependencies = [
5395 5397 "makeover",
5396 5398 "makeover-geometry",
@@ -5407,6 +5409,8 @@
5407 5409 [[package]]
5408 5410 name = "makeover-layout"
5409 5411 version = "0.43.0"
5412 + source = "registry+https://github.com/rust-lang/crates.io-index"
5413 + checksum = "c0a9124f65373683ae4320b00bf4a6198b2554ef3ad3af2806b413ac164545fa"
5410 5414
5411 5415 [[package]]
5412 5416 name = "makeover-timing"
@@ -5419,7 +5423,9 @@
5419 5423
5420 5424 [[package]]
5421 5425 name = "makeover-touch"
5422 - version = "0.32.0"
5426 + version = "0.33.0"
5427 + source = "registry+https://github.com/rust-lang/crates.io-index"
5428 + checksum = "6281006f62d84f15c8c88662056e32f2027717bdd61f29d36f7917b9a694d64a"
5423 5429 dependencies = [
5424 5430 "makeover-geometry",
5425 5431 "makeover-layout",
@@ -5427,7 +5433,9 @@
5427 5433
5428 5434 [[package]]
5429 5435 name = "makeover-webview"
5430 - version = "0.71.0"
5436 + version = "0.72.0"
5437 + source = "registry+https://github.com/rust-lang/crates.io-index"
5438 + checksum = "ae1993234ce8b39b26b326b40cf92f455dfe0656b79d96cbe012c09c982df96d"
5431 5439 dependencies = [
5432 5440 "makeover-geometry",
5433 5441 "makeover-layout",
@@ -10751,26 +10759,6 @@
10751 10759 "pkg-config",
10752 10760 ]
10753 10761
10754 - [[patch.unused]]
10755 - name = "synckit-client"
10756 - version = "0.10.0"
10757 -
10758 - [[patch.unused]]
10759 - name = "synckit-config"
10760 - version = "0.2.0"
10761 -
10762 - [[patch.unused]]
10763 - name = "alloy_tui"
10764 - version = "13.0.0"
10765 -
10766 - [[patch.unused]]
10767 - name = "makeover-immediate"
10768 - version = "0.43.0"
10769 -
10770 - [[patch.unused]]
10771 - name = "makeover-tui"
10772 - version = "0.42.0"
10773 -
10774 10762 [[patch.unused]]
10775 10763 name = "kberg"
10776 10764 version = "0.1.0"
@@ -10783,6 +10771,14 @@
10783 10771 name = "painhours"
10784 10772 version = "0.1.0"
10785 10773
10774 + [[patch.unused]]
10775 + name = "synckit-client"
10776 + version = "0.10.0"
10777 +
10778 + [[patch.unused]]
10779 + name = "synckit-config"
10780 + version = "0.2.0"
10781 +
10786 10782 [[patch.unused]]
10787 10783 name = "quasi-immediate"
10788 10784 version = "0.97.0"
@@ -591,6 +591,9 @@
591 591 bundle_items: Vec::new(),
592 592 bundleable_items: Vec::new(),
593 593 sections: Vec::new(),
594 + tag_suggestions: crate::templates::TagSuggestionsTemplate {
595 + suggestions: Vec::new(),
596 + },
594 597 }
595 598 .render()
596 599 .expect("render the details tab");
@@ -873,6 +873,14 @@
873 873 #[template(path = "partials/tabs/item_overview.html")]
874 874 pub struct ItemOverviewTabTemplate {
875 875 pub item: Item,
876 + /// The analytics block, rendered inline rather than fetched on load.
877 + ///
878 + /// It used to be an empty `<div>` with `hx-trigger="load"`, which cost a
879 + /// second round trip, showed a loading line on the way, and gave a reader
880 + /// with no script nothing at all. The handler is already in the request and
881 + /// can read this now. The range selector still fetches, which is a later
882 + /// answer rather than hydration.
883 + pub analytics: ItemAnalyticsPartialTemplate,
876 884 }
877 885
878 886 /// Item details tab: name, description, tags, content editor, bundle contents, sections.
@@ -883,6 +891,17 @@
883 891 pub bundle_items: Vec<Item>,
884 892 pub bundleable_items: Vec<Item>,
885 893 pub sections: Vec<ItemSection>,
894 + /// Automatic tag suggestions, rendered inline rather than fetched on load.
895 + ///
896 + /// It used to be an empty `<div>` with `hx-trigger="load"`. The handler is
897 + /// already in the request and can read these now, so a reader with no
898 + /// script sees them too. Empty when there is nothing to suggest, which is
899 + /// the same answer the endpoint gives.
900 + ///
901 + /// The partial itself rather than the rows, so the markup and the
902 + /// `onAddTagSuggestion` hook it carries have one definition and the two
903 + /// paths cannot drift.
904 + pub tag_suggestions: TagSuggestionsTemplate,
886 905 }
887 906
888 907 /// Item pricing tab: PWYW settings, license keys, promo codes.
@@ -78,11 +78,10 @@
78 78 </span>
79 79 {% endfor %}
80 80 </div>
81 - <div id="tag-suggestions-auto"
82 - hx-get="/api/items/{{ item.id }}/tag-suggestions"
83 - hx-trigger="load"
84 - hx-swap="innerHTML">
85 - </div>
81 + {# Rendered here rather than fetched on load, so a reader with no
82 + script gets the suggestions too. The endpoint still serves this
83 + partial for the refresh after a tag is added. #}
84 + <div id="tag-suggestions-auto">{{ tag_suggestions|safe }}</div>
86 85 <div class="tag-search-wrap">
87 86 <input type="text" id="item-tags" placeholder="Search tags..."
88 87 autocomplete="off"
@@ -17,11 +17,11 @@
17 17 <p class="form-hint mt-section">This item is still a draft. When you're ready, go to <strong>Details</strong> to publish it.</p>
18 18 {% endif %}
19 19
20 - <div id="item-analytics"
21 - hx-get="/dashboard/item/{{ item.id }}/analytics"
22 - hx-trigger="load"
23 - hx-swap="innerHTML">
24 - </div>
20 + {# Rendered here rather than fetched on load: the handler is already in
21 + the request, and a reader with no script gets the numbers too. The range
22 + selector inside still fetches, which is a later answer and not
23 + hydration. #}
24 + <div id="item-analytics">{{ analytics|safe }}</div>
25 25 </div>
26 26
27 27 <details class="content-section item-overview-embed raised">
@@ -12,7 +12,7 @@
12 12 use crate::{
13 13 auth::AuthUser,
14 14 config::Config,
15 - db::{self, ItemId, Slug, analytics::TimeRange},
15 + db::{self, ItemId, Slug, UserId, analytics::TimeRange},
16 16 error::{AppError, Result, ResultExt},
17 17 helpers::get_csrf_token,
18 18 quasi,
@@ -364,9 +364,14 @@
364 364 .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?;
365 365 quasi::item_files::fragment(&item.id, &versions, &uploader)
366 366 }
367 - _ => ItemOverviewTabTemplate { item: item.clone() }
368 - .render()
369 - .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?,
367 + _ => ItemOverviewTabTemplate {
368 + item: item.clone(),
369 + // The default range, matching what the endpoint falls back to, so
370 + // the first paint and a reload of the selector agree.
371 + analytics: item_analytics(&db, session_user.id, item_id, TimeRange::Days30).await?,
372 + }
373 + .render()
374 + .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?,
370 375 };
371 376 let tabs = quasi::item_tabs::html(&item.id, shown, &panel, is_bundle);
372 377
@@ -381,6 +386,13 @@
381 386 }
382 387
383 388 /// Render the HTMX partial for item analytics (stats + revenue chart).
389 + ///
390 + /// The range selector's endpoint. The first paint does not come through here:
391 + /// the overview tab renders [`item_analytics`] inline, because a region that
392 + /// ships empty and fetches itself on load costs a second round trip, shows a
393 + /// loading line on the way, and gives a reader with no script nothing at all.
394 + /// Re-answering when the reader picks a different range is a later fetch and
395 + /// not hydration.
384 396 #[tracing::instrument(skip_all, name = "dashboard::dashboard_item_analytics")]
385 397 pub(super) async fn dashboard_item_analytics(
386 398 State(db): State<PgPool>,
@@ -408,23 +420,43 @@
408 420 .and_then(|s| s.parse::<TimeRange>().ok())
409 421 .unwrap_or(TimeRange::Days30);
410 422
411 - let buckets =
412 - db::analytics::get_revenue_timeseries(&db, session_user.id, None, Some(item_id), &range)
413 - .await?;
423 + item_analytics(&db, session_user.id, item_id, range).await
424 + }
414 425
415 - let comparison =
416 - db::analytics::get_period_comparison(&db, session_user.id, None, Some(item_id), &range)
417 - .await?;
426 + /// The analytics partial for one item, over one range.
427 + ///
428 + /// Shared by the range selector's endpoint above and by the overview tab, which
429 + /// renders it inline on first paint. One function rather than two, so the two
430 + /// paths cannot answer differently for the same item.
431 + pub(super) async fn item_analytics(
432 + db: &PgPool,
433 + user_id: UserId,
434 + item_id: ItemId,
435 + range: TimeRange,
436 + ) -> Result<ItemAnalyticsPartialTemplate> {
437 + let session_user_id = user_id;
418 438
419 - let bars = super::build_chart_bars(&buckets, session_user.settlement_currency);
420 -
421 - let currency = db::users::get_user_by_id(&db, session_user.id)
439 + // One read of the user rather than two. The old handler took the currency
440 + // off its `AuthUser` for the bars and read it again from the database for
441 + // the revenue string, which is the same fact fetched twice and free to
442 + // disagree if a settlement currency changes between them.
443 + let currency = db::users::get_user_by_id(db, session_user_id)
422 444 .await?
423 445 .map(|u| u.settlement_currency)
424 446 .unwrap_or_default();
447 +
448 + let buckets =
449 + db::analytics::get_revenue_timeseries(db, session_user_id, None, Some(item_id), &range)
450 + .await?;
451 +
452 + let comparison =
453 + db::analytics::get_period_comparison(db, session_user_id, None, Some(item_id), &range)
454 + .await?;
455 +
456 + let bars = super::build_chart_bars(&buckets, currency);
425 457 let revenue_str = comparison.current_revenue_cents.format_revenue(currency);
426 458
427 - let db_versions = db::versions::get_versions_by_item(&db, item_id).await?;
459 + let db_versions = db::versions::get_versions_by_item(db, item_id).await?;
428 460 let total_downloads: i32 = db_versions.iter().map(|v| v.download_count).sum();
429 461
430 462 let stats = vec![
@@ -46,7 +46,7 @@
46 46 Item::from_db_detail(db_item, item_tags, None, None, is_free, true, currency)
47 47 }
48 48
49 - /// Item overview tab: quick actions + analytics (lazy-loaded).
49 + /// Item overview tab: quick actions and analytics, both in this response.
50 50 #[tracing::instrument(skip_all, name = "item_tabs::item_tab_overview")]
51 51 pub(in crate::routes::pages::dashboard) async fn item_tab_overview(
52 52 State(db): State<PgPool>,
@@ -56,7 +56,16 @@
56 56 let (db_item, _db_project) = resolve_owned_item(&db, session_user.id, &id).await?;
57 57 let item_tags = db::tags::get_tags_for_item(&db, db_item.id).await?;
58 58 let item = build_item_view(&db_item, &item_tags, session_user.settlement_currency);
59 - Ok(ItemOverviewTabTemplate { item })
59 + Ok(ItemOverviewTabTemplate {
60 + item,
61 + analytics: super::super::main::item_analytics(
62 + &db,
63 + session_user.id,
64 + db_item.id,
65 + db::analytics::TimeRange::Days30,
66 + )
67 + .await?,
68 + })
60 69 }
61 70
62 71 /// Item details tab: name, description, tags, content editor, bundle contents.
@@ -108,11 +117,33 @@
108 117 .map(crate::types::ItemSection::from)
109 118 .collect();
110 119
120 + // In this response rather than fetched on load. Same call the endpoint at
121 + // `/api/items/{id}/tag-suggestions` makes, which stays for the refresh
122 + // after a tag is added.
123 + let suggested = db::tags::suggest_tags_for_item(
124 + &db,
125 + item_id,
126 + &db_item.title,
127 + db_item.description.as_deref(),
128 + &db_item.item_type.to_string(),
129 + )
130 + .await?;
131 + let tag_suggestions = crate::templates::TagSuggestionsTemplate {
132 + suggestions: suggested
133 + .into_iter()
134 + .map(|t| crate::templates::TagSuggestion {
135 + id: t.id.to_string(),
136 + name: t.name,
137 + })
138 + .collect(),
139 + };
140 +
111 141 Ok(ItemDetailsTabTemplate {
112 142 item,
113 143 bundle_items,
114 144 bundleable_items,
115 145 sections,
146 + tag_suggestions,
116 147 })
117 148 }
118 149