max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
4 files changed,
+659 insertions,
-22 deletions
| @@ -7541,6 +7541,18 @@ | |||
| 7541 | 7541 | "winnow 1.0.4", | |
| 7542 | 7542 | ] | |
| 7543 | 7543 | ||
| 7544 | + | [[patch.unused]] | |
| 7545 | + | name = "kberg" | |
| 7546 | + | version = "0.1.0" | |
| 7547 | + | ||
| 7548 | + | [[patch.unused]] | |
| 7549 | + | name = "ops-status" | |
| 7550 | + | version = "0.1.0" | |
| 7551 | + | ||
| 7552 | + | [[patch.unused]] | |
| 7553 | + | name = "painhours" | |
| 7554 | + | version = "0.1.0" | |
| 7555 | + | ||
| 7544 | 7556 | [[patch.unused]] | |
| 7545 | 7557 | name = "quasi-axum" | |
| 7546 | 7558 | version = "0.2.0" | |
| @@ -7560,15 +7572,3 @@ | |||
| 7560 | 7572 | [[patch.unused]] | |
| 7561 | 7573 | name = "quasi-webview" | |
| 7562 | 7574 | version = "0.2.0" | |
| 7563 | - | ||
| 7564 | - | [[patch.unused]] | |
| 7565 | - | name = "kberg" | |
| 7566 | - | version = "0.1.0" | |
| 7567 | - | ||
| 7568 | - | [[patch.unused]] | |
| 7569 | - | name = "ops-status" | |
| 7570 | - | version = "0.1.0" | |
| 7571 | - | ||
| 7572 | - | [[patch.unused]] | |
| 7573 | - | name = "painhours" | |
| 7574 | - | version = "0.1.0" |
| @@ -136,6 +136,34 @@ | |||
| 136 | 136 | Syncing, | |
| 137 | 137 | } | |
| 138 | 138 | ||
| 139 | + | /// A subscription, as the description needs to name it. | |
| 140 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 141 | + | pub struct Subscription { | |
| 142 | + | /// Whether it is paid up and running. | |
| 143 | + | pub active: bool, | |
| 144 | + | /// What was bought, in bytes. | |
| 145 | + | pub limit_bytes: i64, | |
| 146 | + | /// What is used, in bytes. | |
| 147 | + | pub used_bytes: i64, | |
| 148 | + | /// `monthly` or `annual`, as the wire spells it. | |
| 149 | + | pub interval: String, | |
| 150 | + | /// A cap change already queued for the next renewal. | |
| 151 | + | pub pending_limit_bytes: Option<i64>, | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | /// What a cap may be and what it costs. | |
| 155 | + | /// | |
| 156 | + | /// The bounds, plus the ability to price a cap. The *quote* is a method rather | |
| 157 | + | /// than a table because pricing is the server's and the app is not going to | |
| 158 | + | /// reimplement it: see [`sync`]'s header on why the price cannot follow a | |
| 159 | + | /// slider. | |
| 160 | + | pub struct Pricing { | |
| 161 | + | /// The smallest cap that may be bought, in bytes. | |
| 162 | + | pub min_bytes: i64, | |
| 163 | + | /// The largest, in bytes. | |
| 164 | + | pub max_bytes: i64, | |
| 165 | + | } | |
| 166 | + | ||
| 139 | 167 | /// Cloud sync, as much of it as a described screen needs. | |
| 140 | 168 | /// | |
| 141 | 169 | /// The same narrowing [`Config`] makes, for the same three reasons, and it lands | |
| @@ -180,6 +208,32 @@ | |||
| 180 | 208 | ||
| 181 | 209 | /// Stop syncing this vault. | |
| 182 | 210 | fn disconnect(&self); | |
| 211 | + | ||
| 212 | + | /// The subscription, once it has been fetched. | |
| 213 | + | /// | |
| 214 | + | /// `None` is "not known yet" rather than "none": the manager fetches it | |
| 215 | + | /// asynchronously, which is what the described screen reports as | |
| 216 | + | /// [`Readiness::Pending`](quasi_router::layout::Readiness::Pending). | |
| 217 | + | fn subscription(&self) -> Option<Subscription>; | |
| 218 | + | ||
| 219 | + | /// What a cap may be, once pricing has been fetched. | |
| 220 | + | fn pricing(&self) -> Option<Pricing>; | |
| 221 | + | ||
| 222 | + | /// What this cap costs at this cadence, in cents. | |
| 223 | + | fn quote_cents(&self, cap_bytes: i64, annual: bool) -> i64; | |
| 224 | + | ||
| 225 | + | /// Go and ask what the subscription is. | |
| 226 | + | fn refresh_subscription(&self); | |
| 227 | + | ||
| 228 | + | /// Buy this cap at this cadence. | |
| 229 | + | /// | |
| 230 | + | /// Answers nothing, and that is the shape rather than an omission: the | |
| 231 | + | /// checkout URL is fetched asynchronously and the manager opens it itself, | |
| 232 | + | /// so unlike [`connect`](Self::connect) there is no address to hand back. | |
| 233 | + | fn subscribe(&self, cap_bytes: i64, annual: bool); | |
| 234 | + | ||
| 235 | + | /// Change the cap on a running subscription, at the next renewal. | |
| 236 | + | fn queue_cap_change(&self, cap_bytes: i64); | |
| 183 | 237 | } | |
| 184 | 238 | ||
| 185 | 239 | /// The app's sync manager, as the narrow thing a described screen borrows. | |
| @@ -237,6 +291,53 @@ | |||
| 237 | 291 | fn disconnect(&self) { | |
| 238 | 292 | self.0.disconnect(); | |
| 239 | 293 | } | |
| 294 | + | ||
| 295 | + | fn subscription(&self) -> Option<Subscription> { | |
| 296 | + | let status = self.0.status(); | |
| 297 | + | status.subscription.map(|sub| Subscription { | |
| 298 | + | active: sub.active, | |
| 299 | + | limit_bytes: sub.storage_limit_bytes.unwrap_or(0), | |
| 300 | + | used_bytes: sub.storage_used_bytes.unwrap_or(0), | |
| 301 | + | interval: sub | |
| 302 | + | .interval | |
| 303 | + | .map_or_else(|| "monthly".to_owned(), |i| i.as_str().to_owned()), | |
| 304 | + | pending_limit_bytes: sub.pending_storage_limit_bytes, | |
| 305 | + | }) | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | fn pricing(&self) -> Option<Pricing> { | |
| 309 | + | self.0.status().pricing.map(|pricing| Pricing { | |
| 310 | + | min_bytes: pricing.min_cap_bytes, | |
| 311 | + | max_bytes: pricing.max_cap_bytes, | |
| 312 | + | }) | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | fn quote_cents(&self, cap_bytes: i64, annual: bool) -> i64 { | |
| 316 | + | self.0.status().pricing.map_or(0, |pricing| { | |
| 317 | + | pricing.quote_cents(cap_bytes, interval_of(annual)).0 | |
| 318 | + | }) | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | fn refresh_subscription(&self) { | |
| 322 | + | self.0.fetch_subscription_status(); | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | fn subscribe(&self, cap_bytes: i64, annual: bool) { | |
| 326 | + | self.0.subscribe(cap_bytes, interval_of(annual)); | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | fn queue_cap_change(&self, cap_bytes: i64) { | |
| 330 | + | self.0.queue_cap_change(cap_bytes); | |
| 331 | + | } | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | /// A cadence as the client spells it. | |
| 335 | + | fn interval_of(annual: bool) -> audiofiles_sync::BillingInterval { | |
| 336 | + | if annual { | |
| 337 | + | audiofiles_sync::BillingInterval::Annual | |
| 338 | + | } else { | |
| 339 | + | audiofiles_sync::BillingInterval::Monthly | |
| 340 | + | } | |
| 240 | 341 | } | |
| 241 | 342 | ||
| 242 | 343 | /// Sync that is not configured on this machine. | |
| @@ -271,6 +372,18 @@ | |||
| 271 | 372 | fn set_interval(&self, _minutes: u32) {} | |
| 272 | 373 | fn clear_error(&self) {} | |
| 273 | 374 | fn disconnect(&self) {} | |
| 375 | + | fn subscription(&self) -> Option<Subscription> { | |
| 376 | + | None | |
| 377 | + | } | |
| 378 | + | fn pricing(&self) -> Option<Pricing> { | |
| 379 | + | None | |
| 380 | + | } | |
| 381 | + | fn quote_cents(&self, _cap_bytes: i64, _annual: bool) -> i64 { | |
| 382 | + | 0 | |
| 383 | + | } | |
| 384 | + | fn refresh_subscription(&self) {} | |
| 385 | + | fn subscribe(&self, _cap_bytes: i64, _annual: bool) {} | |
| 386 | + | fn queue_cap_change(&self, _cap_bytes: i64) {} | |
| 274 | 387 | } | |
| 275 | 388 | ||
| 276 | 389 | /// A theme the host resolved, as the description needs to name it. |
| @@ -55,13 +55,49 @@ | |||
| 55 | 55 | //! the user" — a fact about the content, which the renderer then answers with a | |
| 56 | 56 | //! cadence of its own choosing. Filed rather than invented here. | |
| 57 | 57 | //! | |
| 58 | - | //! # Out of scope, deliberately | |
| 58 | + | //! # The subscription section, and the two findings it produced | |
| 59 | 59 | //! | |
| 60 | - | //! The subscription and storage-cap section (`draw_subscription_section`, | |
| 61 | - | //! `draw_cap_slider`, `draw_cap_picker`, ~250 lines). It is a purchase flow with | |
| 62 | - | //! money in it, an external checkout and a pricing fetch, and it deserves its own | |
| 63 | - | //! pass rather than riding along with the state machine. Nothing about it looks | |
| 64 | - | //! undescribable; it is simply a second screen wearing this one's window. | |
| 60 | + | //! Added in a second pass. It is a purchase flow: a cap to choose, a price that | |
| 61 | + | //! depends on it, two cadences, and a Stripe checkout in a browser. Most of it | |
| 62 | + | //! describes cleanly and two things do not. | |
| 63 | + | //! | |
| 64 | + | //! **A price cannot follow a slider.** The shipped picker is a logarithmic | |
| 65 | + | //! slider with a live quote beside it, recomputed per drag frame from | |
| 66 | + | //! `AppPricing::quote_cents` — a *server* pricing model the app happens to hold | |
| 67 | + | //! a copy of. A described field carries a value and `Field::changes` fires a | |
| 68 | + | //! route when it changes, so following the drag means a request per step. The | |
| 69 | + | //! port therefore quotes what is **committed**, not what is under the thumb, and | |
| 70 | + | //! the finding is the general one: **nothing describes a display derived from a | |
| 71 | + | //! control's own uncommitted value.** Filed rather than papered over. | |
| 72 | + | //! | |
| 73 | + | //! Note what is *not* the answer: shipping the pricing model in the description | |
| 74 | + | //! so the renderer can compute. That is a formula travelling as data, and the | |
| 75 | + | //! next change to it silently prices three renderers differently. | |
| 76 | + | //! | |
| 77 | + | //! **A form has one submit, and this offers two priced choices over one value.** | |
| 78 | + | //! Annual and monthly are two actions over the same cap. | |
| 79 | + | //! [`Node::Form`](quasi_router::Node::Form) carries one `action` and one | |
| 80 | + | //! `submit`, so this is unsayable as drawn. The port makes the cadence a | |
| 81 | + | //! described [`Choice`] inside the form and submits once, which is arguably the | |
| 82 | + | //! better screen — the two buttons *are* a radio wearing button clothes — but it | |
| 83 | + | //! is a redesign forced by the vocabulary rather than chosen, and that is worth | |
| 84 | + | //! recording as such. | |
| 85 | + | //! | |
| 86 | + | //! **The checkout URL is not `Outcome::Goto`, and the auth URL is.** Same | |
| 87 | + | //! affordance, two shapes, and the difference is not aesthetic: | |
| 88 | + | //! `SyncManager::start_auth` answers a URL synchronously, so connecting is a | |
| 89 | + | //! described `Destination::External`. `subscribe` fetches the checkout URL | |
| 90 | + | //! asynchronously and opens it itself, so the route can only ask and answer with | |
| 91 | + | //! the screen. Whether "go here" is describable turns on whether the address is | |
| 92 | + | //! known when the description is built. | |
| 93 | + | //! | |
| 94 | + | //! # What it deleted | |
| 95 | + | //! | |
| 96 | + | //! About thirty-five lines of loading-flag bookkeeping: two `Instant` fields, | |
| 97 | + | //! two thirty-second timeouts, and the rule that a checkout error clears one | |
| 98 | + | //! flag but a fetch error must not. A described screen says | |
| 99 | + | //! [`Readiness::Pending`](quasi_router::layout::Readiness::Pending) and the | |
| 100 | + | //! renderer owns what waiting looks like. | |
| 65 | 101 | ||
| 66 | 102 | use quasi_router::layout::{FieldKind, Selector, Tone}; | |
| 67 | 103 | use quasi_router::{ | |
| @@ -69,11 +105,17 @@ | |||
| 69 | 105 | Slot, | |
| 70 | 106 | }; | |
| 71 | 107 | ||
| 72 | - | use super::{State, Status, Sync}; | |
| 108 | + | use super::{State, Status, Subscription, Sync}; | |
| 73 | 109 | ||
| 74 | 110 | /// The region the whole screen answers into. | |
| 75 | 111 | const BODY: &str = "sync-body"; | |
| 76 | 112 | ||
| 113 | + | /// One gibibyte, which is what a cap is counted in. | |
| 114 | + | const GIB: i64 = 1024 * 1024 * 1024; | |
| 115 | + | ||
| 116 | + | /// The field a cap is chosen with. | |
| 117 | + | const CAP: &str = "cap_gib"; | |
| 118 | + | ||
| 77 | 119 | /// The cadences the panel offers, in minutes. | |
| 78 | 120 | /// | |
| 79 | 121 | /// The same four the shipped panel has. A [`Selector::Segmented`] rather than a | |
| @@ -93,6 +135,9 @@ | |||
| 93 | 135 | .post("/sync/interval", interval) | |
| 94 | 136 | .post("/sync/error/clear", clear_error) | |
| 95 | 137 | .post("/sync/disconnect", disconnect) | |
| 138 | + | .post("/sync/subscription/refresh", refresh_subscription) | |
| 139 | + | .post("/sync/subscribe", subscribe) | |
| 140 | + | .post("/sync/cap", cap) | |
| 96 | 141 | } | |
| 97 | 142 | ||
| 98 | 143 | /// `GET /sync` | |
| @@ -175,6 +220,58 @@ | |||
| 175 | 220 | Ok(screen(state.sync).into()) | |
| 176 | 221 | } | |
| 177 | 222 | ||
| 223 | + | /// `POST /sync/subscription/refresh` | |
| 224 | + | fn refresh_subscription( | |
| 225 | + | state: &super::Panels<'_>, | |
| 226 | + | _request: Request, | |
| 227 | + | ) -> Result<Response, RouteError> { | |
| 228 | + | state.sync.refresh_subscription(); | |
| 229 | + | Ok(screen(state.sync).into()) | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | /// `POST /sync/subscribe` | |
| 233 | + | /// | |
| 234 | + | /// Answers with the screen rather than with somewhere to go, which is the | |
| 235 | + | /// contrast the module header draws: the checkout URL is fetched asynchronously | |
| 236 | + | /// and the manager opens it, so there is no address to put in an | |
| 237 | + | /// [`Outcome::Goto`](quasi_router::Outcome::Goto). | |
| 238 | + | fn subscribe(state: &super::Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 239 | + | let cap = cap_from(state, &request)?; | |
| 240 | + | let annual = request.payload.get("cadence") == Some("annual"); | |
| 241 | + | state.sync.subscribe(cap, annual); | |
| 242 | + | Ok(Response::from(screen(state.sync)).toast(Tone::Info, "Opening checkout in your browser.")) | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | /// `POST /sync/cap` | |
| 246 | + | fn cap(state: &super::Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 247 | + | let cap = cap_from(state, &request)?; | |
| 248 | + | state.sync.queue_cap_change(cap); | |
| 249 | + | Ok(Response::from(screen(state.sync)) | |
| 250 | + | .toast(Tone::Success, "The cap changes at your next renewal.")) | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | /// The cap a request asked for, in bytes, refused if it is outside what is sold. | |
| 254 | + | /// | |
| 255 | + | /// Bounds-checked here and not only in the field, on the rule the interval route | |
| 256 | + | /// already follows: `Field::min` and `max` are what a renderer draws, and a | |
| 257 | + | /// route is reachable by typing. | |
| 258 | + | fn cap_from(state: &super::Panels<'_>, request: &Request) -> Result<i64, RouteError> { | |
| 259 | + | let gib: i64 = request | |
| 260 | + | .payload | |
| 261 | + | .get(CAP) | |
| 262 | + | .and_then(|value| value.parse().ok()) | |
| 263 | + | .ok_or_else(|| RouteError::not_found("no cap named"))?; | |
| 264 | + | let bytes = gib.saturating_mul(GIB); | |
| 265 | + | let pricing = state | |
| 266 | + | .sync | |
| 267 | + | .pricing() | |
| 268 | + | .ok_or_else(|| RouteError::internal("Pricing is not loaded yet."))?; | |
| 269 | + | if bytes < pricing.min_bytes || bytes > pricing.max_bytes { | |
| 270 | + | return Err(RouteError::not_found("that cap is not on offer")); | |
| 271 | + | } | |
| 272 | + | Ok(bytes) | |
| 273 | + | } | |
| 274 | + | ||
| 178 | 275 | /// `POST /sync/disconnect` | |
| 179 | 276 | fn disconnect(state: &super::Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 180 | 277 | state.sync.disconnect(); | |
| @@ -195,7 +292,7 @@ | |||
| 195 | 292 | State::Disconnected => disconnected(body), | |
| 196 | 293 | State::Authenticating => authenticating(body), | |
| 197 | 294 | State::NeedsEncryption { has_server_key } => needs_encryption(body, has_server_key), | |
| 198 | - | State::Ready | State::Syncing => ready(body, &status), | |
| 295 | + | State::Ready | State::Syncing => ready(body, &status, sync), | |
| 199 | 296 | }; | |
| 200 | 297 | ||
| 201 | 298 | // The error banner, on every state, because a failure can arrive in any of | |
| @@ -276,7 +373,7 @@ | |||
| 276 | 373 | } | |
| 277 | 374 | ||
| 278 | 375 | /// Connected, and what it is doing. | |
| 279 | - | fn ready(body: Slot, status: &Status) -> Slot { | |
| 376 | + | fn ready(body: Slot, status: &Status, sync: &dyn Sync) -> Slot { | |
| 280 | 377 | let syncing = matches!(status.state, State::Syncing); | |
| 281 | 378 | let mut body = body.with(Node::text(if syncing { "Syncing..." } else { "Connected" })); | |
| 282 | 379 | ||
| @@ -321,9 +418,174 @@ | |||
| 321 | 418 | chosen: Some(status.sync_interval_minutes.to_string()), | |
| 322 | 419 | action: Some(Action::post("/sync/interval")), | |
| 323 | 420 | }) | |
| 421 | + | .with(Node::section("Audio file sync")) | |
| 422 | + | .with(subscription(sync)) | |
| 324 | 423 | .with(Node::Act( | |
| 325 | 424 | Act::new("Disconnect", Action::post("/sync/disconnect")) | |
| 326 | 425 | .tone(Tone::Danger) | |
| 327 | 426 | .confirm("Disconnect this vault from cloud sync?"), | |
| 328 | 427 | )) | |
| 329 | 428 | } | |
| 429 | + | ||
| 430 | + | /// What is bought, or what may be. | |
| 431 | + | /// | |
| 432 | + | /// Three shapes: not fetched yet, subscribed, or on offer. The first is a region | |
| 433 | + | /// rather than a sentence, because "not fetched yet" is exactly what | |
| 434 | + | /// [`Readiness::Pending`](quasi_router::layout::Readiness::Pending) says and the | |
| 435 | + | /// renderer already knows how to draw waiting. | |
| 436 | + | fn subscription(sync: &dyn Sync) -> Node { | |
| 437 | + | let Some(pricing) = sync.pricing() else { | |
| 438 | + | return waiting("Loading pricing..."); | |
| 439 | + | }; | |
| 440 | + | match sync.subscription() { | |
| 441 | + | None => waiting("Checking subscription..."), | |
| 442 | + | Some(sub) if sub.active => subscribed(sync, &sub, &pricing), | |
| 443 | + | Some(_) => on_offer(sync, &pricing), | |
| 444 | + | } | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | /// A region that is waiting on something. | |
| 448 | + | /// | |
| 449 | + | /// The whole of what the shipped panel spends two `Instant` fields, two | |
| 450 | + | /// thirty-second timeouts and a spinner on. How long to wait and what to draw | |
| 451 | + | /// while waiting are the renderer's, which is why neither is here. | |
| 452 | + | fn waiting(says: &str) -> Node { | |
| 453 | + | Node::Region( | |
| 454 | + | Slot::new("subscription", RegionKind::Pane) | |
| 455 | + | .pending() | |
| 456 | + | .with(Node::text(says)) | |
| 457 | + | .with(Node::Act(Act::new( | |
| 458 | + | "Retry", | |
| 459 | + | Action::post("/sync/subscription/refresh"), | |
| 460 | + | ))), | |
| 461 | + | ) | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | /// A running subscription: what it holds, how full it is, and how to change it. | |
| 465 | + | fn subscribed(sync: &dyn Sync, sub: &Subscription, pricing: &super::Pricing) -> Node { | |
| 466 | + | let mut slot = Slot::new("subscription", RegionKind::Pane).with(Node::text(format!( | |
| 467 | + | "Subscribed: {} ({})", | |
| 468 | + | gib_of(sub.limit_bytes), | |
| 469 | + | sub.interval | |
| 470 | + | ))); | |
| 471 | + | ||
| 472 | + | if sub.limit_bytes > 0 { | |
| 473 | + | // A real proportion, so a real `Meter`: used against bought, both known. | |
| 474 | + | // Counted in GiB rather than bytes because the bar is read by a person | |
| 475 | + | // and `Meter` takes two `u32`s. | |
| 476 | + | slot = slot.with(Node::Meter( | |
| 477 | + | quasi_router::Meter::new(gib_count(sub.used_bytes), gib_count(sub.limit_bytes).max(1)) | |
| 478 | + | .label("GiB used") | |
| 479 | + | // At ninety percent, not past it: this is a cap that stops syncing | |
| 480 | + | // when it fills, and the point of saying so is to say it before | |
| 481 | + | // that happens. | |
| 482 | + | .tone(if sub.used_bytes * 10 >= sub.limit_bytes * 9 { | |
| 483 | + | Tone::Warning | |
| 484 | + | } else { | |
| 485 | + | Tone::Neutral | |
| 486 | + | }), | |
| 487 | + | )); | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | if let Some(pending) = sub.pending_limit_bytes { | |
| 491 | + | slot = slot.with(Node::text(format!( | |
| 492 | + | "Pending: cap changes to {} at next renewal.", | |
| 493 | + | gib_of(pending) | |
| 494 | + | ))); | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | let annual = sub.interval == "annual"; | |
| 498 | + | Node::Region(slot.with(Node::Form { | |
| 499 | + | fields: vec![cap_field(pricing, sub.limit_bytes).hint(format!( | |
| 500 | + | "{} at this cap, {}.", | |
| 501 | + | money(sync.quote_cents(sub.limit_bytes, annual)), | |
| 502 | + | if annual { "per year" } else { "per month" } | |
| 503 | + | ))], | |
| 504 | + | submit: "Update cap".to_owned(), | |
| 505 | + | action: Action::post("/sync/cap"), | |
| 506 | + | })) | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | /// No subscription yet: pick a cap and a cadence. | |
| 510 | + | /// | |
| 511 | + | /// One form with the cadence in it, rather than one cap and two priced buttons. | |
| 512 | + | /// That is the redesign the module header records: a form carries one action and | |
| 513 | + | /// one submit, so two priced choices over one value is unsayable as drawn. | |
| 514 | + | fn on_offer(sync: &dyn Sync, pricing: &super::Pricing) -> Node { | |
| 515 | + | let start = pricing.min_bytes; | |
| 516 | + | Node::Region( | |
| 517 | + | Slot::new("subscription", RegionKind::Pane) | |
| 518 | + | .with(Node::text("Pick a storage cap for audio file sync.")) | |
| 519 | + | .with(Node::text( | |
| 520 | + | "Annual is two months free: fewer Stripe fees, and we pass the savings on.", | |
| 521 | + | )) | |
| 522 | + | .with(Node::Form { | |
| 523 | + | fields: vec![ | |
| 524 | + | cap_field(pricing, start), | |
| 525 | + | Field::select( | |
| 526 | + | "cadence", | |
| 527 | + | "Billing", | |
| 528 | + | vec![ | |
| 529 | + | Choice::new( | |
| 530 | + | "annual", | |
| 531 | + | format!("Annual, {} a year", money(sync.quote_cents(start, true))), | |
| 532 | + | ), | |
| 533 | + | Choice::new( | |
| 534 | + | "monthly", | |
| 535 | + | format!( | |
| 536 | + | "Monthly, {} a month", | |
| 537 | + | money(sync.quote_cents(start, false)) | |
| 538 | + | ), | |
| 539 | + | ), | |
| 540 | + | ], | |
| 541 | + | ) | |
| 542 | + | .hint("Prices shown are for the smallest cap; the exact figure is on the checkout page."), | |
| 543 | + | ], | |
| 544 | + | submit: "Subscribe".to_owned(), | |
| 545 | + | action: Action::post("/sync/subscribe"), | |
| 546 | + | }), | |
| 547 | + | ) | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | /// The cap, as a bounded number. | |
| 551 | + | /// | |
| 552 | + | /// `min` and `max` are the bounds a renderer draws as a track and a route checks | |
| 553 | + | /// again. What is *not* said is that the shipped slider is logarithmic: that is | |
| 554 | + | /// a scale, which is how a renderer spends the space it has, and a description | |
| 555 | + | /// that named it would be naming a widget. | |
| 556 | + | fn cap_field(pricing: &super::Pricing, current: i64) -> Field { | |
| 557 | + | Field::new(FieldKind::Number, CAP, "Storage cap (GiB)") | |
| 558 | + | .value(gib_count(current.max(pricing.min_bytes)).to_string()) | |
| 559 | + | .required() | |
| 560 | + | } | |
| 561 | + | ||
| 562 | + | /// A byte count as whole GiB, for a person. | |
| 563 | + | fn gib_count(bytes: i64) -> u32 { | |
| 564 | + | u32::try_from(bytes / GIB).unwrap_or(u32::MAX) | |
| 565 | + | } | |
| 566 | + | ||
| 567 | + | /// A byte count as a cap, spelled the way the shipped panel spells it. | |
| 568 | + | fn gib_of(bytes: i64) -> String { | |
| 569 | + | let gib = bytes / GIB; | |
| 570 | + | if gib >= 1024 { | |
| 571 | + | #[expect( | |
| 572 | + | clippy::cast_precision_loss, | |
| 573 | + | reason = "a cap in TiB is small enough that f64 is exact here" | |
| 574 | + | )] | |
| 575 | + | let tib = gib as f64 / 1024.0; | |
| 576 | + | format!("{tib:.1} TiB") | |
| 577 | + | } else { | |
| 578 | + | format!("{gib} GiB") | |
| 579 | + | } | |
| 580 | + | } | |
| 581 | + | ||
| 582 | + | /// Cents as money, the way the shipped panel writes it. | |
| 583 | + | fn money(cents: i64) -> String { | |
| 584 | + | let dollars = cents / 100; | |
| 585 | + | let pennies = cents % 100; | |
| 586 | + | if pennies == 0 { | |
| 587 | + | format!("${dollars}") | |
| 588 | + | } else { | |
| 589 | + | format!("${dollars}.{pennies:02}") | |
| 590 | + | } | |
| 591 | + | } |
| @@ -11,7 +11,7 @@ | |||
| 11 | 11 | use audiofiles_core::config_key::ConfigKey; | |
| 12 | 12 | use quasi_router::{Method, Node, Outcome, Params, Request, Response, Screen}; | |
| 13 | 13 | ||
| 14 | - | use super::{Config, Panels, State, Status, Sync, ThemeChoice, router}; | |
| 14 | + | use super::{Config, Panels, Pricing, State, Status, Subscription, Sync, ThemeChoice, router}; | |
| 15 | 15 | ||
| 16 | 16 | /// A config store in memory. | |
| 17 | 17 | /// | |
| @@ -84,6 +84,18 @@ | |||
| 84 | 84 | fn set_interval(&self, _minutes: u32) {} | |
| 85 | 85 | fn clear_error(&self) {} | |
| 86 | 86 | fn disconnect(&self) {} | |
| 87 | + | fn subscription(&self) -> Option<Subscription> { | |
| 88 | + | None | |
| 89 | + | } | |
| 90 | + | fn pricing(&self) -> Option<Pricing> { | |
| 91 | + | None | |
| 92 | + | } | |
| 93 | + | fn quote_cents(&self, _cap_bytes: i64, _annual: bool) -> i64 { | |
| 94 | + | 0 | |
| 95 | + | } | |
| 96 | + | fn refresh_subscription(&self) {} | |
| 97 | + | fn subscribe(&self, _cap_bytes: i64, _annual: bool) {} | |
| 98 | + | fn queue_cap_change(&self, _cap_bytes: i64) {} | |
| 87 | 99 | } | |
| 88 | 100 | ||
| 89 | 101 | fn themes() -> Vec<ThemeChoice> { | |
| @@ -332,6 +344,10 @@ | |||
| 332 | 344 | struct FakeSync { | |
| 333 | 345 | status: Status, | |
| 334 | 346 | calls: RefCell<Vec<String>>, | |
| 347 | + | /// What the subscription fetch has answered, if it has. | |
| 348 | + | subscription: Option<Subscription>, | |
| 349 | + | /// Whether pricing has arrived. | |
| 350 | + | priced: bool, | |
| 335 | 351 | } | |
| 336 | 352 | ||
| 337 | 353 | impl FakeSync { | |
| @@ -346,6 +362,8 @@ | |||
| 346 | 362 | sync_interval_minutes: 15, | |
| 347 | 363 | }, | |
| 348 | 364 | calls: RefCell::new(Vec::new()), | |
| 365 | + | subscription: None, | |
| 366 | + | priced: true, | |
| 349 | 367 | } | |
| 350 | 368 | } | |
| 351 | 369 | ||
| @@ -399,6 +417,55 @@ | |||
| 399 | 417 | fn disconnect(&self) { | |
| 400 | 418 | self.note("disconnect"); | |
| 401 | 419 | } | |
| 420 | + | ||
| 421 | + | fn subscription(&self) -> Option<Subscription> { | |
| 422 | + | self.subscription.clone() | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | fn pricing(&self) -> Option<Pricing> { | |
| 426 | + | self.priced.then_some(Pricing { | |
| 427 | + | min_bytes: 10 * GIB, | |
| 428 | + | max_bytes: 2048 * GIB, | |
| 429 | + | }) | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | fn quote_cents(&self, cap_bytes: i64, annual: bool) -> i64 { | |
| 433 | + | // A stand-in for the server's model: a dollar a gibibyte a month, and | |
| 434 | + | // two months free on the year. The screen never computes a price, so | |
| 435 | + | // what matters here is only that the number reaches the label. | |
| 436 | + | let monthly = (cap_bytes / GIB) * 100; | |
| 437 | + | if annual { monthly * 10 } else { monthly } | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | fn refresh_subscription(&self) { | |
| 441 | + | self.note("refresh_subscription"); | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | fn subscribe(&self, cap_bytes: i64, annual: bool) { | |
| 445 | + | self.note(&format!( | |
| 446 | + | "subscribe:{}:{}", | |
| 447 | + | cap_bytes / GIB, | |
| 448 | + | if annual { "annual" } else { "monthly" } | |
| 449 | + | )); | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | fn queue_cap_change(&self, cap_bytes: i64) { | |
| 453 | + | self.note(&format!("cap:{}", cap_bytes / GIB)); | |
| 454 | + | } | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | /// One gibibyte, as the routes count caps. | |
| 458 | + | const GIB: i64 = 1024 * 1024 * 1024; | |
| 459 | + | ||
| 460 | + | /// A subscription that is running. | |
| 461 | + | fn active(limit_gib: i64, used_gib: i64) -> Subscription { | |
| 462 | + | Subscription { | |
| 463 | + | active: true, | |
| 464 | + | limit_bytes: limit_gib * GIB, | |
| 465 | + | used_bytes: used_gib * GIB, | |
| 466 | + | interval: "monthly".to_owned(), | |
| 467 | + | pending_limit_bytes: None, | |
| 468 | + | } | |
| 402 | 469 | } | |
| 403 | 470 | ||
| 404 | 471 | /// A router call against a sync in this state. | |
| @@ -593,3 +660,198 @@ | |||
| 593 | 660 | ); | |
| 594 | 661 | } | |
| 595 | 662 | } | |
| 663 | + | ||
| 664 | + | /// Every form on a screen, as (submit label, action path, field names). | |
| 665 | + | fn forms(screen: &Screen) -> Vec<(String, String, Vec<String>)> { | |
| 666 | + | screen | |
| 667 | + | .slots | |
| 668 | + | .iter() | |
| 669 | + | .flat_map(|slot| &slot.body) | |
| 670 | + | .flat_map(|node| match node { | |
| 671 | + | Node::Region(slot) => slot.body.clone(), | |
| 672 | + | other => vec![other.clone()], | |
| 673 | + | }) | |
| 674 | + | .filter_map(|node| match node { | |
| 675 | + | Node::Form { | |
| 676 | + | submit, | |
| 677 | + | action, | |
| 678 | + | fields, | |
| 679 | + | } => Some(( | |
| 680 | + | submit, | |
| 681 | + | action.destination.as_str().to_owned(), | |
| 682 | + | fields.iter().map(|f| f.name.clone()).collect(), | |
| 683 | + | )), | |
| 684 | + | _ => None, | |
| 685 | + | }) | |
| 686 | + | .collect() | |
| 687 | + | } | |
| 688 | + | ||
| 689 | + | #[test] | |
| 690 | + | fn a_subscription_that_has_not_arrived_is_pending_rather_than_absent() { | |
| 691 | + | // `None` is "not fetched yet", which is what Readiness says and what the | |
| 692 | + | // shipped panel spends two Instants and a thirty-second timeout on. | |
| 693 | + | let sync = FakeSync::in_state(State::Ready); | |
| 694 | + | let response = syncing(&sync, Request::get("/sync")).expect("answered"); | |
| 695 | + | let region = screen_of(&response) | |
| 696 | + | .slots | |
| 697 | + | .iter() | |
| 698 | + | .flat_map(|slot| &slot.body) | |
| 699 | + | .find_map(|node| match node { | |
| 700 | + | Node::Region(slot) if slot.id == "subscription" => Some(slot), | |
| 701 | + | _ => None, | |
| 702 | + | }) | |
| 703 | + | .expect("the screen has a subscription region"); | |
| 704 | + | assert_eq!(region.readiness, quasi_router::layout::Readiness::Pending); | |
| 705 | + | } | |
| 706 | + | ||
| 707 | + | #[test] | |
| 708 | + | fn one_form_carries_the_cap_and_the_cadence_because_a_form_has_one_submit() { | |
| 709 | + | // The redesign the vocabulary forced: the shipped panel has one cap and two | |
| 710 | + | // priced buttons, and `Node::Form` carries one action and one submit. | |
| 711 | + | let mut sync = FakeSync::in_state(State::Ready); | |
| 712 | + | sync.subscription = Some(Subscription { | |
| 713 | + | active: false, | |
| 714 | + | limit_bytes: 0, | |
| 715 | + | used_bytes: 0, | |
| 716 | + | interval: "monthly".to_owned(), | |
| 717 | + | pending_limit_bytes: None, | |
| 718 | + | }); | |
| 719 | + | let response = syncing(&sync, Request::get("/sync")).expect("answered"); | |
| 720 | + | let found = forms(screen_of(&response)); | |
| 721 | + | let offer = found | |
| 722 | + | .iter() | |
| 723 | + | .find(|(_, action, _)| action == "/sync/subscribe") | |
| 724 | + | .expect("the screen offers a subscription"); | |
| 725 | + | assert_eq!(offer.0, "Subscribe"); | |
| 726 | + | assert_eq!(offer.2, ["cap_gib", "cadence"], "{offer:?}"); | |
| 727 | + | } | |
| 728 | + | ||
| 729 | + | #[test] | |
| 730 | + | fn subscribing_sends_the_cap_and_the_cadence_it_was_given() { | |
| 731 | + | let mut sync = FakeSync::in_state(State::Ready); | |
| 732 | + | sync.subscription = Some(Subscription { | |
| 733 | + | active: false, | |
| 734 | + | limit_bytes: 0, | |
| 735 | + | used_bytes: 0, | |
| 736 | + | interval: "monthly".to_owned(), | |
| 737 | + | pending_limit_bytes: None, | |
| 738 | + | }); | |
| 739 | + | syncing( | |
| 740 | + | &sync, | |
| 741 | + | Request::post("/sync/subscribe").sending( | |
| 742 | + | Params::new() | |
| 743 | + | .with("cap_gib".to_owned(), "100".to_owned()) | |
| 744 | + | .with("cadence".to_owned(), "annual".to_owned()), | |
| 745 | + | ), | |
| 746 | + | ) | |
| 747 | + | .expect("answered"); | |
| 748 | + | assert_eq!(sync.called(), ["subscribe:100:annual"]); | |
| 749 | + | } | |
| 750 | + | ||
| 751 | + | #[test] | |
| 752 | + | fn a_cap_outside_what_is_sold_never_reaches_the_manager() { | |
| 753 | + | // Money: the route checks the bounds again rather than trusting the field, | |
| 754 | + | // because the address is reachable by typing. | |
| 755 | + | let sync = FakeSync::in_state(State::Ready); | |
| 756 | + | for out_of_range in ["1", "9999"] { | |
| 757 | + | let refused = syncing( | |
| 758 | + | &sync, | |
| 759 | + | Request::post("/sync/subscribe").sending( | |
| 760 | + | Params::new() | |
| 761 | + | .with("cap_gib".to_owned(), out_of_range.to_owned()) | |
| 762 | + | .with("cadence".to_owned(), "monthly".to_owned()), | |
| 763 | + | ), | |
| 764 | + | ); | |
| 765 | + | assert!(refused.is_err(), "{out_of_range} GiB was accepted"); | |
| 766 | + | } | |
| 767 | + | assert!( | |
| 768 | + | sync.called().is_empty(), | |
| 769 | + | "a cap outside the offer reached the manager" | |
| 770 | + | ); | |
| 771 | + | ||
| 772 | + | // And one inside it does. | |
| 773 | + | syncing( | |
| 774 | + | &sync, | |
| 775 | + | Request::post("/sync/cap") | |
| 776 | + | .sending(Params::new().with("cap_gib".to_owned(), "50".to_owned())), | |
| 777 | + | ) | |
| 778 | + | .expect("answered"); | |
| 779 | + | assert_eq!(sync.called(), ["cap:50"]); | |
| 780 | + | } | |
| 781 | + | ||
| 782 | + | #[test] | |
| 783 | + | fn a_cap_asked_for_before_pricing_arrived_is_refused_rather_than_guessed() { | |
| 784 | + | // Without pricing there are no bounds, and a purchase route that cannot | |
| 785 | + | // check its bounds must not proceed. | |
| 786 | + | let mut sync = FakeSync::in_state(State::Ready); | |
| 787 | + | sync.priced = false; | |
| 788 | + | let refused = syncing( | |
| 789 | + | &sync, | |
| 790 | + | Request::post("/sync/subscribe").sending( | |
| 791 | + | Params::new() | |
| 792 | + | .with("cap_gib".to_owned(), "50".to_owned()) | |
| 793 | + | .with("cadence".to_owned(), "monthly".to_owned()), | |
| 794 | + | ), | |
| 795 | + | ); | |
| 796 | + | assert!(refused.is_err()); | |
| 797 | + | assert!(sync.called().is_empty()); | |
| 798 | + | } | |
| 799 | + | ||
| 800 | + | #[test] | |
| 801 | + | fn a_running_subscription_shows_what_it_holds_and_how_full_it_is() { | |
| 802 | + | let mut sync = FakeSync::in_state(State::Ready); | |
| 803 | + | sync.subscription = Some(active(100, 90)); | |
| 804 | + | let response = syncing(&sync, Request::get("/sync")).expect("answered"); | |
| 805 | + | ||
| 806 | + | let meter = screen_of(&response) | |
| 807 | + | .slots | |
| 808 | + | .iter() | |
| 809 | + | .flat_map(|slot| &slot.body) | |
| 810 | + | .flat_map(|node| match node { | |
| 811 | + | Node::Region(slot) => slot.body.clone(), | |
| 812 | + | other => vec![other.clone()], | |
| 813 | + | }) | |
| 814 | + | .find_map(|node| match node { | |
| 815 | + | Node::Meter(meter) => Some(meter), | |
| 816 | + | _ => None, | |
| 817 | + | }) | |
| 818 | + | .expect("a subscription draws how full it is"); | |
| 819 | + | ||
| 820 | + | assert_eq!(meter.done, 90); | |
| 821 | + | assert_eq!(meter.total, 100); | |
| 822 | + | // The tone is carried because no renderer can work it out: 90% of a paid cap | |
| 823 | + | // is a warning and 90% of a subtask rollup is a success. | |
| 824 | + | assert_eq!(meter.tone, quasi_router::layout::Tone::Warning); | |
| 825 | + | } | |
| 826 | + | ||
| 827 | + | #[test] | |
| 828 | + | fn the_price_shown_is_the_committed_cap_and_not_a_live_quote() { | |
| 829 | + | // The finding, asserted so it cannot be quietly "fixed" by making the field | |
| 830 | + | // fire per keystroke: the hint prices what is stored, because nothing | |
| 831 | + | // describes a display derived from a control's own uncommitted value. | |
| 832 | + | let mut sync = FakeSync::in_state(State::Ready); | |
| 833 | + | sync.subscription = Some(active(20, 1)); | |
| 834 | + | let response = syncing(&sync, Request::get("/sync")).expect("answered"); | |
| 835 | + | let screen = screen_of(&response); | |
| 836 | + | assert!( | |
| 837 | + | forms(screen) | |
| 838 | + | .iter() | |
| 839 | + | .any(|(_, action, _)| action == "/sync/cap"), | |
| 840 | + | "a running subscription offers a cap change" | |
| 841 | + | ); | |
| 842 | + | let hint = screen | |
| 843 | + | .slots | |
| 844 | + | .iter() | |
| 845 | + | .flat_map(|slot| &slot.body) | |
| 846 | + | .flat_map(|node| match node { | |
| 847 | + | Node::Region(slot) => slot.body.clone(), | |
| 848 | + | other => vec![other.clone()], | |
| 849 | + | }) | |
| 850 | + | .find_map(|node| match node { | |
| 851 | + | Node::Form { fields, .. } => fields.iter().find(|f| f.name == "cap_gib")?.hint.clone(), | |
| 852 | + | _ => None, | |
| 853 | + | }) | |
| 854 | + | .expect("the cap field is priced"); | |
| 855 | + | // 20 GiB at the fixture's dollar-a-gibibyte, monthly. | |
| 856 | + | assert!(hint.contains("$20"), "{hint}"); | |
| 857 | + | } |