Skip to main content

max / makenotwork

server: route public price display through canonical formatter + UX wiring nits (ultra-fuzz Run 2 UX) SERIOUS (price fragmentation): the embed card and buy button rendered prices with `format!("${:.2}", cents as f64 / 100.0)` — no thousands separators, always a trailing .00, and a lossy f64 divide — so a $12,345 item read "$12,345" on its public page but "$12345.00" on its embed card and "12345.00" in the wizard preview. The embed and the wizard preview (format_price_display) now use the canonical formatting::format_price, so all three surfaces match. The pricing-step input fields (bare "{}.{:02}" feeding number inputs) are deliberately left as decimals — format_price would put "$"/commas into an <input>. MINOR: ITEM_STEPS now documents that `sections` is an intentional out-of-band HTMX step (reached by direct GET, not linear nav) so the const and the step_save/render_step arms no longer appear to disagree. MINOR: the wizard "schedule" action returned the item to draft silently when publish_at was missing or unparseable; it now returns a validation error so the creator knows scheduling failed. MINOR: the followed-feed page param is clamped to an upper bound like the admin/git pagination (the i64 widening already prevented the overflow panic). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-23 16:55 UTC
Commit: 8fb3d7659f7b9b43b85fdf99fd296c7ffd6a0d3f
Parent: 766f40f
4 files changed, +51 insertions, -31 deletions
@@ -50,12 +50,15 @@ async fn fetch_item_embed_context(state: &AppState, item_id: ItemId) -> Result<I
50 50 return Err(AppError::NotFound);
51 51 }
52 52
53 + // Use the canonical formatter so the embed card matches every other surface
54 + // (thousands separators, whole-dollar shortening) instead of an f64-divided
55 + // "$12345.00" (Run #2 UX SERIOUS — price fragmentation).
53 56 let (price_display, button_text) = if item.price_cents == 0 {
54 57 ("Free".to_string(), "Get".to_string())
55 58 } else if item.pwyw_enabled {
56 - (format!("${:.2}+", item.price_cents as f64 / 100.0), "Buy".to_string())
59 + (format!("{}+", crate::formatting::format_price(item.price_cents)), "Buy".to_string())
57 60 } else {
58 - (format!("${:.2}", item.price_cents as f64 / 100.0), "Buy".to_string())
61 + (crate::formatting::format_price(item.price_cents), "Buy".to_string())
59 62 };
60 63
61 64 let purchase_url = format!("{}/buy/{}", state.config.host_url, item_id);
@@ -25,19 +25,28 @@ use crate::{
25 25
26 26 use super::build_step_nav;
27 27
28 - /// Format a price for display: "Free", "$X.XX", or "PWYW (min $X.XX)".
28 + /// Format a price for display: "Free", "$X", or "PWYW (min $X)". Routes through
29 + /// the canonical `format_price` so the wizard preview matches the public item
30 + /// surfaces (Run #2 UX SERIOUS — price fragmentation).
29 31 pub(super) fn format_price_display(price_cents: i32, pwyw_enabled: bool, pwyw_min_cents: Option<i32>) -> String {
30 32 if pwyw_enabled {
31 - let min = pwyw_min_cents.unwrap_or(0);
32 - format!("PWYW (min ${}.{:02})", min / 100, min % 100)
33 - } else if price_cents == 0 {
34 - "Free".to_string()
33 + match pwyw_min_cents.unwrap_or(0) {
34 + min if min > 0 => format!("PWYW (min {})", crate::formatting::format_price(min)),
35 + _ => "PWYW".to_string(),
36 + }
35 37 } else {
36 - format!("${}.{:02}", price_cents / 100, price_cents % 100)
38 + // format_price renders 0 as "Free".
39 + crate::formatting::format_price(price_cents)
37 40 }
38 41 }
39 42
40 - /// Ordered step names for the item wizard.
43 + /// Ordered step names for the item wizard's LINEAR navigation (`next_step`).
44 + ///
45 + /// Deliberately excludes `sections`: that step is managed out-of-band via the
46 + /// HTMX sections API and reached by a direct GET, not by stepping through the
47 + /// wizard, so `step_save`/`render_step` handle `"sections"` even though it never
48 + /// appears here. Keep the two in sync — a new linear step must be added here AND
49 + /// given a handler arm (Run #2 UX MINOR).
41 50 pub const ITEM_STEPS: &[&str] = &[
42 51 "type",
43 52 "basics",
@@ -281,27 +281,32 @@ pub(super) async fn save_preview(
281 281 }
282 282 }
283 283 "schedule" => {
284 - if let Some(datetime_str) = form.get("publish_at")
285 - && let Ok(dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M")
286 - {
287 - let utc_dt = dt.and_utc();
288 - db::items::update_item(
289 - &state.db,
290 - item.id,
291 - user.id,
292 - None,
293 - None,
294 - None,
295 - None,
296 - None,
297 - None,
298 - None,
299 - Some(Some(utc_dt)),
300 - None,
301 - None, None, // ai_tier, ai_disclosure
302 - )
303 - .await?;
304 - }
284 + // A missing or unparseable publish_at used to fall through silently,
285 + // leaving the item a draft while the creator believed it was scheduled
286 + // (Run #2 UX MINOR). Surface a validation error instead.
287 + let datetime_str = form
288 + .get("publish_at")
289 + .filter(|s| !s.is_empty())
290 + .ok_or_else(|| AppError::validation("Enter a publish date and time to schedule this item.".to_string()))?;
291 + let dt = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M")
292 + .map_err(|_| AppError::validation("Enter a valid publish date and time.".to_string()))?;
293 + let utc_dt = dt.and_utc();
294 + db::items::update_item(
295 + &state.db,
296 + item.id,
297 + user.id,
298 + None,
299 + None,
300 + None,
301 + None,
302 + None,
303 + None,
304 + None,
305 + Some(Some(utc_dt)),
306 + None,
307 + None, None, // ai_tier, ai_disclosure
308 + )
309 + .await?;
305 310 }
306 311 _ => {} // draft -- leave as is
307 312 }
@@ -33,7 +33,10 @@ pub(super) async fn feed_page(
33 33 let csrf_token = get_csrf_token(&session).await;
34 34 let session_user = Some(user.clone());
35 35
36 - let page = query.page.unwrap_or(1).max(1);
36 + // Clamp the upper bound too (matches admin/git pagination); the i64 widening
37 + // below already prevents the overflow panic, but an unbounded page is a
38 + // pointless huge offset (Run #2 UX MINOR).
39 + let page = query.page.unwrap_or(1).clamp(1, 1_000_000_000);
37 40 // Widen to i64 BEFORE multiplying — `(page - 1) * FEED_PAGE_SIZE` in u32
38 41 // overflows for a large `?page=` (garbage offset in release, panic in debug).
39 42 let offset = (page as i64 - 1) * constants::FEED_PAGE_SIZE as i64;