diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml new file mode 100644 index 0000000..4f86ffe --- /dev/null +++ b/.github/workflows/migrations.yml @@ -0,0 +1,61 @@ +# 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: + # 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 + + - 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/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`. diff --git a/backend/migrations/applied.txt b/backend/migrations/applied.txt new file mode 100644 index 0000000..ae690ba --- /dev/null +++ b/backend/migrations/applied.txt @@ -0,0 +1,19 @@ +# 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 +0008_backlog_claimed_at.sql +0009_like_count_index.sql diff --git a/mobile/App.tsx b/mobile/App.tsx index 7475db0..1d475e0 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,22 +113,25 @@ function ThemedApp() { }; return ( - <> - - - - + {!online && } + + + + + - + + {whatsNew.entry && ( )} - + ); } @@ -194,11 +201,13 @@ export default function App() { - - - - - + + + + + + + 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/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/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/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 }, + }); diff --git a/mobile/src/context/AuthContext.tsx b/mobile/src/context/AuthContext.tsx index 89c4306..d9e615b 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); @@ -92,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 new file mode 100644 index 0000000..c881f09 --- /dev/null +++ b/mobile/src/context/ConnectivityContext.tsx @@ -0,0 +1,23 @@ +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). + * + * 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 = useSyncExternalStore(subscribeConnectivity, getConnectivity); + return {children}; +} + +/** True when the last network attempt reached the server. */ +export function useOnline(): boolean { + return useContext(ConnectivityContext); +} diff --git a/mobile/src/context/ProgressContext.tsx b/mobile/src/context/ProgressContext.tsx index cd85808..9583740 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'; @@ -37,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); @@ -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) => ({ @@ -239,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/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: [ diff --git a/mobile/src/screens/AuthScreen.tsx b/mobile/src/screens/AuthScreen.tsx index b7fb7a7..a5a1461 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" @@ -155,16 +166,32 @@ export function AuthScreen() { {error ? ( - + {error} ) : 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', diff --git a/mobile/src/services/mutationQueue.ts b/mobile/src/services/mutationQueue.ts new file mode 100644 index 0000000..d2a1c8d --- /dev/null +++ b/mobile/src/services/mutationQueue.ts @@ -0,0 +1,84 @@ +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[] } + // 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 { + 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. 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)) 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). */ +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(() => {}); +} diff --git a/mobile/src/services/progressRepository.ts b/mobile/src/services/progressRepository.ts index 0d2c735..e691b1e 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; @@ -29,6 +36,12 @@ 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 + * 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..f5340ae 100644 --- a/mobile/src/services/remoteProgressRepository.ts +++ b/mobile/src/services/remoteProgressRepository.ts @@ -1,12 +1,19 @@ 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 { todayKey } from './dates'; +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 +97,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 +145,43 @@ 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 (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 }]; + 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; + } + 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 +215,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 +249,67 @@ 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 { + async toggleBookmark( + conceptId: string, + title?: string, + topicName?: 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. 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); + 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); + } 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 +318,70 @@ 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; + + 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), 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), 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; } } }