Compare commits

..

8 commits

Author SHA1 Message Date
le king fu
9a53022421 chore: bump version to 1.6.1 (versionCode 13)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:44:19 -04:00
fe43b65cfd Merge pull request 'fix: replace broken swipe-to-refresh with toolbar button (#61)' (#67) from issue-61-refresh-button-toolbar into master 2026-04-09 13:44:08 +00:00
le king fu
5b0d27175c fix: replace broken swipe-to-refresh with toolbar refresh button (#61)
The RefreshControl on DraggableFlatList never worked because the
library wraps its FlatList in a GestureDetector with Gesture.Pan(),
which intercepts vertical swipes before RefreshControl can detect
them — particularly with activationDistance=0 in position sort mode.

Replace with a toolbar refresh button (RefreshCw icon) on inbox and
list detail screens. The button uses an Animated spin during refresh,
matching the web UX. Removes all dead RefreshControl code and the
useless refreshControl prop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:37:26 -04:00
1a1eddfd68 Merge pull request 'fix: refresh Android widget after sync push and pull (#65)' (#66) from issue-65-widget-sync-refresh into master 2026-04-09 13:37:06 +00:00
le king fu
23f3144dc4 fix: refresh Android widget after sync push and pull (#65)
The sync client writes directly to the DB via drizzle, bypassing the
repository functions that normally trigger syncWidgetData(). As a
result, changes coming from the web (or any remote source) never
refreshed the home screen widget.

Call syncWidgetData() once at the end of pullChanges (after all remote
changes are applied) and after a successful pushChanges (to reflect
synced state). Single call per cycle avoids spamming widget updates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:01:29 -04:00
le king fu
1df41cef1f fix: auto-apply migrations on startup + cleanup duplicate inboxes (#60)
- Add migration 0002 to soft-delete duplicate inboxes per user, keeping
  the oldest one and reassigning tasks to it.
- Run drizzle migrations on server startup via drizzle-orm/node-postgres
  migrator.
- Update Dockerfile to copy the migrations folder into the runtime image
  and externalize pg/drizzle-orm from the esbuild bundle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 08:53:09 -04:00
le king fu
2a0dd01307 chore: bump version to 1.6.0 (versionCode 12)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:37:50 -04:00
137dc83bf8 Merge pull request 'fix: issues #60 #61 #62 #63 — inbox, refresh, subtask depth, chevron/detail' (#64) from issue-60-fix-duplicate-inbox into master 2026-04-09 01:33:40 +00:00
9 changed files with 152 additions and 32 deletions

View file

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

View file

@ -1,7 +1,7 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { View, Text, Pressable, TextInput, useColorScheme, Alert, RefreshControl } from 'react-native';
import { View, Text, Pressable, TextInput, useColorScheme, Alert, Animated, Easing } from 'react-native';
import { useRouter } from 'expo-router';
import { Plus, ArrowUpDown, Filter, Download, Search, X } from 'lucide-react-native';
import { Plus, ArrowUpDown, Filter, Download, Search, X, RefreshCw } from 'lucide-react-native';
import { useTranslation } from 'react-i18next';
import * as Haptics from 'expo-haptics';
import DraggableFlatList, { RenderItemParams } from 'react-native-draggable-flatlist';
@ -45,6 +45,7 @@ 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();
@ -72,10 +73,29 @@ export default function InboxScreen() {
}, [loadTasks]);
const handleRefresh = useCallback(async () => {
if (refreshing) return;
setRefreshing(true);
await loadTasks();
setRefreshing(false);
}, [loadTasks]);
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);
@ -171,6 +191,11 @@ 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>
@ -208,14 +233,6 @@ 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, RefreshControl } from 'react-native';
import { View, Text, Pressable, TextInput, useColorScheme, Alert, Animated, Easing } from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import {
ArrowLeft, Plus, ArrowUpDown, Filter, Download, Search, X,
ArrowLeft, Plus, ArrowUpDown, Filter, Download, Search, X, RefreshCw,
List, ShoppingCart, Briefcase, Home, Heart, Star, BookOpen,
GraduationCap, Dumbbell, Utensils, Plane, Music, Code, Wrench,
Gift, Camera, Palette, Dog, Leaf, Zap,
@ -62,6 +62,7 @@ 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();
@ -97,10 +98,29 @@ export default function ListDetailScreen() {
}, [loadData]);
const handleRefresh = useCallback(async () => {
if (refreshing) return;
setRefreshing(true);
await loadData();
setRefreshing(false);
}, [loadData]);
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);
@ -199,6 +219,11 @@ 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>
@ -256,14 +281,6 @@ 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.5.2",
"version": "1.6.1",
"scripts": {
"start": "expo start",
"android": "expo start --android",

View file

@ -4,6 +4,7 @@ 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';
@ -97,6 +98,8 @@ 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(() => {});
}
}
@ -118,9 +121,11 @@ 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);
}
@ -130,6 +135,11 @@ 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:next --external:.next --external:pg --external:pg-native --external:drizzle-orm
# Production
FROM base AS runner
@ -31,6 +31,7 @@ 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,15 +1,38 @@
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();
app.prepare().then(() => {
(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);
@ -21,4 +44,4 @@ app.prepare().then(() => {
console.log(`> Ready on http://${hostname}:${port}`);
console.log(`> WebSocket server on ws://${hostname}:${port}/ws`);
});
});
})();

View file

@ -0,0 +1,45 @@
-- 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,6 +15,13 @@
"when": 1775567900000,
"tag": "0001_change_user_id_to_text",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1775649600000,
"tag": "0002_cleanup_duplicate_inboxes",
"breakpoints": true
}
]
}