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
+55
View File
@@ -57,6 +57,7 @@ GET /sync
{ {
"id": "sub_1", "id": "sub_1",
"taskId": "task_1", "taskId": "task_1",
"parentSubtaskId": null,
"title": "Setup Expo project", "title": "Setup Expo project",
"completed": true, "completed": true,
"order": 0, "order": 0,
@@ -64,10 +65,21 @@ GET /sync
"updatedAt": 1699000000000 "updatedAt": 1699000000000
} }
], ],
"repeatProfiles": [],
"friendships": [],
"deleted": [
{
"entity": "tasks",
"id": "task_5",
"updatedAt": 1699150000000
}
],
"timestamp": 1699150000000 "timestamp": 1699150000000
} }
``` ```
`deleted` contains every row tombstoned (deleted) since `since` — one entry per entity type/id. Clients must remove the row locally (and any related rows: a task deletion implies its subtasks, a category deletion implies its tasks and their subtasks, a parent subtask deletion implies its children).
**Error Responses** **Error Responses**
| Status | Code | Message | | Status | Code | Message |
|--------|------|---------| |--------|------|---------|
@@ -114,6 +126,7 @@ POST /sync/push
{ {
"id": "sub_new", "id": "sub_new",
"taskId": "task_new", "taskId": "task_new",
"parentSubtaskId": null,
"title": "Subtask 1", "title": "Subtask 1",
"completed": false, "completed": false,
"order": 0, "order": 0,
@@ -122,10 +135,19 @@ POST /sync/push
} }
] ]
}, },
"deleted": [
{
"entity": "categories",
"id": "cat_gone",
"updatedAt": 1699150000000
}
],
"lastPulledAt": 1699100000000 "lastPulledAt": 1699100000000
} }
``` ```
`deleted` is a list of tombstones the client recorded locally since its last successful sync. Deletions are resolved by last-writer-wins: if the server row's `updatedAt` is newer than the tombstone's `updatedAt`, the deletion is rejected and reported as a `server_wins` conflict (with the server row as `serverVersion`) so the client re-pulls it. Accepted deletions remove the rows server-side; cascaded rows (subtasks of a deleted task, tasks/subtasks of a deleted category, children of a deleted parent subtask) are tombstoned automatically so every device removes them.
**Response** (200 OK) **Response** (200 OK)
```json ```json
{ {
@@ -568,6 +590,16 @@ interface Task {
completed: boolean; completed: boolean;
dueDate: number; // Unix timestamp (ms), 0 if not set dueDate: number; // Unix timestamp (ms), 0 if not set
dueTime: string; // HH:MM format, empty if not set dueTime: string; // HH:MM format, empty if not set
endTime: string; // HH:MM format, empty if not set
allDay: boolean;
repeat: 'none' | 'daily' | 'weekly' | 'monthly' | 'custom';
repeatInterval: number;
repeatDays: string; // comma separated weekday numbers, e.g. "0,2,4"
seriesId: string; // repeating-series id, empty if not part of a series
reminder: 'none' | 'at_time' | '15' | '30' | '60' | '120' | '1440';
reminders: string; // comma separated reminder values, empty if none
assigneeId: string | null;
completedAt: number | null; // Unix timestamp (ms) when completed
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
subtasks?: Subtask[]; subtasks?: Subtask[];
@@ -579,14 +611,37 @@ interface Task {
interface Subtask { interface Subtask {
id: string; id: string;
taskId: string; taskId: string;
parentSubtaskId: string | null; // id of the parent subtask, null for top-level
title: string; title: string;
description: string;
priority: 'none' | 'low' | 'medium' | 'high' | 'critical';
completed: boolean; completed: boolean;
dueDate: number;
dueTime: string;
endTime: string;
allDay: boolean;
repeat: 'none' | 'daily' | 'weekly' | 'monthly' | 'custom';
repeatInterval: number;
repeatDays: string;
seriesId: string;
reminder: 'none' | 'at_time' | '15' | '30' | '60' | '120' | '1440';
reminders: string;
assigneeId: string | null;
order: number; order: number;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
} }
``` ```
### Tombstone
```typescript
interface Tombstone {
entity: 'categories' | 'tasks' | 'subtasks' | 'repeatProfiles' | 'friendships';
id: string; // id of the deleted row
updatedAt: number; // Unix timestamp (ms) of the deletion
}
```
### UserSettings ### UserSettings
```typescript ```typescript
interface UserSettings { interface UserSettings {
+513 -136
View File
@@ -1,3 +1,5 @@
export declare const ENTITIES: readonly ["categories", "tasks", "subtasks", "repeatProfiles", "friendships"];
export type EntityName = (typeof ENTITIES)[number];
export declare const users: import("drizzle-orm/pg-core").PgTableWithColumns<{ export declare const users: import("drizzle-orm/pg-core").PgTableWithColumns<{
name: "users"; name: "users";
schema: undefined; schema: undefined;
@@ -50,6 +52,38 @@ export declare const users: import("drizzle-orm/pg-core").PgTableWithColumns<{
baseColumn: never; baseColumn: never;
generated: undefined; generated: undefined;
}, {}, {}>; }, {}, {}>;
resetToken: import("drizzle-orm/pg-core").PgColumn<{
name: "reset_token";
tableName: "users";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: false;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
resetTokenExpiry: import("drizzle-orm/pg-core").PgColumn<{
name: "reset_token_expiry";
tableName: "users";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: false;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
createdAt: import("drizzle-orm/pg-core").PgColumn<{ createdAt: import("drizzle-orm/pg-core").PgColumn<{
name: "created_at"; name: "created_at";
tableName: "users"; tableName: "users";
@@ -334,7 +368,7 @@ export declare const tasks: import("drizzle-orm/pg-core").PgTableWithColumns<{
columnType: "PgText"; columnType: "PgText";
data: string; data: string;
driverParam: string; driverParam: string;
notNull: true; notNull: false;
hasDefault: false; hasDefault: false;
isPrimaryKey: false; isPrimaryKey: false;
isAutoincrement: false; isAutoincrement: false;
@@ -455,6 +489,22 @@ export declare const tasks: import("drizzle-orm/pg-core").PgTableWithColumns<{
baseColumn: never; baseColumn: never;
generated: undefined; generated: undefined;
}, {}, {}>; }, {}, {}>;
allDay: import("drizzle-orm/pg-core").PgColumn<{
name: "all_day";
tableName: "tasks";
dataType: "boolean";
columnType: "PgBoolean";
data: boolean;
driverParam: boolean;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
repeat: import("drizzle-orm/pg-core").PgColumn<{ repeat: import("drizzle-orm/pg-core").PgColumn<{
name: "repeat"; name: "repeat";
tableName: "tasks"; tableName: "tasks";
@@ -551,6 +601,38 @@ export declare const tasks: import("drizzle-orm/pg-core").PgTableWithColumns<{
baseColumn: never; baseColumn: never;
generated: undefined; generated: undefined;
}, {}, {}>; }, {}, {}>;
reminders: import("drizzle-orm/pg-core").PgColumn<{
name: "reminders";
tableName: "tasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
completedAt: import("drizzle-orm/pg-core").PgColumn<{
name: "completed_at";
tableName: "tasks";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: false;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
createdAt: import("drizzle-orm/pg-core").PgColumn<{ createdAt: import("drizzle-orm/pg-core").PgColumn<{
name: "created_at"; name: "created_at";
tableName: "tasks"; tableName: "tasks";
@@ -586,6 +668,436 @@ export declare const tasks: import("drizzle-orm/pg-core").PgTableWithColumns<{
}; };
dialect: "pg"; dialect: "pg";
}>; }>;
export declare const tombstones: import("drizzle-orm/pg-core").PgTableWithColumns<{
name: "tombstones";
schema: undefined;
columns: {
entity: import("drizzle-orm/pg-core").PgColumn<{
name: "entity";
tableName: "tombstones";
dataType: "string";
columnType: "PgText";
data: "categories" | "tasks" | "subtasks" | "repeatProfiles" | "friendships";
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: ["categories", "tasks", "subtasks", "repeatProfiles", "friendships"];
baseColumn: never;
generated: undefined;
}, {}, {}>;
entityId: import("drizzle-orm/pg-core").PgColumn<{
name: "entity_id";
tableName: "tombstones";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
userId: import("drizzle-orm/pg-core").PgColumn<{
name: "user_id";
tableName: "tombstones";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
updatedAt: import("drizzle-orm/pg-core").PgColumn<{
name: "updated_at";
tableName: "tombstones";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
};
dialect: "pg";
}>;
export declare const subtasks: import("drizzle-orm/pg-core").PgTableWithColumns<{
name: "subtasks";
schema: undefined;
columns: {
id: import("drizzle-orm/pg-core").PgColumn<{
name: "id";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: true;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
userId: import("drizzle-orm/pg-core").PgColumn<{
name: "user_id";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
taskId: import("drizzle-orm/pg-core").PgColumn<{
name: "task_id";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
parentSubtaskId: import("drizzle-orm/pg-core").PgColumn<{
name: "parent_subtask_id";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: false;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
title: import("drizzle-orm/pg-core").PgColumn<{
name: "title";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
description: import("drizzle-orm/pg-core").PgColumn<{
name: "description";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
priority: import("drizzle-orm/pg-core").PgColumn<{
name: "priority";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: "none" | "low" | "medium" | "high" | "critical";
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: ["none", "low", "medium", "high", "critical"];
baseColumn: never;
generated: undefined;
}, {}, {}>;
completed: import("drizzle-orm/pg-core").PgColumn<{
name: "completed";
tableName: "subtasks";
dataType: "boolean";
columnType: "PgBoolean";
data: boolean;
driverParam: boolean;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
dueDate: import("drizzle-orm/pg-core").PgColumn<{
name: "due_date";
tableName: "subtasks";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
dueTime: import("drizzle-orm/pg-core").PgColumn<{
name: "due_time";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
endTime: import("drizzle-orm/pg-core").PgColumn<{
name: "end_time";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
allDay: import("drizzle-orm/pg-core").PgColumn<{
name: "all_day";
tableName: "subtasks";
dataType: "boolean";
columnType: "PgBoolean";
data: boolean;
driverParam: boolean;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
repeat: import("drizzle-orm/pg-core").PgColumn<{
name: "repeat";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: "custom" | "none" | "daily" | "weekly" | "monthly";
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: ["none", "daily", "weekly", "monthly", "custom"];
baseColumn: never;
generated: undefined;
}, {}, {}>;
repeatInterval: import("drizzle-orm/pg-core").PgColumn<{
name: "repeat_interval";
tableName: "subtasks";
dataType: "number";
columnType: "PgInteger";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
repeatDays: import("drizzle-orm/pg-core").PgColumn<{
name: "repeat_days";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
seriesId: import("drizzle-orm/pg-core").PgColumn<{
name: "series_id";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
reminder: import("drizzle-orm/pg-core").PgColumn<{
name: "reminder";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440";
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: ["none", "at_time", "15", "30", "60", "120", "1440"];
baseColumn: never;
generated: undefined;
}, {}, {}>;
reminders: import("drizzle-orm/pg-core").PgColumn<{
name: "reminders";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
assigneeId: import("drizzle-orm/pg-core").PgColumn<{
name: "assignee_id";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: false;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
order: import("drizzle-orm/pg-core").PgColumn<{
name: "order";
tableName: "subtasks";
dataType: "number";
columnType: "PgInteger";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
createdAt: import("drizzle-orm/pg-core").PgColumn<{
name: "created_at";
tableName: "subtasks";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
updatedAt: import("drizzle-orm/pg-core").PgColumn<{
name: "updated_at";
tableName: "subtasks";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
};
dialect: "pg";
}>;
export declare const repeatProfiles: import("drizzle-orm/pg-core").PgTableWithColumns<{ export declare const repeatProfiles: import("drizzle-orm/pg-core").PgTableWithColumns<{
name: "repeat_profiles"; name: "repeat_profiles";
schema: undefined; schema: undefined;
@@ -721,141 +1233,6 @@ export declare const repeatProfiles: import("drizzle-orm/pg-core").PgTableWithCo
}; };
dialect: "pg"; dialect: "pg";
}>; }>;
export declare const subtasks: import("drizzle-orm/pg-core").PgTableWithColumns<{
name: "subtasks";
schema: undefined;
columns: {
id: import("drizzle-orm/pg-core").PgColumn<{
name: "id";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: true;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
userId: import("drizzle-orm/pg-core").PgColumn<{
name: "user_id";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
taskId: import("drizzle-orm/pg-core").PgColumn<{
name: "task_id";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
title: import("drizzle-orm/pg-core").PgColumn<{
name: "title";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
completed: import("drizzle-orm/pg-core").PgColumn<{
name: "completed";
tableName: "subtasks";
dataType: "boolean";
columnType: "PgBoolean";
data: boolean;
driverParam: boolean;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
order: import("drizzle-orm/pg-core").PgColumn<{
name: "order";
tableName: "subtasks";
dataType: "number";
columnType: "PgInteger";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
createdAt: import("drizzle-orm/pg-core").PgColumn<{
name: "created_at";
tableName: "subtasks";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
updatedAt: import("drizzle-orm/pg-core").PgColumn<{
name: "updated_at";
tableName: "subtasks";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
};
dialect: "pg";
}>;
export declare const userSettings: import("drizzle-orm/pg-core").PgTableWithColumns<{ export declare const userSettings: import("drizzle-orm/pg-core").PgTableWithColumns<{
name: "user_settings"; name: "user_settings";
schema: undefined; schema: undefined;
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/db/schema.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,KAAK;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAKhB,CAAC;AAEH,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAevB,CAAC;AAEF,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAQrB,CAAC;AAEH,eAAO,MAAM,KAAK;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAmBhB,CAAC;AAEH,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EASzB,CAAC;AAEH,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EASnB,CAAC;AAEH,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EASvB,CAAC"} {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/db/schema.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,QAAQ,+EAAgF,CAAC;AACtG,MAAM,MAAM,UAAU,GAAG,CAAC,OAAO,QAAQ,CAAC,CAAC,MAAM,CAAC,CAAC;AAEnD,eAAO,MAAM,KAAK;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAOhB,CAAC;AAEH,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAevB,CAAC;AAEF,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAQrB,CAAC;AAEH,eAAO,MAAM,KAAK;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAsBhB,CAAC;AAEH,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAYtB,CAAC;AAGF,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA2BlB,CAAC;AAEJ,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EASzB,CAAC;AAEH,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EASvB,CAAC"}
+46 -12
View File
@@ -1,12 +1,15 @@
"use strict"; "use strict";
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.userSettings = exports.subtasks = exports.repeatProfiles = exports.tasks = exports.categories = exports.friendships = exports.users = void 0; exports.userSettings = exports.repeatProfiles = exports.subtasks = exports.tombstones = exports.tasks = exports.categories = exports.friendships = exports.users = exports.ENTITIES = void 0;
const pg_core_1 = require("drizzle-orm/pg-core"); const pg_core_1 = require("drizzle-orm/pg-core");
const drizzle_orm_1 = require("drizzle-orm"); const drizzle_orm_1 = require("drizzle-orm");
exports.ENTITIES = ['categories', 'tasks', 'subtasks', 'repeatProfiles', 'friendships'];
exports.users = (0, pg_core_1.pgTable)('users', { exports.users = (0, pg_core_1.pgTable)('users', {
id: (0, pg_core_1.text)('id').primaryKey(), id: (0, pg_core_1.text)('id').primaryKey(),
username: (0, pg_core_1.text)('username').notNull().unique(), username: (0, pg_core_1.text)('username').notNull().unique(),
passwordHash: (0, pg_core_1.text)('password_hash').notNull(), passwordHash: (0, pg_core_1.text)('password_hash').notNull(),
resetToken: (0, pg_core_1.text)('reset_token'),
resetTokenExpiry: (0, pg_core_1.bigint)('reset_token_expiry', { mode: 'number' }),
createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`), createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
}); });
exports.friendships = (0, pg_core_1.pgTable)('friendships', { exports.friendships = (0, pg_core_1.pgTable)('friendships', {
@@ -33,7 +36,7 @@ exports.categories = (0, pg_core_1.pgTable)('categories', {
exports.tasks = (0, pg_core_1.pgTable)('tasks', { exports.tasks = (0, pg_core_1.pgTable)('tasks', {
id: (0, pg_core_1.text)('id').primaryKey(), id: (0, pg_core_1.text)('id').primaryKey(),
userId: (0, pg_core_1.text)('user_id').notNull().references(() => exports.users.id, { onDelete: 'cascade' }), userId: (0, pg_core_1.text)('user_id').notNull().references(() => exports.users.id, { onDelete: 'cascade' }),
categoryId: (0, pg_core_1.text)('category_id').notNull().references(() => exports.categories.id, { onDelete: 'cascade' }), categoryId: (0, pg_core_1.text)('category_id').references(() => exports.categories.id, { onDelete: 'cascade' }),
title: (0, pg_core_1.text)('title').notNull(), title: (0, pg_core_1.text)('title').notNull(),
description: (0, pg_core_1.text)('description').notNull().default(''), description: (0, pg_core_1.text)('description').notNull().default(''),
priority: (0, pg_core_1.text)('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'), priority: (0, pg_core_1.text)('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
@@ -41,15 +44,56 @@ exports.tasks = (0, pg_core_1.pgTable)('tasks', {
dueDate: (0, pg_core_1.bigint)('due_date', { mode: 'number' }).notNull().default(0), dueDate: (0, pg_core_1.bigint)('due_date', { mode: 'number' }).notNull().default(0),
dueTime: (0, pg_core_1.text)('due_time').notNull().default(''), dueTime: (0, pg_core_1.text)('due_time').notNull().default(''),
endTime: (0, pg_core_1.text)('end_time').notNull().default(''), endTime: (0, pg_core_1.text)('end_time').notNull().default(''),
allDay: (0, pg_core_1.boolean)('all_day').notNull().default(false),
repeat: (0, pg_core_1.text)('repeat', { enum: ['none', 'daily', 'weekly', 'monthly', 'custom'] }).notNull().default('none'), repeat: (0, pg_core_1.text)('repeat', { enum: ['none', 'daily', 'weekly', 'monthly', 'custom'] }).notNull().default('none'),
repeatInterval: (0, pg_core_1.integer)('repeat_interval').notNull().default(1), repeatInterval: (0, pg_core_1.integer)('repeat_interval').notNull().default(1),
repeatDays: (0, pg_core_1.text)('repeat_days').notNull().default(''), repeatDays: (0, pg_core_1.text)('repeat_days').notNull().default(''),
seriesId: (0, pg_core_1.text)('series_id').notNull().default(''), seriesId: (0, pg_core_1.text)('series_id').notNull().default(''),
assigneeId: (0, pg_core_1.text)('assignee_id').references(() => exports.users.id, { onDelete: 'set null' }), assigneeId: (0, pg_core_1.text)('assignee_id').references(() => exports.users.id, { onDelete: 'set null' }),
reminder: (0, pg_core_1.text)('reminder', { enum: ['none', 'at_time', '15', '30', '60', '120', '1440'] }).notNull().default('none'), reminder: (0, pg_core_1.text)('reminder', { enum: ['none', 'at_time', '15', '30', '60', '120', '1440'] }).notNull().default('none'),
reminders: (0, pg_core_1.text)('reminders').notNull().default(''),
completedAt: (0, pg_core_1.bigint)('completed_at', { mode: 'number' }),
createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`), createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
updatedAt: (0, pg_core_1.bigint)('updated_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`), updatedAt: (0, pg_core_1.bigint)('updated_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
}); });
exports.tombstones = (0, pg_core_1.pgTable)('tombstones', {
entity: (0, pg_core_1.text)('entity', { enum: exports.ENTITIES }).notNull(),
entityId: (0, pg_core_1.text)('entity_id').notNull(),
userId: (0, pg_core_1.text)('user_id').notNull().references(() => exports.users.id, { onDelete: 'cascade' }),
updatedAt: (0, pg_core_1.bigint)('updated_at', { mode: 'number' }).notNull(),
}, (t) => ({
pk: (0, pg_core_1.primaryKey)({ columns: [t.entity, t.entityId] }),
userIdx: (0, pg_core_1.index)('tombstones_user_updated_idx').on(t.userId, t.updatedAt),
}));
// Define subtasks with explicit type to avoid circular reference
exports.subtasks = (0, pg_core_1.pgTable)('subtasks', {
id: (0, pg_core_1.text)('id').primaryKey(),
userId: (0, pg_core_1.text)('user_id').notNull().references(() => exports.users.id, { onDelete: 'cascade' }),
taskId: (0, pg_core_1.text)('task_id').notNull().references(() => exports.tasks.id, { onDelete: 'cascade' }),
parentSubtaskId: (0, pg_core_1.text)('parent_subtask_id').references(() => exports.subtasks.id, { onDelete: 'cascade' }),
title: (0, pg_core_1.text)('title').notNull(),
description: (0, pg_core_1.text)('description').notNull().default(''),
priority: (0, pg_core_1.text)('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
completed: (0, pg_core_1.boolean)('completed').notNull().default(false),
dueDate: (0, pg_core_1.bigint)('due_date', { mode: 'number' }).notNull().default(0),
dueTime: (0, pg_core_1.text)('due_time').notNull().default(''),
endTime: (0, pg_core_1.text)('end_time').notNull().default(''),
allDay: (0, pg_core_1.boolean)('all_day').notNull().default(false),
repeat: (0, pg_core_1.text)('repeat', { enum: ['none', 'daily', 'weekly', 'monthly', 'custom'] }).notNull().default('none'),
repeatInterval: (0, pg_core_1.integer)('repeat_interval').notNull().default(1),
repeatDays: (0, pg_core_1.text)('repeat_days').notNull().default(''),
seriesId: (0, pg_core_1.text)('series_id').notNull().default(''),
reminder: (0, pg_core_1.text)('reminder', { enum: ['none', 'at_time', '15', '30', '60', '120', '1440'] }).notNull().default('none'),
reminders: (0, pg_core_1.text)('reminders').notNull().default(''),
assigneeId: (0, pg_core_1.text)('assignee_id').references(() => exports.users.id, { onDelete: 'set null' }),
order: (0, pg_core_1.integer)('order').notNull().default(0),
createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
updatedAt: (0, pg_core_1.bigint)('updated_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
}, (t) => ({
parentIdx: (0, pg_core_1.index)('subtasks_parent_idx').on(t.parentSubtaskId),
taskIdx: (0, pg_core_1.index)('subtasks_task_idx').on(t.taskId),
userIdx: (0, pg_core_1.index)('subtasks_user_idx').on(t.userId),
}));
exports.repeatProfiles = (0, pg_core_1.pgTable)('repeat_profiles', { exports.repeatProfiles = (0, pg_core_1.pgTable)('repeat_profiles', {
id: (0, pg_core_1.text)('id').primaryKey(), id: (0, pg_core_1.text)('id').primaryKey(),
userId: (0, pg_core_1.text)('user_id').notNull().references(() => exports.users.id, { onDelete: 'cascade' }), userId: (0, pg_core_1.text)('user_id').notNull().references(() => exports.users.id, { onDelete: 'cascade' }),
@@ -60,16 +104,6 @@ exports.repeatProfiles = (0, pg_core_1.pgTable)('repeat_profiles', {
createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`), createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
updatedAt: (0, pg_core_1.bigint)('updated_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`), updatedAt: (0, pg_core_1.bigint)('updated_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
}); });
exports.subtasks = (0, pg_core_1.pgTable)('subtasks', {
id: (0, pg_core_1.text)('id').primaryKey(),
userId: (0, pg_core_1.text)('user_id').notNull().references(() => exports.users.id, { onDelete: 'cascade' }),
taskId: (0, pg_core_1.text)('task_id').notNull().references(() => exports.tasks.id, { onDelete: 'cascade' }),
title: (0, pg_core_1.text)('title').notNull(),
completed: (0, pg_core_1.boolean)('completed').notNull().default(false),
order: (0, pg_core_1.integer)('order').notNull().default(0),
createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
updatedAt: (0, pg_core_1.bigint)('updated_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
});
exports.userSettings = (0, pg_core_1.pgTable)('user_settings', { exports.userSettings = (0, pg_core_1.pgTable)('user_settings', {
userId: (0, pg_core_1.text)('user_id').primaryKey().references(() => exports.users.id, { onDelete: 'cascade' }), userId: (0, pg_core_1.text)('user_id').primaryKey().references(() => exports.users.id, { onDelete: 'cascade' }),
darkMode: (0, pg_core_1.boolean)('dark_mode').notNull().default(false), darkMode: (0, pg_core_1.boolean)('dark_mode').notNull().default(false),
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,eAAe,CAAC;AAYvB,QAAA,MAAM,GAAG,6CAAY,CAAC;AAkCtB,eAAe,GAAG,CAAC"} {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,eAAe,CAAC;AAavB,QAAA,MAAM,GAAG,6CAAY,CAAC;AAmCtB,eAAe,GAAG,CAAC"}
+2
View File
@@ -12,6 +12,7 @@ const tasks_1 = __importDefault(require("./routes/tasks"));
const subtasks_1 = __importDefault(require("./routes/subtasks")); const subtasks_1 = __importDefault(require("./routes/subtasks"));
const users_1 = __importDefault(require("./routes/users")); const users_1 = __importDefault(require("./routes/users"));
const friends_1 = __importDefault(require("./routes/friends")); const friends_1 = __importDefault(require("./routes/friends"));
const repeatProfiles_1 = __importDefault(require("./routes/repeatProfiles"));
const sync_1 = __importDefault(require("./routes/sync")); const sync_1 = __importDefault(require("./routes/sync"));
const errorHandler_1 = require("./middleware/errorHandler"); const errorHandler_1 = require("./middleware/errorHandler");
const app = (0, express_1.default)(); const app = (0, express_1.default)();
@@ -31,6 +32,7 @@ app.use('/api/categories', categories_1.default);
app.use('/api/tasks', tasks_1.default); app.use('/api/tasks', tasks_1.default);
app.use('/api/subtasks', subtasks_1.default); app.use('/api/subtasks', subtasks_1.default);
app.use('/api/users', users_1.default); app.use('/api/users', users_1.default);
app.use('/api/repeat-profiles', repeatProfiles_1.default);
app.use('/api/sync', sync_1.default); app.use('/api/sync', sync_1.default);
app.use('/api/friends', friends_1.default); app.use('/api/friends', friends_1.default);
// 404 handler // 404 handler
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;AAAA,yBAAuB;AACvB,sDAA8B;AAC9B,gDAAwB;AACxB,yDAAuC;AACvC,qEAAiD;AACjD,2DAAwC;AACxC,iEAA8C;AAC9C,2DAAwC;AACxC,+DAA4C;AAC5C,yDAAuC;AACvC,4DAA0E;AAE1E,MAAM,GAAG,GAAG,IAAA,iBAAO,GAAE,CAAC;AACtB,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC;AAEtC,GAAG,CAAC,GAAG,CAAC,IAAA,cAAI,EAAC;IACX,MAAM,EAAE,IAAI;IACZ,WAAW,EAAE,IAAI;CAClB,CAAC,CAAC,CAAC;AACJ,GAAG,CAAC,GAAG,CAAC,iBAAO,CAAC,IAAI,EAAE,CAAC,CAAC;AAExB,eAAe;AACf,GAAG,CAAC,GAAG,CAAC,SAAS,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAC9B,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;AACpD,CAAC,CAAC,CAAC;AAEH,aAAa;AACb,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,cAAU,CAAC,CAAC;AACjC,GAAG,CAAC,GAAG,CAAC,iBAAiB,EAAE,oBAAc,CAAC,CAAC;AAC3C,GAAG,CAAC,GAAG,CAAC,YAAY,EAAE,eAAU,CAAC,CAAC;AAClC,GAAG,CAAC,GAAG,CAAC,eAAe,EAAE,kBAAa,CAAC,CAAC;AACxC,GAAG,CAAC,GAAG,CAAC,YAAY,EAAE,eAAU,CAAC,CAAC;AAClC,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,cAAU,CAAC,CAAC;AACjC,GAAG,CAAC,GAAG,CAAC,cAAc,EAAE,iBAAY,CAAC,CAAC;AAEtC,cAAc;AACd,GAAG,CAAC,GAAG,CAAC,8BAAe,CAAC,CAAC;AAEzB,gBAAgB;AAChB,GAAG,CAAC,GAAG,CAAC,2BAAY,CAAC,CAAC;AAEtB,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,EAAE;IACpB,OAAO,CAAC,GAAG,CAAC,yCAAyC,IAAI,EAAE,CAAC,CAAC;IAC7D,OAAO,CAAC,GAAG,CAAC,wCAAwC,IAAI,MAAM,CAAC,CAAC;AAClE,CAAC,CAAC,CAAC;AAEH,kBAAe,GAAG,CAAC"} {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;AAAA,yBAAuB;AACvB,sDAA8B;AAC9B,gDAAwB;AACxB,yDAAuC;AACvC,qEAAiD;AACjD,2DAAwC;AACxC,iEAA8C;AAC9C,2DAAwC;AACxC,+DAA4C;AAC5C,6EAA0D;AAC1D,yDAAuC;AACvC,4DAA0E;AAE1E,MAAM,GAAG,GAAG,IAAA,iBAAO,GAAE,CAAC;AACtB,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC;AAEtC,GAAG,CAAC,GAAG,CAAC,IAAA,cAAI,EAAC;IACX,MAAM,EAAE,IAAI;IACZ,WAAW,EAAE,IAAI;CAClB,CAAC,CAAC,CAAC;AACJ,GAAG,CAAC,GAAG,CAAC,iBAAO,CAAC,IAAI,EAAE,CAAC,CAAC;AAExB,eAAe;AACf,GAAG,CAAC,GAAG,CAAC,SAAS,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAC9B,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;AACpD,CAAC,CAAC,CAAC;AAEH,aAAa;AACb,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,cAAU,CAAC,CAAC;AACjC,GAAG,CAAC,GAAG,CAAC,iBAAiB,EAAE,oBAAc,CAAC,CAAC;AAC3C,GAAG,CAAC,GAAG,CAAC,YAAY,EAAE,eAAU,CAAC,CAAC;AAClC,GAAG,CAAC,GAAG,CAAC,eAAe,EAAE,kBAAa,CAAC,CAAC;AACxC,GAAG,CAAC,GAAG,CAAC,YAAY,EAAE,eAAU,CAAC,CAAC;AAClC,GAAG,CAAC,GAAG,CAAC,sBAAsB,EAAE,wBAAmB,CAAC,CAAC;AACrD,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,cAAU,CAAC,CAAC;AACjC,GAAG,CAAC,GAAG,CAAC,cAAc,EAAE,iBAAY,CAAC,CAAC;AAEtC,cAAc;AACd,GAAG,CAAC,GAAG,CAAC,8BAAe,CAAC,CAAC;AAEzB,gBAAgB;AAChB,GAAG,CAAC,GAAG,CAAC,2BAAY,CAAC,CAAC;AAEtB,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,EAAE;IACpB,OAAO,CAAC,GAAG,CAAC,yCAAyC,IAAI,EAAE,CAAC,CAAC;IAC7D,OAAO,CAAC,GAAG,CAAC,wCAAwC,IAAI,MAAM,CAAC,CAAC;AAClE,CAAC,CAAC,CAAC;AAEH,kBAAe,GAAG,CAAC"}
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/routes/auth.ts"],"names":[],"mappings":"AASA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAmFxB,eAAe,MAAM,CAAC"} {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/routes/auth.ts"],"names":[],"mappings":"AAYA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA0LxB,eAAe,MAAM,CAAC"}
+101 -9
View File
@@ -1,4 +1,7 @@
"use strict"; "use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express"); const express_1 = require("express");
const asyncHandler_1 = require("../utils/asyncHandler"); const asyncHandler_1 = require("../utils/asyncHandler");
@@ -8,7 +11,47 @@ const drizzle_orm_1 = require("drizzle-orm");
const auth_1 = require("../utils/auth"); const auth_1 = require("../utils/auth");
const errorHandler_1 = require("../middleware/errorHandler"); const errorHandler_1 = require("../middleware/errorHandler");
const zod_1 = require("zod"); const zod_1 = require("zod");
const bcryptjs_1 = __importDefault(require("bcryptjs"));
const express_rate_limit_1 = __importDefault(require("express-rate-limit"));
const crypto_1 = __importDefault(require("crypto"));
const router = (0, express_1.Router)(); const router = (0, express_1.Router)();
// Rate limiting for auth endpoints
const authLimiter = (0, express_rate_limit_1.default)({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // limit each IP to 10 requests per windowMs
message: {
error: {
code: 'RATE_LIMITED',
message: 'Too many attempts. Please try again later.',
},
},
standardHeaders: true,
legacyHeaders: false,
});
const loginLimiter = (0, express_rate_limit_1.default)({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // stricter limit for login
message: {
error: {
code: 'RATE_LIMITED',
message: 'Too many login attempts. Please try again later.',
},
},
standardHeaders: true,
legacyHeaders: false,
});
const passwordResetLimiter = (0, express_rate_limit_1.default)({
windowMs: 60 * 60 * 1000, // 1 hour
max: 3, // very strict for password reset
message: {
error: {
code: 'RATE_LIMITED',
message: 'Too many password reset requests. Please try again later.',
},
},
standardHeaders: true,
legacyHeaders: false,
});
const usernameSchema = zod_1.z const usernameSchema = zod_1.z
.string() .string()
.min(3) .min(3)
@@ -22,15 +65,24 @@ const loginSchema = zod_1.z.object({
username: zod_1.z.string(), username: zod_1.z.string(),
password: zod_1.z.string(), password: zod_1.z.string(),
}); });
// In production, use bcrypt or argon2 for password hashing const forgotPasswordSchema = zod_1.z.object({
function hashPassword(password) { username: zod_1.z.string().min(1),
// Simple hash for demo - replace with bcrypt in production });
return Buffer.from(password).toString('base64'); const resetPasswordSchema = zod_1.z.object({
token: zod_1.z.string().min(1),
password: zod_1.z.string().min(8),
});
const BCRYPT_ROUNDS = 12;
async function hashPassword(password) {
return bcryptjs_1.default.hash(password, BCRYPT_ROUNDS);
} }
function verifyPassword(password, hash) { async function verifyPassword(password, hash) {
return hashPassword(password) === hash; return bcryptjs_1.default.compare(password, hash);
} }
router.post('/register', (0, asyncHandler_1.asyncHandler)(async (req, res) => { function generateResetToken() {
return crypto_1.default.randomBytes(32).toString('hex');
}
router.post('/register', authLimiter, (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const data = registerSchema.parse(req.body); const data = registerSchema.parse(req.body);
const existing = await db_1.db.select().from(schema_1.users).where((0, drizzle_orm_1.eq)(schema_1.users.username, data.username)).limit(1); const existing = await db_1.db.select().from(schema_1.users).where((0, drizzle_orm_1.eq)(schema_1.users.username, data.username)).limit(1);
if (existing.length > 0) { if (existing.length > 0) {
@@ -41,7 +93,7 @@ router.post('/register', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
await db_1.db.insert(schema_1.users).values({ await db_1.db.insert(schema_1.users).values({
id: userId, id: userId,
username: data.username, username: data.username,
passwordHash: hashPassword(data.password), passwordHash: await hashPassword(data.password),
createdAt: now, createdAt: now,
}); });
const token = (0, auth_1.generateToken)({ userId, username: data.username }); const token = (0, auth_1.generateToken)({ userId, username: data.username });
@@ -50,7 +102,7 @@ router.post('/register', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
token, token,
}); });
})); }));
router.post('/login', (0, asyncHandler_1.asyncHandler)(async (req, res) => { router.post('/login', loginLimiter, (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const data = loginSchema.parse(req.body); const data = loginSchema.parse(req.body);
const user = await db_1.db.select().from(schema_1.users).where((0, drizzle_orm_1.eq)(schema_1.users.username, data.username)).limit(1); const user = await db_1.db.select().from(schema_1.users).where((0, drizzle_orm_1.eq)(schema_1.users.username, data.username)).limit(1);
if (user.length === 0 || !verifyPassword(data.password, user[0].passwordHash)) { if (user.length === 0 || !verifyPassword(data.password, user[0].passwordHash)) {
@@ -72,5 +124,45 @@ router.get('/me', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
} }
res.json({ id: user[0].id, username: user[0].username }); res.json({ id: user[0].id, username: user[0].username });
})); }));
router.post('/forgot-password', passwordResetLimiter, (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const data = forgotPasswordSchema.parse(req.body);
const user = await db_1.db.select().from(schema_1.users).where((0, drizzle_orm_1.eq)(schema_1.users.username, data.username)).limit(1);
// Always return success to prevent username enumeration
if (user.length === 0) {
res.json({ success: true });
return;
}
const resetToken = generateResetToken();
const resetTokenExpiry = Date.now() + 3600000; // 1 hour
await db_1.db
.update(schema_1.users)
.set({ resetToken, resetTokenExpiry })
.where((0, drizzle_orm_1.eq)(schema_1.users.id, user[0].id));
// In production, send email with reset link
// For now, return token in response (dev only)
if (process.env.NODE_ENV !== 'production') {
res.json({ success: true, resetToken });
}
else {
res.json({ success: true });
}
}));
router.post('/reset-password', passwordResetLimiter, (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const data = resetPasswordSchema.parse(req.body);
const user = await db_1.db
.select()
.from(schema_1.users)
.where((0, drizzle_orm_1.eq)(schema_1.users.resetToken, data.token))
.limit(1);
if (user.length === 0 || !user[0].resetTokenExpiry || user[0].resetTokenExpiry < Date.now()) {
throw new errorHandler_1.AppError('INVALID_TOKEN', 'Invalid or expired reset token', 400);
}
const newPasswordHash = await hashPassword(data.password);
await db_1.db
.update(schema_1.users)
.set({ passwordHash: newPasswordHash, resetToken: null, resetTokenExpiry: null })
.where((0, drizzle_orm_1.eq)(schema_1.users.id, user[0].id));
res.json({ success: true });
}));
exports.default = router; exports.default = router;
//# sourceMappingURL=auth.js.map //# sourceMappingURL=auth.js.map
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"friends.d.ts","sourceRoot":"","sources":["../../src/routes/friends.ts"],"names":[],"mappings":"AAUA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAsKxB,eAAe,MAAM,CAAC"} {"version":3,"file":"friends.d.ts","sourceRoot":"","sources":["../../src/routes/friends.ts"],"names":[],"mappings":"AAUA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAuLxB,eAAe,MAAM,CAAC"}
+11
View File
@@ -53,6 +53,17 @@ router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
})); }));
res.json({ friends, incoming, outgoing }); res.json({ friends, incoming, outgoing });
})); }));
router.get('/search', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const { q } = validation_1.searchQuerySchema.parse(req.query);
const userId = req.user.userId;
const results = await db_1.db
.select({ id: schema_1.users.id, username: schema_1.users.username })
.from(schema_1.users)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.ne)(schema_1.users.id, userId), (0, drizzle_orm_1.ilike)(schema_1.users.username, `%${q}%`)))
.orderBy((0, drizzle_orm_1.asc)(schema_1.users.username))
.limit(20);
res.json(results);
}));
router.post('/requests', (0, asyncHandler_1.asyncHandler)(async (req, res) => { router.post('/requests', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const { username } = validation_1.friendRequestSchema.parse(req.body); const { username } = validation_1.friendRequestSchema.parse(req.body);
const userId = req.user.userId; const userId = req.user.userId;
+1 -1
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
declare const router: import("express-serve-static-core").Router;
export default router;
//# sourceMappingURL=repeatProfiles.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"repeatProfiles.d.ts","sourceRoot":"","sources":["../../src/routes/repeatProfiles.ts"],"names":[],"mappings":"AAUA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA+ExB,eAAe,MAAM,CAAC"}
+74
View File
@@ -0,0 +1,74 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
const asyncHandler_1 = require("../utils/asyncHandler");
const db_1 = require("../db");
const schema_1 = require("../db/schema");
const drizzle_orm_1 = require("drizzle-orm");
const auth_1 = require("../utils/auth");
const auth_2 = require("../utils/auth");
const errorHandler_1 = require("../middleware/errorHandler");
const validation_1 = require("../utils/validation");
const router = (0, express_1.Router)();
router.use(auth_1.authMiddleware);
router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const profiles = await db_1.db
.select()
.from(schema_1.repeatProfiles)
.where((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, req.user.userId))
.orderBy((0, drizzle_orm_1.asc)(schema_1.repeatProfiles.createdAt));
res.json({ profiles });
}));
router.post('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const data = validation_1.repeatProfileSchema.parse(req.body);
const userId = req.user.userId;
const now = Date.now();
const newProfile = {
id: (0, auth_2.generateId)('rp'),
userId,
name: data.name,
repeat: data.repeat,
repeatInterval: data.repeatInterval,
repeatDays: data.repeatDays,
createdAt: now,
updatedAt: now,
};
await db_1.db.insert(schema_1.repeatProfiles).values(newProfile);
res.status(201).json(newProfile);
}));
router.patch('/:id', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const data = validation_1.repeatProfileUpdateSchema.parse(req.body);
const userId = req.user.userId;
const existing = await db_1.db
.select()
.from(schema_1.repeatProfiles)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, req.params.id), (0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId)))
.limit(1);
if (existing.length === 0) {
throw new errorHandler_1.AppError('NOT_FOUND', 'Repeat profile not found', 404);
}
const now = Date.now();
const updated = await db_1.db
.update(schema_1.repeatProfiles)
.set({ ...data, updatedAt: now })
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, req.params.id), (0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId)))
.returning();
res.json(updated[0]);
}));
router.delete('/:id', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const userId = req.user.userId;
const existing = await db_1.db
.select()
.from(schema_1.repeatProfiles)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, req.params.id), (0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId)))
.limit(1);
if (existing.length === 0) {
throw new errorHandler_1.AppError('NOT_FOUND', 'Repeat profile not found', 404);
}
await db_1.db
.delete(schema_1.repeatProfiles)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, req.params.id), (0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId)));
res.status(204).send();
}));
exports.default = router;
//# sourceMappingURL=repeatProfiles.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"repeatProfiles.js","sourceRoot":"","sources":["../../src/routes/repeatProfiles.ts"],"names":[],"mappings":";;AAAA,qCAAoD;AACpD,wDAAqD;AACrD,8BAA2B;AAC3B,yCAA8C;AAC9C,6CAA2C;AAC3C,wCAA+C;AAC/C,wCAA2C;AAC3C,6DAAsD;AACtD,oDAAqF;AAErF,MAAM,MAAM,GAAG,IAAA,gBAAM,GAAE,CAAC;AAExB,MAAM,CAAC,GAAG,CAAC,qBAAc,CAAC,CAAC;AAE3B,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,IAAA,2BAAY,EAAC,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;IACjE,MAAM,QAAQ,GAAG,MAAM,OAAE;SACtB,MAAM,EAAE;SACR,IAAI,CAAC,uBAAc,CAAC;SACpB,KAAK,CAAC,IAAA,gBAAE,EAAC,uBAAc,CAAC,MAAM,EAAE,GAAG,CAAC,IAAK,CAAC,MAAM,CAAC,CAAC;SAClD,OAAO,CAAC,IAAA,iBAAG,EAAC,uBAAc,CAAC,SAAS,CAAC,CAAC,CAAC;IAE1C,GAAG,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC;AACzB,CAAC,CAAC,CAAC,CAAC;AAEJ,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,IAAA,2BAAY,EAAC,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;IAClE,MAAM,IAAI,GAAG,gCAAmB,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IACjD,MAAM,MAAM,GAAG,GAAG,CAAC,IAAK,CAAC,MAAM,CAAC;IAChC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAEvB,MAAM,UAAU,GAAG;QACjB,EAAE,EAAE,IAAA,iBAAU,EAAC,IAAI,CAAC;QACpB,MAAM;QACN,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,MAAM,EAAE,IAAI,CAAC,MAAM;QACnB,cAAc,EAAE,IAAI,CAAC,cAAc;QACnC,UAAU,EAAE,IAAI,CAAC,UAAU;QAC3B,SAAS,EAAE,GAAG;QACd,SAAS,EAAE,GAAG;KACf,CAAC;IAEF,MAAM,OAAE,CAAC,MAAM,CAAC,uBAAc,CAAC,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;IAEnD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;AACnC,CAAC,CAAC,CAAC,CAAC;AAEJ,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,IAAA,2BAAY,EAAC,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;IACtE,MAAM,IAAI,GAAG,sCAAyB,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IACvD,MAAM,MAAM,GAAG,GAAG,CAAC,IAAK,CAAC,MAAM,CAAC;IAEhC,MAAM,QAAQ,GAAG,MAAM,OAAE;SACtB,MAAM,EAAE;SACR,IAAI,CAAC,uBAAc,CAAC;SACpB,KAAK,CAAC,IAAA,iBAAG,EAAC,IAAA,gBAAE,EAAC,uBAAc,CAAC,EAAE,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,IAAA,gBAAE,EAAC,uBAAc,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;SACnF,KAAK,CAAC,CAAC,CAAC,CAAC;IAEZ,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC1B,MAAM,IAAI,uBAAQ,CAAC,WAAW,EAAE,0BAA0B,EAAE,GAAG,CAAC,CAAC;IACnE,CAAC;IAED,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IACvB,MAAM,OAAO,GAAG,MAAM,OAAE;SACrB,MAAM,CAAC,uBAAc,CAAC;SACtB,GAAG,CAAC,EAAE,GAAG,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE,CAAC;SAChC,KAAK,CAAC,IAAA,iBAAG,EAAC,IAAA,gBAAE,EAAC,uBAAc,CAAC,EAAE,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,IAAA,gBAAE,EAAC,uBAAc,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;SACnF,SAAS,EAAE,CAAC;IAEf,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC;AACvB,CAAC,CAAC,CAAC,CAAC;AAEJ,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,IAAA,2BAAY,EAAC,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;IACvE,MAAM,MAAM,GAAG,GAAG,CAAC,IAAK,CAAC,MAAM,CAAC;IAEhC,MAAM,QAAQ,GAAG,MAAM,OAAE;SACtB,MAAM,EAAE;SACR,IAAI,CAAC,uBAAc,CAAC;SACpB,KAAK,CAAC,IAAA,iBAAG,EAAC,IAAA,gBAAE,EAAC,uBAAc,CAAC,EAAE,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,IAAA,gBAAE,EAAC,uBAAc,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;SACnF,KAAK,CAAC,CAAC,CAAC,CAAC;IAEZ,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC1B,MAAM,IAAI,uBAAQ,CAAC,WAAW,EAAE,0BAA0B,EAAE,GAAG,CAAC,CAAC;IACnE,CAAC;IAED,MAAM,OAAE;SACL,MAAM,CAAC,uBAAc,CAAC;SACtB,KAAK,CAAC,IAAA,iBAAG,EAAC,IAAA,gBAAE,EAAC,uBAAc,CAAC,EAAE,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,IAAA,gBAAE,EAAC,uBAAc,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC;IAEvF,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC;AACzB,CAAC,CAAC,CAAC,CAAC;AAEJ,kBAAe,MAAM,CAAC"}
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"subtasks.d.ts","sourceRoot":"","sources":["../../src/routes/subtasks.ts"],"names":[],"mappings":"AASA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAmIxB,eAAe,MAAM,CAAC"} {"version":3,"file":"subtasks.d.ts","sourceRoot":"","sources":["../../src/routes/subtasks.ts"],"names":[],"mappings":"AASA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAoKxB,eAAe,MAAM,CAAC"}
+28 -2
View File
@@ -10,6 +10,16 @@ const errorHandler_1 = require("../middleware/errorHandler");
const validation_1 = require("../utils/validation"); const validation_1 = require("../utils/validation");
const router = (0, express_1.Router)(); const router = (0, express_1.Router)();
router.use(auth_1.authMiddleware); router.use(auth_1.authMiddleware);
// Helper to build nested subtask tree
const buildSubtaskTree = (allSubtasks, parentId = null) => {
return allSubtasks
.filter((s) => s.parentSubtaskId === parentId)
.sort((a, b) => a.order - b.order)
.map((s) => ({
...s,
subtasks: buildSubtaskTree(allSubtasks, s.id),
}));
};
router.get('/task/:taskId', (0, asyncHandler_1.asyncHandler)(async (req, res) => { router.get('/task/:taskId', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const userId = req.user.userId; const userId = req.user.userId;
// Verify task exists and belongs to user // Verify task exists and belongs to user
@@ -26,7 +36,8 @@ router.get('/task/:taskId', (0, asyncHandler_1.asyncHandler)(async (req, res) =>
.from(schema_1.subtasks) .from(schema_1.subtasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.taskId, req.params.taskId), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId))) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.taskId, req.params.taskId), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)))
.orderBy((0, drizzle_orm_1.asc)(schema_1.subtasks.order)); .orderBy((0, drizzle_orm_1.asc)(schema_1.subtasks.order));
res.json({ subtasks: taskSubtasks }); const nestedSubtasks = buildSubtaskTree(taskSubtasks);
res.json({ subtasks: nestedSubtasks });
})); }));
router.post('/task/:taskId', (0, asyncHandler_1.asyncHandler)(async (req, res) => { router.post('/task/:taskId', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const data = validation_1.subtaskCreateSchema.parse(req.body); const data = validation_1.subtaskCreateSchema.parse(req.body);
@@ -40,10 +51,11 @@ router.post('/task/:taskId', (0, asyncHandler_1.asyncHandler)(async (req, res) =
throw new errorHandler_1.AppError('NOT_FOUND', 'Task not found', 404); throw new errorHandler_1.AppError('NOT_FOUND', 'Task not found', 404);
} }
const now = Date.now(); const now = Date.now();
const parentSubtaskId = data.parentSubtaskId || null;
const maxOrder = await db_1.db const maxOrder = await db_1.db
.select({ order: schema_1.subtasks.order }) .select({ order: schema_1.subtasks.order })
.from(schema_1.subtasks) .from(schema_1.subtasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.taskId, req.params.taskId), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId))) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.taskId, req.params.taskId), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId), parentSubtaskId ? (0, drizzle_orm_1.eq)(schema_1.subtasks.parentSubtaskId, parentSubtaskId) : (0, drizzle_orm_1.isNull)(schema_1.subtasks.parentSubtaskId)))
.orderBy((0, drizzle_orm_1.desc)(schema_1.subtasks.order)) .orderBy((0, drizzle_orm_1.desc)(schema_1.subtasks.order))
.limit(1); .limit(1);
const subtaskId = `sub_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; const subtaskId = `sub_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
@@ -51,8 +63,22 @@ router.post('/task/:taskId', (0, asyncHandler_1.asyncHandler)(async (req, res) =
id: subtaskId, id: subtaskId,
userId, userId,
taskId: req.params.taskId, taskId: req.params.taskId,
parentSubtaskId,
title: data.title, title: data.title,
description: data.description ?? '',
priority: data.priority ?? 'none',
completed: false, completed: false,
dueDate: data.dueDate ?? 0,
dueTime: data.dueTime ?? '',
endTime: data.endTime ?? '',
allDay: data.allDay ?? false,
repeat: data.repeat ?? 'none',
repeatInterval: data.repeatInterval ?? 1,
repeatDays: data.repeatDays ?? '',
seriesId: data.seriesId ?? '',
reminder: data.reminder ?? 'none',
reminders: data.reminders ?? '',
assigneeId: data.assigneeId ?? null,
order: data.order ?? (maxOrder[0]?.order ?? -1) + 1, order: data.order ?? (maxOrder[0]?.order ?? -1) + 1,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../../src/routes/sync.ts"],"names":[],"mappings":"AASA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA8SxB,eAAe,MAAM,CAAC"} {"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../../src/routes/sync.ts"],"names":[],"mappings":"AASA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA2lBxB,eAAe,MAAM,CAAC"}
+281 -5
View File
@@ -27,9 +27,8 @@ router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId), (0, drizzle_orm_1.gte)(schema_1.tasks.updatedAt, sinceDate))) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId), (0, drizzle_orm_1.gte)(schema_1.tasks.updatedAt, sinceDate)))
.orderBy((0, drizzle_orm_1.asc)(schema_1.tasks.updatedAt)); .orderBy((0, drizzle_orm_1.asc)(schema_1.tasks.updatedAt));
// Fetch subtasks changed since timestamp // Fetch subtasks changed since timestamp
const taskIds = changedTasks.map(t => t.id);
let changedSubtasks = []; let changedSubtasks = [];
if (taskIds.length > 0) { if (changedTasks.length > 0) {
changedSubtasks = await db_1.db changedSubtasks = await db_1.db
.select() .select()
.from(schema_1.subtasks) .from(schema_1.subtasks)
@@ -37,7 +36,7 @@ router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
.orderBy((0, drizzle_orm_1.asc)(schema_1.subtasks.updatedAt)); .orderBy((0, drizzle_orm_1.asc)(schema_1.subtasks.updatedAt));
} }
else { 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_1.db changedSubtasks = await db_1.db
.select() .select()
.from(schema_1.subtasks) .from(schema_1.subtasks)
@@ -56,6 +55,12 @@ router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
.from(schema_1.friendships) .from(schema_1.friendships)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.or)((0, drizzle_orm_1.eq)(schema_1.friendships.userId, userId), (0, drizzle_orm_1.eq)(schema_1.friendships.friendId, userId)), (0, drizzle_orm_1.gte)(schema_1.friendships.updatedAt, sinceDate))) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.or)((0, drizzle_orm_1.eq)(schema_1.friendships.userId, userId), (0, drizzle_orm_1.eq)(schema_1.friendships.friendId, userId)), (0, drizzle_orm_1.gte)(schema_1.friendships.updatedAt, sinceDate)))
.orderBy((0, drizzle_orm_1.asc)(schema_1.friendships.updatedAt)); .orderBy((0, drizzle_orm_1.asc)(schema_1.friendships.updatedAt));
// Fetch tombstones (deletions) changed since timestamp
const changedTombstones = await db_1.db
.select()
.from(schema_1.tombstones)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tombstones.userId, userId), (0, drizzle_orm_1.gte)(schema_1.tombstones.updatedAt, sinceDate)))
.orderBy((0, drizzle_orm_1.asc)(schema_1.tombstones.updatedAt));
const timestamp = Date.now(); const timestamp = Date.now();
res.json({ res.json({
categories: changedCategories, categories: changedCategories,
@@ -63,6 +68,7 @@ router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
subtasks: changedSubtasks, subtasks: changedSubtasks,
repeatProfiles: changedRepeatProfiles, repeatProfiles: changedRepeatProfiles,
friendships: changedFriendships, friendships: changedFriendships,
deleted: changedTombstones.map((t) => ({ entity: t.entity, id: t.entityId, updatedAt: t.updatedAt })),
timestamp, timestamp,
}); });
})); }));
@@ -73,6 +79,65 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const timestamp = Date.now(); const timestamp = Date.now();
try { try {
await db_1.db.transaction(async (tx) => { await db_1.db.transaction(async (tx) => {
// Ensure every referenced category exists (FK integrity) so a stale or
// never-synced category reference cannot fail the entire push. Missing
// categories are recreated as a fallback and the client heals on pull.
const referencedCategoryIds = new Set();
for (const task of data.changes.tasks ?? []) {
if (task.categoryId)
referencedCategoryIds.add(task.categoryId);
}
for (const sub of data.changes.subtasks ?? []) {
const task = (data.changes.tasks ?? []).find((t) => t.id === sub.taskId);
if (task?.categoryId)
referencedCategoryIds.add(task.categoryId);
}
if (referencedCategoryIds.size > 0) {
const existing = await tx
.select({ id: schema_1.categories.id })
.from(schema_1.categories)
.where((0, drizzle_orm_1.inArray)(schema_1.categories.id, [...referencedCategoryIds]));
const existingIds = new Set(existing.map((c) => c.id));
const missing = [...referencedCategoryIds].filter((id) => !existingIds.has(id));
if (missing.length > 0) {
const rows = await tx
.select({ max: (0, drizzle_orm_1.sql) `max(${schema_1.categories.order})` })
.from(schema_1.categories)
.where((0, drizzle_orm_1.eq)(schema_1.categories.userId, userId));
const startOrder = (rows[0]?.max ?? -1) + 1;
await tx.insert(schema_1.categories).values(missing.map((id, i) => ({
id,
userId,
name: 'Default',
color: '#9E9E9E',
order: startOrder + i,
createdAt: Date.now(),
updatedAt: Date.now(),
})));
}
}
// 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();
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();
if (assigneeIds.size > 0) {
const rows = await tx
.select({ id: schema_1.users.id })
.from(schema_1.users)
.where((0, drizzle_orm_1.inArray)(schema_1.users.id, [...assigneeIds]));
for (const r of rows)
validAssignees.add(r.id);
}
const sanitizeAssignee = (a) => a && validAssignees.has(a) ? a : null;
// Process categories // Process categories
if (data.changes.categories && data.changes.categories.length > 0) { if (data.changes.categories && data.changes.categories.length > 0) {
for (const cat of data.changes.categories) { for (const cat of data.changes.categories) {
@@ -131,6 +196,20 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
}); });
continue; // Server wins continue; // Server wins
} }
// Prevent completing tasks with future due dates
if (task.completed === true) {
const effectiveDueDate = task.dueDate ?? existing[0].dueDate;
if (!(0, validation_1.canCompleteTask)(effectiveDueDate)) {
conflicts.push({
entity: 'tasks',
id: task.id,
serverVersion: existing[0],
clientVersion: task,
resolution: 'server_wins',
});
continue;
}
}
await tx await tx
.update(schema_1.tasks) .update(schema_1.tasks)
.set({ .set({
@@ -140,14 +219,17 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
priority: task.priority, priority: task.priority,
completed: task.completed, completed: task.completed,
dueDate: task.dueDate, dueDate: task.dueDate,
dueTime: task.dueTime, dueTime: task.dueTime ?? '',
endTime: task.endTime ?? '', endTime: task.endTime ?? '',
allDay: task.allDay ?? false,
repeat: task.repeat ?? 'none', repeat: task.repeat ?? 'none',
repeatInterval: task.repeatInterval ?? 1, repeatInterval: task.repeatInterval ?? 1,
repeatDays: task.repeatDays ?? '', repeatDays: task.repeatDays ?? '',
seriesId: task.seriesId ?? '', seriesId: task.seriesId ?? '',
reminder: task.reminder ?? 'none', reminder: task.reminder ?? 'none',
assigneeId: task.assigneeId ?? null, reminders: task.reminders ?? '',
assigneeId: sanitizeAssignee(task.assigneeId),
completedAt: task.completedAt ?? null,
updatedAt: task.updatedAt, updatedAt: task.updatedAt,
}) })
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, task.id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId))); .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, task.id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)));
@@ -155,6 +237,8 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
else { else {
await tx.insert(schema_1.tasks).values({ await tx.insert(schema_1.tasks).values({
...task, ...task,
assigneeId: sanitizeAssignee(task.assigneeId),
allDay: task.allDay ?? false,
userId, userId,
}); });
} }
@@ -184,8 +268,22 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
.update(schema_1.subtasks) .update(schema_1.subtasks)
.set({ .set({
taskId: sub.taskId, taskId: sub.taskId,
parentSubtaskId: sub.parentSubtaskId ?? null,
title: sub.title, title: sub.title,
description: sub.description ?? '',
priority: sub.priority ?? 'none',
completed: sub.completed, completed: sub.completed,
dueDate: sub.dueDate ?? 0,
dueTime: sub.dueTime ?? '',
endTime: sub.endTime ?? '',
allDay: sub.allDay ?? false,
repeat: sub.repeat ?? 'none',
repeatInterval: sub.repeatInterval ?? 1,
repeatDays: sub.repeatDays ?? '',
seriesId: sub.seriesId ?? '',
reminder: sub.reminder ?? 'none',
reminders: sub.reminders ?? '',
assigneeId: sanitizeAssignee(sub.assigneeId),
order: sub.order, order: sub.order,
updatedAt: sub.updatedAt, updatedAt: sub.updatedAt,
}) })
@@ -194,6 +292,7 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
else { else {
await tx.insert(schema_1.subtasks).values({ await tx.insert(schema_1.subtasks).values({
...sub, ...sub,
assigneeId: sanitizeAssignee(sub.assigneeId),
userId, userId,
}); });
} }
@@ -273,6 +372,13 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
} }
} }
} }
// 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) { catch (error) {
@@ -285,5 +391,175 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
conflicts, conflicts,
}); });
})); }));
// Upsert a tombstone row, keeping the latest updatedAt.
async function upsertTombstone(tx, entity, entityId, updatedAt, userId) {
const existing = await tx
.select({ updatedAt: schema_1.tombstones.updatedAt })
.from(schema_1.tombstones)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tombstones.entity, entity), (0, drizzle_orm_1.eq)(schema_1.tombstones.entityId, entityId)))
.limit(1);
const merged = Math.max(existing[0]?.updatedAt ?? 0, updatedAt);
if (existing.length > 0) {
await tx
.update(schema_1.tombstones)
.set({ updatedAt: merged })
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tombstones.entity, entity), (0, drizzle_orm_1.eq)(schema_1.tombstones.entityId, entityId)));
}
else {
await tx.insert(schema_1.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, entity, id, deletedAt, userId, conflicts) {
const tombstoneOf = (e, ids) => ids.forEach((i) => upsertTombstone(tx, e, i, deletedAt, userId));
if (entity === 'tasks') {
const row = await tx
.select()
.from(schema_1.tasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, id), (0, drizzle_orm_1.eq)(schema_1.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: schema_1.subtasks.id })
.from(schema_1.subtasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.taskId, id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)));
tombstoneOf('subtasks', children.map((c) => c.id));
await tx.delete(schema_1.tasks).where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)));
await upsertTombstone(tx, entity, id, deletedAt, userId);
return;
}
if (entity === 'categories') {
const row = await tx
.select()
.from(schema_1.categories)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, id), (0, drizzle_orm_1.eq)(schema_1.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(schema_1.tasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.categoryId, id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)));
for (const taskRow of catTasks) {
const subIds = await tx
.select({ id: schema_1.subtasks.id })
.from(schema_1.subtasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.taskId, taskRow.id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)));
tombstoneOf('subtasks', subIds.map((s) => s.id));
tombstoneOf('tasks', [taskRow.id]);
}
await tx.delete(schema_1.categories).where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, id), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId)));
await upsertTombstone(tx, entity, id, deletedAt, userId);
return;
}
if (entity === 'subtasks') {
const row = await tx
.select()
.from(schema_1.subtasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.id, id), (0, drizzle_orm_1.eq)(schema_1.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: schema_1.subtasks.id })
.from(schema_1.subtasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.parentSubtaskId, id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)));
tombstoneOf('subtasks', children.map((c) => c.id));
await tx.delete(schema_1.subtasks).where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.id, id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)));
await upsertTombstone(tx, entity, id, deletedAt, userId);
return;
}
if (entity === 'repeatProfiles') {
const row = await tx
.select()
.from(schema_1.repeatProfiles)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, id), (0, drizzle_orm_1.eq)(schema_1.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(schema_1.repeatProfiles).where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, id), (0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId)));
await upsertTombstone(tx, entity, id, deletedAt, userId);
return;
}
if (entity === 'friendships') {
const row = await tx
.select()
.from(schema_1.friendships)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.friendships.id, id), (0, drizzle_orm_1.or)((0, drizzle_orm_1.eq)(schema_1.friendships.userId, userId), (0, drizzle_orm_1.eq)(schema_1.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(schema_1.friendships).where((0, drizzle_orm_1.eq)(schema_1.friendships.id, id));
await upsertTombstone(tx, entity, id, deletedAt, userId);
return;
}
}
exports.default = router; exports.default = router;
//# sourceMappingURL=sync.js.map //# sourceMappingURL=sync.js.map
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"tasks.d.ts","sourceRoot":"","sources":["../../src/routes/tasks.ts"],"names":[],"mappings":"AAUA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA8RxB,eAAe,MAAM,CAAC"} {"version":3,"file":"tasks.d.ts","sourceRoot":"","sources":["../../src/routes/tasks.ts"],"names":[],"mappings":"AAUA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA+TxB,eAAe,MAAM,CAAC"}
+48 -15
View File
@@ -97,14 +97,16 @@ router.post('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const data = validation_1.taskCreateSchema.parse(req.body); const data = validation_1.taskCreateSchema.parse(req.body);
const userId = req.user.userId; const userId = req.user.userId;
const now = Date.now(); const now = Date.now();
// Verify category exists and belongs to user // Verify category exists and belongs to user (optional - tasks may be uncategorized)
const cat = await db_1.db if (data.categoryId) {
.select() const cat = await db_1.db
.from(schema_1.categories) .select()
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, data.categoryId), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId))) .from(schema_1.categories)
.limit(1); .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, data.categoryId), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId)))
if (cat.length === 0) { .limit(1);
throw new errorHandler_1.AppError('NOT_FOUND', 'Category not found', 404); if (cat.length === 0) {
throw new errorHandler_1.AppError('NOT_FOUND', 'Category not found', 404);
}
} }
const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const newTask = { const newTask = {
@@ -118,7 +120,10 @@ router.post('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
dueDate: data.dueDate ?? 0, dueDate: data.dueDate ?? 0,
dueTime: data.dueTime ?? '', dueTime: data.dueTime ?? '',
endTime: data.endTime ?? '', endTime: data.endTime ?? '',
allDay: data.allDay ?? false,
assigneeId: data.assigneeId ?? null, assigneeId: data.assigneeId ?? null,
reminder: data.reminder ?? 'none',
reminders: data.reminders ?? '',
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
@@ -166,6 +171,13 @@ router.patch('/:id', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
throw new errorHandler_1.AppError('NOT_FOUND', 'Category not found', 404); throw new errorHandler_1.AppError('NOT_FOUND', 'Category not found', 404);
} }
} }
// Prevent completing tasks with future due dates
if (data.completed === true) {
const effectiveDueDate = data.dueDate ?? existing[0].dueDate;
if (!(0, validation_1.canCompleteTask)(effectiveDueDate)) {
throw new errorHandler_1.AppError('VALIDATION_ERROR', 'Cannot mark a task as completed if its due date is in the future', 400);
}
}
const now = Date.now(); const now = Date.now();
const updated = await db_1.db const updated = await db_1.db
.update(schema_1.tasks) .update(schema_1.tasks)
@@ -207,13 +219,15 @@ router.post('/batch', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
for (const op of operations) { for (const op of operations) {
try { try {
if (op.type === 'create') { if (op.type === 'create') {
const cat = await db_1.db if (op.data.categoryId) {
.select() const cat = await db_1.db
.from(schema_1.categories) .select()
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, op.data.categoryId), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId))) .from(schema_1.categories)
.limit(1); .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, op.data.categoryId), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId)))
if (cat.length === 0) .limit(1);
throw new errorHandler_1.AppError('NOT_FOUND', 'Category not found', 404); if (cat.length === 0)
throw new errorHandler_1.AppError('NOT_FOUND', 'Category not found', 404);
}
const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const now = Date.now(); const now = Date.now();
await db_1.db.insert(schema_1.tasks).values({ await db_1.db.insert(schema_1.tasks).values({
@@ -227,6 +241,25 @@ router.post('/batch', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
results.push({ id: taskId, success: true }); results.push({ id: taskId, success: true });
} }
else if (op.type === 'update') { else if (op.type === 'update') {
if (op.data.completed === true) {
let effectiveDueDate;
if (op.data.dueDate !== undefined) {
effectiveDueDate = op.data.dueDate;
}
else {
const existingTask = await db_1.db
.select({ dueDate: schema_1.tasks.dueDate })
.from(schema_1.tasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, op.id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)))
.limit(1);
if (existingTask.length === 0)
throw new errorHandler_1.AppError('NOT_FOUND', 'Task not found', 404);
effectiveDueDate = existingTask[0].dueDate;
}
if (!(0, validation_1.canCompleteTask)(effectiveDueDate)) {
throw new errorHandler_1.AppError('VALIDATION_ERROR', 'Cannot mark a task as completed if its due date is in the future', 400);
}
}
await db_1.db await db_1.db
.update(schema_1.tasks) .update(schema_1.tasks)
.set({ ...op.data, updatedAt: Date.now() }) .set({ ...op.data, updatedAt: Date.now() })
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from 'express';
import { AuthPayload } from '../types'; import { AuthPayload } from '../types';
export declare function generateToken(payload: AuthPayload): string; export declare function generateToken(payload: AuthPayload): string;
export declare function verifyToken(token: string): AuthPayload | null; export declare function verifyToken(token: string): AuthPayload | null;
export declare function authMiddleware(req: Request, res: Response, next: NextFunction): void; export declare function authMiddleware(req: Request, res: Response, next: NextFunction): Promise<void>;
export declare function optionalAuthMiddleware(req: Request, res: Response, next: NextFunction): void; export declare function optionalAuthMiddleware(req: Request, res: Response, next: NextFunction): void;
export declare function generateId(prefix?: string): string; export declare function generateId(prefix?: string): string;
export declare function getCurrentTimestamp(): number; export declare function getCurrentTimestamp(): number;
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/utils/auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE1D,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAKvC,wBAAgB,aAAa,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,CAE1D;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAM7D;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,GAAG,IAAI,CA4BpF;AAED,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,GAAG,IAAI,CAW5F;AAED,wBAAgB,UAAU,CAAC,MAAM,GAAE,MAAW,GAAG,MAAM,CAItD;AAED,wBAAgB,mBAAmB,IAAI,MAAM,CAE5C"} {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/utils/auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAK1D,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAKvC,wBAAgB,aAAa,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,CAE1D;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAM7D;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAuCnG;AAED,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,GAAG,IAAI,CAW5F;AAED,wBAAgB,UAAU,CAAC,MAAM,GAAE,MAAW,GAAG,MAAM,CAItD;AAED,wBAAgB,mBAAmB,IAAI,MAAM,CAE5C"}
+14 -1
View File
@@ -10,6 +10,9 @@ exports.optionalAuthMiddleware = optionalAuthMiddleware;
exports.generateId = generateId; exports.generateId = generateId;
exports.getCurrentTimestamp = getCurrentTimestamp; exports.getCurrentTimestamp = getCurrentTimestamp;
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken")); const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
const db_1 = require("../db");
const schema_1 = require("../db/schema");
const drizzle_orm_1 = require("drizzle-orm");
const JWT_SECRET = process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-in-production'; const JWT_SECRET = process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-in-production';
const JWT_EXPIRES_IN = '7d'; const JWT_EXPIRES_IN = '7d';
function generateToken(payload) { function generateToken(payload) {
@@ -23,7 +26,7 @@ function verifyToken(token) {
return null; return null;
} }
} }
function authMiddleware(req, res, next) { async function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) { if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({ res.status(401).json({
@@ -45,6 +48,16 @@ function authMiddleware(req, res, next) {
}); });
return; return;
} }
const user = await db_1.db.select({ id: schema_1.users.id }).from(schema_1.users).where((0, drizzle_orm_1.eq)(schema_1.users.id, payload.userId)).limit(1);
if (user.length === 0) {
res.status(401).json({
error: {
code: 'UNAUTHORIZED',
message: 'User no longer exists, please log in again',
},
});
return;
}
req.user = payload; req.user = payload;
next(); next();
} }
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"auth.js","sourceRoot":"","sources":["../../src/utils/auth.ts"],"names":[],"mappings":";;;;;AAOA,sCAEC;AAED,kCAMC;AAED,wCA4BC;AAED,wDAWC;AAED,gCAIC;AAED,kDAEC;AArED,gEAA+B;AAG/B,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,gDAAgD,CAAC;AAC9F,MAAM,cAAc,GAAG,IAAI,CAAC;AAE5B,SAAgB,aAAa,CAAC,OAAoB;IAChD,OAAO,sBAAG,CAAC,IAAI,CAAC,OAAO,EAAE,UAAU,EAAE,EAAE,SAAS,EAAE,cAAc,EAAE,CAAC,CAAC;AACtE,CAAC;AAED,SAAgB,WAAW,CAAC,KAAa;IACvC,IAAI,CAAC;QACH,OAAO,sBAAG,CAAC,MAAM,CAAC,KAAK,EAAE,UAAU,CAAgB,CAAC;IACtD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,SAAgB,cAAc,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB;IAC5E,MAAM,UAAU,GAAG,GAAG,CAAC,OAAO,CAAC,aAAa,CAAC;IAE7C,IAAI,CAAC,UAAU,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;QACrD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;YACnB,KAAK,EAAE;gBACL,IAAI,EAAE,cAAc;gBACpB,OAAO,EAAE,yCAAyC;aACnD;SACF,CAAC,CAAC;QACH,OAAO;IACT,CAAC;IAED,MAAM,KAAK,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAClC,MAAM,OAAO,GAAG,WAAW,CAAC,KAAK,CAAC,CAAC;IAEnC,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;YACnB,KAAK,EAAE;gBACL,IAAI,EAAE,cAAc;gBACpB,OAAO,EAAE,0BAA0B;aACpC;SACF,CAAC,CAAC;QACH,OAAO;IACT,CAAC;IAED,GAAG,CAAC,IAAI,GAAG,OAAO,CAAC;IACnB,IAAI,EAAE,CAAC;AACT,CAAC;AAED,SAAgB,sBAAsB,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB;IACpF,MAAM,UAAU,GAAG,GAAG,CAAC,OAAO,CAAC,aAAa,CAAC;IAE7C,IAAI,UAAU,IAAI,UAAU,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;QACnD,MAAM,KAAK,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QAClC,MAAM,OAAO,GAAG,WAAW,CAAC,KAAK,CAAC,CAAC;QACnC,IAAI,OAAO,EAAE,CAAC;YACZ,GAAG,CAAC,IAAI,GAAG,OAAO,CAAC;QACrB,CAAC;IACH,CAAC;IACD,IAAI,EAAE,CAAC;AACT,CAAC;AAED,SAAgB,UAAU,CAAC,SAAiB,EAAE;IAC5C,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;IAC1C,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IACvD,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,SAAS,GAAG,MAAM,EAAE,CAAC;AAC9D,CAAC;AAED,SAAgB,mBAAmB;IACjC,OAAO,IAAI,CAAC,GAAG,EAAE,CAAC;AACpB,CAAC"} {"version":3,"file":"auth.js","sourceRoot":"","sources":["../../src/utils/auth.ts"],"names":[],"mappings":";;;;;AAUA,sCAEC;AAED,kCAMC;AAED,wCAuCC;AAED,wDAWC;AAED,gCAIC;AAED,kDAEC;AAnFD,gEAA+B;AAC/B,8BAA2B;AAC3B,yCAAqC;AACrC,6CAAiC;AAGjC,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,gDAAgD,CAAC;AAC9F,MAAM,cAAc,GAAG,IAAI,CAAC;AAE5B,SAAgB,aAAa,CAAC,OAAoB;IAChD,OAAO,sBAAG,CAAC,IAAI,CAAC,OAAO,EAAE,UAAU,EAAE,EAAE,SAAS,EAAE,cAAc,EAAE,CAAC,CAAC;AACtE,CAAC;AAED,SAAgB,WAAW,CAAC,KAAa;IACvC,IAAI,CAAC;QACH,OAAO,sBAAG,CAAC,MAAM,CAAC,KAAK,EAAE,UAAU,CAAgB,CAAC;IACtD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAEM,KAAK,UAAU,cAAc,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB;IAClF,MAAM,UAAU,GAAG,GAAG,CAAC,OAAO,CAAC,aAAa,CAAC;IAE7C,IAAI,CAAC,UAAU,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;QACrD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;YACnB,KAAK,EAAE;gBACL,IAAI,EAAE,cAAc;gBACpB,OAAO,EAAE,yCAAyC;aACnD;SACF,CAAC,CAAC;QACH,OAAO;IACT,CAAC;IAED,MAAM,KAAK,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAClC,MAAM,OAAO,GAAG,WAAW,CAAC,KAAK,CAAC,CAAC;IAEnC,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;YACnB,KAAK,EAAE;gBACL,IAAI,EAAE,cAAc;gBACpB,OAAO,EAAE,0BAA0B;aACpC;SACF,CAAC,CAAC;QACH,OAAO;IACT,CAAC;IAED,MAAM,IAAI,GAAG,MAAM,OAAE,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,cAAK,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,cAAK,CAAC,CAAC,KAAK,CAAC,IAAA,gBAAE,EAAC,cAAK,CAAC,EAAE,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IACxG,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACtB,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;YACnB,KAAK,EAAE;gBACL,IAAI,EAAE,cAAc;gBACpB,OAAO,EAAE,4CAA4C;aACtD;SACF,CAAC,CAAC;QACH,OAAO;IACT,CAAC;IAED,GAAG,CAAC,IAAI,GAAG,OAAO,CAAC;IACnB,IAAI,EAAE,CAAC;AACT,CAAC;AAED,SAAgB,sBAAsB,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB;IACpF,MAAM,UAAU,GAAG,GAAG,CAAC,OAAO,CAAC,aAAa,CAAC;IAE7C,IAAI,UAAU,IAAI,UAAU,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;QACnD,MAAM,KAAK,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QAClC,MAAM,OAAO,GAAG,WAAW,CAAC,KAAK,CAAC,CAAC;QACnC,IAAI,OAAO,EAAE,CAAC;YACZ,GAAG,CAAC,IAAI,GAAG,OAAO,CAAC;QACrB,CAAC;IACH,CAAC;IACD,IAAI,EAAE,CAAC;AACT,CAAC;AAED,SAAgB,UAAU,CAAC,SAAiB,EAAE;IAC5C,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;IAC1C,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IACvD,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,SAAS,GAAG,MAAM,EAAE,CAAC;AAC9D,CAAC;AAED,SAAgB,mBAAmB;IACjC,OAAO,IAAI,CAAC,GAAG,EAAE,CAAC;AACpB,CAAC"}
+370 -90
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
export declare function canCompleteTask(dueDate: number): boolean;
export declare const categoryCreateSchema: z.ZodObject<{ export declare const categoryCreateSchema: z.ZodObject<{
name: z.ZodString; name: z.ZodString;
color: z.ZodString; color: z.ZodString;
@@ -26,20 +27,25 @@ export declare const categoryUpdateSchema: z.ZodObject<{
order?: number | undefined; order?: number | undefined;
}>; }>;
export declare const repeatSchema: z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>; export declare const repeatSchema: z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>;
export declare const repeatFieldSchema: z.ZodOptional<z.ZodEffects<z.ZodUnion<[z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>, z.ZodLiteral<"">]>, "custom" | "none" | "daily" | "weekly" | "monthly", "" | "custom" | "none" | "daily" | "weekly" | "monthly">>;
export declare const repeatIntervalFieldSchema: z.ZodOptional<z.ZodEffects<z.ZodNumber, number, number>>;
export declare const taskCreateSchema: z.ZodObject<{ export declare const taskCreateSchema: z.ZodObject<{
title: z.ZodString; title: z.ZodString;
description: z.ZodOptional<z.ZodString>; description: z.ZodOptional<z.ZodString>;
categoryId: z.ZodString; categoryId: z.ZodNullable<z.ZodEffects<z.ZodString, string | null, string>>;
priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>; priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>;
dueDate: z.ZodOptional<z.ZodNumber>; dueDate: z.ZodOptional<z.ZodNumber>;
dueTime: z.ZodNullable<z.ZodOptional<z.ZodString>>; dueTime: z.ZodEffects<z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>, string, string | undefined>;
endTime: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>; endTime: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
repeat: z.ZodOptional<z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>>; allDay: z.ZodOptional<z.ZodBoolean>;
repeatInterval: z.ZodOptional<z.ZodNumber>; repeat: z.ZodOptional<z.ZodEffects<z.ZodUnion<[z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>, z.ZodLiteral<"">]>, "custom" | "none" | "daily" | "weekly" | "monthly", "" | "custom" | "none" | "daily" | "weekly" | "monthly">>;
repeatInterval: z.ZodOptional<z.ZodEffects<z.ZodNumber, number, number>>;
repeatDays: z.ZodOptional<z.ZodString>; repeatDays: z.ZodOptional<z.ZodString>;
seriesId: z.ZodOptional<z.ZodString>; seriesId: z.ZodOptional<z.ZodString>;
reminder: z.ZodOptional<z.ZodEnum<["none", "at_time", "15", "30", "60", "120", "1440"]>>; reminder: z.ZodOptional<z.ZodEnum<["none", "at_time", "15", "30", "60", "120", "1440"]>>;
reminders: z.ZodOptional<z.ZodString>;
assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>; assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
completedAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
subtasks: z.ZodOptional<z.ZodArray<z.ZodObject<{ subtasks: z.ZodOptional<z.ZodArray<z.ZodObject<{
title: z.ZodString; title: z.ZodString;
}, "strip", z.ZodTypeAny, { }, "strip", z.ZodTypeAny, {
@@ -48,93 +54,189 @@ export declare const taskCreateSchema: z.ZodObject<{
title: string; title: string;
}>, "many">>; }>, "many">>;
}, "strip", z.ZodTypeAny, { }, "strip", z.ZodTypeAny, {
categoryId: string; categoryId: string | null;
title: string; title: string;
dueTime: string;
subtasks?: {
title: string;
}[] | undefined;
description?: string | undefined; description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined; priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined; dueDate?: number | undefined;
dueTime?: string | null | undefined;
endTime?: string | undefined; endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined; repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined; repeatInterval?: number | undefined;
repeatDays?: string | undefined; repeatDays?: string | undefined;
seriesId?: string | undefined; seriesId?: string | undefined;
assigneeId?: string | null | undefined; assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined; reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
subtasks?: { reminders?: string | undefined;
title: string; completedAt?: number | null | undefined;
}[] | undefined;
}, { }, {
categoryId: string; categoryId: string | null;
title: string; title: string;
subtasks?: {
title: string;
}[] | undefined;
description?: string | undefined; description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined; priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined; dueDate?: number | undefined;
dueTime?: string | null | undefined; dueTime?: string | undefined;
endTime?: string | undefined; endTime?: string | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined; allDay?: boolean | undefined;
repeat?: "" | "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined; repeatInterval?: number | undefined;
repeatDays?: string | undefined; repeatDays?: string | undefined;
seriesId?: string | undefined; seriesId?: string | undefined;
assigneeId?: string | null | undefined; assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined; reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
subtasks?: { reminders?: string | undefined;
title: string; completedAt?: number | null | undefined;
}[] | undefined;
}>; }>;
export declare const taskUpdateSchema: z.ZodObject<{ export declare const taskUpdateSchema: z.ZodObject<{
title: z.ZodOptional<z.ZodString>; title: z.ZodOptional<z.ZodString>;
description: z.ZodOptional<z.ZodString>; description: z.ZodOptional<z.ZodString>;
categoryId: z.ZodOptional<z.ZodString>; categoryId: z.ZodEffects<z.ZodOptional<z.ZodNullable<z.ZodString>>, string | null | undefined, string | null | undefined>;
priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>; priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>;
completed: z.ZodOptional<z.ZodBoolean>; completed: z.ZodOptional<z.ZodBoolean>;
dueDate: z.ZodOptional<z.ZodNumber>; dueDate: z.ZodOptional<z.ZodNumber>;
dueTime: z.ZodNullable<z.ZodOptional<z.ZodString>>; dueTime: z.ZodEffects<z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>, string | undefined, string | undefined>;
endTime: z.ZodOptional<z.ZodString>; endTime: z.ZodOptional<z.ZodString>;
reminders: z.ZodOptional<z.ZodString>;
assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>; assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
completedAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
}, "strip", z.ZodTypeAny, { }, "strip", z.ZodTypeAny, {
categoryId?: string | undefined; categoryId?: string | null | undefined;
title?: string | undefined; title?: string | undefined;
description?: string | undefined; description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined; priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
completed?: boolean | undefined; completed?: boolean | undefined;
dueDate?: number | undefined; dueDate?: number | undefined;
dueTime?: string | null | undefined; dueTime?: string | undefined;
endTime?: string | undefined; endTime?: string | undefined;
assigneeId?: string | null | undefined; assigneeId?: string | null | undefined;
reminders?: string | undefined;
completedAt?: number | null | undefined;
}, { }, {
categoryId?: string | undefined; categoryId?: string | null | undefined;
title?: string | undefined; title?: string | undefined;
description?: string | undefined; description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined; priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
completed?: boolean | undefined; completed?: boolean | undefined;
dueDate?: number | undefined; dueDate?: number | undefined;
dueTime?: string | null | undefined; dueTime?: string | undefined;
endTime?: string | undefined; endTime?: string | undefined;
assigneeId?: string | null | undefined; assigneeId?: string | null | undefined;
reminders?: string | undefined;
completedAt?: number | null | undefined;
}>; }>;
export declare const subtaskCreateSchema: z.ZodObject<{ export declare const subtaskCreateSchema: z.ZodObject<{
title: z.ZodString; title: z.ZodString;
description: z.ZodOptional<z.ZodString>;
priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>;
dueDate: z.ZodOptional<z.ZodNumber>;
dueTime: z.ZodEffects<z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>, string, string | undefined>;
endTime: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
allDay: z.ZodOptional<z.ZodBoolean>;
repeat: z.ZodOptional<z.ZodEffects<z.ZodUnion<[z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>, z.ZodLiteral<"">]>, "custom" | "none" | "daily" | "weekly" | "monthly", "" | "custom" | "none" | "daily" | "weekly" | "monthly">>;
repeatInterval: z.ZodOptional<z.ZodEffects<z.ZodNumber, number, number>>;
repeatDays: z.ZodOptional<z.ZodString>;
seriesId: z.ZodOptional<z.ZodString>;
reminder: z.ZodOptional<z.ZodEnum<["none", "at_time", "15", "30", "60", "120", "1440"]>>;
reminders: z.ZodOptional<z.ZodString>;
assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
order: z.ZodOptional<z.ZodNumber>; order: z.ZodOptional<z.ZodNumber>;
parentSubtaskId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, "strip", z.ZodTypeAny, { }, "strip", z.ZodTypeAny, {
title: string; title: string;
dueTime: string;
order?: number | undefined; order?: number | undefined;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined;
endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
reminders?: string | undefined;
parentSubtaskId?: string | null | undefined;
}, { }, {
title: string; title: string;
order?: number | undefined; order?: number | undefined;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined;
dueTime?: string | undefined;
endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "" | "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
reminders?: string | undefined;
parentSubtaskId?: string | null | undefined;
}>; }>;
export declare const subtaskUpdateSchema: z.ZodObject<{ export declare const subtaskUpdateSchema: z.ZodObject<{
title: z.ZodOptional<z.ZodString>; title: z.ZodOptional<z.ZodString>;
description: z.ZodOptional<z.ZodString>;
priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>;
completed: z.ZodOptional<z.ZodBoolean>; completed: z.ZodOptional<z.ZodBoolean>;
dueDate: z.ZodOptional<z.ZodNumber>;
dueTime: z.ZodEffects<z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>, string | undefined, string | undefined>;
endTime: z.ZodOptional<z.ZodString>;
allDay: z.ZodOptional<z.ZodBoolean>;
repeat: z.ZodOptional<z.ZodEffects<z.ZodUnion<[z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>, z.ZodLiteral<"">]>, "custom" | "none" | "daily" | "weekly" | "monthly", "" | "custom" | "none" | "daily" | "weekly" | "monthly">>;
repeatInterval: z.ZodOptional<z.ZodEffects<z.ZodNumber, number, number>>;
repeatDays: z.ZodOptional<z.ZodString>;
seriesId: z.ZodOptional<z.ZodString>;
reminder: z.ZodOptional<z.ZodEnum<["none", "at_time", "15", "30", "60", "120", "1440"]>>;
reminders: z.ZodOptional<z.ZodString>;
assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
order: z.ZodOptional<z.ZodNumber>; order: z.ZodOptional<z.ZodNumber>;
parentSubtaskId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, "strip", z.ZodTypeAny, { }, "strip", z.ZodTypeAny, {
order?: number | undefined; order?: number | undefined;
title?: string | undefined; title?: string | undefined;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
completed?: boolean | undefined; completed?: boolean | undefined;
dueDate?: number | undefined;
dueTime?: string | undefined;
endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
reminders?: string | undefined;
parentSubtaskId?: string | null | undefined;
}, { }, {
order?: number | undefined; order?: number | undefined;
title?: string | undefined; title?: string | undefined;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
completed?: boolean | undefined; completed?: boolean | undefined;
dueDate?: number | undefined;
dueTime?: string | undefined;
endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "" | "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
reminders?: string | undefined;
parentSubtaskId?: string | null | undefined;
}>; }>;
export declare const userSettingsSchema: z.ZodObject<{ export declare const userSettingsSchema: z.ZodObject<{
darkMode: z.ZodOptional<z.ZodBoolean>; darkMode: z.ZodOptional<z.ZodBoolean>;
@@ -174,6 +276,29 @@ export declare const repeatProfileSchema: z.ZodObject<{
repeatInterval: number; repeatInterval: number;
repeatDays: string; repeatDays: string;
}>; }>;
export declare const repeatProfileUpdateSchema: z.ZodObject<{
name: z.ZodOptional<z.ZodString>;
repeat: z.ZodOptional<z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>>;
repeatInterval: z.ZodOptional<z.ZodNumber>;
repeatDays: z.ZodOptional<z.ZodString>;
}, "strip", z.ZodTypeAny, {
name?: string | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
}, {
name?: string | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
}>;
export declare const searchQuerySchema: z.ZodObject<{
q: z.ZodString;
}, "strip", z.ZodTypeAny, {
q: string;
}, {
q: string;
}>;
export declare const friendRequestSchema: z.ZodObject<{ export declare const friendRequestSchema: z.ZodObject<{
username: z.ZodString; username: z.ZodString;
}, "strip", z.ZodTypeAny, { }, "strip", z.ZodTypeAny, {
@@ -203,6 +328,19 @@ export declare const friendshipSchema: z.ZodObject<{
status: "pending" | "accepted"; status: "pending" | "accepted";
updatedAt: number; updatedAt: number;
}>; }>;
export declare const tombstoneSchema: z.ZodObject<{
entity: z.ZodEnum<["categories", "tasks", "subtasks", "repeatProfiles", "friendships"]>;
id: z.ZodString;
updatedAt: z.ZodNumber;
}, "strip", z.ZodTypeAny, {
id: string;
updatedAt: number;
entity: "categories" | "tasks" | "subtasks" | "repeatProfiles" | "friendships";
}, {
id: string;
updatedAt: number;
entity: "categories" | "tasks" | "subtasks" | "repeatProfiles" | "friendships";
}>;
export declare const pushChangesSchema: z.ZodObject<{ export declare const pushChangesSchema: z.ZodObject<{
changes: z.ZodObject<{ changes: z.ZodObject<{
categories: z.ZodOptional<z.ZodArray<z.ZodObject<{ categories: z.ZodOptional<z.ZodArray<z.ZodObject<{
@@ -231,16 +369,18 @@ export declare const pushChangesSchema: z.ZodObject<{
tasks: z.ZodOptional<z.ZodArray<z.ZodObject<{ tasks: z.ZodOptional<z.ZodArray<z.ZodObject<{
title: z.ZodString; title: z.ZodString;
description: z.ZodOptional<z.ZodString>; description: z.ZodOptional<z.ZodString>;
categoryId: z.ZodString; categoryId: z.ZodNullable<z.ZodEffects<z.ZodString, string | null, string>>;
priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>; priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>;
dueDate: z.ZodOptional<z.ZodNumber>; dueDate: z.ZodOptional<z.ZodNumber>;
dueTime: z.ZodNullable<z.ZodOptional<z.ZodString>>; dueTime: z.ZodEffects<z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>, string, string | undefined>;
endTime: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>; endTime: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
repeat: z.ZodOptional<z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>>; allDay: z.ZodOptional<z.ZodBoolean>;
repeatInterval: z.ZodOptional<z.ZodNumber>; repeat: z.ZodOptional<z.ZodEffects<z.ZodUnion<[z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>, z.ZodLiteral<"">]>, "custom" | "none" | "daily" | "weekly" | "monthly", "" | "custom" | "none" | "daily" | "weekly" | "monthly">>;
repeatInterval: z.ZodOptional<z.ZodEffects<z.ZodNumber, number, number>>;
repeatDays: z.ZodOptional<z.ZodString>; repeatDays: z.ZodOptional<z.ZodString>;
seriesId: z.ZodOptional<z.ZodString>; seriesId: z.ZodOptional<z.ZodString>;
reminder: z.ZodOptional<z.ZodEnum<["none", "at_time", "15", "30", "60", "120", "1440"]>>; reminder: z.ZodOptional<z.ZodEnum<["none", "at_time", "15", "30", "60", "120", "1440"]>>;
reminders: z.ZodOptional<z.ZodString>;
assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>; assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
subtasks: z.ZodOptional<z.ZodArray<z.ZodObject<{ subtasks: z.ZodOptional<z.ZodArray<z.ZodObject<{
title: z.ZodString; title: z.ZodString;
@@ -254,52 +394,73 @@ export declare const pushChangesSchema: z.ZodObject<{
completed: z.ZodBoolean; completed: z.ZodBoolean;
createdAt: z.ZodNumber; createdAt: z.ZodNumber;
updatedAt: z.ZodNumber; updatedAt: z.ZodNumber;
completedAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
}, "strip", z.ZodTypeAny, { }, "strip", z.ZodTypeAny, {
id: string; id: string;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
categoryId: string; categoryId: string | null;
title: string; title: string;
completed: boolean; completed: boolean;
dueTime: string;
subtasks?: {
title: string;
}[] | undefined;
description?: string | undefined; description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined; priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined; dueDate?: number | undefined;
dueTime?: string | null | undefined;
endTime?: string | undefined; endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined; repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined; repeatInterval?: number | undefined;
repeatDays?: string | undefined; repeatDays?: string | undefined;
seriesId?: string | undefined; seriesId?: string | undefined;
assigneeId?: string | null | undefined; assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined; reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
subtasks?: { reminders?: string | undefined;
title: string; completedAt?: number | null | undefined;
}[] | undefined;
}, { }, {
id: string; id: string;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
categoryId: string; categoryId: string | null;
title: string; title: string;
completed: boolean; completed: boolean;
subtasks?: {
title: string;
}[] | undefined;
description?: string | undefined; description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined; priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined; dueDate?: number | undefined;
dueTime?: string | null | undefined; dueTime?: string | undefined;
endTime?: string | undefined; endTime?: string | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined; allDay?: boolean | undefined;
repeat?: "" | "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined; repeatInterval?: number | undefined;
repeatDays?: string | undefined; repeatDays?: string | undefined;
seriesId?: string | undefined; seriesId?: string | undefined;
assigneeId?: string | null | undefined; assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined; reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
subtasks?: { reminders?: string | undefined;
title: string; completedAt?: number | null | undefined;
}[] | undefined;
}>, "many">>; }>, "many">>;
subtasks: z.ZodOptional<z.ZodArray<z.ZodObject<{ subtasks: z.ZodOptional<z.ZodArray<z.ZodObject<{
title: z.ZodString; title: z.ZodString;
description: z.ZodOptional<z.ZodString>;
priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>;
dueDate: z.ZodOptional<z.ZodNumber>;
dueTime: z.ZodEffects<z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>, string, string | undefined>;
endTime: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
allDay: z.ZodOptional<z.ZodBoolean>;
repeat: z.ZodOptional<z.ZodEffects<z.ZodUnion<[z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>, z.ZodLiteral<"">]>, "custom" | "none" | "daily" | "weekly" | "monthly", "" | "custom" | "none" | "daily" | "weekly" | "monthly">>;
repeatInterval: z.ZodOptional<z.ZodEffects<z.ZodNumber, number, number>>;
repeatDays: z.ZodOptional<z.ZodString>;
seriesId: z.ZodOptional<z.ZodString>;
reminder: z.ZodOptional<z.ZodEnum<["none", "at_time", "15", "30", "60", "120", "1440"]>>;
reminders: z.ZodOptional<z.ZodString>;
assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
order: z.ZodOptional<z.ZodNumber>; order: z.ZodOptional<z.ZodNumber>;
parentSubtaskId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
} & { } & {
id: z.ZodString; id: z.ZodString;
taskId: z.ZodString; taskId: z.ZodString;
@@ -312,8 +473,22 @@ export declare const pushChangesSchema: z.ZodObject<{
updatedAt: number; updatedAt: number;
title: string; title: string;
completed: boolean; completed: boolean;
dueTime: string;
taskId: string; taskId: string;
order?: number | undefined; order?: number | undefined;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined;
endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
reminders?: string | undefined;
parentSubtaskId?: string | null | undefined;
}, { }, {
id: string; id: string;
createdAt: number; createdAt: number;
@@ -322,6 +497,20 @@ export declare const pushChangesSchema: z.ZodObject<{
completed: boolean; completed: boolean;
taskId: string; taskId: string;
order?: number | undefined; order?: number | undefined;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined;
dueTime?: string | undefined;
endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "" | "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
reminders?: string | undefined;
parentSubtaskId?: string | null | undefined;
}>, "many">>; }>, "many">>;
repeatProfiles: z.ZodOptional<z.ZodArray<z.ZodObject<{ repeatProfiles: z.ZodOptional<z.ZodArray<z.ZodObject<{
name: z.ZodString; name: z.ZodString;
@@ -372,14 +561,6 @@ export declare const pushChangesSchema: z.ZodObject<{
updatedAt: number; updatedAt: number;
}>, "many">>; }>, "many">>;
}, "strip", z.ZodTypeAny, { }, "strip", z.ZodTypeAny, {
friendships?: {
id: string;
createdAt: number;
userId: string;
friendId: string;
status: "pending" | "accepted";
updatedAt: number;
}[] | undefined;
categories?: { categories?: {
id: string; id: string;
name: string; name: string;
@@ -392,23 +573,26 @@ export declare const pushChangesSchema: z.ZodObject<{
id: string; id: string;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
categoryId: string; categoryId: string | null;
title: string; title: string;
completed: boolean; completed: boolean;
dueTime: string;
subtasks?: {
title: string;
}[] | undefined;
description?: string | undefined; description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined; priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined; dueDate?: number | undefined;
dueTime?: string | null | undefined;
endTime?: string | undefined; endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined; repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined; repeatInterval?: number | undefined;
repeatDays?: string | undefined; repeatDays?: string | undefined;
seriesId?: string | undefined; seriesId?: string | undefined;
assigneeId?: string | null | undefined; assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined; reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
subtasks?: { reminders?: string | undefined;
title: string; completedAt?: number | null | undefined;
}[] | undefined;
}[] | undefined; }[] | undefined;
subtasks?: { subtasks?: {
id: string; id: string;
@@ -416,8 +600,22 @@ export declare const pushChangesSchema: z.ZodObject<{
updatedAt: number; updatedAt: number;
title: string; title: string;
completed: boolean; completed: boolean;
dueTime: string;
taskId: string; taskId: string;
order?: number | undefined; order?: number | undefined;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined;
endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
reminders?: string | undefined;
parentSubtaskId?: string | null | undefined;
}[] | undefined; }[] | undefined;
repeatProfiles?: { repeatProfiles?: {
id: string; id: string;
@@ -428,15 +626,15 @@ export declare const pushChangesSchema: z.ZodObject<{
repeatInterval: number; repeatInterval: number;
repeatDays: string; repeatDays: string;
}[] | undefined; }[] | undefined;
friendships?: {
id: string;
createdAt: number;
userId: string;
friendId: string;
status: "pending" | "accepted";
updatedAt: number;
}[] | undefined;
}, { }, {
friendships?: {
id: string;
createdAt: number;
userId: string;
friendId: string;
status: "pending" | "accepted";
updatedAt: number;
}[] | undefined;
categories?: { categories?: {
id: string; id: string;
name: string; name: string;
@@ -449,23 +647,26 @@ export declare const pushChangesSchema: z.ZodObject<{
id: string; id: string;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
categoryId: string; categoryId: string | null;
title: string; title: string;
completed: boolean; completed: boolean;
subtasks?: {
title: string;
}[] | undefined;
description?: string | undefined; description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined; priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined; dueDate?: number | undefined;
dueTime?: string | null | undefined; dueTime?: string | undefined;
endTime?: string | undefined; endTime?: string | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined; allDay?: boolean | undefined;
repeat?: "" | "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined; repeatInterval?: number | undefined;
repeatDays?: string | undefined; repeatDays?: string | undefined;
seriesId?: string | undefined; seriesId?: string | undefined;
assigneeId?: string | null | undefined; assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined; reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
subtasks?: { reminders?: string | undefined;
title: string; completedAt?: number | null | undefined;
}[] | undefined;
}[] | undefined; }[] | undefined;
subtasks?: { subtasks?: {
id: string; id: string;
@@ -475,6 +676,20 @@ export declare const pushChangesSchema: z.ZodObject<{
completed: boolean; completed: boolean;
taskId: string; taskId: string;
order?: number | undefined; order?: number | undefined;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined;
dueTime?: string | undefined;
endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "" | "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
reminders?: string | undefined;
parentSubtaskId?: string | null | undefined;
}[] | undefined; }[] | undefined;
repeatProfiles?: { repeatProfiles?: {
id: string; id: string;
@@ -485,18 +700,31 @@ export declare const pushChangesSchema: z.ZodObject<{
repeatInterval: number; repeatInterval: number;
repeatDays: string; repeatDays: string;
}[] | undefined; }[] | undefined;
friendships?: {
id: string;
createdAt: number;
userId: string;
friendId: string;
status: "pending" | "accepted";
updatedAt: number;
}[] | undefined;
}>; }>;
deleted: z.ZodOptional<z.ZodArray<z.ZodObject<{
entity: z.ZodEnum<["categories", "tasks", "subtasks", "repeatProfiles", "friendships"]>;
id: z.ZodString;
updatedAt: z.ZodNumber;
}, "strip", z.ZodTypeAny, {
id: string;
updatedAt: number;
entity: "categories" | "tasks" | "subtasks" | "repeatProfiles" | "friendships";
}, {
id: string;
updatedAt: number;
entity: "categories" | "tasks" | "subtasks" | "repeatProfiles" | "friendships";
}>, "many">>;
lastPulledAt: z.ZodNumber; lastPulledAt: z.ZodNumber;
}, "strip", z.ZodTypeAny, { }, "strip", z.ZodTypeAny, {
changes: { changes: {
friendships?: {
id: string;
createdAt: number;
userId: string;
friendId: string;
status: "pending" | "accepted";
updatedAt: number;
}[] | undefined;
categories?: { categories?: {
id: string; id: string;
name: string; name: string;
@@ -509,23 +737,26 @@ export declare const pushChangesSchema: z.ZodObject<{
id: string; id: string;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
categoryId: string; categoryId: string | null;
title: string; title: string;
completed: boolean; completed: boolean;
dueTime: string;
subtasks?: {
title: string;
}[] | undefined;
description?: string | undefined; description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined; priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined; dueDate?: number | undefined;
dueTime?: string | null | undefined;
endTime?: string | undefined; endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined; repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined; repeatInterval?: number | undefined;
repeatDays?: string | undefined; repeatDays?: string | undefined;
seriesId?: string | undefined; seriesId?: string | undefined;
assigneeId?: string | null | undefined; assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined; reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
subtasks?: { reminders?: string | undefined;
title: string; completedAt?: number | null | undefined;
}[] | undefined;
}[] | undefined; }[] | undefined;
subtasks?: { subtasks?: {
id: string; id: string;
@@ -533,8 +764,22 @@ export declare const pushChangesSchema: z.ZodObject<{
updatedAt: number; updatedAt: number;
title: string; title: string;
completed: boolean; completed: boolean;
dueTime: string;
taskId: string; taskId: string;
order?: number | undefined; order?: number | undefined;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined;
endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
reminders?: string | undefined;
parentSubtaskId?: string | null | undefined;
}[] | undefined; }[] | undefined;
repeatProfiles?: { repeatProfiles?: {
id: string; id: string;
@@ -545,18 +790,23 @@ export declare const pushChangesSchema: z.ZodObject<{
repeatInterval: number; repeatInterval: number;
repeatDays: string; repeatDays: string;
}[] | undefined; }[] | undefined;
friendships?: {
id: string;
createdAt: number;
userId: string;
friendId: string;
status: "pending" | "accepted";
updatedAt: number;
}[] | undefined;
}; };
lastPulledAt: number; lastPulledAt: number;
deleted?: {
id: string;
updatedAt: number;
entity: "categories" | "tasks" | "subtasks" | "repeatProfiles" | "friendships";
}[] | undefined;
}, { }, {
changes: { changes: {
friendships?: {
id: string;
createdAt: number;
userId: string;
friendId: string;
status: "pending" | "accepted";
updatedAt: number;
}[] | undefined;
categories?: { categories?: {
id: string; id: string;
name: string; name: string;
@@ -569,23 +819,26 @@ export declare const pushChangesSchema: z.ZodObject<{
id: string; id: string;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
categoryId: string; categoryId: string | null;
title: string; title: string;
completed: boolean; completed: boolean;
subtasks?: {
title: string;
}[] | undefined;
description?: string | undefined; description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined; priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined; dueDate?: number | undefined;
dueTime?: string | null | undefined; dueTime?: string | undefined;
endTime?: string | undefined; endTime?: string | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined; allDay?: boolean | undefined;
repeat?: "" | "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined; repeatInterval?: number | undefined;
repeatDays?: string | undefined; repeatDays?: string | undefined;
seriesId?: string | undefined; seriesId?: string | undefined;
assigneeId?: string | null | undefined; assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined; reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
subtasks?: { reminders?: string | undefined;
title: string; completedAt?: number | null | undefined;
}[] | undefined;
}[] | undefined; }[] | undefined;
subtasks?: { subtasks?: {
id: string; id: string;
@@ -595,6 +848,20 @@ export declare const pushChangesSchema: z.ZodObject<{
completed: boolean; completed: boolean;
taskId: string; taskId: string;
order?: number | undefined; order?: number | undefined;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined;
dueTime?: string | undefined;
endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "" | "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
reminders?: string | undefined;
parentSubtaskId?: string | null | undefined;
}[] | undefined; }[] | undefined;
repeatProfiles?: { repeatProfiles?: {
id: string; id: string;
@@ -605,8 +872,21 @@ export declare const pushChangesSchema: z.ZodObject<{
repeatInterval: number; repeatInterval: number;
repeatDays: string; repeatDays: string;
}[] | undefined; }[] | undefined;
friendships?: {
id: string;
createdAt: number;
userId: string;
friendId: string;
status: "pending" | "accepted";
updatedAt: number;
}[] | undefined;
}; };
lastPulledAt: number; lastPulledAt: number;
deleted?: {
id: string;
updatedAt: number;
entity: "categories" | "tasks" | "subtasks" | "repeatProfiles" | "friendships";
}[] | undefined;
}>; }>;
export declare const syncQuerySchema: z.ZodObject<{ export declare const syncQuerySchema: z.ZodObject<{
since: z.ZodPipeline<z.ZodEffects<z.ZodString, number, string>, z.ZodNumber>; since: z.ZodPipeline<z.ZodEffects<z.ZodString, number, string>, z.ZodNumber>;
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../../src/utils/validation.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,eAAO,MAAM,oBAAoB;;;;;;;;;;;;EAI/B,CAAC;AAEH,eAAO,MAAM,oBAAoB;;;;;;;;;;;;EAI/B,CAAC;AAEH,eAAO,MAAM,YAAY,6DAA2D,CAAC;AAErF,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAe3B,CAAC;AAEH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAU3B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;EAG9B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;EAI9B,CAAC;AAEH,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;EAO7B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;EAK9B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;EAE9B,CAAC;AAEH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;EAO3B,CAAC;AAEH,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAS5B,CAAC;AAEH,eAAO,MAAM,eAAe;;;;;;EAE1B,CAAC;AAEH,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAU1B,CAAC"} {"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../../src/utils/validation.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAKxD;AAED,eAAO,MAAM,oBAAoB;;;;;;;;;;;;EAI/B,CAAC;AAEH,eAAO,MAAM,oBAAoB;;;;;;;;;;;;EAI/B,CAAC;AAEH,eAAO,MAAM,YAAY,6DAA2D,CAAC;AAIrF,eAAO,MAAM,iBAAiB,uOAGjB,CAAC;AACd,eAAO,MAAM,yBAAyB,0DAMzB,CAAC;AAEd,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAkB3B,CAAC;AAEH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAY3B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAiB9B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAkB9B,CAAC;AAEH,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;EAO7B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;EAK9B,CAAC;AAEH,eAAO,MAAM,yBAAyB;;;;;;;;;;;;;;;EAKpC,CAAC;AAEH,eAAO,MAAM,iBAAiB;;;;;;EAE5B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;EAE9B,CAAC;AAEH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;EAO3B,CAAC;AAEH,eAAO,MAAM,eAAe;;;;;;;;;;;;EAI1B,CAAC;AAEH,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAU5B,CAAC;AAEH,eAAO,MAAM,eAAe;;;;;;EAE1B,CAAC;AAEH,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAU1B,CAAC"}
+77 -8
View File
@@ -1,7 +1,15 @@
"use strict"; "use strict";
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.taskQuerySchema = exports.syncQuerySchema = exports.pushChangesSchema = exports.friendshipSchema = exports.friendRequestSchema = exports.repeatProfileSchema = exports.userSettingsSchema = exports.subtaskUpdateSchema = exports.subtaskCreateSchema = exports.taskUpdateSchema = exports.taskCreateSchema = exports.repeatSchema = exports.categoryUpdateSchema = exports.categoryCreateSchema = void 0; exports.taskQuerySchema = exports.syncQuerySchema = exports.pushChangesSchema = exports.tombstoneSchema = exports.friendshipSchema = exports.friendRequestSchema = exports.searchQuerySchema = exports.repeatProfileUpdateSchema = exports.repeatProfileSchema = exports.userSettingsSchema = exports.subtaskUpdateSchema = exports.subtaskCreateSchema = exports.taskUpdateSchema = exports.taskCreateSchema = exports.repeatIntervalFieldSchema = exports.repeatFieldSchema = exports.repeatSchema = exports.categoryUpdateSchema = exports.categoryCreateSchema = void 0;
exports.canCompleteTask = canCompleteTask;
const zod_1 = require("zod"); const zod_1 = require("zod");
function canCompleteTask(dueDate) {
if (dueDate === 0)
return true;
const now = new Date();
const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999).getTime();
return dueDate <= endOfToday;
}
exports.categoryCreateSchema = zod_1.z.object({ exports.categoryCreateSchema = zod_1.z.object({
name: zod_1.z.string().min(1).max(50), name: zod_1.z.string().min(1).max(50),
color: zod_1.z.string().regex(/^#[0-9A-Fa-f]{6}$/), color: zod_1.z.string().regex(/^#[0-9A-Fa-f]{6}$/),
@@ -13,41 +21,87 @@ exports.categoryUpdateSchema = zod_1.z.object({
order: zod_1.z.number().int().min(0).optional(), order: zod_1.z.number().int().min(0).optional(),
}); });
exports.repeatSchema = zod_1.z.enum(['none', 'daily', 'weekly', 'monthly', 'custom']); exports.repeatSchema = zod_1.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.
exports.repeatFieldSchema = exports.repeatSchema
.or(zod_1.z.literal(''))
.transform((v) => (v === '' ? 'none' : v))
.optional();
exports.repeatIntervalFieldSchema = zod_1.z
.number()
.int()
.min(0)
.max(30)
.transform((v) => Math.max(1, v))
.optional();
exports.taskCreateSchema = zod_1.z.object({ exports.taskCreateSchema = zod_1.z.object({
title: zod_1.z.string().min(1).max(100), title: zod_1.z.string().min(1).max(100),
description: zod_1.z.string().max(1000).optional(), description: zod_1.z.string().max(1000).optional(),
categoryId: zod_1.z.string().min(1), categoryId: zod_1.z.string().max(100).transform((v) => (v === '' ? null : v)).nullable(),
priority: zod_1.z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(), priority: zod_1.z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
dueDate: zod_1.z.number().int().min(0).optional(), dueDate: zod_1.z.number().int().min(0).optional(),
dueTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().nullable(), dueTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(zod_1.z.literal('')).transform(v => v ?? ''),
endTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(zod_1.z.literal('')), endTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(zod_1.z.literal('')),
repeat: exports.repeatSchema.optional(), allDay: zod_1.z.boolean().optional(),
repeatInterval: zod_1.z.number().int().min(1).max(30).optional(), repeat: exports.repeatFieldSchema,
repeatInterval: exports.repeatIntervalFieldSchema,
repeatDays: zod_1.z.string().max(20).optional(), repeatDays: zod_1.z.string().max(20).optional(),
seriesId: zod_1.z.string().max(50).optional(), seriesId: zod_1.z.string().max(50).optional(),
reminder: zod_1.z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(), reminder: zod_1.z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
reminders: zod_1.z.string().max(100).optional(),
assigneeId: zod_1.z.string().nullable().optional(), assigneeId: zod_1.z.string().nullable().optional(),
completedAt: zod_1.z.number().int().min(0).nullable().optional(),
subtasks: zod_1.z.array(zod_1.z.object({ title: zod_1.z.string().min(1).max(100) })).optional(), subtasks: zod_1.z.array(zod_1.z.object({ title: zod_1.z.string().min(1).max(100) })).optional(),
}); });
exports.taskUpdateSchema = zod_1.z.object({ exports.taskUpdateSchema = zod_1.z.object({
title: zod_1.z.string().min(1).max(100).optional(), title: zod_1.z.string().min(1).max(100).optional(),
description: zod_1.z.string().max(1000).optional(), description: zod_1.z.string().max(1000).optional(),
categoryId: zod_1.z.string().min(1).optional(), categoryId: zod_1.z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)),
priority: zod_1.z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(), priority: zod_1.z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
completed: zod_1.z.boolean().optional(), completed: zod_1.z.boolean().optional(),
dueDate: zod_1.z.number().int().min(0).optional(), dueDate: zod_1.z.number().int().min(0).optional(),
dueTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().nullable(), dueTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(zod_1.z.literal('')).transform(v => v === '' ? undefined : v),
endTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(), endTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(),
reminders: zod_1.z.string().max(100).optional(),
assigneeId: zod_1.z.string().nullable().optional(), assigneeId: zod_1.z.string().nullable().optional(),
completedAt: zod_1.z.number().int().min(0).nullable().optional(),
}); });
exports.subtaskCreateSchema = zod_1.z.object({ exports.subtaskCreateSchema = zod_1.z.object({
title: zod_1.z.string().min(1).max(100), title: zod_1.z.string().min(1).max(100),
description: zod_1.z.string().max(1000).optional(),
priority: zod_1.z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
dueDate: zod_1.z.number().int().min(0).optional(),
dueTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(zod_1.z.literal('')).transform(v => v ?? ''),
endTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(zod_1.z.literal('')),
allDay: zod_1.z.boolean().optional(),
repeat: exports.repeatFieldSchema,
repeatInterval: exports.repeatIntervalFieldSchema,
repeatDays: zod_1.z.string().max(20).optional(),
seriesId: zod_1.z.string().max(50).optional(),
reminder: zod_1.z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
reminders: zod_1.z.string().max(100).optional(),
assigneeId: zod_1.z.string().nullable().optional(),
order: zod_1.z.number().int().min(0).optional(), order: zod_1.z.number().int().min(0).optional(),
parentSubtaskId: zod_1.z.string().nullable().optional(),
}); });
exports.subtaskUpdateSchema = zod_1.z.object({ exports.subtaskUpdateSchema = zod_1.z.object({
title: zod_1.z.string().min(1).max(100).optional(), title: zod_1.z.string().min(1).max(100).optional(),
description: zod_1.z.string().max(1000).optional(),
priority: zod_1.z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
completed: zod_1.z.boolean().optional(), completed: zod_1.z.boolean().optional(),
dueDate: zod_1.z.number().int().min(0).optional(),
dueTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(zod_1.z.literal('')).transform(v => v === '' ? undefined : v),
endTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(),
allDay: zod_1.z.boolean().optional(),
repeat: exports.repeatFieldSchema,
repeatInterval: exports.repeatIntervalFieldSchema,
repeatDays: zod_1.z.string().max(20).optional(),
seriesId: zod_1.z.string().max(50).optional(),
reminder: zod_1.z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
reminders: zod_1.z.string().max(100).optional(),
assigneeId: zod_1.z.string().nullable().optional(),
order: zod_1.z.number().int().min(0).optional(), order: zod_1.z.number().int().min(0).optional(),
parentSubtaskId: zod_1.z.string().nullable().optional(),
}); });
exports.userSettingsSchema = zod_1.z.object({ exports.userSettingsSchema = zod_1.z.object({
darkMode: zod_1.z.boolean().optional(), darkMode: zod_1.z.boolean().optional(),
@@ -63,6 +117,15 @@ exports.repeatProfileSchema = zod_1.z.object({
repeatInterval: zod_1.z.number().int().min(1).max(30), repeatInterval: zod_1.z.number().int().min(1).max(30),
repeatDays: zod_1.z.string().max(20), repeatDays: zod_1.z.string().max(20),
}); });
exports.repeatProfileUpdateSchema = zod_1.z.object({
name: zod_1.z.string().min(1).max(50).optional(),
repeat: exports.repeatSchema.optional(),
repeatInterval: zod_1.z.number().int().min(1).max(30).optional(),
repeatDays: zod_1.z.string().max(20).optional(),
});
exports.searchQuerySchema = zod_1.z.object({
q: zod_1.z.string().min(1).max(50),
});
exports.friendRequestSchema = zod_1.z.object({ exports.friendRequestSchema = zod_1.z.object({
username: zod_1.z.string().min(1).max(50), username: zod_1.z.string().min(1).max(50),
}); });
@@ -74,14 +137,20 @@ exports.friendshipSchema = zod_1.z.object({
createdAt: zod_1.z.number(), createdAt: zod_1.z.number(),
updatedAt: zod_1.z.number(), updatedAt: zod_1.z.number(),
}); });
exports.tombstoneSchema = zod_1.z.object({
entity: zod_1.z.enum(['categories', 'tasks', 'subtasks', 'repeatProfiles', 'friendships']),
id: zod_1.z.string(),
updatedAt: zod_1.z.number().int().min(0),
});
exports.pushChangesSchema = zod_1.z.object({ exports.pushChangesSchema = zod_1.z.object({
changes: zod_1.z.object({ changes: zod_1.z.object({
categories: zod_1.z.array(exports.categoryCreateSchema.extend({ id: zod_1.z.string(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number() })).optional(), categories: zod_1.z.array(exports.categoryCreateSchema.extend({ id: zod_1.z.string(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number() })).optional(),
tasks: zod_1.z.array(exports.taskCreateSchema.extend({ id: zod_1.z.string(), completed: zod_1.z.boolean(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number() })).optional(), tasks: zod_1.z.array(exports.taskCreateSchema.extend({ id: zod_1.z.string(), completed: zod_1.z.boolean(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number(), completedAt: zod_1.z.number().int().min(0).nullable().optional() })).optional(),
subtasks: zod_1.z.array(exports.subtaskCreateSchema.extend({ id: zod_1.z.string(), taskId: zod_1.z.string(), completed: zod_1.z.boolean(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number() })).optional(), subtasks: zod_1.z.array(exports.subtaskCreateSchema.extend({ id: zod_1.z.string(), taskId: zod_1.z.string(), completed: zod_1.z.boolean(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number() })).optional(),
repeatProfiles: zod_1.z.array(exports.repeatProfileSchema.extend({ id: zod_1.z.string(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number() })).optional(), repeatProfiles: zod_1.z.array(exports.repeatProfileSchema.extend({ id: zod_1.z.string(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number() })).optional(),
friendships: zod_1.z.array(exports.friendshipSchema).optional(), friendships: zod_1.z.array(exports.friendshipSchema).optional(),
}), }),
deleted: zod_1.z.array(exports.tombstoneSchema).optional(),
lastPulledAt: zod_1.z.number().int().min(0), lastPulledAt: zod_1.z.number().int().min(0),
}); });
exports.syncQuerySchema = zod_1.z.object({ exports.syncQuerySchema = zod_1.z.object({
File diff suppressed because one or more lines are too long
+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'; 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', { export const users = pgTable('users', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
username: text('username').notNull().unique(), username: text('username').notNull().unique(),
@@ -40,7 +43,7 @@ export const categories = pgTable('categories', {
export const tasks = pgTable('tasks', { 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').notNull().references(() => categories.id, { onDelete: 'cascade' }), categoryId: text('category_id').references(() => categories.id, { onDelete: 'cascade' }),
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'),
@@ -56,26 +59,31 @@ export const tasks = pgTable('tasks', {
assigneeId: text('assignee_id').references(() => users.id, { onDelete: 'set null' }), 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'), reminder: text('reminder', { enum: ['none', 'at_time', '15', '30', '60', '120', '1440'] }).notNull().default('none'),
reminders: text('reminders').notNull().default(''), 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`), 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`), updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
}); });
export const repeatProfiles = pgTable('repeat_profiles', { export const tombstones = pgTable(
id: text('id').primaryKey(), 'tombstones',
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), {
name: text('name').notNull(), entity: text('entity', { enum: ENTITIES }).notNull(),
repeat: text('repeat', { enum: ['none', 'daily', 'weekly', 'monthly', 'custom'] }).notNull().default('none'), entityId: text('entity_id').notNull(),
repeatInterval: integer('repeat_interval').notNull().default(1), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
repeatDays: text('repeat_days').notNull().default(''), updatedAt: bigint('updated_at', { mode: 'number' }).notNull(),
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) => ({
}); 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', { export const subtasks = pgTable('subtasks', {
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' }),
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(() => subtasks.id, { onDelete: 'cascade' }), parentSubtaskId: text('parent_subtask_id').references((): AnyPgColumn => subtasks.id, { onDelete: 'cascade' }),
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'),
@@ -94,6 +102,21 @@ export const subtasks = pgTable('subtasks', {
order: integer('order').notNull().default(0), order: integer('order').notNull().default(0),
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`), 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`), 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', { 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'), sortBy: text('sort_by', { enum: ['dueDate', 'priority', 'title', 'createdAt'] }).notNull().default('dueDate'),
sortOrder: text('sort_order', { enum: ['asc', 'desc'] }).notNull().default('asc'), sortOrder: text('sort_order', { enum: ['asc', 'desc'] }).notNull().default('asc'),
updatedAt: bigint('updated_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`),
}); });
+3 -3
View File
@@ -2,7 +2,7 @@ import { Router, Request, Response } from 'express';
import { asyncHandler } from '../utils/asyncHandler'; import { asyncHandler } from '../utils/asyncHandler';
import { db } from '../db'; import { db } from '../db';
import { subtasks, tasks } from '../db/schema'; 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 { authMiddleware } from '../utils/auth';
import { AppError } from '../middleware/errorHandler'; import { AppError } from '../middleware/errorHandler';
import { subtaskCreateSchema, subtaskUpdateSchema } from '../utils/validation'; import { subtaskCreateSchema, subtaskUpdateSchema } from '../utils/validation';
@@ -70,7 +70,7 @@ router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) =>
.where(and( .where(and(
eq(subtasks.taskId, req.params.taskId), eq(subtasks.taskId, req.params.taskId),
eq(subtasks.userId, userId), 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)) .orderBy(desc(subtasks.order))
.limit(1); .limit(1);
@@ -117,7 +117,7 @@ router.patch('/:id', asyncHandler(async (req: Request, res: Response) => {
const data = subtaskUpdateSchema.parse(req.body); const data = subtaskUpdateSchema.parse(req.body);
const userId = req.user!.userId; const userId = req.user!.userId;
const existing = await db const existing: any[] = await db
.select() .select()
.from(subtasks) .from(subtasks)
.where(and(eq(subtasks.id, req.params.id), eq(subtasks.userId, userId))) .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 { Router, Request, Response } from 'express';
import { asyncHandler } from '../utils/asyncHandler'; import { asyncHandler } from '../utils/asyncHandler';
import { db } from '../db'; import { db } from '../db';
import { categories, tasks, subtasks, repeatProfiles, users, friendships } from '../db/schema'; import { categories, tasks, subtasks, repeatProfiles, users, friendships, tombstones, type EntityName } from '../db/schema';
import { eq, and, gte, lte, asc, or, inArray, sql } from 'drizzle-orm'; import { eq, and, gte, asc, or, inArray, sql } from 'drizzle-orm';
import { authMiddleware } from '../utils/auth'; import { authMiddleware } from '../utils/auth';
import { AppError } from '../middleware/errorHandler'; import { AppError } from '../middleware/errorHandler';
import { syncQuerySchema, pushChangesSchema, canCompleteTask } from '../utils/validation'; import { syncQuerySchema, pushChangesSchema, canCompleteTask } from '../utils/validation';
@@ -32,17 +32,15 @@ router.get('/', asyncHandler(async (req: Request, res: Response) => {
.orderBy(asc(tasks.updatedAt)); .orderBy(asc(tasks.updatedAt));
// Fetch subtasks changed since timestamp // Fetch subtasks changed since timestamp
const taskIds = changedTasks.map(t => t.id);
let changedSubtasks: any[] = []; let changedSubtasks: any[] = [];
if (changedTasks.length > 0) {
if (taskIds.length > 0) {
changedSubtasks = await db changedSubtasks = await db
.select() .select()
.from(subtasks) .from(subtasks)
.where(and(eq(subtasks.userId, userId), gte(subtasks.updatedAt, sinceDate))) .where(and(eq(subtasks.userId, userId), gte(subtasks.updatedAt, sinceDate)))
.orderBy(asc(subtasks.updatedAt)); .orderBy(asc(subtasks.updatedAt));
} else { } 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 changedSubtasks = await db
.select() .select()
.from(subtasks) .from(subtasks)
@@ -67,6 +65,13 @@ router.get('/', asyncHandler(async (req: Request, res: Response) => {
)) ))
.orderBy(asc(friendships.updatedAt)); .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(); const timestamp = Date.now();
res.json({ res.json({
@@ -75,6 +80,7 @@ router.get('/', asyncHandler(async (req: Request, res: Response) => {
subtasks: changedSubtasks, subtasks: changedSubtasks,
repeatProfiles: changedRepeatProfiles, repeatProfiles: changedRepeatProfiles,
friendships: changedFriendships, friendships: changedFriendships,
deleted: changedTombstones.map((t) => ({ entity: t.entity, id: t.entityId, updatedAt: t.updatedAt })),
timestamp, 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 // Process categories
if (data.changes.categories && data.changes.categories.length > 0) { if (data.changes.categories && data.changes.categories.length > 0) {
for (const cat of data.changes.categories) { for (const cat of data.changes.categories) {
@@ -185,7 +212,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
resolution: 'server_wins', resolution: 'server_wins',
}); });
continue; // Server wins continue; // Server wins
} }
// Prevent completing tasks with future due dates // Prevent completing tasks with future due dates
if (task.completed === true) { if (task.completed === true) {
@@ -203,30 +230,32 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
} }
await tx await tx
.update(tasks) .update(tasks)
.set({ .set({
title: task.title, title: task.title,
description: task.description, description: task.description,
categoryId: task.categoryId, categoryId: task.categoryId,
priority: task.priority, priority: task.priority,
completed: task.completed, completed: task.completed,
dueDate: task.dueDate, dueDate: task.dueDate,
dueTime: task.dueTime ?? '', dueTime: task.dueTime ?? '',
endTime: task.endTime ?? '', endTime: task.endTime ?? '',
allDay: task.allDay ?? false, allDay: task.allDay ?? false,
repeat: task.repeat ?? 'none', repeat: task.repeat ?? 'none',
repeatInterval: task.repeatInterval ?? 1, repeatInterval: task.repeatInterval ?? 1,
repeatDays: task.repeatDays ?? '', repeatDays: task.repeatDays ?? '',
seriesId: task.seriesId ?? '', seriesId: task.seriesId ?? '',
reminder: task.reminder ?? 'none', reminder: task.reminder ?? 'none',
reminders: task.reminders ?? '', reminders: task.reminders ?? '',
assigneeId: task.assigneeId ?? null, assigneeId: sanitizeAssignee(task.assigneeId),
updatedAt: task.updatedAt, completedAt: task.completedAt ?? null,
}) updatedAt: task.updatedAt,
.where(and(eq(tasks.id, task.id), eq(tasks.userId, userId))); })
.where(and(eq(tasks.id, task.id), eq(tasks.userId, userId)));
} else { } else {
await tx.insert(tasks).values({ await tx.insert(tasks).values({
...task, ...task,
assigneeId: sanitizeAssignee(task.assigneeId),
allDay: task.allDay ?? false, allDay: task.allDay ?? false,
userId, userId,
}); });
@@ -260,6 +289,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
.update(subtasks) .update(subtasks)
.set({ .set({
taskId: sub.taskId, taskId: sub.taskId,
parentSubtaskId: sub.parentSubtaskId ?? null,
title: sub.title, title: sub.title,
description: sub.description ?? '', description: sub.description ?? '',
priority: sub.priority ?? 'none', priority: sub.priority ?? 'none',
@@ -274,7 +304,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
seriesId: sub.seriesId ?? '', seriesId: sub.seriesId ?? '',
reminder: sub.reminder ?? 'none', reminder: sub.reminder ?? 'none',
reminders: sub.reminders ?? '', reminders: sub.reminders ?? '',
assigneeId: sub.assigneeId ?? null, assigneeId: sanitizeAssignee(sub.assigneeId),
order: sub.order, order: sub.order,
updatedAt: sub.updatedAt, updatedAt: sub.updatedAt,
}) })
@@ -282,6 +312,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
} else { } else {
await tx.insert(subtasks).values({ await tx.insert(subtasks).values({
...sub, ...sub,
assigneeId: sanitizeAssignee(sub.assigneeId),
userId, 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) { } catch (error) {
console.error('Sync push error:', 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; 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 userId = req.user!.userId;
const now = Date.now(); const now = Date.now();
// Verify category exists and belongs to user // Verify category exists and belongs to user (optional - tasks may be uncategorized)
const cat = await db if (data.categoryId) {
.select() const cat = await db
.from(categories) .select()
.where(and(eq(categories.id, data.categoryId), eq(categories.userId, userId))) .from(categories)
.limit(1); .where(and(eq(categories.id, data.categoryId), eq(categories.userId, userId)))
.limit(1);
if (cat.length === 0) { if (cat.length === 0) {
throw new AppError('NOT_FOUND', 'Category not found', 404); throw new AppError('NOT_FOUND', 'Category not found', 404);
}
} }
const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; 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) { for (const op of operations) {
try { try {
if (op.type === 'create') { if (op.type === 'create') {
const cat = await db if (op.data.categoryId) {
.select() const cat = await db
.from(categories) .select()
.where(and(eq(categories.id, op.data.categoryId), eq(categories.userId, userId))) .from(categories)
.limit(1); .where(and(eq(categories.id, op.data.categoryId), eq(categories.userId, userId)))
if (cat.length === 0) throw new AppError('NOT_FOUND', 'Category not found', 404); .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 taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const now = Date.now(); 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']); 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({ 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().min(1), categoryId: z.string().max(100).transform((v) => (v === '' ? null : v)).nullable(),
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 ?? ''),
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')), endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')),
allDay: z.boolean().optional(), allDay: z.boolean().optional(),
repeat: repeatSchema.optional(), repeat: repeatFieldSchema,
repeatInterval: z.number().int().min(1).max(30).optional(), repeatInterval: repeatIntervalFieldSchema,
repeatDays: z.string().max(20).optional(), repeatDays: z.string().max(20).optional(),
seriesId: z.string().max(50).optional(), seriesId: z.string().max(50).optional(),
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(), reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
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(),
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) })).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().min(1).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(),
@@ -51,6 +66,7 @@ export const taskUpdateSchema = z.object({
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(), endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(),
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(),
}); });
export const subtaskCreateSchema = z.object({ 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 ?? ''), 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('')), endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')),
allDay: z.boolean().optional(), allDay: z.boolean().optional(),
repeat: repeatSchema.optional(), repeat: repeatFieldSchema,
repeatInterval: z.number().int().min(1).max(30).optional(), repeatInterval: repeatIntervalFieldSchema,
repeatDays: z.string().max(20).optional(), repeatDays: z.string().max(20).optional(),
seriesId: z.string().max(50).optional(), seriesId: z.string().max(50).optional(),
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).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), 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(), endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(),
allDay: z.boolean().optional(), allDay: z.boolean().optional(),
repeat: repeatSchema.optional(), repeat: repeatFieldSchema,
repeatInterval: z.number().int().min(1).max(30).optional(), repeatInterval: repeatIntervalFieldSchema,
repeatDays: z.string().max(20).optional(), repeatDays: z.string().max(20).optional(),
seriesId: z.string().max(50).optional(), seriesId: z.string().max(50).optional(),
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).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(), 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({ export const pushChangesSchema = z.object({
changes: z.object({ changes: z.object({
categories: z.array(categoryCreateSchema.extend({ id: z.string(), createdAt: z.number(), updatedAt: z.number() })).optional(), 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(), 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(), repeatProfiles: z.array(repeatProfileSchema.extend({ id: z.string(), createdAt: z.number(), updatedAt: z.number() })).optional(),
friendships: z.array(friendshipSchema).optional(), friendships: z.array(friendshipSchema).optional(),
}), }),
deleted: z.array(tombstoneSchema).optional(),
lastPulledAt: z.number().int().min(0), lastPulledAt: z.number().int().min(0),
}); });
+2
View File
@@ -6,11 +6,13 @@
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
"backgroundColor": "#121212",
"ios": { "ios": {
"supportsTablet": true "supportsTablet": true
}, },
"android": { "android": {
"softwareKeyboardLayoutMode": "resize", "softwareKeyboardLayoutMode": "resize",
"backgroundColor": "#121212",
"adaptiveIcon": { "adaptiveIcon": {
"backgroundColor": "#E6F4FE", "backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png", "foregroundImage": "./assets/android-icon-foreground.png",
+31 -13
View File
@@ -3,6 +3,7 @@ import React from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { TabBarIcon } from '@/components/TabBarIcon'; import { TabBarIcon } from '@/components/TabBarIcon';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { setLastVisitedTab } from '@/utils/tabHistory';
export default function TabLayout() { export default function TabLayout() {
const { theme } = useSettings(); const { theme } = useSettings();
@@ -12,27 +13,44 @@ export default function TabLayout() {
<Tabs <Tabs
screenOptions={{ screenOptions={{
tabBarActiveTintColor: theme.accent, tabBarActiveTintColor: theme.accent,
tabBarInactiveTintColor: '#8E8E8E', tabBarInactiveTintColor: theme.text,
tabBarStyle: { tabBarStyle: {
backgroundColor: theme.tabBarBg, backgroundColor: theme.tabBarBg,
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: theme.border, borderTopColor: theme.border,
height: 64 + insets.bottom, height: 68 + insets.bottom,
paddingBottom: insets.bottom, paddingBottom: insets.bottom,
paddingTop: 6,
elevation: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.08,
shadowRadius: 8,
},
tabBarItemStyle: {
paddingVertical: 0,
}, },
tabBarLabelStyle: { tabBarLabelStyle: {
fontSize: 11, fontSize: 11,
fontWeight: '500', fontWeight: '700',
}, },
headerShown: false, headerShown: false,
sceneStyle: { backgroundColor: theme.background },
}} }}
screenListeners={({ route }) => ({
focus: () => {
if (route.name !== 'settings') {
setLastVisitedTab(route.name);
}
},
})}
> >
<Tabs.Screen <Tabs.Screen
name="index" name="index"
options={{ options={{
title: 'Tasks', title: 'ToDo',
tabBarIcon: ({ focused, color }) => ( tabBarIcon: ({ color, focused }) => (
<TabBarIcon name="checklist" focused={focused} color={color} /> <TabBarIcon name="checklist" focused={focused} color={color} size={26} />
), ),
}} }}
/> />
@@ -40,8 +58,8 @@ export default function TabLayout() {
name="calendar" name="calendar"
options={{ options={{
title: 'Calendar', title: 'Calendar',
tabBarIcon: ({ focused, color }) => ( tabBarIcon: ({ color, focused }) => (
<TabBarIcon name="calendar" focused={focused} color={color} /> <TabBarIcon name="calendar" focused={focused} color={color} size={26} />
), ),
}} }}
/> />
@@ -49,8 +67,8 @@ export default function TabLayout() {
name="stats" name="stats"
options={{ options={{
title: 'Stats', title: 'Stats',
tabBarIcon: ({ focused, color }) => ( tabBarIcon: ({ color, focused }) => (
<TabBarIcon name="stats" focused={focused} color={color} /> <TabBarIcon name="stats" focused={focused} color={color} size={26} />
), ),
}} }}
/> />
@@ -58,11 +76,11 @@ export default function TabLayout() {
name="settings" name="settings"
options={{ options={{
title: 'Settings', title: 'Settings',
tabBarIcon: ({ focused, color }) => ( tabBarIcon: ({ color, focused }) => (
<TabBarIcon name="gear" focused={focused} color={color} /> <TabBarIcon name="gear" focused={focused} color={color} size={26} />
), ),
}} }}
/> />
</Tabs> </Tabs>
); );
} }
+60 -52
View File
@@ -1,6 +1,6 @@
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react'; import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions } from 'react-native'; import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions, BackHandler, KeyboardAvoidingView } from 'react-native';
import { useRouter } 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';
@@ -15,10 +15,10 @@ 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 { 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 type { ThemeColors } from '@/theme'; import type { ThemeColors } from '@/theme';
const WIDTH = Dimensions.get('window').width; const WIDTH = Dimensions.get('window').width;
const ORANGE = '#FF7043';
const GERMAN_WEEKDAYS = ['mo', 'di', 'mi', 'do', 'fr', 'sa', 'so']; const GERMAN_WEEKDAYS = ['mo', 'di', 'mi', 'do', 'fr', 'sa', 'so'];
const MONTH_NAMES = [ const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June', 'January', 'February', 'March', 'April', 'May', 'June',
@@ -43,6 +43,13 @@ export default function CalendarScreen() {
const selectedDateRef = useRef(selectedDate); const selectedDateRef = useRef(selectedDate);
selectedDateRef.current = selectedDate; selectedDateRef.current = selectedDate;
useFocusEffect(
useCallback(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => true);
return () => sub.remove();
}, [])
);
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);
@@ -181,17 +188,17 @@ export default function CalendarScreen() {
return ( return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}> <SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<GestureDetector gesture={pan}> <Header title="Calendar" showLogo={false} />
<View style={styles.flex}>
<Header title="Calendar" showLogo={false} />
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}> <KeyboardAvoidingView style={styles.kbAvoid} behavior="padding">
<Animated.View <ScrollView style={styles.scrollBody} showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
onLayout={(e) => { <GestureDetector gesture={pan}>
gridWidthRef.current = e.nativeEvent.layout.width; <Animated.View
}} onLayout={(e) => {
style={[styles.calendarArea, { transform: [{ translateX }] }]} gridWidthRef.current = e.nativeEvent.layout.width;
> }}
style={[styles.calendarArea, { transform: [{ translateX }] }]}
>
<View style={styles.monthRow}> <View style={styles.monthRow}>
<TouchableOpacity <TouchableOpacity
onPress={() => transitionTo(-1)} onPress={() => transitionTo(-1)}
@@ -199,7 +206,7 @@ export default function CalendarScreen() {
activeOpacity={0.7} activeOpacity={0.7}
> >
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M15 18l-6-6 6-6" stroke={theme.textFaint} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" /> <Path d="M15 18l-6-6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
@@ -207,7 +214,7 @@ export default function CalendarScreen() {
<TouchableOpacity onPress={() => setMonthPickerVisible(true)} activeOpacity={0.7} style={styles.monthButton}> <TouchableOpacity onPress={() => setMonthPickerVisible(true)} activeOpacity={0.7} style={styles.monthButton}>
<Text style={[styles.monthLabel, { color: theme.text }]}>{format(visibleMonth, 'MMMM')}</Text> <Text style={[styles.monthLabel, { color: theme.text }]}>{format(visibleMonth, 'MMMM')}</Text>
<Svg width={14} height={14} viewBox="0 0 24 24"> <Svg width={14} height={14} viewBox="0 0 24 24">
<Path d="M6 9l6 6 6-6" stroke={theme.textMuted} strokeWidth={2} 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}> <TouchableOpacity onPress={() => setYearPickerVisible(true)} activeOpacity={0.7} style={styles.yearButton}>
@@ -221,7 +228,7 @@ export default function CalendarScreen() {
activeOpacity={0.7} activeOpacity={0.7}
> >
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M9 6l6 6-6 6" stroke={theme.textFaint} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" /> <Path d="M9 6l6 6-6 6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -252,8 +259,9 @@ export default function CalendarScreen() {
</View> </View>
))} ))}
</Animated.View> </Animated.View>
</GestureDetector>
<View style={styles.panelHeader}> <View style={styles.panelHeader}>
<Text style={[styles.panelDate, { color: theme.text }]}>{format(selectedDate, 'EEEE, MMMM d')}</Text> <Text style={[styles.panelDate, { color: theme.text }]}>{format(selectedDate, 'EEEE, MMMM d')}</Text>
{selectedDayTasks.length > 0 && ( {selectedDayTasks.length > 0 && (
<Text style={[styles.panelCount, { color: theme.textFaint }]}> <Text style={[styles.panelCount, { color: theme.textFaint }]}>
@@ -276,14 +284,14 @@ export default function CalendarScreen() {
onPress={() => handleToggleComplete(task.id)} onPress={() => handleToggleComplete(task.id)}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Svg width={22} height={22} viewBox="0 0 24 24"> <Svg width={30} height={30} viewBox="0 0 24 24">
{task.completed ? ( {task.completed ? (
<> <>
<Circle cx={12} cy={12} r={10} fill={theme.accent} /> <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" /> <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.textMuted} strokeWidth={2} fill="none" /> <Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2.5} fill="none" />
)} )}
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
@@ -303,9 +311,7 @@ export default function CalendarScreen() {
<TouchableOpacity style={styles.menuButton} onPress={() => openTaskEdit(task.id)} activeOpacity={0.7} accessibilityRole="button" accessibilityLabel={`Edit ${task.title}`}> <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"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Circle cx={12} cy={5} r={1.5} fill={theme.textMuted} /> <Path d="M9 6l6 6-6 6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
<Circle cx={12} cy={12} r={1.5} fill={theme.textMuted} />
<Circle cx={12} cy={19} r={1.5} fill={theme.textMuted} />
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -337,28 +343,27 @@ 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>
{modals(() => {})} {modals(() => {})}
<OptionPickerModal <OptionPickerModal
visible={monthPickerVisible} visible={monthPickerVisible}
title="Select Month" title="Select Month"
options={monthOptions} options={monthOptions}
selectedValue={String(visibleMonth.getMonth())} selectedValue={String(visibleMonth.getMonth())}
onSelect={handleSelectMonth} onSelect={handleSelectMonth}
onClose={() => setMonthPickerVisible(false)} onClose={() => setMonthPickerVisible(false)}
/> />
<OptionPickerModal <OptionPickerModal
visible={yearPickerVisible} visible={yearPickerVisible}
title="Select Year" title="Select Year"
options={yearOptions} options={yearOptions}
selectedValue={String(visibleMonth.getFullYear())} selectedValue={String(visibleMonth.getFullYear())}
onSelect={handleSelectYear} onSelect={handleSelectYear}
onClose={() => setYearPickerVisible(false)} onClose={() => setYearPickerVisible(false)}
/> />
</View>
</GestureDetector>
</SafeAreaView> </SafeAreaView>
); );
} }
@@ -376,7 +381,7 @@ function cellTitle(tasks: Task[]): string | null {
function cellColor(tasks: Task[], categories: { id: string; color: string }[]): string { function cellColor(tasks: Task[], categories: { id: string; color: string }[]): string {
if (tasks.length === 0) return '#8E8E8E'; if (tasks.length === 0) return '#8E8E8E';
const cat = categories.find((c) => c.id === tasks[0].categoryId); const cat = categories.find((c) => c.id === tasks[0].categoryId);
return cat?.color ?? '#8E8E8E'; return cat ? desaturate(cat.color, 0.3) : '#8E8E8E';
} }
interface DayCellProps { interface DayCellProps {
@@ -393,7 +398,7 @@ interface DayCellProps {
const DayCell = React.memo(function DayCell({ day, label, dotColor, selected, today, inMonth, theme, onPress }: DayCellProps) { const DayCell = React.memo(function DayCell({ day, label, dotColor, selected, today, inMonth, theme, onPress }: DayCellProps) {
return ( return (
<TouchableOpacity <TouchableOpacity
style={[styles.dayCell, selected && { backgroundColor: ORANGE }]} style={[styles.dayCell, selected && { backgroundColor: theme.accentSoft, borderColor: theme.accentBorder }]}
onPress={() => onPress(day)} onPress={() => onPress(day)}
activeOpacity={0.7} activeOpacity={0.7}
> >
@@ -401,8 +406,8 @@ const DayCell = React.memo(function DayCell({ day, label, dotColor, selected, to
style={[ style={[
styles.dayNumber, styles.dayNumber,
{ color: theme.text }, { color: theme.text },
!inMonth && { color: theme.borderStrong }, !inMonth && { color: theme.textMuted },
today && !selected && { color: ORANGE }, today && !selected && { color: theme.accent },
selected && styles.dayNumberSelected, selected && styles.dayNumberSelected,
]} ]}
> >
@@ -425,12 +430,15 @@ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, },
flex: { scrollContent: {
paddingBottom: 16,
flexGrow: 1,
},
scrollBody: {
flex: 1, flex: 1,
}, },
scrollContent: { kbAvoid: {
paddingBottom: 96, flex: 1,
flexGrow: 1,
}, },
calendarArea: { calendarArea: {
paddingHorizontal: 12, paddingHorizontal: 12,
@@ -565,8 +573,8 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
}, },
checkCircle: { checkCircle: {
width: 24, width: 30,
marginRight: 10, marginRight: 12,
}, },
eventTitleTouch: { eventTitleTouch: {
flex: 1, flex: 1,
+23 -7
View File
@@ -1,5 +1,6 @@
import React from 'react'; import React, { useCallback } from 'react';
import { View, Text, StyleSheet, SafeAreaView } from 'react-native'; import { View, Text, StyleSheet, SafeAreaView, BackHandler, KeyboardAvoidingView } from 'react-native';
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';
import { TaskList } from '@/components/TaskList'; import { TaskList } from '@/components/TaskList';
@@ -12,6 +13,13 @@ export default function TasksScreen() {
const { theme } = useSettings(); const { theme } = useSettings();
const [selectedCategory, setSelectedCategory] = React.useState<string>('all'); const [selectedCategory, setSelectedCategory] = React.useState<string>('all');
useFocusEffect(
useCallback(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => true);
return () => sub.remove();
}, [])
);
if (!isReady) { if (!isReady) {
return ( return (
<View style={[styles.container, { backgroundColor: theme.background }]}> <View style={[styles.container, { backgroundColor: theme.background }]}>
@@ -22,12 +30,17 @@ export default function TasksScreen() {
return ( return (
<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={selectedCategory} onSelect={setSelectedCategory} />
</View> </View>
<TaskList categoryId={selectedCategory} /> <KeyboardAvoidingView
<QuickAddBar /> style={styles.kbAvoid}
behavior="padding"
>
<TaskList categoryId={selectedCategory} />
<QuickAddBar />
</KeyboardAvoidingView>
</SafeAreaView> </SafeAreaView>
); );
} }
@@ -37,11 +50,14 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
}, },
categoryFilterWrapper: { categoryFilterWrapper: {
height: 36, justifyContent: 'center',
},
kbAvoid: {
flex: 1,
}, },
loadingContainer: { loadingContainer: {
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
}); });
+23 -7
View File
@@ -1,5 +1,6 @@
import React, { useState } from 'react'; import React, { useState, useCallback } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, Switch, TouchableOpacity, Alert, Linking, Modal, Pressable } from 'react-native'; import { View, Text, StyleSheet, SafeAreaView, ScrollView, Switch, TouchableOpacity, Alert, Linking, Modal, Pressable, BackHandler } from 'react-native';
import { useFocusEffect, useRouter } from 'expo-router';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { ListItem } from '@/components/ListItem'; import { ListItem } from '@/components/ListItem';
import { OptionPickerModal } from '@/components/OptionPickerModal'; import { OptionPickerModal } from '@/components/OptionPickerModal';
@@ -17,8 +18,10 @@ import { getLastSyncTime } from '@/database/sync';
import Category from '@/models/Category'; import Category from '@/models/Category';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
import ColorWheel from '@/components/ColorWheel'; import ColorWheel from '@/components/ColorWheel';
import { getLastVisitedTab, tabHref } from '@/utils/tabHistory';
export default function SettingsScreen() { export default function SettingsScreen() {
const router = useRouter();
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, apiUrl, accentColor, setAccentColor, todoAheadDays, setTodoAheadDays } = useSettings(); const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, apiUrl, accentColor, setAccentColor, todoAheadDays, setTodoAheadDays } = 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);
@@ -75,8 +78,21 @@ export default function SettingsScreen() {
refreshSyncStatus(); refreshSyncStatus();
}, []); }, []);
useFocusEffect(
useCallback(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
const target = getLastVisitedTab();
if (target && target !== 'settings') {
router.navigate(tabHref(target));
}
return true;
});
return () => sub.remove();
}, [router])
);
const defaultCategory = categories.find((c) => c.id === defaultCategoryId); const defaultCategory = categories.find((c) => c.id === defaultCategoryId);
const defaultCategoryLabel = defaultCategory?.name ?? (categories.length > 0 ? categories[0].name : 'None'); const defaultCategoryLabel = defaultCategory?.name ?? 'None';
const reminderLabel = REMINDER_OPTIONS.find((o) => o.value === reminderPreference)?.label ?? 'No reminder'; const reminderLabel = REMINDER_OPTIONS.find((o) => o.value === reminderPreference)?.label ?? 'No reminder';
const openCustomAccent = () => { const openCustomAccent = () => {
@@ -123,7 +139,7 @@ export default function SettingsScreen() {
> >
<View style={[styles.addIcon, { backgroundColor: theme.accentSoft }]}> <View style={[styles.addIcon, { backgroundColor: theme.accentSoft }]}>
<Svg width={16} height={16} viewBox="0 0 24 24"> <Svg width={16} height={16} viewBox="0 0 24 24">
<Path d="M12 5v14M5 12h14" stroke={theme.accent} strokeWidth={2} strokeLinecap="round" /> <Path d="M12 5v14M5 12h14" stroke={theme.accentText} strokeWidth={2} strokeLinecap="round" />
</Svg> </Svg>
</View> </View>
<Text style={[styles.addCategoryText, { color: theme.accent }]}>Add Category</Text> <Text style={[styles.addCategoryText, { color: theme.accent }]}>Add Category</Text>
@@ -242,8 +258,8 @@ export default function SettingsScreen() {
<OptionPickerModal <OptionPickerModal
visible={picker === 'category'} visible={picker === 'category'}
title="Default Category" title="Default Category"
options={categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))} options={[{ value: '', label: 'None', color: '#9E9E9E' }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
selectedValue={defaultCategoryId || categories[0]?.id} selectedValue={defaultCategoryId}
onSelect={handleDefaultCategory} onSelect={handleDefaultCategory}
onClose={() => setPicker(null)} onClose={() => setPicker(null)}
/> />
@@ -298,7 +314,7 @@ export default function SettingsScreen() {
<Text style={[styles.accentModalTitle, { color: theme.text }]}>Custom Accent Color</Text> <Text style={[styles.accentModalTitle, { color: theme.text }]}>Custom Accent Color</Text>
<TouchableOpacity onPress={() => setCustomAccentVisible(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}> <TouchableOpacity onPress={() => setCustomAccentVisible(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Svg width={18} height={18} viewBox="0 0 24 24"> <Svg width={18} height={18} viewBox="0 0 24 24">
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" /> <Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
+10 -2
View File
@@ -1,5 +1,6 @@
import React from 'react'; import React, { useCallback } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView } from 'react-native'; import { View, Text, StyleSheet, SafeAreaView, ScrollView, BackHandler } from 'react-native';
import { useFocusEffect } from 'expo-router';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { useStats } from '@/hooks/useStats'; import { useStats } from '@/hooks/useStats';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
@@ -8,6 +9,13 @@ export default function StatsScreen() {
const { theme } = useSettings(); const { theme } = useSettings();
const stats = useStats(); const stats = useStats();
useFocusEffect(
useCallback(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => true);
return () => sub.remove();
}, [])
);
const maxDaily = Math.max(1, ...stats.daily.map((d) => d.count)); const maxDaily = Math.max(1, ...stats.daily.map((d) => d.count));
return ( return (
+1 -1
View File
@@ -160,7 +160,7 @@ export default function SubtaskDetailScreen() {
rightAction={ rightAction={
<TouchableOpacity onPress={confirmDelete} activeOpacity={0.7} style={[styles.deleteButton, { borderColor: theme.accentBorder, backgroundColor: theme.accentSoft }]}> <TouchableOpacity onPress={confirmDelete} activeOpacity={0.7} style={[styles.deleteButton, { borderColor: theme.accentBorder, backgroundColor: theme.accentSoft }]}>
<Svg width={22} height={22} viewBox="0 0 24 24"> <Svg width={22} height={22} viewBox="0 0 24 24">
<Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" fill="none" /> <Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={theme.accent} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
} }
+10 -10
View File
@@ -58,8 +58,8 @@ function CollapsibleSection({ title, children, defaultExpanded = false, icon }:
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Path <Path
d="M6 9l6 6 6-6" d="M6 9l6 6 6-6"
stroke={theme.textMuted} stroke={theme.textSecondary}
strokeWidth={2} strokeWidth={2.5}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
fill="none" fill="none"
@@ -245,7 +245,7 @@ export default function TaskDetailScreen() {
rightAction={ rightAction={
<TouchableOpacity onPress={handleDelete} activeOpacity={0.7} style={[styles.deleteButton, { borderColor: theme.accentBorder, backgroundColor: theme.accentSoft }]}> <TouchableOpacity onPress={handleDelete} activeOpacity={0.7} style={[styles.deleteButton, { borderColor: theme.accentBorder, backgroundColor: theme.accentSoft }]}>
<Svg width={22} height={22} viewBox="0 0 24 24"> <Svg width={22} height={22} viewBox="0 0 24 24">
<Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" fill="none" /> <Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={theme.accent} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
} }
@@ -285,8 +285,8 @@ export default function TaskDetailScreen() {
<CollapsibleSection title="Date & Time" icon={ <CollapsibleSection title="Date & Time" icon={
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke={theme.accent} strokeWidth={1.5} fill="none" /> <Circle cx={12} cy={12} r={10} stroke={theme.accent} strokeWidth={1.8} fill="none" />
<Path d="M12 6v6l4 2" stroke={theme.accent} strokeWidth={1.5} strokeLinecap="round" /> <Path d="M12 6v6l4 2" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" />
</Svg> </Svg>
} defaultExpanded={!!dueDate}> } defaultExpanded={!!dueDate}>
<DateTimePickerComponent control={control} /> <DateTimePickerComponent control={control} />
@@ -297,7 +297,7 @@ export default function TaskDetailScreen() {
<Path <Path
d="M17 2l4 4-4 4M3 11v-1a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 13v1a4 4 0 0 1-4 4H3" d="M17 2l4 4-4 4M3 11v-1a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 13v1a4 4 0 0 1-4 4H3"
stroke={theme.accent} stroke={theme.accent}
strokeWidth={1.5} strokeWidth={1.8}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
fill="none" fill="none"
@@ -321,7 +321,7 @@ export default function TaskDetailScreen() {
<Path <Path
d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0" d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0"
stroke={theme.accent} stroke={theme.accent}
strokeWidth={1.5} strokeWidth={1.8}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
fill="none" fill="none"
@@ -337,8 +337,8 @@ export default function TaskDetailScreen() {
<CollapsibleSection title="Assignee" icon={ <CollapsibleSection title="Assignee" icon={
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke={theme.accent} strokeWidth={1.5} fill="none" /> <Circle cx={12} cy={12} r={10} stroke={theme.accent} strokeWidth={1.8} fill="none" />
<Path d="M12 10v6M12 19v1" stroke={theme.accent} strokeWidth={1.5} strokeLinecap="round" /> <Path d="M12 10v6M12 19v1" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" />
</Svg> </Svg>
} defaultExpanded={!!assigneeId}> } defaultExpanded={!!assigneeId}>
<AssigneeSelector <AssigneeSelector
@@ -350,7 +350,7 @@ export default function TaskDetailScreen() {
<CollapsibleSection title="Description" icon={ <CollapsibleSection title="Description" icon={
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M4 6h16M4 12h16M4 18h10" stroke={theme.accent} strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" fill="none" /> <Path d="M4 6h16M4 12h16M4 18h10" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg> </Svg>
} defaultExpanded={!!methods.getValues('description')}> } defaultExpanded={!!methods.getValues('description')}>
<Controller <Controller
+36 -1
View File
@@ -22,12 +22,14 @@
"expo-router": "~57.0.10", "expo-router": "~57.0.10",
"expo-sqlite": "~57.0.1", "expo-sqlite": "~57.0.1",
"expo-status-bar": "~57.0.1", "expo-status-bar": "~57.0.1",
"expo-system-ui": "~57.0.2",
"expo-updates": "~57.0.12", "expo-updates": "~57.0.12",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-hook-form": "^7.51.5", "react-hook-form": "^7.51.5",
"react-native": "0.86.2", "react-native": "0.86.2",
"react-native-gesture-handler": "2.32.0", "react-native-gesture-handler": "2.32.0",
"react-native-keyboard-controller": "1.21.9",
"react-native-safe-area-context": "5.7.0", "react-native-safe-area-context": "5.7.0",
"react-native-screens": "4.26.0", "react-native-screens": "4.26.0",
"react-native-svg": "^15.15.4", "react-native-svg": "^15.15.4",
@@ -6313,6 +6315,26 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/expo-system-ui": {
"version": "57.0.2",
"resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-57.0.2.tgz",
"integrity": "sha512-zABCRqFSioDBAo/RtmS0dQiGgDtDPZUVk01Y3Ti4ducobNM4HTM2sNAtq+YPpEUhaVW3hccPVsEUH4LK/ADVhA==",
"license": "MIT",
"dependencies": {
"@react-native/normalize-colors": "0.86.2",
"debug": "^4.3.2"
},
"peerDependencies": {
"expo": "*",
"react-native": "*",
"react-native-web": "*"
},
"peerDependenciesMeta": {
"react-native-web": {
"optional": true
}
}
},
"node_modules/expo-updates": { "node_modules/expo-updates": {
"version": "57.0.12", "version": "57.0.12",
"resolved": "https://registry.npmjs.org/expo-updates/-/expo-updates-57.0.12.tgz", "resolved": "https://registry.npmjs.org/expo-updates/-/expo-updates-57.0.12.tgz",
@@ -10263,12 +10285,25 @@
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz", "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz",
"integrity": "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==", "integrity": "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==",
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"react": "*", "react": "*",
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/react-native-keyboard-controller": {
"version": "1.21.9",
"resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.21.9.tgz",
"integrity": "sha512-+TkkFldht4+AXBQeDy1hLE7iqiW8/NkY/ekhcFsKIiRdI9qC5JDzx0TfAg1iYZB2IeOXppmURIy2jFCUjOcV1w==",
"license": "MIT",
"dependencies": {
"react-native-is-edge-to-edge": "^1.2.1"
},
"peerDependencies": {
"react": "*",
"react-native": "*",
"react-native-reanimated": ">=3.0.0"
}
},
"node_modules/react-native-reanimated": { "node_modules/react-native-reanimated": {
"version": "4.5.1", "version": "4.5.1",
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.1.tgz", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.1.tgz",
+2
View File
@@ -16,12 +16,14 @@
"expo-router": "~57.0.10", "expo-router": "~57.0.10",
"expo-sqlite": "~57.0.1", "expo-sqlite": "~57.0.1",
"expo-status-bar": "~57.0.1", "expo-status-bar": "~57.0.1",
"expo-system-ui": "~57.0.2",
"expo-updates": "~57.0.12", "expo-updates": "~57.0.12",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-hook-form": "^7.51.5", "react-hook-form": "^7.51.5",
"react-native": "0.86.2", "react-native": "0.86.2",
"react-native-gesture-handler": "2.32.0", "react-native-gesture-handler": "2.32.0",
"react-native-keyboard-controller": "1.21.9",
"react-native-safe-area-context": "5.7.0", "react-native-safe-area-context": "5.7.0",
"react-native-screens": "4.26.0", "react-native-screens": "4.26.0",
"react-native-svg": "^15.15.4", "react-native-svg": "^15.15.4",
@@ -60,8 +60,11 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
await sendFriendRequest(username); await sendFriendRequest(username);
onChange(friendId); onChange(friendId);
setShowModal(false); setShowModal(false);
} catch (err) { } catch (err: any) {
console.error('Failed to add friend:', err); console.error('Failed to add friend:', err.message, err.details || '');
if (err.details) {
console.error('Validation details:', JSON.stringify(err.details, null, 2));
}
} }
}; };
@@ -76,7 +79,7 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
onPress={() => !disabled && setShowModal(true)} onPress={() => !disabled && setShowModal(true)}
activeOpacity={0.8} activeOpacity={0.8}
> >
<Text style={[styles.selectorIcon, { color: theme.accent }]}>👤</Text> <Text style={[styles.selectorIcon, { color: theme.accentStrong }]}>👤</Text>
<View style={styles.selectorContent}> <View style={styles.selectorContent}>
<Text style={[styles.selectorLabel, { color: theme.textMuted }]}>Assignee</Text> <Text style={[styles.selectorLabel, { color: theme.textMuted }]}>Assignee</Text>
<Text <Text
@@ -128,7 +131,7 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
</View> </View>
<Text style={[styles.optionText, { color: theme.text }]}>Unassigned</Text> <Text style={[styles.optionText, { color: theme.text }]}>Unassigned</Text>
{selectedFriend && ( {selectedFriend && (
<Text style={[styles.checkmark, { color: theme.accent }]}></Text> <Text style={[styles.checkmark, { color: theme.accentStrong }]}></Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -158,7 +161,7 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
</View> </View>
<Text style={[styles.optionText, { color: theme.text }]}>{item.username}</Text> <Text style={[styles.optionText, { color: theme.text }]}>{item.username}</Text>
{value === item.id && ( {value === item.id && (
<Text style={[styles.checkmark, { color: theme.accent }]}></Text> <Text style={[styles.checkmark, { color: theme.accentStrong }]}></Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
)} )}
@@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView, KeyboardAvoidingView } from 'react-native'; import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView, KeyboardAvoidingView, Platform, Pressable } from 'react-native';
import Category from '@/models/Category'; import Category from '@/models/Category';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { CATEGORY_COLORS } from '@/constants'; import { CATEGORY_COLORS } from '@/constants';
@@ -59,96 +59,111 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
return ( return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}> <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<KeyboardAvoidingView <KeyboardAvoidingView
style={styles.overlay} style={styles.fill}
behavior="padding" behavior={Platform.OS === 'ios' ? 'padding' : undefined}
> >
<ScrollView contentContainerStyle={styles.overlay} keyboardShouldPersistTaps="handled"> <Pressable
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}> style={styles.overlay}
<View style={styles.header}> onPress={onClose}
<Text style={[styles.title, { color: theme.text }]}> accessibilityRole="none"
{category ? 'Edit Category' : 'New Category'} >
</Text> <Pressable
<TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} accessibilityRole="button" accessibilityLabel="Close category editor"> style={[styles.sheet, { backgroundColor: theme.sheetBg }]}
<Svg width={18} height={18} viewBox="0 0 24 24"> onPress={() => {}}
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" /> >
</Svg> <ScrollView
</TouchableOpacity> showsVerticalScrollIndicator={false}
</View> keyboardShouldPersistTaps="handled"
<Text style={[styles.label, { color: theme.textSecondary }]}>Name</Text>
<TextInput
style={[
styles.input,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
]}
placeholder="Category name"
placeholderTextColor={theme.textMuted}
value={name}
onChangeText={setName}
maxLength={30}
autoFocus
autoCapitalize="sentences"
/>
<Text style={[styles.label, { color: theme.textSecondary }]}>Color</Text>
<View style={styles.colorGrid}>
{CATEGORY_COLORS.map((c) => (
<TouchableOpacity
key={c}
style={[
styles.colorOption,
{ backgroundColor: c },
color === c && styles.colorOptionSelected,
]}
onPress={() => setColor(c)}
activeOpacity={0.8}
accessibilityRole="radio"
accessibilityLabel={`Color ${c}`}
accessibilityState={{ selected: color === c }}
>
{color === c && (
<Svg width={16} height={16} viewBox="0 0 24 24">
<Path d="M5 12l5 5 9-10" stroke="#FFFFFF" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
)}
</TouchableOpacity>
))}
</View>
<ColorPickerInput value={color} onChange={setColor} />
<View style={styles.actions}>
{category && (
<TouchableOpacity
style={[styles.deleteButton, { borderColor: theme.accentBorder }]}
onPress={handleDelete}
disabled={!canDelete}
activeOpacity={0.7}
>
<Text style={[styles.deleteButtonText, { color: canDelete ? theme.accent : theme.textMuted }]}>
Delete
</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.saveButton, { backgroundColor: theme.accent }, !name.trim() && styles.saveButtonDisabled]}
onPress={handleSave}
disabled={!name.trim()}
activeOpacity={0.8}
> >
<Text style={[styles.saveButtonText, { color: theme.accentText }]}>Save</Text> <View style={styles.header}>
</TouchableOpacity> <Text style={[styles.title, { color: theme.text }]}>
</View> {category ? 'Edit Category' : 'New Category'}
</View> </Text>
</ScrollView> <TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} accessibilityRole="button" accessibilityLabel="Close category editor">
<Svg width={18} height={18} viewBox="0 0 24 24">
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
</Svg>
</TouchableOpacity>
</View>
<Text style={[styles.label, { color: theme.textSecondary }]}>Name</Text>
<TextInput
style={[
styles.input,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text },
]}
placeholder="Category name"
placeholderTextColor={theme.textMuted}
value={name}
onChangeText={setName}
maxLength={30}
autoFocus
autoCapitalize="sentences"
/>
<Text style={[styles.label, { color: theme.textSecondary }]}>Color</Text>
<View style={styles.colorGrid}>
{CATEGORY_COLORS.map((c) => (
<TouchableOpacity
key={c}
style={[
styles.colorOption,
{ backgroundColor: c },
color === c && styles.colorOptionSelected,
]}
onPress={() => setColor(c)}
activeOpacity={0.8}
accessibilityRole="radio"
accessibilityLabel={`Color ${c}`}
accessibilityState={{ selected: color === c }}
>
{color === c && (
<Svg width={16} height={16} viewBox="0 0 24 24">
<Path d="M5 12l5 5 9-10" stroke="#FFFFFF" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
)}
</TouchableOpacity>
))}
</View>
<ColorPickerInput value={color} onChange={setColor} />
<View style={styles.actions}>
{category && (
<TouchableOpacity
style={[styles.deleteButton, { borderColor: theme.accentBorder }]}
onPress={handleDelete}
disabled={!canDelete}
activeOpacity={0.7}
>
<Text style={[styles.deleteButtonText, { color: canDelete ? theme.accent : theme.textMuted }]}>
Delete
</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.saveButton, { backgroundColor: theme.accent }, !name.trim() && styles.saveButtonDisabled]}
onPress={handleSave}
disabled={!name.trim()}
activeOpacity={0.8}
>
<Text style={[styles.saveButtonText, { color: theme.accentText }]}>Save</Text>
</TouchableOpacity>
</View>
</ScrollView>
</Pressable>
</Pressable>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</Modal> </Modal>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
fill: {
flex: 1,
},
overlay: { overlay: {
flex: 1, flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)', backgroundColor: 'rgba(0,0,0,0.55)',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
padding: 24, padding: 24,
@@ -156,6 +171,7 @@ const styles = StyleSheet.create({
sheet: { sheet: {
width: '100%', width: '100%',
maxWidth: 380, maxWidth: 380,
maxHeight: '100%',
borderRadius: 16, borderRadius: 16,
padding: 20, padding: 20,
gap: 8, gap: 8,
@@ -1,8 +1,9 @@
import React from 'react'; import React from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native';
import { useRouter } from 'expo-router';
import { useUniqueCategories } from '@/hooks/useDatabase'; import { useUniqueCategories } from '@/hooks/useDatabase';
import { useSettings, ThemeColors } from '@/theme'; import { useSettings, ThemeColors } from '@/theme';
import Category from '@/models/Category'; import Svg, { Path, Circle } from 'react-native-svg';
interface CategoryFilterProps { interface CategoryFilterProps {
selected: string; selected: string;
@@ -12,10 +13,7 @@ interface CategoryFilterProps {
export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) { export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
const categories = useUniqueCategories(); const categories = useUniqueCategories();
const { theme } = useSettings(); const { theme } = useSettings();
const router = useRouter();
if (categories.length === 0) {
return null;
}
return ( return (
<ScrollView <ScrollView
@@ -43,6 +41,22 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
theme={theme} theme={theme}
/> />
))} ))}
<TouchableOpacity
style={[styles.manageButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
onPress={() => router.push('/settings')}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel="Manage categories"
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
>
<Svg width={14} height={14} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={9} stroke={theme.textSecondary} strokeWidth={2.2} fill="none" />
<Circle cx={12} cy={8.5} r={1.2} fill={theme.textSecondary} />
<Circle cx={12} cy={15.5} r={1.2} fill={theme.textSecondary} />
<Path d="M7.8 6.2l1 1.7M16.2 16.1l1 1.7M7.8 17.8l1-1.7M16.2 7.9l1-1.7" stroke={theme.textSecondary} strokeWidth={1.8} strokeLinecap="round" />
</Svg>
<Text style={[styles.manageLabel, { color: theme.textSecondary }]}>Manage</Text>
</TouchableOpacity>
</ScrollView> </ScrollView>
); );
} }
@@ -66,18 +80,18 @@ function CategoryButton({ name, color, selected, onPress, theme }: CategoryButto
]} ]}
onPress={onPress} onPress={onPress}
activeOpacity={0.8} activeOpacity={0.8}
hitSlop={{ top: 6, bottom: 6, left: 4, right: 4 }}
> >
<View <View
style={[ style={[
styles.colorDot, styles.colorDot,
{ backgroundColor: color }, { backgroundColor: color },
selected && styles.colorDotSelected,
]} ]}
/> />
<Text style={[ <Text style={[
styles.buttonText, styles.buttonText,
{ color: theme.textSecondary }, { color: theme.textSecondary },
selected && { color: theme.accent, fontWeight: '600' }, selected && { color: theme.text, fontWeight: '600' },
]}> ]}>
{name} {name}
</Text> </Text>
@@ -87,39 +101,48 @@ function CategoryButton({ name, color, selected, onPress, theme }: CategoryButto
const styles = StyleSheet.create({ const styles = StyleSheet.create({
scrollView: { scrollView: {
paddingVertical: 0, paddingVertical: 6,
marginBottom: 0,
}, },
container: { container: {
paddingHorizontal: 12, paddingHorizontal: 12,
paddingTop: 0, gap: 8,
paddingBottom: 0, alignItems: 'center',
gap: 6,
alignItems: 'flex-start',
}, },
button: { button: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 6, paddingVertical: 10,
borderRadius: 16, borderRadius: 18,
borderWidth: 1, borderWidth: 1.5,
minWidth: 64, minHeight: 38,
justifyContent: 'center', justifyContent: 'center',
gap: 6, gap: 8,
}, },
colorDot: { colorDot: {
width: 8, width: 8,
height: 8, height: 8,
borderRadius: 4, borderRadius: 4,
}, },
colorDotSelected: {
width: 10,
height: 10,
borderRadius: 5,
},
buttonText: { buttonText: {
fontSize: 12, fontSize: 12,
fontWeight: '500', fontWeight: '500',
}, },
}); manageButton: {
alignSelf: 'center',
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 18,
borderWidth: 1,
borderStyle: 'dashed',
minHeight: 38,
justifyContent: 'center',
},
manageLabel: {
fontSize: 12,
fontWeight: '600',
},
});
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Modal, Pressable, KeyboardAvoidingView } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Modal, Pressable, KeyboardAvoidingView } from 'react-native';
import { useCategories } from '@/hooks/useDatabase'; import { useCategories } from '@/hooks/useDatabase';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import Svg, { Path } from 'react-native-svg';
interface CategorySelectorProps { interface CategorySelectorProps {
value: string; value: string;
@@ -37,7 +38,7 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
</View> </View>
</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.textMuted} strokeWidth={2} 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" />
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
{error && <Text style={[styles.errorText, { color: '#E53935' }]}>{error}</Text>} {error && <Text style={[styles.errorText, { color: '#E53935' }]}>{error}</Text>}
@@ -72,7 +73,7 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
<Text style={[ <Text style={[
styles.categoryName, styles.categoryName,
{ color: theme.textSecondary }, { color: theme.textSecondary },
!value && { color: theme.accent, fontWeight: '600' }, !value && { color: theme.accentStrong, fontWeight: '600' },
]}> ]}>
None None
</Text> </Text>
@@ -100,7 +101,7 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
<Text style={[ <Text style={[
styles.categoryName, styles.categoryName,
{ color: theme.textSecondary }, { color: theme.textSecondary },
value === category.id && { color: theme.accent, fontWeight: '600' }, value === category.id && { color: theme.accentStrong, fontWeight: '600' },
]}> ]}>
{category.name} {category.name}
</Text> </Text>
@@ -120,8 +121,6 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
); );
} }
import Svg, { Path } from 'react-native-svg';
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
gap: 6, gap: 6,
@@ -69,31 +69,44 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
onChange(newColor); onChange(newColor);
}, [hue, saturation, value, onChange]); }, [hue, saturation, value, onChange]);
const updateFromWheel = (locationX: number, locationY: number) => {
const center = WHEEL_SIZE / 2;
const dx = locationX - center;
const dy = locationY - center;
const distance = Math.sqrt(dx * dx + dy * dy);
const radius = (WHEEL_SIZE - THUMB_SIZE) / 2;
if (distance > radius) return;
const angle = Math.atan2(dy, dx) * (180 / Math.PI);
let h = angle + 180;
if (h >= 360) h -= 360;
setHue(h);
setSaturation(distance / radius);
setValue(1 - distance / radius);
};
const wheelPanResponder = React.useMemo(() => PanResponder.create({ const wheelPanResponder = React.useMemo(() => PanResponder.create({
onStartShouldSetPanResponder: () => true, onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true, onMoveShouldSetPanResponder: () => true,
onPanResponderMove: (_, gesture) => { onPanResponderGrant: (event) => {
const center = WHEEL_SIZE / 2; updateFromWheel(event.nativeEvent.locationX, event.nativeEvent.locationY);
const dx = gesture.moveX - center; },
const dy = gesture.moveY - center; onPanResponderMove: (event) => {
const distance = Math.sqrt(dx * dx + dy * dy); updateFromWheel(event.nativeEvent.locationX, event.nativeEvent.locationY);
const radius = (WHEEL_SIZE - THUMB_SIZE) / 2;
if (distance > radius) return;
const angle = Math.atan2(dy, dx) * (180 / Math.PI);
let h = angle + 180;
if (h >= 360) h -= 360;
setHue(h);
setSaturation(distance / radius);
setValue(1 - distance / radius);
}, },
}), []); }), []);
const setHueFromLocation = (locationX: number) => {
setHue(Math.min(360, Math.max(0, (locationX / WHEEL_SIZE) * 360)));
};
const huePanResponder = React.useMemo(() => PanResponder.create({ const huePanResponder = React.useMemo(() => PanResponder.create({
onStartShouldSetPanResponder: () => true, onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true, onMoveShouldSetPanResponder: () => true,
onPanResponderMove: (_, gesture) => { onPanResponderGrant: (event) => {
const h = (gesture.moveX / WHEEL_SIZE) * 360; setHueFromLocation(event.nativeEvent.locationX);
setHue(Math.min(360, Math.max(0, h))); },
onPanResponderMove: (event) => {
setHueFromLocation(event.nativeEvent.locationX);
}, },
}), []); }), []);
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo, useRef } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { View, Text, StyleSheet } from 'react-native'; import { View, Text, StyleSheet } from 'react-native';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import Svg, { Rect, Circle, Defs, LinearGradient, Stop } from 'react-native-svg'; import Svg, { Rect, Circle, Defs, LinearGradient, Stop } from 'react-native-svg';
@@ -55,8 +55,6 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
const [saturation, setSaturation] = useState(initialHsv.s); const [saturation, setSaturation] = useState(initialHsv.s);
const [value, setValue] = useState(initialHsv.v); const [value, setValue] = useState(initialHsv.v);
const [prevColor, setPrevColor] = useState(color); const [prevColor, setPrevColor] = useState(color);
const wheelRef = useRef<Svg>(null);
const hueRef = useRef<Svg>(null);
if (prevColor !== color) { if (prevColor !== color) {
setPrevColor(color); setPrevColor(color);
@@ -71,14 +69,13 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
}, [hue, saturation, value, onChange]); }, [hue, saturation, value, onChange]);
const handleWheelMouseDown = (e: React.MouseEvent) => { const handleWheelMouseDown = (e: React.MouseEvent) => {
const rect = e.currentTarget.getBoundingClientRect();
const center = WHEEL_SIZE / 2;
const radius = (WHEEL_SIZE - THUMB_SIZE) / 2;
const handleMove = (moveEvent: MouseEvent) => { const handleMove = (moveEvent: MouseEvent) => {
if (!wheelRef.current) return;
const rect = (wheelRef.current as unknown as HTMLElement).getBoundingClientRect();
const center = WHEEL_SIZE / 2;
const dx = moveEvent.clientX - rect.left - center; const dx = moveEvent.clientX - rect.left - center;
const dy = moveEvent.clientY - rect.top - center; const dy = moveEvent.clientY - rect.top - center;
const distance = Math.sqrt(dx * dx + dy * dy); const distance = Math.sqrt(dx * dx + dy * dy);
const radius = (WHEEL_SIZE - THUMB_SIZE) / 2;
if (distance > radius) return; if (distance > radius) return;
const angle = Math.atan2(dy, dx) * (180 / Math.PI); const angle = Math.atan2(dy, dx) * (180 / Math.PI);
let h = angle + 180; let h = angle + 180;
@@ -97,9 +94,8 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
}; };
const handleHueMouseDown = (e: React.MouseEvent) => { const handleHueMouseDown = (e: React.MouseEvent) => {
const rect = e.currentTarget.getBoundingClientRect();
const handleMove = (moveEvent: MouseEvent) => { const handleMove = (moveEvent: MouseEvent) => {
if (!hueRef.current) return;
const rect = (hueRef.current as unknown as HTMLElement).getBoundingClientRect();
const h = ((moveEvent.clientX - rect.left) / WHEEL_SIZE) * 360; const h = ((moveEvent.clientX - rect.left) / WHEEL_SIZE) * 360;
setHue(Math.min(360, Math.max(0, h))); setHue(Math.min(360, Math.max(0, h)));
}; };
@@ -120,7 +116,7 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.wheelContainer}> <View style={styles.wheelContainer}>
<Svg width={WHEEL_SIZE} height={WHEEL_SIZE} ref={wheelRef} {...({ onMouseDown: handleWheelMouseDown } as object)}> <Svg width={WHEEL_SIZE} height={WHEEL_SIZE} {...({ onMouseDown: handleWheelMouseDown } as object)}>
<DefsElement> <DefsElement>
<LinearGradient id="satGradient" x1="0%" y1="0%" x2="100%" y2="0%"> <LinearGradient id="satGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<Stop offset="0%" stopColor={`hsl(${hue}, 100%, 50%)`} stopOpacity={1} /> <Stop offset="0%" stopColor={`hsl(${hue}, 100%, 50%)`} stopOpacity={1} />
@@ -155,7 +151,7 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
</View> </View>
<View style={styles.hueContainer}> <View style={styles.hueContainer}>
<Svg width={WHEEL_SIZE} height={36} ref={hueRef} {...({ onMouseDown: handleHueMouseDown } as object)}> <Svg width={WHEEL_SIZE} height={36} {...({ onMouseDown: handleHueMouseDown } as object)}>
<DefsElement> <DefsElement>
<LinearGradient id="hueGradient" x1="0%" y1="0%" x2="100%" y2="0%"> <LinearGradient id="hueGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<Stop offset="0%" stopColor="#FF0000" /> <Stop offset="0%" stopColor="#FF0000" />
@@ -1,6 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Modal } from 'react-native'; import { View, Text, TouchableOpacity, StyleSheet, Modal } from 'react-native';
import { DateTimePickerEvent } from '@react-native-community/datetimepicker'; import { DateTimePickerEvent } from '@react-native-community/datetimepicker';
import { useSettings } from '@/theme';
interface WebDateTimeInputProps { interface WebDateTimeInputProps {
value: Date; value: Date;
@@ -32,6 +33,7 @@ export default function WebDateTimeInput({
onDismiss, onDismiss,
is24Hour, is24Hour,
}: WebDateTimeInputProps) { }: WebDateTimeInputProps) {
const { theme } = useSettings();
const [inputValue, setInputValue] = useState(() => dateInputValue(value)); const [inputValue, setInputValue] = useState(() => dateInputValue(value));
const [prevValue, setPrevValue] = useState(value); const [prevValue, setPrevValue] = useState(value);
@@ -57,11 +59,11 @@ export default function WebDateTimeInput({
onChange(event, date); onChange(event, date);
}; };
const bgColor = '#1E1E1E'; const bgColor = theme.sheetBg;
const textColor = '#FFFFFF'; const textColor = theme.text;
const borderColor = '#333333'; const borderColor = theme.borderStrong;
const overlayColor = 'rgba(0,0,0,0.6)'; const overlayColor = theme.overlay;
const mutedColor = '#9E9E9E'; const mutedColor = theme.textFaint;
return ( return (
<Modal visible={isVisible} transparent animationType="fade" onRequestClose={onDismiss}> <Modal visible={isVisible} transparent animationType="fade" onRequestClose={onDismiss}>
@@ -90,7 +92,7 @@ export default function WebDateTimeInput({
borderRadius: 10, borderRadius: 10,
border: `1px solid ${borderColor}`, border: `1px solid ${borderColor}`,
outline: 'none', outline: 'none',
backgroundColor: '#2C2C2C', backgroundColor: theme.inputBg,
color: textColor, color: textColor,
fontFamily: 'inherit', fontFamily: 'inherit',
marginVertical: 16, marginVertical: 16,
@@ -101,14 +103,14 @@ export default function WebDateTimeInput({
</View> </View>
<View style={styles.actions}> <View style={styles.actions}>
<TouchableOpacity <TouchableOpacity
style={[styles.clearButton, { borderColor, backgroundColor: '#2C2C2C' }]} style={[styles.clearButton, { borderColor, backgroundColor: theme.inputBg }]}
onPress={onDismiss} onPress={onDismiss}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.clearButtonText, { color: mutedColor }]}>Clear</Text> <Text style={[styles.clearButtonText, { color: mutedColor }]}>Clear</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={styles.doneButton} onPress={onDismiss} activeOpacity={0.8}> <TouchableOpacity style={[styles.doneButton, { backgroundColor: theme.accent }]} onPress={onDismiss} activeOpacity={0.8}>
<Text style={styles.doneButtonText}>Done</Text> <Text style={[styles.doneButtonText, { color: theme.accentText }]}>Done</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@@ -159,11 +161,9 @@ const styles = StyleSheet.create({
paddingVertical: 10, paddingVertical: 10,
paddingHorizontal: 16, paddingHorizontal: 16,
borderRadius: 10, borderRadius: 10,
backgroundColor: '#E53935',
}, },
doneButtonText: { doneButtonText: {
fontSize: 15, fontSize: 15,
fontWeight: '600', fontWeight: '600',
color: '#FFFFFF',
}, },
}); });
@@ -55,8 +55,8 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
activeOpacity={0.8} activeOpacity={0.8}
> >
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke={field.value ? theme.accent : theme.textMuted} strokeWidth={1.5} fill="none" /> <Circle cx={12} cy={12} r={10} stroke={field.value ? theme.accent : theme.textFaint} strokeWidth={1.8} fill="none" />
<Path d="M12 6v6l4 2" stroke={field.value ? theme.accent : theme.textMuted} strokeWidth={1.5} strokeLinecap="round" /> <Path d="M12 6v6l4 2" stroke={field.value ? theme.accent : theme.textFaint} strokeWidth={1.8} strokeLinecap="round" />
</Svg> </Svg>
<Text <Text
style={[ style={[
@@ -68,7 +68,7 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
> >
{field.value ? formatTime12(field.value) : placeholder} {field.value ? formatTime12(field.value) : placeholder}
</Text> </Text>
{field.value && ( {field.value ? (
<TouchableOpacity <TouchableOpacity
style={styles.clearButton} style={styles.clearButton}
onPress={() => { onPress={() => {
@@ -82,10 +82,10 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
> >
<Svg width={14} height={14} viewBox="0 0 24 24"> <Svg width={14} height={14} viewBox="0 0 24 24">
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" /> <Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
)} ) : null}
</TouchableOpacity> </TouchableOpacity>
); );
}} }}
@@ -114,14 +114,14 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Path <Path
d="M8 2v4M16 2v4M3 10h18M3 18h18" d="M8 2v4M16 2v4M3 10h18M3 18h18"
stroke={field.value ? theme.accent : theme.textMuted} stroke={field.value ? theme.accent : theme.textFaint}
strokeWidth={1.5} strokeWidth={1.8}
strokeLinecap="round" strokeLinecap="round"
/> />
<Path <Path
d="M10 2H14a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z" d="M10 2H14a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z"
stroke={field.value ? theme.accent : theme.textMuted} stroke={field.value ? theme.accent : theme.textFaint}
strokeWidth={1.5} strokeWidth={1.8}
fill="none" fill="none"
/> />
</Svg> </Svg>
@@ -135,17 +135,17 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
> >
{field.value ? format(field.value, 'MMM d, yyyy') : 'Due Date'} {field.value ? format(field.value, 'MMM d, yyyy') : 'Due Date'}
</Text> </Text>
{field.value && ( {field.value ? (
<TouchableOpacity <TouchableOpacity
style={styles.clearButton} style={styles.clearButton}
onPress={() => setDateValue(null)} onPress={() => setDateValue(null)}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
> >
<Svg width={14} height={14} viewBox="0 0 24 24"> <Svg width={14} height={14} viewBox="0 0 24 24">
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" /> <Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
)} ) : null}
</TouchableOpacity> </TouchableOpacity>
); );
}} }}
@@ -183,7 +183,7 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
{endTimeInvalid && ( {endTimeInvalid && (
<View style={[styles.warningRow, { backgroundColor: theme.accentSoft, borderColor: '#E53935' }]}> <View style={[styles.warningRow, { backgroundColor: theme.accentSoft, borderColor: '#E53935' }]}>
<Svg width={14} height={14} viewBox="0 0 24 24"> <Svg width={14} height={14} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke="#E53935" strokeWidth={1.5} fill="none" /> <Circle cx={12} cy={12} r={10} stroke="#EF5350" strokeWidth={1.8} fill="none" />
<Path d="M12 8v5M12 16.5v.5" stroke="#E53935" strokeWidth={1.8} strokeLinecap="round" /> <Path d="M12 8v5M12 16.5v.5" stroke="#E53935" strokeWidth={1.8} strokeLinecap="round" />
</Svg> </Svg>
<Text style={[styles.warningText, { color: '#E53935' }]}> <Text style={[styles.warningText, { color: '#E53935' }]}>
@@ -2,9 +2,11 @@ import React, { useState } from 'react';
import { StyleSheet, TouchableOpacity, Animated, Easing } from 'react-native'; import { StyleSheet, TouchableOpacity, Animated, Easing } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
import { useSettings } from '@/theme';
export function FloatingActionButton() { export function FloatingActionButton() {
const router = useRouter(); const router = useRouter();
const { theme } = useSettings();
const [scaleAnim] = React.useState(new Animated.Value(1)); const [scaleAnim] = React.useState(new Animated.Value(1));
const [rotateAnim] = React.useState(new Animated.Value(0)); const [rotateAnim] = React.useState(new Animated.Value(0));
const [rotation, setRotation] = useState(0); const [rotation, setRotation] = useState(0);
@@ -52,7 +54,7 @@ export function FloatingActionButton() {
]} ]}
> >
<TouchableOpacity <TouchableOpacity
style={styles.button} style={[styles.button, { backgroundColor: theme.accent, shadowColor: theme.accent }]}
onPress={handlePress} onPress={handlePress}
onPressIn={handlePressIn} onPressIn={handlePressIn}
onPressOut={handlePressOut} onPressOut={handlePressOut}
@@ -69,7 +71,7 @@ export function FloatingActionButton() {
<Svg width={24} height={24} viewBox="0 0 24 24"> <Svg width={24} height={24} viewBox="0 0 24 24">
<Path <Path
d="M12 5v14M5 12h14" d="M12 5v14M5 12h14"
stroke="#FFFFFF" stroke={theme.accentText}
strokeWidth={2.5} strokeWidth={2.5}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
@@ -86,7 +88,6 @@ const styles = StyleSheet.create({
position: 'absolute', position: 'absolute',
bottom: 24, bottom: 24,
right: 24, right: 24,
shadowColor: '#E53935',
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3, shadowOpacity: 0.3,
shadowRadius: 12, shadowRadius: 12,
@@ -96,7 +97,6 @@ const styles = StyleSheet.create({
width: 56, width: 56,
height: 56, height: 56,
borderRadius: 16, borderRadius: 16,
backgroundColor: '#E53935',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
@@ -38,7 +38,7 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
<Text style={[styles.cancelButtonText, { color: theme.textSecondary }]}>Cancel</Text> <Text style={[styles.cancelButtonText, { color: theme.textSecondary }]}>Cancel</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.submitButton, isSubmitting && styles.buttonDisabled]} style={[styles.submitButton, { backgroundColor: theme.accent }, isSubmitting && styles.buttonDisabled]}
onPress={handleSubmit(onSubmit)} onPress={handleSubmit(onSubmit)}
disabled={isSubmitting} disabled={isSubmitting}
activeOpacity={0.8} activeOpacity={0.8}
@@ -46,7 +46,7 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
accessibilityLabel={isSubmitting ? 'Saving' : submitLabel} accessibilityLabel={isSubmitting ? 'Saving' : submitLabel}
accessibilityState={{ disabled: isSubmitting, busy: isSubmitting }} accessibilityState={{ disabled: isSubmitting, busy: isSubmitting }}
> >
<Text style={styles.submitButtonText}> <Text style={[styles.submitButtonText, { color: theme.accentText }]}>
{isSubmitting ? 'Saving...' : submitLabel} {isSubmitting ? 'Saving...' : submitLabel}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -88,19 +88,12 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
paddingVertical: 12, paddingVertical: 12,
borderRadius: 10, borderRadius: 10,
backgroundColor: '#E53935',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
shadowColor: '#E53935',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 6,
elevation: 3,
}, },
submitButtonText: { submitButtonText: {
fontSize: 15, fontSize: 15,
fontWeight: '600', fontWeight: '600',
color: '#FFFFFF',
}, },
buttonDisabled: { buttonDisabled: {
opacity: 0.6, opacity: 0.6,
@@ -148,7 +148,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
style={[styles.removeBtn, { backgroundColor: theme.accentSoft, borderColor: theme.accent }]} style={[styles.removeBtn, { backgroundColor: theme.accentSoft, borderColor: theme.accent }]}
onPress={() => handleRemove(item.id)} onPress={() => handleRemove(item.id)}
> >
<Text style={[styles.removeBtnText, { color: theme.accent }]}>Remove</Text> <Text style={[styles.removeBtnText, { color: theme.accentStrong }]}>Remove</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)} )}
+2 -4
View File
@@ -16,8 +16,8 @@ export function Header({ title, showLogo, rightAction }: HeaderProps) {
<View style={[styles.header, { backgroundColor: theme.background, paddingTop: topInset }]}> <View style={[styles.header, { backgroundColor: theme.background, paddingTop: topInset }]}>
<View style={styles.headerContent}> <View style={styles.headerContent}>
{showLogo && ( {showLogo && (
<View style={styles.logoContainer}> <View style={[styles.logoContainer, { backgroundColor: theme.accent }]}>
<Text style={styles.logoText}></Text> <Text style={[styles.logoText, { color: theme.accentText }]}></Text>
</View> </View>
)} )}
<Text style={[styles.title, { color: theme.text }]} accessibilityRole="header">{title}</Text> <Text style={[styles.title, { color: theme.text }]} accessibilityRole="header">{title}</Text>
@@ -48,12 +48,10 @@ const styles = StyleSheet.create({
width: 32, width: 32,
height: 32, height: 32,
borderRadius: 10, borderRadius: 10,
backgroundColor: '#E53935',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
logoText: { logoText: {
color: '#FFFFFF',
fontSize: 18, fontSize: 18,
fontWeight: '700', fontWeight: '700',
}, },
@@ -136,7 +136,7 @@ export function LegalModal({ visible, type, onClose }: LegalModalProps) {
<Text style={[styles.title, { color: theme.text }]}>{title}</Text> <Text style={[styles.title, { color: theme.text }]}>{title}</Text>
<TouchableOpacity onPress={onClose} style={styles.closeButton} activeOpacity={0.7}> <TouchableOpacity onPress={onClose} style={styles.closeButton} activeOpacity={0.7}>
<Svg width={24} height={24} viewBox="0 0 24 24"> <Svg width={24} height={24} viewBox="0 0 24 24">
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" /> <Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
+2 -2
View File
@@ -42,8 +42,8 @@ export function ListItem({ title, subtitle, leftElement, rightElement, onPress,
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Path <Path
d="M9 18l6-6-6-6" d="M9 18l6-6-6-6"
stroke={theme.textMuted} stroke={theme.textSecondary}
strokeWidth={2} strokeWidth={2.5}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
fill="none" fill="none"
@@ -22,7 +22,7 @@ interface OptionPickerModalProps {
export function OptionPickerModal({ visible, title, options, selectedValue, onSelect, onClose, multiSelect = false }: OptionPickerModalProps) { export function OptionPickerModal({ visible, title, options, selectedValue, onSelect, onClose, multiSelect = false }: OptionPickerModalProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const selectedValues = Array.isArray(selectedValue) ? selectedValue : selectedValue ? [selectedValue] : []; const selectedValues = Array.isArray(selectedValue) ? selectedValue : selectedValue == null ? [] : [selectedValue];
return ( return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}> <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
@@ -87,7 +87,7 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Cancel" accessibilityLabel="Cancel"
> >
<Text style={[styles.cancelText, { color: theme.textFaint }]}>Cancel</Text> <Text style={[styles.cancelText, { color: theme.textSecondary }]}>Cancel</Text>
</TouchableOpacity> </TouchableOpacity>
} }
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
@@ -46,7 +46,7 @@ export function PrioritySelector({ value, onChange }: PrioritySelectorProps) {
<Text style={[ <Text style={[
styles.optionText, styles.optionText,
{ color: theme.textFaint }, { color: theme.textFaint },
value === priority.value && { color: theme.accent, fontWeight: '600' }, value === priority.value && { color: theme.accentStrong, fontWeight: '600' },
]}> ]}>
{priority.label} {priority.label}
</Text> </Text>
+37 -59
View File
@@ -1,6 +1,5 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { View, StyleSheet, TextInput, TouchableOpacity, Platform, Keyboard, Animated, Easing } from 'react-native'; import { View, StyleSheet, TextInput, TouchableOpacity, Keyboard, Text } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
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';
@@ -15,13 +14,11 @@ interface QuickAddBarProps {
export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) { export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
const { theme, defaultCategoryId } = useSettings(); const { theme, defaultCategoryId } = useSettings();
const insets = useSafeAreaInsets();
const categories = useCategories(); const categories = useCategories();
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [categoryId, setCategoryId] = useState(() => defaultCategoryId || ''); const [categoryId, setCategoryId] = useState(() => defaultCategoryId || '');
const [categoryPickerVisible, setCategoryPickerVisible] = useState(false); const [categoryPickerVisible, setCategoryPickerVisible] = useState(false);
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
const keyboardHeight = useRef(new Animated.Value(0)).current;
const categoryColor = categories.find((c) => c.id === categoryId)?.color ?? '#9E9E9E'; const categoryColor = categories.find((c) => c.id === categoryId)?.color ?? '#9E9E9E';
@@ -33,32 +30,6 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
}); });
}, []); }, []);
useEffect(() => {
const showListener = Keyboard.addListener('keyboardDidShow', (e) => {
const height = e.endCoordinates.height;
Animated.timing(keyboardHeight, {
toValue: height,
duration: 250,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
});
const hideListener = Keyboard.addListener('keyboardDidHide', () => {
Animated.timing(keyboardHeight, {
toValue: 0,
duration: 250,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
});
return () => {
showListener.remove();
hideListener.remove();
};
}, []);
const handleAdd = async () => { const handleAdd = async () => {
const trimmed = title.trim(); const trimmed = title.trim();
if (!trimmed) return; if (!trimmed) return;
@@ -84,16 +55,11 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
}); });
}); });
setTitle(''); setTitle('');
Keyboard.dismiss();
}; };
const animatedBottom = keyboardHeight.interpolate({
inputRange: [0, 500],
outputRange: [0, insets.bottom + 500],
extrapolate: 'clamp',
});
return ( return (
<Animated.View style={[styles.wrapper, { bottom: animatedBottom }]}> <View style={styles.wrapper}>
<View style={[styles.bar, { backgroundColor: theme.card, borderColor: theme.border }]}> <View style={[styles.bar, { backgroundColor: theme.card, borderColor: theme.border }]}>
<TouchableOpacity <TouchableOpacity
style={[styles.categoryButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]} style={[styles.categoryButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
@@ -103,7 +69,7 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
> >
<View style={[styles.categoryButtonDot, { backgroundColor: categoryColor }]} /> <View style={[styles.categoryButtonDot, { backgroundColor: categoryColor }]} />
<Svg width={12} height={12} viewBox="0 0 24 24"> <Svg width={12} height={12} viewBox="0 0 24 24">
<Path d="M6 9l6 6 6-6" stroke={theme.textFaint} strokeWidth={2} 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>
<TextInput <TextInput
@@ -127,15 +93,18 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
accessibilityHint="Adds the entered task to the list" accessibilityHint="Adds the entered task to the list"
accessibilityState={{ disabled: !title.trim() }} accessibilityState={{ disabled: !title.trim() }}
> >
<Svg width={20} height={20} viewBox="0 0 24 24"> <View style={styles.submitLabelRow}>
<Path <Text style={[styles.submitText, { color: theme.accentText }]}>Add a Task</Text>
d="M12 5v14M5 12h14" <Svg width={18} height={18} viewBox="0 0 24 24">
stroke={theme.accentText} <Path
strokeWidth={2.5} d="M12 5v14M12 5l-5 5M12 5l5 5"
strokeLinecap="round" stroke={theme.accentText}
strokeLinejoin="round" strokeWidth={2.5}
/> strokeLinecap="round"
</Svg> strokeLinejoin="round"
/>
</Svg>
</View>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -147,16 +116,14 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
onSelect={(value) => setCategoryId(Array.isArray(value) ? value[0] : value)} onSelect={(value) => setCategoryId(Array.isArray(value) ? value[0] : value)}
onClose={() => setCategoryPickerVisible(false)} onClose={() => setCategoryPickerVisible(false)}
/> />
</Animated.View> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
wrapper: { wrapper: {
position: 'absolute', paddingHorizontal: 8,
left: 8, paddingTop: 4,
right: 8,
bottom: 12,
}, },
bar: { bar: {
flexDirection: 'row', flexDirection: 'row',
@@ -166,11 +133,12 @@ const styles = StyleSheet.create({
paddingVertical: 8, paddingVertical: 8,
borderRadius: 14, borderRadius: 14,
borderWidth: 1, borderWidth: 1,
borderBottomWidth: 1,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.08, shadowOpacity: 0.10,
shadowRadius: 6, shadowRadius: 6,
elevation: 4, elevation: 6,
}, },
categoryButton: { categoryButton: {
flexDirection: 'row', flexDirection: 'row',
@@ -178,7 +146,7 @@ const styles = StyleSheet.create({
gap: 3, gap: 3,
paddingVertical: 6, paddingVertical: 6,
paddingHorizontal: 6, paddingHorizontal: 6,
borderRadius: 8, borderRadius: 10,
borderWidth: 1, borderWidth: 1,
height: 40, height: 40,
}, },
@@ -194,13 +162,23 @@ const styles = StyleSheet.create({
minHeight: 40, minHeight: 40,
}, },
submit: { submit: {
width: 40, minWidth: 88,
height: 40, height: 40,
borderRadius: 8, paddingHorizontal: 12,
borderRadius: 10,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
submitLabelRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
submitText: {
fontSize: 13,
fontWeight: '700',
},
submitDisabled: { submitDisabled: {
opacity: 0.5, opacity: 0.5,
}, },
}); });
@@ -43,13 +43,13 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Path <Path
d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0" d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0"
stroke={selectedReminders.length > 0 ? theme.accent : theme.textMuted} stroke={selectedReminders.length > 0 ? theme.accentText : theme.textSecondary}
strokeWidth={1.5} strokeWidth={1.8}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
fill="none" fill="none"
/> />
<Circle cx={18.5} cy={5.5} r={3.5} fill={selectedReminders.length > 0 ? theme.accent : 'transparent'} stroke={selectedReminders.length > 0 ? theme.accent : theme.textMuted} strokeWidth={1.5} /> <Circle cx={18.5} cy={5.5} r={3.5} fill={selectedReminders.length > 0 ? theme.accent : 'transparent'} stroke={selectedReminders.length > 0 ? theme.accentText : theme.textSecondary} strokeWidth={1.8} />
</Svg> </Svg>
<Text <Text
style={[ style={[
@@ -62,7 +62,7 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
</Text> </Text>
<View style={styles.chevron}> <View style={styles.chevron}>
<Svg width={16} height={16} viewBox="0 0 24 24"> <Svg width={16} height={16} viewBox="0 0 24 24">
<Path d="M9 18l6-6-6-6" stroke={theme.textMuted} strokeWidth={2} 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" />
</Svg> </Svg>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Modal, TextInput, Pressable, KeyboardAvoidingView } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity, Modal, TextInput, Pressable, KeyboardAvoidingView } from 'react-native';
import { Repeat, REPEAT_OPTIONS, WEEKDAY_LABELS, repeatDaysFromString } from '@/types'; import { Repeat, REPEAT_OPTIONS, WEEKDAY_LABELS, WEEKDAY_ORDER, weekdaysInDisplayOrder, repeatDaysFromString } from '@/types';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { useRepeatProfiles } from '@/hooks/useDatabase'; import { useRepeatProfiles } from '@/hooks/useDatabase';
import { createRepeatProfile, deleteRepeatProfile } from '@/utils/repeatProfileActions'; import { createRepeatProfile, deleteRepeatProfile } from '@/utils/repeatProfileActions';
@@ -168,7 +168,7 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
style={[ style={[
styles.chipText, styles.chipText,
{ color: theme.textFaint }, { color: theme.textFaint },
value === option.value && { color: theme.accent, fontWeight: '600' }, value === option.value && { color: theme.accentStrong, fontWeight: '600' },
]} ]}
> >
{option.label.replace('No Repeat', 'None')} {option.label.replace('No Repeat', 'None')}
@@ -212,9 +212,9 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
{isDaysBased(value) && ( {isDaysBased(value) && (
<View style={styles.dayRow}> <View style={styles.dayRow}>
{WEEKDAY_LABELS.map((label, day) => ( {WEEKDAY_ORDER.map((day) => (
<TouchableOpacity <TouchableOpacity
key={`${label}-${day}`} key={`${WEEKDAY_LABELS[day]}-${day}`}
style={[ style={[
styles.dayChip, styles.dayChip,
{ backgroundColor: theme.card, borderColor: theme.borderStrong }, { backgroundColor: theme.card, borderColor: theme.borderStrong },
@@ -234,7 +234,7 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
days.includes(day) && { color: '#FFFFFF', fontWeight: '700' }, days.includes(day) && { color: '#FFFFFF', fontWeight: '700' },
]} ]}
> >
{label} {WEEKDAY_LABELS[day]}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
))} ))}
@@ -252,7 +252,7 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
<Svg width={13} height={13} viewBox="0 0 24 24"> <Svg width={13} height={13} viewBox="0 0 24 24">
<Path d="M12 5v14M5 12h14" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" /> <Path d="M12 5v14M5 12h14" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" />
</Svg> </Svg>
<Text style={[styles.saveProfileText, { color: theme.accent }]}>Save as profile</Text> <Text style={[styles.saveProfileText, { color: theme.accentStrong }]}>Save as profile</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
@@ -271,7 +271,7 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
<Text style={[styles.modalTitle, { color: theme.text }]}>Save repeat profile</Text> <Text style={[styles.modalTitle, { color: theme.text }]}>Save repeat profile</Text>
<Text style={[styles.modalHint, { color: theme.textFaint }]}> <Text style={[styles.modalHint, { color: theme.textFaint }]}>
{`${REPEAT_OPTIONS.find((o) => o.value === value)?.label.replace('No Repeat', 'None')}, every ${interval} ${unitLabel(value)}${interval > 1 ? 's' : ''}`} {`${REPEAT_OPTIONS.find((o) => o.value === value)?.label.replace('No Repeat', 'None')}, every ${interval} ${unitLabel(value)}${interval > 1 ? 's' : ''}`}
{isDaysBased(value) && days.length > 0 ? ` · ${days.map((d) => WEEKDAY_LABELS[d]).join(' ')}` : ''} {isDaysBased(value) && days.length > 0 ? ` · ${weekdaysInDisplayOrder(days).map((d) => WEEKDAY_LABELS[d]).join(' ')}` : ''}
</Text> </Text>
<TextInput <TextInput
style={[styles.modalInput, { backgroundColor: theme.cardAlt, borderColor: theme.borderStrong, color: theme.text }]} style={[styles.modalInput, { backgroundColor: theme.cardAlt, borderColor: theme.borderStrong, color: theme.text }]}
@@ -42,7 +42,7 @@ export function ServerUrlModal({ visible, onClose }: ServerUrlModalProps) {
<Text style={[styles.title, { color: theme.text }]}>Backend URL</Text> <Text style={[styles.title, { color: theme.text }]}>Backend URL</Text>
<TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}> <TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Svg width={18} height={18} viewBox="0 0 24 24"> <Svg width={18} height={18} viewBox="0 0 24 24">
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" /> <Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
+57 -40
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react'; 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';
@@ -6,63 +6,78 @@ import { useSettings } from '@/theme';
interface SubtaskItemProps { interface SubtaskItemProps {
subtask: SubtaskData; subtask: SubtaskData;
onToggle: () => void | Promise<void>; onToggle?: (subtask: SubtaskData) => void | Promise<void>;
onDelete?: () => void; onDelete?: (subtask: SubtaskData) => void;
onPress?: () => void; onPress?: (subtask: SubtaskData) => void;
onLongPress?: () => void; onLongPress?: (subtask: SubtaskData) => void;
onMenuOpen?: () => void; onMenuOpen?: (subtask: SubtaskData) => void;
selected?: boolean; selected?: boolean;
selectionMode?: boolean; selectionMode?: boolean;
draggable?: boolean; draggable?: boolean;
onDragStart?: () => void; onDragStart?: (subtask: SubtaskData) => void;
onDragUpdate?: (absoluteY: number) => void; onDragUpdate?: (absoluteY: number) => void;
onDragEnd?: (absoluteY: number) => void; onDragEnd?: (absoluteY: number) => void;
depth?: number; depth?: number;
categoryColor?: string; categoryColor?: string;
registerRef?: (subtaskId: string, parentTaskId: string, ref: View | null) => void;
hoveredId?: string | null;
} }
export const SubtaskItem = React.memo(function SubtaskItem({ export const SubtaskItem = React.memo(function SubtaskItem({
subtask, subtask,
onToggle, onToggle,
onDelete, onDelete,
onPress, onPress,
onLongPress, onLongPress,
onMenuOpen, onMenuOpen,
selected, selected,
selectionMode, selectionMode,
draggable, draggable,
onDragStart, onDragStart,
onDragUpdate, onDragUpdate,
onDragEnd, onDragEnd,
depth = 1, depth = 1,
categoryColor categoryColor,
registerRef,
hoveredId,
}: SubtaskItemProps) { }: SubtaskItemProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const [expanded, setExpanded] = useState(true); 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 handleExpand = () => setExpanded(!expanded); const handleExpand = () => setExpanded(!expanded);
useEffect(() => {
if (hovered && hasChildren && !expanded) {
setExpanded(true);
}
}, [hovered, hasChildren, expanded]);
return ( return (
<View> <View>
<TaskItem <View ref={(ref) => registerRef?.(subtask.id, subtask.taskId, ref)}>
task={subtask} <TaskItem
indented task={subtask}
depth={depth} indented
onToggle={onToggle} depth={depth}
onDelete={onDelete} expanded={hasChildren && expanded}
onPress={onPress ?? (() => {})} onToggle={() => onToggle?.(subtask)}
onLongPress={onLongPress} onDelete={() => onDelete?.(subtask)}
onMenuOpen={onMenuOpen} onPress={onPress ? () => onPress(subtask) : handleExpand}
selected={selected} onLongPress={() => onLongPress?.(subtask)}
selectionMode={selectionMode} onMenuOpen={() => onMenuOpen?.(subtask)}
completedSection={subtask.completed} selected={selected}
draggable={draggable} selectionMode={selectionMode}
onDragStart={onDragStart} completedSection={subtask.completed}
onDragUpdate={onDragUpdate} draggable={draggable}
onDragEnd={onDragEnd} hovered={hovered}
categoryColor={categoryColor} onDragStart={() => onDragStart?.(subtask)}
/> onDragUpdate={onDragUpdate}
onDragEnd={onDragEnd}
categoryColor={categoryColor}
/>
</View>
{hasChildren && expanded && ( {hasChildren && expanded && (
<View style={[styles.nestedSubtasks, { marginLeft: depth * 12 }]}> <View style={[styles.nestedSubtasks, { marginLeft: depth * 12 }]}>
{subtask.subtasks {subtask.subtasks
@@ -72,7 +87,7 @@ export const SubtaskItem = React.memo(function SubtaskItem({
<SubtaskItem <SubtaskItem
key={child.id} key={child.id}
subtask={child} subtask={child}
onToggle={() => {}} onToggle={onToggle}
onDelete={onDelete} onDelete={onDelete}
onPress={onPress} onPress={onPress}
onLongPress={onLongPress} onLongPress={onLongPress}
@@ -85,6 +100,8 @@ export const SubtaskItem = React.memo(function SubtaskItem({
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
depth={depth + 1} depth={depth + 1}
categoryColor={categoryColor} categoryColor={categoryColor}
registerRef={registerRef}
hoveredId={hoveredId}
/> />
))} ))}
</View> </View>
@@ -97,4 +114,4 @@ const styles = StyleSheet.create({
nestedSubtasks: { nestedSubtasks: {
marginTop: 4, marginTop: 4,
}, },
}); });
@@ -48,7 +48,7 @@ export function SubtasksSection({ control }: SubtasksSectionProps) {
<Text style={[styles.label, { color: theme.text }]}>Subtasks</Text> <Text style={[styles.label, { color: theme.text }]}>Subtasks</Text>
{items.length > 0 && ( {items.length > 0 && (
<View style={[styles.badge, { backgroundColor: theme.accentSoft }]}> <View style={[styles.badge, { backgroundColor: theme.accentSoft }]}>
<Text style={[styles.badgeText, { color: theme.accent }]}>{items.length}</Text> <Text style={[styles.badgeText, { color: theme.accentStrong }]}>{items.length}</Text>
</View> </View>
)} )}
</View> </View>
@@ -67,8 +67,8 @@ export function SubtasksSection({ control }: SubtasksSectionProps) {
<Svg width={16} height={16} viewBox="0 0 24 24"> <Svg width={16} height={16} viewBox="0 0 24 24">
<Path <Path
d="M6 9l6 6 6-6" d="M6 9l6 6 6-6"
stroke={theme.textMuted} stroke={theme.textSecondary}
strokeWidth={2} strokeWidth={2.5}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
fill="none" fill="none"
@@ -105,7 +105,7 @@ export function SubtasksSection({ control }: SubtasksSectionProps) {
<Svg width={16} height={16} viewBox="0 0 24 24"> <Svg width={16} height={16} viewBox="0 0 24 24">
<Path d="M12 5v14M5 12h14" stroke={theme.accent} strokeWidth={2} strokeLinecap="round" /> <Path d="M12 5v14M5 12h14" stroke={theme.accent} strokeWidth={2} strokeLinecap="round" />
</Svg> </Svg>
<Text style={[styles.addButtonText, { color: theme.accent }]}>Add Subtask</Text> <Text style={[styles.addButtonText, { color: theme.accentStrong }]}>Add Subtask</Text>
</TouchableOpacity> </TouchableOpacity>
</Animated.View> </Animated.View>
</View> </View>
@@ -131,8 +131,8 @@ function SubtaskItem({ index, value, onChange, onRemove }: SubtaskItemProps) {
<Svg width={22} height={22} viewBox="0 0 24 24"> <Svg width={22} height={22} viewBox="0 0 24 24">
<Path <Path
d="M4 12.5l5 5 10-10" d="M4 12.5l5 5 10-10"
stroke={theme.borderStrong} stroke={theme.textFaint}
strokeWidth={2} strokeWidth={2.2}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
fill="none" fill="none"
@@ -150,7 +150,7 @@ function SubtaskItem({ index, value, onChange, onRemove }: SubtaskItemProps) {
/> />
<TouchableOpacity style={styles.removeButton} onPress={onRemove} activeOpacity={0.7}> <TouchableOpacity style={styles.removeButton} onPress={onRemove} activeOpacity={0.7}>
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" /> <Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -66,21 +66,21 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
case 'success': case 'success':
return ( return (
<Svg width={16} height={16} viewBox="0 0 24 24"> <Svg width={16} height={16} viewBox="0 0 24 24">
<Path d="M5 12l5 5 9-10" stroke="#43A047" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" /> <Path d="M5 12l5 5 9-10" stroke="#66BB6A" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg> </Svg>
); );
case 'error': case 'error':
return ( return (
<Svg width={16} height={16} viewBox="0 0 24 24"> <Svg width={16} height={16} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke="#E53935" strokeWidth={2} fill="none" /> <Circle cx={12} cy={12} r={10} stroke="#EF5350" strokeWidth={2} fill="none" />
<Path d="M12 8v4M12 16h.01" stroke="#E53935" strokeWidth={2} strokeLinecap="round" /> <Path d="M12 8v4M12 16h.01" stroke="#EF5350" strokeWidth={2} strokeLinecap="round" />
</Svg> </Svg>
); );
default: default:
return ( return (
<Svg width={16} height={16} viewBox="0 0 24 24"> <Svg width={16} height={16} viewBox="0 0 24 24">
<Path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" stroke={theme.textMuted} strokeWidth={1.5} fill="none" /> <Path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" stroke={theme.textFaint} strokeWidth={1.8} fill="none" />
<Path d="M12 22V12" stroke={theme.textMuted} strokeWidth={1.5} strokeLinecap="round" /> <Path d="M12 22V12" stroke={theme.textFaint} strokeWidth={1.8} strokeLinecap="round" />
</Svg> </Svg>
); );
} }
+34 -18
View File
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { ColorValue } from 'react-native'; import { ColorValue } from 'react-native';
import Svg, { Path, Circle } from 'react-native-svg'; import Svg, { Path, Circle, Rect } from 'react-native-svg';
interface TabBarIconProps { interface TabBarIconProps {
name: 'checklist' | 'calendar' | 'gear' | 'stats'; name: 'checklist' | 'calendar' | 'gear' | 'stats';
@@ -15,48 +15,64 @@ export function TabBarIcon({ name, focused, color, size = 24 }: TabBarIconProps)
{name === 'checklist' && ( {name === 'checklist' && (
<> <>
<Path <Path
d="M3 5h18M3 12h18M3 19h18" d="M8.5 6h12.5M8.5 12h12.5M8.5 18h12.5"
stroke={color} stroke={color}
strokeWidth={focused ? 2.5 : 2} strokeWidth={focused ? 3.5 : 3}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
/> />
{focused && ( <Circle cx={4} cy={6} r={focused ? 1.75 : 1.5} fill={color} />
<Circle cx={6} cy={5} r={2} fill={color} /> <Circle cx={4} cy={12} r={focused ? 1.75 : 1.5} fill={color} />
)} <Circle cx={4} cy={18} r={focused ? 1.75 : 1.5} fill={color} />
</> </>
)} )}
{name === 'calendar' && ( {name === 'calendar' && (
<> <>
<Path <Path
d="M8 2v4M16 2v4M3 10h18M3 18h18" d="M8 2v4M16 2v4M3 10h18"
stroke={color} stroke={color}
strokeWidth={focused ? 2.5 : 2} strokeWidth={focused ? 3.5 : 3}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
/> />
<Path <Rect
d="M10 2H14a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z" x={3}
y={4}
width={18}
height={18}
rx={2}
stroke={color} stroke={color}
strokeWidth={focused ? 2.5 : 2} strokeWidth={focused ? 3.5 : 3}
fill="none" fill="none"
/> />
{focused && (
<>
<Circle cx={12} cy={16} r={1.5} fill={color} />
<Circle cx={7} cy={16} r={1.5} fill={color} />
<Circle cx={17} cy={16} r={1.5} fill={color} />
</>
)}
</> </>
)} )}
{name === 'gear' && ( {name === 'gear' && (
<Path <>
d="M12 3.5a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1zM11.3 4.5a.7.7 0 0 1 1.4 0l.9.9a.5.5 0 0 1-.7.7l-.9-.9a.7.7 0 0 1 0-1.4zM20.5 12a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1zM17.3 11.3a.7.7 0 0 1 0 1.4l-.9.9a.5.5 0 0 1-.7-.7l.9-.9a.7.7 0 0 1 1.4 0zM12 20.5a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1zM11.3 19.6a.7.7 0 0 1 1.4 0l.9.9a.5.5 0 0 1-.7.7l-.9-.9a.7.7 0 0 1 0-1.4zM3.5 12a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1zM4.5 11.3a.7.7 0 0 1 1.4 0l.9.9a.5.5 0 0 1-.7.7l-.9-.9a.7.7 0 0 1 0-1.4zM12 7.5a4.5 4.5 0 1 0 0 9 4.5 4.5 0 0 0 0-9z" <Circle cx={12} cy={12} r={3.5} stroke={color} strokeWidth={focused ? 3.5 : 3} fill="none" />
stroke={color} <Path
strokeWidth={focused ? 2.5 : 2} d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
fill="none" stroke={color}
/> strokeWidth={focused ? 3 : 2.5}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</>
)} )}
{name === 'stats' && ( {name === 'stats' && (
<> <>
<Path <Path
d="M4 20V10M10 20V4M16 20v-7M21 20H3" d="M4 20V10M10 20V4M16 20v-7M21 20H3"
stroke={color} stroke={color}
strokeWidth={focused ? 2.5 : 2} strokeWidth={focused ? 3.5 : 3}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
/> />
@@ -46,7 +46,7 @@ export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, subta
<Text style={[styles.title, { color: theme.text }]}>{subtask ? 'Delete Subtask' : 'Delete Task'}</Text> <Text style={[styles.title, { color: theme.text }]}>{subtask ? 'Delete Subtask' : 'Delete Task'}</Text>
<TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}> <TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Svg width={18} height={18} viewBox="0 0 24 24"> <Svg width={18} height={18} viewBox="0 0 24 24">
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" /> <Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -71,7 +71,7 @@ export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, subta
onPress={() => onDelete(option.scope)} onPress={() => onDelete(option.scope)}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.actionLabel, { color: theme.accent }]}>{option.label}</Text> <Text style={[styles.actionLabel, { color: theme.accentStrong }]}>{option.label}</Text>
{option.hint && <Text style={[styles.actionHint, { color: theme.textMuted }]}>{option.hint}</Text>} {option.hint && <Text style={[styles.actionHint, { color: theme.textMuted }]}>{option.hint}</Text>}
</TouchableOpacity> </TouchableOpacity>
))} ))}
+34 -51
View File
@@ -133,11 +133,10 @@ export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete,
const hasDueDate = task.dueDate > 0; const hasDueDate = task.dueDate > 0;
const isOverdue = hasDueDate && !task.completed && task.dueDate < startOfToday.getTime(); const isOverdue = hasDueDate && !task.completed && task.dueDate < startOfToday.getTime();
const isDueToday = hasDueDate && !task.completed && task.dueDate >= startOfToday.getTime() && task.dueDate <= endOfToday.getTime(); const isDueToday = hasDueDate && !task.completed && task.dueDate >= startOfToday.getTime() && task.dueDate <= endOfToday.getTime();
const canComplete = !hasDueDate || isOverdue || isDueToday; return { hasDueDate, isOverdue, isDueToday };
return { hasDueDate, isOverdue, isDueToday, canComplete };
}, [task.dueDate, task.completed]); }, [task.dueDate, task.completed]);
const { hasDueDate, isOverdue, isDueToday, canComplete } = dueInfo; const { hasDueDate, isOverdue, isDueToday } = dueInfo;
const formattedDueDate = React.useMemo( const formattedDueDate = React.useMemo(
() => formatDueDate(task.dueDate, task.dueTime, task.endTime || ''), () => formatDueDate(task.dueDate, task.dueTime, task.endTime || ''),
@@ -158,17 +157,6 @@ export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete,
const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) => { const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) => {
const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [-80, 0] }); const translateX = progress.interpolate({ inputRange: [0, 1], outputRange: [-80, 0] });
if (!canComplete && !task.completed) {
return (
<Animated.View style={[styles.swipeAction, styles.swipeComplete, { transform: [{ translateX }] }]}>
<Svg width={24} height={24} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke="#FFFFFF" strokeWidth={2} fill="none" />
<Path d="M12 8v5M12 16.5v.5" stroke="#FFFFFF" strokeWidth={1.8} strokeLinecap="round" />
</Svg>
<Text style={styles.swipeActionText}>Not Due</Text>
</Animated.View>
);
}
return ( return (
<Animated.View style={[styles.swipeAction, styles.swipeComplete, { transform: [{ translateX }] }]}> <Animated.View style={[styles.swipeAction, styles.swipeComplete, { transform: [{ translateX }] }]}>
<Svg width={24} height={24} viewBox="0 0 24 24"> <Svg width={24} height={24} viewBox="0 0 24 24">
@@ -189,9 +177,7 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
swipeableRef.current?.close(); swipeableRef.current?.close();
}} }}
onSwipeableLeftOpen={() => { onSwipeableLeftOpen={() => {
if (canComplete || task.completed) { onToggle();
onToggle();
}
swipeableRef.current?.close(); swipeableRef.current?.close();
}} }}
overshootRight={false} overshootRight={false}
@@ -234,21 +220,21 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
) : null} ) : null}
</View> </View>
<TouchableOpacity <TouchableOpacity
style={[styles.checkCircle, !canComplete && !task.completed && styles.checkCircleDisabled]} style={styles.checkCircle}
onPress={canComplete || task.completed ? onToggle : undefined} onPress={onToggle}
activeOpacity={0.7} activeOpacity={0.7}
accessibilityRole="checkbox" accessibilityRole="checkbox"
accessibilityLabel={task.completed ? 'Mark incomplete' : canComplete ? 'Mark complete' : 'Task not due yet'} accessibilityLabel={task.completed ? 'Mark incomplete' : 'Mark complete'}
accessibilityState={{ checked: task.completed, disabled: !canComplete && !task.completed }} accessibilityState={{ checked: task.completed }}
> >
<Svg width={24} height={24} viewBox="0 0 24 24"> <Svg width={30} height={30} viewBox="0 0 24 24">
{task.completed ? ( {task.completed ? (
<> <>
<Circle cx={12} cy={12} r={10} fill={theme.accent} /> <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" /> <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={!canComplete ? theme.textFaint : isOverdue ? '#E53935' : theme.textMuted} strokeWidth={2} fill="none" /> <Circle cx={12} cy={12} r={10} stroke={isOverdue ? '#FF8A80' : theme.textSecondary} strokeWidth={2.5} fill="none" />
)} )}
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
@@ -286,8 +272,8 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
<Svg width={14} height={14} viewBox="0 0 24 24"> <Svg width={14} height={14} viewBox="0 0 24 24">
<Path <Path
d="M17 2l4 4-4 4M3 11v-1a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 13v1a4 4 0 0 1-4 4H3" d="M17 2l4 4-4 4M3 11v-1a4 4 0 0 1 4-4h14M7 22l-4-4 4-4M21 13v1a4 4 0 0 1-4 4H3"
stroke={theme.textMuted} stroke={theme.textSecondary}
strokeWidth={2} strokeWidth={2.2}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
fill="none" fill="none"
@@ -301,7 +287,7 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
<Path <Path
d="M12 2v2M12 20v2M2 12h2M20 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4l1.4-1.4M17 7l1.4-1.4" d="M12 2v2M12 20v2M2 12h2M20 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4l1.4-1.4M17 7l1.4-1.4"
stroke={theme.accent} stroke={theme.accent}
strokeWidth={1.5} strokeWidth={1.8}
strokeLinecap="round" strokeLinecap="round"
fill="none" fill="none"
/> />
@@ -314,8 +300,8 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
<Svg width={16} height={16} viewBox="0 0 24 24"> <Svg width={16} height={16} viewBox="0 0 24 24">
<Path <Path
d="M6 9l6 6 6-6" d="M6 9l6 6 6-6"
stroke={theme.textMuted} stroke={theme.textSecondary}
strokeWidth={2} strokeWidth={2.5}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
fill="none" fill="none"
@@ -326,8 +312,8 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
{hasDueDate && ( {hasDueDate && (
<View style={styles.dueRow}> <View style={styles.dueRow}>
<Svg width={14} height={14} viewBox="0 0 24 24"> <Svg width={14} height={14} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke={theme.textMuted} strokeWidth={1.5} fill="none" /> <Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2} fill="none" />
<Path d="M12 6v6l4 2" stroke={theme.textMuted} strokeWidth={1.5} strokeLinecap="round" /> <Path d="M12 6v6l4 2" stroke={theme.textSecondary} strokeWidth={2} strokeLinecap="round" />
</Svg> </Svg>
<Animated.Text <Animated.Text
style={[ style={[
@@ -344,8 +330,8 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
<Svg width={13} height={13} viewBox="0 0 24 24"> <Svg width={13} height={13} viewBox="0 0 24 24">
<Path <Path
d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0" d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0"
stroke={theme.textFaint} stroke={theme.textSecondary}
strokeWidth={1.6} strokeWidth={1.8}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
fill="none" fill="none"
@@ -432,10 +418,10 @@ const styles = StyleSheet.create({
marginBottom: 4, marginBottom: 4,
}, },
taskOverdue: { taskOverdue: {
borderColor: '#4A2B2B', borderColor: '#573431',
}, },
taskDueToday: { taskDueToday: {
borderColor: '#1E88E5', borderColor: '#2C4766',
}, },
taskSelected: { taskSelected: {
shadowColor: '#000', shadowColor: '#000',
@@ -444,8 +430,8 @@ const styles = StyleSheet.create({
elevation: 2, elevation: 2,
}, },
taskHovered: { taskHovered: {
borderColor: '#1E88E5', borderColor: '#2C4766',
backgroundColor: 'rgba(30, 136, 229, 0.05)', backgroundColor: 'rgba(44, 71, 102, 0.15)',
}, },
dragLifted: { dragLifted: {
zIndex: 100, zIndex: 100,
@@ -518,7 +504,7 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
}, },
titleOverdue: { titleOverdue: {
color: '#E53935', color: '#E57373',
}, },
priorityBadge: { priorityBadge: {
paddingHorizontal: 6, paddingHorizontal: 6,
@@ -536,14 +522,14 @@ const styles = StyleSheet.create({
width: 20, width: 20,
height: 20, height: 20,
borderRadius: 10, borderRadius: 10,
backgroundColor: '#4A2B2B', backgroundColor: '#43302E',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
assigneeText: { assigneeText: {
fontSize: 9, fontSize: 9,
fontWeight: '700', fontWeight: '700',
color: '#EF5350', color: '#FF9C8F',
}, },
dueRow: { dueRow: {
flexDirection: 'row', flexDirection: 'row',
@@ -554,11 +540,11 @@ const styles = StyleSheet.create({
fontSize: 13, fontSize: 13,
}, },
dueTextOverdue: { dueTextOverdue: {
color: '#E53935', color: '#E57373',
fontWeight: '600', fontWeight: '600',
}, },
dueTextDueToday: { dueTextDueToday: {
color: '#1E88E5', color: '#64B5F6',
fontWeight: '600', fontWeight: '600',
}, },
menuButton: { menuButton: {
@@ -573,24 +559,21 @@ const styles = StyleSheet.create({
paddingHorizontal: 6, paddingHorizontal: 6,
paddingVertical: 2, paddingVertical: 2,
borderRadius: 8, borderRadius: 8,
backgroundColor: 'rgba(30, 136, 229, 0.15)', backgroundColor: 'rgba(100, 181, 246, 0.15)',
}, },
allDayText: { allDayText: {
fontSize: 11, fontSize: 11,
fontWeight: '600', fontWeight: '600',
color: '#1E88E5', color: '#64B5F6',
}, },
checkCircle: { checkCircle: {
width: 24, width: 30,
height: 24, height: 30,
borderRadius: 12, borderRadius: 15,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginRight: 12, marginRight: 12,
}, },
checkCircleDisabled: {
opacity: 0.4,
},
swipeAction: { swipeAction: {
width: 80, width: 80,
alignItems: 'center', alignItems: 'center',
@@ -598,12 +581,12 @@ const styles = StyleSheet.create({
gap: 4, gap: 4,
}, },
swipeDelete: { swipeDelete: {
backgroundColor: '#E53935', backgroundColor: '#B8504A',
borderTopRightRadius: 16, borderTopRightRadius: 16,
borderBottomRightRadius: 16, borderBottomRightRadius: 16,
}, },
swipeComplete: { swipeComplete: {
backgroundColor: '#43A047', backgroundColor: '#3D7A4A',
borderTopLeftRadius: 16, borderTopLeftRadius: 16,
borderBottomLeftRadius: 16, borderBottomLeftRadius: 16,
}, },
+127 -35
View File
@@ -15,6 +15,7 @@ import {
convertTaskToSubtask, convertTaskToSubtask,
convertSubtaskToTask, convertSubtaskToTask,
moveSubtaskToTask, moveSubtaskToTask,
setSubtaskParent,
toggleSubtaskComplete, toggleSubtaskComplete,
fetchSubtaskTree, fetchSubtaskTree,
} from '@/utils/taskActions'; } from '@/utils/taskActions';
@@ -60,8 +61,9 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
const [subtasksMap, setSubtasksMap] = useState<Map<string, SubtaskData[]>>(new Map()); const [subtasksMap, setSubtasksMap] = useState<Map<string, SubtaskData[]>>(new Map());
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 dragStateRef = useRef<{ taskId: string; positions: Record<string, { top: number; bottom: number }> } | null>(null); const subtaskRefs = useRef<Map<string, { ref: View; parentTaskId: string }>>(new Map());
const subtaskDragRef = useRef<{ subtaskId: string; parentTaskId: string; positions: Record<string, { top: number; bottom: number }> } | null>(null); const dragStateRef = useRef<{ taskId: string; positions: Record<string, { top: number; bottom: number }>; subPositions: Record<string, { top: number; bottom: number }> } | null>(null);
const subtaskDragRef = useRef<{ subtaskId: string; parentTaskId: string; positions: Record<string, { top: number; bottom: number }>; subPositions: Record<string, { top: number; bottom: number }> } | null>(null);
const { const {
modals, modals,
openTaskDelete, openTaskDelete,
@@ -78,6 +80,14 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
} }
}, []); }, []);
const registerSubtaskRef = useCallback((subtaskId: string, parentTaskId: string, ref: View | null) => {
if (ref) {
subtaskRefs.current.set(subtaskId, { ref, parentTaskId });
} else {
subtaskRefs.current.delete(subtaskId);
}
}, []);
const sortedTasks = useMemo(() => { const sortedTasks = useMemo(() => {
const sorted = [...tasks]; const sorted = [...tasks];
switch (sortBy) { switch (sortBy) {
@@ -210,7 +220,30 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
return positions; return positions;
}, []); }, []);
const findHoverTarget = useCallback((absoluteY: number, draggedId: string, positions: Record<string, { top: number; bottom: number }>) => { const measureAll = useCallback(async () => {
const positions = await measureItems();
const subPositions: Record<string, { top: number; bottom: number }> = {};
const entries = Array.from(subtaskRefs.current.entries());
await Promise.all(entries.map(([id, entry]) => {
return new Promise<void>((resolve) => {
entry.ref?.measureInWindow((_x, y, _w, h) => {
subPositions[id] = { top: y, bottom: y + h };
resolve();
});
});
}));
return { positions, subPositions };
}, [measureItems]);
const findHoverTarget = useCallback((absoluteY: number, draggedId: string, positions: Record<string, { top: number; bottom: number }>, subPositions: Record<string, { top: number; bottom: number }>) => {
const subEntries = Object.entries(subPositions);
for (let i = subEntries.length - 1; i >= 0; i--) {
const [id, p] = subEntries[i];
if (id === draggedId) continue;
if (absoluteY >= p.top && absoluteY <= p.bottom) {
return id;
}
}
for (const [id, p] of Object.entries(positions)) { for (const [id, p] of Object.entries(positions)) {
if (id === draggedId) continue; if (id === draggedId) continue;
if (absoluteY >= p.top && absoluteY <= p.bottom) { if (absoluteY >= p.top && absoluteY <= p.bottom) {
@@ -228,17 +261,43 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
}, []); }, []);
const handleDragStart = useCallback(async (taskId: string) => { const handleDragStart = useCallback(async (taskId: string) => {
dragStateRef.current = { taskId, positions: await measureItems() }; dragStateRef.current = { taskId, ...(await measureAll()) };
}, [measureItems]); }, [measureAll]);
const refreshDragPositions = useCallback(() => {
measureAll().then((m) => {
if (subtaskDragRef.current) {
subtaskDragRef.current = { ...subtaskDragRef.current, ...m };
} else if (dragStateRef.current) {
dragStateRef.current = { ...dragStateRef.current, ...m };
}
});
}, [measureAll]);
const updateHoverTarget = useCallback((target: string | null) => {
let changed = false;
setHoverTaskId((prev) => {
if (prev === target) return prev;
changed = true;
return target;
});
if (changed && target) {
setTimeout(refreshDragPositions, 80);
}
}, [refreshDragPositions]);
const handleDragUpdate = useCallback((absoluteY: number) => { const handleDragUpdate = useCallback((absoluteY: number) => {
const state = dragStateRef.current; const state = dragStateRef.current;
if (!state) return; if (!state) return;
const target = findHoverTarget(absoluteY, state.taskId, state.positions); const target = findHoverTarget(absoluteY, state.taskId, state.positions, state.subPositions);
setHoverTaskId((prev) => (prev === target ? prev : target)); updateHoverTarget(target);
if (target) { if (target) {
const position = calculateDropPosition(absoluteY, target, state.positions); if (state.subPositions[target]) {
setDropIndicator({ targetId: target, position }); setDropIndicator(null);
} else {
const position = calculateDropPosition(absoluteY, target, state.positions);
setDropIndicator({ targetId: target, position });
}
} else { } else {
// Check if below last item // Check if below last item
const positions = state.positions; const positions = state.positions;
@@ -249,7 +308,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
setDropIndicator(null); setDropIndicator(null);
} }
} }
}, [findHoverTarget, calculateDropPosition]); }, [findHoverTarget, calculateDropPosition, updateHoverTarget]);
const handleDragEnd = useCallback((absoluteY: number) => { const handleDragEnd = useCallback((absoluteY: number) => {
const state = dragStateRef.current; const state = dragStateRef.current;
@@ -258,25 +317,50 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
setDropIndicator(null); setDropIndicator(null);
if (!state) return; if (!state) return;
const target = findHoverTarget(absoluteY, state.taskId, state.positions); const target = findHoverTarget(absoluteY, state.taskId, state.positions, state.subPositions);
if (target) { if (target) {
(async () => { if (state.subPositions[target]) {
await convertTaskToSubtask(state.taskId, target); const parentTaskId = subtaskRefs.current.get(target)?.parentTaskId ?? state.taskId;
refreshAll(); (async () => {
})(); await convertTaskToSubtask(state.taskId, parentTaskId, target);
await fetchSubtasks(parentTaskId);
refreshAll();
})();
} else {
(async () => {
await convertTaskToSubtask(state.taskId, target);
refreshAll();
})();
}
} }
}, [findHoverTarget, refreshAll]); }, [findHoverTarget, refreshAll, fetchSubtasks]);
const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string) => { const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string) => {
subtaskDragRef.current = { subtaskId, parentTaskId, positions: await measureItems() }; subtaskDragRef.current = { subtaskId, parentTaskId, ...(await measureAll()) };
}, [measureItems]); }, [measureAll]);
const handleSubtaskDragUpdate = useCallback((absoluteY: number) => { const handleSubtaskDragUpdate = useCallback((absoluteY: number) => {
const state = subtaskDragRef.current; const state = subtaskDragRef.current;
if (!state) return; if (!state) return;
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions); const target = findHoverTarget(absoluteY, state.subtaskId, state.positions, state.subPositions);
setHoverTaskId((prev) => (prev === target ? prev : target)); updateHoverTarget(target);
}, [findHoverTarget]); if (target) {
if (state.subPositions[target]) {
setDropIndicator(null);
} else {
const position = calculateDropPosition(absoluteY, target, state.positions);
setDropIndicator({ targetId: target, position });
}
} else {
const positions = state.positions;
const lastItem = Object.values(positions).reduce((max, p) => p.bottom > max.bottom ? p : max, { bottom: 0 });
if (absoluteY > lastItem.bottom) {
setDropIndicator({ targetId: null, position: 'below' });
} else {
setDropIndicator(null);
}
}
}, [findHoverTarget, calculateDropPosition, updateHoverTarget]);
const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => { const handleSubtaskDragEnd = useCallback(async (absoluteY: number) => {
const state = subtaskDragRef.current; const state = subtaskDragRef.current;
@@ -285,12 +369,16 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
setDropIndicator(null); setDropIndicator(null);
if (!state) return; if (!state) return;
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions); const target = findHoverTarget(absoluteY, state.subtaskId, state.positions, state.subPositions);
if (target === state.parentTaskId) return; if (target === state.subtaskId) return;
if (target) { if (target) {
await moveSubtaskToTask(state.subtaskId, target); if (state.subPositions[target]) {
await fetchSubtasks(target); await setSubtaskParent(state.subtaskId, target);
} else if (target !== state.parentTaskId) {
await moveSubtaskToTask(state.subtaskId, target);
await fetchSubtasks(target);
}
} else { } else {
await convertSubtaskToTask(state.subtaskId); await convertSubtaskToTask(state.subtaskId);
} }
@@ -315,6 +403,8 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
selectionMode={selectionMode} selectionMode={selectionMode}
hovered={hoverTaskId === item.id} hovered={hoverTaskId === item.id}
registerRef={registerRef} registerRef={registerRef}
registerSubtaskRef={registerSubtaskRef}
hoverTaskId={hoverTaskId}
onToggle={handleToggle} onToggle={handleToggle}
onDelete={openTaskDelete} onDelete={openTaskDelete}
onExpand={toggleExpand} onExpand={toggleExpand}
@@ -323,7 +413,6 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
onMenuOpen={(task) => openTaskEdit(task.id)} onMenuOpen={(task) => openTaskEdit(task.id)}
onSubtaskToggle={handleSubtaskToggle} onSubtaskToggle={handleSubtaskToggle}
onSubtaskDelete={openSubtaskDelete} onSubtaskDelete={openSubtaskDelete}
onSubtaskEdit={openSubtaskEdit}
onSubtaskMenuOpen={(subtask) => openSubtaskEdit(subtask.id)} onSubtaskMenuOpen={(subtask) => openSubtaskEdit(subtask.id)}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragUpdate={handleDragUpdate} onDragUpdate={handleDragUpdate}
@@ -420,7 +509,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
<View style={[styles.selectionBar, { backgroundColor: theme.sheetBg, borderTopColor: theme.border }]}> <View style={[styles.selectionBar, { backgroundColor: theme.sheetBg, borderTopColor: theme.border }]}>
<TouchableOpacity onPress={exitSelection} style={styles.selectionCancel} activeOpacity={0.7}> <TouchableOpacity onPress={exitSelection} style={styles.selectionCancel} activeOpacity={0.7}>
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textFaint} strokeWidth={2} strokeLinecap="round" /> <Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
</Svg> </Svg>
<Text style={[styles.selectionCancelText, { color: theme.textFaint }]}>Cancel</Text> <Text style={[styles.selectionCancelText, { color: theme.textFaint }]}>Cancel</Text>
</TouchableOpacity> </TouchableOpacity>
@@ -455,6 +544,8 @@ interface TaskRowProps {
selectionMode: boolean; selectionMode: boolean;
hovered: boolean; hovered: boolean;
registerRef: (taskId: string, ref: View | null) => void; registerRef: (taskId: string, ref: View | null) => void;
registerSubtaskRef: (subtaskId: string, parentTaskId: string, ref: View | null) => void;
hoverTaskId: string | null;
onToggle: (taskId: string) => void | Promise<void>; onToggle: (taskId: string) => void | Promise<void>;
onDelete: (task: Task) => void; onDelete: (task: Task) => void;
onExpand: (taskId: string) => void | Promise<void>; onExpand: (taskId: string) => void | Promise<void>;
@@ -463,7 +554,6 @@ interface TaskRowProps {
onMenuOpen: (task: Task) => void; onMenuOpen: (task: Task) => void;
onSubtaskToggle: (subtaskId: string, taskId: string) => void; onSubtaskToggle: (subtaskId: string, taskId: string) => void;
onSubtaskDelete: (subtask: SubtaskData) => void; onSubtaskDelete: (subtask: SubtaskData) => void;
onSubtaskEdit: (subtaskId: string) => void;
onSubtaskMenuOpen: (subtask: SubtaskData) => void; onSubtaskMenuOpen: (subtask: SubtaskData) => void;
onDragStart: (taskId: string) => void; onDragStart: (taskId: string) => void;
onDragUpdate: (absoluteY: number) => void; onDragUpdate: (absoluteY: number) => void;
@@ -485,7 +575,9 @@ const TaskRow = React.memo(function TaskRow({
selected, selected,
selectionMode, selectionMode,
hovered, hovered,
hoverTaskId,
registerRef, registerRef,
registerSubtaskRef,
onToggle, onToggle,
onDelete, onDelete,
onExpand, onExpand,
@@ -494,7 +586,6 @@ const TaskRow = React.memo(function TaskRow({
onMenuOpen, onMenuOpen,
onSubtaskToggle, onSubtaskToggle,
onSubtaskDelete, onSubtaskDelete,
onSubtaskEdit,
onSubtaskMenuOpen, onSubtaskMenuOpen,
onDragStart, onDragStart,
onDragUpdate, onDragUpdate,
@@ -540,12 +631,13 @@ const TaskRow = React.memo(function TaskRow({
{expanded && subtasks.length > 0 && ( {expanded && subtasks.length > 0 && (
<View style={styles.subtaskList}> <View style={styles.subtaskList}>
{sortedSubtasks.map((sub) => ( {sortedSubtasks.map((sub) => (
<SubtaskItem <SubtaskItem
key={sub.id} key={sub.id}
subtask={sub} subtask={sub}
onToggle={() => onSubtaskToggle(sub.id, task.id)} hoveredId={hoverTaskId}
registerRef={registerSubtaskRef}
onToggle={() => onSubtaskToggle(sub.id, task.id)}
onDelete={() => onSubtaskDelete(sub)} onDelete={() => onSubtaskDelete(sub)}
onPress={() => onSubtaskEdit(sub.id)}
onMenuOpen={() => onSubtaskMenuOpen(sub)} onMenuOpen={() => onSubtaskMenuOpen(sub)}
selected={selectedIds.has(sub.id)} selected={selectedIds.has(sub.id)}
selectionMode={selectionMode} selectionMode={selectionMode}
@@ -569,7 +661,7 @@ const styles = StyleSheet.create({
listContent: { listContent: {
paddingHorizontal: 16, paddingHorizontal: 16,
paddingTop: 8, paddingTop: 8,
paddingBottom: 120, paddingBottom: 12,
}, },
loadingContainer: { loadingContainer: {
flex: 1, flex: 1,
@@ -47,19 +47,19 @@ export function TaskOverflowMenu({
{ {
key: 'edit', key: 'edit',
label: 'Edit', label: 'Edit',
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M4 20h4L19.5 8.5a2.1 2.1 0 0 0-3-3L5 17v3z" stroke={theme.textSecondary} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>, icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M4 20h4L19.5 8.5a2.1 2.1 0 0 0-3-3L5 17v3z" stroke={theme.text} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
onPress: onEdit, onPress: onEdit,
}, },
{ {
key: 'complete', key: 'complete',
label: task.completed ? 'Mark Incomplete' : 'Mark Complete', label: task.completed ? 'Mark Incomplete' : 'Mark Complete',
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M5 12l5 5 9-10" stroke={theme.textSecondary} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>, icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M5 12l5 5 9-10" stroke={theme.text} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
onPress: onToggleComplete, onPress: onToggleComplete,
}, },
{ {
key: 'duplicate', key: 'duplicate',
label: 'Duplicate', label: 'Duplicate',
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M8 8h12v12H8zM4 16V4h12" stroke={theme.textSecondary} strokeWidth={1.8} fill="none" /></Svg>, icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M8 8h12v12H8zM4 16V4h12" stroke={theme.text} strokeWidth={2.2} fill="none" /></Svg>,
onPress: onDuplicate, onPress: onDuplicate,
}, },
]; ];
@@ -77,14 +77,14 @@ export function TaskOverflowMenu({
{ {
key: 'priority', key: 'priority',
label: 'Change Priority', label: 'Change Priority',
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M5 3v18M5 5h14l-3 4 3 4H5" stroke={theme.textSecondary} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>, icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M5 3v18M5 5h14l-3 4 3 4H5" stroke={theme.text} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
onPress: onChangePriority, onPress: onChangePriority,
}, },
{ {
key: 'delete', key: 'delete',
label: 'Delete', label: 'Delete',
destructive: true, destructive: true,
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke="#E53935" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>, icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke="#E57373" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" /></Svg>,
onPress: onDelete, onPress: onDelete,
}, },
); );
@@ -93,7 +93,7 @@ export function TaskOverflowMenu({
actions.push({ actions.push({
key: 'addSubtask', key: 'addSubtask',
label: 'Add Subtask', label: 'Add Subtask',
icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M12 5v14M5 12h14" stroke={theme.textSecondary} strokeWidth={2} strokeLinecap="round" /></Svg>, icon: <Svg width={20} height={20} viewBox="0 0 24 24"><Path d="M12 5v14M5 12h14" stroke={theme.text} strokeWidth={2.5} strokeLinecap="round" /></Svg>,
onPress: onAddSubtask, onPress: onAddSubtask,
}); });
} }
@@ -138,8 +138,8 @@ function Circle2() {
const { theme } = useSettings(); const { theme } = useSettings();
return ( return (
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z" stroke={theme.textSecondary} strokeWidth={1.8} fill="none" /> <Path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z" stroke={theme.text} strokeWidth={2.2} fill="none" />
<Path d="M12 2a10 10 0 0 1 0 20z" fill={theme.textSecondary} opacity={0.3} /> <Path d="M12 2a10 10 0 0 1 0 20z" fill={theme.text} opacity={0.35} />
</Svg> </Svg>
); );
} }
@@ -212,7 +212,7 @@ export function WheelTimePicker({
</TouchableOpacity> </TouchableOpacity>
<View style={styles.headerCenter}> <View style={styles.headerCenter}>
<Text style={[styles.title, { color: theme.text }]}>{title}</Text> <Text style={[styles.title, { color: theme.text }]}>{title}</Text>
<Text style={[styles.preview, { color: theme.accent }]}> <Text style={[styles.preview, { color: theme.accentStrong }]}>
{formatTime12(hourIndex, minuteIndex)} {formatTime12(hourIndex, minuteIndex)}
</Text> </Text>
</View> </View>
@@ -220,7 +220,7 @@ export function WheelTimePicker({
onPress={() => onConfirm(`${String(hourIndex).padStart(2, '0')}:${String(minuteIndex).padStart(2, '0')}`)} onPress={() => onConfirm(`${String(hourIndex).padStart(2, '0')}:${String(minuteIndex).padStart(2, '0')}`)}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
> >
<Text style={[styles.headerAction, styles.doneText, { color: theme.accent }]}>Done</Text> <Text style={[styles.headerAction, styles.doneText, { color: theme.accentStrong }]}>Done</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -238,7 +238,7 @@ export function WheelTimePicker({
onIndexChange={setHourIndex} onIndexChange={setHourIndex}
/> />
<View style={[styles.colonWrap, { width: 24, backgroundColor: theme.sheetBg }]}> <View style={[styles.colonWrap, { width: 24, backgroundColor: theme.sheetBg }]}>
<Text style={[styles.colon, { color: theme.accent }]}>:</Text> <Text style={[styles.colon, { color: theme.accentStrong }]}>:</Text>
</View> </View>
<WheelColumn <WheelColumn
data={MINUTES} data={MINUTES}
+3 -1
View File
@@ -5,10 +5,11 @@ import Task from '../models/Task';
import Subtask from '../models/Subtask'; import Subtask from '../models/Subtask';
import RepeatProfile from '../models/RepeatProfile'; import RepeatProfile from '../models/RepeatProfile';
import Friendship from '../models/Friendship'; import Friendship from '../models/Friendship';
import Tombstone from '../models/Tombstone';
export const database = new Database({ export const database = new Database({
adapter, adapter,
modelClasses: [Category, Task, Subtask, RepeatProfile, Friendship], modelClasses: [Category, Task, Subtask, RepeatProfile, Friendship, Tombstone],
}); });
export const collections = { export const collections = {
@@ -17,4 +18,5 @@ export const collections = {
subtasks: database.collections.get<Subtask>('subtasks'), subtasks: database.collections.get<Subtask>('subtasks'),
repeatProfiles: database.collections.get<RepeatProfile>('repeat_profiles'), repeatProfiles: database.collections.get<RepeatProfile>('repeat_profiles'),
friendships: database.collections.get<Friendship>('friendships'), friendships: database.collections.get<Friendship>('friendships'),
tombstones: database.collections.get<Tombstone>('tombstones'),
}; };
@@ -180,5 +180,18 @@ export const migrations = schemaMigrations({
}), }),
], ],
}, },
{
toVersion: 17,
steps: [
createTable({
name: 'tombstones',
columns: [
{ name: 'entity', type: 'string' },
{ name: 'entity_id', type: 'string', isIndexed: true },
{ name: 'deleted_at', type: 'number', isIndexed: true },
],
}),
],
},
], ],
}); });
+9 -1
View File
@@ -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: 16, version: 17,
tables: [ tables: [
tableSchema({ tableSchema({
name: 'categories', name: 'categories',
@@ -85,5 +85,13 @@ export const schema = appSchema({
{ name: 'updated_at', type: 'number', isIndexed: true }, { name: 'updated_at', type: 'number', isIndexed: true },
], ],
}), }),
tableSchema({
name: 'tombstones',
columns: [
{ name: 'entity', type: 'string' },
{ name: 'entity_id', type: 'string', isIndexed: true },
{ name: 'deleted_at', type: 'number', isIndexed: true },
],
}),
], ],
}); });
+126 -6
View File
@@ -3,11 +3,14 @@ import { database, collections } from './index';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { Q } from '@nozbe/watermelondb'; import { Q } from '@nozbe/watermelondb';
import { apiFetch, getAuthToken } from '@/services/auth'; import { apiFetch, getAuthToken } from '@/services/auth';
import { cancelTaskReminder } from '@/services/notifications';
import Category from '@/models/Category'; import Category from '@/models/Category';
import Task from '@/models/Task'; import Task from '@/models/Task';
import Subtask from '@/models/Subtask'; import Subtask from '@/models/Subtask';
import RepeatProfile from '@/models/RepeatProfile'; import RepeatProfile from '@/models/RepeatProfile';
import Friendship from '@/models/Friendship'; import Friendship from '@/models/Friendship';
import { TombstoneEntity } from '@/models/Tombstone';
import { fetchPendingTombstones, removeTombstonesInBatch } from './tombstones';
const LAST_PULLED_AT_KEY = 'sync:lastPulledAt'; const LAST_PULLED_AT_KEY = 'sync:lastPulledAt';
const LAST_RUN_AT_KEY = 'sync:lastRunAt'; const LAST_RUN_AT_KEY = 'sync:lastRunAt';
@@ -79,11 +82,13 @@ export async function runSync(): Promise<SyncResult> {
timestamp: 0, timestamp: 0,
}; };
const conflicts = await pushChanges(); const { conflicts, pushedDeletions } = await pushChanges();
result.conflicts = conflicts.length; result.conflicts = conflicts.length;
await applyConflicts(conflicts); await applyConflicts(conflicts);
await pruneSyncedTombstones(conflicts, pushedDeletions);
const lastPulledAt = await getLastPulledAt(); const lastPulledAt = await getLastPulledAt();
const response = await apiFetch(`/sync?since=${lastPulledAt}`); const response = await apiFetch(`/sync?since=${lastPulledAt}`);
const data = await response.json(); const data = await response.json();
@@ -97,7 +102,7 @@ export async function runSync(): Promise<SyncResult> {
return result; return result;
} }
async function pushChanges(): Promise<PushConflict[]> { async function pushChanges(): Promise<{ conflicts: PushConflict[]; pushedDeletions: { entity: TombstoneEntity; id: string }[] }> {
const lastPulledAt = await getLastPulledAt(); const lastPulledAt = await getLastPulledAt();
const [tasks, subtasks, repeatProfiles, friendships] = await Promise.all([ const [tasks, subtasks, repeatProfiles, friendships] = await Promise.all([
@@ -125,6 +130,7 @@ async function pushChanges(): Promise<PushConflict[]> {
reminder: t.reminder || 'none', reminder: t.reminder || 'none',
reminders: t.reminders || '', reminders: t.reminders || '',
assigneeId: t.assigneeId || null, assigneeId: t.assigneeId || null,
completedAt: t.completedAt ?? null,
createdAt: t.createdAt.getTime(), createdAt: t.createdAt.getTime(),
updatedAt: t.updatedAt.getTime(), updatedAt: t.updatedAt.getTime(),
}); });
@@ -143,6 +149,7 @@ async function pushChanges(): Promise<PushConflict[]> {
const changedSubtasks = subtasks.map((s) => ({ const changedSubtasks = subtasks.map((s) => ({
id: s.id, id: s.id,
taskId: s.taskId, taskId: s.taskId,
parentSubtaskId: s.parentSubtaskId || null,
title: s.title, title: s.title,
description: s.description ?? '', description: s.description ?? '',
priority: s.priority ?? 'none', priority: s.priority ?? 'none',
@@ -221,14 +228,22 @@ async function pushChanges(): Promise<PushConflict[]> {
updatedAt: f.updatedAt.getTime(), updatedAt: f.updatedAt.getTime(),
})); }));
const pendingTombstones = await fetchPendingTombstones(lastPulledAt);
const changedDeletions = pendingTombstones.map((t) => ({
entity: t.entity,
id: t.entityId,
updatedAt: t.deletedAt.getTime(),
}));
if ( if (
changedCategories.length === 0 && changedCategories.length === 0 &&
changedTasks.length === 0 && changedTasks.length === 0 &&
changedSubtasks.length === 0 && changedSubtasks.length === 0 &&
changedRepeatProfiles.length === 0 && changedRepeatProfiles.length === 0 &&
changedFriendships.length === 0 changedFriendships.length === 0 &&
changedDeletions.length === 0
) { ) {
return []; return { conflicts: [], pushedDeletions: [] };
} }
const response = await apiFetch('/sync/push', { const response = await apiFetch('/sync/push', {
@@ -241,12 +256,40 @@ async function pushChanges(): Promise<PushConflict[]> {
repeatProfiles: changedRepeatProfiles, repeatProfiles: changedRepeatProfiles,
friendships: changedFriendships, friendships: changedFriendships,
}, },
deleted: changedDeletions,
lastPulledAt, lastPulledAt,
}), }),
}); });
const data = await response.json(); const data = await response.json();
return (data.conflicts ?? []) as PushConflict[]; return {
conflicts: (data.conflicts ?? []) as PushConflict[],
pushedDeletions: pendingTombstones.map((t) => ({ entity: t.entity, id: t.entityId })),
};
}
// Remove local tombstones that were just accepted by the server. Tombstones
// whose deletion lost to a newer server row (conflict) are kept so the row
// stays marked as deleted locally after the server version wins.
async function pruneSyncedTombstones(
conflicts: PushConflict[],
pushedDeletions: { entity: TombstoneEntity; id: string }[]
): Promise<void> {
if (pushedDeletions.length === 0) return;
const conflictKeys = new Set(conflicts.map((c) => `${c.entity}:${c.id}`));
const accepted = pushedDeletions.filter((d) => !conflictKeys.has(`${d.entity}:${d.id}`));
const grouped = new Map<TombstoneEntity, string[]>();
for (const t of accepted) {
const list = grouped.get(t.entity) ?? [];
list.push(t.id);
grouped.set(t.entity, list);
}
if (accepted.length === 0) return;
await database.write(async () => {
for (const [entity, ids] of grouped) {
await removeTombstonesInBatch(entity, ids);
}
});
} }
async function applyConflicts(conflicts: PushConflict[]): Promise<void> { async function applyConflicts(conflicts: PushConflict[]): Promise<void> {
@@ -272,6 +315,9 @@ async function applyConflicts(conflicts: PushConflict[]): Promise<void> {
await upsertFriendship(row, true); await upsertFriendship(row, true);
break; break;
} }
// The server version of this row is newer - drop any pending local
// tombstone so the resurrected row is kept and synced going forward.
await removeTombstonesInBatch(conflict.entity, [conflict.id]);
} }
}); });
} }
@@ -282,10 +328,16 @@ async function applyServerChanges(data: {
subtasks: ServerRow[]; subtasks: ServerRow[];
repeatProfiles: ServerRow[]; repeatProfiles: ServerRow[];
friendships: ServerRow[]; friendships: ServerRow[];
deleted?: { entity: TombstoneEntity; id: string; updatedAt: number }[];
}): Promise<SyncResult['pulled']> { }): Promise<SyncResult['pulled']> {
const counts = { categories: 0, tasks: 0, subtasks: 0, repeatProfiles: 0, friendships: 0 }; const counts = { categories: 0, tasks: 0, subtasks: 0, repeatProfiles: 0, friendships: 0 };
await database.write(async () => { await database.write(async () => {
// Apply deletions first so rows that were removed server-side are gone
// before any upsert re-imports their data.
for (const tomb of data.deleted ?? []) {
await applyRemoteTombstone(tomb);
}
for (const cat of data.categories) { for (const cat of data.categories) {
if (await upsertCategory(cat, false)) counts.categories++; if (await upsertCategory(cat, false)) counts.categories++;
} }
@@ -306,6 +358,68 @@ async function applyServerChanges(data: {
return counts; return counts;
} }
// Apply a server-side deletion to the local DB, cascading to every related
// row (category -> tasks -> subtasks, task -> subtasks, subtask -> children).
// LWW: if the local row is newer than the deletion, it survives.
async function applyRemoteTombstone(tomb: { entity: TombstoneEntity; id: string; updatedAt: number }): Promise<void> {
const { entity, id, updatedAt } = tomb;
const row = await findOrNull<any>(collections[entity as keyof typeof collections] as any, id);
if (row) {
if (row.updatedAt.getTime() > updatedAt) {
return; // local edit is newer than the remote deletion - keep it
}
await destroyLocalEntityCascade(entity, id);
} else {
// Row already gone locally - clear the tombstone if the server has it.
await removeTombstonesInBatch(entity, [id]);
}
}
async function destroyLocalEntityCascade(entity: TombstoneEntity, id: string): Promise<void> {
if (entity === 'tasks') {
const subtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch();
for (const s of subtasks) {
await destroyLocalEntityCascade('subtasks', s.id);
}
const task = await findOrNull<any>(collections.tasks, id).catch(() => null);
if (task) {
await task.destroyPermanently();
await cancelTaskReminder(id).catch(() => {});
}
return;
}
if (entity === 'categories') {
const tasks = await collections.tasks.query(Q.where('category_id', id)).fetch();
for (const t of tasks) {
await destroyLocalEntityCascade('tasks', t.id);
}
const cat = await findOrNull<any>(collections.categories, id).catch(() => null);
if (cat) await cat.destroyPermanently();
return;
}
if (entity === 'subtasks') {
const children = await collections.subtasks.query(Q.where('parent_subtask_id', id)).fetch();
for (const c of children) {
await destroyLocalEntityCascade('subtasks', c.id);
}
const sub = await findOrNull<any>(collections.subtasks, id).catch(() => null);
if (sub) {
await sub.destroyPermanently();
await cancelTaskReminder(id).catch(() => {});
}
return;
}
if (entity === 'repeatProfiles') {
const p = await findOrNull<any>(collections.repeatProfiles, id).catch(() => null);
if (p) await p.destroyPermanently();
return;
}
if (entity === 'friendships') {
const f = await findOrNull<any>(collections.friendships, id).catch(() => null);
if (f) await f.destroyPermanently();
}
}
async function upsertCategory(row: ServerRow, force: boolean): Promise<boolean> { async function upsertCategory(row: ServerRow, force: boolean): Promise<boolean> {
const local = await findOrNull<Category>(collections.categories, row.id); const local = await findOrNull<Category>(collections.categories, row.id);
if (!local) { if (!local) {
@@ -347,7 +461,9 @@ async function upsertTask(row: ServerRow, force: boolean): Promise<boolean> {
t.repeatDays = String(row.repeatDays ?? ''); t.repeatDays = String(row.repeatDays ?? '');
t.seriesId = String(row.seriesId ?? ''); t.seriesId = String(row.seriesId ?? '');
t.reminder = row.reminder ?? 'none'; t.reminder = row.reminder ?? 'none';
t.reminders = String(row.reminders ?? '');
t.assigneeId = row.assigneeId ?? null; t.assigneeId = row.assigneeId ?? null;
t.completedAt = row.completedAt == null ? null : Number(row.completedAt);
t.createdAt = new Date(row.createdAt ?? Date.now()); t.createdAt = new Date(row.createdAt ?? Date.now());
t.updatedAt = new Date(row.updatedAt ?? Date.now()); t.updatedAt = new Date(row.updatedAt ?? Date.now());
}); });
@@ -370,7 +486,9 @@ async function upsertTask(row: ServerRow, force: boolean): Promise<boolean> {
t.repeatDays = String(row.repeatDays ?? t.repeatDays); t.repeatDays = String(row.repeatDays ?? t.repeatDays);
t.seriesId = String(row.seriesId ?? t.seriesId); t.seriesId = String(row.seriesId ?? t.seriesId);
t.reminder = row.reminder ?? t.reminder; t.reminder = row.reminder ?? t.reminder;
t.reminders = String(row.reminders ?? t.reminders ?? '');
t.assigneeId = row.assigneeId ?? t.assigneeId; t.assigneeId = row.assigneeId ?? t.assigneeId;
t.completedAt = row.completedAt == null ? null : Number(row.completedAt ?? t.completedAt);
t.updatedAt = new Date(row.updatedAt ?? Date.now()); t.updatedAt = new Date(row.updatedAt ?? Date.now());
}); });
return true; return true;
@@ -410,6 +528,7 @@ async function upsertSubtask(row: ServerRow, force: boolean): Promise<boolean> {
if (!local) { if (!local) {
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.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';
@@ -435,6 +554,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.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;
@@ -572,4 +692,4 @@ export function isSyncInFlight(): boolean {
export function isWatchingForUpdates(): boolean { export function isWatchingForUpdates(): boolean {
return watcherActive; return watcherActive;
} }
@@ -0,0 +1,65 @@
import { database, collections } from './index';
import { Q } from '@nozbe/watermelondb';
import Tombstone, { TombstoneEntity } from '@/models/Tombstone';
// Batch variant: must be called from inside an existing database.write().
export async function recordTombstonesInBatch(entity: TombstoneEntity, ids: string[]): Promise<void> {
const uniqueIds = [...new Set(ids)].filter(Boolean);
for (const id of uniqueIds) {
const existing = await collections.tombstones
.query(Q.where('entity', entity), Q.where('entity_id', id))
.fetch();
const now = new Date();
if (existing.length > 0) {
await existing[0].update((t) => {
t.deletedAt = now;
});
} else {
await collections.tombstones.create((t) => {
t.entity = entity;
t.entityId = id;
t.deletedAt = now;
});
}
}
}
// Batch variant: must be called from inside an existing database.write().
export async function removeTombstonesInBatch(entity: TombstoneEntity, ids: string[]): Promise<void> {
for (const id of ids) {
const existing = await collections.tombstones
.query(Q.where('entity', entity), Q.where('entity_id', id))
.fetch();
for (const row of existing) {
await row.destroyPermanently();
}
}
}
export async function recordTombstone(entity: TombstoneEntity, id: string): Promise<void> {
await database.write(async () => {
await recordTombstonesInBatch(entity, [id]);
});
}
export async function recordTombstones(entity: TombstoneEntity, ids: string[]): Promise<void> {
await database.write(async () => {
await recordTombstonesInBatch(entity, ids);
});
}
export async function removeTombstone(entity: TombstoneEntity, id: string): Promise<void> {
await database.write(async () => {
await removeTombstonesInBatch(entity, [id]);
});
}
export async function removeTombstones(entity: TombstoneEntity, ids: string[]): Promise<void> {
await database.write(async () => {
await removeTombstonesInBatch(entity, ids);
});
}
export async function fetchPendingTombstones(sinceMs: number): Promise<Tombstone[]> {
return collections.tombstones.query(Q.where('deleted_at', Q.gt(sinceMs))).fetch();
}
-1
View File
@@ -23,7 +23,6 @@ export default class Task extends Model {
@field('repeat') repeat!: Repeat; @field('repeat') repeat!: Repeat;
@field('repeat_interval') repeatInterval!: number; @field('repeat_interval') repeatInterval!: number;
@field('repeat_days') repeatDays!: string; @field('repeat_days') repeatDays!: string;
@field('color') color!: string;
@field('series_id') seriesId!: string; @field('series_id') seriesId!: string;
@field('reminder') reminder!: Reminder; @field('reminder') reminder!: Reminder;
@field('reminders') reminders!: string; @field('reminders') reminders!: string;
+12
View File
@@ -0,0 +1,12 @@
import { Model } from '@nozbe/watermelondb';
import { field, date } from '@nozbe/watermelondb/decorators';
export type TombstoneEntity = 'categories' | 'tasks' | 'subtasks' | 'repeatProfiles' | 'friendships';
export default class Tombstone extends Model {
static table = 'tombstones';
@field('entity') entity!: TombstoneEntity;
@field('entity_id') entityId!: string;
@date('deleted_at') deletedAt!: Date;
}
+8 -1
View File
@@ -97,15 +97,22 @@ export async function apiFetch(path: string, options: RequestInit = {}): Promise
const response = await fetch(`${apiBaseUrl}${path}`, { ...options, headers }); const response = await fetch(`${apiBaseUrl}${path}`, { ...options, headers });
if (!response.ok) { if (!response.ok) {
let message = `Request failed (${response.status})`; let message = `Request failed (${response.status})`;
let details: any = null;
try { try {
const body = await response.json(); const body = await response.json();
if (body && typeof body.message === 'string') { if (body && typeof body.message === 'string') {
message = body.message; message = body.message;
} }
if (body && body.error && body.error.details) {
details = body.error.details;
}
} catch { } catch {
// ignore // ignore
} }
throw new Error(message); const error = new Error(message);
(error as any).details = details;
(error as any).status = response.status;
throw error;
} }
return response; return response;
} }
+24 -12
View File
@@ -34,6 +34,7 @@ export interface ThemeColors {
textFaint: string; textFaint: string;
textMuted: string; textMuted: string;
accent: string; accent: string;
accentStrong: string;
accentSoft: string; accentSoft: string;
accentBorder: string; accentBorder: string;
accentText: string; accentText: string;
@@ -74,6 +75,13 @@ export function mixHex(color: string, target: string, ratio: number): string {
return `#${toHexByte(r1 + (r2 - r1) * ratio)}${toHexByte(g1 + (g2 - g1) * ratio)}${toHexByte(b1 + (b2 - b1) * ratio)}`.toUpperCase(); return `#${toHexByte(r1 + (r2 - r1) * ratio)}${toHexByte(g1 + (g2 - g1) * ratio)}${toHexByte(b1 + (b2 - b1) * ratio)}`.toUpperCase();
} }
export function desaturate(hex: string, amount: number): string {
const [r, g, b] = parseHex(hex);
const gray = 0.2126 * r + 0.7152 * g + 0.0722 * b;
const shift = 1 - amount;
return `#${toHexByte(gray + (r - gray) * shift)}${toHexByte(gray + (g - gray) * shift)}${toHexByte(gray + (b - gray) * shift)}`.toUpperCase();
}
function accentTextColor(accent: string): string { function accentTextColor(accent: string): string {
const [r, g, b] = parseHex(accent).map((v) => v / 255); const [r, g, b] = parseHex(accent).map((v) => v / 255);
const linear = (v: number) => (v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)); const linear = (v: number) => (v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
@@ -82,24 +90,26 @@ function accentTextColor(accent: string): string {
} }
export function colors(accent: string = DEFAULT_ACCENT): ThemeColors { export function colors(accent: string = DEFAULT_ACCENT): ThemeColors {
const softAccent = desaturate(accent, 0.35);
return { return {
background: '#121212', background: '#121212',
card: '#1E1E1E', card: '#1E1E1E',
cardAlt: '#262626', cardAlt: '#28292C',
border: '#2A2A2A', border: '#2C2E33',
borderStrong: '#3A3A3A', borderStrong: '#3C3E45',
text: '#F5F5F5', text: '#F2F3F5',
textSecondary: '#E0E0E0', textSecondary: '#E3E4E8',
textFaint: '#BDBDBD', textFaint: '#C4C6CC',
textMuted: '#8E8E8E', textMuted: '#9C9FA7',
accent, accent,
accentSoft: mixHex(accent, '#121212', 0.12), accentStrong: mixHex(accent, '#FFFFFF', 0.55),
accentBorder: mixHex(accent, '#121212', 0.25), accentSoft: mixHex(softAccent, '#121212', 0.18),
accentBorder: mixHex(desaturate(accent, 0.15), '#121212', 0.5),
accentText: accentTextColor(accent), accentText: accentTextColor(accent),
inputBg: '#1A1A1A', inputBg: '#1A1B1E',
overlay: 'rgba(0,0,0,0.6)', overlay: 'rgba(0,0,0,0.6)',
sheetBg: '#242424', sheetBg: '#26282D',
tabBarBg: '#1A1A1A', tabBarBg: '#16171A',
}; };
} }
@@ -225,3 +235,5 @@ export function useSettings() {
} }
return context; return context;
} }
export * from './theme/tokens';
+194
View File
@@ -0,0 +1,194 @@
export const SPACING = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 20,
xxl: 24,
xxxl: 32,
} as const;
export const BORDER_RADIUS = {
xs: 6,
sm: 8,
md: 10,
lg: 12,
xl: 16,
xxl: 20,
round: 999,
circle: 50,
} as const;
export const SHADOWS = {
none: {
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
xs: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.04,
shadowRadius: 2,
elevation: 1,
},
sm: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.06,
shadowRadius: 4,
elevation: 2,
},
md: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.08,
shadowRadius: 8,
elevation: 4,
},
lg: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.12,
shadowRadius: 16,
elevation: 8,
},
xl: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.16,
shadowRadius: 24,
elevation: 12,
},
inner: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.03,
shadowRadius: 2,
elevation: 0,
},
} as const;
export const TYPOGRAPHY = {
display: {
fontSize: 34,
fontWeight: '700' as const,
lineHeight: 42,
letterSpacing: -0.5,
},
h1: {
fontSize: 28,
fontWeight: '700' as const,
lineHeight: 36,
letterSpacing: -0.3,
},
h2: {
fontSize: 22,
fontWeight: '700' as const,
lineHeight: 30,
letterSpacing: -0.2,
},
h3: {
fontSize: 18,
fontWeight: '600' as const,
lineHeight: 24,
letterSpacing: 0,
},
h4: {
fontSize: 16,
fontWeight: '600' as const,
lineHeight: 22,
letterSpacing: 0,
},
body: {
fontSize: 16,
fontWeight: '400' as const,
lineHeight: 24,
letterSpacing: 0,
},
bodyStrong: {
fontSize: 16,
fontWeight: '600' as const,
lineHeight: 24,
letterSpacing: 0,
},
bodySmall: {
fontSize: 14,
fontWeight: '400' as const,
lineHeight: 20,
letterSpacing: 0,
},
bodySmallStrong: {
fontSize: 14,
fontWeight: '600' as const,
lineHeight: 20,
letterSpacing: 0,
},
caption: {
fontSize: 12,
fontWeight: '400' as const,
lineHeight: 16,
letterSpacing: 0.2,
},
captionStrong: {
fontSize: 12,
fontWeight: '600' as const,
lineHeight: 16,
letterSpacing: 0.2,
},
overline: {
fontSize: 11,
fontWeight: '600' as const,
lineHeight: 14,
letterSpacing: 0.8,
textTransform: 'uppercase' as const,
},
button: {
fontSize: 15,
fontWeight: '600' as const,
lineHeight: 20,
letterSpacing: 0.2,
},
buttonSmall: {
fontSize: 13,
fontWeight: '600' as const,
lineHeight: 18,
letterSpacing: 0.2,
},
} as const;
export const TRANSITIONS = {
fast: 120,
normal: 200,
slow: 300,
slower: 400,
} as const;
export const EASING = {
easeOut: 'ease-out',
easeInOut: 'ease-in-out',
spring: 'spring',
} as const;
export const LAYOUT = {
screenPadding: SPACING.lg,
cardPadding: SPACING.lg,
sectionGap: SPACING.xl,
itemGap: SPACING.md,
inlineGap: SPACING.sm,
} as const;
export const TOUCH_TARGET = {
minSize: 44,
comfortableSize: 48,
} as const;
export const Z_INDEX = {
base: 0,
dropdown: 100,
modal: 200,
toast: 300,
tooltip: 400,
} as const;
+6
View File
@@ -12,6 +12,12 @@ export const REPEAT_OPTIONS: { value: Repeat; label: string }[] = [
export const WEEKDAY_LABELS = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; export const WEEKDAY_LABELS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
export const WEEKDAY_ORDER = [1, 2, 3, 4, 5, 6, 0];
export function weekdaysInDisplayOrder(days: number[]): number[] {
return WEEKDAY_ORDER.filter((day) => days.includes(day));
}
export function repeatDaysToString(days: number[]): string { export function repeatDaysToString(days: number[]): string {
return [...days].sort().join(','); return [...days].sort().join(',');
} }
@@ -1,5 +1,6 @@
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';
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 () => {
@@ -42,5 +43,6 @@ export async function deleteCategory(categoryId: string): Promise<void> {
const category = await collections.categories.find(categoryId); const category = await collections.categories.find(categoryId);
await category.destroyPermanently(); await category.destroyPermanently();
await recordTombstonesInBatch('categories', [categoryId]);
}); });
} }
@@ -1,5 +1,6 @@
import { database, collections } from '../database'; import { database, collections } from '../database';
import { Repeat } from '@/types'; import { Repeat } from '@/types';
import { recordTombstonesInBatch } from '../database/tombstones';
export async function createRepeatProfile( export async function createRepeatProfile(
name: string, name: string,
@@ -24,5 +25,6 @@ export async function deleteRepeatProfile(id: string): Promise<void> {
await database.write(async () => { await database.write(async () => {
const profile = await collections.repeatProfiles.find(id); const profile = await collections.repeatProfiles.find(id);
await profile.destroyPermanently(); await profile.destroyPermanently();
await recordTombstonesInBatch('repeatProfiles', [id]);
}); });
} }
+16
View File
@@ -0,0 +1,16 @@
export const TAB_BAR_HEIGHT = 62;
let lastVisitedTab = 'index';
export function setLastVisitedTab(tab: string): void {
lastVisitedTab = tab;
}
export function getLastVisitedTab(): string {
return lastVisitedTab;
}
export function tabHref(tab: string): string {
if (tab === 'index') return '/';
return `/${tab}`;
}
+95 -7
View File
@@ -2,6 +2,7 @@ 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 } from '@/types';
import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications'; import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications';
import { recordTombstonesInBatch } from '@/database/tombstones';
function mapSubtaskRow(s: any, taskId?: string): SubtaskData { function mapSubtaskRow(s: any, taskId?: string): SubtaskData {
return { return {
@@ -147,6 +148,10 @@ export async function deleteTaskWithSubtasks(taskId: string): Promise<void> {
} }
const task = await collections.tasks.find(taskId); const task = await collections.tasks.find(taskId);
await task.destroyPermanently(); await task.destroyPermanently();
if (subtasks.length > 0) {
await recordTombstonesInBatch('subtasks', subtasks.map((s) => s.id));
}
await recordTombstonesInBatch('tasks', [taskId]);
} }
export type TaskDeleteScope = 'this' | 'future' | 'all'; export type TaskDeleteScope = 'this' | 'future' | 'all';
@@ -200,7 +205,7 @@ export async function toggleTaskComplete(taskId: string): Promise<string | null>
t.updatedAt = new Date(); t.updatedAt = new Date();
}); });
if (completing && task.repeat !== 'none' && task.dueDate) { if (completing && task.repeat !== 'none' && task.dueDate) {
nextOccurrence = await createNextOccurrence(task); nextOccurrence = await createNextOccurrenceIfNeeded(task);
} }
}); });
if (nextOccurrence) await scheduleTaskReminder(nextOccurrence); if (nextOccurrence) await scheduleTaskReminder(nextOccurrence);
@@ -275,7 +280,15 @@ export async function deleteSubtask(subtaskId: string): Promise<void> {
await database.write(async () => { await database.write(async () => {
const subtask = await collections.subtasks.find(subtaskId); const subtask = await collections.subtasks.find(subtaskId);
parentTaskId = subtask.taskId; parentTaskId = subtask.taskId;
const children = await collections.subtasks.query(Q.where('parent_subtask_id', subtaskId)).fetch();
for (const child of children) {
await child.update((c) => {
c.parentSubtaskId = null;
c.updatedAt = new Date();
});
}
await subtask.destroyPermanently(); await subtask.destroyPermanently();
await recordTombstonesInBatch('subtasks', [subtaskId]);
}); });
await cancelTaskReminder(subtaskId); await cancelTaskReminder(subtaskId);
if (parentTaskId) { if (parentTaskId) {
@@ -379,17 +392,21 @@ export async function deleteTask(taskId: string): Promise<void> {
}); });
} }
export async function convertTaskToSubtask(taskId: string, parentTaskId: string): Promise<void> { export async function convertTaskToSubtask(taskId: string, parentTaskId: string, parentSubtaskId: string | null = null): Promise<void> {
if (taskId === parentTaskId) return; if (taskId === parentTaskId) return;
await database.write(async () => { await database.write(async () => {
const task = await collections.tasks.find(taskId); const task = await collections.tasks.find(taskId);
const ownSubtasks = await collections.subtasks.query(Q.where('task_id', taskId)).fetch(); const ownSubtasks = await collections.subtasks.query(Q.where('task_id', taskId)).fetch();
const existing = await collections.subtasks.query(Q.where('task_id', parentTaskId)).fetch(); const existing = await collections.subtasks.query(
Q.where('task_id', parentTaskId),
parentSubtaskId ? Q.where('parent_subtask_id', parentSubtaskId) : Q.where('parent_subtask_id', null)
).fetch();
const now = new Date(); const now = new Date();
let order = existing.length; let order = existing.length;
await collections.subtasks.create((s) => { const created = await collections.subtasks.create((s) => {
s.taskId = parentTaskId; s.taskId = parentTaskId;
s.parentSubtaskId = parentSubtaskId;
s.title = task.title; s.title = task.title;
s.description = task.description; s.description = task.description;
s.priority = task.priority; s.priority = task.priority;
@@ -412,6 +429,7 @@ export async function convertTaskToSubtask(taskId: string, parentTaskId: string)
for (const subtask of ownSubtasks) { for (const subtask of ownSubtasks) {
await collections.subtasks.create((s) => { await collections.subtasks.create((s) => {
s.taskId = parentTaskId; s.taskId = parentTaskId;
s.parentSubtaskId = created.id;
s.title = subtask.title; s.title = subtask.title;
s.description = subtask.description; s.description = subtask.description;
s.priority = subtask.priority; s.priority = subtask.priority;
@@ -432,7 +450,13 @@ export async function convertTaskToSubtask(taskId: string, parentTaskId: string)
}); });
} }
const parent = await collections.tasks.find(parentTaskId);
await parent.update((p) => {
p.updatedAt = now;
});
await task.destroyPermanently(); await task.destroyPermanently();
await recordTombstonesInBatch('tasks', [taskId]);
}); });
} }
@@ -464,7 +488,16 @@ export async function convertSubtaskToTask(subtaskId: string): Promise<void> {
}); });
newTaskId = task.id; newTaskId = task.id;
const children = await collections.subtasks.query(Q.where('parent_subtask_id', subtaskId)).fetch();
for (const child of children) {
await child.update((c) => {
c.parentSubtaskId = null;
c.updatedAt = now;
});
}
await subtask.destroyPermanently(); await subtask.destroyPermanently();
await recordTombstonesInBatch('subtasks', [subtaskId]);
await parent.update((p) => { await parent.update((p) => {
p.updatedAt = now; p.updatedAt = now;
}); });
@@ -487,6 +520,7 @@ export async function moveSubtaskToTask(subtaskId: string, toTaskId: string): Pr
await subtask.update((s) => { await subtask.update((s) => {
s.taskId = toTaskId; s.taskId = toTaskId;
s.parentSubtaskId = null;
s.order = siblings.length; s.order = siblings.length;
s.updatedAt = now; s.updatedAt = now;
}); });
@@ -505,6 +539,44 @@ export async function moveSubtaskToTask(subtaskId: string, toTaskId: string): Pr
}); });
} }
export async function setSubtaskParent(subtaskId: string, newParentId: string): Promise<void> {
if (subtaskId === newParentId) return;
await database.write(async () => {
const subtask = await collections.subtasks.find(subtaskId);
if (subtask.parentSubtaskId === newParentId) return;
let cursor: any = null;
try {
cursor = await collections.subtasks.find(newParentId);
} catch {
return;
}
const now = new Date();
let guard = 0;
while (cursor && guard++ < 200) {
if (cursor.id === subtaskId) return;
if (!cursor.parentSubtaskId) break;
cursor = await collections.subtasks.find(cursor.parentSubtaskId);
}
const siblings = await collections.subtasks.query(
Q.where('task_id', subtask.taskId),
Q.where('parent_subtask_id', newParentId)
).fetch();
await subtask.update((s) => {
s.parentSubtaskId = newParentId;
s.order = siblings.length;
s.updatedAt = now;
});
const task = await collections.tasks.find(subtask.taskId);
await task.update((t) => {
t.updatedAt = now;
});
});
}
export async function setTaskCompleted(taskId: string, completed: boolean): Promise<void> { export async function setTaskCompleted(taskId: string, completed: boolean): Promise<void> {
let nextOccurrence: any = null; let nextOccurrence: any = null;
await database.write(async () => { await database.write(async () => {
@@ -515,7 +587,7 @@ export async function setTaskCompleted(taskId: string, completed: boolean): Prom
t.updatedAt = new Date(); t.updatedAt = new Date();
}); });
if (completed && task.repeat !== 'none' && task.dueDate) { if (completed && task.repeat !== 'none' && task.dueDate) {
nextOccurrence = await createNextOccurrence(task); nextOccurrence = await createNextOccurrenceIfNeeded(task);
} }
}); });
if (nextOccurrence) await scheduleTaskReminder(nextOccurrence); if (nextOccurrence) await scheduleTaskReminder(nextOccurrence);
@@ -524,7 +596,23 @@ export async function setTaskCompleted(taskId: string, completed: boolean): Prom
else await scheduleTaskReminder(task); else await scheduleTaskReminder(task);
} }
async function createNextOccurrence(task: any): Promise<any> { async function createNextOccurrenceIfNeeded(task: any): Promise<any> {
const seriesId = task.seriesId || newSeriesId();
const existing = await collections.tasks.query(
Q.where('series_id', seriesId),
Q.where('completed', false)
).fetch();
if (existing.length > 0) return null;
const next = await createNextOccurrence(task, seriesId);
if (!task.seriesId) {
await task.update((t: any) => {
t.seriesId = seriesId;
});
}
return next;
}
async function createNextOccurrence(task: any, seriesId: string): Promise<any> {
const nextDate = computeNextOccurrence(task.dueDate, task.repeat, task.repeatInterval, task.repeatDays); const nextDate = computeNextOccurrence(task.dueDate, task.repeat, task.repeatInterval, task.repeatDays);
const subtasks = await collections.subtasks.query(Q.where('task_id', task.id)).fetch(); const subtasks = await collections.subtasks.query(Q.where('task_id', task.id)).fetch();
const now = new Date(); const now = new Date();
@@ -542,7 +630,7 @@ async function createNextOccurrence(task: any): Promise<any> {
t.repeat = task.repeat; t.repeat = task.repeat;
t.repeatInterval = task.repeatInterval || 1; t.repeatInterval = task.repeatInterval || 1;
t.repeatDays = task.repeatDays || ''; t.repeatDays = task.repeatDays || '';
t.seriesId = task.seriesId || newSeriesId(); t.seriesId = seriesId;
t.reminder = task.reminder || 'none'; t.reminder = task.reminder || 'none';
t.assigneeId = task.assigneeId ?? null; t.assigneeId = task.assigneeId ?? null;
t.createdAt = now; t.createdAt = now;
+5 -1
View File
@@ -1,2 +1,6 @@
tech tech
osuhhgoshghgjs osuhhgoshghgjs
test
SFGHDHTHHDHRERTHEH

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 157 KiB

+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://opencode.ai/config.json",
"subagent_depth": 999999
}
+1 -1
View File
@@ -31,4 +31,4 @@
- [ ] bottom page buttons need to be reworked looking ass currently. - [ ] bottom page buttons need to be reworked looking ass currently.
- [ ] add a task button should say "Add a Task" and (the button to add have an arrow pointing upwards instead of a cross) could look better - [ ] add a task button should say "Add a Task" and (the button to add have an arrow pointing upwards instead of a cross) could look better
- [ ] space between add task button and bottom bar should be zero of the task below should be blurred currently task visible between the two looks weird - [ ] space between add task button and bottom bar should be zero of the task below should be blurred currently task visible between the two looks weird
- [ ] Stats page rework (when time is right) - [ ] Stats page rework (when time is right)