feat(backend): subtask category support (schema, validation, sync, routes)
This commit is contained in:
@@ -44,6 +44,7 @@ export const tasks = pgTable('tasks', {
|
|||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
categoryId: text('category_id').references(() => categories.id, { onDelete: 'cascade' }),
|
categoryId: text('category_id').references(() => categories.id, { onDelete: 'cascade' }),
|
||||||
|
tags: text('tags').notNull().default(''),
|
||||||
title: text('title').notNull(),
|
title: text('title').notNull(),
|
||||||
description: text('description').notNull().default(''),
|
description: text('description').notNull().default(''),
|
||||||
priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
|
priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
|
||||||
@@ -84,6 +85,7 @@ export const subtasks = pgTable('subtasks', {
|
|||||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
taskId: text('task_id').notNull().references(() => tasks.id, { onDelete: 'cascade' }),
|
taskId: text('task_id').notNull().references(() => tasks.id, { onDelete: 'cascade' }),
|
||||||
parentSubtaskId: text('parent_subtask_id').references((): AnyPgColumn => subtasks.id, { onDelete: 'cascade' }),
|
parentSubtaskId: text('parent_subtask_id').references((): AnyPgColumn => subtasks.id, { onDelete: 'cascade' }),
|
||||||
|
categoryId: text('category_id').references(() => categories.id, { onDelete: 'set null' }),
|
||||||
title: text('title').notNull(),
|
title: text('title').notNull(),
|
||||||
description: text('description').notNull().default(''),
|
description: text('description').notNull().default(''),
|
||||||
priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
|
priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) =>
|
|||||||
userId,
|
userId,
|
||||||
taskId: req.params.taskId,
|
taskId: req.params.taskId,
|
||||||
parentSubtaskId,
|
parentSubtaskId,
|
||||||
|
categoryId: data.categoryId ?? null,
|
||||||
title: data.title,
|
title: data.title,
|
||||||
description: data.description ?? '',
|
description: data.description ?? '',
|
||||||
priority: data.priority ?? 'none',
|
priority: data.priority ?? 'none',
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
|
|||||||
title: task.title,
|
title: task.title,
|
||||||
description: task.description,
|
description: task.description,
|
||||||
categoryId: task.categoryId,
|
categoryId: task.categoryId,
|
||||||
|
tags: task.tags ?? '',
|
||||||
priority: task.priority,
|
priority: task.priority,
|
||||||
completed: task.completed,
|
completed: task.completed,
|
||||||
dueDate: task.dueDate,
|
dueDate: task.dueDate,
|
||||||
@@ -290,6 +291,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
|
|||||||
.set({
|
.set({
|
||||||
taskId: sub.taskId,
|
taskId: sub.taskId,
|
||||||
parentSubtaskId: sub.parentSubtaskId ?? null,
|
parentSubtaskId: sub.parentSubtaskId ?? null,
|
||||||
|
categoryId: sub.categoryId ?? null,
|
||||||
title: sub.title,
|
title: sub.title,
|
||||||
description: sub.description ?? '',
|
description: sub.description ?? '',
|
||||||
priority: sub.priority ?? 'none',
|
priority: sub.priority ?? 'none',
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ router.post('/', asyncHandler(async (req: Request, res: Response) => {
|
|||||||
id: taskId,
|
id: taskId,
|
||||||
userId,
|
userId,
|
||||||
categoryId: data.categoryId,
|
categoryId: data.categoryId,
|
||||||
|
tags: data.tags ?? '',
|
||||||
title: data.title,
|
title: data.title,
|
||||||
description: data.description ?? '',
|
description: data.description ?? '',
|
||||||
priority: data.priority ?? 'none',
|
priority: data.priority ?? 'none',
|
||||||
@@ -159,6 +160,7 @@ router.post('/', asyncHandler(async (req: Request, res: Response) => {
|
|||||||
userId,
|
userId,
|
||||||
taskId,
|
taskId,
|
||||||
title: st.title,
|
title: st.title,
|
||||||
|
categoryId: (st as any).categoryId ?? null,
|
||||||
completed: false,
|
completed: false,
|
||||||
order: index,
|
order: index,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const taskCreateSchema = z.object({
|
|||||||
title: z.string().min(1).max(100),
|
title: z.string().min(1).max(100),
|
||||||
description: z.string().max(1000).optional(),
|
description: z.string().max(1000).optional(),
|
||||||
categoryId: z.string().max(100).transform((v) => (v === '' ? null : v)).nullable(),
|
categoryId: z.string().max(100).transform((v) => (v === '' ? null : v)).nullable(),
|
||||||
|
tags: z.string().max(500).optional(),
|
||||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
||||||
dueDate: z.number().int().min(0).optional(),
|
dueDate: z.number().int().min(0).optional(),
|
||||||
dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v ?? ''),
|
dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v ?? ''),
|
||||||
@@ -52,13 +53,14 @@ export const taskCreateSchema = z.object({
|
|||||||
reminders: z.string().max(100).optional(),
|
reminders: z.string().max(100).optional(),
|
||||||
assigneeId: z.string().nullable().optional(),
|
assigneeId: z.string().nullable().optional(),
|
||||||
completedAt: z.number().int().min(0).nullable().optional(),
|
completedAt: z.number().int().min(0).nullable().optional(),
|
||||||
subtasks: z.array(z.object({ title: z.string().min(1).max(100) })).optional(),
|
subtasks: z.array(z.object({ title: z.string().min(1).max(100), categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)) })).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const taskUpdateSchema = z.object({
|
export const taskUpdateSchema = z.object({
|
||||||
title: z.string().min(1).max(100).optional(),
|
title: z.string().min(1).max(100).optional(),
|
||||||
description: z.string().max(1000).optional(),
|
description: z.string().max(1000).optional(),
|
||||||
categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)),
|
categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)),
|
||||||
|
tags: z.string().max(500).optional(),
|
||||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
||||||
completed: z.boolean().optional(),
|
completed: z.boolean().optional(),
|
||||||
dueDate: z.number().int().min(0).optional(),
|
dueDate: z.number().int().min(0).optional(),
|
||||||
@@ -72,6 +74,7 @@ export const taskUpdateSchema = z.object({
|
|||||||
export const subtaskCreateSchema = z.object({
|
export const subtaskCreateSchema = z.object({
|
||||||
title: z.string().min(1).max(100),
|
title: z.string().min(1).max(100),
|
||||||
description: z.string().max(1000).optional(),
|
description: z.string().max(1000).optional(),
|
||||||
|
categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)),
|
||||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
||||||
dueDate: z.number().int().min(0).optional(),
|
dueDate: z.number().int().min(0).optional(),
|
||||||
dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v ?? ''),
|
dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v ?? ''),
|
||||||
@@ -91,6 +94,7 @@ export const subtaskCreateSchema = z.object({
|
|||||||
export const subtaskUpdateSchema = z.object({
|
export const subtaskUpdateSchema = z.object({
|
||||||
title: z.string().min(1).max(100).optional(),
|
title: z.string().min(1).max(100).optional(),
|
||||||
description: z.string().max(1000).optional(),
|
description: z.string().max(1000).optional(),
|
||||||
|
categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)),
|
||||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
||||||
completed: z.boolean().optional(),
|
completed: z.boolean().optional(),
|
||||||
dueDate: z.number().int().min(0).optional(),
|
dueDate: z.number().int().min(0).optional(),
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import React, { useCallback, useMemo } from 'react';
|
||||||
|
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, KeyboardAvoidingView, BackHandler } from 'react-native';
|
||||||
|
import { useRouter, useLocalSearchParams, useFocusEffect } from 'expo-router';
|
||||||
|
import { Header } from '@/components/Header';
|
||||||
|
import { useTasksByDate } from '@/hooks/useTasks';
|
||||||
|
import { useSubtasks } from '@/hooks/useSubtasks';
|
||||||
|
import { useTaskModals } from '@/hooks/useTaskModals';
|
||||||
|
import { useSettings } from '@/theme';
|
||||||
|
import { useCategories } from '@/hooks/useDatabase';
|
||||||
|
import { toggleTaskComplete, toggleSubtaskComplete } from '@/utils/taskActions';
|
||||||
|
import { QuickAddBar } from '@/components/QuickAddBar';
|
||||||
|
import { SubtaskData } from '@/types';
|
||||||
|
import { format, startOfDay } from 'date-fns';
|
||||||
|
import Svg, { Path, Circle } from 'react-native-svg';
|
||||||
|
import { desaturate } from '@/theme';
|
||||||
|
|
||||||
|
function isDayMatch(timestamp: number, day: Date): boolean {
|
||||||
|
const d = new Date(timestamp);
|
||||||
|
return d.getFullYear() === day.getFullYear() && d.getMonth() === day.getMonth() && d.getDate() === day.getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DayViewScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { theme } = useSettings();
|
||||||
|
const categories = useCategories();
|
||||||
|
const { modals, openTaskEdit } = useTaskModals();
|
||||||
|
const { date: dateParam } = useLocalSearchParams<{ date?: string }>();
|
||||||
|
|
||||||
|
const day = useMemo(() => {
|
||||||
|
const parsed = dateParam ? new Date(dateParam) : new Date();
|
||||||
|
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
||||||
|
}, [dateParam]);
|
||||||
|
const dayStart = useMemo(() => startOfDay(day), [day]);
|
||||||
|
|
||||||
|
const { map: subtasksByTask, refresh: refreshSubtasks } = useSubtasks();
|
||||||
|
const { tasks: dayTasks, loading, refresh } = useTasksByDate(day);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
const sub = BackHandler.addEventListener('hardwareBackPress', () => false);
|
||||||
|
refreshSubtasks();
|
||||||
|
refresh();
|
||||||
|
return () => sub.remove();
|
||||||
|
}, [refreshSubtasks, refresh])
|
||||||
|
);
|
||||||
|
|
||||||
|
const subtasksOnDay = useMemo(() => {
|
||||||
|
const map = new Map<string, SubtaskData[]>();
|
||||||
|
for (const [taskId, roots] of subtasksByTask) {
|
||||||
|
const due = roots.filter((s) => s.dueDate && isDayMatch(s.dueDate, day));
|
||||||
|
if (due.length > 0) map.set(taskId, due);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [subtasksByTask, day]);
|
||||||
|
|
||||||
|
const handleToggleComplete = useCallback(async (taskId: string) => {
|
||||||
|
await toggleTaskComplete(taskId);
|
||||||
|
refresh();
|
||||||
|
refreshSubtasks();
|
||||||
|
}, [refresh, refreshSubtasks]);
|
||||||
|
|
||||||
|
const handleToggleSubtask = useCallback(async (subtaskId: string) => {
|
||||||
|
await toggleSubtaskComplete(subtaskId);
|
||||||
|
refreshSubtasks();
|
||||||
|
}, [refreshSubtasks]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||||
|
<Header title={format(day, 'EEEE, MMM d')} showLogo={false} />
|
||||||
|
<KeyboardAvoidingView style={styles.kbAvoid} behavior="padding">
|
||||||
|
<ScrollView style={styles.scrollBody} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||||
|
{loading ? (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>Loading...</Text>
|
||||||
|
</View>
|
||||||
|
) : dayTasks.length === 0 && subtasksOnDay.size === 0 ? (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No tasks on this day</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{dayTasks.map((task) => {
|
||||||
|
const subs = subtasksOnDay.get(task.id) ?? [];
|
||||||
|
const openCount = subs.filter((s) => !s.completed).length;
|
||||||
|
const cat = categories.find((c) => c.id === task.categoryId);
|
||||||
|
return (
|
||||||
|
<View key={task.id} style={[styles.eventCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||||
|
<View style={styles.eventRow}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.checkCircle}
|
||||||
|
onPress={() => handleToggleComplete(task.id)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
accessibilityRole="checkbox"
|
||||||
|
accessibilityState={{ checked: task.completed }}
|
||||||
|
>
|
||||||
|
<Svg width={30} height={30} viewBox="0 0 24 24">
|
||||||
|
{task.completed ? (
|
||||||
|
<>
|
||||||
|
<Circle cx={12} cy={12} r={10} fill={theme.accent} />
|
||||||
|
<Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2.5} fill="none" />
|
||||||
|
)}
|
||||||
|
</Svg>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.eventTouch}
|
||||||
|
onPress={() => router.push({ pathname: '/task-detail', params: { id: task.id } })}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[styles.eventTitle, { color: theme.text }, task.completed && styles.eventCompleted]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</Text>
|
||||||
|
{openCount > 0 && (
|
||||||
|
<Text style={[styles.eventSub, { color: theme.textMuted }]} numberOfLines={1}>
|
||||||
|
{openCount} open subtask{openCount === 1 ? '' : 's'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{!task.completed && (
|
||||||
|
<View style={styles.metaRow}>
|
||||||
|
{cat && (
|
||||||
|
<View style={[styles.tagChip, { backgroundColor: desaturate(cat.color, 0.3) }]}>
|
||||||
|
<Text style={styles.tagText} numberOfLines={1}>{cat.name}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{task.dueTime ? (
|
||||||
|
<Text style={[styles.timeText, { color: theme.textSecondary }]}>{task.dueTime}{task.endTime ? `–${task.endTime}` : ''}</Text>
|
||||||
|
) : task.dueDate ? (
|
||||||
|
<Text style={[styles.timeText, { color: theme.textSecondary }]}>{format(task.dueDate, 'HH:mm')}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.menuButton}
|
||||||
|
onPress={() => openTaskEdit(task.id)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Edit ${task.title}`}
|
||||||
|
>
|
||||||
|
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||||
|
<Path d="M9 6l6 6-6 6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
|
</Svg>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{Array.from(subtasksOnDay.entries()).flatMap(([taskId, subs]) => {
|
||||||
|
const parent = dayTasks.find((t) => t.id === taskId);
|
||||||
|
if (parent) return [];
|
||||||
|
return subs.map((sub) => (
|
||||||
|
<View key={sub.id} style={[styles.eventCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||||
|
<View style={styles.eventRow}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.checkCircle}
|
||||||
|
onPress={() => handleToggleSubtask(sub.id)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
accessibilityRole="checkbox"
|
||||||
|
accessibilityState={{ checked: sub.completed }}
|
||||||
|
>
|
||||||
|
<Svg width={30} height={30} viewBox="0 0 24 24">
|
||||||
|
{sub.completed ? (
|
||||||
|
<>
|
||||||
|
<Circle cx={12} cy={12} r={10} fill={theme.accent} />
|
||||||
|
<Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2.5} fill="none" />
|
||||||
|
)}
|
||||||
|
</Svg>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.eventTouch}
|
||||||
|
onPress={() => router.push({ pathname: '/subtask-detail', params: { id: sub.id } })}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[styles.eventTitle, { color: theme.text }, sub.completed && styles.eventCompleted]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{sub.title}
|
||||||
|
</Text>
|
||||||
|
{sub.dueTime ? (
|
||||||
|
<Text style={[styles.timeText, { color: theme.textSecondary }]}>{sub.dueTime}{sub.endTime ? `–${sub.endTime}` : ''}</Text>
|
||||||
|
) : null}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
));
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
<QuickAddBar dueDate={dayStart.getTime()} placeholder={`Add event for ${format(day, 'MMM d')}`} />
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
{modals(() => {})}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
kbAvoid: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollBody: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingTop: 12,
|
||||||
|
paddingBottom: 16,
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 64,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
eventCard: {
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
eventRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
checkCircle: {
|
||||||
|
width: 30,
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
eventTouch: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
eventTitle: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
eventCompleted: {
|
||||||
|
textDecorationLine: 'line-through',
|
||||||
|
color: '#9E9E9E',
|
||||||
|
},
|
||||||
|
eventSub: {
|
||||||
|
fontSize: 13,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
metaRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
tagChip: {
|
||||||
|
paddingHorizontal: 7,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 6,
|
||||||
|
maxWidth: 140,
|
||||||
|
},
|
||||||
|
tagText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
timeText: {
|
||||||
|
fontSize: 12.5,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
menuButton: {
|
||||||
|
padding: 4,
|
||||||
|
marginLeft: 6,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -193,5 +193,14 @@ export const migrations = schemaMigrations({
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
toVersion: 18,
|
||||||
|
steps: [
|
||||||
|
addColumns({
|
||||||
|
table: 'tasks',
|
||||||
|
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: 17,
|
version: 18,
|
||||||
tables: [
|
tables: [
|
||||||
tableSchema({
|
tableSchema({
|
||||||
name: 'categories',
|
name: 'categories',
|
||||||
@@ -19,6 +19,7 @@ export const schema = appSchema({
|
|||||||
{ name: 'title', type: 'string' },
|
{ name: 'title', type: 'string' },
|
||||||
{ name: 'description', type: 'string' },
|
{ name: 'description', type: 'string' },
|
||||||
{ name: 'category_id', type: 'string', isIndexed: true },
|
{ name: 'category_id', type: 'string', isIndexed: true },
|
||||||
|
{ name: 'tags', type: 'string', isOptional: true },
|
||||||
{ name: 'priority', type: 'string' },
|
{ name: 'priority', type: 'string' },
|
||||||
{ name: 'completed', type: 'boolean', isIndexed: true },
|
{ name: 'completed', type: 'boolean', isIndexed: true },
|
||||||
{ name: 'completed_at', type: 'number', isOptional: true },
|
{ name: 'completed_at', type: 'number', isOptional: true },
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletio
|
|||||||
title: t.title,
|
title: t.title,
|
||||||
description: t.description,
|
description: t.description,
|
||||||
categoryId: t.categoryId,
|
categoryId: t.categoryId,
|
||||||
|
tags: t.tags || '',
|
||||||
priority: t.priority,
|
priority: t.priority,
|
||||||
completed: t.completed,
|
completed: t.completed,
|
||||||
dueDate: t.dueDate,
|
dueDate: t.dueDate,
|
||||||
@@ -451,6 +452,7 @@ async function upsertTask(row: ServerRow, force: boolean): Promise<boolean> {
|
|||||||
t.title = String(row.title ?? '');
|
t.title = String(row.title ?? '');
|
||||||
t.description = String(row.description ?? '');
|
t.description = String(row.description ?? '');
|
||||||
t.categoryId = String(row.categoryId ?? '');
|
t.categoryId = String(row.categoryId ?? '');
|
||||||
|
t.tags = String(row.tags ?? '');
|
||||||
t.priority = row.priority ?? 'none';
|
t.priority = row.priority ?? 'none';
|
||||||
t.completed = Boolean(row.completed);
|
t.completed = Boolean(row.completed);
|
||||||
t.dueDate = Number(row.dueDate ?? 0);
|
t.dueDate = Number(row.dueDate ?? 0);
|
||||||
@@ -476,6 +478,7 @@ async function upsertTask(row: ServerRow, force: boolean): Promise<boolean> {
|
|||||||
t.title = String(row.title ?? t.title);
|
t.title = String(row.title ?? t.title);
|
||||||
t.description = String(row.description ?? t.description);
|
t.description = String(row.description ?? t.description);
|
||||||
t.categoryId = String(row.categoryId ?? t.categoryId);
|
t.categoryId = String(row.categoryId ?? t.categoryId);
|
||||||
|
t.tags = String(row.tags ?? t.tags ?? '');
|
||||||
t.priority = row.priority ?? t.priority;
|
t.priority = row.priority ?? t.priority;
|
||||||
t.completed = Boolean(row.completed ?? t.completed);
|
t.completed = Boolean(row.completed ?? t.completed);
|
||||||
t.dueDate = Number(row.dueDate ?? t.dueDate);
|
t.dueDate = Number(row.dueDate ?? t.dueDate);
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useDatabase } from './useDatabase';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { SubtaskData } from '@/types';
|
||||||
|
function mapRow(s: any): SubtaskData {
|
||||||
|
return {
|
||||||
|
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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTrees(rows: any[]): Map<string, SubtaskData[]> {
|
||||||
|
const nodes = new Map<string, SubtaskData>();
|
||||||
|
for (const row of rows) {
|
||||||
|
nodes.set(row.id, mapRow(row));
|
||||||
|
}
|
||||||
|
const rootsByTask = new Map<string, SubtaskData[]>();
|
||||||
|
for (const node of nodes.values()) {
|
||||||
|
if (node.parentSubtaskId && nodes.has(node.parentSubtaskId)) {
|
||||||
|
const parent = nodes.get(node.parentSubtaskId)!;
|
||||||
|
parent.subtasks.push(node);
|
||||||
|
} else {
|
||||||
|
const list = rootsByTask.get(node.taskId) ?? [];
|
||||||
|
list.push(node);
|
||||||
|
rootsByTask.set(node.taskId, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const list of rootsByTask.values()) {
|
||||||
|
list.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||||
|
}
|
||||||
|
const sortDeep = (list: SubtaskData[]) => {
|
||||||
|
for (const node of list) {
|
||||||
|
node.subtasks.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||||
|
sortDeep(node.subtasks);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const list of rootsByTask.values()) {
|
||||||
|
sortDeep(list);
|
||||||
|
}
|
||||||
|
return rootsByTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSubtasks(): { map: Map<string, SubtaskData[]>; refresh: () => void } {
|
||||||
|
const { collections } = useDatabase();
|
||||||
|
const [rows, setRows] = useState<any[]>([]);
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
const subscription = collections.subtasks
|
||||||
|
.query()
|
||||||
|
.observe()
|
||||||
|
.subscribe({
|
||||||
|
next: (result) => {
|
||||||
|
if (mounted) setRows(result);
|
||||||
|
},
|
||||||
|
error: () => {},
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
subscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}, [collections.subtasks, refreshKey]);
|
||||||
|
|
||||||
|
const map = useMemo(() => buildTrees(rows), [rows]);
|
||||||
|
|
||||||
|
return { map, refresh };
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ export default class Task extends Model {
|
|||||||
@field('title') title!: string;
|
@field('title') title!: string;
|
||||||
@field('description') description!: string;
|
@field('description') description!: string;
|
||||||
@field('category_id') categoryId!: string;
|
@field('category_id') categoryId!: string;
|
||||||
|
@field('tags') tags!: string;
|
||||||
@field('priority') priority!: Priority;
|
@field('priority') priority!: Priority;
|
||||||
@field('completed') completed!: boolean;
|
@field('completed') completed!: boolean;
|
||||||
@field('completed_at') completedAt!: number | null;
|
@field('completed_at') completedAt!: number | null;
|
||||||
|
|||||||
@@ -128,6 +128,10 @@ interface SettingsContextType {
|
|||||||
setAccentColor: (value: string) => void;
|
setAccentColor: (value: string) => void;
|
||||||
todoAheadDays: number;
|
todoAheadDays: number;
|
||||||
setTodoAheadDays: (value: number) => void;
|
setTodoAheadDays: (value: number) => void;
|
||||||
|
showCompleted: boolean;
|
||||||
|
setShowCompleted: (value: boolean) => void;
|
||||||
|
calendarCategoryId: string;
|
||||||
|
setCalendarCategoryId: (value: string) => void;
|
||||||
theme: ThemeColors;
|
theme: ThemeColors;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +144,8 @@ const STORAGE_KEYS = {
|
|||||||
reminderPreference: 'settings:reminderPreference',
|
reminderPreference: 'settings:reminderPreference',
|
||||||
accentColor: 'settings:accentColor',
|
accentColor: 'settings:accentColor',
|
||||||
todoAheadDays: 'settings:todoAheadDays',
|
todoAheadDays: 'settings:todoAheadDays',
|
||||||
|
showCompleted: 'settings:showCompleted',
|
||||||
|
calendarCategoryId: 'settings:calendarCategoryId',
|
||||||
};
|
};
|
||||||
|
|
||||||
const SettingsContext = createContext<SettingsContextType | null>(null);
|
const SettingsContext = createContext<SettingsContextType | null>(null);
|
||||||
@@ -181,6 +187,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||||||
const [apiUrl, setApiUrl] = useStoredSetting<string>(API_URL_KEY, DEFAULT_API_BASE_URL);
|
const [apiUrl, setApiUrl] = useStoredSetting<string>(API_URL_KEY, DEFAULT_API_BASE_URL);
|
||||||
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 [calendarCategoryId, setCalendarCategoryId] = useStoredSetting<string>(STORAGE_KEYS.calendarCategoryId, '');
|
||||||
|
|
||||||
const theme = useMemo(() => colors(accentColor), [accentColor]);
|
const theme = useMemo(() => colors(accentColor), [accentColor]);
|
||||||
|
|
||||||
@@ -200,6 +208,10 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||||||
setAccentColor,
|
setAccentColor,
|
||||||
todoAheadDays,
|
todoAheadDays,
|
||||||
setTodoAheadDays,
|
setTodoAheadDays,
|
||||||
|
showCompleted,
|
||||||
|
setShowCompleted,
|
||||||
|
calendarCategoryId,
|
||||||
|
setCalendarCategoryId,
|
||||||
theme,
|
theme,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
@@ -217,6 +229,10 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
|||||||
setAccentColor,
|
setAccentColor,
|
||||||
todoAheadDays,
|
todoAheadDays,
|
||||||
setTodoAheadDays,
|
setTodoAheadDays,
|
||||||
|
showCompleted,
|
||||||
|
setShowCompleted,
|
||||||
|
calendarCategoryId,
|
||||||
|
setCalendarCategoryId,
|
||||||
theme,
|
theme,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -99,11 +99,30 @@ export interface CategoryData {
|
|||||||
order: number;
|
order: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function tagsToString(tags: string[]): string {
|
||||||
|
return `,${tags.filter(Boolean).join(',')},`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tagsFromString(raw: string | null | undefined): string[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
return raw
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTaskTags(tagsRaw: string | null | undefined, categoryId: string): string[] {
|
||||||
|
const parsed = tagsFromString(tagsRaw);
|
||||||
|
if (parsed.length > 0) return parsed;
|
||||||
|
return categoryId ? [categoryId] : [];
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskData {
|
export interface TaskData {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
categoryId: string;
|
categoryId: string;
|
||||||
|
tags?: string;
|
||||||
priority: Priority;
|
priority: Priority;
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
dueDate: number;
|
dueDate: number;
|
||||||
@@ -154,6 +173,7 @@ export interface TaskFormData {
|
|||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
categoryId: string;
|
categoryId: string;
|
||||||
|
tags?: string[];
|
||||||
priority: Priority;
|
priority: Priority;
|
||||||
dueDate: Date | null;
|
dueDate: Date | null;
|
||||||
dueTime?: string;
|
dueTime?: string;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { database, collections } from '@/database';
|
import { database, collections } from '@/database';
|
||||||
import { Q } from '@nozbe/watermelondb';
|
import { Q } from '@nozbe/watermelondb';
|
||||||
import { recordTombstonesInBatch } from '@/database/tombstones';
|
import { recordTombstonesInBatch } from '@/database/tombstones';
|
||||||
|
import { tagsFromString, tagsToString, parseTaskTags } from '@/types';
|
||||||
|
|
||||||
export async function createCategory(name: string, color: string): Promise<void> {
|
export async function createCategory(name: string, color: string): Promise<void> {
|
||||||
await database.write(async () => {
|
await database.write(async () => {
|
||||||
@@ -36,7 +37,18 @@ export async function deleteCategory(categoryId: string): Promise<void> {
|
|||||||
const tasks = await collections.tasks.query(Q.where('category_id', categoryId)).fetch();
|
const tasks = await collections.tasks.query(Q.where('category_id', categoryId)).fetch();
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
await task.update((t) => {
|
await task.update((t) => {
|
||||||
t.categoryId = fallback?.id ?? '';
|
const tags = parseTaskTags(t.tags, t.categoryId).filter((id) => id !== categoryId);
|
||||||
|
t.categoryId = fallback?.id ?? tags[0] ?? '';
|
||||||
|
t.tags = tagsToString(tags);
|
||||||
|
t.updatedAt = new Date();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagReferencedTasks = await collections.tasks.query(Q.where('tags', Q.like(`%,${categoryId},%`))).fetch();
|
||||||
|
for (const task of tagReferencedTasks) {
|
||||||
|
const tags = parseTaskTags(task.tags, task.categoryId).filter((id) => id !== categoryId);
|
||||||
|
await task.update((t) => {
|
||||||
|
t.tags = tagsToString(tags);
|
||||||
t.updatedAt = new Date();
|
t.updatedAt = new Date();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { database, collections } from '@/database';
|
import { database, collections } from '@/database';
|
||||||
import { Q } from '@nozbe/watermelondb';
|
import { Q } from '@nozbe/watermelondb';
|
||||||
import { Priority, Repeat, Reminder, SubtaskData } from '@/types';
|
import { Priority, Repeat, Reminder, SubtaskData, tagsToString } from '@/types';
|
||||||
import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications';
|
import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications';
|
||||||
import { recordTombstonesInBatch } from '@/database/tombstones';
|
import { recordTombstonesInBatch } from '@/database/tombstones';
|
||||||
|
|
||||||
@@ -621,6 +621,7 @@ async function createNextOccurrence(task: any, seriesId: string): Promise<any> {
|
|||||||
t.title = task.title;
|
t.title = task.title;
|
||||||
t.description = task.description;
|
t.description = task.description;
|
||||||
t.categoryId = task.categoryId;
|
t.categoryId = task.categoryId;
|
||||||
|
t.tags = task.tags || '';
|
||||||
t.priority = task.priority;
|
t.priority = task.priority;
|
||||||
t.completed = false;
|
t.completed = false;
|
||||||
t.dueDate = nextDate.getTime();
|
t.dueDate = nextDate.getTime();
|
||||||
@@ -664,6 +665,7 @@ export async function setTaskCategory(taskId: string, categoryId: string): Promi
|
|||||||
const task = await collections.tasks.find(taskId);
|
const task = await collections.tasks.find(taskId);
|
||||||
await task.update((t) => {
|
await task.update((t) => {
|
||||||
t.categoryId = categoryId;
|
t.categoryId = categoryId;
|
||||||
|
t.tags = tagsToString(categoryId ? [categoryId] : []);
|
||||||
t.updatedAt = new Date();
|
t.updatedAt = new Date();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -690,6 +692,7 @@ export async function duplicateTask(taskId: string): Promise<void> {
|
|||||||
t.title = task.title;
|
t.title = task.title;
|
||||||
t.description = task.description;
|
t.description = task.description;
|
||||||
t.categoryId = task.categoryId;
|
t.categoryId = task.categoryId;
|
||||||
|
t.tags = task.tags || '';
|
||||||
t.priority = task.priority;
|
t.priority = task.priority;
|
||||||
t.completed = false;
|
t.completed = false;
|
||||||
t.dueDate = task.dueDate;
|
t.dueDate = task.dueDate;
|
||||||
|
|||||||
Reference in New Issue
Block a user