Compare commits

..

No commits in common. "master" and "fix/simpl-liste-26-vulnerable-deps" have entirely different histories.

115 changed files with 285 additions and 14930 deletions

View file

@ -1,28 +0,0 @@
---
name: eas-build
description: Build APK via EAS and create Forgejo release
user-invocable: true
---
# /eas-build — Build APK Simpl-Liste
## Context injection
1. Lire `app.json``expo.android.versionCode` et `expo.version`
2. Lire `eas.json` → profils disponibles
## Workflow
1. Lire le `versionCode` actuel dans `app.json`
2. Incrementer `versionCode` (+1) — doit etre strictement superieur
3. Si demande par l'utilisateur : bumper `version` dans `app.json` + `package.json`
4. Commit : `chore: bump versionCode to <N>`
5. Build : `npx --yes eas-cli build --platform android --profile preview --non-interactive`
6. Quand le build est termine : creer une release Forgejo avec le lien APK
## Regles
- `versionCode` doit etre **strictement superieur** a la valeur precedente
- `autoIncrement` dans eas.json ne s'applique qu'au profil `production`, pas `preview`
- Toujours utiliser `npx --yes eas-cli` (pas d'install globale)
- Ne JAMAIS `git push --tags` — push les tags un par un si necessaire

View file

@ -129,17 +129,17 @@ Couleurs sombres : fond `#1A1A1A`, surface `#2A2A2A`, bordure `#3A3A3A`, texte `
- **SimplListeLarge** (4×4) — Liste de 8 tâches
### Sync des données
- `widgetSync.ts` lit les tâches depuis SQLite et les cache dans AsyncStorage (`widget:state`)
- Le thème est lu depuis AsyncStorage (`simpl-liste-settings` → `state.theme`), résolu si `system` via `Appearance.getColorScheme()`
- `widgetTaskHandler.ts` gère le rendu headless (quand l'app n'est pas ouverte) en lisant la clé consolidée `widget:state`
- `widgetSync.ts` lit les tâches depuis SQLite et les cache dans AsyncStorage (`widget:tasks`)
- Le thème est lu depuis AsyncStorage (`simpl-liste-settings` → `state.theme`), résolu si `system` via `Appearance.getColorScheme()`, et stocké dans `widget:isDark`
- `widgetTaskHandler.ts` gère le rendu headless (quand l'app n'est pas ouverte) en lisant les deux clés AsyncStorage
- Les couleurs du widget suivent la même palette que l'app (voir `LIGHT_COLORS` / `DARK_COLORS` dans `TaskListWidget.tsx`)
- Un debounce de 2s sur `TOGGLE_EXPAND` empêche les double-taps d'annuler l'expansion
### Clés AsyncStorage utilisées par le widget
| Clé | Contenu |
|-----|---------|
| `widget:state` | `WidgetState` sérialisé JSON (tasks, isDark, expandedTaskIds) |
| `simpl-liste-settings` | Store Zustand persisté (contient `state.theme`, `state.widgetPeriodWeeks`) |
| `widget:tasks` | `WidgetTask[]` sérialisé JSON |
| `widget:isDark` | `boolean` sérialisé JSON |
| `simpl-liste-settings` | Store Zustand persisté (contient `state.theme`) |
## Build & déploiement
@ -160,28 +160,16 @@ npx eas-cli build --platform android --profile production --non-interactive # AA
### Processus de release
1. Bumper `version` dans `app.json` ET `package.json`
2. **Bumper `android.versionCode` dans `app.json`** — doit être **strictement supérieur** au versionCode du dernier build publié. Android refuse d'installer un APK avec un versionCode égal ou inférieur. Vérifier le dernier versionCode avec :
2. Le `versionCode` Android est auto-incrémenté par EAS (`autoIncrement: true`)
3. Build preview (APK) + production (AAB)
4. Créer la release sur Forgejo via API :
```bash
npx --yes eas-cli build:list --platform android --limit 1 --json 2>/dev/null | jq '.[0].appBuildVersion'
```
⚠️ `autoIncrement: true` dans eas.json ne s'applique qu'au profil `production`. Pour le profil `preview` (APK), le versionCode vient directement de `app.json` — il faut le mettre à jour manuellement.
3. Commit le bump de version, tag, push
4. Build preview (APK) :
```bash
npx --yes eas-cli build --platform android --profile preview --non-interactive
```
5. Télécharger l'APK et créer la release sur Forgejo :
```bash
# Récupérer l'URL de l'APK
npx --yes eas-cli build:list --platform android --limit 1 --json 2>/dev/null | jq -r '.[0].artifacts.buildUrl'
# Télécharger
curl -L -o simpl-liste-vX.Y.Z.apk "<url>"
# Créer la release
curl -X POST ".../api/v1/repos/maximus/simpl-liste/releases" -d '{"tag_name":"vX.Y.Z",...}'
# Attacher l'APK
curl -X POST ".../releases/{id}/assets?name=simpl-liste-vX.Y.Z.apk" -F "attachment=@fichier.apk"
```
6. Le bouton « Vérifier les mises à jour » dans l'app utilise l'endpoint `/releases/latest` et propose le téléchargement de l'asset `.apk`
5. Le bouton « Vérifier les mises à jour » dans l'app utilise l'endpoint `/releases/latest` et propose le téléchargement de l'asset `.apk`
### Repo Forgejo

View file

@ -2,7 +2,7 @@
"expo": {
"name": "Simpl-Liste",
"slug": "simpl-liste",
"version": "1.6.1",
"version": "1.3.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "simplliste",
@ -24,7 +24,7 @@
"backgroundColor": "#FFF8F0"
},
"edgeToEdgeEnabled": true,
"versionCode": 13
"versionCode": 6
},
"plugins": [
"expo-router",
@ -74,9 +74,7 @@
}
]
}
],
"expo-secure-store",
"expo-web-browser"
]
],
"experiments": {
"typedRoutes": true

View file

@ -1,7 +1,7 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { View, Text, Pressable, TextInput, useColorScheme, Alert, Animated, Easing } from 'react-native';
import { View, Text, Pressable, TextInput, useColorScheme, Alert } from 'react-native';
import { useRouter } from 'expo-router';
import { Plus, ArrowUpDown, Filter, Download, Search, X, RefreshCw } from 'lucide-react-native';
import { Plus, ArrowUpDown, Filter, Download, Search, X } from 'lucide-react-native';
import { useTranslation } from 'react-i18next';
import * as Haptics from 'expo-haptics';
import DraggableFlatList, { RenderItemParams } from 'react-native-draggable-flatlist';
@ -44,8 +44,6 @@ export default function InboxScreen() {
const theme = useSettingsStore((s) => s.theme);
const isDark = (theme === 'system' ? systemScheme : theme) === 'dark';
const isDraggingRef = useRef(false);
const [refreshing, setRefreshing] = useState(false);
const spinAnim = useRef(new Animated.Value(0)).current;
const { sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate, hasActiveFilters } = useTaskStore();
@ -72,31 +70,6 @@ export default function InboxScreen() {
return () => clearInterval(interval);
}, [loadTasks]);
const handleRefresh = useCallback(async () => {
if (refreshing) return;
setRefreshing(true);
spinAnim.setValue(0);
Animated.loop(
Animated.timing(spinAnim, {
toValue: 1,
duration: 800,
easing: Easing.linear,
useNativeDriver: true,
})
).start();
try {
await loadTasks();
} finally {
setRefreshing(false);
spinAnim.stopAnimation();
}
}, [loadTasks, refreshing, spinAnim]);
const spin = spinAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg'],
});
const handleToggle = async (id: string) => {
await toggleComplete(id);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
@ -191,11 +164,6 @@ export default function InboxScreen() {
</View>
) : (
<View className={`flex-row items-center justify-end border-b px-4 py-2 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}>
<Pressable onPress={handleRefresh} disabled={refreshing} className="mr-3 p-1">
<Animated.View style={{ transform: [{ rotate: spin }] }}>
<RefreshCw size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</Animated.View>
</Pressable>
<Pressable onPress={() => setShowSearch(true)} className="mr-3 p-1">
<Search size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</Pressable>

View file

