From 6dbf04e909fa3c49f88cd64ee79969dddb70a5cb Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Mon, 7 Sep 2026 11:18:02 +0500 Subject: [PATCH 01/15] CI: flag migrations not applied to production (#131 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploys don't auto-migrate — migrations are run on Supabase by hand — so a released migration nobody ran leaves the app querying a missing column (the pool-topup 'column claimed_at does not exist' crash). Add an applied.txt ledger of prod-applied migrations and a workflow that fails on main / release PRs when any backend/migrations/*.sql isn't listed. Seeded with 0001-0007 (actually applied); 0008/0009 are intentionally absent so the check flags the current real gap until they're run on prod and recorded. --- .github/workflows/migrations.yml | 58 ++++++++++++++++++++++++++++++++ backend/migrations/applied.txt | 17 ++++++++++ 2 files changed, 75 insertions(+) create mode 100644 .github/workflows/migrations.yml create mode 100644 backend/migrations/applied.txt diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml new file mode 100644 index 0000000..a448f81 --- /dev/null +++ b/.github/workflows/migrations.yml @@ -0,0 +1,58 @@ +# Flags migrations that ship in the code but were never applied to the +# production database. Migrations are applied to Supabase by hand (the deploy +# does not auto-migrate), so a released migration that nobody ran leaves the +# app querying a column/table that doesn't exist — e.g. the pool-topup worker +# crashing on "column claimed_at does not exist". +# +# The check compares backend/migrations/*.sql against the applied.txt ledger. +# It runs on main and on PRs INTO main (release PRs) — not on develop, where +# unreleased migrations are legitimately not-yet-applied. +name: Migrations applied check + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Flag migrations not recorded as applied to production + working-directory: backend/migrations + run: | + ledger=applied.txt + if [ ! -f "$ledger" ]; then + echo "::error::$ledger is missing — the migrations ledger must exist." + exit 1 + fi + + shopt -s nullglob + missing=() + for f in *.sql; do + grep -qxF "$f" "$ledger" || missing+=("$f") + done + + if [ ${#missing[@]} -eq 0 ]; then + echo "✓ All backend/migrations/*.sql files are recorded as applied to production." + exit 0 + fi + + { + echo "## ⚠️ Migrations not applied to production" + echo "" + echo "These \`backend/migrations/*.sql\` files are **not** listed in \`applied.txt\`, so they may ship in the code without ever having been run on the production database:" + echo "" + for f in "${missing[@]}"; do echo "- \`$f\`"; done + echo "" + echo "**Before this release reaches main:** run each on prod —" + echo "Supabase → SQL Editor, or \`psql \"\$DIRECT_URL\" -f backend/migrations/\` —" + echo "then add its filename to \`backend/migrations/applied.txt\` in this PR." + } | tee -a "$GITHUB_STEP_SUMMARY" + + echo "::error::Unapplied migrations: ${missing[*]}" + exit 1 diff --git a/backend/migrations/applied.txt b/backend/migrations/applied.txt new file mode 100644 index 0000000..f53f87a --- /dev/null +++ b/backend/migrations/applied.txt @@ -0,0 +1,17 @@ +# Migrations applied to the PRODUCTION Supabase database. +# +# CI (.github/workflows/migrations.yml) fails on main and on release PRs if any +# backend/migrations/*.sql file is NOT listed here — i.e. the code shipped a +# migration that was never run on prod (the pool-topup "column claimed_at does +# not exist" crash was exactly this). +# +# When you apply a migration to prod — Supabase → SQL Editor, or +# psql "$DIRECT_URL" -f backend/migrations/ +# — add its filename below, in the same release PR. +0001_schema.sql +0002_rls.sql +0003_seed_topics.sql +0004_seed_concepts.sql +0005_concept_backlog.sql +0006_seed_backlog.sql +0007_reminder_log.sql From 1d8753b97451fefd502a69a00184818bf0a617e0 Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Mon, 7 Sep 2026 11:24:35 +0500 Subject: [PATCH 02/15] Name the migrations-check job for a stable required-check context (#140) The job had no name, so GitHub reported the check as 'check'. Name it 'Migrations applied check' so it can be added as a required status check on main with a clear, unambiguous context. --- .github/workflows/migrations.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index a448f81..4f86ffe 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -18,6 +18,9 @@ on: jobs: check: + # Explicit name so the reported check context is stable and descriptive + # ("Migrations applied check") — it's a required status check on main. + name: Migrations applied check runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 From da101db7a272ee143492959812b8141d3c890d07 Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Mon, 7 Sep 2026 11:29:02 +0500 Subject: [PATCH 03/15] Document the release + manual-migration procedure (RELEASING.md) (#141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the develop->main release flow, the JS-only vs native (runtimeVersion) rule, the features-only What's New policy, and — the part that keeps biting — that backend/migrations must be applied to prod by hand and recorded in applied.txt, enforced by the required Migrations applied check. --- RELEASING.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 RELEASING.md diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..3cfbd90 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,47 @@ +# Releasing One Concept + +Production release flow. Keep it boring and repeatable. + +## Branch model +- `develop` is the default branch; all feature/fix PRs target it. +- `main` is production. A release is a single PR **develop → main** (no `release/*` branch). +- Merging to `main` triggers `.github/workflows/release.yml`. + +## Cutting a release +1. **Bump the version on `develop` first.** Edit `mobile/app.config.js` → `expo.version` + (the marketing version). Open a small PR into `develop` and merge it. + - **runtimeVersion** (`expo.runtimeVersion`) is the native-ABI identity. Leave it + UNCHANGED for a JS-only release; bump it ONLY on a native change (new native + module, URL scheme, permission, config plugin) — that requires a new APK. +2. **What's New card.** Add an entry to `mobile/src/data/whatsNew.ts` for the new + version. **Features only** — no bug fixes / UI tweaks / removals (issue #97). +3. **Apply pending DB migrations to production** (see below) **before opening the + release PR.** +4. Open the release PR **develop → main**. It must pass the required + **"Migrations applied check"** and get its approval, then merge. +5. On merge, `release.yml` publishes the production + preview OTA, cuts the `vX.Y.Z` + tag + GitHub Release, and dispatches the APK build (which **skips** unless + runtimeVersion changed). Railway auto-deploys the `api` service from `main`. + +## Database migrations — MANUAL, every release +The deploy does **not** auto-migrate. Files in `backend/migrations/*.sql` must be run +by hand against the production Supabase DB, or the app queries columns/tables that +don't exist (this caused the `pool-topup` "column claimed_at does not exist" crash). + +For each migration not yet applied to prod: +1. Run it — Supabase → **SQL Editor** (paste the file), or + `psql "$DIRECT_URL" -f backend/migrations/` (`DIRECT_URL` = the 5432 session pooler). +2. Add its filename to **`backend/migrations/applied.txt`**. + +`.github/workflows/migrations.yml` fails on `main` and on release PRs if any migration +isn't listed in `applied.txt`. It is a **required** check on `main`, so a release +cannot merge with an unapplied migration. Migrations are immutable once applied — +never edit an applied file; add a new one. + +## After a JS-only release +Installed apps update over the air on next launch — no reinstall. The stable APK +download link in the README changes only on a native (runtimeVersion) release. + +## One-time backend config (already set in production) +- `SUPABASE_ANON_KEY` on the Railway `api` service — the `/reset-password` page needs it. +- Supabase → Auth → Redirect URLs must include `/reset-password`. From 3f3a4307ca7f7be933ad6977c9e2011fddcede40 Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Mon, 7 Sep 2026 11:33:07 +0500 Subject: [PATCH 04/15] Record migrations 0008 + 0009 as applied to production (#142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds concept_backlog.claimed_at (fixes the pool-topup 'column claimed_at does not exist' crash) and the by-concept like-count index to the applied ledger. MERGE ONLY AFTER these have actually been run on the prod Supabase DB — the ledger asserts prod state, and the Migrations applied check goes green off it. --- backend/migrations/applied.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/migrations/applied.txt b/backend/migrations/applied.txt index f53f87a..ae690ba 100644 --- a/backend/migrations/applied.txt +++ b/backend/migrations/applied.txt @@ -15,3 +15,5 @@ 0005_concept_backlog.sql 0006_seed_backlog.sql 0007_reminder_log.sql +0008_backlog_claimed_at.sql +0009_like_count_index.sql From ac9b8ffd5a95ea0073903379d4e61b692d05f09a Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Mon, 7 Sep 2026 20:52:48 +0500 Subject: [PATCH 05/15] Improve the Forgot Password confirmation banner (#136) - Bold header ('Check your email') above the body, for scannable hierarchy. - Show the entered email back (bold) so typos are obvious, while staying neutral about whether an account exists ('if it's registered'). - Body in the primary text colour instead of green-on-green, for readable contrast on the success surface in both light and dark themes; banner marked accessibilityRole='alert'. - Clear the banner when the email field is edited again. - Same treatment applied to the sign-up 'check your email' notice. --- mobile/src/screens/AuthScreen.tsx | 59 ++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/mobile/src/screens/AuthScreen.tsx b/mobile/src/screens/AuthScreen.tsx index b7fb7a7..b004154 100644 --- a/mobile/src/screens/AuthScreen.tsx +++ b/mobile/src/screens/AuthScreen.tsx @@ -30,7 +30,11 @@ export function AuthScreen() { const [password, setPassword] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const [notice, setNotice] = useState(null); + // A banner with a bold header + body. The reset variant carries the email so + // it can be shown back for a quick typo check (issue #136). + const [notice, setNotice] = useState< + { title: string; body: string } | { title: string; sentTo: string } | null + >(null); const canSubmit = email.trim().length > 3 && password.length >= 6 && !busy; @@ -44,7 +48,10 @@ export function AuthScreen() { } else { const { needsConfirmation } = await signUp(email, password); if (needsConfirmation) { - setNotice('Check your email to confirm your account, then sign in.'); + setNotice({ + title: 'Check your email', + body: 'Confirm your account from the email we just sent, then sign in.', + }); setMode('signIn'); } } @@ -67,10 +74,9 @@ export function AuthScreen() { setBusy(true); try { await resetPassword(trimmed); - // Deliberately neutral: never reveal whether an account exists. - setNotice( - 'If an account exists for that email, a password reset link is on its way. Check your inbox (and spam).' - ); + // Show the email back for a typo check, but stay neutral about whether an + // account exists ("if it's registered") — no account enumeration. + setNotice({ title: 'Check your email', sentTo: trimmed }); } catch (e) { setError(e instanceof Error ? e.message : 'Could not send the reset email. Try again.'); } finally { @@ -118,7 +124,12 @@ export function AuthScreen() { { + setEmail(t); + // Editing the address invalidates the previous banner (#136). + if (notice) setNotice(null); + if (error) setError(null); + }} placeholder="you@example.com" placeholderTextColor={colors.textMuted} autoCapitalize="none" @@ -162,9 +173,25 @@ export function AuthScreen() { ) : null} {notice ? ( - + - {notice} + + {notice.title} + + {'sentTo' in notice ? ( + <> + We’ve sent a password reset link to{' '} + {notice.sentTo} (if it’s + registered). Check your inbox and spam folder. + + ) : ( + notice.body + )} + + ) : null} @@ -251,6 +278,20 @@ const createStyles = (colors: ThemeColors) => errorBanner: { backgroundColor: colors.categoryChip }, noticeBanner: { backgroundColor: colors.successSurface }, bannerText: { flex: 1, fontSize: scaleFont(14), lineHeight: scaleFont(20) }, + bannerBody: { flex: 1, gap: 2 }, + noticeTitle: { + fontSize: scaleFont(15), + fontWeight: '700', + color: colors.success, + }, + // Body in the primary text colour, not green-on-green — high contrast on + // the success surface in both light and dark themes (issue #136). + noticeText: { + fontSize: scaleFont(14), + lineHeight: scaleFont(20), + color: colors.text, + }, + noticeStrong: { fontWeight: '700', color: colors.text }, busy: { paddingVertical: spacing.md, alignItems: 'center' }, switchText: { textAlign: 'center', From a2f1eed862af34ba42b2e32ebd36237c40687fc3 Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Mon, 7 Sep 2026 20:59:16 +0500 Subject: [PATCH 06/15] Announce the auth error banner to screen readers too (review of #143) The notice banner got accessibilityRole='alert'; give the error banner the same so errors are announced consistently. --- mobile/src/screens/AuthScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/src/screens/AuthScreen.tsx b/mobile/src/screens/AuthScreen.tsx index b004154..a5a1461 100644 --- a/mobile/src/screens/AuthScreen.tsx +++ b/mobile/src/screens/AuthScreen.tsx @@ -166,7 +166,7 @@ export function AuthScreen() { {error ? ( - + {error} From 8ea5fb6fc915bf77de8a411934213f809f0f0a1a Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Mon, 7 Sep 2026 21:20:38 +0500 Subject: [PATCH 07/15] Infer online/offline from request outcomes (#133 phase 1) No native connectivity module (keeps the app JS-only / OTA). The API client flips to offline when a fetch throws and back to online the moment any request reaches the server; ConnectivityContext exposes it app-wide via useOnline(). --- mobile/src/api/client.ts | 28 ++++++++++++++++++++++ mobile/src/context/ConnectivityContext.tsx | 20 ++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 mobile/src/context/ConnectivityContext.tsx diff --git a/mobile/src/api/client.ts b/mobile/src/api/client.ts index 26dd261..2d9cf94 100644 --- a/mobile/src/api/client.ts +++ b/mobile/src/api/client.ts @@ -34,6 +34,30 @@ export function isApiConfigured(): boolean { return API_BASE_URL.length > 0; } +// --- Connectivity, inferred from request outcomes (no native listener) ------- +// We learn we're offline when a fetch throws (no response), and back online the +// moment any request reaches the server (even an HTTP error is "reachable"). +// The app subscribes to drive a global offline banner + the sync queue. +let online = true; +const connectivityListeners = new Set<(online: boolean) => void>(); + +export function getConnectivity(): boolean { + return online; +} + +export function subscribeConnectivity(fn: (online: boolean) => void): () => void { + connectivityListeners.add(fn); + return () => { + connectivityListeners.delete(fn); + }; +} + +function setConnectivity(next: boolean): void { + if (next === online) return; + online = next; + connectivityListeners.forEach((fn) => fn(next)); +} + interface RequestOptions { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; body?: unknown; @@ -66,9 +90,13 @@ export async function apiRequest(path: string, options: RequestOptions = {}): }); } catch (cause) { // Offline or unreachable host: callers fall back to cached state. + setConnectivity(false); throw new ApiError(0, 'Network request failed', cause); } + // Got a response (even a 4xx/5xx) — the server is reachable, so we're online. + setConnectivity(true); + if (response.status === 204) return undefined as T; const payload = await response.json().catch(() => null); diff --git a/mobile/src/context/ConnectivityContext.tsx b/mobile/src/context/ConnectivityContext.tsx new file mode 100644 index 0000000..f0bd0b7 --- /dev/null +++ b/mobile/src/context/ConnectivityContext.tsx @@ -0,0 +1,20 @@ +import { createContext, ReactNode, useContext, useEffect, useState } from 'react'; +import { getConnectivity, subscribeConnectivity } from '../api/client'; + +/** + * App-wide online/offline state, inferred from request outcomes in the API + * client (no native connectivity module — keeps the app JS-only / OTA). Starts + * optimistic (online) and flips the first time a request fails or succeeds. + */ +const ConnectivityContext = createContext(true); + +export function ConnectivityProvider({ children }: { children: ReactNode }) { + const [online, setOnline] = useState(getConnectivity()); + useEffect(() => subscribeConnectivity(setOnline), []); + return {children}; +} + +/** True when the last network attempt reached the server. */ +export function useOnline(): boolean { + return useContext(ConnectivityContext); +} From c8d5ded676460992cbd4fd77c1a5d7fe4f50e716 Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Mon, 7 Sep 2026 21:20:38 +0500 Subject: [PATCH 08/15] Add a global offline banner (#133 phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A thin strip above the navigator (signed-in and auth screens) that appears while offline — 'Offline — changes will sync when you reconnect' — replacing the single per-screen Today notice as the app-wide indicator. --- mobile/App.tsx | 25 +++++++++------ mobile/src/components/OfflineBanner.tsx | 42 +++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 9 deletions(-) create mode 100644 mobile/src/components/OfflineBanner.tsx diff --git a/mobile/App.tsx b/mobile/App.tsx index 7475db0..5fb03ce 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -13,8 +13,10 @@ import { StatusBar } from 'expo-status-bar'; import { ComponentProps, useCallback, useRef } from 'react'; import { ActivityIndicator, View } from 'react-native'; import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { OfflineBanner } from './src/components/OfflineBanner'; import { WhatsNewCard } from './src/components/WhatsNewCard'; import { AuthProvider, useAuth } from './src/context/AuthContext'; +import { ConnectivityProvider, useOnline } from './src/context/ConnectivityContext'; import { ProgressProvider } from './src/context/ProgressContext'; import { ThemeProvider, useTheme } from './src/context/ThemeContext'; import { useWhatsNew } from './src/hooks/useWhatsNew'; @@ -69,6 +71,7 @@ function ThemedApp() { const { colors, mode } = useTheme(); const { loading, session } = useAuth(); const whatsNew = useWhatsNew(); + const online = useOnline(); if (loading) { return ( @@ -88,10 +91,11 @@ function ThemedApp() { if (!session) { return ( - <> + + {!online && } - + ); } @@ -109,7 +113,8 @@ function ThemedApp() { }; return ( - <> + + {!online && } @@ -124,7 +129,7 @@ function ThemedApp() { )} - + ); } @@ -194,11 +199,13 @@ export default function App() { - - - - - + + + + + + + diff --git a/mobile/src/components/OfflineBanner.tsx b/mobile/src/components/OfflineBanner.tsx new file mode 100644 index 0000000..7a60064 --- /dev/null +++ b/mobile/src/components/OfflineBanner.tsx @@ -0,0 +1,42 @@ +import { Ionicons } from '@expo/vector-icons'; +import { StyleSheet, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useTheme } from '../context/ThemeContext'; +import { scaleFont, scaleIcon, spacing, ThemeColors } from '../theme'; + +/** + * Thin app-wide strip shown while offline. Rendered above the navigator so it + * pushes content down (no overlap) and disappears cleanly when back online. + * Sits under the status bar via the top safe-area inset. + */ +export function OfflineBanner() { + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + const styles = createStyles(colors); + return ( + + + + Offline — changes will sync when you reconnect + + + ); +} + +const createStyles = (colors: ThemeColors) => + StyleSheet.create({ + container: { backgroundColor: colors.textMuted }, + row: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.xs + 2, + paddingVertical: spacing.xs + 2, + paddingHorizontal: spacing.md, + }, + text: { fontSize: scaleFont(12), fontWeight: '600', color: colors.onPrimary }, + }); From c3827015dfea2cfd220e9f274d020b9a7d5146ff Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Mon, 7 Sep 2026 21:20:38 +0500 Subject: [PATCH 09/15] Never hang on the startup spinner offline (#133 phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app-level spinner is gated on auth loading, which only cleared inside getSession()'s .then — a rejection or hang left it spinning forever (the infinite-spinner-offline report). Add a .catch (treat as signed out) and an 8s failsafe timeout so loading always resolves. --- mobile/src/context/AuthContext.tsx | 51 +++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/mobile/src/context/AuthContext.tsx b/mobile/src/context/AuthContext.tsx index 89c4306..c7ba4d3 100644 --- a/mobile/src/context/AuthContext.tsx +++ b/mobile/src/context/AuthContext.tsx @@ -60,21 +60,42 @@ export function AuthProvider({ children }: { children: ReactNode }) { }); let active = true; - supabase.auth.getSession().then(({ data }) => { - if (!active) return; - setSession(data.session); - setLoading(false); - // Best-effort: reminders are a bonus, never a blocker for signing in. - if (data.session) { - registerForReminders().catch(() => {}); - syncTimezone().catch(() => {}); - } else { - // Signed out at startup: account caches have no business existing. - // Covers sessions that vanished without a SIGNED_OUT ever firing - // (cleared or corrupted auth storage across a restart). - clearAccountCaches().catch(() => {}); - } - }); + + // The app-level spinner is gated on `loading`, so it MUST always resolve — + // even offline or if secure storage is unreadable. A timeout is the + // backstop against getSession() hanging; the catch handles a rejection. + // Either way we fall through to the signed-out UI rather than an infinite + // spinner (issue #133). + const failsafe = setTimeout(() => { + if (active) setLoading(false); + }, 8000); + + supabase.auth + .getSession() + .then(({ data }) => { + if (!active) return; + setSession(data.session); + setLoading(false); + // Best-effort: reminders are a bonus, never a blocker for signing in. + if (data.session) { + registerForReminders().catch(() => {}); + syncTimezone().catch(() => {}); + } else { + // Signed out at startup: account caches have no business existing. + // Covers sessions that vanished without a SIGNED_OUT ever firing + // (cleared or corrupted auth storage across a restart). + clearAccountCaches().catch(() => {}); + } + }) + .catch(() => { + // Couldn't read the session (e.g. corrupted/locked secure store): + // treat as signed out instead of hanging on the spinner. + if (active) { + setSession(null); + setLoading(false); + } + }) + .finally(() => clearTimeout(failsafe)); const { data: subscription } = supabase.auth.onAuthStateChange((event, next) => { setSession(next); From 56b3376417b812dc276229259b45020f93684b6d Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Mon, 7 Sep 2026 21:28:40 +0500 Subject: [PATCH 10/15] Offline banner review fixes: reliable connectivity, layout, timer (#144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use useSyncExternalStore so the provider reads current connectivity on mount and can't drop a flip that happens before subscription — fixes the banner not showing when the app is opened offline (child providers fire the first requests before the parent provider's effect would subscribe). - Wrap NavigationContainer in a flex:1 View so it fills beneath the banner. - clearTimeout the auth failsafe on unmount. --- mobile/App.tsx | 14 ++++++++------ mobile/src/context/AuthContext.tsx | 1 + mobile/src/context/ConnectivityContext.tsx | 13 ++++++++----- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/mobile/App.tsx b/mobile/App.tsx index 5fb03ce..1d475e0 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -115,16 +115,18 @@ function ThemedApp() { return ( {!online && } - - - - + + + + - + + {whatsNew.entry && ( )} diff --git a/mobile/src/context/AuthContext.tsx b/mobile/src/context/AuthContext.tsx index c7ba4d3..d9e615b 100644 --- a/mobile/src/context/AuthContext.tsx +++ b/mobile/src/context/AuthContext.tsx @@ -113,6 +113,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { return () => { active = false; + clearTimeout(failsafe); subscription.subscription.unsubscribe(); }; }, []); diff --git a/mobile/src/context/ConnectivityContext.tsx b/mobile/src/context/ConnectivityContext.tsx index f0bd0b7..c881f09 100644 --- a/mobile/src/context/ConnectivityContext.tsx +++ b/mobile/src/context/ConnectivityContext.tsx @@ -1,16 +1,19 @@ -import { createContext, ReactNode, useContext, useEffect, useState } from 'react'; +import { createContext, ReactNode, useContext, useSyncExternalStore } from 'react'; import { getConnectivity, subscribeConnectivity } from '../api/client'; /** * App-wide online/offline state, inferred from request outcomes in the API - * client (no native connectivity module — keeps the app JS-only / OTA). Starts - * optimistic (online) and flips the first time a request fails or succeeds. + * client (no native connectivity module — keeps the app JS-only / OTA). + * + * useSyncExternalStore reads the current value on every render and can't drop a + * flip that happens before subscription — important because child providers + * fire the first requests (flipping connectivity) before this parent's effects + * would run (issue #133). */ const ConnectivityContext = createContext(true); export function ConnectivityProvider({ children }: { children: ReactNode }) { - const [online, setOnline] = useState(getConnectivity()); - useEffect(() => subscribeConnectivity(setOnline), []); + const online = useSyncExternalStore(subscribeConnectivity, getConnectivity); return {children}; } From ddfbe42f121b8d9ed19b70a4c8894b8b388a7554 Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Mon, 7 Sep 2026 22:29:42 +0500 Subject: [PATCH 11/15] Add a durable, coalescing offline mutation queue (#133 phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AsyncStorage-backed outbox keyed by (kind, target) so only the latest intent per key is kept — safe because the server ops are idempotent/whole-list (like/save PUT/DELETE, topics whole-list PUT, daily-complete idempotent). A like→unlike→like offline collapses to one 'like'. --- mobile/src/services/mutationQueue.ts | 78 ++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 mobile/src/services/mutationQueue.ts diff --git a/mobile/src/services/mutationQueue.ts b/mobile/src/services/mutationQueue.ts new file mode 100644 index 0000000..59f1c43 --- /dev/null +++ b/mobile/src/services/mutationQueue.ts @@ -0,0 +1,78 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +/** + * Durable outbox for mutations made while offline (issue #133). + * + * Entries are COALESCED by a stable key, keeping only the latest intent — the + * server operations are all idempotent or whole-list (like/save via PUT/DELETE, + * topics via a whole-list PUT, daily-complete is idempotent), so replaying the + * final desired state is correct and order across different keys doesn't matter. + * A like→unlike→like offline collapses to a single "like"; two topic edits keep + * only the final set. + */ +const QUEUE_KEY = 'one-concept/mutation-queue/v1'; + +export type QueuedMutation = + | { kind: 'like'; slug: string; desired: boolean } + | { kind: 'save'; slug: string; desired: boolean } + | { kind: 'topics'; slugs: string[] } + | { kind: 'learn' }; + +/** Stable coalescing key — one pending intent per (kind, target). */ +export function keyOf(m: QueuedMutation): string { + switch (m.kind) { + case 'like': + return `like:${m.slug}`; + case 'save': + return `save:${m.slug}`; + case 'topics': + return 'topics'; + case 'learn': + return 'learn'; + } +} + +// In-memory mirror of the persisted map, lazily loaded once. +let map: Record | null = null; + +async function ensureLoaded(): Promise> { + if (map) return map; + try { + const raw = await AsyncStorage.getItem(QUEUE_KEY); + map = raw ? (JSON.parse(raw) as Record) : {}; + } catch { + map = {}; + } + return map; +} + +async function persist(): Promise { + if (map) await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(map)).catch(() => {}); +} + +/** Add or replace the intent for its key (latest wins). */ +export async function enqueue(m: QueuedMutation): Promise { + const q = await ensureLoaded(); + q[keyOf(m)] = m; + await persist(); +} + +/** Remove the intent at a key (no-op if absent). */ +export async function dequeue(key: string): Promise { + const q = await ensureLoaded(); + if (key in q) { + delete q[key]; + await persist(); + } +} + +/** All pending intents (order across keys is not significant). */ +export async function pending(): Promise { + return Object.values(await ensureLoaded()); +} + +/** Drop everything — used on sign-out so one account's queue can't leak. */ +export async function clearQueue(): Promise { + map = {}; + await AsyncStorage.removeItem(QUEUE_KEY).catch(() => {}); +} From 7cf931d05d8357dc0e165a860b7278c0b1eb91aa Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Mon, 7 Sep 2026 22:29:42 +0500 Subject: [PATCH 12/15] Queue mutations when offline; replay + reconcile on flush (#133 phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each mutation now, on a network failure (ApiError status 0), enqueues the desired intent and PERSISTS the optimistic change instead of throwing (which made the context roll it back). A real HTTP error still throws → rolls back. On success it supersedes any stale queued intent for that key. flushQueue() replays the queue (stop on offline, drop 4xx poison, keep 5xx) then reloads /me/state to reconcile. forget() clears the queue on sign-out. markLearned carries title/topic so an offline completion keeps a proper History row. --- mobile/src/services/progressRepository.ts | 16 +- .../src/services/remoteProgressRepository.ts | 169 +++++++++++++++--- 2 files changed, 159 insertions(+), 26 deletions(-) diff --git a/mobile/src/services/progressRepository.ts b/mobile/src/services/progressRepository.ts index 0d2c735..daf458a 100644 --- a/mobile/src/services/progressRepository.ts +++ b/mobile/src/services/progressRepository.ts @@ -20,8 +20,15 @@ export interface ProgressRepository { /** Pin the concept assigned for a day. Server-side this becomes GET /v1/daily. */ setAssignment(conceptId: string, date: string): Promise; - /** Mark the day's concept learned. Server-side: POST /v1/daily/complete. */ - markLearned(conceptId: string, date: string): Promise; + /** Mark the day's concept learned. Server-side: POST /v1/daily/complete. + * title/topicName let an offline completion keep a proper History row until + * the server's record replaces it on the next sync. */ + markLearned( + conceptId: string, + date: string, + title?: string, + topicName?: string + ): Promise; /** Follow / unfollow a topic. Server-side: PUT /v1/me/topics. */ toggleTopic(category: Category): Promise; @@ -31,4 +38,9 @@ export interface ProgressRepository { /** Server-side: PUT|DELETE /v1/concepts/{id}/save. */ toggleBookmark(conceptId: string): Promise; + + /** Replay any mutations queued while offline and return the reconciled state, + * or null if there's nothing to sync. Only the server-backed repository + * implements this; the local (signed-out) one has no queue. */ + flushQueue?(): Promise; } diff --git a/mobile/src/services/remoteProgressRepository.ts b/mobile/src/services/remoteProgressRepository.ts index 32ead80..9a55538 100644 --- a/mobile/src/services/remoteProgressRepository.ts +++ b/mobile/src/services/remoteProgressRepository.ts @@ -1,12 +1,18 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; -import { apiRequest } from '../api/client'; +import { ApiError, apiRequest } from '../api/client'; import { Category, DailyPayload, ProgressState } from '../types'; +import { clearQueue, dequeue, enqueue, keyOf, pending, QueuedMutation } from './mutationQueue'; import { ProgressRepository } from './progressRepository'; import { EMPTY_PROGRESS } from './storage'; import { toCategory, toSlug } from './topics'; const CACHE_KEY = 'one-concept/server-state/v1'; +/** True for a network failure (no response) — the signal to queue offline. */ +function isOffline(err: unknown): boolean { + return err instanceof ApiError && err.status === 0; +} + interface StatePayload { display_name: string | null; timezone: string; @@ -90,10 +96,12 @@ export class RemoteProgressRepository implements ProgressRepository { return this.remember(toProgressState(payload), epoch); } - /** Drop the in-memory state; the module singleton outlives a sign-out. */ + /** Drop the in-memory state; the module singleton outlives a sign-out. The + * offline queue is account data too, so it goes with it. */ forget(): void { this.epoch += 1; this.cache = EMPTY_PROGRESS; + clearQueue().catch(() => {}); } async loadCached(): Promise { @@ -136,13 +144,34 @@ export class RemoteProgressRepository implements ProgressRepository { return this.cache; } - async markLearned(conceptId: string): Promise { + async markLearned( + conceptId: string, + today: string, + title?: string, + topicName?: string + ): Promise { const epoch = this.epoch; - const done = await apiRequest<{ + let done: { completed: boolean; assigned_for: string; stats: { current: number; longest: number; total_learned: number }; - }>('/v1/daily/complete', { method: 'POST' }); + }; + try { + done = await apiRequest('/v1/daily/complete', { method: 'POST' }); + } catch (err) { + if (isOffline(err)) { + // Queue the completion and persist the optimistic learned record — with + // its title/topic so the History row stays right — until the server's + // record replaces it on the next sync. + await enqueue({ kind: 'learn' }); + const learned = this.cache.learned.some((r) => r.date === today) + ? this.cache.learned + : [...this.cache.learned, { conceptId, date: today, title, topicName }]; + return this.remember({ ...this.cache, learned }, epoch); + } + throw err; + } + await dequeue('learn'); // Reload the full state so History shows the true server record — the actual // completed concept with its title and topic — rather than a client-side @@ -176,13 +205,23 @@ export class RemoteProgressRepository implements ProgressRepository { const next = following ? this.cache.followedTopics.filter((c) => c !== category) : [...this.cache.followedTopics, category]; + const slugs = next.map(toSlug); // Whole-list semantics: PUT replaces the set, so a retry is harmless. - const payload = await apiRequest('/v1/me/topics', { - method: 'PUT', - body: { topics: next.map(toSlug) }, - }); - return this.fromState(payload, epoch); + try { + const payload = await apiRequest('/v1/me/topics', { + method: 'PUT', + body: { topics: slugs }, + }); + await dequeue('topics'); + return this.fromState(payload, epoch); + } catch (err) { + if (isOffline(err)) { + await enqueue({ kind: 'topics', slugs }); + return this.remember({ ...this.cache, followedTopics: next }, epoch); + } + throw err; + } } private async toggle( @@ -200,19 +239,53 @@ export class RemoteProgressRepository implements ProgressRepository { async toggleLike(conceptId: string): Promise { const epoch = this.epoch; const currently = this.cache.likes.includes(conceptId); - await this.toggle(conceptId, 'like', currently); - return this.remember({ + const desired = !currently; + const next: ProgressState = { ...this.cache, - likes: currently - ? this.cache.likes.filter((id) => id !== conceptId) - : [...this.cache.likes, conceptId], - }, epoch); + likes: desired + ? [...this.cache.likes, conceptId] + : this.cache.likes.filter((id) => id !== conceptId), + }; + try { + await this.toggle(conceptId, 'like', currently); + await dequeue(`like:${conceptId}`); + return this.remember(next, epoch); + } catch (err) { + if (isOffline(err)) { + await enqueue({ kind: 'like', slug: conceptId, desired }); + return this.remember(next, epoch); + } + throw err; + } } async toggleBookmark(conceptId: string): Promise { const epoch = this.epoch; const currently = this.cache.bookmarks.includes(conceptId); - await this.toggle(conceptId, 'save', currently); + const desired = !currently; + + // Patch bookmarks/savedConcepts in place for the desired state — reused by + // the offline and reload-failed paths. + const patched = (): ProgressState => ({ + ...this.cache, + bookmarks: desired + ? [...this.cache.bookmarks, conceptId] + : this.cache.bookmarks.filter((id) => id !== conceptId), + savedConcepts: desired + ? this.cache.savedConcepts + : (this.cache.savedConcepts ?? []).filter((s) => s.conceptId !== conceptId), + }); + + try { + await this.toggle(conceptId, 'save', currently); + } catch (err) { + if (isOffline(err)) { + await enqueue({ kind: 'save', slug: conceptId, desired }); + return this.remember(patched(), epoch); + } + throw err; + } + await dequeue(`save:${conceptId}`); // The save/unsave has already persisted. Refresh the full state so the saved // list (which needs each concept's title/topic) reflects it — but if that // refresh fails, do NOT throw: a succeeded toggle must never be rolled back @@ -221,13 +294,61 @@ export class RemoteProgressRepository implements ProgressRepository { try { return await this.fromState(await apiRequest('/v1/me/state'), epoch); } catch { - const bookmarks = currently - ? this.cache.bookmarks.filter((id) => id !== conceptId) - : [...this.cache.bookmarks, conceptId]; - const savedConcepts = currently - ? (this.cache.savedConcepts ?? []).filter((s) => s.conceptId !== conceptId) - : this.cache.savedConcepts; - return this.remember({ ...this.cache, bookmarks, savedConcepts }, epoch); + return this.remember(patched(), epoch); + } + } + + /** + * Replay queued offline mutations, then reconcile with the server (#133). + * Called when connectivity returns. Stops (leaving the rest queued) on the + * first network failure; drops an entry the server rejects with a 4xx (a + * poison op that can never succeed), keeps 5xx to retry later. Returns the + * reconciled state when the queue drains, or null if there's nothing to do + * or we're still offline. + */ + async flushQueue(): Promise { + const epoch = this.epoch; + const entries = await pending(); + if (entries.length === 0) return null; + + for (const m of entries) { + if (epoch !== this.epoch) return null; // signed out mid-flush + try { + await this.replay(m); + await dequeue(keyOf(m)); + } catch (err) { + if (isOffline(err)) return null; // still offline — keep the rest queued + if (err instanceof ApiError && err.status >= 500) continue; // transient — retry next time + await dequeue(keyOf(m)); // 4xx: unfixable, drop so it can't block forever + } + } + + if (epoch !== this.epoch) return null; + try { + return await this.fromState(await apiRequest('/v1/me/state'), epoch); + } catch { + return null; + } + } + + private async replay(m: QueuedMutation): Promise { + switch (m.kind) { + case 'like': + await apiRequest(`/v1/concepts/${encodeURIComponent(m.slug)}/like`, { + method: m.desired ? 'PUT' : 'DELETE', + }); + return; + case 'save': + await apiRequest(`/v1/concepts/${encodeURIComponent(m.slug)}/save`, { + method: m.desired ? 'PUT' : 'DELETE', + }); + return; + case 'topics': + await apiRequest('/v1/me/topics', { method: 'PUT', body: { topics: m.slugs } }); + return; + case 'learn': + await apiRequest('/v1/daily/complete', { method: 'POST' }); + return; } } } From 5e50639c252ea57b0906f40d99448775bcb27c8d Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Mon, 7 Sep 2026 22:29:42 +0500 Subject: [PATCH 13/15] Flush the offline queue on reconnect and foreground (#133 phase 2) ProgressProvider drains repository.flushQueue() when connectivity returns (subscribeConnectivity) or the app foregrounds (AppState), and once on mount for leftovers from a previous session, applying the reconciled state. Serialised so overlapping triggers don't double-replay. --- mobile/src/context/ProgressContext.tsx | 45 +++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/mobile/src/context/ProgressContext.tsx b/mobile/src/context/ProgressContext.tsx index cd85808..d1d584c 100644 --- a/mobile/src/context/ProgressContext.tsx +++ b/mobile/src/context/ProgressContext.tsx @@ -8,6 +8,8 @@ import { useRef, useState, } from 'react'; +import { AppState } from 'react-native'; +import { subscribeConnectivity } from '../api/client'; import { CONCEPTS } from '../data/concepts'; import { Category, Concept, DailyOutcome, ProgressState } from '../types'; import { selectDailyConcept } from '../services/dailyConcept'; @@ -106,6 +108,41 @@ export function ProgressProvider({ children, repository: override }: Props) { }; }, [repository, today]); + // Drain the offline mutation queue when connectivity returns or the app comes + // back to the foreground, then apply the server-reconciled state (issue #133). + // Serialised via `flushing` so overlapping triggers don't double-replay. + useEffect(() => { + if (!repository.flushQueue) return; + let active = true; + let flushing = false; + const flush = async () => { + if (flushing || !active) return; + flushing = true; + try { + const next = await repository.flushQueue?.(); + if (active && next) setProgress(next); + } catch { + // A flush failure just leaves items queued for the next trigger. + } finally { + flushing = false; + } + }; + + const unsubscribe = subscribeConnectivity((online) => { + if (online) flush(); + }); + const appState = AppState.addEventListener('change', (s) => { + if (s === 'active') flush(); + }); + flush(); // catch up on anything left from a previous session + + return () => { + active = false; + unsubscribe(); + appState.remove(); + }; + }, [repository]); + // The day's assignment is pinned once made, even if the concept's topic is // unfollowed later that day — topic changes apply from the next assignment. // Memoised so a parent re-render (e.g. an hourly token refresh handing down a @@ -196,7 +233,13 @@ export function ProgressProvider({ children, repository: override }: Props) { }, }; }, - () => repository.markLearned(learnedConcept.id, today), + () => + repository.markLearned( + learnedConcept.id, + today, + learnedConcept.title, + learnedConcept.category + ), // On failure, remove just today's record; the stats revert is approximate // (longest can't be reconstructed) and the next state load corrects it. (prev) => ({ From 59b22650412d841c3898123667f3a3b5b80bab7c Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Mon, 7 Sep 2026 22:40:15 +0500 Subject: [PATCH 14/15] Offline queue review fixes: streak, learn-date, dequeue race, saved list (#145) - Offline mark-learned now bumps the streak/stats to match the optimistic update, so the streak no longer reverts after an offline completion. - The 'learn' queue entry carries its date; a completion queued on a previous day is dropped on flush rather than wrongly completing today's concept (/v1/daily/complete only targets 'today'). - Guarded dequeue: after replaying an intent, only remove it if the stored entry still equals it, so a same-key mutation made mid-flush isn't clobbered. - Offline save adds a minimal savedConcepts row (title/topic threaded through toggleBookmark) so it shows in the Saved list immediately, not just the count. --- mobile/src/components/ConceptActions.tsx | 2 +- mobile/src/context/ProgressContext.tsx | 7 +- mobile/src/services/mutationQueue.ts | 20 ++++-- mobile/src/services/progressRepository.ts | 5 +- .../src/services/remoteProgressRepository.ts | 69 ++++++++++++++----- 5 files changed, 72 insertions(+), 31 deletions(-) diff --git a/mobile/src/components/ConceptActions.tsx b/mobile/src/components/ConceptActions.tsx index 2712398..c2b3d0d 100644 --- a/mobile/src/components/ConceptActions.tsx +++ b/mobile/src/components/ConceptActions.tsx @@ -70,7 +70,7 @@ export function ConceptActions({ concept }: { concept: Concept }) { { - toggleBookmark(concept.id); + toggleBookmark(concept.id, concept.title, concept.category); save.pop(); }} style={styles.action} diff --git a/mobile/src/context/ProgressContext.tsx b/mobile/src/context/ProgressContext.tsx index d1d584c..9583740 100644 --- a/mobile/src/context/ProgressContext.tsx +++ b/mobile/src/context/ProgressContext.tsx @@ -39,7 +39,7 @@ export interface ProgressContextValue { markLearned: (target?: Concept) => void; toggleTopic: (category: Category) => void; toggleLike: (conceptId: string) => void; - toggleBookmark: (conceptId: string) => void; + toggleBookmark: (conceptId: string, title?: string, topicName?: string) => void; } const ProgressContext = createContext(null); @@ -282,12 +282,13 @@ export function ProgressProvider({ children, repository: override }: Props) { ); const toggleBookmark = useCallback( - (conceptId: string) => { + (conceptId: string, title?: string, topicName?: string) => { const toggle = (prev: ProgressState) => ({ ...prev, bookmarks: flip(prev.bookmarks, conceptId), }); - apply(toggle, () => repository.toggleBookmark(conceptId), toggle); + // title/topic let an offline save appear in the Saved list, not just the count. + apply(toggle, () => repository.toggleBookmark(conceptId, title, topicName), toggle); }, [apply, repository] ); diff --git a/mobile/src/services/mutationQueue.ts b/mobile/src/services/mutationQueue.ts index 59f1c43..d2a1c8d 100644 --- a/mobile/src/services/mutationQueue.ts +++ b/mobile/src/services/mutationQueue.ts @@ -16,7 +16,9 @@ export type QueuedMutation = | { kind: 'like'; slug: string; desired: boolean } | { kind: 'save'; slug: string; desired: boolean } | { kind: 'topics'; slugs: string[] } - | { kind: 'learn' }; + // The date it was completed: /v1/daily/complete only completes "today", so a + // 'learn' queued on a previous day must be dropped, not replayed (#133). + | { kind: 'learn'; date: string }; /** Stable coalescing key — one pending intent per (kind, target). */ export function keyOf(m: QueuedMutation): string { @@ -57,13 +59,17 @@ export async function enqueue(m: QueuedMutation): Promise { await persist(); } -/** Remove the intent at a key (no-op if absent). */ -export async function dequeue(key: string): Promise { +/** + * Remove the intent at a key. If `expected` is given, only remove it when the + * stored intent still equals it — so a flush that replayed an old intent can't + * clobber a newer one enqueued for the same key mid-flush (#133). + */ +export async function dequeue(key: string, expected?: QueuedMutation): Promise { const q = await ensureLoaded(); - if (key in q) { - delete q[key]; - await persist(); - } + if (!(key in q)) return; + if (expected && JSON.stringify(q[key]) !== JSON.stringify(expected)) return; + delete q[key]; + await persist(); } /** All pending intents (order across keys is not significant). */ diff --git a/mobile/src/services/progressRepository.ts b/mobile/src/services/progressRepository.ts index daf458a..e691b1e 100644 --- a/mobile/src/services/progressRepository.ts +++ b/mobile/src/services/progressRepository.ts @@ -36,8 +36,9 @@ export interface ProgressRepository { /** Server-side: PUT|DELETE /v1/concepts/{id}/like. */ toggleLike(conceptId: string): Promise; - /** Server-side: PUT|DELETE /v1/concepts/{id}/save. */ - toggleBookmark(conceptId: string): Promise; + /** Server-side: PUT|DELETE /v1/concepts/{id}/save. title/topicName let an + * offline save show in the Saved list (not just the count) until sync. */ + toggleBookmark(conceptId: string, title?: string, topicName?: string): Promise; /** Replay any mutations queued while offline and return the reconciled state, * or null if there's nothing to sync. Only the server-backed repository diff --git a/mobile/src/services/remoteProgressRepository.ts b/mobile/src/services/remoteProgressRepository.ts index 9a55538..f5340ae 100644 --- a/mobile/src/services/remoteProgressRepository.ts +++ b/mobile/src/services/remoteProgressRepository.ts @@ -1,6 +1,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { ApiError, apiRequest } from '../api/client'; import { Category, DailyPayload, ProgressState } from '../types'; +import { todayKey } from './dates'; import { clearQueue, dequeue, enqueue, keyOf, pending, QueuedMutation } from './mutationQueue'; import { ProgressRepository } from './progressRepository'; import { EMPTY_PROGRESS } from './storage'; @@ -160,14 +161,23 @@ export class RemoteProgressRepository implements ProgressRepository { done = await apiRequest('/v1/daily/complete', { method: 'POST' }); } catch (err) { if (isOffline(err)) { - // Queue the completion and persist the optimistic learned record — with - // its title/topic so the History row stays right — until the server's - // record replaces it on the next sync. - await enqueue({ kind: 'learn' }); - const learned = this.cache.learned.some((r) => r.date === today) + // Queue the completion (with the date — it can only be replayed today) + // and persist the optimistic record + streak bump so History AND the + // streak stay right until the server's numbers replace them on sync. + await enqueue({ kind: 'learn', date: today }); + const already = this.cache.learned.some((r) => r.date === today); + const learned = already ? this.cache.learned : [...this.cache.learned, { conceptId, date: today, title, topicName }]; - return this.remember({ ...this.cache, learned }, epoch); + const stats = + already || !this.cache.stats + ? this.cache.stats + : { + current: this.cache.stats.current + 1, + longest: Math.max(this.cache.stats.longest, this.cache.stats.current + 1), + totalLearned: this.cache.stats.totalLearned + 1, + }; + return this.remember({ ...this.cache, learned, stats }, epoch); } throw err; } @@ -259,22 +269,36 @@ export class RemoteProgressRepository implements ProgressRepository { } } - async toggleBookmark(conceptId: string): Promise { + async toggleBookmark( + conceptId: string, + title?: string, + topicName?: string + ): Promise { const epoch = this.epoch; const currently = this.cache.bookmarks.includes(conceptId); const desired = !currently; // Patch bookmarks/savedConcepts in place for the desired state — reused by - // the offline and reload-failed paths. - const patched = (): ProgressState => ({ - ...this.cache, - bookmarks: desired + // the offline and reload-failed paths. When saving offline, add a minimal + // savedConcepts row (needs title + topic) so the concept shows in the Saved + // list right away, not just in the count (#133). Leave savedConcepts alone + // when it's absent (signed-out demo) or the caller didn't pass a title. + const patched = (): ProgressState => { + const bookmarks = desired ? [...this.cache.bookmarks, conceptId] - : this.cache.bookmarks.filter((id) => id !== conceptId), - savedConcepts: desired - ? this.cache.savedConcepts - : (this.cache.savedConcepts ?? []).filter((s) => s.conceptId !== conceptId), - }); + : this.cache.bookmarks.filter((id) => id !== conceptId); + let savedConcepts = this.cache.savedConcepts; + if (savedConcepts) { + if (desired) { + if (title && topicName && !savedConcepts.some((s) => s.conceptId === conceptId)) { + savedConcepts = [{ conceptId, title, topicName, likeCount: 0 }, ...savedConcepts]; + } + } else { + savedConcepts = savedConcepts.filter((s) => s.conceptId !== conceptId); + } + } + return { ...this.cache, bookmarks, savedConcepts }; + }; try { await this.toggle(conceptId, 'save', currently); @@ -311,15 +335,24 @@ export class RemoteProgressRepository implements ProgressRepository { const entries = await pending(); if (entries.length === 0) return null; + const today = todayKey(); for (const m of entries) { if (epoch !== this.epoch) return null; // signed out mid-flush + + // A completion can only be replayed on its own day — /v1/daily/complete + // always targets "today", so a stale 'learn' is dropped, not replayed. + if (m.kind === 'learn' && m.date !== today) { + await dequeue(keyOf(m), m); + continue; + } + try { await this.replay(m); - await dequeue(keyOf(m)); + await dequeue(keyOf(m), m); // guarded: don't clobber a newer same-key intent } catch (err) { if (isOffline(err)) return null; // still offline — keep the rest queued if (err instanceof ApiError && err.status >= 500) continue; // transient — retry next time - await dequeue(keyOf(m)); // 4xx: unfixable, drop so it can't block forever + await dequeue(keyOf(m), m); // 4xx: unfixable, drop so it can't block forever } } From c78b418035825aef04843ace23ceb99f3b3e1e3a Mon Sep 17 00:00:00 2001 From: Muawiya Amir Date: Mon, 7 Sep 2026 23:08:46 +0500 Subject: [PATCH 15/15] Bump version to 1.7.0 + What's New card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JS-only release (runtimeVersion stays 1.3.0, ships OTA). Headline: offline mode (#133) — cached browsing, offline like/save/follow/mark-learned with a durable queue that auto-syncs on reconnect, plus the never-hang startup and global offline banner. --- mobile/app.config.js | 2 +- mobile/src/data/whatsNew.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/mobile/app.config.js b/mobile/app.config.js index 8366867..2ecbb38 100644 --- a/mobile/app.config.js +++ b/mobile/app.config.js @@ -18,7 +18,7 @@ module.exports = { name: 'One Concept', slug: 'one-concept', owner: 'coding-moves', - version: '1.6.0', + version: '1.7.0', orientation: 'portrait', icon: './assets/icon.png', userInterfaceStyle: 'automatic', diff --git a/mobile/src/data/whatsNew.ts b/mobile/src/data/whatsNew.ts index 8bc29f7..a257956 100644 --- a/mobile/src/data/whatsNew.ts +++ b/mobile/src/data/whatsNew.ts @@ -21,6 +21,12 @@ export interface WhatsNewEntry { } export const WHATS_NEW: WhatsNewEntry[] = [ + { + version: '1.7.0', + highlights: [ + 'Works offline — read, like, save, and mark concepts learned with no connection, and everything syncs automatically the moment you’re back online.', + ], + }, { version: '1.6.0', highlights: [