fixxed color selector in the category settings and fixxed drag and drop
Build APK / build (push) Canceled after 2m0s

subtask
This commit is contained in:
2026-08-09 21:42:54 +02:00
parent 4b6e87c979
commit 4c3d1a118c
99 changed files with 3335 additions and 920 deletions
+37 -14
View File
@@ -1,6 +1,9 @@
import { pgTable, text, integer, bigint, boolean, timestamp, unique, index } from 'drizzle-orm/pg-core';
import { pgTable, text, integer, bigint, boolean, unique, index, primaryKey, AnyPgColumn } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
export const ENTITIES = ['categories', 'tasks', 'subtasks', 'repeatProfiles', 'friendships'] as const;
export type EntityName = (typeof ENTITIES)[number];
export const users = pgTable('users', {
id: text('id').primaryKey(),
username: text('username').notNull().unique(),
@@ -40,7 +43,7 @@ export const categories = pgTable('categories', {
export const tasks = pgTable('tasks', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
categoryId: text('category_id').notNull().references(() => categories.id, { onDelete: 'cascade' }),
categoryId: text('category_id').references(() => categories.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
description: text('description').notNull().default(''),
priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
@@ -56,26 +59,31 @@ export const tasks = pgTable('tasks', {
assigneeId: text('assignee_id').references(() => users.id, { onDelete: 'set null' }),
reminder: text('reminder', { enum: ['none', 'at_time', '15', '30', '60', '120', '1440'] }).notNull().default('none'),
reminders: text('reminders').notNull().default(''),
completedAt: bigint('completed_at', { mode: 'number' }),
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
});
export const repeatProfiles = pgTable('repeat_profiles', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
repeat: text('repeat', { enum: ['none', 'daily', 'weekly', 'monthly', 'custom'] }).notNull().default('none'),
repeatInterval: integer('repeat_interval').notNull().default(1),
repeatDays: text('repeat_days').notNull().default(''),
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
});
export const tombstones = pgTable(
'tombstones',
{
entity: text('entity', { enum: ENTITIES }).notNull(),
entityId: text('entity_id').notNull(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
updatedAt: bigint('updated_at', { mode: 'number' }).notNull(),
},
(t) => ({
pk: primaryKey({ columns: [t.entity, t.entityId] }),
userIdx: index('tombstones_user_updated_idx').on(t.userId, t.updatedAt),
})
);
// Define subtasks with explicit type to avoid circular reference
export const subtasks = pgTable('subtasks', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
taskId: text('task_id').notNull().references(() => tasks.id, { onDelete: 'cascade' }),
parentSubtaskId: text('parent_subtask_id').references(() => subtasks.id, { onDelete: 'cascade' }),
parentSubtaskId: text('parent_subtask_id').references((): AnyPgColumn => subtasks.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
description: text('description').notNull().default(''),
priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
@@ -94,6 +102,21 @@ export const subtasks = pgTable('subtasks', {
order: integer('order').notNull().default(0),
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
}, (t) => ({
parentIdx: index('subtasks_parent_idx').on(t.parentSubtaskId),
taskIdx: index('subtasks_task_idx').on(t.taskId),
userIdx: index('subtasks_user_idx').on(t.userId),
}));
export const repeatProfiles = pgTable('repeat_profiles', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
repeat: text('repeat', { enum: ['none', 'daily', 'weekly', 'monthly', 'custom'] }).notNull().default('none'),
repeatInterval: integer('repeat_interval').notNull().default(1),
repeatDays: text('repeat_days').notNull().default(''),
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
});
export const userSettings = pgTable('user_settings', {
@@ -105,4 +128,4 @@ export const userSettings = pgTable('user_settings', {
sortBy: text('sort_by', { enum: ['dueDate', 'priority', 'title', 'createdAt'] }).notNull().default('dueDate'),
sortOrder: text('sort_order', { enum: ['asc', 'desc'] }).notNull().default('asc'),
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
});
});
+3 -3
View File
@@ -2,7 +2,7 @@ import { Router, Request, Response } from 'express';
import { asyncHandler } from '../utils/asyncHandler';
import { db } from '../db';
import { subtasks, tasks } from '../db/schema';
import { eq, and, asc, desc } from 'drizzle-orm';
import { eq, and, asc, desc, isNull } from 'drizzle-orm';
import { authMiddleware } from '../utils/auth';
import { AppError } from '../middleware/errorHandler';
import { subtaskCreateSchema, subtaskUpdateSchema } from '../utils/validation';
@@ -70,7 +70,7 @@ router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) =>
.where(and(
eq(subtasks.taskId, req.params.taskId),
eq(subtasks.userId, userId),
parentSubtaskId ? eq(subtasks.parentSubtaskId, parentSubtaskId) : eq(subtasks.parentSubtaskId, null)
parentSubtaskId ? eq(subtasks.parentSubtaskId, parentSubtaskId) : isNull(subtasks.parentSubtaskId)
))
.orderBy(desc(subtasks.order))
.limit(1);
@@ -117,7 +117,7 @@ router.patch('/:id', asyncHandler(async (req: Request, res: Response) => {
const data = subtaskUpdateSchema.parse(req.body);
const userId = req.user!.userId;
const existing = await db
const existing: any[] = await db
.select()
.from(subtasks)
.where(and(eq(subtasks.id, req.params.id), eq(subtasks.userId, userId)))
+261 -29
View File
@@ -1,8 +1,8 @@
import { Router, Request, Response } from 'express';
import { asyncHandler } from '../utils/asyncHandler';
import { db } from '../db';
import { categories, tasks, subtasks, repeatProfiles, users, friendships } from '../db/schema';
import { eq, and, gte, lte, asc, or, inArray, sql } from 'drizzle-orm';
import { categories, tasks, subtasks, repeatProfiles, users, friendships, tombstones, type EntityName } from '../db/schema';
import { eq, and, gte, asc, or, inArray, sql } from 'drizzle-orm';
import { authMiddleware } from '../utils/auth';
import { AppError } from '../middleware/errorHandler';
import { syncQuerySchema, pushChangesSchema, canCompleteTask } from '../utils/validation';
@@ -32,17 +32,15 @@ router.get('/', asyncHandler(async (req: Request, res: Response) => {
.orderBy(asc(tasks.updatedAt));
// Fetch subtasks changed since timestamp
const taskIds = changedTasks.map(t => t.id);
let changedSubtasks: any[] = [];
if (taskIds.length > 0) {
if (changedTasks.length > 0) {
changedSubtasks = await db
.select()
.from(subtasks)
.where(and(eq(subtasks.userId, userId), gte(subtasks.updatedAt, sinceDate)))
.orderBy(asc(subtasks.updatedAt));
} else {
// Also fetch subtasks for tasks that might have been deleted (we track by updatedAt)
// Also fetch subtasks for tasks that might have been deleted
changedSubtasks = await db
.select()
.from(subtasks)
@@ -67,6 +65,13 @@ router.get('/', asyncHandler(async (req: Request, res: Response) => {
))
.orderBy(asc(friendships.updatedAt));
// Fetch tombstones (deletions) changed since timestamp
const changedTombstones = await db
.select()
.from(tombstones)
.where(and(eq(tombstones.userId, userId), gte(tombstones.updatedAt, sinceDate)))
.orderBy(asc(tombstones.updatedAt));
const timestamp = Date.now();
res.json({
@@ -75,6 +80,7 @@ router.get('/', asyncHandler(async (req: Request, res: Response) => {
subtasks: changedSubtasks,
repeatProfiles: changedRepeatProfiles,
friendships: changedFriendships,
deleted: changedTombstones.map((t) => ({ entity: t.entity, id: t.entityId, updatedAt: t.updatedAt })),
timestamp,
});
}));
@@ -125,6 +131,27 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
}
}
// Sanitize assignee references: only real user ids may be stored (FK).
// Stale/unknown assignee ids are silently dropped to null instead of
// failing the whole push transaction.
const assigneeIds = new Set<string>();
for (const task of data.changes.tasks ?? []) {
if (task.assigneeId) assigneeIds.add(task.assigneeId);
}
for (const sub of data.changes.subtasks ?? []) {
if (sub.assigneeId) assigneeIds.add(sub.assigneeId);
}
const validAssignees = new Set<string>();
if (assigneeIds.size > 0) {
const rows = await tx
.select({ id: users.id })
.from(users)
.where(inArray(users.id, [...assigneeIds]));
for (const r of rows) validAssignees.add(r.id);
}
const sanitizeAssignee = (a: string | null | undefined): string | null =>
a && validAssignees.has(a) ? a : null;
// Process categories
if (data.changes.categories && data.changes.categories.length > 0) {
for (const cat of data.changes.categories) {
@@ -185,7 +212,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
resolution: 'server_wins',
});
continue; // Server wins
}
}
// Prevent completing tasks with future due dates
if (task.completed === true) {
@@ -203,30 +230,32 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
}
await tx
.update(tasks)
.set({
title: task.title,
description: task.description,
categoryId: task.categoryId,
priority: task.priority,
completed: task.completed,
dueDate: task.dueDate,
dueTime: task.dueTime ?? '',
endTime: task.endTime ?? '',
allDay: task.allDay ?? false,
repeat: task.repeat ?? 'none',
repeatInterval: task.repeatInterval ?? 1,
repeatDays: task.repeatDays ?? '',
seriesId: task.seriesId ?? '',
reminder: task.reminder ?? 'none',
reminders: task.reminders ?? '',
assigneeId: task.assigneeId ?? null,
updatedAt: task.updatedAt,
})
.where(and(eq(tasks.id, task.id), eq(tasks.userId, userId)));
.update(tasks)
.set({
title: task.title,
description: task.description,
categoryId: task.categoryId,
priority: task.priority,
completed: task.completed,
dueDate: task.dueDate,
dueTime: task.dueTime ?? '',
endTime: task.endTime ?? '',
allDay: task.allDay ?? false,
repeat: task.repeat ?? 'none',
repeatInterval: task.repeatInterval ?? 1,
repeatDays: task.repeatDays ?? '',
seriesId: task.seriesId ?? '',
reminder: task.reminder ?? 'none',
reminders: task.reminders ?? '',
assigneeId: sanitizeAssignee(task.assigneeId),
completedAt: task.completedAt ?? null,
updatedAt: task.updatedAt,
})
.where(and(eq(tasks.id, task.id), eq(tasks.userId, userId)));
} else {
await tx.insert(tasks).values({
...task,
assigneeId: sanitizeAssignee(task.assigneeId),
allDay: task.allDay ?? false,
userId,
});
@@ -260,6 +289,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
.update(subtasks)
.set({
taskId: sub.taskId,
parentSubtaskId: sub.parentSubtaskId ?? null,
title: sub.title,
description: sub.description ?? '',
priority: sub.priority ?? 'none',
@@ -274,7 +304,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
seriesId: sub.seriesId ?? '',
reminder: sub.reminder ?? 'none',
reminders: sub.reminders ?? '',
assigneeId: sub.assigneeId ?? null,
assigneeId: sanitizeAssignee(sub.assigneeId),
order: sub.order,
updatedAt: sub.updatedAt,
})
@@ -282,6 +312,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
} else {
await tx.insert(subtasks).values({
...sub,
assigneeId: sanitizeAssignee(sub.assigneeId),
userId,
});
}
@@ -365,6 +396,14 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
}
}
}
// Process deletions (tombstones) - last so they see the state produced
// by the upserts above and resolve by last-writer-wins.
if (data.deleted && data.deleted.length > 0) {
for (const deleted of data.deleted) {
await applyTombstone(tx, deleted.entity, deleted.id, deleted.updatedAt, userId, conflicts);
}
}
});
} catch (error) {
console.error('Sync push error:', error);
@@ -378,4 +417,197 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
});
}));
// Upsert a tombstone row, keeping the latest updatedAt.
async function upsertTombstone(
tx: any,
entity: EntityName,
entityId: string,
updatedAt: number,
userId: string
): Promise<void> {
const existing = await tx
.select({ updatedAt: tombstones.updatedAt })
.from(tombstones)
.where(and(eq(tombstones.entity, entity), eq(tombstones.entityId, entityId)))
.limit(1);
const merged = Math.max(existing[0]?.updatedAt ?? 0, updatedAt);
if (existing.length > 0) {
await tx
.update(tombstones)
.set({ updatedAt: merged })
.where(and(eq(tombstones.entity, entity), eq(tombstones.entityId, entityId)));
} else {
await tx.insert(tombstones).values({ entity, entityId, userId, updatedAt: merged });
}
}
// Apply a client deletion. LWW: if the server row is newer than the deletion
// timestamp, the deletion is rejected (server_wins conflict) so the client
// re-pulls the row. Accepted deletions cascade tombstones to every FK-cascaded
// child so all devices remove them too.
async function applyTombstone(
tx: any,
entity: EntityName,
id: string,
deletedAt: number,
userId: string,
conflicts: any[]
): Promise<void> {
const tombstoneOf = (e: EntityName, ids: string[]) => ids.forEach((i) => upsertTombstone(tx, e, i, deletedAt, userId));
if (entity === 'tasks') {
const row = await tx
.select()
.from(tasks)
.where(and(eq(tasks.id, id), eq(tasks.userId, userId)))
.limit(1);
if (row.length === 0) {
await upsertTombstone(tx, entity, id, deletedAt, userId);
return;
}
if (row[0].updatedAt > deletedAt) {
conflicts.push({
entity: 'tasks',
id,
serverVersion: row[0],
clientVersion: { id, updatedAt: deletedAt },
resolution: 'server_wins',
});
return;
}
const children = await tx
.select({ id: subtasks.id })
.from(subtasks)
.where(and(eq(subtasks.taskId, id), eq(subtasks.userId, userId)));
tombstoneOf('subtasks', children.map((c: any) => c.id));
await tx.delete(tasks).where(and(eq(tasks.id, id), eq(tasks.userId, userId)));
await upsertTombstone(tx, entity, id, deletedAt, userId);
return;
}
if (entity === 'categories') {
const row = await tx
.select()
.from(categories)
.where(and(eq(categories.id, id), eq(categories.userId, userId)))
.limit(1);
if (row.length === 0) {
await upsertTombstone(tx, entity, id, deletedAt, userId);
return;
}
if (row[0].updatedAt > deletedAt) {
conflicts.push({
entity: 'categories',
id,
serverVersion: row[0],
clientVersion: { id, updatedAt: deletedAt },
resolution: 'server_wins',
});
return;
}
// Deleting the category cascades its tasks (and their subtasks) -
// tombstone all of them so every client removes them.
const catTasks = await tx
.select()
.from(tasks)
.where(and(eq(tasks.categoryId, id), eq(tasks.userId, userId)));
for (const taskRow of catTasks) {
const subIds = await tx
.select({ id: subtasks.id })
.from(subtasks)
.where(and(eq(subtasks.taskId, taskRow.id), eq(subtasks.userId, userId)));
tombstoneOf('subtasks', subIds.map((s: any) => s.id));
tombstoneOf('tasks', [taskRow.id]);
}
await tx.delete(categories).where(and(eq(categories.id, id), eq(categories.userId, userId)));
await upsertTombstone(tx, entity, id, deletedAt, userId);
return;
}
if (entity === 'subtasks') {
const row = await tx
.select()
.from(subtasks)
.where(and(eq(subtasks.id, id), eq(subtasks.userId, userId)))
.limit(1);
if (row.length === 0) {
await upsertTombstone(tx, entity, id, deletedAt, userId);
return;
}
if (row[0].updatedAt > deletedAt) {
conflicts.push({
entity: 'subtasks',
id,
serverVersion: row[0],
clientVersion: { id, updatedAt: deletedAt },
resolution: 'server_wins',
});
return;
}
// Deleting a parent subtask cascades its children in PG - tombstone them.
const children = await tx
.select({ id: subtasks.id })
.from(subtasks)
.where(and(eq(subtasks.parentSubtaskId, id), eq(subtasks.userId, userId)));
tombstoneOf('subtasks', children.map((c: any) => c.id));
await tx.delete(subtasks).where(and(eq(subtasks.id, id), eq(subtasks.userId, userId)));
await upsertTombstone(tx, entity, id, deletedAt, userId);
return;
}
if (entity === 'repeatProfiles') {
const row = await tx
.select()
.from(repeatProfiles)
.where(and(eq(repeatProfiles.id, id), eq(repeatProfiles.userId, userId)))
.limit(1);
if (row.length === 0) {
await upsertTombstone(tx, entity, id, deletedAt, userId);
return;
}
if (row[0].updatedAt > deletedAt) {
conflicts.push({
entity: 'repeatProfiles',
id,
serverVersion: row[0],
clientVersion: { id, updatedAt: deletedAt },
resolution: 'server_wins',
});
return;
}
await tx.delete(repeatProfiles).where(and(eq(repeatProfiles.id, id), eq(repeatProfiles.userId, userId)));
await upsertTombstone(tx, entity, id, deletedAt, userId);
return;
}
if (entity === 'friendships') {
const row = await tx
.select()
.from(friendships)
.where(and(
eq(friendships.id, id),
or(eq(friendships.userId, userId), eq(friendships.friendId, userId))
))
.limit(1);
if (row.length === 0) {
await upsertTombstone(tx, entity, id, deletedAt, userId);
return;
}
if (row[0].updatedAt > deletedAt) {
conflicts.push({
entity: 'friendships',
id,
serverVersion: row[0],
clientVersion: { id, updatedAt: deletedAt },
resolution: 'server_wins',
});
return;
}
await tx.delete(friendships).where(eq(friendships.id, id));
await upsertTombstone(tx, entity, id, deletedAt, userId);
return;
}
}
export default router;
+18 -14
View File
@@ -116,15 +116,17 @@ router.post('/', asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.userId;
const now = Date.now();
// Verify category exists and belongs to user
const cat = await db
.select()
.from(categories)
.where(and(eq(categories.id, data.categoryId), eq(categories.userId, userId)))
.limit(1);
// Verify category exists and belongs to user (optional - tasks may be uncategorized)
if (data.categoryId) {
const cat = await db
.select()
.from(categories)
.where(and(eq(categories.id, data.categoryId), eq(categories.userId, userId)))
.limit(1);
if (cat.length === 0) {
throw new AppError('NOT_FOUND', 'Category not found', 404);
if (cat.length === 0) {
throw new AppError('NOT_FOUND', 'Category not found', 404);
}
}
const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
@@ -261,12 +263,14 @@ router.post('/batch', asyncHandler(async (req: Request, res: Response) => {
for (const op of operations) {
try {
if (op.type === 'create') {
const cat = await db
.select()
.from(categories)
.where(and(eq(categories.id, op.data.categoryId), eq(categories.userId, userId)))
.limit(1);
if (cat.length === 0) throw new AppError('NOT_FOUND', 'Category not found', 404);
if (op.data.categoryId) {
const cat = await db
.select()
.from(categories)
.where(and(eq(categories.id, op.data.categoryId), eq(categories.userId, userId)))
.limit(1);
if (cat.length === 0) throw new AppError('NOT_FOUND', 'Category not found', 404);
}
const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const now = Date.now();
+32 -9
View File
@@ -21,29 +21,44 @@ export const categoryUpdateSchema = z.object({
export const repeatSchema = z.enum(['none', 'daily', 'weekly', 'monthly', 'custom']);
// The client stores '' / 0 for unset repeat values; normalize them so stale or
// legacy rows never fail validation on push.
export const repeatFieldSchema = repeatSchema
.or(z.literal(''))
.transform((v) => (v === '' ? 'none' : v))
.optional();
export const repeatIntervalFieldSchema = z
.number()
.int()
.min(0)
.max(30)
.transform((v) => Math.max(1, v))
.optional();
export const taskCreateSchema = z.object({
title: z.string().min(1).max(100),
description: z.string().max(1000).optional(),
categoryId: z.string().min(1),
categoryId: z.string().max(100).transform((v) => (v === '' ? null : v)).nullable(),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).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 ?? ''),
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')),
allDay: z.boolean().optional(),
repeat: repeatSchema.optional(),
repeatInterval: z.number().int().min(1).max(30).optional(),
repeat: repeatFieldSchema,
repeatInterval: repeatIntervalFieldSchema,
repeatDays: z.string().max(20).optional(),
seriesId: z.string().max(50).optional(),
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
reminders: z.string().max(100).optional(),
assigneeId: z.string().nullable().optional(),
completedAt: z.number().int().min(0).nullable().optional(),
subtasks: z.array(z.object({ title: z.string().min(1).max(100) })).optional(),
});
export const taskUpdateSchema = z.object({
title: z.string().min(1).max(100).optional(),
description: z.string().max(1000).optional(),
categoryId: z.string().min(1).optional(),
categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
completed: z.boolean().optional(),
dueDate: z.number().int().min(0).optional(),
@@ -51,6 +66,7 @@ export const taskUpdateSchema = z.object({
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(),
reminders: z.string().max(100).optional(),
assigneeId: z.string().nullable().optional(),
completedAt: z.number().int().min(0).nullable().optional(),
});
export const subtaskCreateSchema = z.object({
@@ -61,8 +77,8 @@ export const subtaskCreateSchema = z.object({
dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v ?? ''),
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')),
allDay: z.boolean().optional(),
repeat: repeatSchema.optional(),
repeatInterval: z.number().int().min(1).max(30).optional(),
repeat: repeatFieldSchema,
repeatInterval: repeatIntervalFieldSchema,
repeatDays: z.string().max(20).optional(),
seriesId: z.string().max(50).optional(),
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
@@ -81,8 +97,8 @@ export const subtaskUpdateSchema = z.object({
dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v === '' ? undefined : v),
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(),
allDay: z.boolean().optional(),
repeat: repeatSchema.optional(),
repeatInterval: z.number().int().min(1).max(30).optional(),
repeat: repeatFieldSchema,
repeatInterval: repeatIntervalFieldSchema,
repeatDays: z.string().max(20).optional(),
seriesId: z.string().max(50).optional(),
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
@@ -132,14 +148,21 @@ export const friendshipSchema = z.object({
updatedAt: z.number(),
});
export const tombstoneSchema = z.object({
entity: z.enum(['categories', 'tasks', 'subtasks', 'repeatProfiles', 'friendships']),
id: z.string(),
updatedAt: z.number().int().min(0),
});
export const pushChangesSchema = z.object({
changes: z.object({
categories: z.array(categoryCreateSchema.extend({ id: z.string(), createdAt: z.number(), updatedAt: z.number() })).optional(),
tasks: z.array(taskCreateSchema.extend({ id: z.string(), completed: z.boolean(), createdAt: z.number(), updatedAt: z.number() })).optional(),
tasks: z.array(taskCreateSchema.extend({ id: z.string(), completed: z.boolean(), createdAt: z.number(), updatedAt: z.number(), completedAt: z.number().int().min(0).nullable().optional() })).optional(),
subtasks: z.array(subtaskCreateSchema.extend({ id: z.string(), taskId: z.string(), completed: z.boolean(), createdAt: z.number(), updatedAt: z.number() })).optional(),
repeatProfiles: z.array(repeatProfileSchema.extend({ id: z.string(), createdAt: z.number(), updatedAt: z.number() })).optional(),
friendships: z.array(friendshipSchema).optional(),
}),
deleted: z.array(tombstoneSchema).optional(),
lastPulledAt: z.number().int().min(0),
});