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',