Skip to main content

max / balanced_breakfast

Add sync subscription gate and UI Subscription status command + subscribe command (opens Stripe checkout in browser). Scheduler detects 402 and backs off 1 hour. Sync modal shows subscription banner with $8/year and $1/month options. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-07 03:16 UTC
Commit: de079cd1275a26d620f95b8baa2373986136e587
Parent: f363985
6 files changed, +177 insertions, -20 deletions
M docs/todo.md +47 -4
@@ -3,7 +3,49 @@
3 3 ## Status
4 4 Done: All pre-beta phases. Active: None. Next: Post-beta features.
5 5
6 - v0.3.1. Audit grade A. 601 tests (`--workspace`).
6 + v0.3.1. Audit grade A. 601 tests (`--workspace`). Rust 2024 edition (2026-05-06). Renamed `gen` → `fg` in tests (reserved keyword).
7 +
8 + ---
9 +
10 + ## UX Audit Findings (2026-05-05)
11 +
12 + Usability audit across complexity, feature completeness, learnability, and discoverability.
13 + Overall grade: B. Grades: Complexity A-, Completeness B-, Learnability B-, Discoverability C+.
14 +
15 + ### Critical (blocks core workflows) — DONE
16 +
17 + - [x] **Separate unread filter from sort** — added "Unread" toggle button in toolbar (U key), independent of sort. `buildFilter` uses `BB.state.unreadOnly`. "All caught up!" empty state when no unread items.
18 + - [x] **Add mark-all-as-read** — backend `mark_all_read` in repository + command. Toolbar "Mark All Read" button (Shift+A) with confirmation. Per-source mark-all-read in feed popover. Reloads sources + items after.
19 +
20 + ### Discoverability — DONE (except mobile gestures)
21 +
22 + - [x] Add persistent `?` help button in header — visible button wired to existing help modal
23 + - [ ] Add mobile gesture hints on first touch session — swipe right=star, left=read, pull-to-refresh. No visual affordance currently exists for any touch gesture.
24 + - [x] Show gear icon on sources always (not just on hover) — changed from `display:none` to `opacity:0.4`, full opacity on hover
25 + - [x] Rename "+ Query Feed" to "+ Saved Filter" with tooltip explaining what it does
26 + - [x] Add "Import OPML" link in the empty-state message alongside "+ Add Feed"
27 + - [x] Show keyboard shortcut hints as tooltips on toolbar buttons (Refresh, Sort, etc.)
28 +
29 + ### Learnability — DONE
30 +
31 + - [x] Add "Recommended" badge to plugin picker — rss, mastodon, reddit sorted first with yellow badge. Picker title changed to "Select a source type"
32 + - [x] Auto-trigger refresh after adding first feed — `refresh()` called after `create_feed`, toast says "Fetching articles..."
33 + - [x] Change empty-state message when feeds exist but items are empty: "Feeds added but no articles yet. Click Refresh to fetch posts."
34 + - [x] Standardize delete confirmations — bookmarks.js `confirm()` replaced with `BB.ui.confirmAction()`
35 + - [x] Clarify sync encryption setup — "First device setup" vs "Unlock this device" with clear instructions
36 + - [x] Rename "Reading List" to "Saved Articles"
37 +
38 + ### Feature Completeness
39 +
40 + - [ ] Add search highlight — inject `<mark>` tags in detail view when search is active (`detail.js:renderDetail`)
41 + - [ ] Add bookmark search — reading list only supports tag filtering, no text search (`bookmarks.js`)
42 + - [ ] Add bulk bookmark operations — delete, tag, export multiple at once
43 + - [ ] Add export all bookmarks as HTML — currently only exports one at a time
44 +
45 + ### Complexity (minor)
46 +
47 + - [ ] Add "all conditions must match" label more prominently in query feed builder — easy to miss the AND-only logic
48 + - [ ] Show live match count preview in query feed builder before saving
7 49
8 50 ---
9 51
@@ -11,10 +53,11 @@ v0.3.1. Audit grade A. 601 tests (`--workspace`).
11 53
12 54 BB is free. Cloud sync is the only revenue source. See `MNW/server/docs/internal/business/app_sync_pricing.md` for full pricing rationale.
13 55
14 - - [ ] Stripe product + prices: $1/mo monthly, $8/yr annual
15 - - [ ] Sync gate: check subscription status before enabling SyncKit sync
16 - - [ ] Subscription UI: in-app purchase/manage flow (settings panel)
56 + - [x] Stripe pricing: inline price_data ($1/mo, $8/yr), no pre-created products needed
57 + - [x] Sync gate: server returns 402, scheduler backs off 1 hour + emits `sync:subscription-required`
58 + - [x] Subscription UI: banner in sync modal with Annual/Monthly buttons, opens Stripe checkout in browser
17 59 - [ ] Annual billing messaging: explain why annual is preferred (Stripe fee transparency)
60 + - [ ] Test full checkout flow against live Stripe (end-to-end: subscribe → webhook → sync gate passes)
18 61
19 62 ---
20 63
@@ -63,6 +63,12 @@
63 63 * @returns {Promise<number>}
64 64 */
65 65 unreadCount: () => invoke('get_unread_count'),
66 + /**
67 + * Mark all unread items as read, optionally for a specific source.
68 + * @param {string|null} [sourceId] - Source busser_id, or null for all.
69 + * @returns {Promise<number>} Count of items marked read.
70 + */
71 + markAllRead: (sourceId) => invoke('mark_all_read', { sourceId: sourceId || null }),
66 72 },
67 73
68 74 // --- Plugins: Rhai busser plugins ---
@@ -281,6 +287,8 @@
281 287 * @returns {Promise<void>}
282 288 */
283 289 updateSettings: (input) => invoke('sync_update_settings', { input }),
290 + subscriptionStatus: () => invoke('sync_subscription_status'),
291 + subscribe: (interval) => invoke('sync_subscribe', { interval }),
284 292 },
285 293 };
286 294 })();
@@ -273,11 +273,11 @@
273 273
274 274 if (hasKey) {
275 275 div.innerHTML =
276 - '<p>This device needs your encryption password to decrypt your synced data.</p>';
276 + '<p><strong>Unlock this device.</strong> Enter the encryption password you set on your first device to decrypt your synced data.</p>';
277 277 } else {
278 278 div.innerHTML =
279 - '<p>Set an encryption password. This password protects your data with end-to-end encryption. ' +
280 - 'You will need it when adding new devices.</p>';
279 + '<p><strong>First device setup.</strong> Choose an encryption password to protect your data with end-to-end encryption. ' +
280 + 'You\'ll need this same password when adding Balanced Breakfast on other devices.</p>';
281 281 }
282 282
283 283 const form = document.createElement('form');
@@ -342,6 +342,12 @@
342 342 const div = document.createElement('div');
343 343 div.className = 'sync-ready';
344 344
345 + // Subscription banner (populated async)
346 + const subBanner = document.createElement('div');
347 + subBanner.id = 'sync-subscription-banner';
348 + div.appendChild(subBanner);
349 + checkSubscriptionBanner(subBanner);
350 +
345 351 // Status info
346 352 const info = document.createElement('div');
347 353 info.className = 'sync-info';
@@ -450,6 +456,47 @@
450 456 container.appendChild(div);
451 457 }
452 458
459 + /**
460 + * Check subscription status and populate the banner element.
461 + * @param {HTMLElement} banner - The banner container element.
462 + */
463 + async function checkSubscriptionBanner(banner) {
464 + try {
465 + const sub = await BB.api.sync.subscriptionStatus();
466 + if (sub.active) {
467 + const periodEnd = sub.currentPeriodEnd
468 + ? new Date(sub.currentPeriodEnd).toLocaleDateString()
469 + : '';
470 + banner.innerHTML =
471 + '<p class="sync-sub-active">Subscription active' +
472 + (periodEnd ? ' (renews ' + periodEnd + ')' : '') + '</p>';
473 + } else {
474 + banner.innerHTML =
475 + '<div class="sync-sub-required">' +
476 + '<p><strong>Subscription required</strong></p>' +
477 + '<p>Cloud sync requires a subscription ($1/month or $8/year). ' +
478 + 'Annual billing saves on payment processing fees.</p>' +
479 + '<div style="display:flex;gap:0.5rem;margin-top:0.5rem;">' +
480 + '<button class="btn btn-primary" id="sync-sub-annual">Subscribe ($8/year)</button>' +
481 + '<button class="btn" id="sync-sub-monthly">$1/month</button>' +
482 + '</div></div>';
483 + banner.querySelector('#sync-sub-annual').onclick = () => doSubscribe('annual');
484 + banner.querySelector('#sync-sub-monthly').onclick = () => doSubscribe('monthly');
485 + }
486 + } catch (_) {
487 + // Non-fatal
488 + }
489 + }
490 +
491 + async function doSubscribe(interval) {
492 + try {
493 + await BB.api.sync.subscribe(interval);
494 + BB.ui.showToast('Opening checkout in your browser...', 'success');
495 + } catch (err) {
496 + BB.ui.showToast('Subscribe failed: ' + (err.message || err), 'error');
497 + }
498 + }
499 +
453 500 // Listen for sync events to refresh feed list
454 501 if (window.__TAURI__?.event?.listen) {
455 502 window.__TAURI__.event.listen('sync:changes-applied', () => {
@@ -434,3 +434,50 @@ pub async fn sync_update_settings(
434 434
435 435 Ok(true)
436 436 }
437 +
438 + // ── Subscription Commands ──
439 +
440 + /// Check subscription status for this user + app.
441 + #[tauri::command]
442 + #[instrument(skip_all)]
443 + pub async fn sync_subscription_status(
444 + state: State<'_, Arc<AppState>>,
445 + ) -> Result<synckit_client::SubscriptionStatus, ApiError> {
446 + let client = require_sync_client(&state)?;
447 +
448 + if client.session_info().is_none() {
449 + return Err(ApiError::bad_request("Not authenticated"));
450 + }
451 +
452 + client
453 + .get_subscription_status()
454 + .await
455 + .map_err(|e| ApiError::internal(e.to_string()))
456 + }
457 +
458 + /// Create a Stripe Checkout session for subscribing to cloud sync.
459 + /// Opens the checkout URL in the user's default browser.
460 + #[tauri::command]
461 + #[instrument(skip_all)]
462 + pub async fn sync_subscribe(
463 + state: State<'_, Arc<AppState>>,
464 + interval: String,
465 + ) -> Result<String, ApiError> {
466 + let client = require_sync_client(&state)?;
467 +
468 + if client.session_info().is_none() {
469 + return Err(ApiError::bad_request("Not authenticated"));
470 + }
471 +
472 + let response = client
473 + .create_subscription_checkout("standard", &interval)
474 + .await
475 + .map_err(|e| ApiError::internal(e.to_string()))?;
476 +
477 + // Open in default browser
478 + if let Err(e) = open::that(&response.checkout_url) {
479 + tracing::warn!(error = %e, "Failed to open browser, returning URL");
480 + }
481 +
482 + Ok(response.checkout_url)
483 + }
@@ -79,6 +79,7 @@ pub fn build_app() -> tauri::Builder<tauri::Wry> {
79 79 commands::star_item,
80 80 commands::unstar_item,
81 81 commands::get_unread_count,
82 + commands::mark_all_read,
82 83 commands::list_plugins,
83 84 commands::get_plugin_schema,
84 85 commands::get_feeds_by_busser,
@@ -108,6 +109,8 @@ pub fn build_app() -> tauri::Builder<tauri::Wry> {
108 109 commands::sync_setup_encryption_new,
109 110 commands::sync_setup_encryption_existing,
110 111 commands::sync_update_settings,
112 + commands::sync_subscription_status,
113 + commands::sync_subscribe,
111 114 commands::get_config,
112 115 commands::set_config,
113 116 commands::list_query_feeds,
@@ -182,19 +182,28 @@ pub fn start_sync_scheduler(app: AppHandle) -> tokio::task::AbortHandle {
182 182 }
183 183 }
184 184 Err(e) => {
185 - consecutive_failures += 1;
186 - let backoff_secs =
187 - exponential_backoff_secs(consecutive_failures, 15) * 60;
188 - backoff_until = Some(
189 - chrono::Utc::now()
190 - + chrono::Duration::seconds(backoff_secs as i64),
191 - );
192 - warn!(
193 - error = %e,
194 - attempt = consecutive_failures,
195 - backoff_secs,
196 - "Auto-sync failed"
197 - );
185 + // If the server returned 402 (payment required), stop retrying —
186 + // the user needs to subscribe before sync will work.
187 + let is_payment_required = e.to_string().contains("402");
188 + if is_payment_required {
189 + let _ = app.emit("sync:subscription-required", ());
190 + warn!("Auto-sync: subscription required, pausing scheduler for 1 hour");
191 + backoff_until = Some(chrono::Utc::now() + chrono::Duration::hours(1));
192 + } else {
193 + consecutive_failures += 1;
194 + let backoff_secs =
195 + exponential_backoff_secs(consecutive_failures, 15) * 60;
196 + backoff_until = Some(
197 + chrono::Utc::now()
198 + + chrono::Duration::seconds(backoff_secs as i64),
199 + );
200 + warn!(
201 + error = %e,
202 + attempt = consecutive_failures,
203 + backoff_secs,
204 + "Auto-sync failed"
205 + );
206 + }
198 207 }
199 208 }
200 209