Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions frontend/constants/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
57 changes: 57 additions & 0 deletions frontend/services/streak-service.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -51,12 +88,32 @@ export class StreakService {

// Save streak data to storage
static async saveStreakData(userId: string, data: StreakData): Promise<void> {
// 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);
}
}

/**
Expand Down
83 changes: 83 additions & 0 deletions frontend/supabase/migrations/20260111000000_streaks.sql
Original file line number Diff line number Diff line change
@@ -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();

32 changes: 32 additions & 0 deletions frontend/types/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down