@ -2,17 +2,14 @@ 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 { useLogto } from '@logto/rn';
import { colors } from '@/src/theme/colors';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
import { redirectUri, postSignOutRedirectUri } from '@/src/lib/logtoConfig';
import { getAllTags, createTag, updateTag, deleteTag } from '@/src/db/repository/tags';
import { initCalendar } from '@/src/services/calendar';
import { syncWidgetData } from '@/src/services/widgetSync';
import { fullSync, initialMerge, initialReset } from '@/src/services/syncClient';
import i18n from '@/src/i18n';
type ThemeMode = 'light' | 'dark' | 'system';
@ -28,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';
@ -40,8 +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 { signIn: logtoSignIn, signOut: logtoSignOut, getIdTokenClaims, isAuthenticated } = useLogto();
const loadTags = useCallback(async () => {
const result = await getAllTags();
@ -102,125 +94,6 @@ export default function SettingsScreen() {
]);
};
const handleSignIn = async () => {
try {
await logtoSignIn({ redirectUri });
const claims = await getIdTokenClaims();
setUserId(claims.sub);
setSyncEnabled(true);
// First sync: show merge/reset choice
Alert.alert(
t('sync.firstSyncTitle'),
t('sync.firstSyncMessage'),
[
{
text: t('sync.mergeLocal'),
onPress: async () => {
setIsSyncing(true);
try {
await initialMerge();
Alert.alert(t('sync.firstSyncTitle'), t('sync.mergeDone'));
} catch {
Alert.alert(t('sync.syncError'));
} finally {
setIsSyncing(false);
}
},
},
{
text: t('sync.resetFromServer'),
style: 'destructive',
onPress: async () => {
setIsSyncing(true);
try {
await initialReset();
Alert.alert(t('sync.firstSyncTitle'), t('sync.resetDone'));
} catch {
Alert.alert(t('sync.syncError'));
} finally {
setIsSyncing(false);
}
},
},
],
);
} catch (err) {
console.warn('[auth] sign-in error:', err);
}
};
const handleSignOut = () => {
Alert.alert(t('sync.signOutConfirm'), '', [
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('sync.signOut'),
style: 'destructive',
onPress: async () => {
try {
await logtoSignOut(postSignOutRedirectUri);
} catch {
// Sign-out may fail if session expired, that's OK
}
setSyncEnabled(false);
setUserId(null);
setLastSyncAt(null);
},
},
]);
};
const handleSyncNow = async () => {
// If never synced before, show first-sync choice
if (!lastSyncAt) {
Alert.alert(
t('sync.firstSyncTitle'),
t('sync.firstSyncMessage'),
[
{
text: t('sync.mergeLocal'),
onPress: async () => {
setIsSyncing(true);
try {
await initialMerge();
Alert.alert(t('sync.firstSyncTitle'), t('sync.mergeDone'));
} catch {
Alert.alert(t('sync.syncError'));
} finally {
setIsSyncing(false);
}
},
},
{
text: t('sync.resetFromServer'),
style: 'destructive',
onPress: async () => {
setIsSyncing(true);
try {
await initialReset();
Alert.alert(t('sync.firstSyncTitle'), t('sync.resetDone'));
} catch {
Alert.alert(t('sync.syncError'));
} finally {
setIsSyncing(false);
}
},
},
],
);
return;
}
setIsSyncing(true);
try {
await fullSync();
} catch {
// Sync errors are logged internally
} finally {
setIsSyncing(false);
}
};
const handleCheckUpdate = async () => {
setCheckingUpdate(true);
try {
@ -428,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';
@ -8,16 +8,12 @@ import { useMigrations } from 'drizzle-orm/expo-sqlite/migrator';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { KeyboardProvider } from 'react-native-keyboard-controller';
import { LogtoProvider, useLogto } from '@logto/rn';
import { db } from '@/src/db/client';
import migrations from '@/src/db/migrations/migrations';
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 { logtoConfig } from '@/src/lib/logtoConfig';
import { setTokenGetter, clearTokenGetter } from '@/src/lib/authToken';
import '@/src/i18n';
import '@/src/global.css';
@ -59,6 +55,10 @@ export default function RootLayout() {
const { success: migrationsReady, error: migrationError } = useMigrations(db, migrations);
const systemScheme = useColorScheme();
const theme = useSettingsStore((s) => s.theme);
const effectiveScheme = theme === 'system' ? systemScheme : theme;
useEffect(() => {
if (fontError) throw fontError;
if (migrationError) throw migrationError;
@ -78,57 +78,6 @@ export default function RootLayout() {
return null;
}
return (
<LogtoProvider config={logtoConfig}>
<AppContent />
</LogtoProvider>
);
}
function AppContent() {
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);
const { getAccessToken, isAuthenticated } = useLogto();
// Register the token getter for syncClient when authenticated
useEffect(() => {
if (isAuthenticated && syncEnabled) {
setTokenGetter(getAccessToken);
} else {
clearTokenGetter();
}
return () => clearTokenGetter();
}, [isAuthenticated, syncEnabled, getAccessToken]);
// Sync polling: run on launch, every 2 min, and on return from background
useEffect(() => {
if (!syncEnabled) 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]);
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider>

View file

@ -1,8 +1,8 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { View, Text, Pressable, TextInput, useColorScheme, Alert, Animated, Easing } from 'react-native';
import { View, Text, Pressable, TextInput, useColorScheme, Alert } from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import {
ArrowLeft, Plus, ArrowUpDown, Filter, Download, Search, X, RefreshCw,
ArrowLeft, Plus, ArrowUpDown, Filter, Download, Search, X,
List, ShoppingCart, Briefcase, Home, Heart, Star, BookOpen,
GraduationCap, Dumbbell, Utensils, Plane, Music, Code, Wrench,
Gift, Camera, Palette, Dog, Leaf, Zap,
@ -61,8 +61,6 @@ export default function ListDetailScreen() {
const theme = useSettingsStore((s) => s.theme);
const isDark = (theme === 'system' ? systemScheme : theme) === 'dark';
const isDraggingRef = useRef(false);
const [refreshing, setRefreshing] = useState(false);
const spinAnim = useRef(new Animated.Value(0)).current;
const { sortBy, sortOrder, filterPriority, filterTag, filterCompleted, filterDueDate, hasActiveFilters } = useTaskStore();
@ -97,31 +95,6 @@ export default function ListDetailScreen() {
return () => clearInterval(interval);
}, [loadData]);
const handleRefresh = useCallback(async () => {
if (refreshing) return;
setRefreshing(true);
spinAnim.setValue(0);
Animated.loop(
Animated.timing(spinAnim, {
toValue: 1,
duration: 800,
easing: Easing.linear,
useNativeDriver: true,
})
).start();
try {
await loadData();
} finally {
setRefreshing(false);
spinAnim.stopAnimation();
}
}, [loadData, refreshing, spinAnim]);
const spin = spinAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg'],
});
const handleToggle = async (taskId: string) => {
await toggleComplete(taskId);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
@ -219,11 +192,6 @@ export default function ListDetailScreen() {
</Text>
</View>
<View className="flex-row items-center">
<Pressable onPress={handleRefresh} disabled={refreshing} className="mr-3 p-1">
<Animated.View style={{ transform: [{ rotate: spin }] }}>
<RefreshCw size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</Animated.View>
</Pressable>
<Pressable onPress={() => setShowSearch(true)} className="mr-3 p-1">
<Search size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
</Pressable>

View file

@ -54,7 +54,6 @@ type TaskData = {
priority: number;
dueDate: Date | null;
listId: string;
parentId: string | null;
recurrence: string | null;
};
@ -77,8 +76,6 @@ export default function TaskDetailScreen() {
const [recurrence, setRecurrence] = useState<string | null>(null);
const [subtasks, setSubtasks] = useState<SubtaskData[]>([]);
const [newSubtask, setNewSubtask] = useState('');
const [editingSubtaskId, setEditingSubtaskId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState('');
const [availableTags, setAvailableTags] = useState<{ id: string; name: string; color: string }[]>([]);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const [lists, setLists] = useState<{ id: string; name: string; color: string | null; icon: string | null; isInbox: boolean }[]>([]);
@ -165,38 +162,6 @@ export default function TaskDetailScreen() {
loadSubtasks();
};
const handleEditSubtask = (sub: SubtaskData) => {
setEditingSubtaskId(sub.id);
setEditingTitle(sub.title);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
};
const handleSaveSubtaskEdit = async () => {
if (!editingSubtaskId) return;
const trimmed = editingTitle.trim();
if (trimmed) {
await updateTask(editingSubtaskId, { title: trimmed });
loadSubtasks();
}
setEditingSubtaskId(null);
setEditingTitle('');
};
const handleDeleteSubtask = (subtaskId: string) => {
Alert.alert(t('task.deleteSubtaskConfirm'), '', [
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('common.delete'),
style: 'destructive',
onPress: async () => {
await deleteTask(subtaskId);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
loadSubtasks();
},
},
]);
};
const handleDateChange = (_: DateTimePickerEvent, date?: Date) => {
setShowDatePicker(Platform.OS === 'ios');
if (date) setDueDate(date);
@ -401,71 +366,47 @@ export default function TaskDetailScreen() {
</>
)}
{/* Subtasks — only for root tasks (not subtasks themselves) */}
{!task?.parentId && (
<>
<Text className={`mb-2 mt-6 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`} style={{ fontFamily: 'Inter_600SemiBold' }}>
{t('task.subtasks')}
</Text>
{subtasks.map((sub) => (
<Pressable
key={sub.id}
onPress={() => editingSubtaskId === sub.id ? undefined : handleToggleSubtask(sub.id)}
onLongPress={() => handleEditSubtask(sub)}
className={`flex-row items-center border-b py-2.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}
>
<View
className="mr-3 h-5 w-5 items-center justify-center rounded-full border-2"
style={{
borderColor: sub.completed ? colors.bleu.DEFAULT : colors.priority.none,
backgroundColor: sub.completed ? colors.bleu.DEFAULT : 'transparent',
}}
>
{sub.completed && <Text className="text-xs text-white" style={{ fontFamily: 'Inter_700Bold' }}></Text>}
</View>
{editingSubtaskId === sub.id ? (
<TextInput
value={editingTitle}
onChangeText={setEditingTitle}
onSubmitEditing={handleSaveSubtaskEdit}
onBlur={handleSaveSubtaskEdit}
autoFocus
className={`flex-1 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
/>
) : (
<Text
className={`flex-1 text-base ${sub.completed ? 'line-through ' + (isDark ? 'text-[#A0A0A0]' : 'text-[#9CA3AF]') : isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{sub.title}
</Text>
)}
<Pressable
onPress={() => handleDeleteSubtask(sub.id)}
className="ml-2 p-1.5"
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<X size={16} color={isDark ? '#A0A0A0' : '#9CA3AF'} />
</Pressable>
</Pressable>
))}
{/* Add subtask */}
<View className="mt-2 flex-row items-center">
<Plus size={18} color={colors.bleu.DEFAULT} />
<TextInput
value={newSubtask}
onChangeText={setNewSubtask}
onSubmitEditing={handleAddSubtask}
placeholder={t('task.addSubtask')}
placeholderTextColor={isDark ? '#A0A0A0' : '#6B6B6B'}
className={`ml-2 flex-1 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
/>
{/* Subtasks */}
<Text className={`mb-2 mt-6 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`} style={{ fontFamily: 'Inter_600SemiBold' }}>
{t('task.subtasks')}
</Text>
{subtasks.map((sub) => (
<Pressable
key={sub.id}
onPress={() => handleToggleSubtask(sub.id)}
className={`flex-row items-center border-b py-2.5 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}
>
<View
className="mr-3 h-5 w-5 items-center justify-center rounded-full border-2"
style={{
borderColor: sub.completed ? colors.bleu.DEFAULT : colors.priority.none,
backgroundColor: sub.completed ? colors.bleu.DEFAULT : 'transparent',
}}
>
{sub.completed && <Text className="text-xs text-white" style={{ fontFamily: 'Inter_700Bold' }}></Text>}
</View>
</>
)}
<Text
className={`text-base ${sub.completed ? 'line-through ' + (isDark ? 'text-[#A0A0A0]' : 'text-[#9CA3AF]') : isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
>
{sub.title}
</Text>
</Pressable>
))}
{/* Add subtask */}
<View className="mt-2 flex-row items-center">
<Plus size={18} color={colors.bleu.DEFAULT} />
<TextInput
value={newSubtask}
onChangeText={setNewSubtask}
onSubmitEditing={handleAddSubtask}
placeholder={t('task.addSubtask')}
placeholderTextColor={isDark ? '#A0A0A0' : '#6B6B6B'}
className={`ml-2 flex-1 text-base ${isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: 'Inter_400Regular' }}
/>
</View>
<View style={{ height: 32 }} />
</KeyboardAwareScrollView>

186
package-lock.json generated
View file

@ -1,24 +1,22 @@
{
"name": "simpl-liste",
"version": "1.5.1",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "simpl-liste",
"version": "1.5.1",
"version": "1.3.0",
"dependencies": {
"@expo-google-fonts/inter": "^0.4.2",
"@expo/ngrok": "^4.1.3",
"@expo/vector-icons": "^15.0.3",
"@logto/rn": "^1.1.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/datetimepicker": "8.4.4",
"@react-navigation/native": "^7.1.8",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.45.2",
"drizzle-orm": "^0.45.1",
"expo": "~54.0.33",
"expo-auth-session": "~7.0.10",
"expo-calendar": "~15.0.8",
"expo-constants": "~18.0.13",
"expo-crypto": "~15.0.8",
@ -30,7 +28,6 @@
"expo-localization": "~17.0.8",
"expo-notifications": "~0.32.16",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-splash-screen": "~31.0.13",
"expo-sqlite": "~16.0.10",
@ -2918,47 +2915,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@logto/client": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@logto/client/-/client-3.1.2.tgz",
"integrity": "sha512-HRu6qO4QYQn5ckO5wHi8On/C4Nsp/5qYDbf6zrFjymSVlJlXmDft+OW/AQ9jdPl1kAgZJIlQzjvpM9YFy/7c6Q==",
"license": "MIT",
"dependencies": {
"@logto/js": "^5.1.1",
"@silverhand/essentials": "^2.9.2",
"camelcase-keys": "^9.1.3",
"jose": "^5.2.2"
}
},
"node_modules/@logto/js": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@logto/js/-/js-5.1.1.tgz",
"integrity": "sha512-HMK9AFQ+mzJQ2WuKrJJ2apjoTjGbbu45vIhAl31t0JbSi++3IcPp3/oIhsS+VJ7AOs8x5P+fjWJO2AIwhQe3Vg==",
"license": "MIT",
"dependencies": {
"@silverhand/essentials": "^2.9.2",
"camelcase-keys": "^9.1.3"
}
},
"node_modules/@logto/rn": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@logto/rn/-/rn-1.1.0.tgz",
"integrity": "sha512-GcB6gGrjBASrTy4FsyJWCgYHaCjl2Tl/6CL+OZfU9Vro7meyfrW2+bHBOi7aKeXl+tLNqUybHoFcv+sVvUObxw==",
"license": "MIT",
"dependencies": {
"@logto/client": "3.1.2",
"@logto/js": "5.1.1",
"crypto-es": "^2.1.0",
"js-base64": "^3.7.7"
},
"peerDependencies": {
"@react-native-async-storage/async-storage": ">=1.23.1 <3",
"expo-crypto": ">=14.0.2 <16",
"expo-secure-store": ">=14.0.1 <16",
"expo-web-browser": ">=14.0.2 <16",
"react-native": ">=0.76.0 <1"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -3572,16 +3528,6 @@
"nanoid": "^3.3.11"
}
},
"node_modules/@silverhand/essentials": {
"version": "2.9.3",
"resolved": "https://registry.npmjs.org/@silverhand/essentials/-/essentials-2.9.3.tgz",
"integrity": "sha512-OM9pyGc/yYJMVQw+fFOZZaTHXDWc45sprj+ky+QjC9inhf5w51L1WBmzAwFuYkHAwO1M19fxVf2sTH9KKP48yg==",
"license": "MIT",
"engines": {
"node": ">=18.12.0",
"pnpm": "^10.0.0"
}
},
"node_modules/@sinclair/typebox": {
"version": "0.27.10",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
@ -3816,9 +3762,9 @@
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.12",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
"integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@ -4552,60 +4498,6 @@
"node": ">= 6"
}
},
"node_modules/camelcase-keys": {
"version": "9.1.3",
"resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-9.1.3.tgz",
"integrity": "sha512-Rircqi9ch8AnZscQcsA1C47NFdaO3wukpmIRzYcDOrmvgt78hM/sj5pZhZNec2NM12uk5vTwRHZ4anGcrC4ZTg==",
"license": "MIT",
"dependencies": {
"camelcase": "^8.0.0",
"map-obj": "5.0.0",
"quick-lru": "^6.1.1",
"type-fest": "^4.3.2"
},
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/camelcase-keys/node_modules/camelcase": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz",
"integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/camelcase-keys/node_modules/quick-lru": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/camelcase-keys/node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001770",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz",
@ -4992,12 +4884,6 @@
"node": ">= 8"
}
},
"node_modules/crypto-es": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/crypto-es/-/crypto-es-2.1.0.tgz",
"integrity": "sha512-C5Dbuv4QTPGuloy5c5Vv/FZHtmK+lobLAypFfuRaBbwCsk3qbCWWESCH3MUcBsrgXloRNMrzwUAiPg4U6+IaKA==",
"license": "MIT"
},
"node_modules/crypto-random-string": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
@ -5375,9 +5261,9 @@
}
},
"node_modules/drizzle-orm": {
"version": "0.45.2",
"resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz",
"integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==",
"version": "0.45.1",
"resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz",
"integrity": "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==",
"license": "Apache-2.0",
"peerDependencies": {
"@aws-sdk/client-rds-data": ">=3",
@ -5804,24 +5690,6 @@
"react-native": "*"
}
},
"node_modules/expo-auth-session": {
"version": "7.0.10",
"resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-7.0.10.tgz",
"integrity": "sha512-XDnKkudvhHSKkZfJ+KkodM+anQcrxB71i+h0kKabdLa5YDXTQ81aC38KRc3TMqmnBDHAu0NpfbzEVd9WDFY3Qg==",
"license": "MIT",
"dependencies": {
"expo-application": "~7.0.8",
"expo-constants": "~18.0.11",
"expo-crypto": "~15.0.8",
"expo-linking": "~8.0.10",
"expo-web-browser": "~15.0.10",
"invariant": "^2.2.4"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-calendar": {
"version": "15.0.8",
"resolved": "https://registry.npmjs.org/expo-calendar/-/expo-calendar-15.0.8.tgz",
@ -6239,15 +6107,6 @@
"node": ">=10"
}
},
"node_modules/expo-secure-store": {
"version": "15.0.8",
"resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz",
"integrity": "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-server": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz",
@ -7668,21 +7527,6 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/jose": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-base64": {
"version": "3.7.8",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
"integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==",
"license": "BSD-3-Clause"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -8223,18 +8067,6 @@
"tmpl": "1.0.5"
}
},
"node_modules/map-obj": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-5.0.0.tgz",
"integrity": "sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/marky": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz",

View file

@ -1,7 +1,7 @@
{
"name": "simpl-liste",
"main": "index.js",
"version": "1.6.1",
"version": "1.3.0",
"scripts": {
"start": "expo start",
"android": "expo start --android",
@ -12,14 +12,12 @@
"@expo-google-fonts/inter": "^0.4.2",
"@expo/ngrok": "^4.1.3",
"@expo/vector-icons": "^15.0.3",
"@logto/rn": "^1.1.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/datetimepicker": "8.4.4",
"@react-navigation/native": "^7.1.8",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.45.2",
"drizzle-orm": "^0.45.1",
"expo": "~54.0.33",
"expo-auth-session": "~7.0.10",
"expo-calendar": "~15.0.8",
"expo-constants": "~18.0.13",
"expo-crypto": "~15.0.8",
@ -31,7 +29,6 @@
"expo-localization": "~17.0.8",
"expo-notifications": "~0.32.16",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-splash-screen": "~31.0.13",
"expo-sqlite": "~16.0.10",

View file

@ -1 +0,0 @@
ALTER TABLE `tags` ADD `updated_at` integer;

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,316 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "d3023632-946c-4fe9-b543-61cdf8af873c",
"prevId": "3b2c3545-d1aa-4879-9654-4c6b58c73dc2",
"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": {}
},
"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

@ -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

@ -22,20 +22,6 @@
"when": 1771639773448,
"tag": "0002_majestic_wendell_rand",
"breakpoints": true
},
{
"idx": 3,
"version": "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

@ -4,17 +4,13 @@ import journal from './meta/_journal.json';
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,
migrations: {
m0000,
m0001,
m0002,
m0003,
m0004
m0002
}
}

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,
isInbox: 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' | 'taskTag';
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);
@ -11,29 +10,22 @@ export async function getAllTags() {
export async function createTag(name: string, color: string) {
const id = randomUUID();
const now = new Date();
await db.insert(tags).values({
id,
name: truncate(name, 100),
color,
createdAt: now,
updatedAt: now,
createdAt: new Date(),
});
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(() => {});
await db.update(tags).set({ name: truncate(name, 100), color }).where(eq(tags.id, id));
}
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,10 +44,6 @@ export async function setTagsForTask(taskId: string, tagIds: string[]) {
tagIds.map((tagId) => ({ taskId, tagId }))
);
}
// Send individual taskTag create operations (server expects entityId=taskId, data.tagId)
for (const tagId of tagIds) {
writeOutboxEntry('taskTag', taskId, 'create', { tagId }).catch(() => {});
}
}
export async function addTagToTask(taskId: string, tagId: string) {

View file

@ -4,16 +4,22 @@ import { tasks, taskTags } from '../schema';
import { randomUUID } from '@/src/lib/uuid';
import { getNextOccurrence, type RecurrenceType } from '@/src/lib/recurrence';
import { startOfDay, endOfDay, endOfWeek, startOfWeek } from 'date-fns';
import type { TaskFilters, SortBy, SortOrder } from '@/src/shared/types';
import type { SortBy, SortOrder, FilterCompleted, FilterDueDate } from '@/src/stores/useTaskStore';
import { scheduleTaskReminder, cancelTaskReminder } from '@/src/services/notifications';
import { addTaskToCalendar, updateCalendarEvent, removeCalendarEvent } from '@/src/services/calendar';
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';
export interface TaskFilters {
sortBy?: SortBy;
sortOrder?: SortOrder;
filterPriority?: number | null;
filterTag?: string | null;
filterCompleted?: FilterCompleted;
filterDueDate?: FilterDueDate;
}
export async function getTasksByList(listId: string, filters?: TaskFilters) {
const conditions = [eq(tasks.listId, listId), isNull(tasks.parentId)];
@ -171,20 +177,6 @@ export async function createTask(data: {
}
}
// Sync outbox
writeOutboxEntry('task', id, 'create', {
id,
title: data.title,
notes: data.notes ?? null,
completed: false,
completedAt: null,
priority: data.priority ?? 0,
dueDate: data.dueDate?.toISOString() ?? null,
listId: data.listId,
parentId: data.parentId ?? null,
recurrence: sanitizedRecurrence,
}).catch(() => {});
syncWidgetData().catch(() => {});
return id;
@ -250,22 +242,6 @@ export async function updateTask(
}
}
// Sync outbox
if (task) {
writeOutboxEntry('task', id, 'update', {
id,
title: task.title,
notes: task.notes,
completed: task.completed,
completedAt: task.completedAt ? new Date(task.completedAt).toISOString() : null,
priority: task.priority,
dueDate: task.dueDate ? new Date(task.dueDate).toISOString() : null,
listId: task.listId,
parentId: task.parentId,
recurrence: task.recurrence,
}).catch(() => {});
}
syncWidgetData().catch(() => {});
}
@ -334,15 +310,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

@ -33,17 +33,6 @@ export const tags = sqliteTable('tags', {
name: text('name').notNull(),
color: text('color').notNull().default('#4A90A4'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
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(

View file

@ -21,7 +21,6 @@
"completed": "Completed",
"newTask": "New task",
"deleteConfirm": "Are you sure you want to delete this task?",
"deleteSubtaskConfirm": "Are you sure you want to delete this subtask?",
"swipeDelete": "Swipe to delete",
"swipeComplete": "Swipe to complete",
"dragHandle": "Hold to reorder"
@ -131,29 +130,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",
"firstSyncTitle": "First sync",
"firstSyncMessage": "You have tasks on this device. What would you like to do?",
"mergeLocal": "Merge my tasks",
"mergeDescription": "Sends your local tasks to the server",
"resetFromServer": "Start from server",
"resetDescription": "Replaces local data with server data",
"merging": "Merging...",
"mergeDone": "Tasks merged successfully!",
"resetDone": "Data synced from server.",
"syncError": "Sync error"
},
"widget": {
"title": "Simpl-Liste",
"taskCount_one": "{{count}} task",

View file

@ -21,7 +21,6 @@
"completed": "Terminée",
"newTask": "Nouvelle tâche",
"deleteConfirm": "Voulez-vous vraiment supprimer cette tâche ?",
"deleteSubtaskConfirm": "Voulez-vous vraiment supprimer cette sous-tâche ?",
"swipeDelete": "Glisser pour supprimer",
"swipeComplete": "Glisser pour compléter",
"dragHandle": "Maintenir pour réordonner"
@ -131,29 +130,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",
"firstSyncTitle": "Première synchronisation",
"firstSyncMessage": "Vous avez des tâches sur cet appareil. Que voulez-vous faire ?",
"mergeLocal": "Fusionner mes tâches",
"mergeDescription": "Envoie vos tâches locales vers le serveur",
"resetFromServer": "Repartir du serveur",
"resetDescription": "Remplace les données locales par celles du serveur",
"merging": "Fusion en cours...",
"mergeDone": "Tâches fusionnées avec succès !",
"resetDone": "Données synchronisées depuis le serveur.",
"syncError": "Erreur de synchronisation"
},
"widget": {
"title": "Simpl-Liste",
"taskCount_one": "{{count}} tâche",

View file

@ -1,23 +0,0 @@
// Holds a reference to the getAccessToken function from @logto/rn.
// Set from the React tree (via LogtoProvider/useLogto), used by syncClient.
type TokenGetter = () => Promise<string>;
let _getAccessToken: TokenGetter | null = null;
export function setTokenGetter(getter: TokenGetter): void {
_getAccessToken = getter;
}
export function clearTokenGetter(): void {
_getAccessToken = null;
}
export async function getAccessToken(): Promise<string | null> {
if (!_getAccessToken) return null;
try {
return await _getAccessToken();
} catch {
return null;
}
}

View file

@ -1,11 +0,0 @@
import type { LogtoNativeConfig } from '@logto/rn';
export const logtoConfig: LogtoNativeConfig = {
endpoint: 'https://auth.lacompagniemaximus.com',
appId: 'sl-mobile-native',
scopes: ['openid', 'profile', 'email'],
};
// Redirect URI uses the app scheme defined in app.json
export const redirectUri = 'simplliste://callback';
export const postSignOutRedirectUri = 'simplliste://';

View file

@ -1 +1,29 @@
export { getPriorityColor, getPriorityOptions } from '@/src/shared/priority';
import { colors } from '@/src/theme/colors';
const lightColors = [
colors.priority.none,
colors.priority.low,
colors.priority.medium,
colors.priority.high,
];
const darkColors = [
colors.priority.noneLight,
colors.priority.lowLight,
colors.priority.mediumLight,
colors.priority.highLight,
];
export function getPriorityColor(priority: number, isDark: boolean): string {
const palette = isDark ? darkColors : lightColors;
return palette[priority] ?? palette[0];
}
export function getPriorityOptions(isDark: boolean) {
return [
{ value: 0, labelKey: 'priority.none', color: getPriorityColor(0, isDark) },
{ value: 1, labelKey: 'priority.low', color: getPriorityColor(1, isDark) },
{ value: 2, labelKey: 'priority.medium', color: getPriorityColor(2, isDark) },
{ value: 3, labelKey: 'priority.high', color: getPriorityColor(3, isDark) },
];
}

View file

@ -1,2 +1,18 @@
export { getNextOccurrence, RECURRENCE_OPTIONS } from '@/src/shared/recurrence';
export type { RecurrenceType } from '@/src/shared/recurrence';
import { addDays, addWeeks, addMonths, addYears } from 'date-fns';
export type RecurrenceType = 'daily' | 'weekly' | 'monthly' | 'yearly';
export const RECURRENCE_OPTIONS: RecurrenceType[] = ['daily', 'weekly', 'monthly', 'yearly'];
export function getNextOccurrence(dueDate: Date, recurrence: RecurrenceType): Date {
switch (recurrence) {
case 'daily':
return addDays(dueDate, 1);
case 'weekly':
return addWeeks(dueDate, 1);
case 'monthly':
return addMonths(dueDate, 1);
case 'yearly':
return addYears(dueDate, 1);
}
}

View file

@ -1,432 +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';
import { getAccessToken } from '@/src/lib/authToken';
import { randomUUID } from '@/src/lib/uuid';
import { syncWidgetData } from '@/src/services/widgetSync';
const SYNC_API_BASE = 'https://liste.lacompagniemaximus.com';
const INBOX_ID = '00000000-0000-0000-0000-000000000001';
interface SyncOperation {
idempotencyKey: string;
entityType: 'list' | 'task' | 'tag' | 'taskTag';
entityId: string;
action: 'create' | 'update' | 'delete';
data?: Record<string, unknown>;
}
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;
}
async function getAuthHeaders(): Promise<Record<string, string>> {
const token = await getAccessToken();
if (!token) return {};
return {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
};
}
/**
* Send a batch of operations to the server sync endpoint.
*/
async function sendOperations(operations: SyncOperation[], headers: Record<string, string>): Promise<boolean> {
const batchSize = 50;
for (let i = 0; i < operations.length; i += batchSize) {
const batch = operations.slice(i, i + batchSize);
try {
const res = await fetch(`${SYNC_API_BASE}/api/sync`, {
method: 'POST',
headers,
body: JSON.stringify({ operations: batch }),
});
if (!res.ok) {
console.warn(`[sync] push failed with status ${res.status}`);
return false;
}
} catch (err) {
console.warn('[sync] push error:', err);
return false;
}
}
return true;
}
/**
* Push unsynced outbox entries to the server.
*/
export async function pushChanges(): Promise<void> {
const headers = await getAuthHeaders();
if (!headers['Authorization']) return;
const unsynced = await db
.select()
.from(syncOutbox)
.where(isNull(syncOutbox.syncedAt));
if (unsynced.length === 0) return;
const operations: SyncOperation[] = unsynced.map((entry) => {
const data = JSON.parse(entry.payload);
return {
idempotencyKey: entry.id,
entityType: entry.entityType as SyncOperation['entityType'],
entityId: entry.entityId,
action: entry.action as SyncOperation['action'],
data,
};
});
const ok = await sendOperations(operations, headers);
if (ok) {
const now = new Date().toISOString();
for (const entry of unsynced) {
await db
.update(syncOutbox)
.set({ syncedAt: now })
.where(eq(syncOutbox.id, entry.id));
}
// Refresh widget after a successful push to reflect the synced state
syncWidgetData().catch(() => {});
}
}
/**
* Pull changes from the server since the last sync timestamp.
*/
export async function pullChanges(since: string): Promise<void> {
const headers = await 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();
let appliedChanges = 0;
for (const change of data.changes) {
try {
await applyChange(change);
appliedChanges++;
} 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);
}
// Refresh widget once after applying all remote changes
if (appliedChanges > 0) {
syncWidgetData().catch(() => {});
}
} 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);
}
}
/**
* First-time sync: merge all local data to server.
* Creates an Inbox on the server, remaps the local hardcoded Inbox ID,
* then pushes all lists, tasks, tags, and task-tag relations.
*/
export async function initialMerge(): Promise<void> {
const headers = await getAuthHeaders();
if (!headers['Authorization']) return;
const operations: SyncOperation[] = [];
// 1. Read all local data
const allLists = await db.select().from(lists);
const allTasks = await db.select().from(tasks);
const allTags = await db.select().from(tags);
const allTaskTags = await db.select().from(taskTags);
// 2. First, create the Inbox on the server with a new UUID
const serverInboxId = randomUUID();
const localInbox = allLists.find((l) => l.id === INBOX_ID);
// Map old inbox ID → new inbox ID for task remapping
const idMap: Record<string, string> = {};
if (localInbox) {
idMap[INBOX_ID] = serverInboxId;
}
// 3. Push lists
for (const list of allLists) {
const newId = idMap[list.id] || list.id;
operations.push({
idempotencyKey: randomUUID(),
entityType: 'list',
entityId: newId,
action: 'create',
data: {
name: list.name,
color: list.color,
icon: list.icon,
position: list.position,
isInbox: list.isInbox,
},
});
}
// 4. Push tasks (remap listId if it pointed to the old inbox)
for (const task of allTasks) {
const remappedListId = idMap[task.listId] || task.listId;
const remappedParentId = task.parentId || undefined;
operations.push({
idempotencyKey: randomUUID(),
entityType: 'task',
entityId: task.id,
action: 'create',
data: {
title: task.title,
notes: task.notes,
completed: task.completed,
priority: task.priority,
dueDate: task.dueDate ? task.dueDate.toISOString() : undefined,
listId: remappedListId,
parentId: remappedParentId,
position: task.position,
recurrence: task.recurrence,
},
});
}
// 5. Push tags
for (const tag of allTags) {
operations.push({
idempotencyKey: randomUUID(),
entityType: 'tag',
entityId: tag.id,
action: 'create',
data: {
name: tag.name,
color: tag.color,
},
});
}
// 6. Push task-tag relations
for (const tt of allTaskTags) {
operations.push({
idempotencyKey: randomUUID(),
entityType: 'taskTag',
entityId: tt.taskId,
action: 'create',
data: { tagId: tt.tagId },
});
}
// 7. Send to server
const ok = await sendOperations(operations, headers);
if (!ok) {
throw new Error('Failed to push local data to server');
}
// 8. Remap local Inbox ID to match the server
if (localInbox) {
// Update all tasks pointing to the old inbox
await db.update(tasks).set({ listId: serverInboxId }).where(eq(tasks.listId, INBOX_ID));
// Delete old inbox and insert with new ID
await db.delete(lists).where(eq(lists.id, INBOX_ID));
await db.insert(lists).values({
...localInbox,
id: serverInboxId,
updatedAt: new Date(),
});
}
// 9. Mark sync timestamp
useSettingsStore.getState().setLastSyncAt(new Date().toISOString());
}
/**
* First-time sync: discard local data and pull everything from server.
*/
export async function initialReset(): Promise<void> {
const headers = await getAuthHeaders();
if (!headers['Authorization']) return;
// 1. Delete all local data
await db.delete(taskTags);
await db.delete(tasks);
await db.delete(tags);
await db.delete(lists);
await db.delete(syncOutbox);
// 2. Pull everything from server
await pullChanges('1970-01-01T00:00:00.000Z');
// 3. Ensure we have a local inbox (the server may have created one)
const serverLists = await db.select().from(lists);
const hasInbox = serverLists.some((l) => l.isInbox);
if (!hasInbox) {
// Import ensureInbox dynamically to avoid circular deps
const { ensureInbox } = await import('@/src/db/repository/lists');
await ensureInbox();
}
}
/**
* 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

@ -7,13 +7,8 @@ import { eq, and, isNull, gte, lte, lt, asc, sql } from 'drizzle-orm';
import { startOfDay, endOfDay, addWeeks } from 'date-fns';
import { TaskListWidget } from '../widgets/TaskListWidget';
export const WIDGET_STATE_KEY = 'widget:state';
export const WIDGET_NAMES = ['SimplListeSmall', 'SimplListeMedium', 'SimplListeLarge'] as const;
// Legacy keys — used for migration only
const LEGACY_DATA_KEY = 'widget:tasks';
const LEGACY_DARK_KEY = 'widget:isDark';
const LEGACY_EXPANDED_KEY = 'widget:expandedTaskIds';
export const WIDGET_DATA_KEY = 'widget:tasks';
export const WIDGET_DARK_KEY = 'widget:isDark';
export interface WidgetSubtask {
id: string;
@ -33,51 +28,6 @@ export interface WidgetTask {
subtasks: WidgetSubtask[];
}
export interface WidgetState {
tasks: WidgetTask[];
isDark: boolean;
expandedTaskIds: string[];
}
export async function getWidgetState(): Promise<WidgetState> {
try {
const raw = await AsyncStorage.getItem(WIDGET_STATE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
return {
tasks: Array.isArray(parsed.tasks) ? parsed.tasks : [],
isDark: parsed.isDark === true,
expandedTaskIds: Array.isArray(parsed.expandedTaskIds) ? parsed.expandedTaskIds : [],
};
}
// Migration from legacy keys
const [dataRaw, darkRaw, expandedRaw] = await Promise.all([
AsyncStorage.getItem(LEGACY_DATA_KEY),
AsyncStorage.getItem(LEGACY_DARK_KEY),
AsyncStorage.getItem(LEGACY_EXPANDED_KEY),
]);
const state: WidgetState = {
tasks: dataRaw ? JSON.parse(dataRaw) : [],
isDark: darkRaw ? JSON.parse(darkRaw) === true : false,
expandedTaskIds: expandedRaw ? JSON.parse(expandedRaw) : [],
};
// Write consolidated key and clean up legacy keys
await AsyncStorage.setItem(WIDGET_STATE_KEY, JSON.stringify(state));
await AsyncStorage.multiRemove([LEGACY_DATA_KEY, LEGACY_DARK_KEY, LEGACY_EXPANDED_KEY]);
return state;
} catch {
return { tasks: [], isDark: false, expandedTaskIds: [] };
}
}
export async function setWidgetState(state: WidgetState): Promise<void> {
await AsyncStorage.setItem(WIDGET_STATE_KEY, JSON.stringify(state));
}
export async function syncWidgetData(): Promise<void> {
if (Platform.OS !== 'android') return;
@ -85,7 +35,8 @@ export async function syncWidgetData(): Promise<void> {
const now = new Date();
const todayStart = startOfDay(now);
// Read widget period setting from AsyncStorage
// Read widget period setting from AsyncStorage (0 = all, N = N weeks ahead)
// Coupled with useSettingsStore.ts — key 'simpl-liste-settings', path state.widgetPeriodWeeks
let widgetPeriodWeeks = 0;
try {
const settingsRaw = await AsyncStorage.getItem('simpl-liste-settings');
@ -202,20 +153,23 @@ export async function syncWidgetData(): Promise<void> {
// Default to light
}
// Read existing expanded state to preserve it
await AsyncStorage.setItem(WIDGET_DATA_KEY, JSON.stringify(allTasks));
await AsyncStorage.setItem(WIDGET_DARK_KEY, JSON.stringify(isDark));
// Read expanded state
let expandedTaskIds: string[] = [];
try {
const existing = await getWidgetState();
expandedTaskIds = existing.expandedTaskIds;
const expandedRaw = await AsyncStorage.getItem('widget:expandedTaskIds');
if (expandedRaw) {
const parsed = JSON.parse(expandedRaw);
if (Array.isArray(parsed)) expandedTaskIds = parsed;
}
} catch {
// Default to none expanded
}
const state: WidgetState = { tasks: allTasks, isDark, expandedTaskIds };
await setWidgetState(state);
// Request widget update for all 3 sizes
const widgetNames = WIDGET_NAMES;
const widgetNames = ['SimplListeSmall', 'SimplListeMedium', 'SimplListeLarge'];
for (const widgetName of widgetNames) {
try {
await requestWidgetUpdate({

View file

@ -1,40 +0,0 @@
export const colors = {
bleu: {
DEFAULT: '#4A90A4',
light: '#6BAEC2',
dark: '#3A7389',
},
creme: {
DEFAULT: '#FFF8F0',
dark: '#F5EDE3',
},
terracotta: {
DEFAULT: '#C17767',
light: '#D49585',
dark: '#A45F50',
},
priority: {
high: '#C17767',
medium: '#4A90A4',
low: '#8BA889',
none: '#9CA3AF',
highLight: '#E8A090',
mediumLight: '#7CC0D6',
lowLight: '#B0D4A8',
noneLight: '#C0C7CF',
},
light: {
background: '#FFF8F0',
surface: '#FFFFFF',
text: '#1A1A1A',
textSecondary: '#6B6B6B',
border: '#E5E7EB',
},
dark: {
background: '#1A1A1A',
surface: '#2A2A2A',
text: '#F5F5F5',
textSecondary: '#A0A0A0',
border: '#3A3A3A',
},
} as const;

View file

@ -1,29 +0,0 @@
import { colors } from './colors';
const lightColors = [
colors.priority.none,
colors.priority.low,
colors.priority.medium,
colors.priority.high,
];
const darkColors = [
colors.priority.noneLight,
colors.priority.lowLight,
colors.priority.mediumLight,
colors.priority.highLight,
];
export function getPriorityColor(priority: number, isDark: boolean): string {
const palette = isDark ? darkColors : lightColors;
return palette[priority] ?? palette[0];
}
export function getPriorityOptions(isDark: boolean) {
return [
{ value: 0, labelKey: 'priority.none', color: getPriorityColor(0, isDark) },
{ value: 1, labelKey: 'priority.low', color: getPriorityColor(1, isDark) },
{ value: 2, labelKey: 'priority.medium', color: getPriorityColor(2, isDark) },
{ value: 3, labelKey: 'priority.high', color: getPriorityColor(3, isDark) },
];
}

View file

@ -1,18 +0,0 @@
import { addDays, addWeeks, addMonths, addYears } from 'date-fns';
export type RecurrenceType = 'daily' | 'weekly' | 'monthly' | 'yearly';
export const RECURRENCE_OPTIONS: RecurrenceType[] = ['daily', 'weekly', 'monthly', 'yearly'];
export function getNextOccurrence(dueDate: Date, recurrence: RecurrenceType): Date {
switch (recurrence) {
case 'daily':
return addDays(dueDate, 1);
case 'weekly':
return addWeeks(dueDate, 1);
case 'monthly':
return addMonths(dueDate, 1);
case 'yearly':
return addYears(dueDate, 1);
}
}

View file

@ -1,17 +0,0 @@
export type { RecurrenceType } from './recurrence';
export type Priority = 0 | 1 | 2 | 3;
export type SortBy = 'position' | 'priority' | 'dueDate' | 'title' | 'createdAt';
export type SortOrder = 'asc' | 'desc';
export type FilterCompleted = 'all' | 'active' | 'completed';
export type FilterDueDate = 'all' | 'today' | 'week' | 'overdue' | 'noDate';
export interface TaskFilters {
sortBy?: SortBy;
sortOrder?: SortOrder;
filterPriority?: number | null;
filterTag?: string | null;
filterCompleted?: FilterCompleted;
filterDueDate?: FilterDueDate;
}

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

View file

@ -1,9 +1,11 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { SortBy, SortOrder, FilterCompleted, FilterDueDate } from '@/src/shared/types';
export type { SortBy, SortOrder, FilterCompleted, FilterDueDate } from '@/src/shared/types';
export type SortBy = 'position' | 'priority' | 'dueDate' | 'title' | 'createdAt';
export type SortOrder = 'asc' | 'desc';
export type FilterCompleted = 'all' | 'active' | 'completed';
export type FilterDueDate = 'all' | 'today' | 'week' | 'overdue' | 'noDate';
interface TaskStoreState {
sortBy: SortBy;

View file

@ -1 +1,40 @@
export { colors } from '@/src/shared/colors';
export const colors = {
bleu: {
DEFAULT: '#4A90A4',
light: '#6BAEC2',
dark: '#3A7389',
},
creme: {
DEFAULT: '#FFF8F0',
dark: '#F5EDE3',
},
terracotta: {
DEFAULT: '#C17767',
light: '#D49585',
dark: '#A45F50',
},
priority: {
high: '#C17767',
medium: '#4A90A4',
low: '#8BA889',
none: '#9CA3AF',
highLight: '#E8A090',
mediumLight: '#7CC0D6',
lowLight: '#B0D4A8',
noneLight: '#C0C7CF',
},
light: {
background: '#FFF8F0',
surface: '#FFFFFF',
text: '#1A1A1A',
textSecondary: '#6B6B6B',
border: '#E5E7EB',
},
dark: {
background: '#1A1A1A',
surface: '#2A2A2A',
text: '#F5F5F5',
textSecondary: '#A0A0A0',
border: '#3A3A3A',
},
} as const;

View file

@ -1,18 +1,73 @@
import type { WidgetTaskHandlerProps } from 'react-native-android-widget';
import { requestWidgetUpdate } from 'react-native-android-widget';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { TaskListWidget } from './TaskListWidget';
import { getWidgetState, setWidgetState, WIDGET_NAMES, type WidgetTask } from '../services/widgetSync';
import { WIDGET_DATA_KEY, WIDGET_DARK_KEY, type WidgetTask } from '../services/widgetSync';
import { isValidUUID } from '../lib/validation';
const EXPAND_DEBOUNCE_MS = 2000;
const lastExpandTimes = new Map<string, number>();
const WIDGET_EXPANDED_KEY = 'widget:expandedTaskIds';
function isWidgetTask(item: unknown): item is WidgetTask {
if (typeof item !== 'object' || item === null) return false;
const obj = item as Record<string, unknown>;
return (
typeof obj.id === 'string' &&
typeof obj.title === 'string' &&
typeof obj.priority === 'number' &&
typeof obj.completed === 'boolean' &&
(obj.dueDate === null || typeof obj.dueDate === 'string') &&
(obj.listColor === null || obj.listColor === undefined || typeof obj.listColor === 'string') &&
(obj.subtaskCount === undefined || typeof obj.subtaskCount === 'number') &&
(obj.subtaskDoneCount === undefined || typeof obj.subtaskDoneCount === 'number')
);
}
async function getWidgetTasks(): Promise<WidgetTask[]> {
try {
const data = await AsyncStorage.getItem(WIDGET_DATA_KEY);
if (!data) return [];
const parsed: unknown = JSON.parse(data);
if (!Array.isArray(parsed)) return [];
return parsed.filter(isWidgetTask).map((t) => ({
...t,
subtasks: Array.isArray(t.subtasks) ? t.subtasks : [],
}));
} catch {
return [];
}
}
async function getWidgetIsDark(): Promise<boolean> {
try {
const data = await AsyncStorage.getItem(WIDGET_DARK_KEY);
if (!data) return false;
return JSON.parse(data) === true;
} catch {
return false;
}
}
async function getExpandedTaskIds(): Promise<Set<string>> {
try {
const data = await AsyncStorage.getItem(WIDGET_EXPANDED_KEY);
if (!data) return new Set();
const parsed: unknown = JSON.parse(data);
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((id): id is string => typeof id === 'string'));
} catch {
return new Set();
}
}
async function setExpandedTaskIds(ids: Set<string>): Promise<void> {
await AsyncStorage.setItem(WIDGET_EXPANDED_KEY, JSON.stringify([...ids]));
}
function renderWithState(
renderWidget: WidgetTaskHandlerProps['renderWidget'],
widgetInfo: WidgetTaskHandlerProps['widgetInfo'],
tasks: WidgetTask[],
isDark: boolean,
expandedTaskIds: string[],
expandedTaskIds: Set<string>,
) {
renderWidget(
TaskListWidget({
@ -20,30 +75,11 @@ function renderWithState(
widgetName: widgetInfo.widgetName,
tasks,
isDark,
expandedTaskIds,
expandedTaskIds: [...expandedTaskIds],
})
);
}
async function forceWidgetRefresh(
tasks: WidgetTask[],
isDark: boolean,
expandedTaskIds: string[],
): Promise<void> {
for (const widgetName of WIDGET_NAMES) {
try {
await requestWidgetUpdate({
widgetName,
renderWidget: (props) =>
TaskListWidget({ ...props, widgetName, tasks, isDark, expandedTaskIds }),
widgetNotFound: () => {},
});
} catch {
// Widget not placed on home screen
}
}
}
export async function widgetTaskHandler(
props: WidgetTaskHandlerProps
): Promise<void> {
@ -53,8 +89,12 @@ export async function widgetTaskHandler(
case 'WIDGET_ADDED':
case 'WIDGET_UPDATE':
case 'WIDGET_RESIZED': {
const state = await getWidgetState();
renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds);
const [tasks, isDark, expandedTaskIds] = await Promise.all([
getWidgetTasks(),
getWidgetIsDark(),
getExpandedTaskIds(),
]);
renderWithState(renderWidget, widgetInfo, tasks, isDark, expandedTaskIds);
break;
}
@ -66,11 +106,15 @@ export async function widgetTaskHandler(
const taskId = props.clickActionData?.taskId;
if (!isValidUUID(taskId)) break;
const state = await getWidgetState();
state.tasks = state.tasks.filter((t) => t.id !== taskId);
await setWidgetState(state);
const [tasks, isDark, expandedTaskIds] = await Promise.all([
getWidgetTasks(),
getWidgetIsDark(),
getExpandedTaskIds(),
]);
const updatedTasks = tasks.filter((t) => t.id !== taskId);
await AsyncStorage.setItem(WIDGET_DATA_KEY, JSON.stringify(updatedTasks));
renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds);
renderWithState(renderWidget, widgetInfo, updatedTasks, isDark, expandedTaskIds);
try {
const { toggleComplete } = await import('../db/repository/tasks');
@ -84,24 +128,20 @@ export async function widgetTaskHandler(
const taskId = props.clickActionData?.taskId as string | undefined;
if (!taskId) break;
// Debounce: ignore rapid double-taps on the same task
const now = Date.now();
const lastTime = lastExpandTimes.get(taskId) ?? 0;
if (now - lastTime < EXPAND_DEBOUNCE_MS) break;
lastExpandTimes.set(taskId, now);
const [tasks, isDark, expandedTaskIds] = await Promise.all([
getWidgetTasks(),
getWidgetIsDark(),
getExpandedTaskIds(),
]);
const state = await getWidgetState();
const expandedSet = new Set(state.expandedTaskIds);
if (expandedSet.has(taskId)) {
expandedSet.delete(taskId);
if (expandedTaskIds.has(taskId)) {
expandedTaskIds.delete(taskId);
} else {
expandedSet.add(taskId);
expandedTaskIds.add(taskId);
}
state.expandedTaskIds = [...expandedSet];
await setWidgetState(state);
await setExpandedTaskIds(expandedTaskIds);
renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds);
renderWithState(renderWidget, widgetInfo, tasks, isDark, expandedTaskIds);
}
if (props.clickAction === 'TOGGLE_SUBTASK') {
@ -109,10 +149,14 @@ export async function widgetTaskHandler(
const parentId = props.clickActionData?.parentId as string | undefined;
if (!isValidUUID(subtaskId) || !parentId) break;
const state = await getWidgetState();
const [tasks, isDark, expandedTaskIds] = await Promise.all([
getWidgetTasks(),
getWidgetIsDark(),
getExpandedTaskIds(),
]);
// Update subtask state in cached data
const parent = state.tasks.find((t) => t.id === parentId);
const parent = tasks.find((t) => t.id === parentId);
if (parent) {
const sub = parent.subtasks?.find((s) => s.id === subtaskId);
if (sub) {
@ -120,9 +164,9 @@ export async function widgetTaskHandler(
parent.subtaskDoneCount = (parent.subtasks ?? []).filter((s) => s.completed).length;
}
}
await setWidgetState(state);
await AsyncStorage.setItem(WIDGET_DATA_KEY, JSON.stringify(tasks));
await forceWidgetRefresh(state.tasks, state.isDark, state.expandedTaskIds);
renderWithState(renderWidget, widgetInfo, tasks, isDark, expandedTaskIds);
try {
const { toggleComplete } = await import('../db/repository/tasks');

View file

@ -14,8 +14,5 @@
".expo/types/**/*.ts",
"expo-env.d.ts",
"nativewind-env.d.ts"
],
"exclude": [
"web"
]
}

View file

@ -1,8 +0,0 @@
DATABASE_URL=postgresql://user:password@localhost:5432/simpliste
# Logto
LOGTO_ENDPOINT=https://auth.lacompagniemaximus.com
LOGTO_APP_ID=
LOGTO_APP_SECRET=
LOGTO_COOKIE_SECRET=
LOGTO_BASE_URL=https://liste.lacompagniemaximus.com

41
web/.gitignore vendored
View file

@ -1,41 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View file

@ -1,5 +0,0 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

View file

@ -1 +0,0 @@
@AGENTS.md

View file

@ -1,41 +0,0 @@
FROM node:22-alpine AS base
# Install production dependencies
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Build
FROM base AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Bundle custom server + ws into a single JS file
RUN npx esbuild server.ts --bundle --platform=node --target=node22 --outfile=dist-server/server.js \
--external:next --external:.next --external:pg --external:pg-native --external:drizzle-orm
# Production
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy production node_modules (has full next package)
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder --chown=nextjs:nodejs /app/dist-server/server.js ./server.js
COPY --from=builder --chown=nextjs:nodejs /app/src/db/migrations ./src/db/migrations
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View file

@ -1,36 +0,0 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View file

@ -1,11 +0,0 @@
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './src/db/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});

View file

@ -1,18 +0,0 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View file

@ -1,7 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;

8669
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,39 +0,0 @@
{
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@logto/next": "^4.2.9",
"@types/pg": "^8.20.0",
"dotenv": "^17.4.1",
"drizzle-orm": "^0.45.2",
"i18next": "^26.0.3",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.7.0",
"next": "16.2.2",
"pg": "^8.20.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-i18next": "^17.0.2",
"ws": "^8.20.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/ws": "^8.18.1",
"drizzle-kit": "^0.31.10",
"eslint": "^9",
"eslint-config-next": "16.2.2",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View file

@ -1,7 +0,0 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View file

@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

View file

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

View file

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

View file

@ -1,47 +0,0 @@
import { createServer } from 'http';
import next from 'next';
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { setupWebSocket } from './src/lib/ws';
const dev = process.env.NODE_ENV !== 'production';
const hostname = process.env.HOSTNAME || '0.0.0.0';
const port = parseInt(process.env.PORT || '3000', 10);
async function runMigrations() {
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const db = drizzle(pool);
try {
await migrate(db, { migrationsFolder: './src/db/migrations' });
console.log('> Migrations applied');
} finally {
await pool.end();
}
}
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();
(async () => {
try {
await runMigrations();
} catch (err) {
console.error('> Migration error:', err);
process.exit(1);
}
await app.prepare();
const server = createServer((req, res) => {
// Don't log query params on /ws route (ticket security)
handle(req, res);
});
setupWebSocket(server);
server.listen(port, hostname, () => {
console.log(`> Ready on http://${hostname}:${port}`);
console.log(`> WebSocket server on ws://${hostname}:${port}/ws`);
});
})();

View file

@ -1,46 +0,0 @@
export const dynamic = "force-dynamic";
import { redirect } from "next/navigation";
import { getAuthenticatedUser } from "@/lib/auth";
import { db } from "@/db/client";
import { slLists, slTags } from "@/db/schema";
import { eq, isNull, and, asc } from "drizzle-orm";
import { Sidebar } from "@/components/Sidebar";
import { Header } from "@/components/Header";
import { AppShell } from "@/components/AppShell";
export default async function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
const user = await getAuthenticatedUser();
if (!user) {
redirect("/auth");
}
const [lists, tags] = await Promise.all([
db
.select()
.from(slLists)
.where(and(eq(slLists.userId, user.userId), isNull(slLists.deletedAt)))
.orderBy(asc(slLists.position)),
db
.select()
.from(slTags)
.where(and(eq(slTags.userId, user.userId), isNull(slTags.deletedAt)))
.orderBy(asc(slTags.name)),
]);
return (
<AppShell>
<div className="flex h-screen overflow-hidden">
<Sidebar lists={lists} tags={tags} />
<div className="flex-1 flex flex-col min-w-0">
<Header userName={user.name || user.email || ""} />
<main className="flex-1 overflow-y-auto p-4 md:p-6">{children}</main>
</div>
</div>
</AppShell>
);
}

View file

@ -1,108 +0,0 @@
export const dynamic = "force-dynamic";
import { notFound, redirect } from "next/navigation";
import { getAuthenticatedUser } from "@/lib/auth";
import { db } from "@/db/client";
import { slLists, slTasks } from "@/db/schema";
import { eq, and, isNull, asc, desc } from "drizzle-orm";
import { TaskList } from "@/components/TaskList";
import type { Task } from "@/lib/types";
import type { SQL } from "drizzle-orm";
export default async function ListPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const user = await getAuthenticatedUser();
if (!user) redirect("/auth");
const userId = user.userId;
const { id: listId } = await params;
const search = await searchParams;
// Verify list belongs to user
const [list] = await db
.select()
.from(slLists)
.where(
and(
eq(slLists.id, listId),
eq(slLists.userId, userId),
isNull(slLists.deletedAt)
)
);
if (!list) notFound();
// Build conditions
const conditions: SQL[] = [
eq(slTasks.listId, listId),
eq(slTasks.userId, userId),
isNull(slTasks.deletedAt),
isNull(slTasks.parentId),
];
const completed = typeof search.completed === "string" ? search.completed : undefined;
if (completed === "true" || completed === "false") {
conditions.push(eq(slTasks.completed, completed === "true"));
}
const sortBy = (typeof search.sortBy === "string" ? search.sortBy : "position") as string;
const sortOrder = (typeof search.sortOrder === "string" ? search.sortOrder : "asc") as string;
const sortColumn =
sortBy === "priority"
? slTasks.priority
: sortBy === "dueDate"
? slTasks.dueDate
: sortBy === "createdAt"
? slTasks.createdAt
: sortBy === "title"
? slTasks.title
: slTasks.position;
const orderFn = sortOrder === "desc" ? desc : asc;
const tasks = await db
.select()
.from(slTasks)
.where(and(...conditions))
.orderBy(orderFn(sortColumn));
// Fetch subtasks for all parent tasks
const parentIds = tasks.map((t) => t.id);
let subtasksMap: Record<string, Task[]> = {};
if (parentIds.length > 0) {
const allSubtasks = await db
.select()
.from(slTasks)
.where(
and(
eq(slTasks.userId, userId),
isNull(slTasks.deletedAt)
)
)
.orderBy(asc(slTasks.position));
const parentIdSet = new Set(parentIds);
for (const sub of allSubtasks) {
if (sub.parentId && parentIdSet.has(sub.parentId)) {
if (!subtasksMap[sub.parentId]) subtasksMap[sub.parentId] = [];
subtasksMap[sub.parentId].push(sub as Task);
}
}
}
return (
<TaskList
tasks={tasks as Task[]}
subtasksMap={subtasksMap}
listId={listId}
listName={list.name}
/>
);
}

View file

@ -1,26 +0,0 @@
import { redirect } from "next/navigation";
import { getAuthenticatedUser } from "@/lib/auth";
import { db } from "@/db/client";
import { slLists } from "@/db/schema";
import { eq, isNull, and, asc } from "drizzle-orm";
import { WelcomeMessage } from "@/components/WelcomeMessage";
export const dynamic = "force-dynamic";
export default async function AppHome() {
const user = await getAuthenticatedUser();
if (!user) redirect("/auth");
const userId = user.userId;
const lists = await db
.select()
.from(slLists)
.where(and(eq(slLists.userId, userId), isNull(slLists.deletedAt)))
.orderBy(asc(slLists.position));
const inbox = lists.find((l) => l.isInbox);
if (inbox) redirect(`/lists/${inbox.id}`);
if (lists.length > 0) redirect(`/lists/${lists[0].id}`);
return <WelcomeMessage />;
}

View file

@ -1,40 +0,0 @@
import { NextResponse } from 'next/server';
import { getLogtoContext } from '@logto/next/server-actions';
import { logtoConfig } from '@/lib/logto';
import { cookies } from 'next/headers';
import { db } from '@/db/client';
import { slLists } from '@/db/schema';
import { eq, isNull, and, asc } from 'drizzle-orm';
export const dynamic = 'force-dynamic';
export async function GET() {
const cookieStore = await cookies();
const allCookies = cookieStore.getAll().map(c => ({ name: c.name, length: c.value.length }));
try {
const context = await getLogtoContext(logtoConfig);
const userId = context.claims?.sub;
let lists = null;
if (userId) {
lists = await db
.select({ id: slLists.id, name: slLists.name, isInbox: slLists.isInbox, userId: slLists.userId })
.from(slLists)
.where(and(eq(slLists.userId, userId), isNull(slLists.deletedAt)))
.orderBy(asc(slLists.position));
}
return NextResponse.json({
cookies: allCookies,
isAuthenticated: context.isAuthenticated,
claims: context.claims ?? null,
lists,
});
} catch (error) {
return NextResponse.json({
cookies: allCookies,
error: error instanceof Error ? error.message : String(error),
});
}
}

View file

@ -1,37 +0,0 @@
import { NextResponse } from 'next/server';
import { db } from '@/db/client';
import { sql } from 'drizzle-orm';
import { getActiveConnections } from '@/lib/ws';
export async function GET() {
const start = Date.now();
try {
await db.execute(sql`SELECT 1`);
const dbLatency = Date.now() - start;
return NextResponse.json({
status: 'ok',
timestamp: new Date().toISOString(),
db: {
status: 'connected',
latencyMs: dbLatency,
},
ws: {
activeConnections: getActiveConnections(),
},
});
} catch (error) {
return NextResponse.json({
status: 'degraded',
timestamp: new Date().toISOString(),
db: {
status: 'disconnected',
error: error instanceof Error ? error.message : 'Unknown error',
},
ws: {
activeConnections: getActiveConnections(),
},
}, { status: 503 });
}
}

View file

@ -1,57 +0,0 @@
import { NextResponse } from 'next/server';
import { db } from '@/db/client';
import { slLists } from '@/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth, parseBody } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
import { updateListSchema } from '@/lib/validators';
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'create');
if (rl) return rl;
const { id } = await params;
const body = await parseBody(request, (d) => updateListSchema.parse(d));
if (body.error) return body.error;
const [updated] = await db
.update(slLists)
.set({ ...body.data, updatedAt: new Date() })
.where(and(eq(slLists.id, id), eq(slLists.userId, auth.userId)))
.returning();
if (!updated) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json(updated);
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'create');
if (rl) return rl;
const { id } = await params;
const [deleted] = await db
.update(slLists)
.set({ deletedAt: new Date(), updatedAt: new Date() })
.where(and(eq(slLists.id, id), eq(slLists.userId, auth.userId)))
.returning();
if (!deleted) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json({ ok: true });
}

View file

@ -1,88 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db/client';
import { slTasks, slLists, slTaskTags } from '@/db/schema';
import { eq, and, isNull, asc, desc, inArray, SQL } from 'drizzle-orm';
import { requireAuth } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'read');
if (rl) return rl;
const { id: listId } = await params;
// Verify list belongs to user
const [list] = await db
.select({ id: slLists.id })
.from(slLists)
.where(and(eq(slLists.id, listId), eq(slLists.userId, auth.userId)));
if (!list) {
return NextResponse.json({ error: 'List not found' }, { status: 404 });
}
const url = request.nextUrl;
const completed = url.searchParams.get('completed');
const priority = url.searchParams.get('priority');
const dueDate = url.searchParams.get('dueDate');
const tags = url.searchParams.get('tags');
const sortBy = url.searchParams.get('sortBy') || 'position';
const sortOrder = url.searchParams.get('sortOrder') || 'asc';
const conditions: SQL[] = [
eq(slTasks.listId, listId),
eq(slTasks.userId, auth.userId),
isNull(slTasks.deletedAt),
isNull(slTasks.parentId),
];
if (completed !== null) {
conditions.push(eq(slTasks.completed, completed === 'true'));
}
if (priority !== null) {
conditions.push(eq(slTasks.priority, parseInt(priority, 10)));
}
// Build query
let query = db
.select()
.from(slTasks)
.where(and(...conditions));
// Sort
const sortColumn = sortBy === 'priority' ? slTasks.priority
: sortBy === 'dueDate' ? slTasks.dueDate
: sortBy === 'createdAt' ? slTasks.createdAt
: sortBy === 'title' ? slTasks.title
: slTasks.position;
const orderFn = sortOrder === 'desc' ? desc : asc;
const tasks = await query.orderBy(orderFn(sortColumn));
// Filter by tags if specified (post-query since it's a join table)
if (tags) {
const tagIds = tags.split(',');
const taskTagRows = await db
.select({ taskId: slTaskTags.taskId })
.from(slTaskTags)
.where(inArray(slTaskTags.tagId, tagIds));
const taskIdsWithTags = new Set(taskTagRows.map((r) => r.taskId));
return NextResponse.json(tasks.filter((t) => taskIdsWithTags.has(t.id)));
}
// Filter by dueDate if specified (before/on that date)
if (dueDate) {
const cutoff = new Date(dueDate);
return NextResponse.json(
tasks.filter((t) => t.dueDate && t.dueDate <= cutoff)
);
}
return NextResponse.json(tasks);
}

View file

@ -1,39 +0,0 @@
import { NextResponse } from 'next/server';
import { db } from '@/db/client';
import { slLists } from '@/db/schema';
import { eq, and, inArray } from 'drizzle-orm';
import { requireAuth, parseBody } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
import { reorderSchema } from '@/lib/validators';
export async function PUT(request: Request) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'create');
if (rl) return rl;
const body = await parseBody(request, (d) => reorderSchema.parse(d));
if (body.error) return body.error;
// Verify all lists belong to user
const existing = await db
.select({ id: slLists.id })
.from(slLists)
.where(and(eq(slLists.userId, auth.userId), inArray(slLists.id, body.data.ids)));
if (existing.length !== body.data.ids.length) {
return NextResponse.json({ error: 'Some lists not found' }, { status: 404 });
}
// Update positions in order
await Promise.all(
body.data.ids.map((id, index) =>
db
.update(slLists)
.set({ position: index, updatedAt: new Date() })
.where(and(eq(slLists.id, id), eq(slLists.userId, auth.userId)))
)
);
return NextResponse.json({ ok: true });
}

View file

@ -1,39 +0,0 @@
import { NextResponse } from 'next/server';
import { db } from '@/db/client';
import { slLists } from '@/db/schema';
import { eq, isNull, and, asc } from 'drizzle-orm';
import { requireAuth, parseBody } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
import { createListSchema } from '@/lib/validators';
export async function GET() {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'read');
if (rl) return rl;
const lists = await db
.select()
.from(slLists)
.where(and(eq(slLists.userId, auth.userId), isNull(slLists.deletedAt)))
.orderBy(asc(slLists.position));
return NextResponse.json(lists);
}
export async function POST(request: Request) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'create');
if (rl) return rl;
const body = await parseBody(request, (d) => createListSchema.parse(d));
if (body.error) return body.error;
const [list] = await db
.insert(slLists)
.values({ ...body.data, userId: auth.userId })
.returning();
return NextResponse.json(list, { status: 201 });
}

