Carrot — Architecture & Technical Patterns
docs/ARCHITECTURE.md · 15.6K
# Carrot — Architecture & Technical Patterns
> Learnings aus bestehenden Projekten (mindecho-app, mybdy-*) und aktuellem Web-Research. Stand: April 2026.
> **Update Mai 2026:** Mobile App Plans 2-7 sind implementiert (Onboarding, Goal Detail mit Progress-Ring,
> Progress Tab mit Calendar/Charts/Achievements, Squad Tab mit Challenges/Bets/Duels/Pacts, Profile, Web Export).
> Aktuelle Architektur: AsyncStorage-only via `AppDataProvider` (`apps/mobile/providers/AppDataProvider.tsx`).
> Die hier beschriebenen Patterns (TanStack Query, Supabase, RevenueCat) sind als Phase-2-Migration vorgesehen,
> sobald das Supabase-Projekt live ist. SQL-Schema in `supabase/migrations/0001-0009.sql` ist ready zum Apply.
> Admin Portal in `apps/admin/` (Astro 5 + Tailwind 4 + React island für Recharts, statisch auf CF Pages mit
> Auto-Deploy on push) ergänzt die Architektur als drittes Standalone-App. Geschützt via Cloudflare Access
> (Email-OTP, 3 Admin-Whitelist).
---
## 1. Offline-First Architektur
### Warum kritisch für Carrot
Habit Check-ins müssen **immer funktionieren** — auch ohne Internet. Ein User der morgens im Zug einchecken will und "keine Verbindung" sieht, kommt nicht wieder.
### Empfohlener Ansatz: TanStack Query + AsyncStorage Persister
Bewährt in **mindecho-app** (`lib/queryClient.ts`):
```
PersistQueryClientProvider
→ createAsyncStoragePersister (AsyncStorage)
→ Query Cache: staleTime 5min, gcTime 24h
→ Mutation Queue: retry 3, persisted across app kills
→ focusManager → AppState (refetch on foreground)
→ Cache Buster String (invalidate on schema changes)
```
**Key Pattern**: Mutations (Habit Check-ins) werden in AsyncStorage persistiert und überleben App-Neustarts. Bei Reconnect werden sie automatisch gesynct.
**Alternatives für später:**
- **Legend-State + Supabase** — Local-first Realtime Sync (offiziell von Supabase empfohlen)
- **WatermelonDB** — Nur wenn wir komplexe Offline-Queries brauchen (Overkill für Check-ins)
### Smart Cache Pruning (aus mindecho-app)
`prunePersistedClientToSize()` rankt Queries nach Priorität und entfernt die größten/unwichtigsten um unter 700KB zu bleiben. Wichtig für Langzeit-User mit viel History.
---
## 2. Supabase Client Setup
### Bewährtes Pattern (mindecho-app: `lib/supabase.ts`)
```typescript
import 'react-native-url-polyfill/auto';
import 'expo-sqlite/localStorage/install'; // Sync storage für Supabase Auth
export const supabase = createClient<Database>(url, key, {
auth: {
storage: localStorage, // expo-sqlite polyfill (synchron)
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false, // Kein Web OAuth in der App
},
});
```
**Wichtig:**
- `expo-sqlite/localStorage/install` statt AsyncStorage für Auth (synchrone API, verhindert Race Conditions)
- `detectSessionInUrl: false` — keine Web-Redirects in React Native
- Supabase URL/Key in `app.config.ts` via `extra` Feld (nicht in .env für OTA Updates)
- Database Types auto-generiert via `npm run db:types`
---
## 3. Auth Flow & Routing Guard
### 5-State Routing Guard (mindecho-app: `app/_layout.tsx`)
```
Unauthenticated → Update Required → Disclaimer → Onboarding → Main App (Tabs)
```
Für Carrot adaptiert:
```
1. Auth Check → Nicht eingeloggt? → Login/Signup Screen
2. Version Check → App-Update nötig? → Update Screen
3. Onboarding → Erststart? → Habit-Auswahl + Ziel-Setup
4. Main App → Tab Navigation (Home, Progress, Squad, Profile)
```
**Key Detail:** `isAuthReady` Flag verhindert Flash-of-Wrong-Screen bevor Supabase die Session wiederhergestellt hat.
### Auth Store Pattern (Zustand, NICHT persisted)
```typescript
// Auth State lebt in Zustand, aber Supabase managed die Persistierung
const useAuthStore = create<AuthState>((set) => ({
session: null,
isAuthReady: false,
}));
// In Root Layout:
supabase.auth.onAuthStateChange((event, session) => {
useAuthStore.setState({ session, isAuthReady: true });
});
```
---
## 4. Zustand Store Patterns
### Zwei Kategorien (aus mindecho-app)
**Ephemeral (kein Persist):**
- `authStore` — Session State (Supabase persistiert selbst)
- `syncStateStore` — Upload-Status
- `toastStore` — UI Notifications
**Persisted (AsyncStorage):**
- `onboardingStore` — Onboarding abgeschlossen?
- `settingsStore` — User Preferences (Reminder-Zeit, Sprache)
- `streakStore` — Lokaler Streak-Cache für instant UI
### Für Carrot geplant:
| Store | Typ | Inhalt |
|-------|-----|--------|
| `authStore` | Ephemeral | Session, isAuthReady |
| `habitStore` | Ephemeral | TanStack Query managed die Daten |
| `onboardingStore` | Persisted | Onboarding-Status, gewählte Kategorien |
| `settingsStore` | Persisted | Reminder-Zeiten, Sprache, Theme |
| `toastStore` | Ephemeral | In-App Notifications |
---
## 5. Push Notifications
### Scheduling Pattern (mybdy-app-native: `local-notifications.ts`)
```
1. Compute gewünschte Notifications aus Habit-Zeitplänen
2. Diff gegen bereits geplante Notifications
3. Batch-Cancel veraltete, Batch-Schedule neue
4. Mutex-Guard verhindert parallele Scheduling-Cycles
```
### Best Practices (Web Research 2026)
| Regel | Detail |
|-------|--------|
| **Max 2-3/Tag** | 64% der User churnen bei 5+/Woche |
| **Personalisiert** | Habit-Name, Streak-Count referenzieren → 39% vs 21% Retention |
| **Timing** | User-gewählte Erinnerungszeit pro Habit, Morgens (6-8) und Abends (22-24) performen am besten |
| **Erste 90 Tage** | Notifications in den ersten 90 Tagen → 3x höhere Retention |
| **"Don't break your streak"** | Zur gewählten Zeit + Celebration Push bei Milestones |
### Expo Implementation
```typescript
// expo-notifications für lokale + remote Notifications
// Kein OneSignal nötig am Anfang — Expo Push API reicht
import * as Notifications from 'expo-notifications';
// Pro Habit: lokale Notification scheduled
// Bei Streak-Milestone: Server-side Push via Supabase Edge Function
```
---
## 6. Gamification System
### Datenmodell
```sql
-- Pro Habit
ALTER TABLE habits ADD COLUMN current_streak INT DEFAULT 0;
ALTER TABLE habits ADD COLUMN longest_streak INT DEFAULT 0;
ALTER TABLE habits ADD COLUMN last_completed_at TIMESTAMPTZ;
-- XP Ledger (append-only)
CREATE TABLE xp_ledger (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users NOT NULL,
amount INT NOT NULL,
reason TEXT NOT NULL, -- 'check_in', 'streak_7', 'streak_30', 'badge_unlock'
created_at TIMESTAMPTZ DEFAULT now()
);
-- Materialisierte Summe am User
ALTER TABLE profiles ADD COLUMN total_xp INT DEFAULT 0;
-- Badges
CREATE TABLE badges (
id TEXT PRIMARY KEY, -- 'first_checkin', 'streak_7', 'week_warrior'
name TEXT NOT NULL,
description TEXT,
icon TEXT,
unlock_condition JSONB -- { "type": "streak", "value": 7 }
);
CREATE TABLE user_badges (
user_id UUID REFERENCES auth.users,
badge_id TEXT REFERENCES badges,
unlocked_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (user_id, badge_id)
);
```
### Streak-Berechnung
**Server-side (Supabase Edge Function oder DB Trigger)** — nicht client-side, um Manipulation zu verhindern. Timezone-aware: User's lokale Mitternacht als Grenze.
### XP-Multiplikatoren
| Event | XP |
|-------|-----|
| Normaler Check-in | 10 |
| 7-Tage Streak | 2x (20) |
| 30-Tage Streak | 3x (30) |
| Badge Unlock | 50-200 je nach Seltenheit |
### Leaderboards
Supabase View mit `rank()` Window Function über Total XP. Cache via TanStack Query (staleTime ~5min). Optional: Freunde-only Leaderboard.
---
## 7. Squad System (Group Subscriptions)
### Konzept
Squads sind Gruppen von 4-6 Freunden, die ein gemeinsames Abo teilen. Der Squad-Owner kauft das Squad-Abo (€80/Jahr) und lädt bis zu 4 weitere Mitglieder ein. Squad-Features: Squad Challenges, Squad Leaderboards, Squad Streaks, Squad vs Squad Competition.
### Datenmodell
```sql
-- Squad (die Gruppe)
CREATE TABLE squads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL, -- z.B. "Gym Bros", "Book Club"
owner_id UUID REFERENCES auth.users NOT NULL,
invite_code TEXT UNIQUE NOT NULL, -- 6-stelliger Code zum Beitreten
max_members INT DEFAULT 5,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Squad-Mitglieder
CREATE TABLE squad_members (
squad_id UUID REFERENCES squads ON DELETE CASCADE,
user_id UUID REFERENCES auth.users,
role TEXT DEFAULT 'member', -- 'owner' | 'member'
joined_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (squad_id, user_id)
);
-- Squad Challenges (gemeinsame Ziele)
CREATE TABLE squad_challenges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
squad_id UUID REFERENCES squads ON DELETE CASCADE,
title TEXT NOT NULL, -- z.B. "30 Tage Fitness"
description TEXT,
habit_template JSONB, -- Habit-Vorlage die alle übernehmen
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
created_by UUID REFERENCES auth.users,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Squad Challenge Teilnahme & Fortschritt
CREATE TABLE squad_challenge_members (
challenge_id UUID REFERENCES squad_challenges ON DELETE CASCADE,
user_id UUID REFERENCES auth.users,
completed_days INT DEFAULT 0,
PRIMARY KEY (challenge_id, user_id)
);
```
### Squad Streak-Berechnung
Ein **Squad Streak** zählt die aufeinanderfolgenden Tage, an denen **alle Mitglieder** mindestens einen Check-in hatten. Berechnung via pg_cron Job (täglich):
```sql
-- Prüfe ob gestern ALLE Squad-Mitglieder mindestens 1 Check-in hatten
-- Wenn ja: squad_streak += 1, wenn nein: squad_streak = 0
```
Squad Streaks sind schwerer zu halten als individuelle Streaks → stärkerer Social Pressure → bessere Retention.
### Squad Leaderboard Queries
```sql
-- Squad-internes Leaderboard (XP diese Woche)
SELECT p.display_name, SUM(x.amount) as weekly_xp
FROM xp_ledger x
JOIN squad_members sm ON sm.user_id = x.user_id
JOIN profiles p ON p.id = x.user_id
WHERE sm.squad_id = $1
AND x.created_at >= date_trunc('week', now())
GROUP BY p.id
ORDER BY weekly_xp DESC;
-- Squad vs Squad Leaderboard (Gesamt-XP aller Mitglieder)
SELECT s.name, SUM(p.total_xp) as squad_xp
FROM squads s
JOIN squad_members sm ON sm.squad_id = s.id
JOIN profiles p ON p.id = sm.user_id
GROUP BY s.id
ORDER BY squad_xp DESC
LIMIT 50;
```
### RevenueCat Integration für Squad
Squad-Abo mapped auf ein **"Squad" Entitlement**. Der Owner hat das Entitlement direkt, Mitglieder bekommen Premium-Zugang über Squad-Mitgliedschaft (geprüft via DB, nicht via RevenueCat).
```
Squad Owner kauft Abo → RevenueCat "Squad" Entitlement
Squad Members → Premium via squad_members Tabelle (kein eigenes RevenueCat Entitlement)
Owner kündigt → Grace Period → Squad-Members verlieren Premium
```
### RLS Policies
```sql
-- Squad-Mitglieder können nur ihre eigenen Squads sehen
CREATE POLICY squad_member_select ON squads
FOR SELECT USING (id IN (SELECT squad_id FROM squad_members WHERE user_id = auth.uid()));
-- Nur Owner kann Squad bearbeiten
CREATE POLICY squad_owner_update ON squads
FOR UPDATE USING (owner_id = auth.uid());
-- Squad-Daten nur für Mitglieder sichtbar
CREATE POLICY squad_members_select ON squad_members
FOR SELECT USING (squad_id IN (SELECT squad_id FROM squad_members WHERE user_id = auth.uid()));
```
---
## 8. RevenueCat Integration (updated for Squad)
### Setup
```
react-native-purchases + react-native-purchases-ui
→ Expo Development Build nötig (nicht Expo Go für echte Käufe)
→ Preview API Mode in Expo Go für Prototyping
```
### Entitlement-basiert, nicht Produkt-basiert
```
Solo Monthly ──┐
├──→ "Premium" Entitlement
Solo Yearly ──┘
Squad Yearly ──────→ "Squad" Entitlement (includes Premium)
```
Solo-Abos mappen auf "Premium", Squad-Abo auf "Squad" (das Premium einschließt). Squad-Mitglieder erhalten Premium-Zugang über die DB (squad_members), nicht über eigene RevenueCat Entitlements.
### Sync mit Supabase
```
User kauft Abo → RevenueCat Webhook → Supabase Edge Function → UPDATE profiles SET is_premium = true
```
---
## 9. Sentry Error Monitoring
### Setup (aus mindecho-app: `lib/sentry.ts`)
```typescript
Sentry.init({
dsn: '...',
integrations: [reactNavigationIntegration],
tracesSampleRate: 0.2, // 20% Performance Traces
replaysOnErrorSampleRate: 1.0, // 100% Replay bei Errors
// Mobile Replay: alles maskiert (GDPR)
});
// Root Layout:
export default Sentry.wrap(RootLayout);
```
### Helper Functions
```typescript
reportError(error) // Handles unknown types + PostgrestError
reportWarning(msg) // Für nicht-kritische Issues
addBreadcrumb(...) // Kontext für Debugging
```
---
## 10. Shared Package Architektur
### Aktuell: `@carrot/shared`
```
packages/shared/src/
constants/ → colors, spacing, typography, radii, shadows, animation
database.types.ts → Auto-generiert
```
### Geplante Erweiterung (inspiriert von mybdy-light: `@mybdy/*`)
```
packages/shared/src/
constants/ → Design Tokens
database.types.ts
validation.ts → Zod Schemas (shared zwischen App und Edge Functions)
i18n/ → Übersetzungen (de.ts, en.ts) — flat object, kein Framework nötig
utils/ → Formatierung, Datum, Streak-Berechnung
```
### i18n Approach (aus mybdy-light)
German-first, flat typed Object. Kein i18n-Framework am Anfang nötig:
```typescript
export const de = {
common: { save: 'Speichern', cancel: 'Abbrechen' },
habits: { checkIn: 'Einchecken', streak: 'Streak' },
// ...
};
```
Später: `expo-localization` + `i18next` für Runtime Language Switching.
---
## 11. DSGVO / Cookie Consent (Landing Page)
### Pattern (aus mybdy-light: `consent.svelte.ts`)
```
- Versionierte Consent in localStorage (365 Tage Ablauf)
- Auto-Invalidierung bei Version-Bump
- CustomEvent dispatch bei Consent-Änderung (Analytics reagiert)
- 3 Optionen: Einstellungen, Nur Essentielle, Alle Akzeptieren
```
Für Carrot Landing Page übertragbar — als Astro-Komponente mit `localStorage` API.
---
## 12. Cron Jobs / Background Tasks
### Pattern (aus mybdy-nutrition: Cloudflare Worker)
```typescript
export default {
async scheduled(event, env, ctx) {
ctx.waitUntil(Promise.all([
processHabitReminders(env),
processStreakCalculations(env),
sendDailySummaries(env),
]));
}
};
```
Für Carrot: **Supabase Edge Function + pg_cron** als Alternative zu Cloudflare Workers.
| Job | Frequenz | Aufgabe |
|-----|----------|---------|
| Streak Calculator | Täglich 00:05 (UTC) | Streaks aktualisieren, Badges vergeben |
| Reminder Push | Stündlich | Fällige Habit-Reminders senden |
| Weekly Summary | Sonntag 18:00 | Wochen-Report Push Notification |
---
## Quellen
### Aus eigenen Projekten
- mindecho-app: Offline-first, Auth, Zustand, Sentry, Supabase Client
- mybdy-app-native: Cache Layer, Local Notifications, Progress Charts
- mybdy-nutrition: Cron Worker, Monorepo, Stripe
- mybdy-light: Shared Packages, i18n, DSGVO Consent
### Web Research
- [Expo + Supabase Guide](https://docs.expo.dev/guides/using-supabase/)
- [Local-first Expo + Legend-State](https://supabase.com/blog/local-first-expo-legend-state)
- [TanStack Query Offline-first](https://www.benoitpaul.com/blog/react-native/offline-first-tanstack-query/)
- [Supabase RLS Best Practices](https://supabase.com/docs/guides/troubleshooting/rls-performance-and-best-practices-Z5Jjwv)
- [RevenueCat + Expo Tutorial](https://expo.dev/blog/expo-revenuecat-in-app-purchase-tutorial)
- [Gamification Streaks](https://www.plotline.so/blog/streaks-for-gamification-in-mobile-apps/)
- [Push Notification Strategy 2026](https://appmaker.xyz/blog/effective-push-notification-strategies)
- [React Native Reanimated 2026](https://swmansion.com/blog/react-native-in-2026-trends-our-predictions-463a837420c7)