Compare commits
No commits in common. "master" and "issue-60-fix-duplicate-inbox" have entirely different histories.
master
...
issue-60-f
9 changed files with 32 additions and 152 deletions
4
app.json
4
app.json
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
});
|
||||
})();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in a new issue