Carrot — Architecture & Technical Patterns

docs/ARCHITECTURE.md

← Zurück zu Knowledge

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)