View file

@ -1,15 +0,0 @@
import { handleSignIn } from '@logto/next/server-actions';
import { logtoConfig } from '@/lib/logto';
import { redirect } from 'next/navigation';
import { type NextRequest } from 'next/server';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
const callbackUrl = new URL(
`/api/logto/callback?${request.nextUrl.searchParams.toString()}`,
logtoConfig.baseUrl
);
await handleSignIn(logtoConfig, callbackUrl);
redirect('/');
}

View file

@ -1,9 +0,0 @@
import { signIn } from '@logto/next/server-actions';
import { logtoConfig } from '@/lib/logto';
export const dynamic = 'force-dynamic';
export async function GET() {
// signIn calls redirect() internally — must not be in try/catch
await signIn(logtoConfig, `${logtoConfig.baseUrl}/api/logto/callback`);
}

View file

@ -1,16 +0,0 @@
import { signOut } from '@logto/next/server-actions';
import { logtoConfig } from '@/lib/logto';
import { cookies } from 'next/headers';
export const dynamic = 'force-dynamic';
export async function GET() {
// Clear the Logto session cookie explicitly
const cookieStore = await cookies();
const logtoCookie = cookieStore.getAll().find(c => c.name.startsWith('logto_'));
if (logtoCookie) {
cookieStore.delete(logtoCookie.name);
}
await signOut(logtoConfig, logtoConfig.baseUrl);
}

