max / balanced_breakfast
6 files changed,
+177 insertions,
-20 deletions
| @@ -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 |