Compare commits

..

No commits in common. "master" and "issue-60-fix-duplicate-inbox" have entirely different histories.

9 changed files with 32 additions and 152 deletions

View file

@ -2,7 +2,7 @@
"expo": {
"name": "Simpl-Liste",
"slug": "simpl-liste",
"version": "1.6.1",
"version": "1.5.2",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "simplliste",
@ -24,7 +24,7 @@
"backgroundColor": "#FFF8F0"
},
"edgeToEdgeEnabled": true,
"versionCode": 13
"versionCode": 11
},
"plugins": [
"expo-router",

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, RefreshControl } 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';
@ -45,7 +45,6 @@ export default function InboxScreen() {
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();
@ -73,29 +72,10 @@ export default function InboxScreen() {
}, [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'],
});
await loadTasks();
setRefreshing(false);
}, [loadTasks]);
const handleToggle = async (id: string) => {
await toggleComplete(id);
@ -191,11 +171,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>
@ -233,6 +208,14 @@ export default function InboxScreen() {
onDragBegin={() => { isDraggingRef.current = true; }}
onDragEnd={handleDragEnd}
activationDistance={canDrag ? 0 : 10000}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={colors.bleu.DEFAULT}
colors={[colors.bleu.DEFAULT]}
/>
}
/>
</GestureHandlerRootView>
)}

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, RefreshControl } 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,
@ -62,7 +62,6 @@ export default function ListDetailScreen() {
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();
@ -98,29 +97,10 @@ export default function ListDetailScreen() {
}, [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'],
});
await loadData();
setRefreshing(false);
}, [loadData]);
const handleToggle = async (taskId: string) => {
await toggleComplete(taskId);
@ -219,11 +199,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>
@ -281,6 +256,14 @@ export default function ListDetailScreen() {
onDragBegin={() => { isDraggingRef.current = true; }}
onDragEnd={handleDragEnd}
activationDistance={canDrag ? 0 : 10000}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={colors.bleu.DEFAULT}
colors={[colors.bleu.DEFAULT]}
/>
}
/>
</GestureHandlerRootView>
)}

View file

@ -1,7 +1,7 @@
{
"name": "simpl-liste",
"main": "index.js",
"version": "1.6.1",
"version": "1.5.2",
"scripts": {
"start": "expo start",
"android": "expo start --android",

View file

@ -4,7 +4,6 @@ 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';
@ -98,8 +97,6 @@ export async function pushChanges(): Promise<void> {
.set({ syncedAt: now })
.where(eq(syncOutbox.id, entry.id));
}
// Refresh widget after a successful push to reflect the synced state
syncWidgetData().catch(() => {});
}
}
@ -121,11 +118,9 @@ export async function pullChanges(since: string): Promise<void> {
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);
}
@ -135,11 +130,6 @@ export async function pullChanges(since: string): Promise<void> {
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);
}

View file

@ -15,7 +15,7 @@ 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
--external:next --external:.next
# Production
FROM base AS runner
@ -31,7 +31,6 @@ 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

View file

@ -1,38 +1,15 @@
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();
app.prepare().then(() => {
const server = createServer((req, res) => {
// Don't log query params on /ws route (ticket security)
handle(req, res);
@ -44,4 +21,4 @@ const handle = app.getRequestHandler();
console.log(`> Ready on http://${hostname}:${port}`);
console.log(`> WebSocket server on ws://${hostname}:${port}/ws`);
});
})();
});

View file

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

View file

@ -15,13 +15,6 @@
"when": 1775567900000,
"tag": "0001_change_user_id_to_text",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1775649600000,
"tag": "0002_cleanup_duplicate_inboxes",
"breakpoints": true
}
]
}