View file

@ -1,341 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db/client';
import { slLists, slTasks, slTags, slTaskTags } from '@/db/schema';
import { eq, and, gte, isNull } from 'drizzle-orm';
import { requireAuth, parseBody } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
import { syncPushSchema, type SyncOperation } from '@/lib/validators';
// Idempotency key store (TTL 24h)
const idempotencyStore = new Map<string, { result: unknown; expiresAt: number }>();
// Cleanup expired keys periodically
function cleanupIdempotencyKeys() {
const now = Date.now();
for (const [key, entry] of idempotencyStore) {
if (entry.expiresAt < now) {
idempotencyStore.delete(key);
}
}
}
const TTL_24H = 24 * 60 * 60 * 1000;
export async function GET(request: NextRequest) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'sync');
if (rl) return rl;
const since = request.nextUrl.searchParams.get('since');
if (!since) {
return NextResponse.json({ error: 'Missing "since" parameter' }, { status: 400 });
}
const sinceDate = new Date(since);
if (isNaN(sinceDate.getTime())) {
return NextResponse.json({ error: 'Invalid "since" timestamp' }, { status: 400 });
}
// Fetch all entities updated since timestamp (including soft-deleted)
const [lists, tasks, tags] = await Promise.all([
db
.select()
.from(slLists)
.where(and(eq(slLists.userId, auth.userId), gte(slLists.updatedAt, sinceDate))),
db
.select()
.from(slTasks)
.where(and(eq(slTasks.userId, auth.userId), gte(slTasks.updatedAt, sinceDate))),
db
.select()
.from(slTags)
.where(and(eq(slTags.userId, auth.userId), gte(slTags.createdAt, sinceDate))),
]);
// Get task-tag relations for the affected tasks
const taskIds = tasks.map((t) => t.id);
let taskTags: { taskId: string; tagId: string }[] = [];
if (taskIds.length > 0) {
const { inArray } = await import('drizzle-orm');
taskTags = await db
.select()
.from(slTaskTags)
.where(inArray(slTaskTags.taskId, taskIds));
}
// Transform entities into the changes format expected by the mobile client
const changes: {
entity_type: string;
entity_id: string;
action: 'create' | 'update' | 'delete';
payload: Record<string, unknown>;
updated_at: string;
}[] = [];
for (const l of lists) {
changes.push({
entity_type: 'list',
entity_id: l.id,
action: l.deletedAt ? 'delete' : 'update',
payload: {
name: l.name,
color: l.color,
icon: l.icon,
position: l.position,
is_inbox: l.isInbox,
created_at: l.createdAt.toISOString(),
updated_at: l.updatedAt.toISOString(),
},
updated_at: l.updatedAt.toISOString(),
});
}
for (const t of tasks) {
changes.push({
entity_type: 'task',
entity_id: t.id,
action: t.deletedAt ? 'delete' : 'update',
payload: {
title: t.title,
notes: t.notes,
completed: t.completed,
completed_at: t.completedAt?.toISOString() ?? null,
priority: t.priority,
due_date: t.dueDate?.toISOString() ?? null,
list_id: t.listId,
parent_id: t.parentId,
position: t.position,
recurrence: t.recurrence,
created_at: t.createdAt.toISOString(),
updated_at: t.updatedAt.toISOString(),
},
updated_at: t.updatedAt.toISOString(),
});
}
for (const tag of tags) {
changes.push({
entity_type: 'tag',
entity_id: tag.id,
action: tag.deletedAt ? 'delete' : 'update',
payload: {
name: tag.name,
color: tag.color,
created_at: tag.createdAt.toISOString(),
updated_at: tag.createdAt.toISOString(),
},
updated_at: tag.createdAt.toISOString(),
});
}
for (const tt of taskTags) {
changes.push({
entity_type: 'task_tag',
entity_id: `${tt.taskId}:${tt.tagId}`,
action: 'update',
payload: {
task_id: tt.taskId,
tag_id: tt.tagId,
},
updated_at: new Date().toISOString(),
});
}
return NextResponse.json({
changes,
sync_token: new Date().toISOString(),
});
}
export async function POST(request: Request) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'sync');
if (rl) return rl;
const body = await parseBody(request, (d) => syncPushSchema.parse(d));
if (body.error) return body.error;
cleanupIdempotencyKeys();
const results: { idempotencyKey: string; status: 'applied' | 'skipped'; error?: string }[] = [];
for (const op of body.data.operations) {
const storeKey = `${auth.userId}:${op.idempotencyKey}`;
// Check idempotency
const existing = idempotencyStore.get(storeKey);
if (existing && existing.expiresAt > Date.now()) {
results.push({ idempotencyKey: op.idempotencyKey, status: 'skipped' });
continue;
}
try {
await processOperation(op, auth.userId);
idempotencyStore.set(storeKey, {
result: true,
expiresAt: Date.now() + TTL_24H,
});
results.push({ idempotencyKey: op.idempotencyKey, status: 'applied' });
} catch (e) {
results.push({
idempotencyKey: op.idempotencyKey,
status: 'skipped',
error: e instanceof Error ? e.message : 'Unknown error',
});
}
}
return NextResponse.json({ results, syncedAt: new Date().toISOString() });
}
async function processOperation(op: SyncOperation, userId: string) {
const { entityType, entityId, action, data } = op;
const now = new Date();
switch (entityType) {
case 'list': {
if (action === 'create') {
const d = (data as Record<string, unknown>) || {};
const incomingIsInbox = d.isInbox as boolean | undefined;
const listValues = {
id: entityId,
userId,
name: d.name as string || 'Untitled',
color: d.color as string | undefined,
icon: d.icon as string | undefined,
position: d.position as number | undefined,
isInbox: incomingIsInbox,
};
// If the incoming list is an inbox, check for an existing inbox and merge
if (incomingIsInbox) {
await db.transaction(async (tx) => {
const [existingInbox] = await tx
.select()
.from(slLists)
.where(and(eq(slLists.userId, userId), eq(slLists.isInbox, true), isNull(slLists.deletedAt)));
if (existingInbox && existingInbox.id !== entityId) {
// Reassign all tasks (including subtasks) from the old inbox to the new one
await tx.update(slTasks)
.set({ listId: entityId, updatedAt: now })
.where(and(eq(slTasks.listId, existingInbox.id), eq(slTasks.userId, userId)));
// Soft-delete the old inbox
await tx.update(slLists)
.set({ deletedAt: now, updatedAt: now })
.where(eq(slLists.id, existingInbox.id));
}
await tx.insert(slLists).values(listValues).onConflictDoNothing();
});
} else {
await db.insert(slLists).values(listValues).onConflictDoNothing();
}
} else if (action === 'update') {
await verifyOwnership(slLists, entityId, userId);
await db.update(slLists)
.set({ ...(data as Record<string, unknown>), updatedAt: now })
.where(and(eq(slLists.id, entityId), eq(slLists.userId, userId)));
} else if (action === 'delete') {
await verifyOwnership(slLists, entityId, userId);
await db.update(slLists)
.set({ deletedAt: now, updatedAt: now })
.where(and(eq(slLists.id, entityId), eq(slLists.userId, userId)));
}
break;
}
case 'task': {
if (action === 'create') {
const d = (data as Record<string, unknown>) || {};
await db.insert(slTasks).values({
id: entityId,
userId,
title: d.title as string || 'Untitled',
listId: d.listId as string,
notes: d.notes as string | undefined,
priority: d.priority as number | undefined,
dueDate: d.dueDate ? new Date(d.dueDate as string) : undefined,
parentId: d.parentId as string | undefined,
recurrence: d.recurrence as string | undefined,
position: d.position as number | undefined,
}).onConflictDoNothing();
} else if (action === 'update') {
await verifyOwnership(slTasks, entityId, userId);
const raw = { ...(data as Record<string, unknown>), updatedAt: now } as Record<string, unknown>;
// Remove id from payload to avoid overwriting primary key
delete raw.id;
if (raw.dueDate !== undefined) {
raw.dueDate = raw.dueDate ? new Date(raw.dueDate as string) : null;
}
if (raw.completedAt !== undefined) {
raw.completedAt = raw.completedAt ? new Date(raw.completedAt as string) : null;
}
await db.update(slTasks)
.set(raw)
.where(and(eq(slTasks.id, entityId), eq(slTasks.userId, userId)));
} else if (action === 'delete') {
await verifyOwnership(slTasks, entityId, userId);
await db.update(slTasks)
.set({ deletedAt: now, updatedAt: now })
.where(and(eq(slTasks.id, entityId), eq(slTasks.userId, userId)));
}
break;
}
case 'tag': {
if (action === 'create') {
const d = (data as Record<string, unknown>) || {};
await db.insert(slTags).values({
id: entityId,
userId,
name: d.name as string || 'Untitled',
color: d.color as string | undefined,
}).onConflictDoNothing();
} else if (action === 'update') {
await verifyTagOwnership(entityId, userId);
await db.update(slTags)
.set(data as Record<string, unknown>)
.where(and(eq(slTags.id, entityId), eq(slTags.userId, userId)));
} else if (action === 'delete') {
await verifyTagOwnership(entityId, userId);
await db.update(slTags)
.set({ deletedAt: now })
.where(and(eq(slTags.id, entityId), eq(slTags.userId, userId)));
}
break;
}
case 'taskTag': {
// entityId is used as taskId, tagId comes from data
const d = (data as Record<string, unknown>) || {};
const tagId = d.tagId as string;
if (action === 'create') {
await db.insert(slTaskTags)
.values({ taskId: entityId, tagId })
.onConflictDoNothing();
} else if (action === 'delete') {
await db.delete(slTaskTags)
.where(and(eq(slTaskTags.taskId, entityId), eq(slTaskTags.tagId, tagId)));
}
break;
}
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function verifyOwnership(table: any, entityId: string, userId: string) {
const [row] = await db
.select({ id: table.id })
.from(table)
.where(and(eq(table.id, entityId), eq(table.userId, userId)));
if (!row) throw new Error('Entity not found or access denied');
}
async function verifyTagOwnership(entityId: string, userId: string) {
const [row] = await db
.select({ id: slTags.id })
.from(slTags)
.where(and(eq(slTags.id, entityId), eq(slTags.userId, userId)));
if (!row) throw new Error('Tag not found or access denied');
}

View file

@ -1,57 +0,0 @@
import { NextResponse } from 'next/server';
import { db } from '@/db/client';
import { slTags } from '@/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth, parseBody } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
import { updateTagSchema } from '@/lib/validators';
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'create');
if (rl) return rl;
const { id } = await params;
const body = await parseBody(request, (d) => updateTagSchema.parse(d));
if (body.error) return body.error;
const [updated] = await db
.update(slTags)
.set(body.data)
.where(and(eq(slTags.id, id), eq(slTags.userId, auth.userId)))
.returning();
if (!updated) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json(updated);
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'create');
if (rl) return rl;
const { id } = await params;
const [deleted] = await db
.update(slTags)
.set({ deletedAt: new Date() })
.where(and(eq(slTags.id, id), eq(slTags.userId, auth.userId)))
.returning();
if (!deleted) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json({ ok: true });
}

