feat: add mobile sync client with outbox pattern (#40) #47
15 changed files with 949 additions and 4 deletions
|
|
@ -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() {
|
|||
</View>
|
||||
</View>
|
||||
|
||||
{/* Account / Sync Section */}
|
||||
<View className="px-4 pt-6">
|
||||
<Text
|
||||
className={`mb-3 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
|
||||
style={{ fontFamily: 'Inter_600SemiBold' }}
|
||||
>
|
||||
{t('sync.title')}
|
||||
</Text>
|
||||
<View className={`overflow-hidden rounded-xl ${isDark ? 'bg-[#2A2A2A]' : 'bg-white'}`}>
|
||||
{!userId ? (
|
||||
<Pressable
|
||||
onPress={handleSignIn}
|
||||
className={`flex-row items-center px-4 py-3.5`}
|
||||
>
|
||||
<LogIn size={20} color={colors.bleu.DEFAULT} />
|
||||
<Text
|
||||
className="ml-3 text-base text-bleu"
|
||||
style={{ fontFamily: 'Inter_500Medium' }}
|
||||
>
|
||||
{t('sync.signIn')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : (
|
||||
<>
|
||||
{/* Connected user */}
|
||||
<View className={`px-4 py-3.5 border-b ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}>
|
||||
<View className="flex-row items-center">
|
||||
<Cloud size={20} color={colors.bleu.DEFAULT} />
|
||||
<Text
|
||||
className={`ml-3 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
|
||||
style={{ fontFamily: 'Inter_500Medium' }}
|
||||
>
|
||||
{t('sync.connectedAs', { userId })}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
className={`mt-1 ml-8 text-xs ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
|
||||
style={{ fontFamily: 'Inter_400Regular' }}
|
||||
>
|
||||
{lastSyncAt
|
||||
? t('sync.lastSync', { date: new Date(lastSyncAt).toLocaleString() })
|
||||
: t('sync.never')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Sync now button */}
|
||||
<Pressable
|
||||
onPress={handleSyncNow}
|
||||
disabled={isSyncing}
|
||||
className={`flex-row items-center border-b px-4 py-3.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}
|
||||
>
|
||||
{isSyncing ? (
|
||||
<ActivityIndicator size={20} color={colors.bleu.DEFAULT} />
|
||||
) : (
|
||||
<RefreshCw size={20} color={colors.bleu.DEFAULT} />
|
||||
)}
|
||||
<Text
|
||||
className="ml-3 text-base text-bleu"
|
||||
style={{ fontFamily: 'Inter_500Medium' }}
|
||||
>
|
||||
{isSyncing ? t('sync.syncing') : t('sync.syncNow')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Sign out */}
|
||||
<Pressable
|
||||
onPress={handleSignOut}
|
||||
className="flex-row items-center px-4 py-3.5"
|
||||
>
|
||||
<LogOut size={20} color={colors.terracotta.DEFAULT} />
|
||||
<Text
|
||||
className="ml-3 text-base text-terracotta"
|
||||
style={{ fontFamily: 'Inter_500Medium' }}
|
||||
>
|
||||
{t('sync.signOut')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Widget Section */}
|
||||
<View className="px-4 pt-6">
|
||||
<Text
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useColorScheme } from 'react-native';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useColorScheme, AppState, type AppStateStatus } from 'react-native';
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import { Stack } from 'expo-router';
|
||||
import { useFonts, Inter_400Regular, Inter_500Medium, Inter_600SemiBold, Inter_700Bold } from '@expo-google-fonts/inter';
|
||||
|
|
@ -14,6 +14,7 @@ import { ensureInbox } from '@/src/db/repository/lists';
|
|||
import { useSettingsStore } from '@/src/stores/useSettingsStore';
|
||||
import { initNotifications } from '@/src/services/notifications';
|
||||
import { syncWidgetData } from '@/src/services/widgetSync';
|
||||
import { fullSync, cleanOutbox } from '@/src/services/syncClient';
|
||||
import '@/src/i18n';
|
||||
import '@/src/global.css';
|
||||
|
||||
|
|
@ -57,7 +58,9 @@ export default function RootLayout() {
|
|||
|
||||
const systemScheme = useColorScheme();
|
||||
const theme = useSettingsStore((s) => 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;
|
||||
}
|
||||
|
|
|
|||
9
src/db/migrations/0004_nosy_human_torch.sql
Normal file
9
src/db/migrations/0004_nosy_human_torch.sql
Normal file
|
|
@ -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
|
||||
);
|
||||
375
src/db/migrations/meta/0004_snapshot.json
Normal file
375
src/db/migrations/meta/0004_snapshot.json
Normal file
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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(() => {});
|
||||
}
|
||||
|
|
|
|||
31
src/db/repository/outbox.ts
Normal file
31
src/db/repository/outbox.ts
Normal file
|
|
@ -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<string, unknown>
|
||||
): Promise<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(() => {});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
270
src/services/syncClient.ts
Normal file
270
src/services/syncClient.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface SyncPullResponse {
|
||||
changes: SyncPullChange[];
|
||||
sync_token: string;
|
||||
}
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await db.delete(syncOutbox).where(not(isNull(syncOutbox.syncedAt)));
|
||||
}
|
||||
|
|
@ -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<SettingsState>()(
|
||||
|
|
@ -28,12 +34,18 @@ export const useSettingsStore = create<SettingsState>()(
|
|||
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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue