feat(app): subtask categories, live refresh on save, nested subtask fixes
This commit is contained in:
@@ -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 { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions, BackHandler, KeyboardAvoidingView } from 'react-native';
|
||||||
import { useRouter, useFocusEffect } from 'expo-router';
|
import { useRouter, useFocusEffect } from 'expo-router';
|
||||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||||
import { Header } from '@/components/Header';
|
import { Header } from '@/components/Header';
|
||||||
import { useTasksByDate, useTasksInMonth } from '@/hooks/useTasks';
|
import { useTasksByDate, useTasksInMonth } from '@/hooks/useTasks';
|
||||||
|
import { useSubtasks } from '@/hooks/useSubtasks';
|
||||||
import { useTaskModals } from '@/hooks/useTaskModals';
|
import { useTaskModals } from '@/hooks/useTaskModals';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { useCategories, useDatabase } from '@/hooks/useDatabase';
|
import { useCategories } from '@/hooks/useDatabase';
|
||||||
import { toggleTaskComplete } from '@/utils/taskActions';
|
import { toggleTaskComplete } from '@/utils/taskActions';
|
||||||
import { QuickAddBar } from '@/components/QuickAddBar';
|
import { QuickAddBar } from '@/components/QuickAddBar';
|
||||||
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
||||||
import { SubtaskData } from '@/types';
|
|
||||||
import Task from '@/models/Task';
|
import Task from '@/models/Task';
|
||||||
import { format, addMonths, addDays, startOfMonth, isSameDay, isSameMonth, isToday } from 'date-fns';
|
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 Svg, { Path, Circle } from 'react-native-svg';
|
||||||
import { desaturate } from '@/theme';
|
import { desaturate } from '@/theme';
|
||||||
import type { ThemeColors } from '@/theme';
|
import type { ThemeColors } from '@/theme';
|
||||||
@@ -28,7 +27,6 @@ const MONTH_NAMES = [
|
|||||||
export default function CalendarScreen() {
|
export default function CalendarScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
const { collections } = useDatabase();
|
|
||||||
const categories = useCategories();
|
const categories = useCategories();
|
||||||
const { modals, openTaskEdit } = useTaskModals();
|
const { modals, openTaskEdit } = useTaskModals();
|
||||||
|
|
||||||
@@ -36,27 +34,31 @@ export default function CalendarScreen() {
|
|||||||
const [selectedDate, setSelectedDate] = useState(() => new Date());
|
const [selectedDate, setSelectedDate] = useState(() => new Date());
|
||||||
const [monthPickerVisible, setMonthPickerVisible] = useState(false);
|
const [monthPickerVisible, setMonthPickerVisible] = useState(false);
|
||||||
const [yearPickerVisible, setYearPickerVisible] = useState(false);
|
const [yearPickerVisible, setYearPickerVisible] = useState(false);
|
||||||
const [subtasksMap, setSubtasksMap] = useState<Record<string, SubtaskData[]>>({});
|
const { map: subtasksByTask, refresh: refreshSubtasks } = useSubtasks();
|
||||||
|
|
||||||
const visibleMonthRef = useRef(visibleMonth);
|
const visibleMonthRef = useRef(visibleMonth);
|
||||||
visibleMonthRef.current = visibleMonth;
|
visibleMonthRef.current = visibleMonth;
|
||||||
const selectedDateRef = useRef(selectedDate);
|
const selectedDateRef = useRef(selectedDate);
|
||||||
selectedDateRef.current = selectedDate;
|
selectedDateRef.current = selectedDate;
|
||||||
|
|
||||||
|
const { tasks: selectedDayTasks, refresh: refreshDayTasks } = useTasksByDate(selectedDate);
|
||||||
|
const monthTasks = useTasksInMonth(visibleMonth);
|
||||||
|
const refreshMonthTasks = monthTasks.refresh;
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => true);
|
const sub = BackHandler.addEventListener('hardwareBackPress', () => true);
|
||||||
|
refreshDayTasks();
|
||||||
|
refreshMonthTasks();
|
||||||
|
refreshSubtasks();
|
||||||
return () => sub.remove();
|
return () => sub.remove();
|
||||||
}, [])
|
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks])
|
||||||
);
|
);
|
||||||
|
|
||||||
const translateX = useRef(new Animated.Value(0)).current;
|
const translateX = useRef(new Animated.Value(0)).current;
|
||||||
const animatingRef = useRef(false);
|
const animatingRef = useRef(false);
|
||||||
const gridWidthRef = useRef<number>(WIDTH);
|
const gridWidthRef = useRef<number>(WIDTH);
|
||||||
|
|
||||||
const { tasks: selectedDayTasks } = useTasksByDate(selectedDate);
|
|
||||||
const monthTasks = useTasksInMonth(visibleMonth);
|
|
||||||
|
|
||||||
const weeks = useMemo(() => {
|
const weeks = useMemo(() => {
|
||||||
const first = startOfMonth(visibleMonth);
|
const first = startOfMonth(visibleMonth);
|
||||||
const offset = (first.getDay() + 6) % 7; // week starts Monday
|
const offset = (first.getDay() + 6) % 7; // week starts Monday
|
||||||
@@ -67,67 +69,22 @@ export default function CalendarScreen() {
|
|||||||
return rows;
|
return rows;
|
||||||
}, [visibleMonth]);
|
}, [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(
|
const transitionTo = useCallback(
|
||||||
(dir: 1 | -1) => {
|
(dir: 1 | -1, animate = true) => {
|
||||||
if (animatingRef.current) return;
|
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;
|
animatingRef.current = true;
|
||||||
const w = gridWidthRef.current || WIDTH;
|
const w = gridWidthRef.current || WIDTH;
|
||||||
const target = dir === 1 ? -w : w;
|
const target = dir === 1 ? -w : w;
|
||||||
Animated.timing(translateX, { toValue: target, duration: 220, useNativeDriver: false }).start(() => {
|
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);
|
setVisibleMonth(next);
|
||||||
translateX.setValue(-target);
|
translateX.setValue(-target);
|
||||||
Animated.timing(translateX, { toValue: 0, duration: 180, useNativeDriver: false }).start(() => {
|
Animated.timing(translateX, { toValue: 0, duration: 180, useNativeDriver: false }).start(() => {
|
||||||
@@ -138,6 +95,18 @@ export default function CalendarScreen() {
|
|||||||
[translateX]
|
[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(
|
const pan = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Gesture.Pan()
|
Gesture.Pan()
|
||||||
@@ -153,10 +122,10 @@ export default function CalendarScreen() {
|
|||||||
const dx = e.translationX;
|
const dx = e.translationX;
|
||||||
if (dx <= -w / 4) {
|
if (dx <= -w / 4) {
|
||||||
translateX.stopAnimation();
|
translateX.stopAnimation();
|
||||||
transitionTo(1);
|
transitionTo(1, false);
|
||||||
} else if (dx >= w / 4) {
|
} else if (dx >= w / 4) {
|
||||||
translateX.stopAnimation();
|
translateX.stopAnimation();
|
||||||
transitionTo(-1);
|
transitionTo(-1, false);
|
||||||
} else {
|
} else {
|
||||||
Animated.spring(translateX, { toValue: 0, useNativeDriver: false }).start();
|
Animated.spring(translateX, { toValue: 0, useNativeDriver: false }).start();
|
||||||
}
|
}
|
||||||
@@ -184,7 +153,10 @@ export default function CalendarScreen() {
|
|||||||
|
|
||||||
const handleToggleComplete = useCallback(async (taskId: string) => {
|
const handleToggleComplete = useCallback(async (taskId: string) => {
|
||||||
await toggleTaskComplete(taskId);
|
await toggleTaskComplete(taskId);
|
||||||
}, []);
|
refreshDayTasks();
|
||||||
|
refreshMonthTasks();
|
||||||
|
refreshSubtasks();
|
||||||
|
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
<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" />
|
<Path d="M6 9l6 6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
</Svg>
|
</Svg>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity onPress={() => setYearPickerVisible(true)} activeOpacity={0.7} style={styles.yearButton}>
|
|
||||||
<Text style={[styles.yearLabel, { color: theme.textMuted }]}>{format(visibleMonth, 'yyyy')}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity onPress={() => setYearPickerVisible(true)} activeOpacity={0.7} style={styles.yearButton}>
|
||||||
|
<Text style={[styles.yearLabel, { color: theme.textMuted }]}>{format(visibleMonth, 'yyyy')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => transitionTo(1)}
|
onPress={() => transitionTo(1)}
|
||||||
style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
|
style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
|
||||||
@@ -316,15 +289,10 @@ export default function CalendarScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{(subtasksMap[task.id] ?? []).length > 0 && (
|
{(subtasksByTask.get(task.id) ?? []).length > 0 && (
|
||||||
<View style={styles.bullets}>
|
<View style={styles.bullets}>
|
||||||
{(subtasksMap[task.id] ?? []).map((sub) => (
|
{(subtasksByTask.get(task.id) ?? []).map((sub) => (
|
||||||
<TouchableOpacity
|
<View key={sub.id} style={styles.bulletRow}>
|
||||||
key={sub.id}
|
|
||||||
style={styles.bulletRow}
|
|
||||||
onPress={() => router.push({ pathname: '/subtask-detail', params: { id: sub.id } })}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<Svg width={5} height={5} viewBox="0 0 6 6" style={styles.bulletDot as any}>
|
<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} />
|
<Circle cx={3} cy={3} r={3} fill={theme.textMuted} />
|
||||||
</Svg>
|
</Svg>
|
||||||
@@ -334,7 +302,7 @@ export default function CalendarScreen() {
|
|||||||
>
|
>
|
||||||
{sub.title}
|
{sub.title}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -343,7 +311,10 @@ export default function CalendarScreen() {
|
|||||||
)}
|
)}
|
||||||
</ScrollView>
|
</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>
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
{modals(() => {})}
|
{modals(() => {})}
|
||||||
@@ -461,7 +432,8 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
monthSelectorGroup: {
|
monthSelectorGroup: {
|
||||||
alignItems: 'center',
|
alignItems: 'flex-start',
|
||||||
|
flex: 1,
|
||||||
},
|
},
|
||||||
monthButton: {
|
monthButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -476,10 +448,11 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
yearButton: {
|
yearButton: {
|
||||||
marginTop: -2,
|
marginTop: -2,
|
||||||
|
paddingHorizontal: 6,
|
||||||
},
|
},
|
||||||
yearLabel: {
|
yearLabel: {
|
||||||
fontSize: 13,
|
fontSize: 17,
|
||||||
fontWeight: '500',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
weekdayRow: {
|
weekdayRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import { View, Text, StyleSheet, SafeAreaView, BackHandler, KeyboardAvoidingView } from 'react-native';
|
import { View, Text, StyleSheet, SafeAreaView, BackHandler, KeyboardAvoidingView, TouchableOpacity } from 'react-native';
|
||||||
import { useFocusEffect } from 'expo-router';
|
import { useFocusEffect } from 'expo-router';
|
||||||
import { Header } from '@/components/Header';
|
import { Header } from '@/components/Header';
|
||||||
import { CategoryFilter } from '@/components/CategoryFilter';
|
import { CategoryFilter } from '@/components/CategoryFilter';
|
||||||
@@ -7,11 +7,14 @@ import { TaskList } from '@/components/TaskList';
|
|||||||
import { QuickAddBar } from '@/components/QuickAddBar';
|
import { QuickAddBar } from '@/components/QuickAddBar';
|
||||||
import { useDatabase } from '@/hooks/useDatabase';
|
import { useDatabase } from '@/hooks/useDatabase';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
|
import Svg, { Path, Circle } from 'react-native-svg';
|
||||||
|
|
||||||
export default function TasksScreen() {
|
export default function TasksScreen() {
|
||||||
const { isReady } = useDatabase();
|
const { isReady } = useDatabase();
|
||||||
const { theme } = useSettings();
|
const { theme, showCompleted, setShowCompleted } = useSettings();
|
||||||
const [selectedCategory, setSelectedCategory] = React.useState<string>('all');
|
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const categoryIds = useMemo(() => selectedCategories, [selectedCategories]);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
@@ -32,13 +35,41 @@ export default function TasksScreen() {
|
|||||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||||
<Header title="ToDo" showLogo={false} />
|
<Header title="ToDo" showLogo={false} />
|
||||||
<View style={styles.categoryFilterWrapper}>
|
<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>
|
</View>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={styles.kbAvoid}
|
style={styles.kbAvoid}
|
||||||
behavior="padding"
|
behavior="padding"
|
||||||
>
|
>
|
||||||
<TaskList categoryId={selectedCategory} />
|
<TaskList categoryIds={categoryIds} showCompleted={showCompleted} />
|
||||||
<QuickAddBar />
|
<QuickAddBar />
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
@@ -51,13 +82,25 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
categoryFilterWrapper: {
|
categoryFilterWrapper: {
|
||||||
justifyContent: 'center',
|
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: {
|
kbAvoid: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
loadingContainer: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
@@ -22,7 +22,7 @@ import { getLastVisitedTab, tabHref } from '@/utils/tabHistory';
|
|||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
const router = useRouter();
|
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 categories = useCategories();
|
||||||
const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder' | 'todoAhead'>(null);
|
const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder' | 'todoAhead'>(null);
|
||||||
const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null);
|
const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null);
|
||||||
@@ -176,6 +176,18 @@ export default function SettingsScreen() {
|
|||||||
onPress={() => setPicker('sort')}
|
onPress={() => setPicker('sort')}
|
||||||
showChevron
|
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
|
<ListItem
|
||||||
title="Show Calendar Tasks"
|
title="Show Calendar Tasks"
|
||||||
subtitle={todoAheadDays === 0 ? 'Only today' : `Up to ${todoAheadDays} days ahead`}
|
subtitle={todoAheadDays === 0 ? 'Only today' : `Up to ${todoAheadDays} days ahead`}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ function RootNavigator() {
|
|||||||
<Stack.Screen name="(tabs)" />
|
<Stack.Screen name="(tabs)" />
|
||||||
<Stack.Screen name="add-task" />
|
<Stack.Screen name="add-task" />
|
||||||
<Stack.Screen name="task-detail" />
|
<Stack.Screen name="task-detail" />
|
||||||
|
<Stack.Screen name="day-view" />
|
||||||
</Stack>
|
</Stack>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { useDatabase } from '@/hooks/useDatabase';
|
import { useDatabase } from '@/hooks/useDatabase';
|
||||||
import { database, collections } from '@/database';
|
import { database, collections } from '@/database';
|
||||||
import { TaskFormData } from '@/types';
|
import { TaskFormData, tagsToString } from '@/types';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { scheduleTaskReminder } from '@/services/notifications';
|
import { scheduleTaskReminder } from '@/services/notifications';
|
||||||
import { useFriends } from '@/hooks/useFriends';
|
import { useFriends } from '@/hooks/useFriends';
|
||||||
@@ -26,6 +26,7 @@ const taskSchema = z.object({
|
|||||||
title: z.string().trim().min(1, 'Task name is required').max(100),
|
title: z.string().trim().min(1, 'Task name is required').max(100),
|
||||||
description: z.string().max(1000).optional(),
|
description: z.string().max(1000).optional(),
|
||||||
categoryId: z.string().optional(),
|
categoryId: z.string().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
|
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
|
||||||
dueDate: z.date().nullable().optional(),
|
dueDate: z.date().nullable().optional(),
|
||||||
dueTime: z.string().optional(),
|
dueTime: z.string().optional(),
|
||||||
@@ -60,6 +61,7 @@ export default function AddTaskScreen() {
|
|||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
categoryId: initialCategory,
|
categoryId: initialCategory,
|
||||||
|
tags: initialCategory ? [initialCategory] : [],
|
||||||
priority: 'none',
|
priority: 'none',
|
||||||
dueDate: initialDate,
|
dueDate: initialDate,
|
||||||
dueTime: '',
|
dueTime: '',
|
||||||
@@ -83,7 +85,7 @@ export default function AddTaskScreen() {
|
|||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = methods;
|
} = methods;
|
||||||
|
|
||||||
const categoryId = watch('categoryId');
|
const tags = watch('tags') ?? [];
|
||||||
const priority = watch('priority');
|
const priority = watch('priority');
|
||||||
const repeat = watch('repeat');
|
const repeat = watch('repeat');
|
||||||
const repeatInterval = watch('repeatInterval') ?? 1;
|
const repeatInterval = watch('repeatInterval') ?? 1;
|
||||||
@@ -94,10 +96,10 @@ export default function AddTaskScreen() {
|
|||||||
const assigneeId = watch('assigneeId');
|
const assigneeId = watch('assigneeId');
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!categoryId && initialCategory) {
|
if (tags.length === 0 && initialCategory) {
|
||||||
setValue('categoryId', initialCategory);
|
setValue('tags', [initialCategory]);
|
||||||
}
|
}
|
||||||
}, [initialCategory, categoryId, setValue]);
|
}, [initialCategory, tags, setValue]);
|
||||||
|
|
||||||
const onSubmit = async (data: TaskFormData) => {
|
const onSubmit = async (data: TaskFormData) => {
|
||||||
if (!isReady) return;
|
if (!isReady) return;
|
||||||
@@ -107,7 +109,7 @@ export default function AddTaskScreen() {
|
|||||||
const seriesId = data.repeat !== 'none'
|
const seriesId = data.repeat !== 'none'
|
||||||
? `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`
|
? `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`
|
||||||
: '';
|
: '';
|
||||||
const resolvedCategoryId = data.categoryId || '';
|
const resolvedCategoryId = (data.tags && data.tags[0]) || '';
|
||||||
|
|
||||||
let createdTask: any = null;
|
let createdTask: any = null;
|
||||||
|
|
||||||
@@ -116,6 +118,7 @@ export default function AddTaskScreen() {
|
|||||||
t.title = data.title.trim();
|
t.title = data.title.trim();
|
||||||
t.description = data.description || '';
|
t.description = data.description || '';
|
||||||
t.categoryId = resolvedCategoryId;
|
t.categoryId = resolvedCategoryId;
|
||||||
|
t.tags = tagsToString(data.tags || []);
|
||||||
t.priority = data.priority;
|
t.priority = data.priority;
|
||||||
t.completed = false;
|
t.completed = false;
|
||||||
t.dueDate = dueDateTimestamp;
|
t.dueDate = dueDateTimestamp;
|
||||||
@@ -184,8 +187,8 @@ export default function AddTaskScreen() {
|
|||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
>
|
||||||
<CategorySelector
|
<CategorySelector
|
||||||
value={categoryId}
|
value={tags}
|
||||||
onChange={(value) => setValue('categoryId', value)}
|
onChange={(value) => setValue('tags', value)}
|
||||||
error={errors.categoryId?.message}
|
error={errors.categoryId?.message}
|
||||||
/>
|
/>
|
||||||
<Controller
|
<Controller
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ import { collections } from '@/database';
|
|||||||
import { SubtaskFormData, Reminder, parseReminders, toRemindersString } from '@/types';
|
import { SubtaskFormData, Reminder, parseReminders, toRemindersString } from '@/types';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { updateSubtask, deleteSubtask } from '@/utils/taskActions';
|
import { updateSubtask, deleteSubtask } from '@/utils/taskActions';
|
||||||
|
import { CategorySelector } from '@/components/CategorySelector';
|
||||||
import { useFriends } from '@/hooks/useFriends';
|
import { useFriends } from '@/hooks/useFriends';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
import Svg, { Path } from 'react-native-svg';
|
||||||
|
|
||||||
const subtaskSchema = z.object({
|
const subtaskSchema = z.object({
|
||||||
title: z.string().trim().min(1, 'Subtask name is required').max(100),
|
title: z.string().trim().min(1, 'Subtask name is required').max(100),
|
||||||
description: z.string().max(1000).optional(),
|
description: z.string().max(1000).optional(),
|
||||||
|
categoryId: z.string().optional(),
|
||||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
|
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
|
||||||
dueDate: z.date().nullable().optional(),
|
dueDate: z.date().nullable().optional(),
|
||||||
dueTime: z.string().optional(),
|
dueTime: z.string().optional(),
|
||||||
@@ -51,6 +53,7 @@ export default function SubtaskDetailScreen() {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
categoryId: '',
|
||||||
priority: 'none',
|
priority: 'none',
|
||||||
dueDate: null,
|
dueDate: null,
|
||||||
dueTime: '',
|
dueTime: '',
|
||||||
@@ -68,6 +71,7 @@ export default function SubtaskDetailScreen() {
|
|||||||
const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
|
const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
|
||||||
|
|
||||||
const priority = watch('priority');
|
const priority = watch('priority');
|
||||||
|
const categoryId = watch('categoryId');
|
||||||
const repeat = watch('repeat');
|
const repeat = watch('repeat');
|
||||||
const repeatInterval = watch('repeatInterval') ?? 1;
|
const repeatInterval = watch('repeatInterval') ?? 1;
|
||||||
const repeatDays = watch('repeatDays') ?? [];
|
const repeatDays = watch('repeatDays') ?? [];
|
||||||
@@ -80,13 +84,13 @@ export default function SubtaskDetailScreen() {
|
|||||||
if (!id || !isReady) return;
|
if (!id || !isReady) return;
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
|
|
||||||
(async () => {
|
const subscription = collections.subtasks.findAndObserve(id).subscribe({
|
||||||
try {
|
next: (subtask: any) => {
|
||||||
const subtask = await collections.subtasks.find(id);
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
reset({
|
reset({
|
||||||
title: subtask.title,
|
title: subtask.title,
|
||||||
description: subtask.description,
|
description: subtask.description,
|
||||||
|
categoryId: subtask.categoryId || '',
|
||||||
priority: subtask.priority,
|
priority: subtask.priority,
|
||||||
dueDate: subtask.dueDate ? new Date(subtask.dueDate) : null,
|
dueDate: subtask.dueDate ? new Date(subtask.dueDate) : null,
|
||||||
dueTime: subtask.dueTime,
|
dueTime: subtask.dueTime,
|
||||||
@@ -94,18 +98,25 @@ export default function SubtaskDetailScreen() {
|
|||||||
allDay: subtask.allDay ?? false,
|
allDay: subtask.allDay ?? false,
|
||||||
repeat: subtask.repeat,
|
repeat: subtask.repeat,
|
||||||
repeatInterval: subtask.repeatInterval || 1,
|
repeatInterval: subtask.repeatInterval || 1,
|
||||||
repeatDays: (subtask.repeatDays || '').split(',').map(Number).filter((d) => !Number.isNaN(d)),
|
repeatDays: ((subtask.repeatDays || '') as string).split(',').map(Number).filter((d) => !Number.isNaN(d)),
|
||||||
reminder: (subtask.reminder || 'none') as Reminder,
|
reminder: (subtask.reminder || 'none') as Reminder,
|
||||||
reminders: subtask.reminders || '',
|
reminders: subtask.reminders || '',
|
||||||
assigneeId: subtask.assigneeId ?? null,
|
assigneeId: subtask.assigneeId ?? null,
|
||||||
});
|
});
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
} catch {
|
},
|
||||||
|
error: () => {
|
||||||
if (mounted) setNotFound(true);
|
if (mounted) setNotFound(true);
|
||||||
}
|
},
|
||||||
})();
|
complete: () => {
|
||||||
|
if (mounted) setNotFound(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return () => { mounted = false; };
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
subscription.unsubscribe();
|
||||||
|
};
|
||||||
}, [id, isReady, reset]);
|
}, [id, isReady, reset]);
|
||||||
|
|
||||||
const onSubmit = async (data: SubtaskFormData) => {
|
const onSubmit = async (data: SubtaskFormData) => {
|
||||||
@@ -114,6 +125,7 @@ export default function SubtaskDetailScreen() {
|
|||||||
await updateSubtask(id, {
|
await updateSubtask(id, {
|
||||||
title: data.title,
|
title: data.title,
|
||||||
description: data.description || '',
|
description: data.description || '',
|
||||||
|
categoryId: data.categoryId || '',
|
||||||
priority: data.priority,
|
priority: data.priority,
|
||||||
dueDate: data.dueDate ? data.dueDate.getTime() : 0,
|
dueDate: data.dueDate ? data.dueDate.getTime() : 0,
|
||||||
dueTime: data.dueTime || '',
|
dueTime: data.dueTime || '',
|
||||||
@@ -187,6 +199,10 @@ export default function SubtaskDetailScreen() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<CategorySelector
|
||||||
|
value={categoryId ? [categoryId] : []}
|
||||||
|
onChange={(value) => setValue('categoryId', value[0] ?? '')}
|
||||||
|
/>
|
||||||
<PrioritySelector
|
<PrioritySelector
|
||||||
value={priority}
|
value={priority}
|
||||||
onChange={(value) => setValue('priority', value)}
|
onChange={(value) => setValue('priority', value)}
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ import { z } from 'zod';
|
|||||||
import { useDatabase } from '@/hooks/useDatabase';
|
import { useDatabase } from '@/hooks/useDatabase';
|
||||||
import { database, collections } from '@/database';
|
import { database, collections } from '@/database';
|
||||||
import { Q } from '@nozbe/watermelondb';
|
import { Q } from '@nozbe/watermelondb';
|
||||||
import { TaskFormData } from '@/types';
|
import { TaskFormData, parseTaskTags, tagsToString } from '@/types';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { deleteTaskOccurrences } from '@/utils/taskActions';
|
import { deleteTaskOccurrences } from '@/utils/taskActions';
|
||||||
import { scheduleTaskReminder } from '@/services/notifications';
|
import { scheduleTaskReminder } from '@/services/notifications';
|
||||||
|
import { recordTombstonesInBatch } from '@/database/tombstones';
|
||||||
import { useFriends } from '@/hooks/useFriends';
|
import { useFriends } from '@/hooks/useFriends';
|
||||||
import Svg, { Path, Circle } from 'react-native-svg';
|
import Svg, { Path, Circle } from 'react-native-svg';
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ const taskSchema = z.object({
|
|||||||
title: z.string().trim().min(1, 'Task name is required').max(100),
|
title: z.string().trim().min(1, 'Task name is required').max(100),
|
||||||
description: z.string().max(1000).optional(),
|
description: z.string().max(1000).optional(),
|
||||||
categoryId: z.string().optional(),
|
categoryId: z.string().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
|
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
|
||||||
dueDate: z.date().nullable().optional(),
|
dueDate: z.date().nullable().optional(),
|
||||||
dueTime: z.string().optional(),
|
dueTime: z.string().optional(),
|
||||||
@@ -86,6 +88,7 @@ export default function TaskDetailScreen() {
|
|||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
categoryId: '',
|
categoryId: '',
|
||||||
|
tags: [],
|
||||||
priority: 'none',
|
priority: 'none',
|
||||||
dueDate: null,
|
dueDate: null,
|
||||||
dueTime: '',
|
dueTime: '',
|
||||||
@@ -102,7 +105,7 @@ export default function TaskDetailScreen() {
|
|||||||
|
|
||||||
const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
|
const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
|
||||||
|
|
||||||
const categoryId = watch('categoryId');
|
const tags = watch('tags') ?? [];
|
||||||
const priority = watch('priority');
|
const priority = watch('priority');
|
||||||
const repeat = watch('repeat');
|
const repeat = watch('repeat');
|
||||||
const repeatInterval = watch('repeatInterval') ?? 1;
|
const repeatInterval = watch('repeatInterval') ?? 1;
|
||||||
@@ -127,6 +130,7 @@ export default function TaskDetailScreen() {
|
|||||||
title: task.title,
|
title: task.title,
|
||||||
description: task.description,
|
description: task.description,
|
||||||
categoryId: task.categoryId,
|
categoryId: task.categoryId,
|
||||||
|
tags: parseTaskTags(task.tags, task.categoryId),
|
||||||
priority: task.priority,
|
priority: task.priority,
|
||||||
dueDate: task.dueDate ? new Date(task.dueDate) : null,
|
dueDate: task.dueDate ? new Date(task.dueDate) : null,
|
||||||
dueTime: task.dueTime,
|
dueTime: task.dueTime,
|
||||||
@@ -160,14 +164,12 @@ export default function TaskDetailScreen() {
|
|||||||
const task = await collections.tasks.find(id);
|
const task = await collections.tasks.find(id);
|
||||||
savedTask = task;
|
savedTask = task;
|
||||||
const existingSubtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch();
|
const existingSubtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch();
|
||||||
for (const subtask of existingSubtasks) {
|
|
||||||
await subtask.destroyPermanently();
|
|
||||||
}
|
|
||||||
|
|
||||||
await task.update((t) => {
|
await task.update((t) => {
|
||||||
t.title = data.title.trim();
|
t.title = data.title.trim();
|
||||||
t.description = data.description || '';
|
t.description = data.description || '';
|
||||||
t.categoryId = data.categoryId || task.categoryId || '';
|
t.categoryId = (data.tags && data.tags[0]) || task.categoryId || '';
|
||||||
|
t.tags = tagsToString(data.tags || []);
|
||||||
t.priority = data.priority;
|
t.priority = data.priority;
|
||||||
t.dueDate = dueDateTimestamp;
|
t.dueDate = dueDateTimestamp;
|
||||||
t.dueTime = data.dueTime || '';
|
t.dueTime = data.dueTime || '';
|
||||||
@@ -184,32 +186,64 @@ export default function TaskDetailScreen() {
|
|||||||
t.updatedAt = now;
|
t.updatedAt = now;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.subtasks && data.subtasks.length > 0) {
|
const keptIds = new Set<string>();
|
||||||
for (let i = 0; i < data.subtasks.length; i++) {
|
let order = 0;
|
||||||
const subtask = data.subtasks[i];
|
for (const formItem of data.subtasks ?? []) {
|
||||||
if (subtask.title.trim()) {
|
const trimmed = formItem.title.trim();
|
||||||
await collections.subtasks.create((s) => {
|
if (!trimmed) continue;
|
||||||
s.taskId = task.id;
|
if (formItem._key) {
|
||||||
s.title = subtask.title.trim();
|
const existing = existingSubtasks.find((s) => s.id === formItem._key && !s.parentSubtaskId);
|
||||||
s.description = '';
|
if (existing) {
|
||||||
s.priority = 'none';
|
keptIds.add(existing.id);
|
||||||
s.completed = false;
|
await existing.update((s) => {
|
||||||
s.dueDate = 0;
|
s.title = trimmed;
|
||||||
s.dueTime = '';
|
s.order = order;
|
||||||
s.endTime = '';
|
|
||||||
s.allDay = false;
|
|
||||||
s.repeat = 'none';
|
|
||||||
s.repeatInterval = 1;
|
|
||||||
s.repeatDays = '';
|
|
||||||
s.seriesId = '';
|
|
||||||
s.reminder = 'none';
|
|
||||||
s.assigneeId = null;
|
|
||||||
s.order = i;
|
|
||||||
s.createdAt = now;
|
|
||||||
s.updatedAt = now;
|
s.updatedAt = now;
|
||||||
});
|
});
|
||||||
|
order++;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await collections.subtasks.create((s) => {
|
||||||
|
s.taskId = task.id;
|
||||||
|
s.categoryId = task.categoryId || '';
|
||||||
|
s.title = trimmed;
|
||||||
|
s.description = '';
|
||||||
|
s.priority = 'none';
|
||||||
|
s.completed = false;
|
||||||
|
s.dueDate = 0;
|
||||||
|
s.dueTime = '';
|
||||||
|
s.endTime = '';
|
||||||
|
s.allDay = false;
|
||||||
|
s.repeat = 'none';
|
||||||
|
s.repeatInterval = 1;
|
||||||
|
s.repeatDays = '';
|
||||||
|
s.seriesId = '';
|
||||||
|
s.reminder = 'none';
|
||||||
|
s.reminders = '';
|
||||||
|
s.assigneeId = null;
|
||||||
|
s.order = order++;
|
||||||
|
s.createdAt = now;
|
||||||
|
s.updatedAt = now;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const removedIds: string[] = [];
|
||||||
|
for (const existing of existingSubtasks) {
|
||||||
|
if (existing.parentSubtaskId) continue;
|
||||||
|
if (keptIds.has(existing.id)) continue;
|
||||||
|
removedIds.push(existing.id);
|
||||||
|
const children = await collections.subtasks.query(Q.where('parent_subtask_id', existing.id)).fetch();
|
||||||
|
for (const child of children) {
|
||||||
|
await child.update((c) => {
|
||||||
|
c.parentSubtaskId = null;
|
||||||
|
c.updatedAt = now;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await existing.destroyPermanently();
|
||||||
|
}
|
||||||
|
if (removedIds.length > 0) {
|
||||||
|
await recordTombstonesInBatch('subtasks', removedIds);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -261,8 +295,8 @@ export default function TaskDetailScreen() {
|
|||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
>
|
||||||
<CategorySelector
|
<CategorySelector
|
||||||
value={categoryId}
|
value={tags}
|
||||||
onChange={(value) => setValue('categoryId', value)}
|
onChange={(value) => setValue('tags', value)}
|
||||||
error={errors.categoryId?.message}
|
error={errors.categoryId?.message}
|
||||||
/>
|
/>
|
||||||
<Controller
|
<Controller
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { useSettings, ThemeColors } from '@/theme';
|
|||||||
import Svg, { Path, Circle } from 'react-native-svg';
|
import Svg, { Path, Circle } from 'react-native-svg';
|
||||||
|
|
||||||
interface CategoryFilterProps {
|
interface CategoryFilterProps {
|
||||||
selected: string;
|
selected: string[];
|
||||||
onSelect: (categoryId: string) => void;
|
onSelect: (categoryIds: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
||||||
@@ -15,6 +15,19 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
|||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const selectedSet = new Set(selected);
|
||||||
|
const isAnythingSelected = selected.length > 0;
|
||||||
|
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
const next = new Set(selectedSet);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
onSelect(Array.from(next));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
horizontal
|
horizontal
|
||||||
@@ -26,8 +39,8 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
|||||||
id="all"
|
id="all"
|
||||||
name="All"
|
name="All"
|
||||||
color="#9E9E9E"
|
color="#9E9E9E"
|
||||||
selected={selected === 'all'}
|
selected={!isAnythingSelected}
|
||||||
onPress={() => onSelect('all')}
|
onPress={() => onSelect([])}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
@@ -36,8 +49,8 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
|||||||
id={category.id}
|
id={category.id}
|
||||||
name={category.name}
|
name={category.name}
|
||||||
color={category.color}
|
color={category.color}
|
||||||
selected={selected === category.id}
|
selected={selectedSet.has(category.id)}
|
||||||
onPress={() => onSelect(category.id)}
|
onPress={() => toggle(category.id)}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { useSettings } from '@/theme';
|
|||||||
import Svg, { Path } from 'react-native-svg';
|
import Svg, { Path } from 'react-native-svg';
|
||||||
|
|
||||||
interface CategorySelectorProps {
|
interface CategorySelectorProps {
|
||||||
value: string;
|
value: string[];
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string[]) => void;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,11 +15,30 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
|||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
const selectedCategory = categories.find(c => c.id === value);
|
const selected = new Set(value);
|
||||||
|
const selectedCategories = categories.filter((c) => selected.has(c.id));
|
||||||
|
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
const next = new Set(selected);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
onChange(Array.from(next));
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary =
|
||||||
|
selectedCategories.length === 0
|
||||||
|
? 'None'
|
||||||
|
: selectedCategories
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((c) => c.name)
|
||||||
|
.join(', ') + (selectedCategories.length > 2 ? ` +${selectedCategories.length - 2}` : '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Text style={[styles.label, { color: theme.text }]}>Category</Text>
|
<Text style={[styles.label, { color: theme.text }]}>Tags</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
styles.selectorButton,
|
styles.selectorButton,
|
||||||
@@ -28,14 +47,22 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
|||||||
onPress={() => setShowModal(true)}
|
onPress={() => setShowModal(true)}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Select category"
|
accessibilityLabel="Select tags"
|
||||||
accessibilityHint="Opens a list of categories to choose from"
|
accessibilityHint="Opens a list of tags to choose from"
|
||||||
>
|
>
|
||||||
<View style={styles.selectorContent}>
|
<View style={styles.selectorContent}>
|
||||||
<View style={styles.selectorRow}>
|
{selectedCategories.length > 0 ? (
|
||||||
<View style={[styles.colorCircle, { backgroundColor: selectedCategory?.color || '#9E9E9E' }]} />
|
<View style={styles.chipRow}>
|
||||||
<Text style={[styles.selectorValue, { color: theme.text }]}>{selectedCategory?.name || 'None'}</Text>
|
{selectedCategories.slice(0, 3).map((c) => (
|
||||||
</View>
|
<View key={c.id} style={[styles.chip, { backgroundColor: theme.cardAlt, borderColor: theme.border }]}>
|
||||||
|
<View style={[styles.colorCircle, { backgroundColor: c.color }]} />
|
||||||
|
<Text style={[styles.chipText, { color: theme.textSecondary }]} numberOfLines={1}>{c.name}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text style={[styles.selectorValue, { color: theme.textMuted }]}>None</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
<Path d="M9 18l6-6-6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
<Path d="M9 18l6-6-6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
@@ -51,67 +78,62 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
|||||||
<Pressable style={styles.modalOverlay} onPress={() => setShowModal(false)}>
|
<Pressable style={styles.modalOverlay} onPress={() => setShowModal(false)}>
|
||||||
<Pressable style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}>
|
<Pressable style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}>
|
||||||
<View style={styles.modalHeader}>
|
<View style={styles.modalHeader}>
|
||||||
<Text style={[styles.modalTitle, { color: theme.text }]}>Select Category</Text>
|
<Text style={[styles.modalTitle, { color: theme.text }]}>Select Tags</Text>
|
||||||
<TouchableOpacity onPress={() => setShowModal(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} accessibilityRole="button" accessibilityLabel="Close category picker">
|
<View style={styles.modalHeaderRight}>
|
||||||
<Text style={[styles.closeText, { color: theme.textMuted }]}>✕</Text>
|
<TouchableOpacity
|
||||||
</TouchableOpacity>
|
onPress={() => { onChange([]); setShowModal(false); }}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Clear all tags"
|
||||||
|
>
|
||||||
|
<Text style={[styles.clearText, { color: theme.textMuted }]}>Clear</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowModal(false)}
|
||||||
|
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Done selecting tags"
|
||||||
|
>
|
||||||
|
<Text style={[styles.doneText, { color: theme.accent }]}>Done</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView contentContainerStyle={styles.modalContent}>
|
<ScrollView contentContainerStyle={styles.modalContent}>
|
||||||
<TouchableOpacity
|
<Text style={[styles.modalHint, { color: theme.textMuted }]}>
|
||||||
style={[
|
A task can have multiple tags. Selected: {selected.size}
|
||||||
styles.modalOption,
|
</Text>
|
||||||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
{categories.map((category) => {
|
||||||
!value && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
|
const isSelected = selected.has(category.id);
|
||||||
]}
|
return (
|
||||||
onPress={() => { onChange(''); setShowModal(false); }}
|
<TouchableOpacity
|
||||||
activeOpacity={0.8}
|
key={category.id}
|
||||||
accessibilityRole="radio"
|
style={[
|
||||||
accessibilityLabel="No category"
|
styles.modalOption,
|
||||||
accessibilityState={{ selected: !value }}
|
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
||||||
>
|
isSelected && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
|
||||||
<View style={[styles.colorCircle, { backgroundColor: '#9E9E9E' }, !value && styles.colorCircleSelected]} />
|
]}
|
||||||
<Text style={[
|
onPress={() => toggle(category.id)}
|
||||||
styles.categoryName,
|
activeOpacity={0.8}
|
||||||
{ color: theme.textSecondary },
|
accessibilityRole="checkbox"
|
||||||
!value && { color: theme.accentStrong, fontWeight: '600' },
|
accessibilityLabel={`Tag ${category.name}`}
|
||||||
]}>
|
accessibilityState={{ checked: isSelected }}
|
||||||
None
|
>
|
||||||
</Text>
|
<View style={[styles.colorCircle, { backgroundColor: category.color }, isSelected && styles.colorCircleSelected]} />
|
||||||
{!value && (
|
<Text style={[
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
styles.categoryName,
|
||||||
<Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
{ color: theme.textSecondary },
|
||||||
</Svg>
|
isSelected && { color: theme.text, fontWeight: '600' },
|
||||||
)}
|
]}>
|
||||||
</TouchableOpacity>
|
{category.name}
|
||||||
{categories.map((category) => (
|
</Text>
|
||||||
<TouchableOpacity
|
{isSelected && (
|
||||||
key={category.id}
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
style={[
|
<Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
styles.modalOption,
|
</Svg>
|
||||||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
)}
|
||||||
value === category.id && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
|
</TouchableOpacity>
|
||||||
]}
|
);
|
||||||
onPress={() => { onChange(category.id); setShowModal(false); }}
|
})}
|
||||||
activeOpacity={0.8}
|
|
||||||
accessibilityRole="radio"
|
|
||||||
accessibilityLabel={`Category ${category.name}`}
|
|
||||||
accessibilityState={{ selected: value === category.id }}
|
|
||||||
>
|
|
||||||
<View style={[styles.colorCircle, { backgroundColor: category.color }, value === category.id && styles.colorCircleSelected]} />
|
|
||||||
<Text style={[
|
|
||||||
styles.categoryName,
|
|
||||||
{ color: theme.textSecondary },
|
|
||||||
value === category.id && { color: theme.accentStrong, fontWeight: '600' },
|
|
||||||
]}>
|
|
||||||
{category.name}
|
|
||||||
</Text>
|
|
||||||
{value === category.id && (
|
|
||||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
|
||||||
<Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
|
||||||
</Svg>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -141,11 +163,25 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
selectorContent: {
|
selectorContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
paddingRight: 8,
|
||||||
},
|
},
|
||||||
selectorRow: {
|
chipRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
chip: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 10,
|
gap: 6,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
chipText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
colorCircle: {
|
colorCircle: {
|
||||||
width: 12,
|
width: 12,
|
||||||
@@ -185,13 +221,27 @@ const styles = StyleSheet.create({
|
|||||||
padding: 20,
|
padding: 20,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
},
|
},
|
||||||
|
modalHeaderRight: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
modalTitle: {
|
modalTitle: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
closeText: {
|
clearText: {
|
||||||
fontSize: 22,
|
fontSize: 14,
|
||||||
fontWeight: '300',
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
doneText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
modalHint: {
|
||||||
|
fontSize: 12,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
paddingBottom: 4,
|
||||||
},
|
},
|
||||||
modalContent: {
|
modalContent: {
|
||||||
padding: 12,
|
padding: 12,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { View, StyleSheet, TextInput, TouchableOpacity, Keyboard, Text } from 'react-native';
|
import { View, StyleSheet, TextInput, TouchableOpacity, Keyboard, Text } from 'react-native';
|
||||||
import { database, collections } from '@/database';
|
import { database, collections } from '@/database';
|
||||||
import { useCategories } from '@/hooks/useDatabase';
|
import { useCategories } from '@/hooks/useDatabase';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
||||||
|
import { tagsToString } from '@/types';
|
||||||
import Svg, { Path } from 'react-native-svg';
|
import Svg, { Path } from 'react-native-svg';
|
||||||
import { subscribeToQuickAdd } from '@/utils/quickAddFocus';
|
import { subscribeToQuickAdd } from '@/utils/quickAddFocus';
|
||||||
|
|
||||||
@@ -20,7 +21,11 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
|||||||
const [categoryPickerVisible, setCategoryPickerVisible] = useState(false);
|
const [categoryPickerVisible, setCategoryPickerVisible] = useState(false);
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
const categoryColor = categories.find((c) => c.id === categoryId)?.color ?? '#9E9E9E';
|
const visibleCategories = useMemo(
|
||||||
|
() => categories.filter((c) => c.name.toLowerCase() !== 'calendar'),
|
||||||
|
[categories]
|
||||||
|
);
|
||||||
|
const categoryColor = visibleCategories.find((c) => c.id === categoryId)?.color ?? '#9E9E9E';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return subscribeToQuickAdd(() => {
|
return subscribeToQuickAdd(() => {
|
||||||
@@ -40,6 +45,7 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
|||||||
t.title = trimmed;
|
t.title = trimmed;
|
||||||
t.description = '';
|
t.description = '';
|
||||||
t.categoryId = categoryId || '';
|
t.categoryId = categoryId || '';
|
||||||
|
t.tags = tagsToString(categoryId ? [categoryId] : []);
|
||||||
t.priority = 'none';
|
t.priority = 'none';
|
||||||
t.completed = false;
|
t.completed = false;
|
||||||
t.dueDate = dueDate;
|
t.dueDate = dueDate;
|
||||||
@@ -111,7 +117,7 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
|||||||
<OptionPickerModal
|
<OptionPickerModal
|
||||||
visible={categoryPickerVisible}
|
visible={categoryPickerVisible}
|
||||||
title="Select Category"
|
title="Select Category"
|
||||||
options={[{ value: '', label: 'None', color: '#9E9E9E' }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
|
options={[{ value: '', label: 'None', color: '#9E9E9E' }, ...visibleCategories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
|
||||||
selectedValue={categoryId}
|
selectedValue={categoryId}
|
||||||
onSelect={(value) => setCategoryId(Array.isArray(value) ? value[0] : value)}
|
onSelect={(value) => setCategoryId(Array.isArray(value) ? value[0] : value)}
|
||||||
onClose={() => setCategoryPickerVisible(false)}
|
onClose={() => setCategoryPickerVisible(false)}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { View, StyleSheet } from 'react-native';
|
import { View, StyleSheet } from 'react-native';
|
||||||
import { SubtaskData } from '@/types';
|
import { SubtaskData } from '@/types';
|
||||||
import { TaskItem } from './TaskItem';
|
import { TaskItem } from './TaskItem';
|
||||||
import { useSettings } from '@/theme';
|
|
||||||
|
|
||||||
interface SubtaskItemProps {
|
interface SubtaskItemProps {
|
||||||
subtask: SubtaskData;
|
subtask: SubtaskData;
|
||||||
@@ -19,6 +18,7 @@ interface SubtaskItemProps {
|
|||||||
onDragEnd?: (absoluteY: number) => void;
|
onDragEnd?: (absoluteY: number) => void;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
categoryColor?: string;
|
categoryColor?: string;
|
||||||
|
categoryColorResolver?: (categoryId: string | undefined) => string | undefined;
|
||||||
registerRef?: (subtaskId: string, parentTaskId: string, ref: View | null) => void;
|
registerRef?: (subtaskId: string, parentTaskId: string, ref: View | null) => void;
|
||||||
hoveredId?: string | null;
|
hoveredId?: string | null;
|
||||||
}
|
}
|
||||||
@@ -38,13 +38,16 @@ export const SubtaskItem = React.memo(function SubtaskItem({
|
|||||||
onDragEnd,
|
onDragEnd,
|
||||||
depth = 1,
|
depth = 1,
|
||||||
categoryColor,
|
categoryColor,
|
||||||
|
categoryColorResolver,
|
||||||
registerRef,
|
registerRef,
|
||||||
hoveredId,
|
hoveredId,
|
||||||
}: SubtaskItemProps) {
|
}: SubtaskItemProps) {
|
||||||
const { theme } = useSettings();
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const hasChildren = subtask.subtasks && subtask.subtasks.length > 0;
|
const hasChildren = subtask.subtasks && subtask.subtasks.length > 0;
|
||||||
const hovered = hoveredId === subtask.id;
|
const hovered = hoveredId === subtask.id;
|
||||||
|
const effectiveColor = subtask.categoryId
|
||||||
|
? categoryColorResolver?.(subtask.categoryId) ?? categoryColor
|
||||||
|
: categoryColor;
|
||||||
|
|
||||||
const handleExpand = () => setExpanded(!expanded);
|
const handleExpand = () => setExpanded(!expanded);
|
||||||
|
|
||||||
@@ -75,7 +78,7 @@ export const SubtaskItem = React.memo(function SubtaskItem({
|
|||||||
onDragStart={() => onDragStart?.(subtask)}
|
onDragStart={() => onDragStart?.(subtask)}
|
||||||
onDragUpdate={onDragUpdate}
|
onDragUpdate={onDragUpdate}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
categoryColor={categoryColor}
|
categoryColor={effectiveColor}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{hasChildren && expanded && (
|
{hasChildren && expanded && (
|
||||||
@@ -99,7 +102,8 @@ export const SubtaskItem = React.memo(function SubtaskItem({
|
|||||||
onDragUpdate={onDragUpdate}
|
onDragUpdate={onDragUpdate}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
categoryColor={categoryColor}
|
categoryColor={effectiveColor}
|
||||||
|
categoryColorResolver={categoryColorResolver}
|
||||||
registerRef={registerRef}
|
registerRef={registerRef}
|
||||||
hoveredId={hoveredId}
|
hoveredId={hoveredId}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -42,9 +42,10 @@ interface TaskItemProps {
|
|||||||
indented?: boolean;
|
indented?: boolean;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
categoryColor?: string;
|
categoryColor?: string;
|
||||||
|
categoryColors?: (string | undefined)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMenuOpen, selected, selectionMode, completedSection, draggable, hovered, onDragStart, onDragUpdate, onDragEnd, onReorderStart, onReorderUpdate, onReorderEnd, expanded, indented, depth = 0, categoryColor }: TaskItemProps) {
|
export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMenuOpen, selected, selectionMode, completedSection, draggable, hovered, onDragStart, onDragUpdate, onDragEnd, onReorderStart, onReorderUpdate, onReorderEnd, expanded, indented, depth = 0, categoryColor, categoryColors }: TaskItemProps) {
|
||||||
const { theme } = useSettings();
|
const { theme } = useSettings();
|
||||||
const [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1));
|
const [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1));
|
||||||
const [dragTranslateX] = React.useState(new Animated.Value(0));
|
const [dragTranslateX] = React.useState(new Animated.Value(0));
|
||||||
@@ -215,9 +216,16 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
|||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
<View style={styles.titleRow}>
|
<View style={styles.titleRow}>
|
||||||
<View style={styles.categoryDotSlot}>
|
<View style={styles.categoryDotSlot}>
|
||||||
{categoryColor ? (
|
{(categoryColors ?? (categoryColor ? [categoryColor] : [])).slice(0, 3).map((color, i) => (
|
||||||
<View style={[styles.categoryDot, { backgroundColor: categoryColor }]} />
|
<View
|
||||||
) : null}
|
key={`${color}-${i}`}
|
||||||
|
style={[
|
||||||
|
styles.categoryDot,
|
||||||
|
{ backgroundColor: color },
|
||||||
|
i > 0 && { marginLeft: -6 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.checkCircle}
|
style={styles.checkCircle}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
import React, { useState, useCallback, useMemo, useRef } from 'react';
|
||||||
import { View, Text, StyleSheet, Animated, RefreshControl, Alert, TouchableOpacity } from 'react-native';
|
import { View, Text, StyleSheet, Animated, RefreshControl, Alert, TouchableOpacity } from 'react-native';
|
||||||
import { useTasks } from '@/hooks/useTasks';
|
import { useTasks } from '@/hooks/useTasks';
|
||||||
|
import { useSubtasks } from '@/hooks/useSubtasks';
|
||||||
import { useCategories } from '@/hooks/useDatabase';
|
import { useCategories } from '@/hooks/useDatabase';
|
||||||
import { useTaskModals } from '@/hooks/useTaskModals';
|
import { useTaskModals } from '@/hooks/useTaskModals';
|
||||||
|
import { useFocusEffect } from 'expo-router';
|
||||||
import { TaskItem } from './TaskItem';
|
import { TaskItem } from './TaskItem';
|
||||||
import { SubtaskItem } from './SubtaskItem';
|
import { SubtaskItem } from './SubtaskItem';
|
||||||
import { TaskData, SubtaskData } from '@/types';
|
import { TaskData, SubtaskData, parseTaskTags } from '@/types';
|
||||||
import Task from '@/models/Task';
|
import Task from '@/models/Task';
|
||||||
import { useSettings } from '@/theme';
|
import { useSettings } from '@/theme';
|
||||||
import {
|
import {
|
||||||
@@ -17,12 +19,12 @@ import {
|
|||||||
moveSubtaskToTask,
|
moveSubtaskToTask,
|
||||||
setSubtaskParent,
|
setSubtaskParent,
|
||||||
toggleSubtaskComplete,
|
toggleSubtaskComplete,
|
||||||
fetchSubtaskTree,
|
|
||||||
} from '@/utils/taskActions';
|
} from '@/utils/taskActions';
|
||||||
import Svg, { Path, Rect } from 'react-native-svg';
|
import Svg, { Path } from 'react-native-svg';
|
||||||
|
|
||||||
interface TaskListProps {
|
interface TaskListProps {
|
||||||
categoryId?: string;
|
categoryIds?: string[];
|
||||||
|
showCompleted?: boolean;
|
||||||
onSelectionChange?: (active: boolean) => void;
|
onSelectionChange?: (active: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,10 +41,10 @@ const DropIndicator = ({ theme }: { theme: any }) => (
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProps) {
|
export function TaskList({ categoryIds = [], showCompleted = false, onSelectionChange }: TaskListProps) {
|
||||||
const { theme, sortBy, todoAheadDays } = useSettings();
|
const { theme, sortBy, todoAheadDays } = useSettings();
|
||||||
|
|
||||||
const { tasks, loading } = useTasks(categoryId, 'all', todoAheadDays);
|
const { tasks, loading, refresh: refreshTasks } = useTasks(categoryIds, showCompleted ? 'all' : false, todoAheadDays);
|
||||||
|
|
||||||
const categories = useCategories();
|
const categories = useCategories();
|
||||||
const categoryColors = useMemo(() => {
|
const categoryColors = useMemo(() => {
|
||||||
@@ -58,7 +60,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [hoverTaskId, setHoverTaskId] = useState<string | null>(null);
|
const [hoverTaskId, setHoverTaskId] = useState<string | null>(null);
|
||||||
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
|
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
|
||||||
const [subtasksMap, setSubtasksMap] = useState<Map<string, SubtaskData[]>>(new Map());
|
const { map: subtasksMap, refresh: refreshSubtasks } = useSubtasks();
|
||||||
const [dropIndicator, setDropIndicator] = useState<{ targetId: string | null; position: 'above' | 'below' } | null>(null);
|
const [dropIndicator, setDropIndicator] = useState<{ targetId: string | null; position: 'above' | 'below' } | null>(null);
|
||||||
const itemRefs = useRef<Map<string, View>>(new Map());
|
const itemRefs = useRef<Map<string, View>>(new Map());
|
||||||
const subtaskRefs = useRef<Map<string, { ref: View; parentTaskId: string }>>(new Map());
|
const subtaskRefs = useRef<Map<string, { ref: View; parentTaskId: string }>>(new Map());
|
||||||
@@ -88,6 +90,11 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const categoryColorResolver = useCallback(
|
||||||
|
(categoryId: string | undefined) => (categoryId ? categoryColors.get(categoryId) : undefined),
|
||||||
|
[categoryColors]
|
||||||
|
);
|
||||||
|
|
||||||
const sortedTasks = useMemo(() => {
|
const sortedTasks = useMemo(() => {
|
||||||
const sorted = [...tasks];
|
const sorted = [...tasks];
|
||||||
switch (sortBy) {
|
switch (sortBy) {
|
||||||
@@ -104,13 +111,27 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
sorted.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
sorted.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
return sorted;
|
const active: typeof sorted = [];
|
||||||
|
const done: typeof sorted = [];
|
||||||
|
for (const t of sorted) {
|
||||||
|
(t.completed ? done : active).push(t);
|
||||||
|
}
|
||||||
|
return [...active, ...done];
|
||||||
}, [tasks, sortBy]);
|
}, [tasks, sortBy]);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
refreshTasks();
|
||||||
|
refreshSubtasks();
|
||||||
|
}, [refreshTasks, refreshSubtasks])
|
||||||
|
);
|
||||||
|
|
||||||
const onRefresh = useCallback(() => {
|
const onRefresh = useCallback(() => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
|
refreshTasks();
|
||||||
|
refreshSubtasks();
|
||||||
setTimeout(() => setRefreshing(false), 600);
|
setTimeout(() => setRefreshing(false), 600);
|
||||||
}, []);
|
}, [refreshTasks, refreshSubtasks]);
|
||||||
|
|
||||||
const exitSelection = useCallback(() => {
|
const exitSelection = useCallback(() => {
|
||||||
setSelectionMode(false);
|
setSelectionMode(false);
|
||||||
@@ -140,22 +161,10 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
});
|
});
|
||||||
}, [onSelectionChange]);
|
}, [onSelectionChange]);
|
||||||
|
|
||||||
const fetchSubtasks = useCallback(async (taskId: string) => {
|
|
||||||
const withNested = await fetchSubtaskTree(taskId);
|
|
||||||
setSubtasksMap((prev) => new Map(prev).set(taskId, withNested));
|
|
||||||
return withNested;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const refreshAll = useCallback(() => {
|
|
||||||
for (const taskId of expandedTasks) {
|
|
||||||
fetchSubtasks(taskId);
|
|
||||||
}
|
|
||||||
}, [expandedTasks, fetchSubtasks]);
|
|
||||||
|
|
||||||
const handleToggle = useCallback(async (taskId: string) => {
|
const handleToggle = useCallback(async (taskId: string) => {
|
||||||
await toggleTaskComplete(taskId);
|
await toggleTaskComplete(taskId);
|
||||||
refreshAll();
|
refreshTasks();
|
||||||
}, [refreshAll]);
|
}, [refreshTasks]);
|
||||||
|
|
||||||
const toggleExpand = useCallback(async (taskId: string) => {
|
const toggleExpand = useCallback(async (taskId: string) => {
|
||||||
setExpandedTasks((prev) => {
|
setExpandedTasks((prev) => {
|
||||||
@@ -167,23 +176,13 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const isCurrentlyExpanded = expandedTasks.has(taskId);
|
const handleSubtaskToggle = useCallback(async (subtaskId: string) => {
|
||||||
if (isCurrentlyExpanded) {
|
|
||||||
setSubtasksMap((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.delete(taskId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await fetchSubtasks(taskId);
|
|
||||||
}
|
|
||||||
}, [expandedTasks, fetchSubtasks]);
|
|
||||||
|
|
||||||
const handleSubtaskToggle = useCallback(async (subtaskId: string, taskId: string) => {
|
|
||||||
await toggleSubtaskComplete(subtaskId);
|
await toggleSubtaskComplete(subtaskId);
|
||||||
await fetchSubtasks(taskId);
|
refreshTasks();
|
||||||
}, [fetchSubtasks]);
|
refreshSubtasks();
|
||||||
|
}, [refreshTasks, refreshSubtasks]);
|
||||||
|
|
||||||
const handleBulkDelete = useCallback(() => {
|
const handleBulkDelete = useCallback(() => {
|
||||||
Alert.alert(`Delete ${selectedIds.size} task${selectedIds.size > 1 ? 's' : ''}?`, 'This cannot be undone.', [
|
Alert.alert(`Delete ${selectedIds.size} task${selectedIds.size > 1 ? 's' : ''}?`, 'This cannot be undone.', [
|
||||||
@@ -194,17 +193,15 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
await Promise.all(Array.from(selectedIds).map(taskId => deleteTask(taskId)));
|
await Promise.all(Array.from(selectedIds).map(taskId => deleteTask(taskId)));
|
||||||
exitSelection();
|
exitSelection();
|
||||||
refreshAll();
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}, [selectedIds, exitSelection, refreshAll]);
|
}, [selectedIds, exitSelection]);
|
||||||
|
|
||||||
const handleBulkComplete = useCallback(async () => {
|
const handleBulkComplete = useCallback(async () => {
|
||||||
await Promise.all(Array.from(selectedIds).map(taskId => setTaskCompleted(taskId, true)));
|
await Promise.all(Array.from(selectedIds).map(taskId => setTaskCompleted(taskId, true)));
|
||||||
exitSelection();
|
exitSelection();
|
||||||
refreshAll();
|
}, [selectedIds, exitSelection]);
|
||||||
}, [selectedIds, exitSelection, refreshAll]);
|
|
||||||
|
|
||||||
const measureItems = useCallback(async () => {
|
const measureItems = useCallback(async () => {
|
||||||
const positions: Record<string, { top: number; bottom: number }> = {};
|
const positions: Record<string, { top: number; bottom: number }> = {};
|
||||||
@@ -323,17 +320,14 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
const parentTaskId = subtaskRefs.current.get(target)?.parentTaskId ?? state.taskId;
|
const parentTaskId = subtaskRefs.current.get(target)?.parentTaskId ?? state.taskId;
|
||||||
(async () => {
|
(async () => {
|
||||||
await convertTaskToSubtask(state.taskId, parentTaskId, target);
|
await convertTaskToSubtask(state.taskId, parentTaskId, target);
|
||||||
await fetchSubtasks(parentTaskId);
|
|
||||||
refreshAll();
|
|
||||||
})();
|
})();
|
||||||
} else {
|
} else {
|
||||||
(async () => {
|
(async () => {
|
||||||
await convertTaskToSubtask(state.taskId, target);
|
await convertTaskToSubtask(state.taskId, target);
|
||||||
refreshAll();
|
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [findHoverTarget, refreshAll, fetchSubtasks]);
|
}, [findHoverTarget]);
|
||||||
|
|
||||||
const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string) => {
|
const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string) => {
|
||||||
subtaskDragRef.current = { subtaskId, parentTaskId, ...(await measureAll()) };
|
subtaskDragRef.current = { subtaskId, parentTaskId, ...(await measureAll()) };
|
||||||
@@ -377,14 +371,11 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
await setSubtaskParent(state.subtaskId, target);
|
await setSubtaskParent(state.subtaskId, target);
|
||||||
} else if (target !== state.parentTaskId) {
|
} else if (target !== state.parentTaskId) {
|
||||||
await moveSubtaskToTask(state.subtaskId, target);
|
await moveSubtaskToTask(state.subtaskId, target);
|
||||||
await fetchSubtasks(target);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await convertSubtaskToTask(state.subtaskId);
|
await convertSubtaskToTask(state.subtaskId);
|
||||||
}
|
}
|
||||||
await fetchSubtasks(state.parentTaskId);
|
}, [findHoverTarget]);
|
||||||
refreshAll();
|
|
||||||
}, [findHoverTarget, fetchSubtasks, refreshAll]);
|
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item, index }: { item: Task; index: number }) => {
|
({ item, index }: { item: Task; index: number }) => {
|
||||||
@@ -392,6 +383,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
const itemSubtasks = subtasksMap.get(item.id) ?? [];
|
const itemSubtasks = subtasksMap.get(item.id) ?? [];
|
||||||
const showDropAbove = dropIndicator?.targetId === item.id && dropIndicator?.position === 'above';
|
const showDropAbove = dropIndicator?.targetId === item.id && dropIndicator?.position === 'above';
|
||||||
const showDropBelow = dropIndicator?.targetId === item.id && dropIndicator?.position === 'below';
|
const showDropBelow = dropIndicator?.targetId === item.id && dropIndicator?.position === 'below';
|
||||||
|
const tagColors = parseTaskTags(item.tags, item.categoryId).map((id) => categoryColors.get(id));
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
{showDropAbove && <DropIndicator theme={theme} />}
|
{showDropAbove && <DropIndicator theme={theme} />}
|
||||||
@@ -425,6 +417,8 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
onReorderEnd={handleDragEnd}
|
onReorderEnd={handleDragEnd}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
categoryColor={categoryColors.get(item.categoryId)}
|
categoryColor={categoryColors.get(item.categoryId)}
|
||||||
|
categoryColors={tagColors}
|
||||||
|
categoryColorResolver={categoryColorResolver}
|
||||||
/>
|
/>
|
||||||
{showDropBelow && <DropIndicator theme={theme} />}
|
{showDropBelow && <DropIndicator theme={theme} />}
|
||||||
</View>
|
</View>
|
||||||
@@ -454,6 +448,8 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
handleSubtaskDragStart,
|
handleSubtaskDragStart,
|
||||||
handleSubtaskDragUpdate,
|
handleSubtaskDragUpdate,
|
||||||
handleSubtaskDragEnd,
|
handleSubtaskDragEnd,
|
||||||
|
registerSubtaskRef,
|
||||||
|
categoryColorResolver,
|
||||||
categoryColors,
|
categoryColors,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -503,7 +499,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
|||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={styles.listContent}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{modals(refreshAll)}
|
{modals(() => {})}
|
||||||
|
|
||||||
{selectionMode && (
|
{selectionMode && (
|
||||||
<View style={[styles.selectionBar, { backgroundColor: theme.sheetBg, borderTopColor: theme.border }]}>
|
<View style={[styles.selectionBar, { backgroundColor: theme.sheetBg, borderTopColor: theme.border }]}>
|
||||||
@@ -566,6 +562,8 @@ interface TaskRowProps {
|
|||||||
onReorderEnd: (absoluteY: number, translationY: number) => void;
|
onReorderEnd: (absoluteY: number, translationY: number) => void;
|
||||||
selectedIds: Set<string>;
|
selectedIds: Set<string>;
|
||||||
categoryColor?: string;
|
categoryColor?: string;
|
||||||
|
categoryColors?: (string | undefined)[];
|
||||||
|
categoryColorResolver?: (categoryId: string | undefined) => string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TaskRow = React.memo(function TaskRow({
|
const TaskRow = React.memo(function TaskRow({
|
||||||
@@ -598,6 +596,8 @@ const TaskRow = React.memo(function TaskRow({
|
|||||||
onReorderEnd,
|
onReorderEnd,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
categoryColor,
|
categoryColor,
|
||||||
|
categoryColors,
|
||||||
|
categoryColorResolver,
|
||||||
}: TaskRowProps) {
|
}: TaskRowProps) {
|
||||||
const sortedSubtasks = useMemo(
|
const sortedSubtasks = useMemo(
|
||||||
() => subtasks.slice().sort((a, b) => a.order - b.order),
|
() => subtasks.slice().sort((a, b) => a.order - b.order),
|
||||||
@@ -627,6 +627,7 @@ const TaskRow = React.memo(function TaskRow({
|
|||||||
onReorderUpdate={onReorderUpdate}
|
onReorderUpdate={onReorderUpdate}
|
||||||
onReorderEnd={onReorderEnd}
|
onReorderEnd={onReorderEnd}
|
||||||
categoryColor={categoryColor}
|
categoryColor={categoryColor}
|
||||||
|
categoryColors={categoryColors}
|
||||||
/>
|
/>
|
||||||
{expanded && subtasks.length > 0 && (
|
{expanded && subtasks.length > 0 && (
|
||||||
<View style={styles.subtaskList}>
|
<View style={styles.subtaskList}>
|
||||||
@@ -636,16 +637,17 @@ const TaskRow = React.memo(function TaskRow({
|
|||||||
subtask={sub}
|
subtask={sub}
|
||||||
hoveredId={hoverTaskId}
|
hoveredId={hoverTaskId}
|
||||||
registerRef={registerSubtaskRef}
|
registerRef={registerSubtaskRef}
|
||||||
onToggle={() => onSubtaskToggle(sub.id, task.id)}
|
onToggle={(sub) => onSubtaskToggle(sub.id, task.id)}
|
||||||
onDelete={() => onSubtaskDelete(sub)}
|
onDelete={() => onSubtaskDelete(sub)}
|
||||||
onMenuOpen={() => onSubtaskMenuOpen(sub)}
|
onMenuOpen={() => onSubtaskMenuOpen(sub)}
|
||||||
selected={selectedIds.has(sub.id)}
|
selected={selectedIds.has(sub.id)}
|
||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={() => onSubtaskDragStart(sub.id, task.id)}
|
onDragStart={(sub) => onSubtaskDragStart(sub.id, task.id)}
|
||||||
onDragUpdate={onSubtaskDragUpdate}
|
onDragUpdate={onSubtaskDragUpdate}
|
||||||
onDragEnd={onSubtaskDragEnd}
|
onDragEnd={onSubtaskDragEnd}
|
||||||
categoryColor={categoryColor}
|
categoryColor={categoryColor}
|
||||||
|
categoryColorResolver={categoryColorResolver}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -202,5 +202,23 @@ export const migrations = schemaMigrations({
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
toVersion: 19,
|
||||||
|
steps: [
|
||||||
|
addColumns({
|
||||||
|
table: 'subtasks',
|
||||||
|
columns: [{ name: 'category_id', type: 'string', isOptional: true }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
toVersion: 20,
|
||||||
|
steps: [
|
||||||
|
addColumns({
|
||||||
|
table: 'subtasks',
|
||||||
|
columns: [{ name: 'tags', type: 'string', isOptional: true }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { appSchema, tableSchema } from '@nozbe/watermelondb';
|
import { appSchema, tableSchema } from '@nozbe/watermelondb';
|
||||||
|
|
||||||
export const schema = appSchema({
|
export const schema = appSchema({
|
||||||
version: 18,
|
version: 20,
|
||||||
tables: [
|
tables: [
|
||||||
tableSchema({
|
tableSchema({
|
||||||
name: 'categories',
|
name: 'categories',
|
||||||
@@ -45,6 +45,9 @@ export const schema = appSchema({
|
|||||||
columns: [
|
columns: [
|
||||||
{ name: 'task_id', type: 'string', isIndexed: true },
|
{ name: 'task_id', type: 'string', isIndexed: true },
|
||||||
{ name: 'parent_subtask_id', type: 'string', isIndexed: true, isOptional: true },
|
{ name: 'parent_subtask_id', type: 'string', isIndexed: true, isOptional: true },
|
||||||
|
{ name: 'category_id', type: 'string', isOptional: true },
|
||||||
|
{ name: 'tags', type: 'string', isOptional: true },
|
||||||
|
{ name: 'category_id', type: 'string', isIndexed: true, isOptional: true },
|
||||||
{ name: 'title', type: 'string' },
|
{ name: 'title', type: 'string' },
|
||||||
{ name: 'description', type: 'string', isOptional: true },
|
{ name: 'description', type: 'string', isOptional: true },
|
||||||
{ name: 'priority', type: 'string', isOptional: true },
|
{ name: 'priority', type: 'string', isOptional: true },
|
||||||
|
|||||||
@@ -151,6 +151,8 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
|
|||||||
id: s.id,
|
id: s.id,
|
||||||
taskId: s.taskId,
|
taskId: s.taskId,
|
||||||
parentSubtaskId: s.parentSubtaskId || null,
|
parentSubtaskId: s.parentSubtaskId || null,
|
||||||
|
categoryId: s.categoryId || '',
|
||||||
|
tags: s.tags || '',
|
||||||
title: s.title,
|
title: s.title,
|
||||||
description: s.description ?? '',
|
description: s.description ?? '',
|
||||||
priority: s.priority ?? 'none',
|
priority: s.priority ?? 'none',
|
||||||
@@ -532,6 +534,8 @@ async function upsertSubtask(row: ServerRow, force: boolean): Promise<boolean> {
|
|||||||
await collections.subtasks.create((s) => {
|
await collections.subtasks.create((s) => {
|
||||||
s.taskId = String(row.taskId ?? '');
|
s.taskId = String(row.taskId ?? '');
|
||||||
s.parentSubtaskId = row.parentSubtaskId == null ? null : String(row.parentSubtaskId);
|
s.parentSubtaskId = row.parentSubtaskId == null ? null : String(row.parentSubtaskId);
|
||||||
|
s.categoryId = row.categoryId ? String(row.categoryId) : '';
|
||||||
|
s.tags = String(row.tags ?? '');
|
||||||
s.title = String(row.title ?? '');
|
s.title = String(row.title ?? '');
|
||||||
s.description = String(row.description ?? '');
|
s.description = String(row.description ?? '');
|
||||||
s.priority = row.priority ?? 'none';
|
s.priority = row.priority ?? 'none';
|
||||||
@@ -558,6 +562,7 @@ async function upsertSubtask(row: ServerRow, force: boolean): Promise<boolean> {
|
|||||||
await local.update((s) => {
|
await local.update((s) => {
|
||||||
s.taskId = String(row.taskId ?? s.taskId);
|
s.taskId = String(row.taskId ?? s.taskId);
|
||||||
s.parentSubtaskId = row.parentSubtaskId == null ? null : String(row.parentSubtaskId);
|
s.parentSubtaskId = row.parentSubtaskId == null ? null : String(row.parentSubtaskId);
|
||||||
|
s.categoryId = row.categoryId ? String(row.categoryId) : s.categoryId;
|
||||||
s.title = String(row.title ?? s.title);
|
s.title = String(row.title ?? s.title);
|
||||||
s.description = String(row.description ?? s.description);
|
s.description = String(row.description ?? s.description);
|
||||||
s.priority = row.priority ?? s.priority;
|
s.priority = row.priority ?? s.priority;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ function mapRow(s: any): SubtaskData {
|
|||||||
id: s.id,
|
id: s.id,
|
||||||
taskId: s.taskId,
|
taskId: s.taskId,
|
||||||
parentSubtaskId: s.parentSubtaskId || null,
|
parentSubtaskId: s.parentSubtaskId || null,
|
||||||
|
categoryId: s.categoryId || '',
|
||||||
|
tags: s.tags || '',
|
||||||
title: s.title,
|
title: s.title,
|
||||||
description: s.description || '',
|
description: s.description || '',
|
||||||
priority: (s.priority || 'none') as SubtaskData['priority'],
|
priority: (s.priority || 'none') as SubtaskData['priority'],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useDatabase } from './useDatabase';
|
import { useDatabase } from './useDatabase';
|
||||||
import { Q } from '@nozbe/watermelondb';
|
import { Q } from '@nozbe/watermelondb';
|
||||||
import { useEffect, useState, useMemo } from 'react';
|
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||||
import Task from '../models/Task';
|
import Task from '../models/Task';
|
||||||
import { startOfMonth, endOfMonth } from 'date-fns';
|
import { startOfMonth, endOfMonth } from 'date-fns';
|
||||||
|
|
||||||
@@ -8,6 +8,8 @@ export function useTasksInMonth(monthDate: Date) {
|
|||||||
const { collections } = useDatabase();
|
const { collections } = useDatabase();
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
|
||||||
|
|
||||||
const range = useMemo(() => {
|
const range = useMemo(() => {
|
||||||
const s = startOfMonth(monthDate);
|
const s = startOfMonth(monthDate);
|
||||||
@@ -42,7 +44,7 @@ export function useTasksInMonth(monthDate: Date) {
|
|||||||
mounted = false;
|
mounted = false;
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
};
|
};
|
||||||
}, [collections, range.start, range.end]);
|
}, [collections, range.start, range.end, refreshKey]);
|
||||||
|
|
||||||
const byDay = useMemo(() => {
|
const byDay = useMemo(() => {
|
||||||
const map: Record<number, Task[]> = {};
|
const map: Record<number, Task[]> = {};
|
||||||
@@ -55,13 +57,15 @@ export function useTasksInMonth(monthDate: Date) {
|
|||||||
return map;
|
return map;
|
||||||
}, [tasks]);
|
}, [tasks]);
|
||||||
|
|
||||||
return { tasks, byDay, loading };
|
return { tasks, byDay, loading, refresh };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTasks(categoryId?: string, showCompleted: boolean | 'all' = false, maxAheadDays?: number) {
|
export function useTasks(categoryIds: string[] = [], showCompleted: boolean | 'all' = false, maxAheadDays?: number) {
|
||||||
const { collections } = useDatabase();
|
const { collections } = useDatabase();
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
|
||||||
|
|
||||||
const cutoff = useMemo(() => {
|
const cutoff = useMemo(() => {
|
||||||
if (maxAheadDays === undefined) return null;
|
if (maxAheadDays === undefined) return null;
|
||||||
@@ -75,8 +79,14 @@ export function useTasks(categoryId?: string, showCompleted: boolean | 'all' = f
|
|||||||
let mounted = true;
|
let mounted = true;
|
||||||
const conditions: any[] = [];
|
const conditions: any[] = [];
|
||||||
|
|
||||||
if (categoryId && categoryId !== 'all') {
|
if (categoryIds.length > 0) {
|
||||||
conditions.push(Q.where('category_id', categoryId));
|
const tagConditions = categoryIds.map((id) =>
|
||||||
|
Q.or(
|
||||||
|
Q.where('tags', Q.like(`%,${id},%`)),
|
||||||
|
Q.where('category_id', id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
conditions.push(tagConditions.length === 1 ? tagConditions[0] : Q.or(...tagConditions));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showCompleted === true) {
|
if (showCompleted === true) {
|
||||||
@@ -111,15 +121,17 @@ export function useTasks(categoryId?: string, showCompleted: boolean | 'all' = f
|
|||||||
mounted = false;
|
mounted = false;
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
};
|
};
|
||||||
}, [collections, categoryId, showCompleted, cutoff]);
|
}, [collections, categoryIds, showCompleted, cutoff, refreshKey]);
|
||||||
|
|
||||||
return { tasks, loading };
|
return { tasks, loading, refresh };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTasksByDate(date: Date) {
|
export function useTasksByDate(date: Date) {
|
||||||
const { collections } = useDatabase();
|
const { collections } = useDatabase();
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
|
||||||
|
|
||||||
const startOfDay = useMemo(() => {
|
const startOfDay = useMemo(() => {
|
||||||
const d = new Date(date);
|
const d = new Date(date);
|
||||||
@@ -156,7 +168,7 @@ export function useTasksByDate(date: Date) {
|
|||||||
mounted = false;
|
mounted = false;
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
};
|
};
|
||||||
}, [collections, startOfDay, endOfDay]);
|
}, [collections, startOfDay, endOfDay, refreshKey]);
|
||||||
|
|
||||||
return { tasks, loading };
|
return { tasks, loading, refresh };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export default class Subtask extends Model {
|
|||||||
|
|
||||||
@field('task_id') taskId!: string;
|
@field('task_id') taskId!: string;
|
||||||
@field('parent_subtask_id') parentSubtaskId!: string | null;
|
@field('parent_subtask_id') parentSubtaskId!: string | null;
|
||||||
|
@field('category_id') categoryId!: string;
|
||||||
|
@field('tags') tags!: string;
|
||||||
@field('title') title!: string;
|
@field('title') title!: string;
|
||||||
@field('description') description!: string;
|
@field('description') description!: string;
|
||||||
@field('priority') priority!: Priority;
|
@field('priority') priority!: Priority;
|
||||||
|
|||||||
@@ -130,8 +130,6 @@ interface SettingsContextType {
|
|||||||
setTodoAheadDays: (value: number) => void;
|
setTodoAheadDays: (value: number) => void;
|
||||||
showCompleted: boolean;
|
showCompleted: boolean;
|
||||||
setShowCompleted: (value: boolean) => void;
|
setShowCompleted: (value: boolean) => void;
|
||||||
calendarCategoryId: string;
|
|
||||||
setCalendarCategoryId: (value: string) => void;
|
|
||||||
theme: ThemeColors;
|
theme: ThemeColors;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +143,6 @@ const STORAGE_KEYS = {
|
|||||||
accentColor: 'settings:accentColor',
|
accentColor: 'settings:accentColor',
|
||||||
todoAheadDays: 'settings:todoAheadDays',
|
todoAheadDays: 'settings:todoAheadDays',
|
||||||
showCompleted: 'settings:showCompleted',
|
showCompleted: 'settings:showCompleted',
|
||||||
calendarCategoryId: 'settings:calendarCategoryId',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const SettingsContext = createContext<SettingsContextType | null>(null);
|
const SettingsContext = createContext<SettingsContextType | null>(null);
|
||||||
@@ -188,7 +185,6 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||||||
const [accentColor, setAccentColor] = useStoredSetting<string>(STORAGE_KEYS.accentColor, DEFAULT_ACCENT);
|
const [accentColor, setAccentColor] = useStoredSetting<string>(STORAGE_KEYS.accentColor, DEFAULT_ACCENT);
|
||||||
const [todoAheadDays, setTodoAheadDays] = useStoredSetting<number>(STORAGE_KEYS.todoAheadDays, DEFAULT_TODO_AHEAD_DAYS);
|
const [todoAheadDays, setTodoAheadDays] = useStoredSetting<number>(STORAGE_KEYS.todoAheadDays, DEFAULT_TODO_AHEAD_DAYS);
|
||||||
const [showCompleted, setShowCompleted] = useStoredSetting<boolean>(STORAGE_KEYS.showCompleted, false);
|
const [showCompleted, setShowCompleted] = useStoredSetting<boolean>(STORAGE_KEYS.showCompleted, false);
|
||||||
const [calendarCategoryId, setCalendarCategoryId] = useStoredSetting<string>(STORAGE_KEYS.calendarCategoryId, '');
|
|
||||||
|
|
||||||
const theme = useMemo(() => colors(accentColor), [accentColor]);
|
const theme = useMemo(() => colors(accentColor), [accentColor]);
|
||||||
|
|
||||||
@@ -210,8 +206,6 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||||||
setTodoAheadDays,
|
setTodoAheadDays,
|
||||||
showCompleted,
|
showCompleted,
|
||||||
setShowCompleted,
|
setShowCompleted,
|
||||||
calendarCategoryId,
|
|
||||||
setCalendarCategoryId,
|
|
||||||
theme,
|
theme,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
@@ -231,8 +225,6 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||||||
setTodoAheadDays,
|
setTodoAheadDays,
|
||||||
showCompleted,
|
showCompleted,
|
||||||
setShowCompleted,
|
setShowCompleted,
|
||||||
calendarCategoryId,
|
|
||||||
setCalendarCategoryId,
|
|
||||||
theme,
|
theme,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ export interface SubtaskData {
|
|||||||
id: string;
|
id: string;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
parentSubtaskId: string | null;
|
parentSubtaskId: string | null;
|
||||||
|
categoryId: string;
|
||||||
|
tags: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
priority: Priority;
|
priority: Priority;
|
||||||
@@ -191,6 +193,7 @@ export interface TaskFormData {
|
|||||||
export interface SubtaskFormData {
|
export interface SubtaskFormData {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
categoryId?: string;
|
||||||
priority: Priority;
|
priority: Priority;
|
||||||
dueDate: Date | null;
|
dueDate: Date | null;
|
||||||
dueTime?: string;
|
dueTime?: string;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ function mapSubtaskRow(s: any, taskId?: string): SubtaskData {
|
|||||||
id: s.id,
|
id: s.id,
|
||||||
taskId: s.taskId ?? taskId ?? '',
|
taskId: s.taskId ?? taskId ?? '',
|
||||||
parentSubtaskId: s.parentSubtaskId || null,
|
parentSubtaskId: s.parentSubtaskId || null,
|
||||||
|
categoryId: s.categoryId || '',
|
||||||
title: s.title,
|
title: s.title,
|
||||||
description: s.description || '',
|
description: s.description || '',
|
||||||
priority: (s.priority || 'none') as Priority,
|
priority: (s.priority || 'none') as Priority,
|
||||||
@@ -235,6 +236,7 @@ export async function toggleSubtaskComplete(subtaskId: string): Promise<void> {
|
|||||||
export interface SubtaskUpdateData {
|
export interface SubtaskUpdateData {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
categoryId?: string;
|
||||||
priority: Priority;
|
priority: Priority;
|
||||||
dueDate: number;
|
dueDate: number;
|
||||||
dueTime?: string;
|
dueTime?: string;
|
||||||
@@ -253,6 +255,7 @@ export async function updateSubtask(subtaskId: string, data: SubtaskUpdateData):
|
|||||||
await subtask.update((s) => {
|
await subtask.update((s) => {
|
||||||
s.title = data.title.trim();
|
s.title = data.title.trim();
|
||||||
s.description = data.description || '';
|
s.description = data.description || '';
|
||||||
|
if (data.categoryId !== undefined) s.categoryId = data.categoryId;
|
||||||
s.priority = data.priority;
|
s.priority = data.priority;
|
||||||
s.dueDate = data.dueDate;
|
s.dueDate = data.dueDate;
|
||||||
s.dueTime = data.dueTime || '';
|
s.dueTime = data.dueTime || '';
|
||||||
@@ -314,6 +317,7 @@ export async function duplicateSubtask(subtaskId: string): Promise<void> {
|
|||||||
|
|
||||||
clone = await collections.subtasks.create((s) => {
|
clone = await collections.subtasks.create((s) => {
|
||||||
s.taskId = subtask.taskId;
|
s.taskId = subtask.taskId;
|
||||||
|
s.categoryId = subtask.categoryId || '';
|
||||||
s.title = subtask.title;
|
s.title = subtask.title;
|
||||||
s.description = subtask.description;
|
s.description = subtask.description;
|
||||||
s.priority = subtask.priority;
|
s.priority = subtask.priority;
|
||||||
@@ -407,6 +411,7 @@ export async function convertTaskToSubtask(taskId: string, parentTaskId: string,
|
|||||||
const created = await collections.subtasks.create((s) => {
|
const created = await collections.subtasks.create((s) => {
|
||||||
s.taskId = parentTaskId;
|
s.taskId = parentTaskId;
|
||||||
s.parentSubtaskId = parentSubtaskId;
|
s.parentSubtaskId = parentSubtaskId;
|
||||||
|
s.categoryId = task.categoryId || '';
|
||||||
s.title = task.title;
|
s.title = task.title;
|
||||||
s.description = task.description;
|
s.description = task.description;
|
||||||
s.priority = task.priority;
|
s.priority = task.priority;
|
||||||
@@ -430,6 +435,7 @@ export async function convertTaskToSubtask(taskId: string, parentTaskId: string,
|
|||||||
await collections.subtasks.create((s) => {
|
await collections.subtasks.create((s) => {
|
||||||
s.taskId = parentTaskId;
|
s.taskId = parentTaskId;
|
||||||
s.parentSubtaskId = created.id;
|
s.parentSubtaskId = created.id;
|
||||||
|
s.categoryId = subtask.categoryId || '';
|
||||||
s.title = subtask.title;
|
s.title = subtask.title;
|
||||||
s.description = subtask.description;
|
s.description = subtask.description;
|
||||||
s.priority = subtask.priority;
|
s.priority = subtask.priority;
|
||||||
@@ -470,7 +476,8 @@ export async function convertSubtaskToTask(subtaskId: string): Promise<void> {
|
|||||||
const task = await collections.tasks.create((t) => {
|
const task = await collections.tasks.create((t) => {
|
||||||
t.title = subtask.title;
|
t.title = subtask.title;
|
||||||
t.description = subtask.description || '';
|
t.description = subtask.description || '';
|
||||||
t.categoryId = parent.categoryId;
|
t.categoryId = subtask.categoryId || parent.categoryId || '';
|
||||||
|
t.tags = tagsToString([subtask.categoryId || parent.categoryId].filter(Boolean));
|
||||||
t.priority = subtask.priority;
|
t.priority = subtask.priority;
|
||||||
t.completed = subtask.completed;
|
t.completed = subtask.completed;
|
||||||
t.dueDate = subtask.dueDate;
|
t.dueDate = subtask.dueDate;
|
||||||
@@ -747,6 +754,7 @@ export async function reorderTasks(taskIds: string[]): Promise<void> {
|
|||||||
export interface CreateSubtaskData {
|
export interface CreateSubtaskData {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
parentSubtaskId?: string | null;
|
parentSubtaskId?: string | null;
|
||||||
|
categoryId?: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
priority?: Priority;
|
priority?: Priority;
|
||||||
@@ -773,6 +781,7 @@ export async function createSubtask(data: CreateSubtaskData): Promise<string> {
|
|||||||
const subtask = await collections.subtasks.create((s) => {
|
const subtask = await collections.subtasks.create((s) => {
|
||||||
s.taskId = data.taskId;
|
s.taskId = data.taskId;
|
||||||
s.parentSubtaskId = data.parentSubtaskId || null;
|
s.parentSubtaskId = data.parentSubtaskId || null;
|
||||||
|
s.categoryId = data.categoryId || '';
|
||||||
s.title = data.title.trim();
|
s.title = data.title.trim();
|
||||||
s.description = data.description || '';
|
s.description = data.description || '';
|
||||||
s.priority = data.priority || 'none';
|
s.priority = data.priority || 'none';
|
||||||
|
|||||||
Reference in New Issue
Block a user