View file

@ -1,39 +0,0 @@
import { NextResponse } from 'next/server';
import { db } from '@/db/client';
import { slTags } from '@/db/schema';
import { eq, isNull, and, asc } from 'drizzle-orm';
import { requireAuth, parseBody } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
import { createTagSchema } from '@/lib/validators';
export async function GET() {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'read');
if (rl) return rl;
const tags = await db
.select()
.from(slTags)
.where(and(eq(slTags.userId, auth.userId), isNull(slTags.deletedAt)))
.orderBy(asc(slTags.name));
return NextResponse.json(tags);
}
export async function POST(request: Request) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'create');
if (rl) return rl;
const body = await parseBody(request, (d) => createTagSchema.parse(d));
if (body.error) return body.error;
const [tag] = await db
.insert(slTags)
.values({ ...body.data, userId: auth.userId })
.returning();
return NextResponse.json(tag, { status: 201 });
}

View file

@ -1,72 +0,0 @@
import { NextResponse } from 'next/server';
import { db } from '@/db/client';
import { slTasks } from '@/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth, parseBody } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
import { updateTaskSchema } from '@/lib/validators';
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'create');
if (rl) return rl;
const { id } = await params;
const body = await parseBody(request, (d) => updateTaskSchema.parse(d));
if (body.error) return body.error;
const updateData: Record<string, unknown> = {
...body.data,
updatedAt: new Date(),
};
// Convert dueDate string to Date
if (body.data.dueDate !== undefined) {
updateData.dueDate = body.data.dueDate ? new Date(body.data.dueDate) : null;
}
// Set completedAt when toggling completed
if (body.data.completed !== undefined) {
updateData.completedAt = body.data.completed ? new Date() : null;
}
const [updated] = await db
.update(slTasks)
.set(updateData)
.where(and(eq(slTasks.id, id), eq(slTasks.userId, auth.userId)))
.returning();
if (!updated) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json(updated);
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'create');
if (rl) return rl;
const { id } = await params;
const [deleted] = await db
.update(slTasks)
.set({ deletedAt: new Date(), updatedAt: new Date() })
.where(and(eq(slTasks.id, id), eq(slTasks.userId, auth.userId)))
.returning();
if (!deleted) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json({ ok: true });
}

