Compare commits

..

No commits in common. "master" and "fix/simpl-liste-19-widget-add-button" have entirely different histories.

122 changed files with 569 additions and 16144 deletions

View file

@ -1,76 +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` (et `npm install` pour synchroniser le lockfile)
4. Commit : `chore: bump versionCode to <N>` (ou `chore: bump version to X.Y.Z (versionCode N)` si version aussi bumpee)
5. Push commit sur master, puis tag `vX.Y.Z` et `git push origin vX.Y.Z`
6. Build : `npx --yes eas-cli build --platform android --profile preview --non-interactive`
7. Quand le build est termine : telecharger l'APK, creer la release Forgejo, attacher l'APK
## Upload de l'APK — pattern resilient
```bash
# 1. Telecharger
APK_URL=$(npx --yes eas-cli build:view <build_id> --json | jq -r '.artifacts.buildUrl')
curl -L -o "/tmp/simpl-liste-vX.Y.Z.apk" "$APK_URL"
# 2. Creer la release
TOKEN=$(cat ~/.forgejo-token)
RELEASE_ID=$(curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
"https://git.lacompagniemaximus.com/api/v1/repos/maximus/simpl-liste/releases" \
-d '{"tag_name":"vX.Y.Z","name":"vX.Y.Z","body":"...","draft":false,"prerelease":false}' \
| jq -r '.id')
# 3. Attacher l'APK — RETRY sur 502/504 (variance reseau, pas un probleme infra)
for i in 1 2 3 4 5; do
http=$(curl -X POST -H "Authorization: token $TOKEN" \
"https://git.lacompagniemaximus.com/api/v1/repos/maximus/simpl-liste/releases/$RELEASE_ID/assets?name=simpl-liste-vX.Y.Z.apk" \
-F "attachment=@/tmp/simpl-liste-vX.Y.Z.apk" \
-o /tmp/asset-resp.json -w "%{http_code}" --max-time 300)
case "$http" in
201) echo "Upload OK"; break;;
502|504) echo "Attempt $i: gateway timeout, retry"; sleep 5;;
*) echo "Unexpected: HTTP $http"; cat /tmp/asset-resp.json; break;;
esac
done
```
## 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
## Gotcha — Upload APK : 502/504 transitoire
**Symptome** : `curl -X POST ... -F "attachment=@<apk>"` retourne HTTP 502 ou 504 apres ~60s. La progression curl montre upload coupe a 50-90% de progression. Affecte typiquement les APK ~90MB+.
**Cause** : Traefik (devant Forgejo via Coolify) a un timeout de gateway de 60s sur la fin de requete. Quand la vitesse upload tombe sous ~1.5 MB/s (variance reseau classique sur connexions residentielles), un APK de 92 MB depasse le seuil et la requete est coupee. **Ce n'est pas un probleme de config infra** — c'est purement la vitesse upload du client a un instant donne.
**Fix** : retry. La 2e ou 3e tentative passe quand la bande passante remonte (j'ai mesure 1.3 MB/s puis 17 MB/s a quelques minutes d'intervalle). Le pattern `for i in 1..5` ci-dessus est suffisant — pas besoin de toucher a Traefik ni Forgejo.
**Verification que c'est bien le bon probleme** :
- L'APK precedent (~97 MB) a ete uploade avec succes les semaines passees → infra OK
- Un fichier de test de 11 octets sur la meme release passe en 0.1s → API OK
- HTTP 502 ou 504 apres 60s exactement → timeout gateway, pas erreur de logique
- Vitesse upload curl reportee < 1.5 MB/s confirme la cause
**Path API a connaitre** :
- Creer release : `POST /repos/{owner}/{repo}/releases` (renvoie `.id`)
- Attacher asset : `POST /repos/{owner}/{repo}/releases/{release_id}/assets?name=<filename>` (multipart `attachment`)
- Supprimer asset : `DELETE /repos/{owner}/{repo}/releases/{release_id}/assets/{asset_id}`**inclure le release_id dans le path**, pas seulement l'asset id, sinon HTTP 404

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

@ -1,50 +0,0 @@
# SECURITY — simpl-liste
Rapport d'etat securite. Scanne quotidiennement par `defenseur-simpl-liste`
(04:15 UTC) dans `~/claude-code/defenseurs/`.
## CVE resolues
### 2026-04-24 — Overrides `@xmldom/xmldom` et `uuid`
Correction via `overrides` dans `package.json`. Aucune n'etait exploitable a
runtime dans l'APK : chaine concernee est build-time (iOS build via xcode,
Expo CLI, plist serialization) ou dev-tunnel (ngrok).
| CVE | Severite | Package | Fix version | Etat |
|---|---|---|---|---|
| GHSA-2v35-w6hq-6mfw | HIGH | @xmldom/xmldom | ^0.8.13 | Resolu |
| GHSA-f6ww-3ggp-fr8h | HIGH | @xmldom/xmldom | ^0.8.13 | Resolu |
| GHSA-x6wf-f3px-wcqx | HIGH | @xmldom/xmldom | ^0.8.13 | Resolu |
| GHSA-j759-j44w-7fr8 | HIGH | @xmldom/xmldom | ^0.8.13 | Resolu |
| GHSA-w5hq-g745-h8pq | MEDIUM | uuid | ^11.0.0 | Mitigation partielle (voir ci-dessous) |
## CVE residuelles
### GHSA-w5hq-g745-h8pq — `uuid` buffer bounds check
**Status** : mitigation partielle. L'advisory npm flag `uuid <14.0.0` meme apres
override a `^11.0.0`. Bug concerne `uuid.v3() / v5() / v6()` quand `buf` param
est fourni. Les consommateurs transitifs (`xcode`, `@expo/ngrok`) utilisent
uniquement `uuid.v4()` — donc **pas de code path vulnerable atteint en pratique**.
Non bump vers `^14.0.0` car ESM-only (casserait les imports CJS de xcode et
ngrok dans la chaine build iOS). Voir spec decision D3 pour details.
**Impact** : cascade via 18 advisories transitives (uuid → xcode/ngrok → expo/* → ...),
toutes remontant a la meme racine. Non-bloquant pour la production.
**Re-evaluation** : lors du prochain upgrade Expo SDK (quand xcode + ngrok
passeront a une version compatible avec `uuid@^14` ou retireront uuid).
## Procedure de scan manuel
```bash
cd ~/claude-code/defenseurs && npx tsx src/defenseur.ts defenseur-simpl-liste
```
## Politique de review
- **HIGH** : fix immediat via PR.
- **MEDIUM** : triage sous 7 jours, fix si exploitable.
- **LOW** : accepte tel quel, documente ici.

View file

@ -1,22 +0,0 @@
# STATE — simpl-liste
> Derniere MAJ : 2026-05-10 (par fix-issue #87)
## Position actuelle
Version 1.6.1 (versionCode 13). Remediation vulnerabilites du defenseur en cours
via overrides `@xmldom/xmldom@^0.8.13` et `uuid@^11.0.0` dans `package.json`
(overnight 2026-04-24). 4 CVE HIGH xmldom nettoyees ; 1 advisory uuid residuelle
non-exploitable en pratique (details dans `SECURITY.md`).
## Decisions recentes
- 2026-05-10 : Aligne 6 patches Expo SDK 54 via expo install --fix — expo-doctor 17/17 (ref #87)
- 2026-05-08 : Defenseur rerun confirme 0 findings — overrides existants suffisent, pas de PR necessaire (ref #81)
- 2026-04-24 : overrides xmldom + uuid (spec `spec-decisions-vuln-simpl-liste.md`) — PRs #77, #78, #79 (pending-human).
- 2026-04-23 : PR #71 merged — fix widget render-optimiste + timing instrumentation.
- 2026-04-18 : archive milestone `spec-simpl-liste-web` (12/12 done).
## Blockers actifs
Aucun.

View file

@ -2,7 +2,7 @@
"expo": {
"name": "Simpl-Liste",
"slug": "simpl-liste",
"version": "1.6.4",
"version": "1.2.5",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "simplliste",
@ -24,7 +24,7 @@
"backgroundColor": "#FFF8F0"
},
"edgeToEdgeEnabled": true,
"versionCode": 16
"versionCode": 4
},
"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,13 @@ 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, 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';
@ -27,10 +23,6 @@ export default function SettingsScreen() {
notificationsEnabled, setNotificationsEnabled,
reminderOffset, setReminderOffset,
calendarSyncEnabled, setCalendarSyncEnabled,
widgetPeriodWeeks, setWidgetPeriodWeeks,
syncEnabled, setSyncEnabled,
lastSyncAt, setLastSyncAt,
userId, setUserId,
} = useSettingsStore();
const isDark = (theme === 'system' ? systemScheme : theme) === 'dark';
@ -40,8 +32,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 +92,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,138 +299,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
className={`mb-3 text-xs uppercase tracking-wide ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_600SemiBold' }}
>
{t('widget.title')}
</Text>
<View className={`overflow-hidden rounded-xl ${isDark ? 'bg-[#2A2A2A]' : 'bg-white'}`}>
<View className="px-4 py-3.5">
<View className="flex-row items-center mb-2">
<LayoutGrid size={20} color={isDark ? '#A0A0A0' : '#6B6B6B'} />
<Text
className={`ml-3 text-sm ${isDark ? 'text-[#A0A0A0]' : 'text-[#6B6B6B]'}`}
style={{ fontFamily: 'Inter_500Medium' }}
>
{t('widget.period')}
</Text>
</View>
<View className="flex-row flex-wrap gap-2">
{[
{ value: 1, label: t('widget.periodWeek', { count: 1 }) },
{ value: 2, label: t('widget.periodWeek', { count: 2 }) },
{ value: 4, label: t('widget.periodWeek', { count: 4 }) },
{ value: 0, label: t('widget.periodAll') },
].map((opt) => {
const isActive = widgetPeriodWeeks === opt.value;
return (
<Pressable
key={opt.value}
onPress={() => {
setWidgetPeriodWeeks(opt.value);
syncWidgetData();
}}
className={`rounded-full px-3 py-1.5 ${isActive ? 'bg-bleu' : isDark ? 'bg-[#3A3A3A]' : 'bg-[#E5E7EB]'}`}
>
<Text
className={`text-sm ${isActive ? 'text-white' : isDark ? 'text-[#F5F5F5]' : 'text-[#1A1A1A]'}`}
style={{ fontFamily: isActive ? 'Inter_600SemiBold' : 'Inter_400Regular' }}
>
{opt.label}
</Text>
</Pressable>
);
})}
</View>
</View>
</View>
</View>
{/* Tags Section */}
<View className="px-4 pt-6">
<View className="mb-3 flex-row items-center justify-between">

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

