diff --git a/apps/rebreak-native/app/_layout.tsx b/apps/rebreak-native/app/_layout.tsx
index 9758d30..357a391 100644
--- a/apps/rebreak-native/app/_layout.tsx
+++ b/apps/rebreak-native/app/_layout.tsx
@@ -17,6 +17,7 @@ import {
} from '@expo-google-fonts/nunito';
import { useAuthStore } from '../stores/auth';
import { useThemeStore } from '../stores/theme';
+import { useRealtimeDebugStore } from '../stores/realtimeDebug';
import { useColors } from '../lib/theme';
import { useLanguageStore } from '../stores/language';
import { useAppLockStore } from '../stores/appLock';
@@ -53,6 +54,7 @@ function RootLayoutInner() {
const initLanguage = useLanguageStore((s) => s.init);
const initAppLock = useAppLockStore((s) => s.init);
const appLockReady = useAppLockStore((s) => s.ready);
+ const initRealtimeDebug = useRealtimeDebugStore((s) => s.init);
const colors = useColors();
const [fontsLoaded] = useFonts({
Nunito_400Regular,
@@ -66,6 +68,7 @@ function RootLayoutInner() {
initTheme();
initLanguage();
initAppLock();
+ if (__DEV__) initRealtimeDebug();
}, []);
useEffect(() => {
diff --git a/apps/rebreak-native/app/debug.tsx b/apps/rebreak-native/app/debug.tsx
index f4bd264..405c6cc 100644
--- a/apps/rebreak-native/app/debug.tsx
+++ b/apps/rebreak-native/app/debug.tsx
@@ -1,5 +1,13 @@
import { useEffect, useState } from 'react';
-import { View, Text, ScrollView, Switch, TouchableOpacity, Alert } from 'react-native';
+import {
+ View,
+ Text,
+ ScrollView,
+ Switch,
+ TouchableOpacity,
+ Alert,
+ Clipboard,
+} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
@@ -8,6 +16,8 @@ import { useMe, invalidateMe, type Plan } from '../hooks/useMe';
import { apiFetch } from '../lib/api';
import { PlanChangeSheet } from '../components/plan/PlanChangeSheet';
import { getCooldownTestMode, setCooldownTestMode } from '../lib/protection';
+import { useRealtimeDebugStore, type LogEntry } from '../stores/realtimeDebug';
+import { supabase } from '../lib/supabase';
export default function DebugScreen() {
const router = useRouter();
@@ -98,6 +108,9 @@ export default function DebugScreen() {
+
+
+
s.connectionState);
+ const reconnectCount = useRealtimeDebugStore((s) => s.reconnectCount);
+ const lastEventAt = useRealtimeDebugStore((s) => s.lastEventAt);
+ const tokenExpiresAt = useRealtimeDebugStore((s) => s.tokenExpiresAt);
+ const refreshSnapshot = useRealtimeDebugStore((s) => s.refreshSnapshot);
+
+ const [tick, setTick] = useState(0);
+ const [channels, setChannels] = useState<{ topic: string; state: string }[]>([]);
+
+ // Re-render once per second to keep relative timestamps live
+ useEffect(() => {
+ const t = setInterval(() => setTick((n) => n + 1), 1000);
+ return () => clearInterval(t);
+ }, []);
+
+ // Refresh snapshot + channel list on each tick
+ useEffect(() => {
+ refreshSnapshot();
+ try {
+ const rt = supabase.realtime as any;
+ const raw: { topic?: string; state?: string }[] = rt.channels ?? [];
+ setChannels(
+ raw.map((ch) => ({
+ topic: ch.topic ?? '?',
+ state: ch.state ?? '?',
+ })),
+ );
+ } catch {
+ setChannels([]);
+ }
+ }, [tick, refreshSnapshot]);
+
+ const stateColor = stateAccent(connectionState, colors);
+
+ const tokenSecsLeft =
+ tokenExpiresAt != null ? Math.round(tokenExpiresAt - Date.now() / 1000) : null;
+
+ const lastEventLabel = lastEventAt
+ ? relativeSeconds(Date.now() - lastEventAt)
+ : 'never';
+
+ return (
+
+
+
+
+
+
+ Realtime Status
+
+ refreshSnapshot()}
+ hitSlop={8}
+ activeOpacity={0.6}
+ >
+
+
+
+
+
+
+
+
+
+ {channels.length > 0 ? (
+
+
+ Active channels ({channels.length})
+
+ {channels.map((ch) => (
+
+
+ {ch.topic}
+
+
+ {ch.state}
+
+
+ ))}
+
+ ) : (
+
+
+ Keine aktiven Channels
+
+
+ )}
+
+ );
+}
+
+function StatusRow({
+ label,
+ value,
+ valueColor,
+ colors,
+}: {
+ label: string;
+ value: string;
+ valueColor?: string;
+ colors: import('../lib/theme').ColorScheme;
+}) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
+// ─── Realtime Log Card ─────────────────────────────────────────────────────
+
+function RealtimeLogCard() {
+ const colors = useColors();
+ const log = useRealtimeDebugStore((s) => s.log);
+ const clearLog = useRealtimeDebugStore((s) => s.clearLog);
+
+ function copyLog() {
+ const text = log
+ .map((e) => `[${new Date(e.ts).toISOString()}] ${e.text}`)
+ .join('\n');
+ Clipboard.setString(text);
+ Alert.alert('Kopiert', `${log.length} Log-Einträge in Zwischenablage.`);
+ }
+
+ return (
+
+
+
+
+
+
+ Realtime Log
+
+
+
+
+
+
+
+
+
+ {log.length === 0 ? (
+
+ Noch keine Events — warte auf Subscription-Aktivität.
+
+ ) : (
+
+
+ {log.map((entry) => (
+
+ ))}
+
+
+ )}
+
+
+ max 100 Einträge, neueste oben
+
+
+ );
+}
+
+function LogLine({
+ entry,
+ colors,
+}: {
+ entry: LogEntry;
+ colors: import('../lib/theme').ColorScheme;
+}) {
+ const time = new Date(entry.ts);
+ const hms = `${pad(time.getHours())}:${pad(time.getMinutes())}:${pad(time.getSeconds())}`;
+ const isError =
+ entry.text.includes('error') ||
+ entry.text.includes('close') ||
+ entry.text.includes('CLOSED');
+ const isOpen = entry.text.includes('open') || entry.text.includes('joined');
+
+ return (
+
+
+ {hms}
+
+
+ {entry.text}
+
+
+ );
+}
+
+// ─── Helpers ───────────────────────────────────────────────────────────────
+
+function pad(n: number) {
+ return String(n).padStart(2, '0');
+}
+
+function relativeSeconds(diffMs: number): string {
+ const s = Math.round(diffMs / 1000);
+ if (s < 60) return `vor ${s}s`;
+ const m = Math.floor(s / 60);
+ if (m < 60) return `vor ${m}m`;
+ return `vor ${Math.floor(m / 60)}h`;
+}
+
+function stateAccent(state: string, colors: import('../lib/theme').ColorScheme): string {
+ const upper = state.toUpperCase();
+ if (upper === 'OPEN' || upper === 'JOINED' || upper === 'SUBSCRIBED') return colors.success;
+ if (upper === 'CLOSED' || upper === 'CHANNEL_ERROR' || upper === 'TIMED_OUT')
+ return colors.error;
+ if (upper === 'CONNECTING' || upper === 'JOINING') return colors.warning;
+ return colors.textMuted;
+}
+
+// ─── Plan Override ─────────────────────────────────────────────────────────
+
const PLANS: Plan[] = ['free', 'pro', 'legend'];
const PLAN_COLOR: Record = {
@@ -250,6 +658,8 @@ function PlanOverrideToggle({
);
}
+// ─── Cooldown Test Mode ────────────────────────────────────────────────────
+
function CooldownTestModeToggle() {
const colors = useColors();
const [enabled, setEnabled] = useState(false);
@@ -317,6 +727,8 @@ function CooldownTestModeToggle() {
);
}
+// ─── Debug Stub ────────────────────────────────────────────────────────────
+
function DebugStub({
title,
subtitle,
diff --git a/apps/rebreak-native/stores/protectedDevices.ts b/apps/rebreak-native/stores/protectedDevices.ts
index bc7a0ee..db96f3b 100644
--- a/apps/rebreak-native/stores/protectedDevices.ts
+++ b/apps/rebreak-native/stores/protectedDevices.ts
@@ -36,8 +36,8 @@ export const useProtectedDevicesStore = create((set, get)
load: async () => {
set({ loading: true });
try {
- const devices = await apiFetch('/api/devices/protected');
- set({ devices });
+ const res = await apiFetch('/api/devices/protected');
+ set({ devices: Array.isArray(res) ? res : [] });
} catch {
// endpoint might not be ready yet — keep empty state, screen handles it
} finally {
diff --git a/apps/rebreak-native/stores/realtimeDebug.ts b/apps/rebreak-native/stores/realtimeDebug.ts
new file mode 100644
index 0000000..8172a1c
--- /dev/null
+++ b/apps/rebreak-native/stores/realtimeDebug.ts
@@ -0,0 +1,159 @@
+import { create } from 'zustand';
+import { supabase } from '../lib/supabase';
+
+export type LogEntry = {
+ id: number;
+ ts: number;
+ text: string;
+};
+
+type RealtimeDebugState = {
+ connectionState: string;
+ reconnectCount: number;
+ lastEventAt: number | null;
+ tokenExpiresAt: number | null;
+ log: LogEntry[];
+ initialized: boolean;
+
+ appendLog: (text: string) => void;
+ clearLog: () => void;
+ refreshSnapshot: () => Promise;
+ init: () => void;
+};
+
+let logIdSeq = 0;
+
+export const useRealtimeDebugStore = create((set, get) => ({
+ connectionState: 'unknown',
+ reconnectCount: 0,
+ lastEventAt: null,
+ tokenExpiresAt: null,
+ log: [],
+ initialized: false,
+
+ appendLog(text) {
+ const entry: LogEntry = { id: logIdSeq++, ts: Date.now(), text };
+ set((s) => ({
+ log: [entry, ...s.log].slice(0, 100),
+ lastEventAt: Date.now(),
+ }));
+ },
+
+ clearLog() {
+ set({ log: [] });
+ },
+
+ async refreshSnapshot() {
+ const rt = supabase.realtime as any;
+
+ // connectionState — Supabase JS v2 exposes this on the internal conn object
+ let connState = 'unknown';
+ try {
+ // supabase.realtime.connectionState() exists in @supabase/realtime-js >= 2.x
+ if (typeof rt.connectionState === 'function') {
+ connState = String(rt.connectionState());
+ } else if (rt.conn?.connectionState) {
+ connState = String(rt.conn.connectionState());
+ }
+ } catch {
+ connState = 'error';
+ }
+
+ const { data } = await supabase.auth.getSession();
+ const tokenExpiresAt = data.session?.expires_at ?? null;
+
+ set({ connectionState: connState, tokenExpiresAt: tokenExpiresAt ?? null });
+ },
+
+ init() {
+ if (get().initialized) return;
+ set({ initialized: true });
+
+ const rt = supabase.realtime as any;
+
+ // Patch socket-level open/close/error — supabase-js internal conn is a Phoenix socket
+ function hookSocket() {
+ const conn = rt.conn;
+ if (!conn) return false;
+
+ const origOnOpen = conn.onOpen?.bind(conn);
+ const origOnClose = conn.onClose?.bind(conn);
+ const origOnError = conn.onError?.bind(conn);
+
+ conn.onOpen(() => {
+ get().appendLog('socket:open');
+ set({ connectionState: 'OPEN' });
+ });
+
+ conn.onClose((event: { code?: number; reason?: string }) => {
+ const reason = event?.reason ? ` reason=${event.reason}` : '';
+ get().appendLog(`socket:close code=${event?.code ?? '?'}${reason}`);
+ set((s) => ({
+ connectionState: 'CLOSED',
+ reconnectCount: s.reconnectCount + 1,
+ }));
+ });
+
+ conn.onError((error: unknown) => {
+ get().appendLog(`socket:error ${String(error)}`);
+ });
+
+ // Reconnect attempts — Phoenix socket emits reconnectAttempts counter change
+ // We monkey-patch reconnectTimer to detect attempts
+ const origReconnect = conn.reconnect?.bind(conn);
+ if (typeof origReconnect === 'function') {
+ conn.reconnect = function (...args: unknown[]) {
+ set((s) => ({ reconnectCount: s.reconnectCount + 1 }));
+ get().appendLog(`socket:reconnect-attempt #${get().reconnectCount}`);
+ return origReconnect(...args);
+ };
+ }
+
+ return true;
+ }
+
+ // The socket may not yet exist when init() is called (before first subscription).
+ // Poll briefly then give up — subsequent channel subscriptions will still log via channel listener.
+ let attempts = 0;
+ const socketPollInterval = setInterval(() => {
+ attempts++;
+ if (hookSocket() || attempts > 20) {
+ clearInterval(socketPollInterval);
+ }
+ }, 500);
+
+ // Listen to all channel state changes via supabase.realtime internal channels array
+ // We wrap channel.subscribe to intercept status callbacks
+ // Approach: patch RealtimeChannel.prototype.subscribe so any new channel gets logged
+ try {
+ const RealtimeChannelProto = Object.getPrototypeOf(supabase.channel('__probe__'));
+ supabase.removeAllChannels();
+
+ const origChanSubscribe = RealtimeChannelProto.subscribe;
+ if (typeof origChanSubscribe === 'function' && !RealtimeChannelProto.__debugPatched) {
+ RealtimeChannelProto.__debugPatched = true;
+ RealtimeChannelProto.subscribe = function (
+ callback?: (status: string, err?: unknown) => void,
+ timeout?: number,
+ ) {
+ const channelTopic: string = this.topic ?? '?';
+ return origChanSubscribe.call(
+ this,
+ (status: string, err?: unknown) => {
+ get().appendLog(
+ `channel:${status.toLowerCase()} ${channelTopic}${err ? ` ${String(err)}` : ''}`,
+ );
+ if (callback) callback(status, err);
+ },
+ timeout,
+ );
+ };
+ }
+ } catch {
+ // Prototype patching failed — not critical, socket-level hooks still work
+ }
+
+ // Initial snapshot
+ get().refreshSnapshot();
+ },
+}));