max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
7 files changed,
+129 insertions,
-49 deletions
| @@ -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 |