diff --git a/frontend/constants/supabase.ts b/frontend/constants/supabase.ts index 4334da2..513ad7f 100644 --- a/frontend/constants/supabase.ts +++ b/frontend/constants/supabase.ts @@ -12,6 +12,7 @@ export const TABLES = { PUSH_TOKENS: 'push_tokens', NOTIFICATION_SETTINGS: 'notification_settings', PRIVACY_SETTINGS: 'privacy_settings', + USER_STREAKS: 'user_streaks', } as const; // Storage Bucket Names @@ -120,5 +121,15 @@ export const SCHEMA = { location_share: 'boolean NOT NULL DEFAULT true', created_at: 'timestamptz DEFAULT now()', updated_at: 'timestamptz DEFAULT now()', + }, + USER_STREAKS: { + id: 'bigserial PRIMARY KEY', + user_id: 'uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE', + current_streak: 'integer NOT NULL DEFAULT 0', + max_streak: 'integer NOT NULL DEFAULT 0', + last_entry_date: 'date', + last_access_time: 'timestamptz', + created_at: 'timestamptz DEFAULT now()', + updated_at: 'timestamptz DEFAULT now()', } } as const; \ No newline at end of file diff --git a/frontend/services/streak-service.ts b/frontend/services/streak-service.ts index b1a8ca6..f44ace2 100644 --- a/frontend/services/streak-service.ts +++ b/frontend/services/streak-service.ts @@ -1,6 +1,7 @@ import { format, differenceInDays, startOfDay } from 'date-fns'; import { deviceStorage } from './device-storage'; import { logger } from '@/lib/logger'; +import { supabase } from '@/lib/supabase'; /** * Streak Service - Manages user daily entry streaks @@ -40,6 +41,42 @@ export class StreakService { console.error('Failed to load streak data:', error); } + // Fallback to Supabase (single row per user) for cross-device sync of streak data + try { + const { data, error } = await supabase + .from('user_streaks') + .select('current_streak, max_streak, last_entry_date, last_access_time') + .eq('user_id', userId) + .maybeSingle<{ + current_streak: number | null; + max_streak: number | null; + last_entry_date: string | null; + last_access_time: string | null; + }>(); + + if (error) { + console.error('Error fetching streak data from Supabase:', error); + } else if (data) { + const fromRemote: StreakData = { + currentStreak: data.current_streak ?? 0, + maxStreak: data.max_streak ?? 0, + lastEntryDate: data.last_entry_date, + lastAccessTime: data.last_access_time, + }; + + // Cache remotely-loaded data locally (best-effort) + try { + await deviceStorage.setItem(`streak_${userId}`, fromRemote); + } catch (cacheError) { + console.error('Failed to cache remote streak data locally:', cacheError); + } + + return fromRemote; + } + } catch (error) { + console.error('Error in Supabase streak fallback:', error); + } + // Return default streak data return { currentStreak: 0, @@ -51,12 +88,32 @@ export class StreakService { // Save streak data to storage static async saveStreakData(userId: string, data: StreakData): Promise { + // 1. Save to local storage (best-effort cache; don't throw) try { await deviceStorage.setItem(`streak_${userId}`, data); console.log('Saved streak data:', data); } catch (error) { console.error('Failed to save streak data:', error); } + + // 2. Sync streak stats to Supabase (best-effort; do not block core app flows) + try { + const { error } = await supabase + .from('user_streaks') + .upsert({ + user_id: userId, + current_streak: data.currentStreak, + max_streak: data.maxStreak, + last_entry_date: data.lastEntryDate, + last_access_time: data.lastAccessTime, + } as never, { onConflict: 'user_id' } as never); + + if (error) { + console.error('Error saving streak data to Supabase:', error); + } + } catch (error) { + console.error('Error in saveStreakData Supabase sync:', error); + } } /** diff --git a/frontend/supabase/migrations/20260111000000_streaks.sql b/frontend/supabase/migrations/20260111000000_streaks.sql new file mode 100644 index 0000000..789b51c --- /dev/null +++ b/frontend/supabase/migrations/20260111000000_streaks.sql @@ -0,0 +1,83 @@ +/* + # User Streaks Table + + 1. New Table + - `user_streaks` - Per-user streak stats (1 row per user) + - `id` (bigint, primary key) + - `user_id` (uuid, references auth.users) + - `current_streak` (integer) + - `max_streak` (integer) + - `last_entry_date` (date, nullable) + - `last_access_time` (timestamptz, nullable) + - `created_at` (timestamp) + - `updated_at` (timestamp) + + 2. Security + - Enable RLS + - Policies so users can manage only their own streak record +*/ + +-- Create user_streaks table +CREATE TABLE IF NOT EXISTS user_streaks ( + id bigserial PRIMARY KEY, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + current_streak integer NOT NULL DEFAULT 0, + max_streak integer NOT NULL DEFAULT 0, + last_entry_date date, + last_access_time timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- Backfill and enforce NOT NULL constraints for existing rows (if any) +UPDATE user_streaks +SET created_at = now() +WHERE created_at IS NULL; + +UPDATE user_streaks +SET updated_at = now() +WHERE updated_at IS NULL; + +ALTER TABLE user_streaks + ALTER COLUMN created_at SET NOT NULL, + ALTER COLUMN updated_at SET NOT NULL; + +-- Enable Row Level Security +ALTER TABLE user_streaks ENABLE ROW LEVEL SECURITY; + +-- Policies: users can manage their own streak record +CREATE POLICY "Users can read own streak" + ON user_streaks + FOR SELECT + TO authenticated + USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert own streak" + ON user_streaks + FOR INSERT + TO authenticated + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update own streak" + ON user_streaks + FOR UPDATE + TO authenticated + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can delete own streak" + ON user_streaks + FOR DELETE + TO authenticated + USING (auth.uid() = user_id); + +-- Indexes for performance +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_streaks_user_id + ON user_streaks(user_id); + +-- updated_at trigger +CREATE TRIGGER update_user_streaks_updated_at + BEFORE UPDATE ON user_streaks + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + diff --git a/frontend/types/database.ts b/frontend/types/database.ts index b493d76..a59b233 100644 --- a/frontend/types/database.ts +++ b/frontend/types/database.ts @@ -317,6 +317,38 @@ export interface Database { updated_at?: string } } + user_streaks: { + Row: { + id: number + user_id: string + current_streak: number + max_streak: number + last_entry_date: string | null + last_access_time: string | null + created_at: string + updated_at: string + } + Insert: { + id?: number + user_id: string + current_streak?: number + max_streak?: number + last_entry_date?: string | null + last_access_time?: string | null + created_at?: string + updated_at?: string + } + Update: { + id?: number + user_id?: string + current_streak?: number + max_streak?: number + last_entry_date?: string | null + last_access_time?: string | null + created_at?: string + updated_at?: string + } + } } Views: { [_ in never]: never