Compare commits

..

No commits in common. "8462aa9ef44f0f615aca2033098e5112fa664bef" and "b7a090df7116073e9e95ce69ec91d142fd917881" have entirely different histories.

15 changed files with 4 additions and 949 deletions

View file

@ -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, Cloud, LogIn, LogOut } from 'lucide-react-native';
import { Sun, Moon, Smartphone, Plus, Trash2, Pencil, Bell, CalendarDays, LayoutGrid, Mail, RefreshCw } from 'lucide-react-native';
import Constants from 'expo-constants';
import { colors } from '@/src/theme/colors';
@ -10,7 +10,6 @@ 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';
@ -26,9 +25,6 @@ export default function SettingsScreen() {
reminderOffset, setReminderOffset,
calendarSyncEnabled, setCalendarSyncEnabled,
widgetPeriodWeeks, setWidgetPeriodWeeks,
syncEnabled, setSyncEnabled,
lastSyncAt, setLastSyncAt,
userId, setUserId,
} = useSettingsStore();
const isDark = (theme === 'system' ? systemScheme : theme) === 'dark';
@ -38,7 +34,6 @@ 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();
@ -99,40 +94,6 @@ 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 {
@ -340,88 +301,6 @@ 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

View file

@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react';
import { useColorScheme, AppState, type AppStateStatus } from 'react-native';
import { useEffect } from 'react';
import { useColorScheme } 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,7 +14,6 @@ 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';
@ -58,9 +57,7 @@ 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;
@ -77,32 +74,6 @@ 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;
}

View file

@ -1,9 +0,0 @@
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
);

View file

@ -1,375 +0,0 @@
{
"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": {}
}
}

View file

@ -29,13 +29,6 @@
"when": 1775486221676,
"tag": "0003_sharp_radioactive_man",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1775493830127,
"tag": "0004_nosy_human_torch",
"breakpoints": true
}
]
}

View file

@ -5,7 +5,6 @@ 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,
@ -13,8 +12,7 @@ import m0004 from './0004_nosy_human_torch.sql';
m0000,
m0001,
m0002,
m0003,
m0004
m0003
}
}

View file

@ -3,7 +3,6 @@ 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';
@ -47,16 +46,6 @@ 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;
}
@ -67,8 +56,6 @@ 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 }[]) {
@ -81,5 +68,4 @@ 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(() => {});
}

View file

@ -1,31 +0,0 @@
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,
});
}

View file

@ -3,7 +3,6 @@ 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);
@ -19,21 +18,16 @@ 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) {
@ -52,7 +46,6 @@ 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) {

View file

@ -11,7 +11,6 @@ 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';
@ -171,18 +170,6 @@ 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;
@ -248,21 +235,6 @@ 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(() => {});
}
@ -331,15 +303,11 @@ 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(() => {});
}

View file

@ -36,16 +36,6 @@ 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',
{

View file

@ -131,19 +131,6 @@
"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",

View file

@ -131,19 +131,6 @@
"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",

View file

@ -1,270 +0,0 @@
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)));
}

View file

@ -11,18 +11,12 @@ 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>()(
@ -34,18 +28,12 @@ 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',