Skip to main content

max / audiofiles

Propose the storage cap instead of soliciting it Both cap screens opened on an unfilled choice: a logarithmic slider from the floor to 10 TiB on the egui panel, a bare unbounded number box on the described one. They were asking a question the app can already answer. Database::synced_storage_stats returns the exact size of what blob sync would upload, and it is one indexed query. So the screens now state the need, propose a cap with headroom, and show what every option costs. The need is a union over VFSes with sync_files set, deduped by hash rather than summed per-VFS: blobs are content-addressed and the server dedups on (app, user, hash), so summing would overstate it and buy the user storage they do not need. The control is named sizes with prices on the labels, plus an exact figure. Not tiers - pricing is continuous, so a named cap commits us to no price and any point between them is still accepted. Prices have to be visible without interacting, because this is a spending decision and the enforced quantity is bytes; the described screen carries both cadences on every label so the cadence field cannot leave a price stale. A filling cap now says so, and says what happens: uploads stop, metadata sync carries on. Previously the first news was a 402 from the blob route, which reads as a sync that broke. The policy lives in one module both screens read. They disagreed before - one drew bounds it did not enforce, the other enforced bounds it did not draw - and a cap the two disagreed about would be two products. Replaces the acceptance test for quasicoherent 57c21152 (a display derived from an uncommitted value): with a price on every option there is no uncommitted value to derive a display from, so the finding is dissolved for this control rather than worked around.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-21 19:43 UTC
Signed with PGP, not checked
Commit: 6cfa9f90362318e89aad0892770da4ea363fcf29
Parent: 08e80f6
11 files changed, +1009 insertions, -157 deletions
@@ -17,6 +17,7 @@
17 17 pub mod loose_files_worker;
18 18 pub mod preview;
19 19 pub mod state;
20 + pub mod storage_cap;
20 21 pub mod ui;
21 22
22 23 /// The described screens, behind the off-by-default `quasi` feature.
@@ -2241,6 +2241,42 @@
2241 2241 )?;
2242 2242 Ok((count, total))
2243 2243 }
2244 +
2245 + /// Count and total bytes of the samples blob sync would actually upload:
2246 + /// the *union* of every VFS with `sync_files` set.
2247 + ///
2248 + /// A union rather than a sum over [`vfs_storage_stats`](Self::vfs_storage_stats),
2249 + /// because a sample placed in two synced VFSes uploads once. Blobs are
2250 + /// content-addressed and the server dedups on `(app, user, hash)`, so
2251 + /// adding the per-VFS figures would overstate the need and buy the user a
2252 + /// cap they do not require. Reads through `live_samples` for the same
2253 + /// reason the per-VFS query does: a tombstoned sample is not going to
2254 + /// upload.
2255 + ///
2256 + /// Zero synced VFSes gives `(0, 0)`, which is the honest answer — nothing
2257 + /// is set to sync, so nothing would upload.
2258 + pub fn synced_storage_stats(&self) -> Result<(u64, u64), DbError> {
2259 + let (count, total): (u64, u64) = self.conn.query_row(
2260 + "SELECT COUNT(*), COALESCE(SUM(file_size), 0) FROM live_samples \
2261 + WHERE hash IN (\
2262 + SELECT DISTINCT sample_hash FROM vfs_nodes \
2263 + WHERE sample_hash IS NOT NULL \
2264 + AND vfs_id IN (SELECT id FROM vfs WHERE sync_files != 0)\
2265 + )",
2266 + [],
2267 + |row| {
2268 + let count = row.get::<_, i64>(0)?;
2269 + let total = row.get::<_, i64>(1)?;
2270 + Ok((
2271 + u64::try_from(count)
2272 + .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(0, count))?,
2273 + u64::try_from(total)
2274 + .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(1, total))?,
2275 + ))
2276 + },
2277 + )?;
2278 + Ok((count, total))
2279 + }
2244 2280 }
2245 2281
2246 2282 #[cfg(test)]
@@ -3018,6 +3054,70 @@
3018 3054 assert_eq!(idx_count, 1);
3019 3055 }
3020 3056
3057 + /// The need blob sync computes is a union over synced VFSes, not a sum.
3058 + ///
3059 + /// The distinction is the whole reason `synced_storage_stats` exists
3060 + /// separately from summing `vfs_storage_stats`: a sample placed in two
3061 + /// synced VFSes uploads once, because blobs are content-addressed. Summing
3062 + /// would report 300 here and propose a cap for storage nobody needs.
3063 + #[test]
3064 + fn synced_storage_counts_a_shared_sample_once() {
3065 + let db = Database::open_in_memory().unwrap();
3066 + db.conn()
3067 + .execute_batch(
3068 + "INSERT INTO samples
3069 + (hash, original_name, file_extension, file_size, import_date, last_modified)
3070 + VALUES ('shared', 's.wav', 'wav', 100, 0, 0),
3071 + ('only_a', 'a.wav', 'wav', 50, 0, 0),
3072 + ('unsynced', 'u.wav', 'wav', 999, 0, 0);
3073 + INSERT INTO vfs (id, name, created_at, modified_at, sync_files)
3074 + VALUES (1, 'A', 0, 0, 1), (2, 'B', 0, 0, 1), (3, 'Off', 0, 0, 0);
3075 + INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, sample_hash, created_at)
3076 + VALUES (1, NULL, 's.wav', 'sample', 'shared', 0),
3077 + (2, NULL, 's.wav', 'sample', 'shared', 0),
3078 + (1, NULL, 'a.wav', 'sample', 'only_a', 0),
3079 + (3, NULL, 'u.wav', 'sample', 'unsynced', 0);",
3080 + )
3081 + .unwrap();
3082 +
3083 + let (count, bytes) = db.synced_storage_stats().unwrap();
3084 + assert_eq!(
3085 + count, 2,
3086 + "the shared sample counts once, the unsynced not at all"
3087 + );
3088 + assert_eq!(bytes, 150, "100 + 50; summing the two VFSes would say 250");
3089 +
3090 + // The per-VFS figures are what a naive sum would have used.
3091 + assert_eq!(db.vfs_storage_stats(1).unwrap(), (2, 150));
3092 + assert_eq!(db.vfs_storage_stats(2).unwrap(), (1, 100));
3093 + }
3094 +
3095 + /// Nothing set to sync means nothing would upload, and the honest answer is
3096 + /// zero rather than the whole library. A default vault has `sync_files = 0`
3097 + /// on every VFS, so this is the state a new user is actually in.
3098 + #[test]
3099 + fn synced_storage_is_zero_when_no_vfs_syncs_files() {
3100 + let db = Database::open_in_memory().unwrap();
3101 + db.conn()
3102 + .execute_batch(
3103 + "INSERT INTO samples
3104 + (hash, original_name, file_extension, file_size, import_date, last_modified)
3105 + VALUES ('a', 'a.wav', 'wav', 100, 0, 0);
3106 + INSERT INTO vfs (id, name, created_at, modified_at, sync_files)
3107 + VALUES (1, 'A', 0, 0, 0);
3108 + INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, sample_hash, created_at)
3109 + VALUES (1, NULL, 'a.wav', 'sample', 'a', 0);",
3110 + )
3111 + .unwrap();
3112 +
3113 + assert_eq!(db.synced_storage_stats().unwrap(), (0, 0));
3114 + assert_eq!(
3115 + db.storage_stats().unwrap(),
3116 + (1, 100),
3117 + "the library is not empty"
3118 + );
3119 + }
3120 +
3021 3121 /// Recovery branch contract: when the non-ALTER batch fails for a
3022 3122 /// reason OTHER than "already exists", `migrate()` must roll back and
3023 3123 /// surface the error, NOT bump `user_version` past the failed
@@ -963,6 +963,12 @@
963 963 /// before enabling blob sync for that vault.
964 964 fn vfs_storage_stats(&self, vfs_id: audiofiles_core::VfsId) -> BackendResult<(u64, u64)>;
965 965
966 + /// `(unique_sample_count, total_bytes)` blob sync would upload: the union
967 + /// of every VFS with `sync_files` set, deduped by hash. This is the number
968 + /// the storage cap is chosen against, so the subscribe screen can propose
969 + /// an answer instead of asking for one.
970 + fn synced_storage_stats(&self) -> BackendResult<(u64, u64)>;
971 +
966 972 /// Non-blocking poll for worker events.
967 973 fn poll_events(&self) -> Vec<BackendEvent>;
968 974 }
@@ -298,6 +298,16 @@
298 298 /// What a cap may be, once pricing has been fetched.
299 299 fn pricing(&self) -> Option<Pricing>;
300 300
301 + /// How many bytes blob sync would upload today: the union of every VFS with
302 + /// `sync_files` set, deduped by hash.
303 + ///
304 + /// The cap is chosen against this, which is why the screen can propose an
305 + /// answer rather than ask for one. `None` is "cannot look" - no backend on
306 + /// this side, or the query failed - and is what the screen falls back to
307 + /// the floor on. `Some(0)` is different and means something: nothing is set
308 + /// to sync yet.
309 + fn synced_library_bytes(&self) -> Option<i64>;
310 +
301 311 /// What this cap costs at this cadence, in cents.
302 312 fn quote_cents(&self, cap_bytes: i64, annual: bool) -> i64;
303 313
@@ -316,11 +326,18 @@
316 326 }
317 327
318 328 /// The app's sync manager, as the narrow thing a described screen borrows.
319 - pub struct FromSyncManager<'a>(pub &'a audiofiles_sync::SyncManager);
329 + ///
330 + /// Carries the backend as well as the manager, for one fact: the cap screen has
331 + /// to know how much would upload, and that lives in the vault rather than in
332 + /// the sync service. Everything else here is the manager.
333 + pub struct FromSyncManager<'a> {
334 + pub manager: &'a audiofiles_sync::SyncManager,
335 + pub backend: &'a dyn crate::backend::Backend,
336 + }
320 337
321 338 impl Sync for FromSyncManager<'_> {
322 339 fn status(&self) -> Status {
323 - let status = self.0.status();
340 + let status = self.manager.status();
324 341 Status {
325 342 state: match status.state {
326 343 audiofiles_sync::SyncState::Disconnected => State::Disconnected,
@@ -340,39 +357,39 @@
340 357 }
341 358
342 359 fn connect(&self) -> Result<String, String> {
343 - self.0.start_auth().map_err(|error| error.to_string())
360 + self.manager.start_auth().map_err(|error| error.to_string())
344 361 }
345 362
346 363 fn cancel(&self) {
347 - self.0.cancel_auth();
364 + self.manager.cancel_auth();
348 365 }
349 366
350 367 fn set_password(&self, password: &str, is_new: bool) {
351 - self.0.setup_encryption(password.to_owned(), is_new);
368 + self.manager.setup_encryption(password.to_owned(), is_new);
352 369 }
353 370
354 371 fn sync_now(&self) {
355 - self.0.sync_now();
372 + self.manager.sync_now();
356 373 }
357 374
358 375 fn set_auto(&self, enabled: bool) {
359 - self.0.update_settings(Some(enabled), None);
376 + self.manager.update_settings(Some(enabled), None);
360 377 }
361 378
362 379 fn set_interval(&self, minutes: u32) {
363 - self.0.update_settings(None, Some(minutes));
380 + self.manager.update_settings(None, Some(minutes));
364 381 }
365 382
366 383 fn clear_error(&self) {
367 - self.0.clear_last_error();
384 + self.manager.clear_last_error();
368 385 }
369 386
370 387 fn disconnect(&self) {
371 - self.0.disconnect();
388 + self.manager.disconnect();
372 389 }
373 390
374 391 fn subscription(&self) -> Option<Subscription> {
375 - let status = self.0.status();
392 + let status = self.manager.status();
376 393 status.subscription.map(|sub| Subscription {
377 394 active: sub.active,
378 395 limit_bytes: sub.storage_limit_bytes.unwrap_or(0),
@@ -385,28 +402,33 @@
385 402 }
386 403
387 404 fn pricing(&self) -> Option<Pricing> {
388 - self.0.status().pricing.map(|pricing| Pricing {
405 + self.manager.status().pricing.map(|pricing| Pricing {
389 406 min_bytes: pricing.min_cap_bytes,
390 407 max_bytes: pricing.max_cap_bytes,
391 408 })
392 409 }
393 410
411 + fn synced_library_bytes(&self) -> Option<i64> {
412 + let (_, bytes) = self.backend.synced_storage_stats().ok()?;
413 + i64::try_from(bytes).ok()
414 + }
415 +
394 416 fn quote_cents(&self, cap_bytes: i64, annual: bool) -> i64 {
395 - self.0.status().pricing.map_or(0, |pricing| {
417 + self.manager.status().pricing.map_or(0, |pricing| {
396 418 pricing.quote_cents(cap_bytes, interval_of(annual)).0
397 419 })
398 420 }
399 421
400 422 fn refresh_subscription(&self) {
401 - self.0.fetch_subscription_status();
423 + self.manager.fetch_subscription_status();
402 424 }
403 425
404 426 fn subscribe(&self, cap_bytes: i64, annual: bool) {
405 - self.0.subscribe(cap_bytes, interval_of(annual));
427 + self.manager.subscribe(cap_bytes, interval_of(annual));
406 428 }
407 429
408 430 fn queue_cap_change(&self, cap_bytes: i64) {
409 - self.0.queue_cap_change(cap_bytes);
431 + self.manager.queue_cap_change(cap_bytes);
410 432 }
411 433 }
412 434
@@ -457,6 +479,9 @@
457 479 fn pricing(&self) -> Option<Pricing> {
458 480 None
459 481 }
482 + fn synced_library_bytes(&self) -> Option<i64> {
483 + None
484 + }
460 485 fn quote_cents(&self, _cap_bytes: i64, _annual: bool) -> i64 {
461 486 0
462 487 }
@@ -1718,7 +1718,10 @@
1718 1718 intents,
1719 1719 } = host;
1720 1720 let config = FromBackend(&*state.backend);
1721 - let manager = sync.map(FromSyncManager);
1721 + let manager = sync.map(|manager| FromSyncManager {
1722 + manager,
1723 + backend: &*state.backend,
1724 + });
1722 1725 let unconfigured = Unconfigured;
1723 1726 let sync: &dyn Sync = match &manager {
1724 1727 Some(manager) => manager,
@@ -116,7 +116,7 @@
116 116 const BODY: &str = "sync-body";
117 117
118 118 /// One gibibyte, which is what a cap is counted in.
119 - const GIB: i64 = 1024 * 1024 * 1024;
119 + use crate::storage_cap::GIB;
120 120
121 121 /// The field a cap is chosen with.
122 122 const CAP: &str = "cap_gib";
@@ -492,7 +492,7 @@
492 492 // At ninety percent, not past it: this is a cap that stops syncing
493 493 // when it fills, and the point of saying so is to say it before
494 494 // that happens.
495 - .tone(if sub.used_bytes * 10 >= sub.limit_bytes * 9 {
495 + .tone(if nearly_full(sub) {
496 496 Tone::Warning
497 497 } else {
498 498 Tone::Neutral
@@ -500,6 +500,23 @@
500 500 ));
501 501 }
502 502
503 + // Say it in words as well as in the bar, and say what happens next. A meter
504 + // that has gone amber reports a quantity; the user needs the consequence,
505 + // which is that uploads stop and metadata sync carries on. Without this the
506 + // first news of a full cap is a failed upload - the 402 from
507 + // `routes/synckit/blobs.rs`, which the user meets as a sync that broke.
508 + if let Some(warning) = cap_warning(sync, sub, pricing) {
509 + slot = slot.with(Node::Notice {
510 + kind: quasi_router::layout::Notice::Banner,
511 + tone: if sub.used_bytes >= sub.limit_bytes {
512 + Tone::Danger
513 + } else {
514 + Tone::Warning
515 + },
516 + text: warning,
517 + });
518 + }
519 +
503 520 if let Some(pending) = sub.pending_limit_bytes {
504 521 slot = slot.with(Node::text(format!(
505 522 "Pending: cap changes to {} at next renewal.",
@@ -507,69 +524,230 @@
507 524 )));
508 525 }
509 526
510 - let annual = sub.interval == "annual";
511 - Node::Region(slot.with(Node::Form {
512 - fields: vec![cap_field(pricing, sub.limit_bytes).hint(format!(
513 - "{} at this cap, {}.",
514 - money(sync.quote_cents(sub.limit_bytes, annual)),
515 - if annual { "per year" } else { "per month" }
516 - ))],
517 - submit: "Update cap".to_owned(),
518 - action: Action::post("/sync/cap"),
519 - }))
527 + // The same control the subscribe screen uses, defaulted to what is already
528 + // bought rather than to a proposal: this user has answered the question, and
529 + // re-proposing over their answer would be the screen arguing with them. The
530 + // exception is a cap that no longer covers the library, where the proposal
531 + // is the point.
532 + let default = if nearly_full(sub) {
533 + proposed_cap(sync.synced_library_bytes(), pricing).max(sub.limit_bytes)
534 + } else {
535 + sub.limit_bytes
536 + };
537 +
538 + Node::Region(
539 + slot.with(Node::Form {
540 + fields: vec![cap_choice(sync, pricing, default)],
541 + submit: "Update cap".to_owned(),
542 + action: Action::post("/sync/cap"),
543 + })
544 + .with(exact_cap_form(pricing, default, "/sync/cap")),
545 + )
520 546 }
521 547
522 - /// No subscription yet: pick a cap and a cadence.
548 + /// Whether the cap is close enough to full to say so.
523 549 ///
524 - /// One form with the cadence in it, rather than one cap and two priced buttons.
525 - /// That is the redesign the module header records: a form carries one action and
526 - /// one submit, so two priced choices over one value is unsayable as drawn.
550 + /// Ninety percent, the same threshold the meter turns amber at, so the bar and
551 + /// the sentence never disagree about whether this is a problem.
552 + fn nearly_full(sub: &Subscription) -> bool {
553 + crate::storage_cap::nearly_full(sub.used_bytes, sub.limit_bytes)
554 + }
555 +
556 + /// What to say about a cap that is filling, if anything.
557 + ///
558 + /// Three cases, and they are different sentences rather than degrees of one.
559 + /// Full means uploads have already stopped. Nearly full means they are about to.
560 + /// A library that has outgrown the cap means the number to fix it is known, so
561 + /// the message carries it and what it costs.
562 + fn cap_warning(sync: &dyn Sync, sub: &Subscription, pricing: &super::Pricing) -> Option<String> {
563 + if !nearly_full(sub) {
564 + return None;
565 + }
566 +
567 + let annual = sub.interval == "annual";
568 + let cadence = if annual { "a year" } else { "a month" };
569 + let suggestion = |bytes: i64| {
570 + format!(
571 + " {} would hold it, at {} {cadence}.",
572 + gib_of(bytes),
573 + money(sync.quote_cents(bytes, annual))
574 + )
575 + };
576 +
577 + // Only offer a bigger cap when there is one, and when it is actually bigger
578 + // than what they have. At the ceiling the honest answer is that raising the
579 + // cap is not the remedy.
580 + let bigger = sync
581 + .synced_library_bytes()
582 + .map(|need| proposed_cap(Some(need), pricing))
583 + .filter(|proposed| *proposed > sub.limit_bytes)
584 + .map_or_else(String::new, suggestion);
585 +
586 + Some(if sub.used_bytes >= sub.limit_bytes {
587 + format!(
588 + "Your storage cap is full. New sample files are not uploading; \
589 + everything else still syncs.{bigger}"
590 + )
591 + } else {
592 + format!(
593 + "You are close to your storage cap. When it fills, new sample files \
594 + stop uploading and everything else keeps syncing.{bigger}"
595 + )
596 + })
597 + }
598 +
599 + /// No subscription yet: the screen proposes a cap and says what it costs.
600 + ///
601 + /// The redesign of 2026-08-21, and what it turns on is that **the app already
602 + /// knows the answer it used to ask for**. `synced_library_bytes` is the exact
603 + /// size of what blob sync would upload, available locally and instantly, so a
604 + /// control that opened on an unfilled number was soliciting a guess at a
605 + /// question it could compute.
606 + ///
607 + /// So: state the need, propose a cap with headroom, and show every alternative
608 + /// with its price attached. The user confirms or nudges one number instead of
609 + /// exploring three orders of magnitude, which is what a slider from 250 GiB to
610 + /// 10 TiB asked them to do and what nobody ever did.
527 611 fn on_offer(sync: &dyn Sync, pricing: &super::Pricing) -> Node {
528 - let start = pricing.min_bytes;
612 + let need = sync.synced_library_bytes();
613 + let proposed = proposed_cap(need, pricing);
614 +
615 + let mut slot = Slot::new("subscription", RegionKind::Pane);
616 +
617 + // The need, first, because it is the reason the rest of the screen says what
618 + // it says. `None` is "cannot look"; `Some(0)` is a real and different answer.
619 + slot = slot.with(Node::text(match need {
620 + Some(0) => "No vault is set to sync sample files yet, so nothing would upload today. \
621 + Turn on file sync for a vault to change that."
622 + .to_owned(),
623 + Some(bytes) => format!(
624 + "Your synced vaults hold {}. That is what would upload.",
625 + gib_of_exact(bytes)
626 + ),
627 + None => "Pick a storage cap for audio file sync.".to_owned(),
628 + }));
629 +
630 + if need.is_some_and(|bytes| bytes > 0) {
631 + slot = slot.with(Node::text(format!(
632 + "Proposed: {}, which leaves room to grow.",
633 + gib_of(proposed)
634 + )));
635 + }
636 +
637 + slot = slot.with(Node::text(
638 + "Annual is two months free: fewer Stripe fees, and we pass the savings on.",
639 + ));
640 +
529 641 Node::Region(
530 - Slot::new("subscription", RegionKind::Pane)
531 - .with(Node::text("Pick a storage cap for audio file sync."))
532 - .with(Node::text(
533 - "Annual is two months free: fewer Stripe fees, and we pass the savings on.",
534 - ))
642 + slot.with(Node::Form {
643 + fields: vec![
644 + cap_choice(sync, pricing, proposed),
645 + Field::radio(
646 + "cadence",
647 + "Billing",
648 + vec![
649 + Choice::new("annual", "Annual"),
650 + Choice::new("monthly", "Monthly"),
651 + ],
652 + )
653 + .value("annual"),
654 + ],
655 + submit: "Subscribe".to_owned(),
656 + action: Action::post("/sync/subscribe"),
657 + })
658 + .with(exact_cap_form(pricing, proposed, "/sync/subscribe")),
659 + )
660 + }
661 +
662 + /// The cap as a few named sizes, each carrying what it costs.
663 + ///
664 + /// A [`Field::radio`] rather than a select, and that is the whole point of the
665 + /// control: the prices have to be *visible* without interacting, because the
666 + /// decision being made is a spending decision and the enforced quantity is
667 + /// bytes. A dropdown hides five of the six prices behind a click.
668 + ///
669 + /// Both cadences are on every label, so no label goes stale when the cadence
670 + /// field changes underneath it. That replaces the old hint - "prices shown are
671 + /// for the smallest cap; the exact figure is on the checkout page" - which was
672 + /// a form apologising for not being able to say what it charged.
673 + fn cap_choice(sync: &dyn Sync, pricing: &super::Pricing, proposed: i64) -> Field {
674 + // The selected cap is always among the options, even when it is not one of
675 + // the named sizes. A user who typed an exact figure, or who is on a cap from
676 + // before this list existed, must see their own cap selected rather than a
677 + // group with nothing chosen - which is what a radio says when its value
678 + // matches no option, and it reads as "you have not chosen" to someone who
679 + // has.
680 + let mut sizes: Vec<i64> = offered_caps(pricing).collect();
681 + if !sizes.contains(&proposed) {
682 + sizes.push(proposed);
683 + sizes.sort_unstable();
684 + }
685 +
686 + let options = sizes
687 + .into_iter()
688 + .map(|bytes| {
689 + Choice::new(
690 + gib_count(bytes).to_string(),
691 + format!(
692 + "{} - {} a month, or {} a year",
693 + gib_of(bytes),
694 + money(sync.quote_cents(bytes, false)),
695 + money(sync.quote_cents(bytes, true))
696 + ),
697 + )
698 + })
699 + .collect();
700 +
701 + Field::radio(CAP, "Storage cap", options).value(gib_count(proposed).to_string())
702 + }
703 +
704 + /// The exact-figure entry, as its own form.
705 + ///
706 + /// Two forms rather than one, because they are two acts. Picking a named size is
707 + /// confirming a proposal; typing a number is overriding it, and a form carries
708 + /// one submit and one action, so a single form offering both would have two
709 + /// controls competing to answer one value.
710 + ///
711 + /// `min` and `max` are set here, which is what the old `cap_field` claimed in its
712 + /// doc comment and did not do: it was a bare number with no bounds, and the only
713 + /// thing that rejected an out-of-range cap was `cap_from`, after submit, with
714 + /// "that cap is not on offer".
715 + fn exact_cap_form(pricing: &super::Pricing, proposed: i64, action: &str) -> Node {
716 + let field = Field {
717 + min: Some(gib_count(pricing.min_bytes).to_string()),
718 + max: Some(gib_count(pricing.max_bytes).to_string()),
719 + ..Field::new(FieldKind::Number, CAP, "Storage cap (GiB)")
720 + }
721 + .value(gib_count(proposed).to_string())
722 + .required()
723 + .hint(format!(
724 + "Anything from {} to {}.",
725 + gib_of(pricing.min_bytes),
726 + gib_of(pricing.max_bytes)
727 + ));
728 +
729 + Node::Region(
730 + Slot::new("exact-cap", RegionKind::Pane)
731 + .with(Node::text("Or set an exact cap."))
535 732 .with(Node::Form {
536 - fields: vec![
537 - cap_field(pricing, start),
538 - Field::select(
539 - "cadence",
540 - "Billing",
541 - vec![
542 - Choice::new(
543 - "annual",
544 - format!("Annual, {} a year", money(sync.quote_cents(start, true))),
545 - ),
546 - Choice::new(
547 - "monthly",
548 - format!(
549 - "Monthly, {} a month",
550 - money(sync.quote_cents(start, false))
551 - ),
552 - ),
553 - ],
554 - )
555 - .hint("Prices shown are for the smallest cap; the exact figure is on the checkout page."),
556 - ],
557 - submit: "Subscribe".to_owned(),
558 - action: Action::post("/sync/subscribe"),
733 + fields: vec![field],
734 + submit: "Use this cap".to_owned(),
735 + action: Action::post(action),
559 736 }),
560 737 )
561 738 }
562 739
563 - /// The cap, as a bounded number.
740 + /// The named caps this pricing actually permits, smallest first.
564 741 ///
565 - /// `min` and `max` are the bounds a renderer draws as a track and a route checks
566 - /// again. What is *not* said is that the shipped slider is logarithmic: that is
567 - /// a scale, which is how a renderer spends the space it has, and a description
568 - /// that named it would be naming a widget.
569 - fn cap_field(pricing: &super::Pricing, current: i64) -> Field {
570 - Field::new(FieldKind::Number, CAP, "Storage cap (GiB)")
571 - .value(gib_count(current.max(pricing.min_bytes)).to_string())
572 - .required()
742 + /// Both screens read the same list from [`crate::storage_cap`]; a cap the egui
743 + /// panel offered and this one did not would be two products.
744 + fn offered_caps(pricing: &super::Pricing) -> impl Iterator<Item = i64> {
745 + crate::storage_cap::offered(pricing.min_bytes, pricing.max_bytes)
746 + }
747 +
748 + /// The cap to propose, sized to what would actually upload.
749 + fn proposed_cap(need: Option<i64>, pricing: &super::Pricing) -> i64 {
750 + crate::storage_cap::proposed(need, pricing.min_bytes, pricing.max_bytes)
573 751 }
574 752
575 753 /// A byte count as whole GiB, for a person.
@@ -577,6 +755,27 @@
577 755 u32::try_from(bytes / GIB).unwrap_or(u32::MAX)
578 756 }
579 757
758 + /// A byte count as a size a person reads, keeping one decimal below a TiB.
759 + ///
760 + /// Distinct from [`gib_of`], which spells a *cap* - always a whole number of
761 + /// GiB, because that is what a cap is. This spells a measurement, where
762 + /// rounding 180.4 GiB to "180 GiB" is fine but rounding 0.4 GiB to "0 GiB"
763 + /// would tell a user with a small library that they have nothing.
764 + fn gib_of_exact(bytes: i64) -> String {
765 + #[expect(
766 + clippy::cast_precision_loss,
767 + reason = "a library size in GiB is far inside f64's exact integer range"
768 + )]
769 + let gib = bytes as f64 / GIB as f64;
770 + if gib >= 1024.0 {
771 + format!("{:.1} TiB", gib / 1024.0)
772 + } else if gib >= 10.0 {
773 + format!("{gib:.0} GiB")
774 + } else {
775 + format!("{gib:.1} GiB")
776 + }
777 + }
778 +
580 779 /// A byte count as a cap, spelled the way the shipped panel spells it.
581 780 fn gib_of(bytes: i64) -> String {
582 781 let gib = bytes / GIB;
@@ -275,6 +275,44 @@
275 275 .collect()
276 276 }
277 277
278 + /// Every node on a screen, descending into regions.
279 + ///
280 + /// [`nodes`] stops at the top level, which is enough for a flat screen. The sync
281 + /// screen nests: a body region holds a subscription region holds the forms, so a
282 + /// test that asks what the screen says has to walk down.
283 + fn nodes_deep(screen: &Screen) -> Vec<Node> {
284 + fn walk(node: &Node, out: &mut Vec<Node>) {
285 + out.push(node.clone());
286 + if let Node::Region(slot) = node {
287 + for placed in &slot.body {
288 + walk(&placed.node, out);
289 + }
290 + }
291 + }
292 + let mut out = Vec::new();
293 + for slot in &screen.slots {
294 + for placed in &slot.body {
295 + walk(&placed.node, &mut out);
296 + }
297 + }
298 + out
299 + }
300 +
301 + /// What the screen says, regions included. See [`said`].
302 + fn said_deep(screen: &Screen) -> String {
303 + nodes_deep(screen)
304 + .iter()
305 + .filter_map(|node| match node {
306 + Node::Text { text, .. } | Node::Notice { text, .. } | Node::Heading { text, .. } => {
307 + Some(text.clone())
308 + }
309 + Node::StandIn { message, .. } => Some(message.clone()),
310 + _ => None,
311 + })
312 + .collect::<Vec<_>>()
313 + .join(" | ")
314 + }
315 +
278 316 /// The text of every prose and notice node on a screen, joined.
279 317 ///
280 318 /// Assertions read against this rather than against node positions: what a
@@ -484,6 +522,9 @@
484 522 fn pricing(&self) -> Option<Pricing> {
485 523 None
486 524 }
525 + fn synced_library_bytes(&self) -> Option<i64> {
526 + None
527 + }
487 528 fn quote_cents(&self, _cap_bytes: i64, _annual: bool) -> i64 {
488 529 0
489 530 }
@@ -842,6 +883,8 @@
842 883 subscription: Option<Subscription>,
843 884 /// Whether pricing has arrived.
844 885 priced: bool,
886 + /// What the vault says would upload, if the screen can look.
887 + library_bytes: Option<i64>,
845 888 }
846 889
847 890 impl FakeSync {
@@ -858,6 +901,7 @@
858 901 calls: RefCell::new(Vec::new()),
859 902 subscription: None,
860 903 priced: true,
904 + library_bytes: Some(0),
861 905 }
862 906 }
863 907
@@ -923,6 +967,10 @@
923 967 })
924 968 }
925 969
970 + fn synced_library_bytes(&self) -> Option<i64> {
971 + self.library_bytes
972 + }
973 +
926 974 fn quote_cents(&self, cap_bytes: i64, annual: bool) -> i64 {
927 975 // A stand-in for the server's model: a dollar a gibibyte a month, and
928 976 // two months free on the year. The screen never computes a price, so
@@ -1331,10 +1379,18 @@
1331 1379 }
1332 1380
1333 1381 #[test]
1334 - fn the_price_shown_is_the_committed_cap_and_not_a_live_quote() {
1335 - // The finding, asserted so it cannot be quietly "fixed" by making the field
1336 - // fire per keystroke: the hint prices what is stored, because nothing
1337 - // describes a display derived from a control's own uncommitted value.
1382 + fn every_offered_cap_carries_its_own_price() {
1383 + // This replaces `the_price_shown_is_the_committed_cap_and_not_a_live_quote`,
1384 + // which asserted the old shape: one hint, pricing the committed cap, because
1385 + // nothing describes a display derived from a control's own uncommitted
1386 + // value (quasicoherent `57c21152`).
1387 + //
1388 + // The redesign dissolves that finding for this control rather than working
1389 + // around it. The cap is now chosen from named sizes and each option carries
1390 + // its own price, so there is no uncommitted value to derive a display from -
1391 + // the price is on the label the user is reading when they choose. Both
1392 + // cadences are on every label too, so the cadence field cannot leave a price
1393 + // stale underneath it.
1338 1394 let mut sync = FakeSync::in_state(State::Ready);
1339 1395 sync.subscription = Some(active(20, 1));
1340 1396 let response = syncing(&sync, Request::get("/sync")).expect("answered");
@@ -1345,21 +1401,218 @@
1345 1401 .any(|(_, action, _)| action == "/sync/cap"),
1346 1402 "a running subscription offers a cap change"
1347 1403 );
1348 - let hint = screen
1349 - .slots
1350 - .iter()
1351 - .flat_map(|slot| &slot.body)
1352 - .flat_map(|placed| match &placed.node {
1353 - Node::Region(slot) => slot.body.iter().map(|inner| inner.node.clone()).collect(),
1354 - other => vec![other.clone()],
1355 - })
1404 +
1405 + let cap = cap_field_of(screen).expect("the cap is chosen from named sizes");
1406 + assert!(
1407 + !cap.options.is_empty(),
1408 + "the cap is a choice, not a bare number"
1409 + );
1410 + for choice in &cap.options {
1411 + let gib: i64 = choice.value.parse().expect("a choice is a cap in GiB");
1412 + // The fixture prices a dollar a gibibyte a month, ten months a year.
1413 + assert!(
1414 + choice.label.contains(&format!("${gib} a month")),
1415 + "every option prices itself monthly: {}",
1416 + choice.label
1417 + );
1418 + assert!(
1419 + choice.label.contains(&format!("${} a year", gib * 10)),
1420 + "and annually, so the cadence field cannot stale it: {}",
1421 + choice.label
1422 + );
1423 + }
1424 +
1425 + // The committed cap is 20 GiB, which is not one of the named sizes. It is
1426 + // still selected, rather than the group reading as unanswered.
1427 + assert_eq!(cap.value.as_deref(), Some("20"));
1428 + assert!(
1429 + cap.options.iter().any(|c| c.value == "20"),
1430 + "a cap off the named list is still one of the options"
1431 + );
1432 + }
1433 +
1434 + /// The cap field, wherever in the screen's regions it landed.
1435 + fn cap_field_of(screen: &Screen) -> Option<quasi_router::Field> {
1436 + nodes_deep(screen).into_iter().find_map(|node| match node {
1437 + Node::Form { fields, .. } => fields
1438 + .iter()
1439 + .find(|f| f.name == "cap_gib" && f.kind == quasi_router::layout::FieldKind::Radio)
1440 + .cloned(),
1441 + _ => None,
1442 + })
1443 + }
1444 +
1445 + /// A subscription that has lapsed, which is what puts the subscribe screen up.
1446 + fn lapsed() -> Subscription {
1447 + Subscription {
1448 + active: false,
1449 + limit_bytes: 0,
1450 + used_bytes: 0,
1451 + interval: "monthly".to_owned(),
1452 + pending_limit_bytes: None,
1453 + }
1454 + }
1455 +
1456 + #[test]
1457 + fn the_subscribe_screen_sizes_the_proposal_to_the_library() {
1458 + // The measurement the redesign turns on: the app already knows how much
1459 + // would upload, so it proposes a cap instead of soliciting one. 400 GiB of
1460 + // samples wants half again as headroom - 600 - and the smallest named size
1461 + // that covers 600 is 1024.
1462 + let mut sync = FakeSync::in_state(State::Ready);
1463 + sync.subscription = Some(lapsed());
1464 + sync.library_bytes = Some(400 * GIB);
1465 +
1466 + let response = syncing(&sync, Request::get("/sync")).expect("answered");
1467 + let screen = screen_of(&response);
1468 +
1469 + assert!(
1470 + said_deep(screen).contains("400 GiB"),
1471 + "the screen states the need it sized against: {}",
1472 + said_deep(screen)
1473 + );
1474 + let cap = cap_field_of(screen).expect("a cap is offered");
1475 + assert_eq!(
1476 + cap.value.as_deref(),
1477 + Some("1024"),
1478 + "the smallest named cap covering 400 GiB plus half again"
1479 + );
1480 + }
1481 +
1482 + #[test]
1483 + fn nothing_set_to_sync_proposes_the_floor_and_says_why() {
1484 + // `Some(0)` is a real answer and a different one from "cannot look": no
1485 + // vault has file sync on, so nothing would upload. Proposing the floor is
1486 + // right, and so is saying why rather than showing a confident 250 GiB with
1487 + // no reason attached.
1488 + let mut sync = FakeSync::in_state(State::Ready);
1489 + sync.subscription = Some(lapsed());
1490 + sync.library_bytes = Some(0);
1491 +
1492 + let response = syncing(&sync, Request::get("/sync")).expect("answered");
1493 + let screen = screen_of(&response);
1494 +
1495 + assert!(
1496 + said_deep(screen).contains("No vault is set to sync"),
1497 + "{}",
1498 + said_deep(screen)
1499 + );
1500 + let cap = cap_field_of(screen).expect("a cap is offered");
1501 + assert_eq!(
1502 + cap.value.as_deref(),
1503 + Some("250"),
1504 + "the cheapest thing on offer"
1505 + );
1506 + }
1507 +
1508 + #[test]
1509 + fn a_library_that_cannot_be_read_claims_no_size() {
1510 + // `None` is "cannot look". The screen must not invent a need, and must not
1511 + // print "0" as though it had measured one.
1512 + let mut sync = FakeSync::in_state(State::Ready);
1513 + sync.subscription = Some(lapsed());
1514 + sync.library_bytes = None;
1515 +
1516 + let response = syncing(&sync, Request::get("/sync")).expect("answered");
1517 + let screen = screen_of(&response);
1518 +
1519 + let text = said_deep(screen);
1520 + assert!(
1521 + !text.contains("would upload"),
1522 + "no measurement is claimed: {text}"
1523 + );
1524 + assert!(
1525 + !text.contains("Proposed:"),
1526 + "and nothing is proposed as sized: {text}"
1527 + );
1528 + let cap = cap_field_of(screen).expect("a cap is still offered");
1529 + assert_eq!(cap.value.as_deref(), Some("250"));
1530 + }
1531 +
1532 + #[test]
1533 + fn a_filling_cap_warns_before_the_upload_fails() {
1534 + // Item 5 of the redesign. Today the first news of a full cap is a 402 from
1535 + // the blob route, which the user meets as a sync that broke. The screen
1536 + // knows the number, so it says the consequence first - and says that
1537 + // metadata sync carries on, which is the half that makes it not an outage.
1538 + let mut sync = FakeSync::in_state(State::Ready);
1539 + sync.subscription = Some(active(1024, 1000));
1540 + sync.library_bytes = Some(1000 * GIB);
1541 +
1542 + let response = syncing(&sync, Request::get("/sync")).expect("answered");
1543 + let text = said_deep(screen_of(&response));
1544 +
1545 + assert!(text.contains("close to your storage cap"), "{text}");
1546 + assert!(
1547 + text.contains("everything else keeps syncing"),
1548 + "the consequence is bounded, not an outage: {text}"
1549 + );
1550 + // 1000 GiB plus half again is 1500, so 2048 is the smallest named cap that
1551 + // holds it, and the warning carries what that costs.
1552 + assert!(
1553 + text.contains("2048 GiB") || text.contains("2.0 TiB"),
1554 + "{text}"
1555 + );
1556 + assert!(
1557 + text.contains("$2048 a month"),
1558 + "priced, at the fixture rate: {text}"
1559 + );
1560 + }
1561 +
1562 + #[test]
1563 + fn a_full_cap_says_uploads_have_already_stopped() {
1564 + // The other side of the same sentence, and it is a different one: "will
1565 + // stop" and "have stopped" are not degrees of one message.
1566 + let mut sync = FakeSync::in_state(State::Ready);
1567 + sync.subscription = Some(active(1024, 1024));
1568 + sync.library_bytes = Some(1024 * GIB);
1569 +
1570 + let response = syncing(&sync, Request::get("/sync")).expect("answered");
1571 + let text = said_deep(screen_of(&response));
1572 +
1573 + assert!(text.contains("cap is full"), "{text}");
1574 + assert!(text.contains("not uploading"), "{text}");
1575 + }
1576 +
1577 + #[test]
1578 + fn a_cap_with_room_says_nothing_about_filling() {
1579 + // The warning is a warning. A subscription at 5% must not carry it, or it
1580 + // stops being read.
1581 + let mut sync = FakeSync::in_state(State::Ready);
1582 + sync.subscription = Some(active(1024, 50));
1583 + sync.library_bytes = Some(50 * GIB);
1584 +
1585 + let response = syncing(&sync, Request::get("/sync")).expect("answered");
1586 + let text = said_deep(screen_of(&response));
1587 +
1588 + assert!(!text.contains("storage cap"), "{text}");
1589 + assert!(!text.contains("cap is full"), "{text}");
1590 + }
1591 +
1592 + #[test]
1593 + fn the_exact_cap_form_carries_the_bounds_it_claims() {
1594 + // The old `cap_field` was a bare `FieldKind::Number` with no min and no max,
1595 + // despite a doc comment saying the bounds were what a renderer draws. The
1596 + // only thing rejecting an out-of-range cap was `cap_from`, after submit.
1597 + let mut sync = FakeSync::in_state(State::Ready);
1598 + sync.subscription = Some(lapsed());
1599 +
1600 + let response = syncing(&sync, Request::get("/sync")).expect("answered");
1601 + let screen = screen_of(&response);
1602 +
1603 + let exact = nodes_deep(screen)
1604 + .into_iter()
1356 1605 .find_map(|node| match node {
1357 - Node::Form { fields, .. } => fields.iter().find(|f| f.name == "cap_gib")?.hint.clone(),
1606 + Node::Form { fields, .. } => fields
1607 + .iter()
1608 + .find(|f| f.name == "cap_gib" && f.kind == quasi_router::layout::FieldKind::Number)
1609 + .cloned(),
1358 1610 _ => None,
1359 1611 })
1360 - .expect("the cap field is priced");
1361 - // 20 GiB at the fixture's dollar-a-gibibyte, monthly.
1362 - assert!(hint.contains("$20"), "{hint}");
1612 + .expect("an exact cap can be typed");
1613 +
1614 + assert_eq!(exact.min.as_deref(), Some("10"), "the fixture's floor");
1615 + assert_eq!(exact.max.as_deref(), Some("2048"), "the fixture's ceiling");
1363 1616 }
1364 1617
1365 1618 #[test]
@@ -750,10 +750,23 @@
750 750 /// each time `show_panel` transitions to false so reopening the panel gets
751 751 /// fresh numbers.
752 752 pub vfs_storage_fetched: bool,
753 - /// User's working cap selection on the cap-picker slider, in GiB.
754 - /// Persisted across frames so dragging the slider doesn't reset. Defaults
755 - /// to 100 GiB the first time the panel renders.
756 - pub cap_picker_gib: i64,
753 + /// The cap the user has picked, in GiB, or `None` while they have not.
754 + ///
755 + /// `None` is what makes the screen able to *propose*: with no choice of
756 + /// their own, the panel shows the cap sized to what would actually upload,
757 + /// and the moment the user picks something else that choice sticks across
758 + /// frames. A plain `i64` could not tell "they chose 250" from "nobody has
759 + /// chosen and 250 is the default", which is why this is not one.
760 + ///
761 + /// Cleared when the panel closes, alongside the storage caches.
762 + pub cap_picker_gib: Option<i64>,
763 + /// What blob sync would upload today, in bytes: the union of every VFS with
764 + /// file sync on, deduped by hash. `None` means not yet looked, or the look
765 + /// failed.
766 + ///
767 + /// Cached on the same terms as `vfs_storage_cache` and for the same reason:
768 + /// it is one indexed query, but not one to run at 60Hz.
769 + pub synced_bytes: Option<i64>,
757 770 }
758 771
759 772 impl Default for SyncUiState {
@@ -774,7 +787,8 @@
774 787 auth_url: None,
775 788 vfs_storage_cache: std::collections::HashMap::new(),
776 789 vfs_storage_fetched: false,
777 - cap_picker_gib: 100,
790 + cap_picker_gib: None,
791 + synced_bytes: None,
778 792 }
779 793 }
780 794 }
@@ -8,6 +8,7 @@
8 8 use audiofiles_sync::{AppPricing, BillingInterval, SyncManager, SyncState, SyncStatus};
9 9
10 10 use crate::state::{BrowserState, ConfirmAction};
11 + use crate::storage_cap;
11 12 use crate::ui::theme;
12 13 use crate::ui::widgets;
13 14
@@ -113,6 +114,8 @@
113 114 if !state.sync.show_panel {
114 115 state.sync.vfs_storage_fetched = false;
115 116 state.sync.vfs_storage_cache.clear();
117 + state.sync.synced_bytes = None;
118 + state.sync.cap_picker_gib = None;
116 119 }
117 120
118 121 let mut open = state.sync.show_panel;
@@ -263,13 +266,70 @@
263 266 );
264 267 }
265 268
266 - // Cap-change slider for subscribed users.
269 + // Say what a filling cap means before the upload that fails says it.
270 + // Without this the first news is a 402 from the blob route, which the
271 + // user meets as a sync that broke rather than a cap that filled.
272 + if storage_cap::nearly_full(used, limit) {
273 + ui.add_space(theme::space::hair());
274 + let full = used >= limit;
275 + let text = if full {
276 + "Your storage cap is full. New sample files are not uploading; \
277 + everything else still syncs."
278 + } else {
279 + "You are close to your storage cap. When it fills, new sample \
280 + files stop uploading and everything else keeps syncing."
281 + };
282 + ui.label(egui::RichText::new(text).color(if full {
283 + theme::danger()
284 + } else {
285 + theme::warning()
286 + }));
287 + }
288 +
289 + // Cap change for subscribed users.
267 290 if let Some(pricing) = &sync_status.pricing {
268 291 let pricing = pricing.clone();
269 292 let interval_enum = BillingInterval::from_wire(interval);
270 293 ui.add_space(theme::space::peer());
271 - ui.label(egui::RichText::new("Adjust cap (takes effect next cycle):").weak());
272 - if let Some(cap) = draw_cap_picker(ui, state, &pricing, interval_enum, "Update cap")
294 + ui.label(egui::RichText::new("Adjust cap:").weak());
295 + ui.label(
296 + egui::RichText::new(
297 + "An increase applies now. A decrease takes effect next cycle.",
298 + )
299 + .small()
300 + .color(theme::content_muted()),
301 + );
302 +
303 + // A cap that no longer covers the library is the one case where
304 + // re-proposing over the user's own answer is the point.
305 + if storage_cap::nearly_full(used, limit) {
306 + let need = synced_need(state);
307 + let proposed =
308 + storage_cap::proposed(need, pricing.min_cap_bytes, pricing.max_cap_bytes);
309 + if proposed > limit {
310 + ui.label(
311 + egui::RichText::new(format!(
312 + "{} would hold it, at {}/{}.",
313 + format_cap(proposed),
314 + format_cents(pricing.quote_cents(proposed, interval_enum).0),
315 + match interval_enum {
316 + BillingInterval::Monthly => "mo",
317 + BillingInterval::Annual => "yr",
318 + }
319 + ))
320 + .color(theme::content_muted()),
321 + );
322 + }
323 + }
324 +
325 + ui.add_space(theme::space::hair());
326 + let cap = draw_cap_choice(ui, state, &pricing, interval_enum);
327 + ui.add_space(theme::space::hair());
328 + let loading = state.sync.checkout_loading;
329 + if ui
330 + .add_enabled_ui(!loading, |ui| widgets::primary_button(ui, "Update cap"))
331 + .inner
332 + .clicked()
273 333 {
274 334 sync.queue_cap_change(cap);
275 335 }
@@ -278,7 +338,10 @@
278 338 _ => {
279 339 if let Some(pricing) = &sync_status.pricing {
280 340 let pricing = pricing.clone();
281 - ui.label("Pick a storage cap for audio file sync:");
341 + // State the need first: it is why the cap below it says what it
342 + // says, and the app knows it exactly.
343 + let need = synced_need(state);
344 + draw_need(ui, need);
282 345 ui.add_space(theme::space::hair());
283 346 ui.label(
284 347 egui::RichText::new(
@@ -289,10 +352,12 @@
289 352 );
290 353 ui.add_space(theme::space::bound());
291 354
292 - // One cap slider, then annual/monthly checkout buttons for that
293 - // single chosen cap, two priced choices, not two sliders that
294 - // secretly share a value.
295 - let cap_bytes = draw_cap_slider(ui, state, &pricing);
355 + // Named sizes, each carrying its price at the annual rate, since
356 + // annual is the recommendation; the monthly figure is on the
357 + // button below. Replaces a logarithmic slider that asked the user
358 + // to sweep three orders of magnitude to reach a number the app
359 + // could already compute.
360 + let cap_bytes = draw_cap_choice(ui, state, &pricing, BillingInterval::Annual);
296 361 ui.add_space(theme::space::bound());
297 362
298 363 if state.sync.checkout_loading {
@@ -716,74 +781,108 @@
716 781 }
717 782 }
718 783
719 - /// Draw just the storage-cap slider (GiB, logarithmic) plus a cap-size label,
720 - /// clamping the working value to the pricing range. Returns the chosen cap in
721 - /// bytes. Used by the subscribe view (one slider feeding two checkout buttons).
722 - fn draw_cap_slider(ui: &mut egui::Ui, state: &mut BrowserState, pricing: &AppPricing) -> i64 {
723 - let min_gib = (pricing.min_cap_bytes / GIB).max(1);
724 - let max_gib = (pricing.max_cap_bytes / GIB).max(min_gib);
725 - state.sync.cap_picker_gib = state.sync.cap_picker_gib.clamp(min_gib, max_gib);
726 - ui.add(
727 - egui::Slider::new(&mut state.sync.cap_picker_gib, min_gib..=max_gib)
728 - .logarithmic(true)
729 - .text("GiB"),
730 - );
731 - let cap_bytes = state.sync.cap_picker_gib * GIB;
732 - ui.label(egui::RichText::new(format_cap(cap_bytes)).strong());
733 - cap_bytes
784 + /// What blob sync would upload today, cached for as long as the panel is open.
785 + ///
786 + /// One indexed query, on the same terms as `vfs_storage_cache`: cheap, but not
787 + /// at 60Hz. `None` means the look failed, which the caller must not print as a
788 + /// measured zero - "nothing is set to sync" and "could not tell" are different
789 + /// things to say.
790 + fn synced_need(state: &mut BrowserState) -> Option<i64> {
791 + if state.sync.synced_bytes.is_none()
792 + && let Ok((_, bytes)) = state.backend.synced_storage_stats()
793 + {
794 + state.sync.synced_bytes = i64::try_from(bytes).ok();
795 + }
796 + state.sync.synced_bytes
734 797 }
735 798
736 - /// Cap-picker widget: slider in GiB + live price preview + action button.
737 - /// Used both for initial subscribe and for queuing a cap change on an active
738 - /// subscription. The slider's working value lives on `BrowserState::sync` so
739 - /// it survives frames; returns `Some(cap_bytes)` on the frame the button is
740 - /// clicked so the caller can fire the action (the helper avoids touching
741 - /// `state` further itself, sidestepping borrow conflicts with action closures).
742 - fn draw_cap_picker(
799 + /// The cap the user is working with: their own pick, or the proposal.
800 + fn working_cap(state: &mut BrowserState, pricing: &AppPricing) -> i64 {
801 + let need = synced_need(state);
802 + let proposed = storage_cap::proposed(need, pricing.min_cap_bytes, pricing.max_cap_bytes);
803 + state.sync.cap_picker_gib.map_or(proposed, |gib| {
804 + (gib * GIB).clamp(pricing.min_cap_bytes, pricing.max_cap_bytes)
805 + })
806 + }
807 +
808 + /// Say what would upload, so the cap below it reads as an answer rather than a
809 + /// question.
810 + fn draw_need(ui: &mut egui::Ui, need: Option<i64>) {
811 + let text = match need {
812 + Some(0) => "No vault is set to sync sample files yet, so nothing would upload.".to_owned(),
813 + Some(bytes) => format!(
814 + "Your synced vaults hold {}. That is what would upload.",
815 + widgets::format_bytes(u64::try_from(bytes).unwrap_or(0))
816 + ),
817 + None => return,
818 + };
819 + ui.label(egui::RichText::new(text).color(theme::content_muted()));
820 + }
821 +
822 + /// The cap, as named sizes carrying their prices, plus an exact figure.
823 + ///
824 + /// Replaces the two logarithmic sliders this module used to draw. The slider
825 + /// asked the user to sweep 250 GiB to 10 TiB - three orders of magnitude - to
826 + /// land on a number the app could already compute, and it showed one price at a
827 + /// time, for whatever the handle happened to be over. Named sizes show every
828 + /// price at once, which is the point: this is a spending decision and the
829 + /// enforced quantity is bytes, so both have to be on screen together.
830 + ///
831 + /// Returns the cap in bytes that is currently selected.
832 + fn draw_cap_choice(
743 833 ui: &mut egui::Ui,
744 834 state: &mut BrowserState,
745 835 pricing: &AppPricing,
746 836 interval: BillingInterval,
747 - button_label: &str,
748 - ) -> Option<i64> {
749 - let min_gib = (pricing.min_cap_bytes / GIB).max(1);
750 - let max_gib = (pricing.max_cap_bytes / GIB).max(min_gib);
751 - if state.sync.cap_picker_gib < min_gib {
752 - state.sync.cap_picker_gib = min_gib;
753 - }
754 - if state.sync.cap_picker_gib > max_gib {
755 - state.sync.cap_picker_gib = max_gib;
837 + ) -> i64 {
838 + let mut chosen = working_cap(state, pricing);
839 +
840 + // The selected cap is always among the options, even when it is not one of
841 + // the named sizes: a user on a cap from before this list existed must see
842 + // their own cap selected, not a group with nothing chosen.
843 + let mut sizes: Vec<i64> =
844 + storage_cap::offered(pricing.min_cap_bytes, pricing.max_cap_bytes).collect();
845 + if !sizes.contains(&chosen) {
846 + sizes.push(chosen);
847 + sizes.sort_unstable();
756 848 }
757 849
758 - ui.add(
759 - egui::Slider::new(&mut state.sync.cap_picker_gib, min_gib..=max_gib)
760 - .logarithmic(true)
761 - .text("GiB"),
762 - );
763 -
764 - let cap_bytes = state.sync.cap_picker_gib * GIB;
765 - let price_cents = pricing.quote_cents(cap_bytes, interval).0;
766 850 let interval_word = match interval {
767 - BillingInterval::Monthly => "month",
768 - BillingInterval::Annual => "year",
851 + BillingInterval::Monthly => "mo",
852 + BillingInterval::Annual => "yr",
769 853 };
770 - ui.label(format!(
771 - "{} to {}/{}",
772 - format_cap(cap_bytes),
773 - format_cents(price_cents),
774 - interval_word,
775 - ));
776 -
777 - let loading = state.sync.checkout_loading;
778 - if ui
779 - .add_enabled_ui(!loading, |ui| widgets::primary_button(ui, button_label))
780 - .inner
781 - .clicked()
782 - {
783 - Some(cap_bytes)
784 - } else {
785 - None
854 + for bytes in sizes {
855 + let price = format_cents(pricing.quote_cents(bytes, interval).0);
856 + let label = format!("{} {price}/{interval_word}", format_cap(bytes));
857 + if ui.radio(chosen == bytes, label).clicked() {
858 + chosen = bytes;
859 + state.sync.cap_picker_gib = Some(bytes / GIB);
860 + }
786 861 }
862 +
863 + // The exact figure, for the cap that is not on the list. A drag value rather
864 + // than a slider: it is for typing a number you already have in mind, not for
865 + // exploring a range.
866 + ui.add_space(theme::space::hair());
867 + ui.horizontal(|ui| {
868 + ui.label(egui::RichText::new("Exact:").color(theme::content_muted()));
869 + let min_gib = (pricing.min_cap_bytes / GIB).max(1);
870 + let max_gib = (pricing.max_cap_bytes / GIB).max(min_gib);
871 + let mut gib = chosen / GIB;
872 + if ui
873 + .add(
874 + egui::DragValue::new(&mut gib)
875 + .range(min_gib..=max_gib)
876 + .suffix(" GiB"),
877 + )
878 + .changed()
879 + {
880 + chosen = gib.clamp(min_gib, max_gib) * GIB;
881 + state.sync.cap_picker_gib = Some(chosen / GIB);
882 + }
883 + });
884 +
885 + chosen
787 886 }
788 887
789 888 #[cfg(test)]
@@ -268,6 +268,12 @@
268 268 .map_err(|e| BackendError::Other(e.to_string()))
269 269 }
270 270
271 + fn synced_storage_stats(&self) -> BackendResult<(u64, u64)> {
272 + let db = self.db.lock();
273 + db.synced_storage_stats()
274 + .map_err(|e| BackendError::Other(e.to_string()))
275 + }
276 +
271 277 fn poll_events(&self) -> Vec<BackendEvent> {
272 278 let mut events = Vec::new();
273 279