View file

@ -1,42 +0,0 @@
import { NextResponse } from 'next/server';
import { db } from '@/db/client';
import { slTasks } from '@/db/schema';
import { eq, and, isNull, asc } from 'drizzle-orm';
import { requireAuth } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'read');
if (rl) return rl;
const { id } = await params;
// Verify parent task belongs to user
const [parent] = await db
.select({ id: slTasks.id })
.from(slTasks)
.where(and(eq(slTasks.id, id), eq(slTasks.userId, auth.userId)));
if (!parent) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
}
const subtasks = await db
.select()
.from(slTasks)
.where(
and(
eq(slTasks.parentId, id),
eq(slTasks.userId, auth.userId),
isNull(slTasks.deletedAt)
)
)
.orderBy(asc(slTasks.position));
return NextResponse.json(subtasks);
}

View file

@ -1,34 +0,0 @@
import { NextResponse } from 'next/server';
import { db } from '@/db/client';
import { slTasks, slTaskTags } from '@/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string; tagId: string }> }
) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'create');
if (rl) return rl;
const { id: taskId, tagId } = await params;
// Verify task belongs to user
const [task] = await db
.select({ id: slTasks.id })
.from(slTasks)
.where(and(eq(slTasks.id, taskId), eq(slTasks.userId, auth.userId)));
if (!task) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
}
await db
.delete(slTaskTags)
.where(and(eq(slTaskTags.taskId, taskId), eq(slTaskTags.tagId, tagId)));
return NextResponse.json({ ok: true });
}

View file

@ -1,50 +0,0 @@
import { NextResponse } from 'next/server';
import { db } from '@/db/client';
import { slTasks, slTags, slTaskTags } from '@/db/schema';
import { eq, and, inArray } from 'drizzle-orm';
import { requireAuth, parseBody } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
import { assignTagsSchema } from '@/lib/validators';
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'create');
if (rl) return rl;
const { id: taskId } = await params;
const body = await parseBody(request, (d) => assignTagsSchema.parse(d));
if (body.error) return body.error;
// Verify task belongs to user
const [task] = await db
.select({ id: slTasks.id })
.from(slTasks)
.where(and(eq(slTasks.id, taskId), eq(slTasks.userId, auth.userId)));
if (!task) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
}
// Verify all tags belong to user
const existingTags = await db
.select({ id: slTags.id })
.from(slTags)
.where(and(eq(slTags.userId, auth.userId), inArray(slTags.id, body.data.tagIds)));
if (existingTags.length !== body.data.tagIds.length) {
return NextResponse.json({ error: 'Some tags not found' }, { status: 404 });
}
// Insert (ignore conflicts)
await db
.insert(slTaskTags)
.values(body.data.tagIds.map((tagId) => ({ taskId, tagId })))
.onConflictDoNothing();
return NextResponse.json({ ok: true }, { status: 201 });
}

View file

@ -1,38 +0,0 @@
import { NextResponse } from 'next/server';
import { db } from '@/db/client';
import { slTasks } from '@/db/schema';
import { eq, and, inArray } from 'drizzle-orm';
import { requireAuth, parseBody } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
import { reorderSchema } from '@/lib/validators';
export async function PUT(request: Request) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'create');
if (rl) return rl;
const body = await parseBody(request, (d) => reorderSchema.parse(d));
if (body.error) return body.error;
// Verify all tasks belong to user
const existing = await db
.select({ id: slTasks.id })
.from(slTasks)
.where(and(eq(slTasks.userId, auth.userId), inArray(slTasks.id, body.data.ids)));
if (existing.length !== body.data.ids.length) {
return NextResponse.json({ error: 'Some tasks not found' }, { status: 404 });
}
await Promise.all(
body.data.ids.map((id, index) =>
db
.update(slTasks)
.set({ position: index, updatedAt: new Date() })
.where(and(eq(slTasks.id, id), eq(slTasks.userId, auth.userId)))
)
);
return NextResponse.json({ ok: true });
}

View file

@ -1,54 +0,0 @@
import { NextResponse } from 'next/server';
import { db } from '@/db/client';
import { slTasks, slLists } from '@/db/schema';
import { eq, and } from 'drizzle-orm';
import { requireAuth, parseBody } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
import { createTaskSchema } from '@/lib/validators';
export async function POST(request: Request) {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'create');
if (rl) return rl;
const body = await parseBody(request, (d) => createTaskSchema.parse(d));
if (body.error) return body.error;
// Verify list belongs to user
const [list] = await db
.select({ id: slLists.id })
.from(slLists)
.where(and(eq(slLists.id, body.data.listId), eq(slLists.userId, auth.userId)));
if (!list) {
return NextResponse.json({ error: 'List not found' }, { status: 404 });
}
// If parentId, verify parent task belongs to user and is not itself a subtask
if (body.data.parentId) {
const [parent] = await db
.select({ id: slTasks.id, parentId: slTasks.parentId })
.from(slTasks)
.where(and(eq(slTasks.id, body.data.parentId), eq(slTasks.userId, auth.userId)));
if (!parent) {
return NextResponse.json({ error: 'Parent task not found' }, { status: 404 });
}
if (parent.parentId) {
return NextResponse.json({ error: 'Cannot create sub-subtasks (max 2 levels)' }, { status: 400 });
}
}
const [task] = await db
.insert(slTasks)
.values({
...body.data,
dueDate: body.data.dueDate ? new Date(body.data.dueDate) : undefined,
userId: auth.userId,
})
.returning();
return NextResponse.json(task, { status: 201 });
}

View file

@ -1,34 +0,0 @@
import { NextResponse } from 'next/server';
import { randomUUID } from 'crypto';
import { requireAuth } from '@/lib/api';
import { rateLimit } from '@/lib/rateLimit';
import { getTicketStore } from '@/lib/ws';
const TTL_30S = 30 * 1000;
function cleanupTickets() {
const store = getTicketStore();
const now = Date.now();
for (const [key, entry] of store) {
if (entry.expiresAt < now) {
store.delete(key);
}
}
}
export async function POST() {
const auth = await requireAuth();
if (auth.error) return auth.error;
const rl = rateLimit(auth.userId, 'ws-ticket');
if (rl) return rl;
cleanupTickets();
const ticket = randomUUID();
getTicketStore().set(ticket, {
userId: auth.userId,
expiresAt: Date.now() + TTL_30S,
});
return NextResponse.json({ ticket }, { status: 201 });
}

View file

