Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
06ed985
Merge pull request #138 from Coding-Moves/main
Muawiya-contact Sep 7, 2026
6dbf04e
CI: flag migrations not applied to production (#131 follow-up)
Muawiya-contact Sep 7, 2026
cf5d6d8
Merge pull request #139 from Coding-Moves/chore/migrations-ci-check
Muawiya-contact Sep 7, 2026
1d8753b
Name the migrations-check job for a stable required-check context (#140)
Muawiya-contact Sep 7, 2026
da101db
Document the release + manual-migration procedure (RELEASING.md) (#141)
Muawiya-contact Sep 7, 2026
3f3a430
Record migrations 0008 + 0009 as applied to production (#142)
Muawiya-contact Sep 7, 2026
ac9b8ff
Improve the Forgot Password confirmation banner (#136)
Muawiya-contact Sep 7, 2026
a2f1eed
Announce the auth error banner to screen readers too (review of #143)
Muawiya-contact Sep 7, 2026
e4e30f4
Merge pull request #143 from Coding-Moves/feat/reset-banner-136
Muawiya-contact Sep 7, 2026
8ea5fb6
Infer online/offline from request outcomes (#133 phase 1)
Muawiya-contact Sep 7, 2026
c8d5ded
Add a global offline banner (#133 phase 1)
Muawiya-contact Sep 7, 2026
c382701
Never hang on the startup spinner offline (#133 phase 1)
Muawiya-contact Sep 7, 2026
56b3376
Offline banner review fixes: reliable connectivity, layout, timer (#144)
Muawiya-contact Sep 7, 2026
ff52563
Merge pull request #144 from Coding-Moves/feat/offline-phase1
Muawiya-contact Sep 7, 2026
ddfbe42
Add a durable, coalescing offline mutation queue (#133 phase 2)
Muawiya-contact Sep 7, 2026
7cf931d
Queue mutations when offline; replay + reconcile on flush (#133 phase 2)
Muawiya-contact Sep 7, 2026
5e50639
Flush the offline queue on reconnect and foreground (#133 phase 2)
Muawiya-contact Sep 7, 2026
59b2265
Offline queue review fixes: streak, learn-date, dequeue race, saved l…
Muawiya-contact Sep 7, 2026
4bf1cb0
Merge pull request #145 from Coding-Moves/feat/offline-phase2
Muawiya-contact Sep 7, 2026
c78b418
Bump version to 1.7.0 + What's New card
Muawiya-contact Sep 7, 2026
fba7d24
Merge pull request #146 from Coding-Moves/chore/release-1.7.0
Muawiya-contact Sep 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .github/workflows/migrations.yml
Original file line number Diff line number Diff line change
@@ -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/<file>\` —"
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
47 changes: 47 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -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/<file>` (`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 `<api-domain>/reset-password`.
19 changes: 19 additions & 0 deletions backend/migrations/applied.txt
Original file line number Diff line number Diff line change
@@ -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/<file>
# — 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
39 changes: 24 additions & 15 deletions mobile/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -69,6 +71,7 @@ function ThemedApp() {
const { colors, mode } = useTheme();
const { loading, session } = useAuth();
const whatsNew = useWhatsNew();
const online = useOnline();

if (loading) {
return (
Expand All @@ -88,10 +91,11 @@ function ThemedApp() {

if (!session) {
return (
<>
<View style={{ flex: 1 }}>
{!online && <OfflineBanner />}
<AuthScreen />
<StatusBar style={mode === 'dark' ? 'light' : 'dark'} />
</>
</View>
);
}

Expand All @@ -109,22 +113,25 @@ function ThemedApp() {
};

return (
<>
<NavigationContainer theme={navigationTheme}>
<RootStack.Navigator screenOptions={{ headerShown: false }}>
<RootStack.Screen name="Tabs" component={Tabs} />
<RootStack.Screen
name="ConceptDetail"
<View style={{ flex: 1 }}>
{!online && <OfflineBanner />}
<View style={{ flex: 1 }}>
<NavigationContainer theme={navigationTheme}>
<RootStack.Navigator screenOptions={{ headerShown: false }}>
<RootStack.Screen name="Tabs" component={Tabs} />
<RootStack.Screen
name="ConceptDetail"
component={ConceptDetailScreen}
options={{ presentation: 'modal', animation: 'slide_from_bottom' }}
/>
</RootStack.Navigator>
</NavigationContainer>
</NavigationContainer>
</View>
{whatsNew.entry && (
<WhatsNewCard entry={whatsNew.entry} onDismiss={whatsNew.dismiss} />
)}
<StatusBar style={mode === 'dark' ? 'light' : 'dark'} />
</>
</View>
);
}

Expand Down Expand Up @@ -194,11 +201,13 @@ export default function App() {
<View style={{ flex: 1 }} onLayout={onLayoutRootView}>
<SafeAreaProvider>
<ThemeProvider>
<AuthProvider>
<ProgressProvider>
<ThemedApp />
</ProgressProvider>
</AuthProvider>
<ConnectivityProvider>
<AuthProvider>
<ProgressProvider>
<ThemedApp />
</ProgressProvider>
</AuthProvider>
</ConnectivityProvider>
</ThemeProvider>
</SafeAreaProvider>
</View>
Expand Down
2 changes: 1 addition & 1 deletion mobile/app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
28 changes: 28 additions & 0 deletions mobile/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -66,9 +90,13 @@ export async function apiRequest<T>(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);
Expand Down
2 changes: 1 addition & 1 deletion mobile/src/components/ConceptActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export function ConceptActions({ concept }: { concept: Concept }) {

<Pressable
onPress={() => {
toggleBookmark(concept.id);
toggleBookmark(concept.id, concept.title, concept.category);
save.pop();
}}
style={styles.action}
Expand Down
42 changes: 42 additions & 0 deletions mobile/src/components/OfflineBanner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View
style={[styles.container, { paddingTop: insets.top }]}
accessibilityRole="alert"
accessibilityLabel="You are offline. Changes will sync when you reconnect."
>
<View style={styles.row}>
<Ionicons name="cloud-offline-outline" size={scaleIcon(14)} color={colors.onPrimary} />
<Text style={styles.text}>Offline — changes will sync when you reconnect</Text>
</View>
</View>
);
}

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 },
});
52 changes: 37 additions & 15 deletions mobile/src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -92,6 +113,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {

return () => {
active = false;
clearTimeout(failsafe);
subscription.subscription.unsubscribe();
};
}, []);
Expand Down
Loading