feat(app): subtask categories, live refresh on save, nested subtask fixes

This commit is contained in:
2026-08-10 11:41:25 +02:00
parent f3bcd78e49
commit 2ca23e276f
22 changed files with 518 additions and 307 deletions
+57 -84
View File
@@ -1,19 +1,18 @@
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
import React, { useMemo, useRef, useState, useCallback } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions, BackHandler, KeyboardAvoidingView } from 'react-native';
import { useRouter, useFocusEffect } from 'expo-router';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { Header } from '@/components/Header';
import { useTasksByDate, useTasksInMonth } from '@/hooks/useTasks';
import { useSubtasks } from '@/hooks/useSubtasks';
import { useTaskModals } from '@/hooks/useTaskModals';
import { useSettings } from '@/theme';
import { useCategories, useDatabase } from '@/hooks/useDatabase';
import { useCategories } from '@/hooks/useDatabase';
import { toggleTaskComplete } from '@/utils/taskActions';
import { QuickAddBar } from '@/components/QuickAddBar';
import { OptionPickerModal } from '@/components/OptionPickerModal';
import { SubtaskData } from '@/types';
import Task from '@/models/Task';
import { format, addMonths, addDays, startOfMonth, isSameDay, isSameMonth, isToday } from 'date-fns';
import { Q } from '@nozbe/watermelondb';
import Svg, { Path, Circle } from 'react-native-svg';
import { desaturate } from '@/theme';
import type { ThemeColors } from '@/theme';
@@ -28,7 +27,6 @@ const MONTH_NAMES = [
export default function CalendarScreen() {
const router = useRouter();
const { theme } = useSettings();
const { collections } = useDatabase();
const categories = useCategories();
const { modals, openTaskEdit } = useTaskModals();
@@ -36,27 +34,31 @@ export default function CalendarScreen() {
const [selectedDate, setSelectedDate] = useState(() => new Date());
const [monthPickerVisible, setMonthPickerVisible] = useState(false);
const [yearPickerVisible, setYearPickerVisible] = useState(false);
const [subtasksMap, setSubtasksMap] = useState<Record<string, SubtaskData[]>>({});
const { map: subtasksByTask, refresh: refreshSubtasks } = useSubtasks();
const visibleMonthRef = useRef(visibleMonth);
visibleMonthRef.current = visibleMonth;
const selectedDateRef = useRef(selectedDate);
selectedDateRef.current = selectedDate;
const { tasks: selectedDayTasks, refresh: refreshDayTasks } = useTasksByDate(selectedDate);
const monthTasks = useTasksInMonth(visibleMonth);
const refreshMonthTasks = monthTasks.refresh;
useFocusEffect(
useCallback(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => true);
refreshDayTasks();
refreshMonthTasks();
refreshSubtasks();
return () => sub.remove();
}, [])
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks])
);
const translateX = useRef(new Animated.Value(0)).current;
const animatingRef = useRef(false);
const gridWidthRef = useRef<number>(WIDTH);
const { tasks: selectedDayTasks } = useTasksByDate(selectedDate);
const monthTasks = useTasksInMonth(visibleMonth);
const weeks = useMemo(() => {
const first = startOfMonth(visibleMonth);
const offset = (first.getDay() + 6) % 7; // week starts Monday
@@ -67,67 +69,22 @@ export default function CalendarScreen() {
return rows;
}, [visibleMonth]);
const loadSubtasks = useCallback(
async (taskId: string): Promise<[string, SubtaskData[]]> => {
const subs = await collections.subtasks
.query(Q.where('task_id', taskId), Q.where('parent_subtask_id', null), Q.sortBy('order', 'asc'))
.fetch();
const mapped: SubtaskData[] = subs.map((s: any) => ({
id: s.id,
taskId: s.taskId,
parentSubtaskId: s.parentSubtaskId || null,
title: s.title,
description: s.description || '',
priority: (s.priority || 'none') as SubtaskData['priority'],
completed: s.completed,
dueDate: s.dueDate || 0,
dueTime: s.dueTime || '',
endTime: s.endTime || '',
allDay: s.allDay ?? false,
repeat: (s.repeat || 'none') as SubtaskData['repeat'],
repeatInterval: s.repeatInterval ?? 1,
repeatDays: s.repeatDays || '',
seriesId: s.seriesId || '',
reminder: (s.reminder || 'none') as SubtaskData['reminder'],
assigneeId: s.assigneeId ?? null,
order: s.order,
subtasks: [],
}));
return [taskId, mapped];
},
[collections.subtasks]
);
useEffect(() => {
let active = true;
setSubtasksMap({});
Promise.all(selectedDayTasks.map((t) => loadSubtasks(t.id))).then((entries) => {
if (!active) return;
setSubtasksMap(Object.fromEntries(entries));
});
return () => {
active = false;
};
}, [selectedDayTasks, loadSubtasks]);
const handleDayPress = useCallback((day: Date) => {
setSelectedDate(day);
if (!isSameMonth(day, visibleMonthRef.current)) {
setVisibleMonth(day);
}
}, []);
const transitionTo = useCallback(
(dir: 1 | -1) => {
(dir: 1 | -1, animate = true) => {
if (animatingRef.current) return;
const next = addMonths(visibleMonthRef.current, dir);
if (!isSameMonth(selectedDateRef.current, next)) {
setSelectedDate(startOfMonth(next));
}
if (!animate) {
setVisibleMonth(next);
translateX.setValue(0);
return;
}
animatingRef.current = true;
const w = gridWidthRef.current || WIDTH;
const target = dir === 1 ? -w : w;
Animated.timing(translateX, { toValue: target, duration: 220, useNativeDriver: false }).start(() => {
const next = addMonths(visibleMonthRef.current, dir);
if (!isSameMonth(selectedDateRef.current, next)) {
setSelectedDate(startOfMonth(next));
}
setVisibleMonth(next);
translateX.setValue(-target);
Animated.timing(translateX, { toValue: 0, duration: 180, useNativeDriver: false }).start(() => {
@@ -138,6 +95,18 @@ export default function CalendarScreen() {
[translateX]
);
const handleDayPress = useCallback(
(day: Date) => {
setSelectedDate(day);
if (!isSameMonth(day, visibleMonthRef.current)) {
transitionTo(day > visibleMonthRef.current ? 1 : -1, true);
} else {
router.push({ pathname: '/day-view', params: { date: day.toISOString() } });
}
},
[router, transitionTo]
);
const pan = useMemo(
() =>
Gesture.Pan()
@@ -153,10 +122,10 @@ export default function CalendarScreen() {
const dx = e.translationX;
if (dx <= -w / 4) {
translateX.stopAnimation();
transitionTo(1);
transitionTo(1, false);
} else if (dx >= w / 4) {
translateX.stopAnimation();
transitionTo(-1);
transitionTo(-1, false);
} else {
Animated.spring(translateX, { toValue: 0, useNativeDriver: false }).start();
}
@@ -184,7 +153,10 @@ export default function CalendarScreen() {
const handleToggleComplete = useCallback(async (taskId: string) => {
await toggleTaskComplete(taskId);
}, []);
refreshDayTasks();
refreshMonthTasks();
refreshSubtasks();
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]);
return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
@@ -217,11 +189,12 @@ export default function CalendarScreen() {
<Path d="M6 9l6 6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
</TouchableOpacity>
<TouchableOpacity onPress={() => setYearPickerVisible(true)} activeOpacity={0.7} style={styles.yearButton}>
<Text style={[styles.yearLabel, { color: theme.textMuted }]}>{format(visibleMonth, 'yyyy')}</Text>
</TouchableOpacity>
</View>
<TouchableOpacity onPress={() => setYearPickerVisible(true)} activeOpacity={0.7} style={styles.yearButton}>
<Text style={[styles.yearLabel, { color: theme.textMuted }]}>{format(visibleMonth, 'yyyy')}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => transitionTo(1)}
style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
@@ -316,15 +289,10 @@ export default function CalendarScreen() {
</TouchableOpacity>
</View>
{(subtasksMap[task.id] ?? []).length > 0 && (
{(subtasksByTask.get(task.id) ?? []).length > 0 && (
<View style={styles.bullets}>
{(subtasksMap[task.id] ?? []).map((sub) => (
<TouchableOpacity
key={sub.id}
style={styles.bulletRow}
onPress={() => router.push({ pathname: '/subtask-detail', params: { id: sub.id } })}
activeOpacity={0.7}
>
{(subtasksByTask.get(task.id) ?? []).map((sub) => (
<View key={sub.id} style={styles.bulletRow}>
<Svg width={5} height={5} viewBox="0 0 6 6" style={styles.bulletDot as any}>
<Circle cx={3} cy={3} r={3} fill={theme.textMuted} />
</Svg>
@@ -334,7 +302,7 @@ export default function CalendarScreen() {
>
{sub.title}
</Text>
</TouchableOpacity>
</View>
))}
</View>
)}
@@ -343,7 +311,10 @@ export default function CalendarScreen() {
)}
</ScrollView>
<QuickAddBar dueDate={selectedDate.getTime()} placeholder={`Add event for ${format(selectedDate, 'MMM d')}`} />
<QuickAddBar
dueDate={selectedDate.getTime()}
placeholder={`Add event for ${format(selectedDate, 'MMM d')}`}
/>
</KeyboardAvoidingView>
{modals(() => {})}
@@ -461,7 +432,8 @@ const styles = StyleSheet.create({
justifyContent: 'center',
},
monthSelectorGroup: {
alignItems: 'center',
alignItems: 'flex-start',
flex: 1,
},
monthButton: {
flexDirection: 'row',
@@ -476,10 +448,11 @@ const styles = StyleSheet.create({
},
yearButton: {
marginTop: -2,
paddingHorizontal: 6,
},
yearLabel: {
fontSize: 13,
fontWeight: '500',
fontSize: 17,
fontWeight: '700',
},
weekdayRow: {
flexDirection: 'row',
+54 -11
View File
@@ -1,5 +1,5 @@
import React, { useCallback } from 'react';
import { View, Text, StyleSheet, SafeAreaView, BackHandler, KeyboardAvoidingView } from 'react-native';
import React, { useCallback, useMemo, useState } from 'react';
import { View, Text, StyleSheet, SafeAreaView, BackHandler, KeyboardAvoidingView, TouchableOpacity } from 'react-native';
import { useFocusEffect } from 'expo-router';
import { Header } from '@/components/Header';
import { CategoryFilter } from '@/components/CategoryFilter';
@@ -7,11 +7,14 @@ import { TaskList } from '@/components/TaskList';
import { QuickAddBar } from '@/components/QuickAddBar';
import { useDatabase } from '@/hooks/useDatabase';
import { useSettings } from '@/theme';
import Svg, { Path, Circle } from 'react-native-svg';
export default function TasksScreen() {
const { isReady } = useDatabase();
const { theme } = useSettings();
const [selectedCategory, setSelectedCategory] = React.useState<string>('all');
const { theme, showCompleted, setShowCompleted } = useSettings();
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const categoryIds = useMemo(() => selectedCategories, [selectedCategories]);
useFocusEffect(
useCallback(() => {
@@ -32,13 +35,41 @@ export default function TasksScreen() {
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title="ToDo" showLogo={false} />
<View style={styles.categoryFilterWrapper}>
<CategoryFilter selected={selectedCategory} onSelect={setSelectedCategory} />
<CategoryFilter selected={selectedCategories} onSelect={setSelectedCategories} />
<TouchableOpacity
style={[
styles.completedToggle,
{ backgroundColor: theme.card, borderColor: showCompleted ? theme.accent : theme.borderStrong },
]}
onPress={() => setShowCompleted(!showCompleted)}
activeOpacity={0.8}
accessibilityRole="switch"
accessibilityLabel="Show completed tasks"
accessibilityState={{ checked: showCompleted }}
>
<Svg width={14} height={14} viewBox="0 0 24 24">
<Circle
cx={12}
cy={12}
r={9}
stroke={showCompleted ? theme.accent : theme.textMuted}
strokeWidth={2}
fill="none"
/>
{showCompleted && (
<Path d="M7 12.5l3.5 3.5 6.5-7" stroke={theme.accent} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
)}
</Svg>
<Text style={[styles.completedToggleText, { color: showCompleted ? theme.text : theme.textMuted }]}>
Completed
</Text>
</TouchableOpacity>
</View>
<KeyboardAvoidingView
style={styles.kbAvoid}
behavior="padding"
>
<TaskList categoryId={selectedCategory} />
<TaskList categoryIds={categoryIds} showCompleted={showCompleted} />
<QuickAddBar />
</KeyboardAvoidingView>
</SafeAreaView>
@@ -51,13 +82,25 @@ const styles = StyleSheet.create({
},
categoryFilterWrapper: {
justifyContent: 'center',
flexDirection: 'row',
alignItems: 'center',
paddingRight: 12,
},
completedToggle: {
flexDirection: 'row',
alignItems: 'center',
gap: 5,
paddingHorizontal: 10,
paddingVertical: 8,
borderRadius: 18,
borderWidth: 1.5,
alignSelf: 'center',
},
completedToggleText: {
fontSize: 12,
fontWeight: '600',
},
kbAvoid: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
});
+13 -1
View File
@@ -22,7 +22,7 @@ import { getLastVisitedTab, tabHref } from '@/utils/tabHistory';
export default function SettingsScreen() {
const router = useRouter();
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, apiUrl, accentColor, setAccentColor, todoAheadDays, setTodoAheadDays } = useSettings();
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, apiUrl, accentColor, setAccentColor, todoAheadDays, setTodoAheadDays, showCompleted, setShowCompleted } = useSettings();
const categories = useCategories();
const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder' | 'todoAhead'>(null);
const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null);
@@ -176,6 +176,18 @@ export default function SettingsScreen() {
onPress={() => setPicker('sort')}
showChevron
/>
<ListItem
title="Show Completed Tasks"
subtitle="Hide or show finished tasks in the Todo list"
rightElement={
<Switch
value={showCompleted}
onValueChange={setShowCompleted}
thumbColor="#FFFFFF"
trackColor={{ false: theme.borderStrong, true: theme.accent }}
/>
}
/>
<ListItem
title="Show Calendar Tasks"
subtitle={todoAheadDays === 0 ? 'Only today' : `Up to ${todoAheadDays} days ahead`}