@ -1,22 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
export default function AuthPage() {
const { t } = useTranslation();
return (
<div className="min-h-screen flex items-center justify-center bg-[#FFF8F0]">
<div className="text-center space-y-6 p-8">
<h1 className="text-3xl font-bold text-[#1A1A1A]">{t("app.name")}</h1>
<p className="text-[#6B6B6B]">{t("auth.subtitle")}</p>
<a
href="/api/logto/sign-in"
className="inline-block px-6 py-3 bg-[#4A90A4] text-white rounded-lg font-medium hover:bg-[#3A7389] transition-colors"
>
{t("auth.signIn")}
</a>
</div>
</div>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View file

@ -1,38 +0,0 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-bleu: #4A90A4;
--color-creme: #FFF8F0;
--color-terracotta: #C17767;
--color-vert: #8BA889;
--color-sable: #D4A574;
--color-violet: #7B68EE;
--color-rouge: #E57373;
--color-teal: #4DB6AC;
--color-surface-light: #FFFFFF;
--color-surface-dark: #2A2A2A;
--color-border-light: #E5E7EB;
--color-border-dark: #3A3A3A;
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
:root {
--background: #FFF8F0;
--foreground: #1A1A1A;
}
.dark {
--background: #1A1A1A;
--foreground: #F5F5F5;
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans), Arial, Helvetica, sans-serif;
}

View file

@ -1,41 +0,0 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { ThemeScript } from "@/components/ThemeScript";
import { I18nProvider } from "@/components/I18nProvider";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Simpl-Liste",
description: "Gestion de tâches minimaliste par La Compagnie Maximus",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="fr"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
suppressHydrationWarning
>
<head>
<ThemeScript />
</head>
<body className="min-h-full flex flex-col">
<I18nProvider>{children}</I18nProvider>
</body>
</html>
);
}

View file

@ -1,8 +0,0 @@
"use client";
import { useSync } from "./useSync";
export function AppShell({ children }: { children: React.ReactNode }) {
useSync();
return <>{children}</>;
}

View file

@ -1,27 +0,0 @@
"use client";
import { createContext, useContext } from "react";
interface AuthUser {
userId: string;
email?: string | null;
name?: string | null;
}
const AuthContext = createContext<AuthUser | null>(null);
export function AuthProvider({
user,
children,
}: {
user: AuthUser;
children: React.ReactNode;
}) {
return <AuthContext.Provider value={user}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}

View file

@ -1,85 +0,0 @@
"use client";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { Filter, ArrowUpDown } from "lucide-react";
import { useTranslation } from "react-i18next";
export function FilterBar() {
const { t } = useTranslation();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const STATUS_OPTIONS = [
{ value: "", label: t("filter.all") },
{ value: "false", label: t("filter.active") },
{ value: "true", label: t("filter.completed") },
];
const SORT_OPTIONS = [
{ value: "position", label: t("sort.position") },
{ value: "priority", label: t("sort.priority") },
{ value: "dueDate", label: t("sort.dueDate") },
{ value: "title", label: t("sort.title") },
{ value: "createdAt", label: t("sort.createdAt") },
];
const completed = searchParams.get("completed") ?? "";
const sortBy = searchParams.get("sortBy") ?? "position";
const sortOrder = searchParams.get("sortOrder") ?? "asc";
const updateParam = (key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
router.push(`${pathname}?${params.toString()}`);
};
return (
<div className="flex flex-wrap items-center gap-3 text-sm">
{/* Status filter */}
<div className="flex items-center gap-1.5">
<Filter size={14} className="text-foreground/50" />
<select
value={completed}
onChange={(e) => updateParam("completed", e.target.value)}
className="bg-transparent border border-border-light dark:border-border-dark rounded px-2 py-1 text-sm focus:outline-none focus:border-bleu"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* Sort */}
<div className="flex items-center gap-1.5">
<ArrowUpDown size={14} className="text-foreground/50" />
<select
value={sortBy}
onChange={(e) => updateParam("sortBy", e.target.value)}
className="bg-transparent border border-border-light dark:border-border-dark rounded px-2 py-1 text-sm focus:outline-none focus:border-bleu"
>
{SORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<button
onClick={() =>
updateParam("sortOrder", sortOrder === "asc" ? "desc" : "asc")
}
className="px-1.5 py-1 border border-border-light dark:border-border-dark rounded hover:bg-black/5 dark:hover:bg-white/5"
title={sortOrder === "asc" ? t("sort.asc") : t("sort.desc")}
>
{sortOrder === "asc" ? "↑" : "↓"}
</button>
</div>
</div>
);
}

View file

@ -1,66 +0,0 @@
"use client";
import { ThemeToggle } from "./ThemeToggle";
import { User, LogOut } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useTranslation } from "react-i18next";
interface HeaderProps {
userName: string;
}
export function Header({ userName }: HeaderProps) {
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
return (
<header className="h-14 shrink-0 flex items-center justify-between px-4 md:px-6 border-b border-border-light dark:border-border-dark bg-surface-light dark:bg-surface-dark">
{/* Spacer for mobile hamburger */}
<div className="w-10 md:hidden" />
<div className="hidden md:block text-sm font-medium text-bleu">
{t("app.name")}
</div>
<div className="flex items-center gap-2">
<ThemeToggle />
{/* User menu */}
<div className="relative">
<button
onClick={() => setMenuOpen(!menuOpen)}
className="flex items-center gap-2 p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 transition-colors text-sm"
>
<User size={18} />
<span className="hidden sm:inline max-w-[120px] truncate">
{userName}
</span>
</button>
{menuOpen && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute right-0 top-full mt-1 z-50 w-48 bg-surface-light dark:bg-surface-dark border border-border-light dark:border-border-dark rounded-lg shadow-lg py-1">
<div className="px-3 py-2 text-xs text-foreground/50 truncate">
{userName}
</div>
<a
href="/api/logto/sign-out"
className="flex items-center gap-2 px-3 py-2 text-sm text-rouge hover:bg-rouge/10 transition-colors"
onClick={() => setMenuOpen(false)}
>
<LogOut size={14} />
{t("auth.signOut")}
</a>
</div>
</>
)}
</div>
</div>
</header>
);
}

View file

@ -1,7 +0,0 @@
"use client";
import "@/i18n";
export function I18nProvider({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View file

@ -1,190 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
Inbox,
Plus,
Tag,
Menu,
X,
ChevronDown,
ChevronRight,
LogOut,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { List as ListType, Tag as TagType } from "@/lib/types";
interface SidebarProps {
lists: ListType[];
tags: TagType[];
}
export function Sidebar({ lists, tags }: SidebarProps) {
const { t } = useTranslation();
const pathname = usePathname();
const router = useRouter();
const [mobileOpen, setMobileOpen] = useState(false);
const [showNewList, setShowNewList] = useState(false);
const [newListName, setNewListName] = useState("");
const [tagsExpanded, setTagsExpanded] = useState(false);
const handleCreateList = async () => {
const name = newListName.trim();
if (!name) return;
await fetch("/api/lists", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
setNewListName("");
setShowNewList(false);
router.refresh();
};
const sidebarContent = (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-border-light dark:border-border-dark">
<h1 className="text-lg font-bold text-bleu">{t("app.name")}</h1>
</div>
{/* Lists */}
<nav className="flex-1 overflow-y-auto p-2 space-y-1">
<p className="px-3 py-1 text-xs font-semibold uppercase text-foreground/50">
{t("sidebar.lists")}
</p>
{lists.map((list) => {
const isActive = pathname === `/lists/${list.id}`;
return (
<Link
key={list.id}
href={`/lists/${list.id}`}
onClick={() => setMobileOpen(false)}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
isActive
? "bg-bleu/10 text-bleu font-medium"
: "hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
{list.isInbox ? (
<Inbox size={16} className="text-bleu shrink-0" />
) : (
<span
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: list.color || "#4A90A4" }}
/>
)}
<span className="truncate">{list.name}</span>
</Link>
);
})}
{/* New list form */}
{showNewList ? (
<div className="px-3 py-1">
<input
autoFocus
value={newListName}
onChange={(e) => setNewListName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreateList();
if (e.key === "Escape") {
setShowNewList(false);
setNewListName("");
}
}}
placeholder={t("sidebar.newListPlaceholder")}
className="w-full px-2 py-1 text-sm border border-border-light dark:border-border-dark rounded bg-transparent focus:outline-none focus:border-bleu"
/>
</div>
) : (
<button
onClick={() => setShowNewList(true)}
className="flex items-center gap-2 px-3 py-2 text-sm text-foreground/60 hover:text-foreground transition-colors w-full"
>
<Plus size={16} />
{t("sidebar.newList")}
</button>
)}
{/* Tags section */}
<div className="mt-4">
<button
onClick={() => setTagsExpanded(!tagsExpanded)}
className="flex items-center gap-2 px-3 py-1 text-xs font-semibold uppercase text-foreground/50 w-full hover:text-foreground/70"
>
{tagsExpanded ? (
<ChevronDown size={12} />
) : (
<ChevronRight size={12} />
)}
{t("sidebar.tags")}
</button>
{tagsExpanded &&
tags.map((tag) => (
<div
key={tag.id}
className="flex items-center gap-2 px-3 py-1.5 text-sm"
>
<Tag size={14} style={{ color: tag.color }} />
<span>{tag.name}</span>
</div>
))}
</div>
</nav>
{/* Sign out */}
<div className="p-4 border-t border-border-light dark:border-border-dark">
<a
href="/api/logto/sign-out"
className="flex items-center gap-2 text-sm text-foreground/60 hover:text-rouge transition-colors"
>
<LogOut size={16} />
{t("auth.signOut")}
</a>
</div>
</div>
);
return (
<>
{/* Mobile hamburger */}
<button
onClick={() => setMobileOpen(true)}
className="md:hidden fixed top-3 left-3 z-50 p-2 rounded-lg bg-surface-light dark:bg-surface-dark shadow-md"
>
<Menu size={20} />
</button>
{/* Mobile overlay */}
{mobileOpen && (
<div
className="md:hidden fixed inset-0 bg-black/50 z-40"
onClick={() => setMobileOpen(false)}
/>
)}
{/* Mobile sidebar */}
<aside
className={`md:hidden fixed inset-y-0 left-0 z-50 w-72 bg-surface-light dark:bg-surface-dark transform transition-transform ${
mobileOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<button
onClick={() => setMobileOpen(false)}
className="absolute top-3 right-3 p-1"
>
<X size={20} />
</button>
{sidebarContent}
</aside>
{/* Desktop sidebar */}
<aside className="hidden md:flex md:w-64 md:shrink-0 bg-surface-light dark:bg-surface-dark border-r border-border-light dark:border-border-dark">
{sidebarContent}
</aside>
</>
);
}

View file

@ -1,166 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Plus, X } from "lucide-react";
import { useTranslation } from "react-i18next";
interface TaskFormProps {
listId: string;
parentId?: string;
onClose?: () => void;
}
export function TaskForm({ listId, parentId, onClose }: TaskFormProps) {
const { t } = useTranslation();
const router = useRouter();
const [title, setTitle] = useState("");
const [notes, setNotes] = useState("");
const [priority, setPriority] = useState(0);
const [dueDate, setDueDate] = useState("");
const [recurrence, setRecurrence] = useState("");
const [expanded, setExpanded] = useState(false);
const [submitting, setSubmitting] = useState(false);
const PRIORITY_LABELS = [
{ value: 0, label: t("priority.none"), color: "" },
{ value: 1, label: t("priority.low"), color: "text-vert" },
{ value: 2, label: t("priority.medium"), color: "text-sable" },
{ value: 3, label: t("priority.high"), color: "text-rouge" },
];
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim() || submitting) return;
setSubmitting(true);
try {
await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: title.trim(),
notes: notes || undefined,
priority,
dueDate: dueDate || undefined,
recurrence: recurrence || undefined,
listId,
parentId: parentId || undefined,
}),
});
setTitle("");
setNotes("");
setPriority(0);
setDueDate("");
setRecurrence("");
setExpanded(false);
router.refresh();
onClose?.();
} finally {
setSubmitting(false);
}
};
if (!expanded && !parentId) {
return (
<button
onClick={() => setExpanded(true)}
className="flex items-center gap-2 w-full px-4 py-3 text-sm text-foreground/60 hover:text-foreground border border-dashed border-border-light dark:border-border-dark rounded-lg hover:border-bleu transition-colors"
>
<Plus size={16} />
{t("task.add")}
</button>
);
}
return (
<form
onSubmit={handleSubmit}
className="border border-border-light dark:border-border-dark rounded-lg p-4 space-y-3 bg-surface-light dark:bg-surface-dark"
>
<div className="flex items-center gap-2">
<input
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={parentId ? t("task.subtaskPlaceholder") : t("task.titlePlaceholder")}
className="flex-1 bg-transparent text-sm focus:outline-none placeholder:text-foreground/40"
/>
{!parentId && (
<button
type="button"
onClick={() => {
setExpanded(false);
onClose?.();
}}
className="p-1 text-foreground/40 hover:text-foreground"
>
<X size={16} />
</button>
)}
</div>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t("task.notesPlaceholder")}
rows={2}
className="w-full bg-transparent text-sm border border-border-light dark:border-border-dark rounded px-2 py-1 focus:outline-none focus:border-bleu resize-none placeholder:text-foreground/40"
/>
<div className="flex flex-wrap items-center gap-3">
<select
value={priority}
onChange={(e) => setPriority(Number(e.target.value))}
className="bg-transparent border border-border-light dark:border-border-dark rounded px-2 py-1 text-sm focus:outline-none focus:border-bleu"
>
{PRIORITY_LABELS.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="bg-transparent border border-border-light dark:border-border-dark rounded px-2 py-1 text-sm focus:outline-none focus:border-bleu"
/>
<select
value={recurrence}
onChange={(e) => setRecurrence(e.target.value)}
className="bg-transparent border border-border-light dark:border-border-dark rounded px-2 py-1 text-sm focus:outline-none focus:border-bleu"
>
<option value="">{t("recurrence.none")}</option>
<option value="daily">{t("recurrence.daily")}</option>
<option value="weekly">{t("recurrence.weekly")}</option>
<option value="monthly">{t("recurrence.monthly")}</option>
<option value="yearly">{t("recurrence.yearly")}</option>
</select>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setExpanded(false);
setTitle("");
onClose?.();
}}
className="px-3 py-1.5 text-sm text-foreground/60 hover:text-foreground"
>
{t("task.cancel")}
</button>
<button
type="submit"
disabled={!title.trim() || submitting}
className="px-3 py-1.5 text-sm bg-bleu text-white rounded-lg hover:bg-bleu/90 disabled:opacity-50 transition-colors"
>
{submitting ? "..." : t("task.addBtn")}
</button>
</div>
</form>
);
}

View file

@ -1,319 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import {
ChevronDown,
ChevronRight,
Trash2,
Calendar,
Repeat,
Check,
Search,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { Task } from "@/lib/types";
import { TaskForm } from "./TaskForm";
const PRIORITY_COLORS: Record<number, string> = {
0: "",
1: "border-l-vert",
2: "border-l-sable",
3: "border-l-rouge",
};
function formatDate(dateStr: string | Date | null): string {
if (!dateStr) return "";
const d = new Date(dateStr);
return d.toLocaleDateString("fr-CA", {
month: "short",
day: "numeric",
});
}
interface TaskItemProps {
task: Task;
subtasks?: Task[];
depth?: number;
}
export function TaskItem({ task, subtasks = [], depth = 0 }: TaskItemProps) {
const { t } = useTranslation();
const router = useRouter();
const [expanded, setExpanded] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [editing, setEditing] = useState(false);
const [title, setTitle] = useState(task.title);
const [notes, setNotes] = useState(task.notes || "");
const [priority, setPriority] = useState(task.priority);
const [dueDate, setDueDate] = useState(
task.dueDate ? new Date(String(task.dueDate)).toISOString().split("T")[0] : ""
);
const [recurrence, setRecurrence] = useState(task.recurrence || "");
const [showSubtaskForm, setShowSubtaskForm] = useState(false);
const [saving, setSaving] = useState(false);
const PRIORITY_LABELS: Record<number, string> = {
0: t("priority.none"),
1: t("priority.low"),
2: t("priority.medium"),
3: t("priority.high"),
};
const RECURRENCE_LABELS: Record<string, string> = {
daily: t("recurrence.daily"),
weekly: t("recurrence.weekly"),
monthly: t("recurrence.monthly"),
yearly: t("recurrence.yearly"),
};
// Sync state when task prop changes
useEffect(() => {
setTitle(task.title);
setNotes(task.notes || "");
setPriority(task.priority);
setDueDate(
task.dueDate ? new Date(String(task.dueDate)).toISOString().split("T")[0] : ""
);
setRecurrence(task.recurrence || "");
}, [task]);
const toggleComplete = async () => {
await fetch(`/api/tasks/${task.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: !task.completed }),
});
router.refresh();
};
const saveEdit = async () => {
if (!title.trim() || saving) return;
setSaving(true);
try {
await fetch(`/api/tasks/${task.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: title.trim(),
notes: notes || null,
priority,
dueDate: dueDate || null,
recurrence: recurrence || null,
}),
});
setEditing(false);
router.refresh();
} finally {
setSaving(false);
}
};
const deleteTask = async () => {
await fetch(`/api/tasks/${task.id}`, { method: "DELETE" });
router.refresh();
};
const borderClass = PRIORITY_COLORS[task.priority] || "";
return (
<div style={{ marginLeft: depth * 24 }}>
<div
className={`border border-border-light dark:border-border-dark rounded-lg mb-1.5 ${borderClass} ${
borderClass ? "border-l-[3px]" : ""
} ${task.completed ? "opacity-60" : ""}`}
>
{/* Main row */}
<div className="flex items-center gap-2 px-3 py-2">
{/* Expand subtasks toggle — only shown when subtasks exist */}
{subtasks.length > 0 ? (
<button
onClick={() => setExpanded(!expanded)}
className="p-0.5 text-foreground/40 hover:text-foreground shrink-0"
>
{expanded ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
</button>
) : (
<span className="w-[18px] shrink-0" />
)}
{/* Checkbox */}
<button
onClick={toggleComplete}
className={`w-5 h-5 rounded border-2 shrink-0 flex items-center justify-center transition-colors ${
task.completed
? "bg-bleu border-bleu text-white"
: "border-foreground/30 hover:border-bleu"
}`}
>
{task.completed && <Check size={12} />}
</button>
{/* Title — click opens detail */}
<span
className={`flex-1 text-sm cursor-pointer ${
task.completed ? "line-through text-foreground/50" : ""
}`}
onClick={() => setDetailOpen(!detailOpen)}
>
{task.title}
</span>
{/* Badges */}
{task.dueDate && (
<span className="flex items-center gap-1 text-xs text-foreground/50">
<Calendar size={12} />
{formatDate(task.dueDate)}
</span>
)}
{task.recurrence && (
<span className="text-xs text-foreground/50">
<Repeat size={12} />
</span>
)}
{subtasks.length > 0 && (
<span className="text-xs text-foreground/40">
{subtasks.filter((s) => s.completed).length}/{subtasks.length}
</span>
)}
{/* Detail view toggle */}
<button
onClick={() => setDetailOpen(!detailOpen)}
className={`p-0.5 shrink-0 transition-colors ${
detailOpen ? "text-bleu" : "text-foreground/30 hover:text-foreground/60"
}`}
>
<Search size={14} />
</button>
</div>
{/* Detail view */}
{detailOpen && (
<div className="px-3 pb-3 pt-1 border-t border-border-light dark:border-border-dark">
{editing ? (
<div className="space-y-2">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full bg-transparent text-sm font-medium focus:outline-none border-b border-border-light dark:border-border-dark pb-1"
/>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t("task.notesPlaceholder")}
rows={2}
className="w-full bg-transparent text-sm border border-border-light dark:border-border-dark rounded px-2 py-1 focus:outline-none focus:border-bleu resize-none placeholder:text-foreground/40"
/>
<div className="flex flex-wrap gap-2 items-center">
<select
value={priority}
onChange={(e) => setPriority(Number(e.target.value))}
className="bg-transparent border border-border-light dark:border-border-dark rounded px-2 py-1 text-sm focus:outline-none"
>
<option value={0}>{t("priority.noneExplicit")}</option>
<option value={1}>{t("priority.low")}</option>
<option value={2}>{t("priority.medium")}</option>
<option value={3}>{t("priority.high")}</option>
</select>
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="bg-transparent border border-border-light dark:border-border-dark rounded px-2 py-1 text-sm focus:outline-none"
/>
<select
value={recurrence}
onChange={(e) => setRecurrence(e.target.value)}
className="bg-transparent border border-border-light dark:border-border-dark rounded px-2 py-1 text-sm focus:outline-none"
>
<option value="">{t("recurrence.none")}</option>
<option value="daily">{t("recurrence.daily")}</option>
<option value="weekly">{t("recurrence.weekly")}</option>
<option value="monthly">{t("recurrence.monthly")}</option>
<option value="yearly">{t("recurrence.yearly")}</option>
</select>
</div>
<div className="flex gap-2 justify-end">
<button
onClick={() => setEditing(false)}
className="px-3 py-1 text-sm text-foreground/60 hover:text-foreground"
>
{t("task.cancel")}
</button>
<button
onClick={saveEdit}
disabled={!title.trim() || saving}
className="px-3 py-1 text-sm bg-bleu text-white rounded hover:bg-bleu/90 disabled:opacity-50"
>
{saving ? "..." : t("task.save")}
</button>
</div>
</div>
) : (
<div className="space-y-2">
{task.notes && (
<p className="text-sm text-foreground/70">{task.notes}</p>
)}
<div className="flex flex-wrap gap-2 text-xs text-foreground/50">
{task.priority > 0 && (
<span>{t("task.priorityLabel", { value: PRIORITY_LABELS[task.priority] })}</span>
)}
{task.dueDate && (
<span>{t("task.dueDate", { value: formatDate(task.dueDate) })}</span>
)}
{task.recurrence && (
<span>{t("task.recurrenceLabel", { value: RECURRENCE_LABELS[task.recurrence] || task.recurrence })}</span>
)}
</div>
<div className="flex gap-2 pt-1">
<button
onClick={() => setEditing(true)}
className="text-xs text-bleu hover:underline"
>
{t("task.edit")}
</button>
{depth < 1 && (
<button
onClick={() => setShowSubtaskForm(!showSubtaskForm)}
className="text-xs text-bleu hover:underline"
>
{t("task.addSubtask")}
</button>
)}
<button
onClick={deleteTask}
className="text-xs text-rouge hover:underline flex items-center gap-1"
>
<Trash2 size={12} />
{t("task.delete")}
</button>
</div>
</div>
)}
</div>
)}
</div>
{/* Subtask form */}
{showSubtaskForm && detailOpen && (
<div style={{ marginLeft: 24 }} className="mb-1.5">
<TaskForm
listId={task.listId}
parentId={task.id}
onClose={() => setShowSubtaskForm(false)}
/>
</div>
)}
{/* Subtasks — toggled by chevron */}
{expanded && subtasks.map((sub) => (
<TaskItem key={sub.id} task={sub} depth={depth + 1} />
))}
</div>
);
}

View file

@ -1,75 +0,0 @@
"use client";
import type { Task } from "@/lib/types";
import { TaskItem } from "./TaskItem";
import { TaskForm } from "./TaskForm";
import { FilterBar } from "./FilterBar";
import { ClipboardList, RefreshCw } from "lucide-react";
import { Suspense, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
interface TaskListProps {
tasks: Task[];
subtasksMap: Record<string, Task[]>;
listId: string;
listName: string;
}
export function TaskList({ tasks, subtasksMap, listId, listName }: TaskListProps) {
const { t } = useTranslation();
const router = useRouter();
const [refreshing, setRefreshing] = useState(false);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
router.refresh();
// Brief visual feedback
setTimeout(() => setRefreshing(false), 500);
}, [router]);
return (
<div className="max-w-2xl mx-auto w-full">
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xl font-semibold">{listName}</h2>
<button
onClick={handleRefresh}
disabled={refreshing}
className="p-1.5 text-foreground/40 hover:text-foreground transition-colors disabled:opacity-50"
title={t("task.refresh")}
>
<RefreshCw size={18} className={refreshing ? "animate-spin" : ""} />
</button>
</div>
<Suspense fallback={null}>
<FilterBar />
</Suspense>
</div>
{/* Add task */}
<div className="mb-4">
<TaskForm listId={listId} />
</div>
{/* Tasks */}
{tasks.length === 0 ? (
<div className="text-center py-12 text-foreground/40">
<ClipboardList size={48} className="mx-auto mb-3 opacity-50" />
<p>{t("task.empty")}</p>
</div>
) : (
<div className="space-y-0">
{tasks.map((task) => (
<TaskItem
key={task.id}
task={task}
subtasks={subtasksMap[task.id] || []}
/>
))}
</div>
)}
</div>
);
}

View file

@ -1,14 +0,0 @@
// Inline script to set dark class before first paint (avoids flash)
export function ThemeScript() {
const script = `
(function() {
try {
var theme = localStorage.getItem('sl-theme') || 'system';
var isDark = theme === 'dark' ||
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) document.documentElement.classList.add('dark');
} catch(e) {}
})();
`;
return <script dangerouslySetInnerHTML={{ __html: script }} />;
}

View file

@ -1,43 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { Sun, Moon, Monitor } from "lucide-react";
import { useTranslation } from "react-i18next";
type Theme = "light" | "dark" | "system";
export function ThemeToggle() {
const { t } = useTranslation();
const [theme, setTheme] = useState<Theme>("system");
useEffect(() => {
const stored = localStorage.getItem("sl-theme") as Theme | null;
if (stored) setTheme(stored);
}, []);
useEffect(() => {
localStorage.setItem("sl-theme", theme);
const isDark =
theme === "dark" ||
(theme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", isDark);
}, [theme]);
const cycle = () => {
setTheme((t) => (t === "light" ? "dark" : t === "dark" ? "system" : "light"));
};
const Icon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor;
const themeLabel = t(`theme.${theme}`);
return (
<button
onClick={cycle}
className="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
title={t("theme.label", { value: themeLabel })}
>
<Icon size={20} />
</button>
);
}

View file

@ -1,16 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
export function WelcomeMessage() {
const { t } = useTranslation();
return (
<div className="flex items-center justify-center h-full text-foreground/50">
<div className="text-center space-y-2">
<p className="text-lg">{t("welcome.title")}</p>
<p className="text-sm">{t("welcome.message")}</p>
</div>
</div>
);
}

View file

@ -1,44 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export function useSync() {
const router = useRouter();
useEffect(() => {
let ws: WebSocket | null = null;
let retryTimeout: ReturnType<typeof setTimeout>;
async function connect() {
try {
// Get a WS ticket from the API
const res = await fetch("/api/ws-ticket", { method: "POST" });
if (!res.ok) return;
const { ticket } = await res.json();
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(`${proto}//${window.location.host}/ws?ticket=${ticket}`);
ws.onmessage = () => {
// Any sync message triggers a data refresh
router.refresh();
};
ws.onclose = () => {
// Retry after 10 seconds
retryTimeout = setTimeout(connect, 10000);
};
} catch {
retryTimeout = setTimeout(connect, 10000);
}
}
connect();
return () => {
ws?.close();
clearTimeout(retryTimeout);
};
}, [router]);
}