@ -25,7 +25,6 @@ import { colors } from '@/src/theme/colors';
import { useSettingsStore } from '@/src/stores/useSettingsStore';
import { isValidUUID } from '@/src/lib/validation';
import { getPriorityOptions } from '@/src/lib/priority';
import { goBack } from '@/src/lib/navigation';
import { RECURRENCE_OPTIONS } from '@/src/lib/recurrence';
import {
getTaskById,
@ -54,7 +53,6 @@ type TaskData = {
priority: number;
dueDate: Date | null;
listId: string;
parentId: string | null;
recurrence: string | null;
};
@ -77,8 +75,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 }[]>([]);
@ -130,9 +126,8 @@ export default function TaskDetailScreen() {
listId: selectedListId,
});
await setTagsForTask(task.id, selectedTagIds);
goBack(router);
router.back();
} catch {
// Save failed — stay on screen so user can retry
setSaving(false);
}
};
@ -146,7 +141,7 @@ export default function TaskDetailScreen() {
onPress: async () => {
await deleteTask(id!);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
goBack(router);
router.back();
},
},
]);
@ -165,38 +160,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);
@ -222,7 +185,7 @@ export default function TaskDetailScreen() {
<View
className={`flex-row items-center justify-between border-b px-4 pb-3 pt-14 ${isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'}`}
>
<Pressable onPress={() => goBack(router)} className="p-2.5" hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Pressable onPress={() => router.back()} className="p-2.5" hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<ArrowLeft size={24} color={isDark ? '#F5F5F5' : '#1A1A1A'} />
</Pressable>
<View className="flex-row items-center">
@ -401,71 +364,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>

View file

@ -27,7 +27,6 @@ import { getInboxId, getAllLists } from '@/src/db/repository/lists';
import { getAllTags, setTagsForTask } from '@/src/db/repository/tags';
import { getPriorityOptions } from '@/src/lib/priority';
import { RECURRENCE_OPTIONS } from '@/src/lib/recurrence';
import { goBack } from '@/src/lib/navigation';
import TagChip from '@/src/components/task/TagChip';
const ICON_MAP: Record<string, LucideIcon> = {
@ -84,7 +83,7 @@ export default function NewTaskScreen() {
for (const sub of pendingSubtasks) {
await createTask({ title: sub, listId: selectedListId, parentId: taskId });
}
goBack(router);
router.back();
} catch {
// FK constraint or other DB error — fallback to inbox
setSaving(false);
@ -121,7 +120,7 @@ export default function NewTaskScreen() {
isDark ? 'border-[#3A3A3A]' : 'border-[#E5E7EB]'
}`}
>
<Pressable onPress={() => goBack(router)} className="p-2.5" hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Pressable onPress={() => router.back()} className="p-2.5" hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<X size={24} color={isDark ? '#F5F5F5' : '#1A1A1A'} />
</Pressable>
<Text

688
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,9 @@
{
"name": "simpl-liste",
"main": "index.js",
"version": "1.6.4",
"version": "1.2.5",
"scripts": {
"start": "expo start",
"test": "node tests/smoke.test.cjs",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
@ -13,31 +12,28 @@
"@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",
"expo": "~54.0.34",
"expo-auth-session": "~7.0.11",
"drizzle-orm": "^0.45.1",
"expo": "~54.0.33",
"expo-calendar": "~15.0.8",
"expo-constants": "~18.0.13",
"expo-crypto": "~15.0.9",
"expo-crypto": "~15.0.8",
"expo-file-system": "~19.0.21",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
"expo-intent-launcher": "~13.0.8",
"expo-linking": "~8.0.12",
"expo-linking": "~8.0.11",
"expo-localization": "~17.0.8",
"expo-notifications": "~0.32.17",
"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",
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.11",
"expo-web-browser": "~15.0.10",
"i18next": "^25.8.13",
"lucide-react-native": "^0.575.0",
"nativewind": "^4.2.2",
@ -65,10 +61,7 @@
"typescript": "~5.9.2"
},
"overrides": {
"esbuild": "^0.25.0",
"@xmldom/xmldom": "^0.8.13",
"uuid": "^11.1.1",
"postcss": "^8.5.10"
"esbuild": "^0.25.0"
},
"private": true
}

View file

@ -1,603 +0,0 @@
# Spec — Simpl-Liste Web
> Date: 2026-04-06
> Projet: simpl-liste
> Statut: Draft
> Dependance: Logto IdP (deploye et operationnel)
## Contexte
Simpl-Liste est actuellement une app mobile React Native/Expo avec stockage SQLite local (offline-first, sans compte). L'objectif est de rendre l'application disponible via le web, integree au systeme d'authentification Compte Maximus (Logto), avec synchronisation bidirectionnelle entre le mobile et le web.
L'app mobile continue de fonctionner sans compte. Le login debloque la sync ; il reste optionnel.
## Objectif
Permettre aux utilisateurs connectes avec un Compte Maximus de gerer leurs listes et taches depuis `liste.lacompagniemaximus.com`, avec sync hybride : temps reel sur le web (WebSocket), polling sur le mobile. Parite fonctionnelle complete avec le mobile (hors widgets, notifications push, et sync calendrier).
## Scope
### IN
- Frontend web Next.js App Router dans `simpl-liste/web/`
- API REST backend (route handlers Next.js) pour CRUD listes, taches, tags, sous-taches
- WebSocket server pour notifications temps reel sur le web
- Schema PostgreSQL (`sl_` prefix) avec Drizzle ORM (`drizzle-orm/pg-core`)
- Auth via Logto (Compte Maximus) — SDK `@logto/next`
- Sync hybride : WebSocket web + polling mobile
- Login optionnel dans l'app mobile avec choix de migration des donnees locales
- Types TypeScript partages entre mobile et web (`src/shared/`)
- i18n FR/EN (francais par defaut)
- Dark mode (light/dark/system)
- Responsive design (utilisable sur mobile)
- Deploiement sur Coolify (`liste.lacompagniemaximus.com`)
### OUT (explicitement exclu)
- Widgets Android (specifique mobile)
- Notifications push (specifique mobile)
- Sync calendrier systeme (specifique mobile via expo-calendar)
- Export ICS depuis le web (future phase)
- Mode offline pour le web (connexion requise)
- Partage de listes entre utilisateurs (future phase)
- App iOS (future phase)
## Design
### Architecture globale
```
simpl-liste/
├── app/ # App mobile React Native (existant)
├── src/ # Code mobile (existant)
│ ├── shared/ # NOUVEAU — types, constantes, logique partagee
│ │ ├── types.ts # Task, List, Tag, TaskFilters, etc.
│ │ ├── colors.ts # Palette (extrait de theme/colors.ts)
│ │ ├── priority.ts # Helpers priorite (extrait de lib/priority.ts)
│ │ └── recurrence.ts # Calcul recurrence (extrait de lib/recurrence.ts)
│ └── ...
└── web/ # NOUVEAU — App Next.js
├── package.json
├── next.config.ts
├── server.ts # Custom server (Next.js + WebSocket)
├── Dockerfile
├── src/db/
│ ├── schema.ts # Schema PostgreSQL Drizzle (sl_ tables)
│ ├── client.ts # Connexion pg + drizzle instance
│ └── migrations/ # Migrations SQL (drizzle-kit generate)
├── src/
│ ├── app/ # App Router (pages, layouts, API routes)
│ │ ├── layout.tsx
│ │ ├── page.tsx # Redirect vers /lists ou /auth
│ │ ├── auth/ # Login via Logto
│ │ ├── api/ # Route handlers REST
│ │ │ ├── lists/
│ │ │ ├── tasks/
│ │ │ ├── tags/
│ │ │ └── sync/
│ │ └── (app)/ # Pages authentifiees
│ │ ├── layout.tsx # Sidebar listes + main area
│ │ ├── inbox/
│ │ └── lists/[id]/
│ ├── lib/
│ │ ├── auth.ts # Logto middleware + session
│ │ ├── db.ts # Re-export drizzle client
│ │ └── ws.ts # WebSocket server + broadcast
│ ├── components/ # Composants React web
│ └── i18n/ # next-intl ou i18next
└── tailwind.config.ts # Palette partagee depuis src/shared/colors.ts
```
### ORM unifie — Drizzle partout
Le projet utilise **Drizzle ORM** sur les deux plateformes :
- **Mobile** : `drizzle-orm/sqlite-core` + `expo-sqlite` (existant)
- **Web/serveur** : `drizzle-orm/pg-core` + `pg` (nouveau)
Un seul ORM a maitriser, un seul outil de migration (`drizzle-kit`), pas de codegen (contrairement a Prisma). Les schemas SQLite et PostgreSQL partagent la meme structure de colonnes — les types inferes par Drizzle (`$inferSelect`) sont directement compatibles.
Le dossier `src/shared/types.ts` exporte les types metier communs (filtres, tri, recurrence, priorites). Les types des entites (Task, List, Tag) sont inferes depuis les schemas Drizzle respectifs — pas besoin d'une couche de mapping manuelle.
```typescript
// src/shared/types.ts — types partages (filtres, enums, helpers)
export type RecurrenceType = 'daily' | 'weekly' | 'monthly' | 'yearly';
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; /* ... */ }
// Les types des entites sont inferes depuis les schemas Drizzle de chaque plateforme :
// Mobile : typeof tasks.$inferSelect (depuis src/db/schema.ts, sqlite-core)
// Web : typeof slTasks.$inferSelect (depuis web/src/db/schema.ts, pg-core)
```
### Identite utilisateur — Compte Maximus
Le `userId` stocke dans les tables PostgreSQL est le **`sub` claim du JWT Logto**. Ce choix est delibere : Logto est le systeme d'identite centralise de La Compagnie Maximus. Toutes les apps actuelles et futures (la-suite-booking, famille-website, etc.) migreront vers Logto comme IdP unique. Utiliser le `sub` comme identifiant commun garantit la compatibilite cross-app et permettra un futur dashboard Compte Maximus unifie.
### Schema PostgreSQL (Drizzle pg-core)
Base sur le schema SQLite existant (`src/db/schema.ts`), avec ajout de `userId` (= `sub` Logto) pour l'isolation multi-utilisateur et `deletedAt` pour le soft-delete (sync). Structure quasi identique au schema mobile — meme ORM, memes conventions.
```typescript
// web/src/db/schema.ts
import { pgTable, uuid, text, integer, boolean, timestamp, primaryKey, index } from 'drizzle-orm/pg-core';
export const slLists = pgTable('sl_lists', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
name: text('name').notNull(),
color: text('color'),
icon: text('icon'),
position: integer('position').notNull().default(0),
isInbox: boolean('is_inbox').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
}, (table) => [
index('idx_sl_lists_user').on(table.userId),
]);
export const slTasks = pgTable('sl_tasks', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
title: text('title').notNull(),
notes: text('notes'),
completed: boolean('completed').notNull().default(false),
completedAt: timestamp('completed_at', { withTimezone: true }),
priority: integer('priority').notNull().default(0), // 0=none, 1=low, 2=medium, 3=high
dueDate: timestamp('due_date', { withTimezone: true }),
listId: uuid('list_id').notNull().references(() => slLists.id, { onDelete: 'cascade' }),
parentId: uuid('parent_id').references((): any => slTasks.id, { onDelete: 'cascade' }),
position: integer('position').notNull().default(0),
recurrence: text('recurrence'), // 'daily'|'weekly'|'monthly'|'yearly'
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
}, (table) => [
index('idx_sl_tasks_user').on(table.userId),
index('idx_sl_tasks_list').on(table.listId),
index('idx_sl_tasks_parent').on(table.parentId),
]);
export const slTags = pgTable('sl_tags', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
name: text('name').notNull(),
color: text('color').notNull().default('#4A90A4'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
}, (table) => [
index('idx_sl_tags_user').on(table.userId),
]);
export const slTaskTags = pgTable('sl_task_tags', {
taskId: uuid('task_id').notNull().references(() => slTasks.id, { onDelete: 'cascade' }),
tagId: uuid('tag_id').notNull().references(() => slTags.id, { onDelete: 'cascade' }),
}, (table) => [
primaryKey({ columns: [table.taskId, table.tagId] }),
]);
```
Notes :
- `deletedAt` (soft-delete) sur lists, tasks, tags — necessaire pour la sync (les suppressions doivent etre propagees aux clients)
- `calendar_event_id` omis (specifique mobile)
- `updatedAt` sert de vecteur de version pour le last-write-wins
- La table `sl_tags` n'a pas de `updatedAt` dans le schema SQLite mobile — a ajouter cote mobile pour la sync
- Le schema PostgreSQL est structurellement identique au schema SQLite mobile, avec `userId` et `deletedAt` en plus — memes noms de colonnes, memes types logiques
### API REST
Base URL: `https://liste.lacompagniemaximus.com/api`
Auth: Session Logto (cookie) pour le web, Bearer JWT pour le mobile
#### Listes
| Methode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/api/lists` | Toutes les listes de l'utilisateur |
| POST | `/api/lists` | Creer une liste |
| PUT | `/api/lists/:id` | Modifier une liste |
| DELETE | `/api/lists/:id` | Soft-delete (cascade taches) |
| PUT | `/api/lists/reorder` | Reordonner (batch positions) |
#### Taches
| Methode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/api/lists/:listId/tasks` | Taches d'une liste (filtres: completed, priority, dueDate, tags) |
| GET | `/api/tasks/:id/subtasks` | Sous-taches |
| POST | `/api/tasks` | Creer une tache |
| PUT | `/api/tasks/:id` | Modifier (titre, notes, completed, priorite, dueDate, listId, position, recurrence) |
| DELETE | `/api/tasks/:id` | Soft-delete (cascade sous-taches) |
| PUT | `/api/tasks/reorder` | Reordonner dans une liste |
#### Tags
| Methode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/api/tags` | Tous les tags de l'utilisateur |
| POST | `/api/tags` | Creer un tag |
| PUT | `/api/tags/:id` | Modifier un tag |
| DELETE | `/api/tags/:id` | Soft-delete |
| POST | `/api/tasks/:id/tags` | Assigner des tags a une tache |
| DELETE | `/api/tasks/:taskId/tags/:tagId` | Retirer un tag |
#### Sync (mobile)
| Methode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/api/sync?since=<ISO timestamp>` | Changements serveur depuis timestamp (inclut soft-deletes) |
| POST | `/api/sync` | Push batch de changements locaux (avec idempotency keys) |
#### WebSocket (web)
| Methode | Endpoint | Description |
|---------|----------|-------------|
| POST | `/api/ws-ticket` | Generer un ticket ephemere (nonce, TTL 30s) pour le handshake WS |
| WS | `/ws?ticket=<nonce>` | Connexion WebSocket (ticket a usage unique) |
### Sync Strategy — Hybride
#### Web : WebSocket temps reel
- Custom server Next.js (`server.ts`) qui attache un serveur `ws` sur le meme port HTTP (path `/ws`)
- A chaque mutation via l'API, le serveur broadcast un message aux WebSocket connectes du meme `userId`
- Le client web ecoute et rafraichit les donnees affectees (pas de re-fetch complet, notification granulaire par entity type + id)
- Reconnexion automatique avec backoff exponentiel cote client
- Heartbeat toutes les 30s pour detecter les connexions mortes
```typescript
// Message WebSocket — notification seulement, pas de payload (securite)
type WsMessage =
| { type: 'sync'; entity: 'list' | 'task' | 'tag'; action: 'create' | 'update' | 'delete'; id: string }
| { type: 'auth_expired' } // session expiree, le client doit se reconnecter
```
#### Mobile : polling periodique
- Sync au lancement de l'app (si connecte)
- Sync toutes les 2 minutes en foreground
- Sync au retour du background (app state change)
- Indicateur visuel de statut sync (icone dans le header)
#### Resolution de conflits
- **Last-write-wins** base sur `updatedAt` — le timestamp le plus recent gagne
- En cas d'egalite exacte (improbable), le serveur gagne
- Les suppressions (soft-delete) sont prioritaires : si un client supprime et l'autre modifie, la suppression gagne
#### Flux de sync mobile
1. Le mobile envoie `POST /api/sync` avec tous les changements locaux depuis `lastSyncAt`
2. Le serveur applique les changements (LWW), retourne les conflits resolus
3. Le mobile fait `GET /api/sync?since=lastSyncAt` pour recuperer les changements serveur
4. Le mobile applique les changements serveur dans SQLite
5. `lastSyncAt` est mis a jour dans AsyncStorage
#### Premiere connexion — Migration des donnees locales
Au premier login dans l'app mobile, une alerte propose :
1. **"Fusionner mes taches"** — Upload toutes les donnees locales vers le serveur, puis sync bidirectionnel
2. **"Repartir du serveur"** — Ecrase les donnees locales avec celles du serveur (si le compte a deja des donnees web)
**Reconciliation de l'Inbox** : L'Inbox mobile a un ID hardcode (`00000000-0000-0000-0000-000000000001`). Le serveur cree une Inbox avec un UUID genere. Lors du merge :
1. Le serveur cree l'Inbox du user si elle n'existe pas
2. Le mobile remap l'ID hardcode vers l'ID Inbox serveur dans toutes les taches locales
3. Push les taches avec le nouvel ID
4. Mettre a jour l'ID Inbox local pour matcher le serveur
#### Outbox pattern — file d'attente locale
Les changements locaux sont d'abord ecrits dans une table SQLite `sync_outbox` avant d'etre envoyes au serveur :
```sql
CREATE TABLE sync_outbox (
id TEXT PRIMARY KEY, -- UUID (= idempotency key)
entity_type TEXT NOT NULL, -- 'list' | 'task' | 'tag' | 'task_tag'
entity_id TEXT NOT NULL, -- ID de l'entite modifiee
action TEXT NOT NULL, -- 'create' | 'update' | 'delete'
payload TEXT NOT NULL, -- JSON de l'operation
created_at TEXT NOT NULL, -- ISO 8601
synced_at TEXT -- NULL tant que pas sync, ISO 8601 apres
);
```
Flux :
1. Chaque mutation locale ecrit dans la table metier ET dans `sync_outbox`
2. Le sync service lit les entries `WHERE synced_at IS NULL`, les envoie en batch via `POST /api/sync`
3. Les entries synced sont marquees (`synced_at = now()`)
4. Les entries de plus de 7 jours sont purgees
5. Si le serveur est inaccessible, l'outbox s'accumule — les changements ne sont jamais perdus
### Auth
#### Web (Next.js)
- SDK `@logto/next` pour l'auth server-side
- Middleware Next.js qui protege toutes les routes sous `/(app)/`
- Session cookie securisee (HttpOnly, SameSite=Strict)
- Extraction du `userId` depuis les claims JWT Logto
- Verification optionnelle du claim `apps.simpl-liste` pour l'acces
#### Mobile (React Native)
- SDK `@logto/rn` pour le flow OAuth2/OIDC
- Bouton "Se connecter" dans Settings > Compte
- Token JWT stocke securise (expo-secure-store)
- Le token est envoye en header `Authorization: Bearer <jwt>` pour les appels API
- Mode deconnecte : l'app fonctionne normalement sans token, la sync est desactivee
### Frontend Web — UX
#### Layout principal
```
┌─────────────────────────────────────────────────┐
│ Simpl-Liste [FR] [🌙] [👤] │
├──────────┬──────────────────────────────────────┤
│ │ │
│ 📥 Inbox │ ☐ Acheter du lait ⚡ Haute │
│ 📋 Courses│ ☐ Appeler le dentiste 📅 Lun │
│ 🏠 Maison │ ☑ Faire le menage ✓ Fait │
│ + Liste │ ☐ Reparer le robinet │
│ │ │
│ Tags │ [+ Nouvelle tache] │
│ 🔵 Urgent│ │
│ 🟢 Perso │ Filtres: [Actives ▾] [Priorite ▾] │
│ │ Tri: [Position ▾] │
├──────────┴──────────────────────────────────────┤
│ ● Connecte — Sync OK │
└─────────────────────────────────────────────────┘
```
- **Sidebar gauche** : listes (avec couleur/icone), section tags, bouton + pour creer
- **Zone principale** : taches de la liste selectionnee, avec filtres et tri en haut
- **Barre d'etat** : statut de connexion et derniere sync
- Edition inline des taches (titre, completion, priorite) — modal pour les details complets (notes, date, tags, sous-taches, recurrence)
- Responsive : sidebar se replie en hamburger menu sur mobile web
### Securite
#### Transport et authentification
- TLS obligatoire (HTTPS/WSS via Caddy) — jamais de connexion non chiffree
- Toutes les requetes API authentifiees (session cookie web / JWT Bearer mobile)
- Session cookie web : `HttpOnly`, `Secure`, `SameSite=Strict`
- JWT mobile : access token court (15 min) + refresh token avec rotation (chaque refresh invalide l'ancien)
- Validation JWT systematique sur chaque requete : signature, `exp`, `aud`, `iss`
#### Stockage des tokens mobile
- Adaptateur de stockage custom pour `@logto/rn` basé sur `expo-secure-store` (Keychain iOS / Keystore Android)
- Ne JAMAIS stocker de tokens dans AsyncStorage (non chiffre, lisible sur appareil roote)
- Ne pas inclure de donnees sensibles dans le payload JWT (le payload est encode en base64, pas chiffre)
#### WebSocket — Anti-CSWSH (Cross-Site WebSocket Hijacking)
- Authentification par **ticket ephemere** (pas par cookie) :
1. Le client appelle `POST /api/ws-ticket` (authentifie par session)
2. Le serveur genere un nonce a usage unique (TTL 30s), stocke en memoire
3. Le client se connecte a `wss://host/ws?ticket=<nonce>`
4. Le serveur valide et invalide le ticket au handshake (usage unique)
- Validation du header `Origin` contre une allowlist (`liste.lacompagniemaximus.com`)
- Re-validation de la session toutes les 15 minutes — fermer la connexion si expiree, envoyer `{ type: 'auth_expired' }` au client
- Ne pas logger les query params sur la route `/ws` (eviter fuite du ticket dans les access logs)
#### Isolation des donnees et autorisation
- Isolation par `userId` sur chaque requete (`WHERE userId = ?`)
- **BOLA prevention sur le sync batch** : pour chaque operation dans `POST /api/sync`, verifier que l'entite appartient au `userId` (`WHERE id = ? AND userId = ?`). Rejeter le batch entier si une seule verification echoue.
- Schemas Zod **strict** sur chaque endpoint — whitelist explicite des champs modifiables
- Ne jamais permettre au client de modifier `userId`, `createdAt`, `deletedAt` via l'API
#### Anti-replay sur les endpoints sync
- **Idempotency keys** : chaque operation dans le batch porte un UUID genere par le client. Le serveur stocke les IDs traites (TTL 24h) et retourne le meme resultat si resoumis.
- **Fenetre temporelle** : rejeter les requetes dont le timestamp depasse +/- 5 minutes
#### Rate limiting granulaire
| Endpoint | Limite par user | Raison |
|----------|----------------|--------|
| `POST /api/sync` | 10/min | Operation lourde (batch) |
| `POST /api/ws-ticket` | 10/min | Prevenir brute-force de tickets |
| `POST /api/lists`, `POST /api/tasks`, `POST /api/tags` | 30/min | Creation |
| `GET /api/*` | 200/min | Lecture |
| Connexions WebSocket | 5/min | Prevenir flood de reconnexions |
#### Messages WebSocket
- Envoyer seulement le type d'entite + id dans les messages WS (pas le contenu des taches)
- Le client re-fetch les donnees modifiees via l'API REST (securisee)
- Evite la fuite de donnees si la connexion WS est compromise
#### Soft-delete et retention
- Purge automatique des enregistrements soft-deleted apres 30 jours (cron job)
- Les clients qui n'ont pas sync depuis 30+ jours effectuent un full sync au lieu d'un delta
- Politique de retention documentee dans les conditions d'utilisation
#### Input validation
- Validation/sanitization (zod) sur tous les champs texte
- Protection XSS : echapper le contenu utilisateur a l'affichage (React le fait par defaut)
- CSRF protection sur les mutations (cookie `SameSite=Strict`)
## Plan de travail
### Issue 1 — Types partages et refactoring mobile [type:task]
Dependances : aucune
- [ ] Creer `src/shared/types.ts` — exporter Task, List, Tag, TaskFilters, SortBy, etc.
- [ ] Creer `src/shared/colors.ts` — extraire la palette de `theme/colors.ts`
- [ ] Creer `src/shared/priority.ts` — extraire de `lib/priority.ts`
- [ ] Creer `src/shared/recurrence.ts` — extraire de `lib/recurrence.ts`
- [ ] Refactorer le code mobile pour importer depuis `shared/`
- [ ] Ajouter `updatedAt` au schema `tags` dans le mobile (migration Drizzle)
### Issue 2 — Setup projet web + schema PostgreSQL [type:task]
Dependances : aucune (parallele avec Issue 1)
- [ ] Init Next.js App Router dans `web/` avec TypeScript, Tailwind, Drizzle ORM (`drizzle-orm/pg-core` + `pg`)
- [ ] Schema Drizzle PostgreSQL (sl_lists, sl_tasks, sl_tags, sl_task_tags) — userId = `sub` Logto
- [ ] Migration initiale + seed (inbox par defaut par user)
- [ ] Endpoint `/api/health` (status API, latence DB, connexions WS actives)
- [ ] Dockerfile + config Coolify pour `liste.lacompagniemaximus.com` (verifier support WS dans Caddy)
- [ ] Configurer Logto app pour le web (redirect URIs, etc.)
### Issue 3 — Auth web + middleware [type:feature]
Dependances : Issue 2
- [ ] Integrer `@logto/next` (sign-in, sign-out, callback)
- [ ] Middleware Next.js : proteger `/(app)/`, extraire userId
- [ ] Page de login (`/auth`) avec redirect vers Logto
- [ ] Gestion de session (cookie securise)
### Issue 4 — API REST backend [type:feature]
Dependances : Issue 2, Issue 3
- [ ] Middleware auth sur `/api/*` (session cookie + Bearer JWT avec validation signature/exp/aud/iss)
- [ ] Endpoints CRUD listes (avec soft-delete)
- [ ] Endpoints CRUD taches (avec filtres, tri, sous-taches, soft-delete)
- [ ] Endpoints CRUD tags + assignation (avec soft-delete)
- [ ] Endpoints sync (GET since + POST batch avec idempotency keys)
- [ ] Verification BOLA par entite sur le batch sync (`WHERE id = ? AND userId = ?`)
- [ ] Schemas Zod strict sur chaque endpoint (whitelist de champs, rejeter champs inconnus)
- [ ] Rate limiting granulaire (sync 10/min, creation 30/min, lecture 200/min)
- [ ] Endpoint `POST /api/ws-ticket` (nonce ephemere TTL 30s, usage unique)
- [ ] Tests API (optionnel mais recommande)
### Issue 5 — WebSocket server [type:feature]
Dependances : Issue 4
- [ ] Custom server (`server.ts`) : Next.js + `ws` sur le meme port
- [ ] Auth WebSocket par ticket ephemere (valider et invalider le nonce au handshake)
- [ ] Validation du header `Origin` contre l'allowlist
- [ ] Broadcast par userId sur chaque mutation API (type + id seulement, pas de payload)
- [ ] Re-validation de session toutes les 15 min (fermer si expiree + message `auth_expired`)
- [ ] Heartbeat 30s + reconnexion auto cote client avec backoff exponentiel
- [ ] Ne pas logger les query params sur la route `/ws`
- [ ] Mettre a jour le Dockerfile pour utiliser le custom server
### Issue 6 — Frontend web [type:feature]
Dependances : Issue 4, Issue 5
- [ ] Layout principal (sidebar listes + zone taches)
- [ ] CRUD listes (creer, renommer, supprimer, couleur/icone, reordonner)
- [ ] CRUD taches (creer, editer inline, completer, supprimer, priorite, date, notes)
- [ ] Sous-taches (affichage imbrique, creer/editer)
- [ ] Tags (creer, assigner, filtrer)
- [ ] Filtres et tri
- [ ] Recurrence (affichage, creation, auto-recurrence au complete)
- [ ] Integration WebSocket (mise a jour temps reel)
- [ ] Dark mode (light/dark/system)
- [ ] i18n FR/EN
- [ ] Responsive design
- [ ] Barre de statut sync
### Issue 7 — Sync mobile [type:feature]
Dependances : Issue 1, Issue 4
- [ ] Integrer `@logto/rn` avec adaptateur `expo-secure-store` (ne pas utiliser AsyncStorage pour les tokens)
- [ ] Bouton login dans Settings > Compte
- [ ] Access token court (15 min) + refresh token avec rotation automatique
- [ ] Intercepteur HTTP pour refresh transparent (capturer 401, rafraichir, relancer)
- [ ] Table `sync_outbox` dans SQLite (migration Drizzle) — outbox pattern
- [ ] Client sync : push outbox entries + pull changes avec idempotency keys
- [ ] Reconciliation Inbox au premier merge (remap ID hardcode → ID serveur)
- [ ] Polling periodique (2 min) + sync au lancement + sync au retour foreground
- [ ] Gestion des conflits LWW
- [ ] Ecran de premiere connexion : choix merge local ou reset serveur
- [ ] Indicateur visuel de statut sync (header)
- [ ] Mode degrade si serveur inaccessible (outbox s'accumule, sync au retour)
### Ordre d'execution
```
Issue 1 (Types partages) ──┐
├── Issue 4 (API REST) ── Issue 5 (WebSocket) ─┐
Issue 2 (Setup web + DB) ──┤ ├── Issue 6 (Frontend)
└── Issue 3 (Auth web) ─┘ │
Issue 1 ── Issue 4 ── Issue 7 (Sync mobile) │
```
## Fichiers concernes
| Fichier | Action | Raison |
|---------|--------|--------|
| `web/` | Creer | Nouveau projet Next.js complet |
| `src/shared/types.ts` | Creer | Types TypeScript partages mobile/web |
| `src/shared/colors.ts` | Creer | Palette centralisee |
| `src/shared/priority.ts` | Creer | Helpers priorite partages |
| `src/shared/recurrence.ts` | Creer | Logique recurrence partagee |
| `src/theme/colors.ts` | Modifier | Importer depuis shared/ |
| `src/lib/priority.ts` | Modifier | Importer depuis shared/ |
| `src/lib/recurrence.ts` | Modifier | Importer depuis shared/ |
| `src/db/schema.ts` | Modifier | Ajouter updatedAt aux tags |
| `src/db/schema.ts` | Modifier | Ajouter table `sync_outbox` |
| `src/db/repository/tasks.ts` | Modifier | Ecrire dans l'outbox a chaque mutation (si connecte) |
| `src/services/syncClient.ts` | Creer | Client sync (push outbox, pull changes, reconciliation Inbox) |
| `src/stores/useSettingsStore.ts` | Modifier | Ajouter syncEnabled, lastSyncAt, userId |
| `app/(tabs)/settings.tsx` | Modifier | Ajouter section Compte (login/logout/sync) |
| `app/_layout.tsx` | Modifier | Init sync polling si connecte |
| `package.json` | Modifier | Ajouter deps auth mobile (@logto/rn, expo-secure-store) |
## Criteres d'acceptation
- [ ] Un utilisateur connecte peut acceder a ses listes depuis liste.lacompagniemaximus.com
- [ ] Les operations CRUD fonctionnent pour les listes, taches, tags et sous-taches sur le web
- [ ] Les modifications sur le web apparaissent en temps reel si deux onglets sont ouverts (WebSocket)
- [ ] Les modifications sur le web apparaissent sur le mobile apres sync polling (et vice versa)
- [ ] L'app mobile continue de fonctionner sans compte (offline-first preserve)
- [ ] Au premier login mobile, l'utilisateur choisit de fusionner ou remplacer ses donnees locales
- [ ] Le site fonctionne en FR et EN, en light et dark mode
- [ ] Le site est responsive (utilisable sur mobile web)
- [ ] Les suppressions se propagent correctement (soft-delete + sync)
- [ ] Le WebSocket se reconnecte automatiquement apres une deconnexion
- [ ] Les tokens mobile sont stockes dans expo-secure-store (pas AsyncStorage)
- [ ] Le handshake WebSocket utilise un ticket ephemere (pas de cookie seul)
- [ ] Les endpoints API rejettent les champs non autorises (userId, createdAt, deletedAt)
- [ ] Le batch sync verifie l'ownership de chaque entite individuellement
- [ ] Les soft-deletes sont purges automatiquement apres 30 jours
## Edge cases et risques
| Cas | Mitigation |
|-----|------------|
| Conflit de modification simultanee (web + mobile) | Last-write-wins sur `updatedAt`. Acceptable pour usage personnel. |
| Suppression sur un appareil, modification sur l'autre | La suppression gagne (soft-delete prioritaire) |
| Serveur inaccessible depuis le mobile | Mode degrade : l'app fonctionne en local, les changements sont queued et sync au retour |
| WebSocket deconnecte | Reconnexion auto avec backoff exponentiel. Fallback : refresh manuel de la page |
| Premiere sync avec beaucoup de donnees locales | Batch l'upload en chunks de 50 entites pour eviter les timeouts |
| Token JWT expire pendant une session mobile | Refresh token avec rotation via Logto SDK. Si echec, re-login requis |
| Deux listes "Inbox" apres merge (locale + serveur) | Remap ID hardcode mobile vers ID Inbox serveur dans toutes les taches locales avant push |
| Utilisateur supprime son compte | Cascade delete de toutes les donnees sl_* du user dans PostgreSQL |
| Custom server Next.js + Coolify | S'assurer que le Dockerfile utilise `node server.ts` et non `next start`. Verifier le support WebSocket dans Caddy. |
| Changements offline perdus (crash app) | Outbox pattern : les mutations sont persistees dans `sync_outbox` SQLite avant envoi |
| Broadcast WS multi-instance | Architecture single-instance pour la v1. Si scaling necessaire, ajouter Redis pub/sub |
| VPS memoire insuffisante | Surveiller via `/api/health`. Prevoir upgrade 8 Go si migration famille-website en parallele |
| CSWSH (Cross-Site WebSocket Hijacking) | Ticket ephemere a usage unique + validation Origin + cookies SameSite=Strict |
| BOLA sur le batch sync | Verification d'ownership par entite dans chaque operation du batch |
| Replay attack sur POST /api/sync | Idempotency keys par operation + fenetre temporelle +/- 5 min |
| Token mobile sur appareil roote | Stockage dans expo-secure-store (Keychain/Keystore), jamais AsyncStorage |
| Session WS survit a l'expiration auth | Re-validation serveur toutes les 15 min, fermeture si expiree |
| Fuite de donnees via messages WS | Messages WS ne contiennent que type + id, pas le contenu des taches |
| Soft-deletes accumules indefiniment | Purge automatique apres 30 jours, full sync pour clients inactifs |
| Mass assignment via l'API | Schemas Zod strict, whitelist de champs modifiables, `userId`/`createdAt`/`deletedAt` non modifiables |
## Decisions prises
| Question | Decision | Raison |
|----------|----------|--------|
| Stack web | Next.js App Router + Drizzle + PostgreSQL | Fullstack dans un seul projet. Drizzle unifie mobile et serveur. |
| Emplacement du code | `simpl-liste/web/` dans le repo existant | Partage des types via `src/shared/`. Un seul repo a maintenir. |
| ORM serveur | Drizzle (`pg-core`) | Meme ORM que le mobile (`sqlite-core`). Un seul outil a maitriser, schemas compatibles, pas de codegen. |
| Auth | Logto (deploye) | Systeme d'auth centralise La Compagnie Maximus. |
| Sync web | WebSocket (via `ws` + custom server) | Temps reel pour le web. Le VPS a largement les ressources (4 cores, 4 Go libres). |
| Sync mobile | Polling periodique (2 min) | React Native ne maintient pas de WebSocket en background. Polling simple et fiable. |
| Conflits | Last-write-wins sur updatedAt | Simple, suffisant pour un usage personnel avec peu d'appareils. |
| Suppressions | Soft-delete (deletedAt) | Necessaire pour propager les suppressions aux autres clients via sync. |
| Compte requis | Non, optionnel | Preserver l'experience offline-first sans friction. Le login debloque la sync. |
| Migration donnees locales | Choix utilisateur (merge ou reset) | Eviter la perte de donnees et laisser le controle a l'utilisateur. |
| Identite utilisateur | `sub` Logto comme `userId` | Compte Maximus unifie cross-app. Toutes les apps actuelles et futures utilisent le meme identifiant. |
| ORM unifie | Drizzle partout (sqlite-core mobile + pg-core serveur) | Un seul ORM, schemas compatibles, types inferes directement, pas de couche de mapping. |
| Reverse proxy | Caddy (pas Traefik) | Caddy est le reverse proxy en place sur le VPS. Support natif WebSocket. |
| Outbox pattern | Table `sync_outbox` dans SQLite mobile | Les changements offline survivent aux crashes. Idempotency keys integrees. |
| Scaling WS | Single-instance, pas de Redis | Suffisant pour l'usage actuel. Redis pub/sub a ajouter si multi-instance futur. |
| Monitoring | Endpoint `/api/health` | Metriques applicatives (DB, WS, API) en complement de vps-health-api (hardware). |
## References
| Source | Pertinence |
|--------|------------|
| [Socket.IO + Next.js guide](https://socket.io/how-to/use-with-nextjs) | Pattern custom server pour WebSocket avec Next.js. On utilise `ws` directement (plus leger). |
| [Next.js WebSocket discussion #58698](https://github.com/vercel/next.js/discussions/58698) | Confirme que les route handlers App Router ne supportent pas les WS — custom server necessaire. |
| [PowerSync React Native SDK](https://docs.powersync.com/client-sdks/reference/react-native-and-expo) | Alternative evaluee pour la sync. Trop lourd pour notre cas (single user). DIY LWW + tombstones suffit. |
| [Expo local-first guide](https://docs.expo.dev/guides/local-first/) | Patterns de sync offline-first avec expo-sqlite. Valide notre approche outbox + polling. |
| [OWASP WebSocket Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html) | CSWSH, auth WS, validation Origin — base de notre strategie de ticket ephemere. |
| [OWASP API Security Top 10](https://owasp.org/www-project-api-security/) | BOLA (#1), Mass Assignment (#3) — guides les controles d'acces sur le batch sync. |
| [CSWSH Exploitation in 2025 — Include Security](https://blog.includesecurity.com/2025/04/cross-site-websocket-hijacking-exploitation-in-2025/) | Confirme que SameSite=Lax ne suffit pas si un sous-domaine est compromis. |
| [Logto Security docs](https://docs.logto.io/security) | Rotation des cles, logout centralise, stockage securise des tokens. |
| [Expo SecureStore](https://docs.expo.dev/versions/latest/sdk/securestore/) | API pour le stockage chiffre (Keychain iOS / Keystore Android) — adaptateur pour @logto/rn. |
## Estimation
7-10 sessions de travail, decoupees ainsi :
- Issues 1-2 (setup) : 1-2 sessions
- Issue 3 (auth) : 1 session
- Issue 4 (API) : 2 sessions
- Issue 5 (WebSocket) : 1 session
- Issue 6 (frontend) : 2-3 sessions
- Issue 7 (sync mobile) : 1-2 sessions

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",
@ -162,10 +138,6 @@
"overdue": "Overdue",
"today": "Today",
"tomorrow": "Tomorrow",
"noDate": "No date",
"period": "Display period",
"periodWeek_one": "{{count}} week",
"periodWeek_other": "{{count}} weeks",
"periodAll": "All"
"noDate": "No date"
}
}

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",
@ -162,10 +138,6 @@
"overdue": "En retard",
"today": "Aujourd'hui",
"tomorrow": "Demain",
"noDate": "Sans date",
"period": "Période affichée",
"periodWeek_one": "{{count}} semaine",
"periodWeek_other": "{{count}} semaines",
"periodAll": "Toutes"
"noDate": "Sans date"
}
}

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,13 +0,0 @@
import type { Router } from 'expo-router';
/**
* Navigate back if possible, otherwise replace with root.
* Shared between task screens to avoid duplication.
*/
export const goBack = (router: Router) => {
if (router.canGoBack()) {
router.back();
} else {
router.replace('/');
}
};

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,70 +28,13 @@ 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;
try {
const now = new Date();
const todayStart = startOfDay(now);
// Read widget period setting from AsyncStorage
let widgetPeriodWeeks = 0;
try {
const settingsRaw = await AsyncStorage.getItem('simpl-liste-settings');
if (settingsRaw) {
const settings = JSON.parse(settingsRaw);
const stored = settings?.state?.widgetPeriodWeeks;
if (typeof stored === 'number') widgetPeriodWeeks = stored;
}
} catch {
// Default to all tasks
}
const twoWeeksEnd = endOfDay(addWeeks(now, 2));
const selectFields = {
id: tasks.id,
@ -110,20 +48,19 @@ export async function syncWidgetData(): Promise<void> {
subtaskDoneCount: sql<number>`(SELECT COUNT(*) FROM tasks AS sub WHERE sub.parent_id = ${tasks.id} AND sub.completed = 1)`.as('subtask_done_count'),
};
// Fetch upcoming tasks (filtered by period setting, 0 = all future tasks)
const upcomingConditions = [
eq(tasks.completed, false),
isNull(tasks.parentId),
gte(tasks.dueDate, todayStart),
];
if (widgetPeriodWeeks > 0) {
upcomingConditions.push(lte(tasks.dueDate, endOfDay(addWeeks(now, widgetPeriodWeeks))));
}
// Fetch tasks with due date in the next 2 weeks
const upcomingTasks = await db
.select(selectFields)
.from(tasks)
.leftJoin(lists, eq(tasks.listId, lists.id))
.where(and(...upcomingConditions))
.where(
and(
eq(tasks.completed, false),
isNull(tasks.parentId),
gte(tasks.dueDate, todayStart),
lte(tasks.dueDate, twoWeeksEnd)
)
)
.orderBy(asc(tasks.dueDate));
// Fetch overdue tasks
@ -202,20 +139,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

@ -10,19 +10,11 @@ interface SettingsState {
notificationsEnabled: boolean;
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>()(
@ -33,19 +25,11 @@ export const useSettingsStore = create<SettingsState>()(
notificationsEnabled: true,
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

@ -380,14 +380,17 @@ function SmallWidget({ tasks, isDark }: { tasks: WidgetTask[]; isDark: boolean }
function ListWidgetContent({
tasks,
maxItems,
isDark,
expandedTaskIds,
}: {
tasks: WidgetTask[];
maxItems: number;
isDark: boolean;
expandedTaskIds: Set<string>;
}) {
const c = getColors(isDark);
const displayTasks = tasks.slice(0, maxItems);
return (
<FlexWidget
@ -473,15 +476,15 @@ function ListWidgetContent({
</FlexWidget>
</FlexWidget>
{/* Task list — cap at 30 items to avoid Android widget memory limits */}
{tasks.length > 0 ? (
{/* Task list */}
{displayTasks.length > 0 ? (
<ListWidget
style={{
height: 'match_parent',
width: 'match_parent',
}}
>
{tasks.slice(0, 30).map((task) => (
{displayTasks.map((task) => (
<FlexWidget
key={task.id}
style={{
@ -531,9 +534,11 @@ export function TaskListWidget(props: TaskListWidgetProps) {
return <SmallWidget tasks={widgetTasks} isDark={isDark} />;
}
const maxItems = widgetName === 'SimplListeLarge' ? 8 : 4;
return (
<ListWidgetContent
tasks={widgetTasks}
maxItems={maxItems}
isDark={isDark}
expandedTaskIds={expandedTaskIds}
/>

View file

@ -1,30 +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 = 600;
const lastExpandTimes = new Map<string, number>();
const WIDGET_EXPANDED_KEY = 'widget:expandedTaskIds';
// Dev-only timing helper. Output goes to logcat:
// adb logcat -s ReactNativeJS | grep '\[widget\]'
async function timed<T>(label: string, fn: () => Promise<T> | T): Promise<T> {
if (!__DEV__) return await fn();
const start = Date.now();
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 {
return await fn();
} finally {
console.log(`[widget] ${label}: ${Date.now() - start}ms`);
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({
@ -32,54 +75,26 @@ 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
}
}
}
// Best-effort persist: failure leaves AsyncStorage stale, but the next
// handler call's getWidgetState() returns the prior state and re-renders
// from it, so the UI self-heals on the next interaction.
async function persistState(state: Awaited<ReturnType<typeof getWidgetState>>): Promise<void> {
try {
await setWidgetState(state);
} catch {
if (__DEV__) console.log('[widget] setWidgetState failed (state will resync on next sync push)');
}
}
export async function widgetTaskHandler(
props: WidgetTaskHandlerProps
): Promise<void> {
const { widgetAction, widgetInfo, renderWidget } = props;
const handlerStart = __DEV__ ? Date.now() : 0;
switch (widgetAction) {
case 'WIDGET_ADDED':
case 'WIDGET_UPDATE':
case 'WIDGET_RESIZED': {
const state = await timed(`${widgetAction} getState`, getWidgetState);
renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds);
if (__DEV__) console.log(`[widget] ${widgetAction} total: ${Date.now() - handlerStart}ms`);
const [tasks, isDark, expandedTaskIds] = await Promise.all([
getWidgetTasks(),
getWidgetIsDark(),
getExpandedTaskIds(),
]);
renderWithState(renderWidget, widgetInfo, tasks, isDark, expandedTaskIds);
break;
}
@ -91,49 +106,42 @@ export async function widgetTaskHandler(
const taskId = props.clickActionData?.taskId;
if (!isValidUUID(taskId)) break;
const state = await timed('TOGGLE_COMPLETE getState', getWidgetState);
state.tasks = state.tasks.filter((t) => t.id !== taskId);
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));
// Render first so the user sees the row disappear immediately,
// then persist + run the DB write.
renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds);
await timed('TOGGLE_COMPLETE setState', () => persistState(state));
renderWithState(renderWidget, widgetInfo, updatedTasks, isDark, expandedTaskIds);
try {
const { toggleComplete } = await import('../db/repository/tasks');
await timed('TOGGLE_COMPLETE db', () => toggleComplete(taskId));
await toggleComplete(taskId);
} catch {
// DB might not be available in headless mode
}
if (__DEV__) console.log(`[widget] TOGGLE_COMPLETE total: ${Date.now() - handlerStart}ms`);
}
if (props.clickAction === 'TOGGLE_EXPAND') {
const taskId = props.clickActionData?.taskId as string | undefined;
if (!taskId) break;
// Anti-double-tap. Short enough to not feel laggy when the user
// genuinely wants to expand-then-collapse.
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 timed('TOGGLE_EXPAND getState', 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 setExpandedTaskIds(expandedTaskIds);
renderWithState(renderWidget, widgetInfo, state.tasks, state.isDark, state.expandedTaskIds);
await timed('TOGGLE_EXPAND setState', () => persistState(state));
if (__DEV__) console.log(`[widget] TOGGLE_EXPAND total: ${Date.now() - handlerStart}ms`);
renderWithState(renderWidget, widgetInfo, tasks, isDark, expandedTaskIds);
}
if (props.clickAction === 'TOGGLE_SUBTASK') {
@ -141,9 +149,14 @@ export async function widgetTaskHandler(
const parentId = props.clickActionData?.parentId as string | undefined;
if (!isValidUUID(subtaskId) || !parentId) break;
const state = await timed('TOGGLE_SUBTASK getState', getWidgetState);
const [tasks, isDark, expandedTaskIds] = await Promise.all([
getWidgetTasks(),
getWidgetIsDark(),
getExpandedTaskIds(),
]);
const parent = state.tasks.find((t) => t.id === parentId);
// Update subtask state in cached data
const parent = tasks.find((t) => t.id === parentId);
if (parent) {
const sub = parent.subtasks?.find((s) => s.id === subtaskId);
if (sub) {
@ -151,24 +164,16 @@ export async function widgetTaskHandler(
parent.subtaskDoneCount = (parent.subtasks ?? []).filter((s) => s.completed).length;
}
}
await AsyncStorage.setItem(WIDGET_DATA_KEY, JSON.stringify(tasks));
// forceWidgetRefresh fans out to all 3 widget sizes (state changed
// affects every widget on the home screen). Run before persist for
// immediate visual feedback; the data passed in is the in-memory
// mutated state, not re-read from AsyncStorage.
await timed('TOGGLE_SUBTASK render', () =>
forceWidgetRefresh(state.tasks, state.isDark, state.expandedTaskIds)
);
await timed('TOGGLE_SUBTASK setState', () => persistState(state));
renderWithState(renderWidget, widgetInfo, tasks, isDark, expandedTaskIds);
try {
const { toggleComplete } = await import('../db/repository/tasks');
await timed('TOGGLE_SUBTASK db', () => toggleComplete(subtaskId));
await toggleComplete(subtaskId);
} catch {
// DB might not be available in headless mode
}
if (__DEV__) console.log(`[widget] TOGGLE_SUBTASK total: ${Date.now() - handlerStart}ms`);
}
break;
}

View file

@ -1,85 +0,0 @@
'use strict';
// Non-regression smoke tests for npm overrides + deps integrity.
// Runs on plain node (no jest, no Expo runtime). Catches the obvious
// breakage paths after `npm install` rewrites lock for an `overrides` bump.
//
// node tests/smoke.test.cjs
//
// Exit 0 if all checks pass, 1 if any fails.
const assert = require('node:assert').strict;
let failed = 0;
function check(name, fn) {
try {
fn();
console.log(`OK ${name}`);
} catch (e) {
failed++;
console.error(`FAIL ${name}: ${e.message}`);
}
}
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
check('package.json is valid JSON with name + deps', () => {
const pkg = require('../package.json');
assert.ok(pkg.name, 'package.json missing name');
assert.ok(pkg.dependencies, 'package.json missing dependencies');
});
check('uuid v4 generates well-formed UUID', () => {
const { v4 } = require('uuid');
for (let i = 0; i < 5; i++) {
assert.match(v4(), UUID_RE);
}
});
// The uuid <14.0.0 advisory (GHSA-w5hq-g745-h8pq) is specifically about
// missing buffer bounds checks in v3/v5/v6. After bumping to v14, these
// must still produce valid UUIDs from a name + namespace.
check('uuid v3 with namespace produces valid UUID', () => {
const { v3 } = require('uuid');
const id = v3('test-name', NAMESPACE);
assert.match(id, UUID_RE);
});
check('uuid v5 with namespace produces valid UUID', () => {
const { v5 } = require('uuid');
const id = v5('test-name', NAMESPACE);
assert.match(id, UUID_RE);
});
// Buffer-arg path is the actual vuln site — must not throw and must
// fill the buffer with the UUID bytes.
check('uuid v3 with buffer arg fills buffer (vuln site)', () => {
const { v3 } = require('uuid');
const buf = Buffer.alloc(16);
v3('test-name', NAMESPACE, buf);
// After the call, buf must have at least one non-zero byte.
assert.ok(
buf.some((b) => b !== 0),
'buffer was not filled by uuid v3'
);
});
check('uuid v5 with buffer arg fills buffer (vuln site)', () => {
const { v5 } = require('uuid');
const buf = Buffer.alloc(16);
v5('test-name', NAMESPACE, buf);
assert.ok(
buf.some((b) => b !== 0),
'buffer was not filled by uuid v5'
);
});
if (failed === 0) {
console.log('\nsmoke OK');
process.exit(0);
} else {
console.error(`\nsmoke FAIL (${failed} failure${failed > 1 ? 's' : ''})`);
process.exit(1);
}

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>
);
}

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