From c496d9586c816676aee8e140150dec120bb67030 Mon Sep 17 00:00:00 2001 From: le king fu Date: Mon, 6 Apr 2026 12:54:44 -0400 Subject: [PATCH] feat: add mobile sync client with outbox pattern (#40) - sync_outbox table in SQLite (migration 0004) - Sync service: push/pull changes, fullSync, outbox cleanup - Outbox writing in task/list/tag repositories after mutations - Settings store: syncEnabled, lastSyncAt, userId - Sync polling: on launch, every 2 min, on return from background - Settings UI: Compte section with connect/sync/disconnect buttons - i18n keys for sync strings (FR + EN) Co-Authored-By: Claude Opus 4.6 (1M context) --- app/(tabs)/settings.tsx | 123 ++++++- app/_layout.tsx | 33 +- src/db/migrations/0004_nosy_human_torch.sql | 9 + src/db/migrations/meta/0004_snapshot.json | 375 ++++++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + src/db/migrations/migrations.js | 4 +- src/db/repository/lists.ts | 14 + src/db/repository/outbox.ts | 31 ++ src/db/repository/tags.ts | 7 + src/db/repository/tasks.ts | 32 ++ src/db/schema.ts | 10 + src/i18n/en.json | 13 + src/i18n/fr.json | 13 + src/services/syncClient.ts | 270 ++++++++++++++ src/stores/useSettingsStore.ts | 12 + 15 files changed, 949 insertions(+), 4 deletions(-) create mode 100644 src/db/migrations/0004_nosy_human_torch.sql create mode 100644 src/db/migrations/meta/0004_snapshot.json create mode 100644 src/db/repository/outbox.ts create mode 100644 src/services/syncClient.ts diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index d9f63af..35591ad 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import { View, Text, Pressable, useColorScheme, TextInput, ScrollView, Alert, Modal, Platform, Switch, Linking, ActivityIndicator } from 'react-native'; import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; import { useTranslation } from 'react-i18next'; -import { Sun, Moon, Smartphone, Plus, Trash2, Pencil, Bell, CalendarDays, LayoutGrid, Mail, RefreshCw } from 'lucide-react-native'; +import { Sun, Moon, Smartphone, Plus, Trash2, Pencil, Bell, CalendarDays, LayoutGrid, Mail, RefreshCw, Cloud, LogIn, LogOut } from 'lucide-react-native'; import Constants from 'expo-constants'; import { colors } from '@/src/theme/colors'; @@ -10,6 +10,7 @@ import { useSettingsStore } from '@/src/stores/useSettingsStore'; import { getAllTags, createTag, updateTag, deleteTag } from '@/src/db/repository/tags'; import { initCalendar } from '@/src/services/calendar'; import { syncWidgetData } from '@/src/services/widgetSync'; +import { fullSync } from '@/src/services/syncClient'; import i18n from '@/src/i18n'; type ThemeMode = 'light' | 'dark' | 'system'; @@ -25,6 +26,9 @@ export default function SettingsScreen() { reminderOffset, setReminderOffset, calendarSyncEnabled, setCalendarSyncEnabled, widgetPeriodWeeks, setWidgetPeriodWeeks, + syncEnabled, setSyncEnabled, + lastSyncAt, setLastSyncAt, + userId, setUserId, } = useSettingsStore(); const isDark = (theme === 'system' ? systemScheme : theme) === 'dark'; @@ -34,6 +38,7 @@ export default function SettingsScreen() { const [tagName, setTagName] = useState(''); const [tagColor, setTagColor] = useState(TAG_COLORS[0]); const [checkingUpdate, setCheckingUpdate] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); const loadTags = useCallback(async () => { const result = await getAllTags(); @@ -94,6 +99,40 @@ export default function SettingsScreen() { ]); }; + const handleSignIn = () => { + // Placeholder: actual Logto OAuth flow would go here + // For now, set a placeholder userId to test the sync UI + const placeholderId = 'user-placeholder'; + setUserId(placeholderId); + setSyncEnabled(true); + }; + + const handleSignOut = () => { + Alert.alert(t('sync.signOutConfirm'), '', [ + { text: t('common.cancel'), style: 'cancel' }, + { + text: t('sync.signOut'), + style: 'destructive', + onPress: () => { + setSyncEnabled(false); + setUserId(null); + setLastSyncAt(null); + }, + }, + ]); + }; + + const handleSyncNow = async () => { + setIsSyncing(true); + try { + await fullSync(); + } catch { + // Sync errors are logged internally + } finally { + setIsSyncing(false); + } + }; + const handleCheckUpdate = async () => { setCheckingUpdate(true); try { @@ -301,6 +340,88 @@ export default function SettingsScreen() { + {/* Account / Sync Section */} + + + {t('sync.title')} + + + {!userId ? ( + + + + {t('sync.signIn')} + + + ) : ( + <> + {/* Connected user */} + + + + + {t('sync.connectedAs', { userId })} + + + + {lastSyncAt + ? t('sync.lastSync', { date: new Date(lastSyncAt).toLocaleString() }) + : t('sync.never')} + + + + {/* Sync now button */} + + {isSyncing ? ( + + ) : ( + + )} + + {isSyncing ? t('sync.syncing') : t('sync.syncNow')} + + + + {/* Sign out */} + + + + {t('sync.signOut')} + + + + )} + + + {/* Widget Section */} s.theme); + const syncEnabled = useSettingsStore((s) => s.syncEnabled); const effectiveScheme = theme === 'system' ? systemScheme : theme; + const appState = useRef(AppState.currentState); useEffect(() => { if (fontError) throw fontError; @@ -74,6 +77,32 @@ export default function RootLayout() { } }, [fontsLoaded, migrationsReady]); + // Sync polling: run on launch, every 2 min, and on return from background + useEffect(() => { + if (!syncEnabled || !migrationsReady) return; + + // Initial sync + fullSync().then(() => cleanOutbox()).catch(() => {}); + + // 2-minute interval + const interval = setInterval(() => { + fullSync().then(() => cleanOutbox()).catch(() => {}); + }, 2 * 60 * 1000); + + // AppState listener: sync when returning from background + const subscription = AppState.addEventListener('change', (nextState: AppStateStatus) => { + if (appState.current.match(/inactive|background/) && nextState === 'active') { + fullSync().catch(() => {}); + } + appState.current = nextState; + }); + + return () => { + clearInterval(interval); + subscription.remove(); + }; + }, [syncEnabled, migrationsReady]); + if (!fontsLoaded || !migrationsReady) { return null; } diff --git a/src/db/migrations/0004_nosy_human_torch.sql b/src/db/migrations/0004_nosy_human_torch.sql new file mode 100644 index 0000000..8a52de5 --- /dev/null +++ b/src/db/migrations/0004_nosy_human_torch.sql @@ -0,0 +1,9 @@ +CREATE TABLE `sync_outbox` ( + `id` text PRIMARY KEY NOT NULL, + `entity_type` text NOT NULL, + `entity_id` text NOT NULL, + `action` text NOT NULL, + `payload` text NOT NULL, + `created_at` text NOT NULL, + `synced_at` text +); diff --git a/src/db/migrations/meta/0004_snapshot.json b/src/db/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..dcb0a25 --- /dev/null +++ b/src/db/migrations/meta/0004_snapshot.json @@ -0,0 +1,375 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3bd69590-afd7-4470-a63b-68306ffbf911", + "prevId": "d3023632-946c-4fe9-b543-61cdf8af873c", + "tables": { + "lists": { + "name": "lists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_inbox": { + "name": "is_inbox", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_outbox": { + "name": "sync_outbox", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "synced_at": { + "name": "synced_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#4A90A4'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_tags": { + "name": "task_tags", + "columns": { + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "task_tags_task_id_tasks_id_fk": { + "name": "task_tags_task_id_tasks_id_fk", + "tableFrom": "task_tags", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_tags_tag_id_tags_id_fk": { + "name": "task_tags_tag_id_tags_id_fk", + "tableFrom": "task_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "task_tags_task_id_tag_id_pk": { + "columns": [ + "task_id", + "tag_id" + ], + "name": "task_tags_task_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed": { + "name": "completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "due_date": { + "name": "due_date", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "list_id": { + "name": "list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "recurrence": { + "name": "recurrence", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "calendar_event_id": { + "name": "calendar_event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "tasks_list_id_lists_id_fk": { + "name": "tasks_list_id_lists_id_fk", + "tableFrom": "tasks", + "tableTo": "lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 738281c..8ee2372 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1775486221676, "tag": "0003_sharp_radioactive_man", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1775493830127, + "tag": "0004_nosy_human_torch", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/migrations/migrations.js b/src/db/migrations/migrations.js index a924b11..7806c8c 100644 --- a/src/db/migrations/migrations.js +++ b/src/db/migrations/migrations.js @@ -5,6 +5,7 @@ import m0000 from './0000_bitter_phalanx.sql'; import m0001 from './0001_sticky_arachne.sql'; import m0002 from './0002_majestic_wendell_rand.sql'; import m0003 from './0003_sharp_radioactive_man.sql'; +import m0004 from './0004_nosy_human_torch.sql'; export default { journal, @@ -12,7 +13,8 @@ import m0003 from './0003_sharp_radioactive_man.sql'; m0000, m0001, m0002, -m0003 +m0003, +m0004 } } \ No newline at end of file diff --git a/src/db/repository/lists.ts b/src/db/repository/lists.ts index 0f3041f..02346be 100644 --- a/src/db/repository/lists.ts +++ b/src/db/repository/lists.ts @@ -3,6 +3,7 @@ import { db } from '../client'; import { lists } from '../schema'; import { randomUUID } from '@/src/lib/uuid'; import { truncate } from '@/src/lib/validation'; +import { writeOutboxEntry } from './outbox'; const INBOX_ID = '00000000-0000-0000-0000-000000000001'; @@ -46,6 +47,16 @@ export async function createList(name: string, color?: string, icon?: string) { createdAt: now, updatedAt: now, }); + + writeOutboxEntry('list', id, 'create', { + id, + name, + color: color ?? null, + icon: icon ?? null, + position: maxPosition + 1, + is_inbox: false, + }).catch(() => {}); + return id; } @@ -56,6 +67,8 @@ export async function updateList(id: string, data: { name?: string; color?: stri .update(lists) .set({ ...sanitized, updatedAt: new Date() }) .where(eq(lists.id, id)); + + writeOutboxEntry('list', id, 'update', { id, ...sanitized }).catch(() => {}); } export async function reorderLists(updates: { id: string; position: number }[]) { @@ -68,4 +81,5 @@ export async function reorderLists(updates: { id: string; position: number }[]) export async function deleteList(id: string) { await db.delete(lists).where(eq(lists.id, id)); + writeOutboxEntry('list', id, 'delete', { id }).catch(() => {}); } diff --git a/src/db/repository/outbox.ts b/src/db/repository/outbox.ts new file mode 100644 index 0000000..5228731 --- /dev/null +++ b/src/db/repository/outbox.ts @@ -0,0 +1,31 @@ +import { db } from '../client'; +import { syncOutbox } from '../schema'; +import { randomUUID } from '@/src/lib/uuid'; +import { useSettingsStore } from '@/src/stores/useSettingsStore'; + +type EntityType = 'task' | 'list' | 'tag' | 'task_tag'; +type Action = 'create' | 'update' | 'delete'; + +/** + * Write an entry to the sync outbox if sync is enabled. + * The entry id serves as the idempotency key. + */ +export async function writeOutboxEntry( + entityType: EntityType, + entityId: string, + action: Action, + payload: Record +): Promise { + const { syncEnabled } = useSettingsStore.getState(); + if (!syncEnabled) return; + + await db.insert(syncOutbox).values({ + id: randomUUID(), + entityType, + entityId, + action, + payload: JSON.stringify(payload), + createdAt: new Date().toISOString(), + syncedAt: null, + }); +} diff --git a/src/db/repository/tags.ts b/src/db/repository/tags.ts index 6ccc4ca..cfe1667 100644 --- a/src/db/repository/tags.ts +++ b/src/db/repository/tags.ts @@ -3,6 +3,7 @@ import { db } from '../client'; import { tags, taskTags } from '../schema'; import { randomUUID } from '@/src/lib/uuid'; import { truncate } from '@/src/lib/validation'; +import { writeOutboxEntry } from './outbox'; export async function getAllTags() { return db.select().from(tags).orderBy(tags.name); @@ -18,16 +19,21 @@ export async function createTag(name: string, color: string) { createdAt: now, updatedAt: now, }); + + writeOutboxEntry('tag', id, 'create', { id, name, color }).catch(() => {}); + return id; } export async function updateTag(id: string, name: string, color: string) { await db.update(tags).set({ name: truncate(name, 100), color, updatedAt: new Date() }).where(eq(tags.id, id)); + writeOutboxEntry('tag', id, 'update', { id, name, color }).catch(() => {}); } export async function deleteTag(id: string) { await db.delete(taskTags).where(eq(taskTags.tagId, id)); await db.delete(tags).where(eq(tags.id, id)); + writeOutboxEntry('tag', id, 'delete', { id }).catch(() => {}); } export async function getTagsForTask(taskId: string) { @@ -46,6 +52,7 @@ export async function setTagsForTask(taskId: string, tagIds: string[]) { tagIds.map((tagId) => ({ taskId, tagId })) ); } + writeOutboxEntry('task_tag', taskId, 'update', { task_id: taskId, tag_ids: tagIds }).catch(() => {}); } export async function addTagToTask(taskId: string, tagId: string) { diff --git a/src/db/repository/tasks.ts b/src/db/repository/tasks.ts index 89308b2..83dc086 100644 --- a/src/db/repository/tasks.ts +++ b/src/db/repository/tasks.ts @@ -11,6 +11,7 @@ import { useSettingsStore } from '@/src/stores/useSettingsStore'; import { syncWidgetData } from '@/src/services/widgetSync'; import { clamp, truncate } from '@/src/lib/validation'; import { RECURRENCE_OPTIONS } from '@/src/lib/recurrence'; +import { writeOutboxEntry } from './outbox'; export type { TaskFilters } from '@/src/shared/types'; @@ -170,6 +171,18 @@ export async function createTask(data: { } } + // Sync outbox + writeOutboxEntry('task', id, 'create', { + id, + title: data.title, + notes: data.notes ?? null, + priority: data.priority ?? 0, + due_date: data.dueDate?.toISOString() ?? null, + list_id: data.listId, + parent_id: data.parentId ?? null, + recurrence: sanitizedRecurrence, + }).catch(() => {}); + syncWidgetData().catch(() => {}); return id; @@ -235,6 +248,21 @@ export async function updateTask( } } + // Sync outbox + if (task) { + writeOutboxEntry('task', id, 'update', { + id, + title: task.title, + notes: task.notes, + completed: task.completed, + priority: task.priority, + due_date: task.dueDate ? new Date(task.dueDate).toISOString() : null, + list_id: task.listId, + parent_id: task.parentId, + recurrence: task.recurrence, + }).catch(() => {}); + } + syncWidgetData().catch(() => {}); } @@ -303,11 +331,15 @@ export async function deleteTask(id: string) { // Delete subtasks first const subtasks = await getSubtasks(id); for (const sub of subtasks) { + writeOutboxEntry('task', sub.id, 'delete', { id: sub.id }).catch(() => {}); await db.delete(taskTags).where(eq(taskTags.taskId, sub.id)); await db.delete(tasks).where(eq(tasks.id, sub.id)); } await db.delete(taskTags).where(eq(taskTags.taskId, id)); await db.delete(tasks).where(eq(tasks.id, id)); + // Sync outbox + writeOutboxEntry('task', id, 'delete', { id }).catch(() => {}); + syncWidgetData().catch(() => {}); } diff --git a/src/db/schema.ts b/src/db/schema.ts index a146c37..e39f610 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -36,6 +36,16 @@ export const tags = sqliteTable('tags', { updatedAt: integer('updated_at', { mode: 'timestamp' }), }); +export const syncOutbox = sqliteTable('sync_outbox', { + id: text('id').primaryKey(), + entityType: text('entity_type').notNull(), // 'task' | 'list' | 'tag' | 'task_tag' + entityId: text('entity_id').notNull(), + action: text('action').notNull(), // 'create' | 'update' | 'delete' + payload: text('payload').notNull(), // JSON-serialized entity data + createdAt: text('created_at').notNull(), // ISO timestamp + syncedAt: text('synced_at'), // ISO timestamp, null = not synced +}); + export const taskTags = sqliteTable( 'task_tags', { diff --git a/src/i18n/en.json b/src/i18n/en.json index 0790166..ac9ed13 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -131,6 +131,19 @@ "inbox": "No tasks yet.\nTap + to get started.", "list": "This list is empty." }, + "sync": { + "title": "Account", + "signIn": "Sign in", + "signOut": "Sign out", + "signOutConfirm": "Are you sure you want to sign out?", + "syncNow": "Sync now", + "syncing": "Syncing...", + "lastSync": "Last sync: {{date}}", + "never": "Never synced", + "connectedAs": "Connected: {{userId}}", + "syncEnabled": "Sync enabled", + "syncDescription": "Syncs your data across devices" + }, "widget": { "title": "Simpl-Liste", "taskCount_one": "{{count}} task", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 4aa5120..373234e 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -131,6 +131,19 @@ "inbox": "Aucune tâche.\nAppuyez sur + pour commencer.", "list": "Cette liste est vide." }, + "sync": { + "title": "Compte", + "signIn": "Se connecter", + "signOut": "Se déconnecter", + "signOutConfirm": "Voulez-vous vraiment vous déconnecter ?", + "syncNow": "Synchroniser", + "syncing": "Synchronisation...", + "lastSync": "Dernière sync : {{date}}", + "never": "Jamais synchronisé", + "connectedAs": "Connecté : {{userId}}", + "syncEnabled": "Synchronisation activée", + "syncDescription": "Synchronise vos données entre appareils" + }, "widget": { "title": "Simpl-Liste", "taskCount_one": "{{count}} tâche", diff --git a/src/services/syncClient.ts b/src/services/syncClient.ts new file mode 100644 index 0000000..90a16d7 --- /dev/null +++ b/src/services/syncClient.ts @@ -0,0 +1,270 @@ +import { eq, isNull, not } from 'drizzle-orm'; +import { db } from '@/src/db/client'; +import { syncOutbox, lists, tasks, tags, taskTags } from '@/src/db/schema'; +import { useSettingsStore } from '@/src/stores/useSettingsStore'; + +const SYNC_API_BASE = 'https://liste.lacompagniemaximus.com'; + +interface SyncPushEntry { + id: string; + entity_type: string; + entity_id: string; + action: string; + payload: string; + created_at: string; +} + +interface SyncPullChange { + entity_type: 'list' | 'task' | 'tag' | 'task_tag'; + entity_id: string; + action: 'create' | 'update' | 'delete'; + payload: Record; + updated_at: string; +} + +interface SyncPullResponse { + changes: SyncPullChange[]; + sync_token: string; +} + +function getAuthHeaders(): Record { + const { userId } = useSettingsStore.getState(); + if (!userId) return {}; + // Placeholder: in real implementation, JWT from Logto would be used + return { + 'Authorization': `Bearer ${userId}`, + 'Content-Type': 'application/json', + }; +} + +/** + * Push unsynced outbox entries to the server. + */ +export async function pushChanges(): Promise { + const headers = getAuthHeaders(); + if (!headers['Authorization']) return; + + const unsynced = await db + .select() + .from(syncOutbox) + .where(isNull(syncOutbox.syncedAt)); + + if (unsynced.length === 0) return; + + // Send in batches of 50 + const batchSize = 50; + for (let i = 0; i < unsynced.length; i += batchSize) { + const batch = unsynced.slice(i, i + batchSize); + const entries: SyncPushEntry[] = batch.map((entry) => ({ + id: entry.id, + entity_type: entry.entityType, + entity_id: entry.entityId, + action: entry.action, + payload: entry.payload, + created_at: entry.createdAt, + })); + + try { + const res = await fetch(`${SYNC_API_BASE}/api/sync`, { + method: 'POST', + headers, + body: JSON.stringify({ changes: entries }), + }); + + if (!res.ok) { + console.warn(`[sync] push failed with status ${res.status}`); + return; // Stop pushing on error, retry later + } + + // Mark entries as synced + const now = new Date().toISOString(); + for (const entry of batch) { + await db + .update(syncOutbox) + .set({ syncedAt: now }) + .where(eq(syncOutbox.id, entry.id)); + } + } catch (err) { + console.warn('[sync] push error:', err); + return; // Network error, retry later + } + } +} + +/** + * Pull changes from the server since the last sync timestamp. + */ +export async function pullChanges(since: string): Promise { + const headers = getAuthHeaders(); + if (!headers['Authorization']) return; + + try { + const url = `${SYNC_API_BASE}/api/sync?since=${encodeURIComponent(since)}`; + const res = await fetch(url, { method: 'GET', headers }); + + if (!res.ok) { + console.warn(`[sync] pull failed with status ${res.status}`); + return; + } + + const data: SyncPullResponse = await res.json(); + + for (const change of data.changes) { + try { + await applyChange(change); + } catch (err) { + console.warn(`[sync] failed to apply change for ${change.entity_type}/${change.entity_id}:`, err); + } + } + + // Update last sync timestamp + if (data.sync_token) { + useSettingsStore.getState().setLastSyncAt(data.sync_token); + } + } catch (err) { + console.warn('[sync] pull error:', err); + } +} + +async function applyChange(change: SyncPullChange): Promise { + const { entity_type, action, payload, entity_id } = change; + + switch (entity_type) { + case 'list': + await applyListChange(entity_id, action, payload); + break; + case 'task': + await applyTaskChange(entity_id, action, payload); + break; + case 'tag': + await applyTagChange(entity_id, action, payload); + break; + case 'task_tag': + await applyTaskTagChange(entity_id, action, payload); + break; + } +} + +async function applyListChange(id: string, action: string, payload: Record) { + if (action === 'delete') { + await db.delete(lists).where(eq(lists.id, id)); + return; + } + + const existing = await db.select().from(lists).where(eq(lists.id, id)); + const values = { + id, + name: payload.name as string, + color: (payload.color as string) ?? null, + icon: (payload.icon as string) ?? null, + position: (payload.position as number) ?? 0, + isInbox: (payload.is_inbox as boolean) ?? false, + createdAt: new Date(payload.created_at as string), + updatedAt: new Date(payload.updated_at as string), + }; + + if (existing.length > 0) { + await db.update(lists).set(values).where(eq(lists.id, id)); + } else { + await db.insert(lists).values(values); + } +} + +async function applyTaskChange(id: string, action: string, payload: Record) { + if (action === 'delete') { + await db.delete(taskTags).where(eq(taskTags.taskId, id)); + await db.delete(tasks).where(eq(tasks.id, id)); + return; + } + + const existing = await db.select().from(tasks).where(eq(tasks.id, id)); + const values = { + id, + title: payload.title as string, + notes: (payload.notes as string) ?? null, + completed: (payload.completed as boolean) ?? false, + completedAt: payload.completed_at ? new Date(payload.completed_at as string) : null, + priority: (payload.priority as number) ?? 0, + dueDate: payload.due_date ? new Date(payload.due_date as string) : null, + listId: payload.list_id as string, + parentId: (payload.parent_id as string) ?? null, + position: (payload.position as number) ?? 0, + recurrence: (payload.recurrence as string) ?? null, + calendarEventId: (payload.calendar_event_id as string) ?? null, + createdAt: new Date(payload.created_at as string), + updatedAt: new Date(payload.updated_at as string), + }; + + if (existing.length > 0) { + await db.update(tasks).set(values).where(eq(tasks.id, id)); + } else { + await db.insert(tasks).values(values); + } +} + +async function applyTagChange(id: string, action: string, payload: Record) { + if (action === 'delete') { + await db.delete(taskTags).where(eq(taskTags.tagId, id)); + await db.delete(tags).where(eq(tags.id, id)); + return; + } + + const existing = await db.select().from(tags).where(eq(tags.id, id)); + const values = { + id, + name: payload.name as string, + color: (payload.color as string) ?? '#4A90A4', + createdAt: new Date(payload.created_at as string), + updatedAt: payload.updated_at ? new Date(payload.updated_at as string) : null, + }; + + if (existing.length > 0) { + await db.update(tags).set(values).where(eq(tags.id, id)); + } else { + await db.insert(tags).values(values); + } +} + +async function applyTaskTagChange(id: string, action: string, payload: Record) { + const taskId = payload.task_id as string; + const tagId = payload.tag_id as string; + + if (action === 'delete') { + await db + .delete(taskTags) + .where(eq(taskTags.taskId, taskId)); + return; + } + + // Upsert: insert if not exists + try { + await db.insert(taskTags).values({ taskId, tagId }).onConflictDoNothing(); + } catch { + // Ignore constraint errors + } +} + +/** + * Full sync: push local changes then pull remote changes. + */ +export async function fullSync(): Promise { + const { syncEnabled } = useSettingsStore.getState(); + if (!syncEnabled) return; + + try { + await pushChanges(); + + const since = useSettingsStore.getState().lastSyncAt ?? '1970-01-01T00:00:00.000Z'; + await pullChanges(since); + } catch (err) { + console.warn('[sync] fullSync error:', err); + } +} + +/** + * Clean up synced outbox entries to prevent unbounded growth. + * Deletes all entries that have been successfully synced. + */ +export async function cleanOutbox(): Promise { + await db.delete(syncOutbox).where(not(isNull(syncOutbox.syncedAt))); +} diff --git a/src/stores/useSettingsStore.ts b/src/stores/useSettingsStore.ts index c9998df..e199699 100644 --- a/src/stores/useSettingsStore.ts +++ b/src/stores/useSettingsStore.ts @@ -11,12 +11,18 @@ interface SettingsState { reminderOffset: number; // hours before due date (0 = at time) calendarSyncEnabled: boolean; widgetPeriodWeeks: number; // 0 = all tasks, otherwise number of weeks ahead + syncEnabled: boolean; + lastSyncAt: string | null; // ISO timestamp + userId: string | null; setTheme: (theme: ThemeMode) => void; setLocale: (locale: 'fr' | 'en') => void; setNotificationsEnabled: (enabled: boolean) => void; setReminderOffset: (offset: number) => void; setCalendarSyncEnabled: (enabled: boolean) => void; setWidgetPeriodWeeks: (weeks: number) => void; + setSyncEnabled: (enabled: boolean) => void; + setLastSyncAt: (timestamp: string | null) => void; + setUserId: (userId: string | null) => void; } export const useSettingsStore = create()( @@ -28,12 +34,18 @@ export const useSettingsStore = create()( reminderOffset: 0, calendarSyncEnabled: false, widgetPeriodWeeks: 0, + syncEnabled: false, + lastSyncAt: null, + userId: null, setTheme: (theme) => set({ theme }), setLocale: (locale) => set({ locale }), setNotificationsEnabled: (notificationsEnabled) => set({ notificationsEnabled }), setReminderOffset: (reminderOffset) => set({ reminderOffset }), setCalendarSyncEnabled: (calendarSyncEnabled) => set({ calendarSyncEnabled }), setWidgetPeriodWeeks: (widgetPeriodWeeks) => set({ widgetPeriodWeeks }), + setSyncEnabled: (syncEnabled) => set({ syncEnabled }), + setLastSyncAt: (lastSyncAt) => set({ lastSyncAt }), + setUserId: (userId) => set({ userId }), }), { name: 'simpl-liste-settings', -- 2.45.2