View file

@ -1,9 +0,0 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export const db = drizzle(pool, { schema });

View file

@ -1,54 +0,0 @@
CREATE TABLE "sl_lists" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"name" text NOT NULL,
"color" text,
"icon" text,
"position" integer DEFAULT 0 NOT NULL,
"is_inbox" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "sl_tags" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"name" text NOT NULL,
"color" text DEFAULT '#4A90A4' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "sl_task_tags" (
"task_id" uuid NOT NULL,
"tag_id" uuid NOT NULL,
CONSTRAINT "sl_task_tags_task_id_tag_id_pk" PRIMARY KEY("task_id","tag_id")
);
--> statement-breakpoint
CREATE TABLE "sl_tasks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"title" text NOT NULL,
"notes" text,
"completed" boolean DEFAULT false NOT NULL,
"completed_at" timestamp with time zone,
"priority" integer DEFAULT 0 NOT NULL,
"due_date" timestamp with time zone,
"list_id" uuid NOT NULL,
"parent_id" uuid,
"position" integer DEFAULT 0 NOT NULL,
"recurrence" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "sl_task_tags" ADD CONSTRAINT "sl_task_tags_task_id_sl_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."sl_tasks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sl_task_tags" ADD CONSTRAINT "sl_task_tags_tag_id_sl_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."sl_tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sl_tasks" ADD CONSTRAINT "sl_tasks_list_id_sl_lists_id_fk" FOREIGN KEY ("list_id") REFERENCES "public"."sl_lists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_sl_lists_user" ON "sl_lists" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_sl_tags_user" ON "sl_tags" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_sl_tasks_user" ON "sl_tasks" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_sl_tasks_list" ON "sl_tasks" USING btree ("list_id");--> statement-breakpoint
CREATE INDEX "idx_sl_tasks_parent" ON "sl_tasks" USING btree ("parent_id");

View file

@ -1,3 +0,0 @@
ALTER TABLE "sl_lists" ALTER COLUMN "user_id" SET DATA TYPE text;--> statement-breakpoint
ALTER TABLE "sl_tasks" ALTER COLUMN "user_id" SET DATA TYPE text;--> statement-breakpoint
ALTER TABLE "sl_tags" ALTER COLUMN "user_id" SET DATA TYPE text;

View file

@ -1,45 +0,0 @@
-- Cleanup duplicate inboxes per user (#60)
-- For each user with more than one active inbox, keep the oldest one
-- (lowest created_at), reassign all tasks to it, and soft-delete the duplicates.
WITH ranked_inboxes AS (
SELECT
id,
user_id,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS rn
FROM sl_lists
WHERE is_inbox = true
AND deleted_at IS NULL
),
canonical AS (
SELECT user_id, id AS canonical_id
FROM ranked_inboxes
WHERE rn = 1
),
duplicates AS (
SELECT r.id AS duplicate_id, c.canonical_id, r.user_id
FROM ranked_inboxes r
JOIN canonical c ON c.user_id = r.user_id
WHERE r.rn > 1
)
-- Reassign tasks from duplicate inboxes to the canonical one
UPDATE sl_tasks
SET list_id = d.canonical_id, updated_at = NOW()
FROM duplicates d
WHERE sl_tasks.list_id = d.duplicate_id
AND sl_tasks.user_id = d.user_id;
--> statement-breakpoint
-- Soft-delete the duplicate inboxes
WITH ranked_inboxes AS (
SELECT
id,
user_id,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS rn
FROM sl_lists
WHERE is_inbox = true
AND deleted_at IS NULL
)
UPDATE sl_lists
SET deleted_at = NOW(), updated_at = NOW()
WHERE id IN (SELECT id FROM ranked_inboxes WHERE rn > 1);

View file

@ -1,410 +0,0 @@
{
"id": "a1bf2951-6318-42a2-adbb-758a703deb0b",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.sl_lists": {
"name": "sl_lists",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"is_inbox": {
"name": "is_inbox",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"idx_sl_lists_user": {
"name": "idx_sl_lists_user",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sl_tags": {
"name": "sl_tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'#4A90A4'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"idx_sl_tags_user": {
"name": "idx_sl_tags_user",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sl_task_tags": {
"name": "sl_task_tags",
"schema": "",
"columns": {
"task_id": {
"name": "task_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"tag_id": {
"name": "tag_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sl_task_tags_task_id_sl_tasks_id_fk": {
"name": "sl_task_tags_task_id_sl_tasks_id_fk",
"tableFrom": "sl_task_tags",
"tableTo": "sl_tasks",
"columnsFrom": [
"task_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"sl_task_tags_tag_id_sl_tags_id_fk": {
"name": "sl_task_tags_tag_id_sl_tags_id_fk",
"tableFrom": "sl_task_tags",
"tableTo": "sl_tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"sl_task_tags_task_id_tag_id_pk": {
"name": "sl_task_tags_task_id_tag_id_pk",
"columns": [
"task_id",
"tag_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sl_tasks": {
"name": "sl_tasks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false
},
"completed": {
"name": "completed",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"completed_at": {
"name": "completed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"priority": {
"name": "priority",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"due_date": {
"name": "due_date",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"list_id": {
"name": "list_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"parent_id": {
"name": "parent_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"recurrence": {
"name": "recurrence",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"idx_sl_tasks_user": {
"name": "idx_sl_tasks_user",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_sl_tasks_list": {
"name": "idx_sl_tasks_list",
"columns": [
{
"expression": "list_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_sl_tasks_parent": {
"name": "idx_sl_tasks_parent",
"columns": [
{
"expression": "parent_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"sl_tasks_list_id_sl_lists_id_fk": {
"name": "sl_tasks_list_id_sl_lists_id_fk",
"tableFrom": "sl_tasks",
"tableTo": "sl_lists",
"columnsFrom": [
"list_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

Some files were not shown because too many files have changed in this diff Show more