Compare commits
14
Commits
4b6e87c979
...
v1.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80663f7d39 | ||
|
|
6969e79029 | ||
|
|
df97937948 | ||
|
|
ed8b406b7d | ||
|
|
e07d28d6b1 | ||
|
|
ab26148a67 | ||
|
|
56e6225dd1 | ||
|
|
bdc6562116 | ||
|
|
9312783793 | ||
|
|
b78ada0865 | ||
|
|
be2f77dbf1 | ||
|
|
2ca23e276f | ||
|
|
f3bcd78e49 | ||
|
|
4c3d1a118c |
@@ -0,0 +1,38 @@
|
||||
{"_e":"root:init","_t":1787490823136,"format":"v0-jsonl","version":"57.0.12"}
|
||||
{"_e":"env:mode","_t":1787490823267,"nodeEnv":"production","babelEnv":"production","mode":"production"}
|
||||
{"_e":"env:load","_t":1787490823269,"mode":"production","files":[],"env":{}}
|
||||
{"_e":"devserver:start","_t":1787490823283,"mode":"production","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":true}
|
||||
{"_e":"metro:config","_t":1787490823513,"serverRoot":"../../..","projectRoot":"../../..","exporting":true,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
|
||||
{"_e":"metro:instantiate","_t":1787490823624,"atlas":false,"workers":10,"host":null,"port":null}
|
||||
{"_e":"metro:bundling:started","_t":1787490824135,"id":"1","platform":"web","environment":null,"entry":"node_modules/expo-router/entry.js"}
|
||||
{"_e":"root:init","_t":1787490834893,"format":"v0-jsonl","version":"57.0.12"}
|
||||
{"_e":"env:mode","_t":1787490834983,"nodeEnv":"production","babelEnv":"production","mode":"production"}
|
||||
{"_e":"env:load","_t":1787490834985,"mode":"production","files":[],"env":{}}
|
||||
{"_e":"devserver:start","_t":1787490834998,"mode":"production","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":true}
|
||||
{"_e":"metro:config","_t":1787490835165,"serverRoot":"../../..","projectRoot":"../../..","exporting":true,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
|
||||
{"_e":"metro:instantiate","_t":1787490835236,"atlas":false,"workers":10,"host":null,"port":null}
|
||||
{"_e":"metro:bundling:started","_t":1787490835782,"id":"1","platform":"web","environment":null,"entry":"node_modules/expo-router/entry.js"}
|
||||
{"_e":"root:init","_t":1787490897618,"format":"v0-jsonl","version":"57.0.12"}
|
||||
{"_e":"env:mode","_t":1787490897716,"nodeEnv":"production","babelEnv":"production","mode":"production"}
|
||||
{"_e":"env:load","_t":1787490897718,"mode":"production","files":[],"env":{}}
|
||||
{"_e":"devserver:start","_t":1787490897732,"mode":"production","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":true}
|
||||
{"_e":"metro:config","_t":1787490897904,"serverRoot":"../../..","projectRoot":"../../..","exporting":true,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
|
||||
{"_e":"metro:instantiate","_t":1787490897978,"atlas":false,"workers":10,"host":null,"port":null}
|
||||
{"_e":"metro:bundling:started","_t":1787490898645,"id":"1","platform":"web","environment":null,"entry":"node_modules/expo-router/entry.js"}
|
||||
{"_e":"metro:bundling:done","_t":1787490904477,"id":"1","total":743,"ms":5831.675237}
|
||||
{"_e":"root:init","_t":1787490907952,"format":"v0-jsonl","version":"57.0.12"}
|
||||
{"_e":"env:mode","_t":1787490908055,"nodeEnv":"production","babelEnv":"production","mode":"production"}
|
||||
{"_e":"env:load","_t":1787490908056,"mode":"production","files":[],"env":{}}
|
||||
{"_e":"devserver:start","_t":1787490908069,"mode":"production","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":true}
|
||||
{"_e":"metro:config","_t":1787490908244,"serverRoot":"../../..","projectRoot":"../../..","exporting":true,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
|
||||
{"_e":"metro:instantiate","_t":1787490908323,"atlas":false,"workers":10,"host":null,"port":null}
|
||||
{"_e":"metro:bundling:started","_t":1787490908955,"id":"1","platform":"web","environment":null,"entry":"node_modules/expo-router/entry.js"}
|
||||
{"_e":"metro:bundling:done","_t":1787490909421,"id":"1","total":731,"ms":465.745099}
|
||||
{"_e":"root:init","_t":1787490971753,"format":"v0-jsonl","version":"57.0.12"}
|
||||
{"_e":"env:mode","_t":1787490972145,"nodeEnv":"production","babelEnv":"production","mode":"production"}
|
||||
{"_e":"env:load","_t":1787490972146,"mode":"production","files":[],"env":{}}
|
||||
{"_e":"devserver:start","_t":1787490972158,"mode":"production","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":true}
|
||||
{"_e":"metro:config","_t":1787490972351,"serverRoot":"../../..","projectRoot":"../../..","exporting":true,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
|
||||
{"_e":"metro:instantiate","_t":1787490972454,"atlas":false,"workers":10,"host":null,"port":null}
|
||||
{"_e":"metro:bundling:started","_t":1787490973074,"id":"1","platform":"web","environment":null,"entry":"node_modules/expo-router/entry.js"}
|
||||
{"_e":"metro:bundling:done","_t":1787490973517,"id":"1","total":743,"ms":441.956684}
|
||||
@@ -29,15 +29,22 @@ jobs:
|
||||
|
||||
- name: Install Android SDK
|
||||
run: |
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdtools.zip
|
||||
unzip -q /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
if [ -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
|
||||
echo "Android SDK already present in cache"
|
||||
else
|
||||
rm -rf "$ANDROID_HOME/cmdline-tools"
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdtools.zip
|
||||
unzip -q /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
fi
|
||||
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
|
||||
"platform-tools" \
|
||||
"platforms;android-35" \
|
||||
"platforms;android-36" \
|
||||
"build-tools;35.0.0" \
|
||||
"build-tools;36.0.0" \
|
||||
"ndk;27.1.12297006"
|
||||
|
||||
- name: Install JS dependencies
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Build Desktop (deb)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install JS dependencies
|
||||
working-directory: carry-your-live
|
||||
run: npm ci
|
||||
|
||||
- name: Export web bundle
|
||||
working-directory: carry-your-live
|
||||
run: npx expo export --platform web
|
||||
|
||||
- name: Build deb package
|
||||
working-directory: carry-your-live
|
||||
run: npx electron-builder --linux deb --config electron-builder.config.js
|
||||
|
||||
- name: Upload deb
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: carry-your-live-desktop-deb
|
||||
path: carry-your-live/desktop-build/*.deb
|
||||
@@ -0,0 +1,56 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute next version
|
||||
id: version
|
||||
run: |
|
||||
latest=$(git tag --list 'v[0-9]*' --sort=-v:refname | head -n 1)
|
||||
if [ -z "$latest" ]; then
|
||||
next="v1.0.0"
|
||||
else
|
||||
latest=${latest#v}
|
||||
IFS='.' read -r major minor patch <<< "$latest"
|
||||
next="v${major}.${minor}.$((patch + 1))"
|
||||
fi
|
||||
echo "next=${next}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build changelog
|
||||
id: changelog
|
||||
run: |
|
||||
prev_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||
if [ -z "$prev_tag" ]; then
|
||||
log=$(git log --oneline --no-merges main | head -n 40)
|
||||
else
|
||||
log=$(git log --oneline --no-merges "$prev_tag"..main | head -n 40)
|
||||
fi
|
||||
{
|
||||
echo "body<<EOF"
|
||||
echo "## What's new in this release"
|
||||
echo ""
|
||||
echo "$log" | sed 's/^/- /'
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.next }}
|
||||
name: ${{ steps.version.outputs.next }}
|
||||
body: ${{ steps.changelog.outputs.body }}
|
||||
generate_release_notes: false
|
||||
+55
@@ -57,6 +57,7 @@ GET /sync
|
||||
{
|
||||
"id": "sub_1",
|
||||
"taskId": "task_1",
|
||||
"parentSubtaskId": null,
|
||||
"title": "Setup Expo project",
|
||||
"completed": true,
|
||||
"order": 0,
|
||||
@@ -64,10 +65,21 @@ GET /sync
|
||||
"updatedAt": 1699000000000
|
||||
}
|
||||
],
|
||||
"repeatProfiles": [],
|
||||
"friendships": [],
|
||||
"deleted": [
|
||||
{
|
||||
"entity": "tasks",
|
||||
"id": "task_5",
|
||||
"updatedAt": 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**
|
||||
| Status | Code | Message |
|
||||
|--------|------|---------|
|
||||
@@ -114,6 +126,7 @@ POST /sync/push
|
||||
{
|
||||
"id": "sub_new",
|
||||
"taskId": "task_new",
|
||||
"parentSubtaskId": null,
|
||||
"title": "Subtask 1",
|
||||
"completed": false,
|
||||
"order": 0,
|
||||
@@ -122,10 +135,19 @@ POST /sync/push
|
||||
}
|
||||
]
|
||||
},
|
||||
"deleted": [
|
||||
{
|
||||
"entity": "categories",
|
||||
"id": "cat_gone",
|
||||
"updatedAt": 1699150000000
|
||||
}
|
||||
],
|
||||
"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)
|
||||
```json
|
||||
{
|
||||
@@ -568,6 +590,16 @@ interface Task {
|
||||
completed: boolean;
|
||||
dueDate: number; // Unix timestamp (ms), 0 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;
|
||||
updatedAt: number;
|
||||
subtasks?: Subtask[];
|
||||
@@ -579,14 +611,37 @@ interface Task {
|
||||
interface Subtask {
|
||||
id: string;
|
||||
taskId: string;
|
||||
parentSubtaskId: string | null; // id of the parent subtask, null for top-level
|
||||
title: string;
|
||||
description: string;
|
||||
priority: 'none' | 'low' | 'medium' | 'high' | 'critical';
|
||||
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;
|
||||
createdAt: 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
|
||||
```typescript
|
||||
interface UserSettings {
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
# Carry Your Live — Roadmap & Feature List
|
||||
|
||||
Status as of Aug 2026. Based on code audit of frontend (`carry-your-live/`), backend (`backend/`), and `API_SPEC.md`.
|
||||
|
||||
## Legend
|
||||
- ✅ Implemented
|
||||
- 🟡 Partial / stubbed
|
||||
- ❌ Missing
|
||||
|
||||
---
|
||||
|
||||
## Current State (Feature List)
|
||||
|
||||
### Core Task Management
|
||||
- ✅ Tasks CRUD with title, description, category, priority, due date/time, all-day
|
||||
- ✅ Nested subtasks (sub-subtask trees), convert task↔subtask, re-parent, reorder
|
||||
- ✅ Categories/tags with colors (task color derives from category)
|
||||
- ✅ Priorities (None → Critical) with badges
|
||||
- ✅ Repeating/recurring tasks: repeat profiles, series IDs, this/future/all delete scopes, undo-complete
|
||||
- ✅ Overdue / due-today highlighting
|
||||
|
||||
### Screens & Navigation
|
||||
- ✅ Tabs: Todo, Calendar, Stats, Settings (+ Add Task, Day View, Task Detail, Subtask Detail stack screens)
|
||||
- ✅ Category filter chips, swipe actions, long-press multi-select, drag-to-reorder
|
||||
- ✅ Quick-add bar, calendar date strip, analog clock widget
|
||||
- ✅ Stats screen (client-side `useStats`)
|
||||
|
||||
### Sync & Accounts
|
||||
- ✅ Offline-first WatermelonDB (schema v21) + full sync engine (tombstones, LWW)
|
||||
- ✅ Backend: Express + Drizzle + Postgres — auth (register/login/me/forgot/reset), sync pull/push, tasks, subtasks, categories, repeat profiles, user settings, user search
|
||||
- ✅ Login/register/sign-out UI (`SyncModal`)
|
||||
|
||||
### Social
|
||||
- ✅ Friends: request/accept/decline/remove, user search, full backend routes
|
||||
- ✅ Assignee field on tasks/subtasks (schema v7)
|
||||
|
||||
### Settings (wired up)
|
||||
- ✅ Default tag, sort order, show-completed, calendar days-ahead, accent color picker, server URL override
|
||||
|
||||
---
|
||||
|
||||
## Gaps (what's broken or stubbed)
|
||||
|
||||
| Item | Status | Where |
|
||||
|---|---|---|
|
||||
| Light mode / theme toggle | ❌ Dark-only, hard-coded palette; backend has `darkMode` column unused | `src/theme.tsx:92` |
|
||||
| Notifications toggle | 🟡 Stored but never read; toggling off doesn't disable reminders | `settings.tsx:148`, `src/services/notifications.ts` |
|
||||
| Global reminder preference | 🟡 Stored but never applied when scheduling | `src/theme.tsx:180` |
|
||||
| Forgot/reset password UI | 🟡 Backend done, no client UI | `backend/src/routes/auth.ts:149` |
|
||||
| Pull-to-refresh | 🟡 Reloads local queries only, doesn't trigger server sync | `TaskList.tsx:129` |
|
||||
| Client settings sync | 🟡 All settings AsyncStorage-only; `PATCH /users/me/settings` never called | — |
|
||||
| Reminder enum mismatch | 🟡 Spec uses minutes (`'15'`,`'30'`…), client uses `'5m'`,`'1h'` style | `API_SPEC.md` vs client |
|
||||
| Webhooks / rate-limit headers | 🟡 In spec, not built | `API_SPEC.md` |
|
||||
|
||||
---
|
||||
|
||||
## Roadmap (by release)
|
||||
|
||||
> Last released: **v1.0.0** (2026-08-05). The `Unreleased` changelog section already contains the friends feature, assignee field, and various fixes.
|
||||
|
||||
---
|
||||
|
||||
### v1.1.0 — Friends & Assignees (staged; ship current `Unreleased` + stub fixes)
|
||||
|
||||
Ships what's already merged plus fixes to half-wired features so nothing in the release is a dead toggle.
|
||||
|
||||
- 📦 Release existing `Unreleased` changes: friends feature, assignee field (schema v7), QuickAddBar on overview, sync fixes for repeat/reminders
|
||||
- Fix notifications toggle so it's actually respected (`src/services/notifications.ts`); wire up `rescheduleAllReminders()`
|
||||
- Apply global reminder preference when scheduling task reminders
|
||||
- Make pull-to-refresh run a real server sync cycle (`TaskList.tsx:129`)
|
||||
- Add forgot/reset password UI (backend routes already exist: `backend/src/routes/auth.ts:149`)
|
||||
- Unify reminder offset enums between client, `API_SPEC.md`, and backend
|
||||
|
||||
**Theme:** "Everything you can toggle does something."
|
||||
|
||||
---
|
||||
|
||||
### v1.2.0 — Theming & Findability
|
||||
|
||||
- Light mode + follow-system-theme option; theme-mode setting synced via `/users/me/settings` (backend `darkMode` column already exists)
|
||||
- Sync all client settings to `PATCH /users/me/settings` instead of AsyncStorage-only
|
||||
- Task search: filter-by-text in list + global search screen
|
||||
- Friend-request badges/indicators in main UI (polling-based)
|
||||
- Resolve API error-format drift between spec and auth routes
|
||||
|
||||
**Theme:** "Look good, find anything."
|
||||
|
||||
---
|
||||
|
||||
### v1.3.0 — Stats & Insights
|
||||
|
||||
- Persisted stats snapshots for history/trends over time (current stats are per-local-data only)
|
||||
- Productivity trends: completion rates by category/priority, streaks
|
||||
- Export stats (CSV/image share)
|
||||
|
||||
**Theme:** "Show me how I'm doing."
|
||||
|
||||
---
|
||||
|
||||
### v1.4.0 — Quick Capture & Calendar UX
|
||||
|
||||
Client-side only; no backend changes required.
|
||||
|
||||
- Natural language quick-add ("pay rent tomorrow 5pm !finance" → parsed fields) in QuickAddBar
|
||||
- Snooze/postpone gesture on overdue tasks
|
||||
- Drag-and-drop rescheduling between days in calendar
|
||||
- Month grid view alongside the date strip
|
||||
- "Today at a glance" dashboard combining overdue, today, and stats
|
||||
- Time estimates + daily planned-vs-free-time indicator
|
||||
|
||||
**Theme:** "Capture in seconds."
|
||||
|
||||
---
|
||||
|
||||
### v1.5.0 — Power Tools & Personalization
|
||||
|
||||
- Task templates / saved routines (builds on repeat profiles)
|
||||
- Soft-archive with search (archive instead of delete)
|
||||
- Full local JSON export/import backup
|
||||
- Attachments/links/photos on tasks
|
||||
- Location-based reminders (expo-location)
|
||||
- Markdown support in descriptions
|
||||
- Deep links (`carryyourlive://task/<id>`) for shortcuts and notifications
|
||||
- Undo toast for all destructive actions (extend existing complete-undo)
|
||||
- Biometric lock / privacy blur on app switch
|
||||
- Haptics settings + reduced-motion mode
|
||||
- Custom app icon / theme presets beyond accent color
|
||||
- Onboarding flow + sample data seeding
|
||||
- Android home-screen widget / week agenda widget
|
||||
- import calendar from other apps
|
||||
|
||||
**Theme:** "Make it yours, make it fast."
|
||||
|
||||
---
|
||||
|
||||
### v2.0.0 — Collaboration
|
||||
|
||||
Breaking-ish social features; requires backend schema changes.
|
||||
|
||||
- Real task assignment delivery: shared-list or assignment mechanism (rows are currently strictly user-scoped — assigneeId goes nowhere)
|
||||
- Shared lists / collaborative categories with permissions
|
||||
- Server push notifications (FCM/APNs): cross-device reminders, friend requests, assignments
|
||||
- Comments/activity feed on assigned tasks
|
||||
- Shared category templates ("family shopping list")
|
||||
- Reactions/acknowledgments on assignments
|
||||
|
||||
**Theme:** "Carry it together."
|
||||
|
||||
---
|
||||
|
||||
### v2.1.0 — Platform & Ops
|
||||
|
||||
- Webhooks (`POST /webhooks/sync`) + documented rate limiting per `API_SPEC.md`
|
||||
- Account management: email field, account deletion
|
||||
- Release hardening: OTA update flow validation, EAS builds for iOS/Android
|
||||
|
||||
**Theme:** "Production-grade."
|
||||
Vendored
+513
-136
@@ -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<{
|
||||
name: "users";
|
||||
schema: undefined;
|
||||
@@ -50,6 +52,38 @@ export declare const users: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
||||
baseColumn: never;
|
||||
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<{
|
||||
name: "created_at";
|
||||
tableName: "users";
|
||||
@@ -334,7 +368,7 @@ export declare const tasks: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
||||
columnType: "PgText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: true;
|
||||
notNull: false;
|
||||
hasDefault: false;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
@@ -455,6 +489,22 @@ export declare const tasks: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
||||
baseColumn: never;
|
||||
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<{
|
||||
name: "repeat";
|
||||
tableName: "tasks";
|
||||
@@ -551,6 +601,38 @@ export declare const tasks: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
||||
baseColumn: never;
|
||||
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<{
|
||||
name: "created_at";
|
||||
tableName: "tasks";
|
||||
@@ -586,6 +668,436 @@ export declare const tasks: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
||||
};
|
||||
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<{
|
||||
name: "repeat_profiles";
|
||||
schema: undefined;
|
||||
@@ -721,141 +1233,6 @@ export declare const repeatProfiles: import("drizzle-orm/pg-core").PgTableWithCo
|
||||
};
|
||||
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<{
|
||||
name: "user_settings";
|
||||
schema: undefined;
|
||||
|
||||
Vendored
+1
-1
@@ -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"}
|
||||
Vendored
+46
-12
@@ -1,12 +1,15 @@
|
||||
"use strict";
|
||||
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 drizzle_orm_1 = require("drizzle-orm");
|
||||
exports.ENTITIES = ['categories', 'tasks', 'subtasks', 'repeatProfiles', 'friendships'];
|
||||
exports.users = (0, pg_core_1.pgTable)('users', {
|
||||
id: (0, pg_core_1.text)('id').primaryKey(),
|
||||
username: (0, pg_core_1.text)('username').notNull().unique(),
|
||||
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`),
|
||||
});
|
||||
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', {
|
||||
id: (0, pg_core_1.text)('id').primaryKey(),
|
||||
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(),
|
||||
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'),
|
||||
@@ -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),
|
||||
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(''),
|
||||
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'),
|
||||
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`),
|
||||
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', {
|
||||
id: (0, pg_core_1.text)('id').primaryKey(),
|
||||
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`),
|
||||
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', {
|
||||
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),
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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"}
|
||||
Vendored
+2
@@ -12,6 +12,7 @@ const tasks_1 = __importDefault(require("./routes/tasks"));
|
||||
const subtasks_1 = __importDefault(require("./routes/subtasks"));
|
||||
const users_1 = __importDefault(require("./routes/users"));
|
||||
const friends_1 = __importDefault(require("./routes/friends"));
|
||||
const repeatProfiles_1 = __importDefault(require("./routes/repeatProfiles"));
|
||||
const sync_1 = __importDefault(require("./routes/sync"));
|
||||
const errorHandler_1 = require("./middleware/errorHandler");
|
||||
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/subtasks', subtasks_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/friends', friends_1.default);
|
||||
// 404 handler
|
||||
|
||||
Vendored
+1
-1
@@ -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"}
|
||||
Vendored
+1
-1
@@ -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"}
|
||||
Vendored
+101
-9
@@ -1,4 +1,7 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const express_1 = require("express");
|
||||
const asyncHandler_1 = require("../utils/asyncHandler");
|
||||
@@ -8,7 +11,47 @@ const drizzle_orm_1 = require("drizzle-orm");
|
||||
const auth_1 = require("../utils/auth");
|
||||
const errorHandler_1 = require("../middleware/errorHandler");
|
||||
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)();
|
||||
// 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
|
||||
.string()
|
||||
.min(3)
|
||||
@@ -22,15 +65,24 @@ const loginSchema = zod_1.z.object({
|
||||
username: zod_1.z.string(),
|
||||
password: zod_1.z.string(),
|
||||
});
|
||||
// In production, use bcrypt or argon2 for password hashing
|
||||
function hashPassword(password) {
|
||||
// Simple hash for demo - replace with bcrypt in production
|
||||
return Buffer.from(password).toString('base64');
|
||||
const forgotPasswordSchema = zod_1.z.object({
|
||||
username: zod_1.z.string().min(1),
|
||||
});
|
||||
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) {
|
||||
return hashPassword(password) === hash;
|
||||
async function verifyPassword(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 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) {
|
||||
@@ -41,7 +93,7 @@ router.post('/register', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||
await db_1.db.insert(schema_1.users).values({
|
||||
id: userId,
|
||||
username: data.username,
|
||||
passwordHash: hashPassword(data.password),
|
||||
passwordHash: await hashPassword(data.password),
|
||||
createdAt: now,
|
||||
});
|
||||
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,
|
||||
});
|
||||
}));
|
||||
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 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)) {
|
||||
@@ -72,5 +124,45 @@ router.get('/me', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||
}
|
||||
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;
|
||||
//# sourceMappingURL=auth.js.map
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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"}
|
||||
Vendored
+11
@@ -53,6 +53,17 @@ router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||
}));
|
||||
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) => {
|
||||
const { username } = validation_1.friendRequestSchema.parse(req.body);
|
||||
const userId = req.user.userId;
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+3
@@ -0,0 +1,3 @@
|
||||
declare const router: import("express-serve-static-core").Router;
|
||||
export default router;
|
||||
//# sourceMappingURL=repeatProfiles.d.ts.map
|
||||
+1
@@ -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
@@ -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
@@ -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
@@ -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"}
|
||||
Vendored
+28
-2
@@ -10,6 +10,16 @@ const errorHandler_1 = require("../middleware/errorHandler");
|
||||
const validation_1 = require("../utils/validation");
|
||||
const router = (0, express_1.Router)();
|
||||
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) => {
|
||||
const userId = req.user.userId;
|
||||
// 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)
|
||||
.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));
|
||||
res.json({ subtasks: taskSubtasks });
|
||||
const nestedSubtasks = buildSubtaskTree(taskSubtasks);
|
||||
res.json({ subtasks: nestedSubtasks });
|
||||
}));
|
||||
router.post('/task/:taskId', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||
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);
|
||||
}
|
||||
const now = Date.now();
|
||||
const parentSubtaskId = data.parentSubtaskId || null;
|
||||
const maxOrder = await db_1.db
|
||||
.select({ order: schema_1.subtasks.order })
|
||||
.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))
|
||||
.limit(1);
|
||||
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,
|
||||
userId,
|
||||
taskId: req.params.taskId,
|
||||
parentSubtaskId,
|
||||
title: data.title,
|
||||
description: data.description ?? '',
|
||||
priority: data.priority ?? 'none',
|
||||
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,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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"}
|
||||
Vendored
+281
-5
@@ -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)))
|
||||
.orderBy((0, drizzle_orm_1.asc)(schema_1.tasks.updatedAt));
|
||||
// Fetch subtasks changed since timestamp
|
||||
const taskIds = changedTasks.map(t => t.id);
|
||||
let changedSubtasks = [];
|
||||
if (taskIds.length > 0) {
|
||||
if (changedTasks.length > 0) {
|
||||
changedSubtasks = await db_1.db
|
||||
.select()
|
||||
.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));
|
||||
}
|
||||
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
|
||||
.select()
|
||||
.from(schema_1.subtasks)
|
||||
@@ -56,6 +55,12 @@ router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||
.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)))
|
||||
.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();
|
||||
res.json({
|
||||
categories: changedCategories,
|
||||
@@ -63,6 +68,7 @@ router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||
subtasks: changedSubtasks,
|
||||
repeatProfiles: changedRepeatProfiles,
|
||||
friendships: changedFriendships,
|
||||
deleted: changedTombstones.map((t) => ({ entity: t.entity, id: t.entityId, updatedAt: t.updatedAt })),
|
||||
timestamp,
|
||||
});
|
||||
}));
|
||||
@@ -73,6 +79,65 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||
const timestamp = Date.now();
|
||||
try {
|
||||
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
|
||||
if (data.changes.categories && data.changes.categories.length > 0) {
|
||||
for (const cat of data.changes.categories) {
|
||||
@@ -131,6 +196,20 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||
});
|
||||
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
|
||||
.update(schema_1.tasks)
|
||||
.set({
|
||||
@@ -140,14 +219,17 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||
priority: task.priority,
|
||||
completed: task.completed,
|
||||
dueDate: task.dueDate,
|
||||
dueTime: task.dueTime,
|
||||
dueTime: task.dueTime ?? '',
|
||||
endTime: task.endTime ?? '',
|
||||
allDay: task.allDay ?? false,
|
||||
repeat: task.repeat ?? 'none',
|
||||
repeatInterval: task.repeatInterval ?? 1,
|
||||
repeatDays: task.repeatDays ?? '',
|
||||
seriesId: task.seriesId ?? '',
|
||||
reminder: task.reminder ?? 'none',
|
||||
assigneeId: task.assigneeId ?? null,
|
||||
reminders: task.reminders ?? '',
|
||||
assigneeId: sanitizeAssignee(task.assigneeId),
|
||||
completedAt: task.completedAt ?? null,
|
||||
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)));
|
||||
@@ -155,6 +237,8 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||
else {
|
||||
await tx.insert(schema_1.tasks).values({
|
||||
...task,
|
||||
assigneeId: sanitizeAssignee(task.assigneeId),
|
||||
allDay: task.allDay ?? false,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
@@ -184,8 +268,22 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||
.update(schema_1.subtasks)
|
||||
.set({
|
||||
taskId: sub.taskId,
|
||||
parentSubtaskId: sub.parentSubtaskId ?? null,
|
||||
title: sub.title,
|
||||
description: sub.description ?? '',
|
||||
priority: sub.priority ?? 'none',
|
||||
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,
|
||||
updatedAt: sub.updatedAt,
|
||||
})
|
||||
@@ -194,6 +292,7 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||
else {
|
||||
await tx.insert(schema_1.subtasks).values({
|
||||
...sub,
|
||||
assigneeId: sanitizeAssignee(sub.assigneeId),
|
||||
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) {
|
||||
@@ -285,5 +391,175 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||
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;
|
||||
//# sourceMappingURL=sync.js.map
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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"}
|
||||
Vendored
+48
-15
@@ -97,14 +97,16 @@ router.post('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||
const data = validation_1.taskCreateSchema.parse(req.body);
|
||||
const userId = req.user.userId;
|
||||
const now = Date.now();
|
||||
// Verify category exists and belongs to user
|
||||
const cat = await db_1.db
|
||||
.select()
|
||||
.from(schema_1.categories)
|
||||
.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)))
|
||||
.limit(1);
|
||||
if (cat.length === 0) {
|
||||
throw new errorHandler_1.AppError('NOT_FOUND', 'Category not found', 404);
|
||||
// Verify category exists and belongs to user (optional - tasks may be uncategorized)
|
||||
if (data.categoryId) {
|
||||
const cat = await db_1.db
|
||||
.select()
|
||||
.from(schema_1.categories)
|
||||
.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)))
|
||||
.limit(1);
|
||||
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 newTask = {
|
||||
@@ -118,7 +120,10 @@ router.post('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||
dueDate: data.dueDate ?? 0,
|
||||
dueTime: data.dueTime ?? '',
|
||||
endTime: data.endTime ?? '',
|
||||
allDay: data.allDay ?? false,
|
||||
assigneeId: data.assigneeId ?? null,
|
||||
reminder: data.reminder ?? 'none',
|
||||
reminders: data.reminders ?? '',
|
||||
createdAt: 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);
|
||||
}
|
||||
}
|
||||
// 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 updated = await db_1.db
|
||||
.update(schema_1.tasks)
|
||||
@@ -207,13 +219,15 @@ router.post('/batch', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
|
||||
for (const op of operations) {
|
||||
try {
|
||||
if (op.type === 'create') {
|
||||
const cat = await db_1.db
|
||||
.select()
|
||||
.from(schema_1.categories)
|
||||
.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)))
|
||||
.limit(1);
|
||||
if (cat.length === 0)
|
||||
throw new errorHandler_1.AppError('NOT_FOUND', 'Category not found', 404);
|
||||
if (op.data.categoryId) {
|
||||
const cat = await db_1.db
|
||||
.select()
|
||||
.from(schema_1.categories)
|
||||
.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)))
|
||||
.limit(1);
|
||||
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 now = Date.now();
|
||||
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 });
|
||||
}
|
||||
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
|
||||
.update(schema_1.tasks)
|
||||
.set({ ...op.data, updatedAt: Date.now() })
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from 'express';
|
||||
import { AuthPayload } from '../types';
|
||||
export declare function generateToken(payload: AuthPayload): string;
|
||||
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 generateId(prefix?: string): string;
|
||||
export declare function getCurrentTimestamp(): number;
|
||||
|
||||
Vendored
+1
-1
@@ -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"}
|
||||
Vendored
+14
-1
@@ -10,6 +10,9 @@ exports.optionalAuthMiddleware = optionalAuthMiddleware;
|
||||
exports.generateId = generateId;
|
||||
exports.getCurrentTimestamp = getCurrentTimestamp;
|
||||
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_EXPIRES_IN = '7d';
|
||||
function generateToken(payload) {
|
||||
@@ -23,7 +26,7 @@ function verifyToken(token) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function authMiddleware(req, res, next) {
|
||||
async function authMiddleware(req, res, next) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
res.status(401).json({
|
||||
@@ -45,6 +48,16 @@ function authMiddleware(req, res, next) {
|
||||
});
|
||||
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;
|
||||
next();
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -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"}
|
||||
Vendored
+370
-90
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
export declare function canCompleteTask(dueDate: number): boolean;
|
||||
export declare const categoryCreateSchema: z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
color: z.ZodString;
|
||||
@@ -26,20 +27,25 @@ export declare const categoryUpdateSchema: z.ZodObject<{
|
||||
order?: number | undefined;
|
||||
}>;
|
||||
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<{
|
||||
title: 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"]>>;
|
||||
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<"">]>;
|
||||
repeat: z.ZodOptional<z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>>;
|
||||
repeatInterval: z.ZodOptional<z.ZodNumber>;
|
||||
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>>;
|
||||
completedAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
||||
subtasks: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
title: z.ZodString;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
@@ -48,93 +54,189 @@ export declare const taskCreateSchema: z.ZodObject<{
|
||||
title: string;
|
||||
}>, "many">>;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
categoryId: string;
|
||||
categoryId: string | null;
|
||||
title: string;
|
||||
dueTime: string;
|
||||
subtasks?: {
|
||||
title: string;
|
||||
}[] | undefined;
|
||||
description?: string | undefined;
|
||||
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||
dueDate?: number | undefined;
|
||||
dueTime?: string | null | 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;
|
||||
subtasks?: {
|
||||
title: string;
|
||||
}[] | undefined;
|
||||
reminders?: string | undefined;
|
||||
completedAt?: number | null | undefined;
|
||||
}, {
|
||||
categoryId: string;
|
||||
categoryId: string | null;
|
||||
title: string;
|
||||
subtasks?: {
|
||||
title: string;
|
||||
}[] | undefined;
|
||||
description?: string | undefined;
|
||||
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||
dueDate?: number | undefined;
|
||||
dueTime?: string | null | undefined;
|
||||
dueTime?: 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;
|
||||
repeatDays?: string | undefined;
|
||||
seriesId?: string | undefined;
|
||||
assigneeId?: string | null | undefined;
|
||||
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
||||
subtasks?: {
|
||||
title: string;
|
||||
}[] | undefined;
|
||||
reminders?: string | undefined;
|
||||
completedAt?: number | null | undefined;
|
||||
}>;
|
||||
export declare const taskUpdateSchema: z.ZodObject<{
|
||||
title: 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"]>>;
|
||||
completed: z.ZodOptional<z.ZodBoolean>;
|
||||
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>;
|
||||
reminders: z.ZodOptional<z.ZodString>;
|
||||
assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
completedAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
categoryId?: string | undefined;
|
||||
categoryId?: string | null | undefined;
|
||||
title?: string | undefined;
|
||||
description?: string | undefined;
|
||||
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||
completed?: boolean | undefined;
|
||||
dueDate?: number | undefined;
|
||||
dueTime?: string | null | undefined;
|
||||
dueTime?: string | undefined;
|
||||
endTime?: string | undefined;
|
||||
assigneeId?: string | null | undefined;
|
||||
reminders?: string | undefined;
|
||||
completedAt?: number | null | undefined;
|
||||
}, {
|
||||
categoryId?: string | undefined;
|
||||
categoryId?: string | null | undefined;
|
||||
title?: string | undefined;
|
||||
description?: string | undefined;
|
||||
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||
completed?: boolean | undefined;
|
||||
dueDate?: number | undefined;
|
||||
dueTime?: string | null | undefined;
|
||||
dueTime?: string | undefined;
|
||||
endTime?: string | undefined;
|
||||
assigneeId?: string | null | undefined;
|
||||
reminders?: string | undefined;
|
||||
completedAt?: number | null | undefined;
|
||||
}>;
|
||||
export declare const subtaskCreateSchema: z.ZodObject<{
|
||||
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>;
|
||||
parentSubtaskId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
title: string;
|
||||
dueTime: string;
|
||||
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;
|
||||
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<{
|
||||
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>;
|
||||
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>;
|
||||
parentSubtaskId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
order?: number | undefined;
|
||||
title?: string | undefined;
|
||||
description?: string | undefined;
|
||||
priority?: "none" | "low" | "medium" | "high" | "critical" | 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;
|
||||
title?: string | undefined;
|
||||
description?: string | undefined;
|
||||
priority?: "none" | "low" | "medium" | "high" | "critical" | 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<{
|
||||
darkMode: z.ZodOptional<z.ZodBoolean>;
|
||||
@@ -174,6 +276,29 @@ export declare const repeatProfileSchema: z.ZodObject<{
|
||||
repeatInterval: number;
|
||||
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<{
|
||||
username: z.ZodString;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
@@ -203,6 +328,19 @@ export declare const friendshipSchema: z.ZodObject<{
|
||||
status: "pending" | "accepted";
|
||||
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<{
|
||||
changes: 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<{
|
||||
title: 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"]>>;
|
||||
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<"">]>;
|
||||
repeat: z.ZodOptional<z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>>;
|
||||
repeatInterval: z.ZodOptional<z.ZodNumber>;
|
||||
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>>;
|
||||
subtasks: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
title: z.ZodString;
|
||||
@@ -254,52 +394,73 @@ export declare const pushChangesSchema: z.ZodObject<{
|
||||
completed: z.ZodBoolean;
|
||||
createdAt: z.ZodNumber;
|
||||
updatedAt: z.ZodNumber;
|
||||
completedAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
categoryId: string;
|
||||
categoryId: string | null;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
dueTime: string;
|
||||
subtasks?: {
|
||||
title: string;
|
||||
}[] | undefined;
|
||||
description?: string | undefined;
|
||||
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||
dueDate?: number | undefined;
|
||||
dueTime?: string | null | 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;
|
||||
subtasks?: {
|
||||
title: string;
|
||||
}[] | undefined;
|
||||
reminders?: string | undefined;
|
||||
completedAt?: number | null | undefined;
|
||||
}, {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
categoryId: string;
|
||||
categoryId: string | null;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
subtasks?: {
|
||||
title: string;
|
||||
}[] | undefined;
|
||||
description?: string | undefined;
|
||||
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||
dueDate?: number | undefined;
|
||||
dueTime?: string | null | undefined;
|
||||
dueTime?: 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;
|
||||
repeatDays?: string | undefined;
|
||||
seriesId?: string | undefined;
|
||||
assigneeId?: string | null | undefined;
|
||||
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
||||
subtasks?: {
|
||||
title: string;
|
||||
}[] | undefined;
|
||||
reminders?: string | undefined;
|
||||
completedAt?: number | null | undefined;
|
||||
}>, "many">>;
|
||||
subtasks: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
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>;
|
||||
parentSubtaskId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
} & {
|
||||
id: z.ZodString;
|
||||
taskId: z.ZodString;
|
||||
@@ -312,8 +473,22 @@ export declare const pushChangesSchema: z.ZodObject<{
|
||||
updatedAt: number;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
dueTime: string;
|
||||
taskId: string;
|
||||
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;
|
||||
createdAt: number;
|
||||
@@ -322,6 +497,20 @@ export declare const pushChangesSchema: z.ZodObject<{
|
||||
completed: boolean;
|
||||
taskId: string;
|
||||
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">>;
|
||||
repeatProfiles: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
@@ -372,14 +561,6 @@ export declare const pushChangesSchema: z.ZodObject<{
|
||||
updatedAt: number;
|
||||
}>, "many">>;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
friendships?: {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
userId: string;
|
||||
friendId: string;
|
||||
status: "pending" | "accepted";
|
||||
updatedAt: number;
|
||||
}[] | undefined;
|
||||
categories?: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -392,23 +573,26 @@ export declare const pushChangesSchema: z.ZodObject<{
|
||||
id: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
categoryId: string;
|
||||
categoryId: string | null;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
dueTime: string;
|
||||
subtasks?: {
|
||||
title: string;
|
||||
}[] | undefined;
|
||||
description?: string | undefined;
|
||||
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||
dueDate?: number | undefined;
|
||||
dueTime?: string | null | 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;
|
||||
subtasks?: {
|
||||
title: string;
|
||||
}[] | undefined;
|
||||
reminders?: string | undefined;
|
||||
completedAt?: number | null | undefined;
|
||||
}[] | undefined;
|
||||
subtasks?: {
|
||||
id: string;
|
||||
@@ -416,8 +600,22 @@ export declare const pushChangesSchema: z.ZodObject<{
|
||||
updatedAt: number;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
dueTime: string;
|
||||
taskId: string;
|
||||
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;
|
||||
repeatProfiles?: {
|
||||
id: string;
|
||||
@@ -428,15 +626,15 @@ export declare const pushChangesSchema: z.ZodObject<{
|
||||
repeatInterval: number;
|
||||
repeatDays: string;
|
||||
}[] | 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?: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -449,23 +647,26 @@ export declare const pushChangesSchema: z.ZodObject<{
|
||||
id: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
categoryId: string;
|
||||
categoryId: string | null;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
subtasks?: {
|
||||
title: string;
|
||||
}[] | undefined;
|
||||
description?: string | undefined;
|
||||
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||
dueDate?: number | undefined;
|
||||
dueTime?: string | null | undefined;
|
||||
dueTime?: 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;
|
||||
repeatDays?: string | undefined;
|
||||
seriesId?: string | undefined;
|
||||
assigneeId?: string | null | undefined;
|
||||
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
||||
subtasks?: {
|
||||
title: string;
|
||||
}[] | undefined;
|
||||
reminders?: string | undefined;
|
||||
completedAt?: number | null | undefined;
|
||||
}[] | undefined;
|
||||
subtasks?: {
|
||||
id: string;
|
||||
@@ -475,6 +676,20 @@ export declare const pushChangesSchema: z.ZodObject<{
|
||||
completed: boolean;
|
||||
taskId: string;
|
||||
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;
|
||||
repeatProfiles?: {
|
||||
id: string;
|
||||
@@ -485,18 +700,31 @@ export declare const pushChangesSchema: z.ZodObject<{
|
||||
repeatInterval: number;
|
||||
repeatDays: string;
|
||||
}[] | 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;
|
||||
}, "strip", z.ZodTypeAny, {
|
||||
changes: {
|
||||
friendships?: {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
userId: string;
|
||||
friendId: string;
|
||||
status: "pending" | "accepted";
|
||||
updatedAt: number;
|
||||
}[] | undefined;
|
||||
categories?: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -509,23 +737,26 @@ export declare const pushChangesSchema: z.ZodObject<{
|
||||
id: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
categoryId: string;
|
||||
categoryId: string | null;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
dueTime: string;
|
||||
subtasks?: {
|
||||
title: string;
|
||||
}[] | undefined;
|
||||
description?: string | undefined;
|
||||
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||
dueDate?: number | undefined;
|
||||
dueTime?: string | null | 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;
|
||||
subtasks?: {
|
||||
title: string;
|
||||
}[] | undefined;
|
||||
reminders?: string | undefined;
|
||||
completedAt?: number | null | undefined;
|
||||
}[] | undefined;
|
||||
subtasks?: {
|
||||
id: string;
|
||||
@@ -533,8 +764,22 @@ export declare const pushChangesSchema: z.ZodObject<{
|
||||
updatedAt: number;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
dueTime: string;
|
||||
taskId: string;
|
||||
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;
|
||||
repeatProfiles?: {
|
||||
id: string;
|
||||
@@ -545,18 +790,23 @@ export declare const pushChangesSchema: z.ZodObject<{
|
||||
repeatInterval: number;
|
||||
repeatDays: string;
|
||||
}[] | undefined;
|
||||
friendships?: {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
userId: string;
|
||||
friendId: string;
|
||||
status: "pending" | "accepted";
|
||||
updatedAt: number;
|
||||
}[] | undefined;
|
||||
};
|
||||
lastPulledAt: number;
|
||||
deleted?: {
|
||||
id: string;
|
||||
updatedAt: number;
|
||||
entity: "categories" | "tasks" | "subtasks" | "repeatProfiles" | "friendships";
|
||||
}[] | undefined;
|
||||
}, {
|
||||
changes: {
|
||||
friendships?: {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
userId: string;
|
||||
friendId: string;
|
||||
status: "pending" | "accepted";
|
||||
updatedAt: number;
|
||||
}[] | undefined;
|
||||
categories?: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -569,23 +819,26 @@ export declare const pushChangesSchema: z.ZodObject<{
|
||||
id: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
categoryId: string;
|
||||
categoryId: string | null;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
subtasks?: {
|
||||
title: string;
|
||||
}[] | undefined;
|
||||
description?: string | undefined;
|
||||
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
|
||||
dueDate?: number | undefined;
|
||||
dueTime?: string | null | undefined;
|
||||
dueTime?: 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;
|
||||
repeatDays?: string | undefined;
|
||||
seriesId?: string | undefined;
|
||||
assigneeId?: string | null | undefined;
|
||||
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
|
||||
subtasks?: {
|
||||
title: string;
|
||||
}[] | undefined;
|
||||
reminders?: string | undefined;
|
||||
completedAt?: number | null | undefined;
|
||||
}[] | undefined;
|
||||
subtasks?: {
|
||||
id: string;
|
||||
@@ -595,6 +848,20 @@ export declare const pushChangesSchema: z.ZodObject<{
|
||||
completed: boolean;
|
||||
taskId: string;
|
||||
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;
|
||||
repeatProfiles?: {
|
||||
id: string;
|
||||
@@ -605,8 +872,21 @@ export declare const pushChangesSchema: z.ZodObject<{
|
||||
repeatInterval: number;
|
||||
repeatDays: string;
|
||||
}[] | undefined;
|
||||
friendships?: {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
userId: string;
|
||||
friendId: string;
|
||||
status: "pending" | "accepted";
|
||||
updatedAt: number;
|
||||
}[] | undefined;
|
||||
};
|
||||
lastPulledAt: number;
|
||||
deleted?: {
|
||||
id: string;
|
||||
updatedAt: number;
|
||||
entity: "categories" | "tasks" | "subtasks" | "repeatProfiles" | "friendships";
|
||||
}[] | undefined;
|
||||
}>;
|
||||
export declare const syncQuerySchema: z.ZodObject<{
|
||||
since: z.ZodPipeline<z.ZodEffects<z.ZodString, number, string>, z.ZodNumber>;
|
||||
|
||||
+1
-1
@@ -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"}
|
||||
Vendored
+77
-8
@@ -1,7 +1,15 @@
|
||||
"use strict";
|
||||
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");
|
||||
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({
|
||||
name: zod_1.z.string().min(1).max(50),
|
||||
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(),
|
||||
});
|
||||
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({
|
||||
title: zod_1.z.string().min(1).max(100),
|
||||
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(),
|
||||
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('')),
|
||||
repeat: exports.repeatSchema.optional(),
|
||||
repeatInterval: zod_1.z.number().int().min(1).max(30).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(),
|
||||
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(),
|
||||
});
|
||||
exports.taskUpdateSchema = zod_1.z.object({
|
||||
title: zod_1.z.string().min(1).max(100).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(),
|
||||
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().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(),
|
||||
reminders: zod_1.z.string().max(100).optional(),
|
||||
assigneeId: zod_1.z.string().nullable().optional(),
|
||||
completedAt: zod_1.z.number().int().min(0).nullable().optional(),
|
||||
});
|
||||
exports.subtaskCreateSchema = zod_1.z.object({
|
||||
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(),
|
||||
parentSubtaskId: zod_1.z.string().nullable().optional(),
|
||||
});
|
||||
exports.subtaskUpdateSchema = zod_1.z.object({
|
||||
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(),
|
||||
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(),
|
||||
parentSubtaskId: zod_1.z.string().nullable().optional(),
|
||||
});
|
||||
exports.userSettingsSchema = zod_1.z.object({
|
||||
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),
|
||||
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({
|
||||
username: zod_1.z.string().min(1).max(50),
|
||||
});
|
||||
@@ -74,14 +137,20 @@ exports.friendshipSchema = zod_1.z.object({
|
||||
createdAt: 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({
|
||||
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(),
|
||||
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(),
|
||||
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(),
|
||||
}),
|
||||
deleted: zod_1.z.array(exports.tombstoneSchema).optional(),
|
||||
lastPulledAt: zod_1.z.number().int().min(0),
|
||||
});
|
||||
exports.syncQuerySchema = zod_1.z.object({
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Generated
+176
-2
@@ -8,6 +8,7 @@
|
||||
"name": "carry-your-live-backend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
@@ -15,6 +16,7 @@
|
||||
"express": "^4.21.0",
|
||||
"express-rate-limit": "^8.6.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"node-fetch": "^3.3.2",
|
||||
"pg": "^8.13.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@@ -1015,12 +1017,21 @@
|
||||
"version": "22.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
|
||||
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-fetch": {
|
||||
"version": "2.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
|
||||
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"form-data": "^4.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.20.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.4.tgz",
|
||||
@@ -1099,6 +1110,12 @@
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
@@ -1198,6 +1215,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
@@ -1251,6 +1280,15 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -1268,6 +1306,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -1504,6 +1551,21 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.19.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz",
|
||||
@@ -1651,6 +1713,29 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fetch-blob": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-domexception": "^1.0.0",
|
||||
"web-streams-polyfill": "^3.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20 || >= 14.13"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
@@ -1684,6 +1769,34 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/formdata-polyfill": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fetch-blob": "^3.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -1800,6 +1913,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
@@ -2037,6 +2165,44 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||
"deprecated": "Use your platform's native DOMException instead",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"data-uri-to-buffer": "^4.0.0",
|
||||
"fetch-blob": "^3.1.4",
|
||||
"formdata-polyfill": "^4.0.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -2977,7 +3143,6 @@
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
@@ -3007,6 +3172,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node-fetch": "^2.6.13",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
@@ -20,6 +21,7 @@
|
||||
"express": "^4.21.0",
|
||||
"express-rate-limit": "^8.6.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"node-fetch": "^3.3.2",
|
||||
"pg": "^8.13.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
|
||||
+42
-14
@@ -1,12 +1,17 @@
|
||||
import { pgTable, text, integer, bigint, boolean, timestamp, unique, index } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, text, integer, bigint, boolean, unique, index, primaryKey, AnyPgColumn } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export const ENTITIES = ['categories', 'tasks', 'subtasks', 'repeatProfiles', 'friendships'] as const;
|
||||
export type EntityName = (typeof ENTITIES)[number];
|
||||
|
||||
export const users = pgTable('users', {
|
||||
id: text('id').primaryKey(),
|
||||
username: text('username').notNull().unique(),
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
resetToken: text('reset_token'),
|
||||
resetTokenExpiry: bigint('reset_token_expiry', { mode: 'number' }),
|
||||
expoPushToken: text('expo_push_token'),
|
||||
pushTokenUpdatedAt: bigint('push_token_updated_at', { mode: 'number' }),
|
||||
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||
});
|
||||
|
||||
@@ -40,7 +45,8 @@ export const categories = pgTable('categories', {
|
||||
export const tasks = pgTable('tasks', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
categoryId: text('category_id').notNull().references(() => categories.id, { onDelete: 'cascade' }),
|
||||
categoryId: text('category_id').references(() => categories.id, { onDelete: 'cascade' }),
|
||||
tags: text('tags').notNull().default(''),
|
||||
title: text('title').notNull(),
|
||||
description: text('description').notNull().default(''),
|
||||
priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
|
||||
@@ -56,26 +62,33 @@ export const tasks = pgTable('tasks', {
|
||||
assigneeId: text('assignee_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
reminder: text('reminder', { enum: ['none', 'at_time', '15', '30', '60', '120', '1440'] }).notNull().default('none'),
|
||||
reminders: text('reminders').notNull().default(''),
|
||||
completedAt: bigint('completed_at', { mode: 'number' }),
|
||||
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||
});
|
||||
|
||||
export const repeatProfiles = pgTable('repeat_profiles', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
repeat: text('repeat', { enum: ['none', 'daily', 'weekly', 'monthly', 'custom'] }).notNull().default('none'),
|
||||
repeatInterval: integer('repeat_interval').notNull().default(1),
|
||||
repeatDays: text('repeat_days').notNull().default(''),
|
||||
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||
});
|
||||
export const tombstones = pgTable(
|
||||
'tombstones',
|
||||
{
|
||||
entity: text('entity', { enum: ENTITIES }).notNull(),
|
||||
entityId: text('entity_id').notNull(),
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
updatedAt: bigint('updated_at', { mode: 'number' }).notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
pk: primaryKey({ columns: [t.entity, t.entityId] }),
|
||||
userIdx: index('tombstones_user_updated_idx').on(t.userId, t.updatedAt),
|
||||
})
|
||||
);
|
||||
|
||||
// Define subtasks with explicit type to avoid circular reference
|
||||
export const subtasks = pgTable('subtasks', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
taskId: text('task_id').notNull().references(() => tasks.id, { onDelete: 'cascade' }),
|
||||
parentSubtaskId: text('parent_subtask_id').references(() => subtasks.id, { onDelete: 'cascade' }),
|
||||
parentSubtaskId: text('parent_subtask_id').references((): AnyPgColumn => subtasks.id, { onDelete: 'cascade' }),
|
||||
categoryId: text('category_id').references(() => categories.id, { onDelete: 'set null' }),
|
||||
tags: text('tags').notNull().default(''),
|
||||
title: text('title').notNull(),
|
||||
description: text('description').notNull().default(''),
|
||||
priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
|
||||
@@ -94,6 +107,21 @@ export const subtasks = pgTable('subtasks', {
|
||||
order: integer('order').notNull().default(0),
|
||||
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||
}, (t) => ({
|
||||
parentIdx: index('subtasks_parent_idx').on(t.parentSubtaskId),
|
||||
taskIdx: index('subtasks_task_idx').on(t.taskId),
|
||||
userIdx: index('subtasks_user_idx').on(t.userId),
|
||||
}));
|
||||
|
||||
export const repeatProfiles = pgTable('repeat_profiles', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
repeat: text('repeat', { enum: ['none', 'daily', 'weekly', 'monthly', 'custom'] }).notNull().default('none'),
|
||||
repeatInterval: integer('repeat_interval').notNull().default(1),
|
||||
repeatDays: text('repeat_days').notNull().default(''),
|
||||
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||
});
|
||||
|
||||
export const userSettings = pgTable('user_settings', {
|
||||
@@ -105,4 +133,4 @@ export const userSettings = pgTable('user_settings', {
|
||||
sortBy: text('sort_by', { enum: ['dueDate', 'priority', 'title', 'createdAt'] }).notNull().default('dueDate'),
|
||||
sortOrder: text('sort_order', { enum: ['asc', 'desc'] }).notNull().default('asc'),
|
||||
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import userRoutes from './routes/users';
|
||||
import friendRoutes from './routes/friends';
|
||||
import repeatProfileRoutes from './routes/repeatProfiles';
|
||||
import syncRoutes from './routes/sync';
|
||||
import pushNotificationRoutes from './routes/pushNotifications';
|
||||
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
|
||||
|
||||
const app = express();
|
||||
@@ -34,6 +35,7 @@ app.use('/api/users', userRoutes);
|
||||
app.use('/api/repeat-profiles', repeatProfileRoutes);
|
||||
app.use('/api/sync', syncRoutes);
|
||||
app.use('/api/friends', friendRoutes);
|
||||
app.use('/api/push', pushNotificationRoutes);
|
||||
|
||||
// 404 handler
|
||||
app.use(notFoundHandler);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { asyncHandler } from '../utils/asyncHandler';
|
||||
import { db } from '../db';
|
||||
import { users } from '../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
import { z } from 'zod';
|
||||
import { sendPushNotification, isExpoPushToken, createTaskReminderPayload } from '../services/pushNotifications';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const registerPushTokenSchema = z.object({
|
||||
pushToken: z.string().min(1),
|
||||
});
|
||||
|
||||
router.post('/push-token', asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
throw new AppError('UNAUTHORIZED', 'Authentication required', 401);
|
||||
}
|
||||
|
||||
const data = registerPushTokenSchema.parse(req.body);
|
||||
|
||||
if (!isExpoPushToken(data.pushToken)) {
|
||||
throw new AppError('INVALID_PUSH_TOKEN', 'Invalid Expo push token format', 400);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
expoPushToken: data.pushToken,
|
||||
pushTokenUpdatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(users.id, req.user.userId));
|
||||
|
||||
res.json({ success: true });
|
||||
}));
|
||||
|
||||
router.delete('/push-token', asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
throw new AppError('UNAUTHORIZED', 'Authentication required', 401);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
expoPushToken: null,
|
||||
pushTokenUpdatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(users.id, req.user.userId));
|
||||
|
||||
res.json({ success: true });
|
||||
}));
|
||||
|
||||
router.post('/test', asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
throw new AppError('UNAUTHORIZED', 'Authentication required', 401);
|
||||
}
|
||||
|
||||
const user = await db.select().from(users).where(eq(users.id, req.user.userId)).limit(1);
|
||||
if (user.length === 0 || !user[0].expoPushToken) {
|
||||
throw new AppError('NO_PUSH_TOKEN', 'No push token registered', 400);
|
||||
}
|
||||
|
||||
const ticket = await sendPushNotification({
|
||||
to: user[0].expoPushToken!,
|
||||
title: 'Test Notification',
|
||||
body: 'Push notifications are working!',
|
||||
data: { type: 'test' },
|
||||
sound: 'default',
|
||||
});
|
||||
|
||||
res.json({ success: true, ticket });
|
||||
}));
|
||||
|
||||
router.post('/send-reminder', asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
throw new AppError('UNAUTHORIZED', 'Authentication required', 401);
|
||||
}
|
||||
|
||||
const { taskId, taskTitle, dueTime, repeat } = req.body;
|
||||
if (!taskId || !taskTitle || !dueTime) {
|
||||
throw new AppError('INVALID_PAYLOAD', 'Missing required fields', 400);
|
||||
}
|
||||
|
||||
const user = await db.select().from(users).where(eq(users.id, req.user.userId)).limit(1);
|
||||
if (user.length === 0 || !user[0].expoPushToken) {
|
||||
throw new AppError('NO_PUSH_TOKEN', 'No push token registered', 400);
|
||||
}
|
||||
|
||||
const ticket = await sendPushNotification(
|
||||
createTaskReminderPayload(user[0].expoPushToken!, taskTitle, dueTime, taskId, repeat)
|
||||
);
|
||||
|
||||
res.json({ success: true, ticket });
|
||||
}));
|
||||
|
||||
export default router;
|
||||
@@ -2,7 +2,7 @@ import { Router, Request, Response } from 'express';
|
||||
import { asyncHandler } from '../utils/asyncHandler';
|
||||
import { db } from '../db';
|
||||
import { subtasks, tasks } from '../db/schema';
|
||||
import { eq, and, asc, desc } from 'drizzle-orm';
|
||||
import { eq, and, asc, desc, isNull } from 'drizzle-orm';
|
||||
import { authMiddleware } from '../utils/auth';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
import { subtaskCreateSchema, subtaskUpdateSchema } from '../utils/validation';
|
||||
@@ -70,7 +70,7 @@ router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) =>
|
||||
.where(and(
|
||||
eq(subtasks.taskId, req.params.taskId),
|
||||
eq(subtasks.userId, userId),
|
||||
parentSubtaskId ? eq(subtasks.parentSubtaskId, parentSubtaskId) : eq(subtasks.parentSubtaskId, null)
|
||||
parentSubtaskId ? eq(subtasks.parentSubtaskId, parentSubtaskId) : isNull(subtasks.parentSubtaskId)
|
||||
))
|
||||
.orderBy(desc(subtasks.order))
|
||||
.limit(1);
|
||||
@@ -82,6 +82,8 @@ router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) =>
|
||||
userId,
|
||||
taskId: req.params.taskId,
|
||||
parentSubtaskId,
|
||||
categoryId: data.categoryId ?? null,
|
||||
tags: data.tags ?? '',
|
||||
title: data.title,
|
||||
description: data.description ?? '',
|
||||
priority: data.priority ?? 'none',
|
||||
@@ -117,7 +119,7 @@ router.patch('/:id', asyncHandler(async (req: Request, res: Response) => {
|
||||
const data = subtaskUpdateSchema.parse(req.body);
|
||||
const userId = req.user!.userId;
|
||||
|
||||
const existing = await db
|
||||
const existing: any[] = await db
|
||||
.select()
|
||||
.from(subtasks)
|
||||
.where(and(eq(subtasks.id, req.params.id), eq(subtasks.userId, userId)))
|
||||
|
||||
+288
-66
@@ -1,8 +1,8 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { asyncHandler } from '../utils/asyncHandler';
|
||||
import { db } from '../db';
|
||||
import { categories, tasks, subtasks, repeatProfiles, users, friendships } from '../db/schema';
|
||||
import { eq, and, gte, lte, asc, or, inArray, sql } from 'drizzle-orm';
|
||||
import { categories, tasks, subtasks, repeatProfiles, users, friendships, tombstones, type EntityName } from '../db/schema';
|
||||
import { eq, and, gte, asc, or, inArray, sql } from 'drizzle-orm';
|
||||
import { authMiddleware } from '../utils/auth';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
import { syncQuerySchema, pushChangesSchema, canCompleteTask } from '../utils/validation';
|
||||
@@ -17,55 +17,47 @@ router.get('/', asyncHandler(async (req: Request, res: Response) => {
|
||||
|
||||
const sinceDate = since;
|
||||
|
||||
// Fetch categories changed since timestamp
|
||||
const changedCategories = await db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(and(eq(categories.userId, userId), gte(categories.updatedAt, sinceDate)))
|
||||
.orderBy(asc(categories.updatedAt));
|
||||
// Fetch all changed entities in parallel
|
||||
const [changedCategories, changedTasks, changedSubtasks, changedRepeatProfiles, changedFriendships, changedTombstones] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(and(eq(categories.userId, userId), gte(categories.updatedAt, sinceDate)))
|
||||
.orderBy(asc(categories.updatedAt)),
|
||||
|
||||
// Fetch tasks changed since timestamp
|
||||
const changedTasks = await db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.userId, userId), gte(tasks.updatedAt, sinceDate)))
|
||||
.orderBy(asc(tasks.updatedAt));
|
||||
db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.userId, userId), gte(tasks.updatedAt, sinceDate)))
|
||||
.orderBy(asc(tasks.updatedAt)),
|
||||
|
||||
// Fetch subtasks changed since timestamp
|
||||
const taskIds = changedTasks.map(t => t.id);
|
||||
let changedSubtasks: any[] = [];
|
||||
|
||||
if (taskIds.length > 0) {
|
||||
changedSubtasks = await db
|
||||
db
|
||||
.select()
|
||||
.from(subtasks)
|
||||
.where(and(eq(subtasks.userId, userId), gte(subtasks.updatedAt, sinceDate)))
|
||||
.orderBy(asc(subtasks.updatedAt));
|
||||
} else {
|
||||
// Also fetch subtasks for tasks that might have been deleted (we track by updatedAt)
|
||||
changedSubtasks = await db
|
||||
.orderBy(asc(subtasks.updatedAt)),
|
||||
|
||||
db
|
||||
.select()
|
||||
.from(subtasks)
|
||||
.where(and(eq(subtasks.userId, userId), gte(subtasks.updatedAt, sinceDate)))
|
||||
.orderBy(asc(subtasks.updatedAt));
|
||||
}
|
||||
.from(repeatProfiles)
|
||||
.where(and(eq(repeatProfiles.userId, userId), gte(repeatProfiles.updatedAt, sinceDate)))
|
||||
.orderBy(asc(repeatProfiles.updatedAt)),
|
||||
|
||||
// Fetch repeat profiles changed since timestamp
|
||||
const changedRepeatProfiles = await db
|
||||
.select()
|
||||
.from(repeatProfiles)
|
||||
.where(and(eq(repeatProfiles.userId, userId), gte(repeatProfiles.updatedAt, sinceDate)))
|
||||
.orderBy(asc(repeatProfiles.updatedAt));
|
||||
db
|
||||
.select()
|
||||
.from(friendships)
|
||||
.where(and(
|
||||
or(eq(friendships.userId, userId), eq(friendships.friendId, userId)),
|
||||
gte(friendships.updatedAt, sinceDate)
|
||||
))
|
||||
.orderBy(asc(friendships.updatedAt)),
|
||||
|
||||
// Fetch friendships changed since timestamp
|
||||
const changedFriendships = await db
|
||||
.select()
|
||||
.from(friendships)
|
||||
.where(and(
|
||||
or(eq(friendships.userId, userId), eq(friendships.friendId, userId)),
|
||||
gte(friendships.updatedAt, sinceDate)
|
||||
))
|
||||
.orderBy(asc(friendships.updatedAt));
|
||||
db
|
||||
.select()
|
||||
.from(tombstones)
|
||||
.where(and(eq(tombstones.userId, userId), gte(tombstones.updatedAt, sinceDate)))
|
||||
.orderBy(asc(tombstones.updatedAt)),
|
||||
]);
|
||||
|
||||
const timestamp = Date.now();
|
||||
|
||||
@@ -75,6 +67,7 @@ router.get('/', asyncHandler(async (req: Request, res: Response) => {
|
||||
subtasks: changedSubtasks,
|
||||
repeatProfiles: changedRepeatProfiles,
|
||||
friendships: changedFriendships,
|
||||
deleted: changedTombstones.map((t) => ({ entity: t.entity, id: t.entityId, updatedAt: t.updatedAt })),
|
||||
timestamp,
|
||||
});
|
||||
}));
|
||||
@@ -125,6 +118,27 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize assignee references: only real user ids may be stored (FK).
|
||||
// Stale/unknown assignee ids are silently dropped to null instead of
|
||||
// failing the whole push transaction.
|
||||
const assigneeIds = new Set<string>();
|
||||
for (const task of data.changes.tasks ?? []) {
|
||||
if (task.assigneeId) assigneeIds.add(task.assigneeId);
|
||||
}
|
||||
for (const sub of data.changes.subtasks ?? []) {
|
||||
if (sub.assigneeId) assigneeIds.add(sub.assigneeId);
|
||||
}
|
||||
const validAssignees = new Set<string>();
|
||||
if (assigneeIds.size > 0) {
|
||||
const rows = await tx
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(inArray(users.id, [...assigneeIds]));
|
||||
for (const r of rows) validAssignees.add(r.id);
|
||||
}
|
||||
const sanitizeAssignee = (a: string | null | undefined): string | null =>
|
||||
a && validAssignees.has(a) ? a : null;
|
||||
|
||||
// Process categories
|
||||
if (data.changes.categories && data.changes.categories.length > 0) {
|
||||
for (const cat of data.changes.categories) {
|
||||
@@ -185,7 +199,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
|
||||
resolution: 'server_wins',
|
||||
});
|
||||
continue; // Server wins
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent completing tasks with future due dates
|
||||
if (task.completed === true) {
|
||||
@@ -203,30 +217,33 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(tasks)
|
||||
.set({
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
categoryId: task.categoryId,
|
||||
priority: task.priority,
|
||||
completed: task.completed,
|
||||
dueDate: task.dueDate,
|
||||
dueTime: task.dueTime ?? '',
|
||||
endTime: task.endTime ?? '',
|
||||
allDay: task.allDay ?? false,
|
||||
repeat: task.repeat ?? 'none',
|
||||
repeatInterval: task.repeatInterval ?? 1,
|
||||
repeatDays: task.repeatDays ?? '',
|
||||
seriesId: task.seriesId ?? '',
|
||||
reminder: task.reminder ?? 'none',
|
||||
reminders: task.reminders ?? '',
|
||||
assigneeId: task.assigneeId ?? null,
|
||||
updatedAt: task.updatedAt,
|
||||
})
|
||||
.where(and(eq(tasks.id, task.id), eq(tasks.userId, userId)));
|
||||
.update(tasks)
|
||||
.set({
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
categoryId: task.categoryId,
|
||||
tags: task.tags ?? '',
|
||||
priority: task.priority,
|
||||
completed: task.completed,
|
||||
dueDate: task.dueDate,
|
||||
dueTime: task.dueTime ?? '',
|
||||
endTime: task.endTime ?? '',
|
||||
allDay: task.allDay ?? false,
|
||||
repeat: task.repeat ?? 'none',
|
||||
repeatInterval: task.repeatInterval ?? 1,
|
||||
repeatDays: task.repeatDays ?? '',
|
||||
seriesId: task.seriesId ?? '',
|
||||
reminder: task.reminder ?? 'none',
|
||||
reminders: task.reminders ?? '',
|
||||
assigneeId: sanitizeAssignee(task.assigneeId),
|
||||
completedAt: task.completedAt ?? null,
|
||||
updatedAt: task.updatedAt,
|
||||
})
|
||||
.where(and(eq(tasks.id, task.id), eq(tasks.userId, userId)));
|
||||
} else {
|
||||
await tx.insert(tasks).values({
|
||||
...task,
|
||||
assigneeId: sanitizeAssignee(task.assigneeId),
|
||||
allDay: task.allDay ?? false,
|
||||
userId,
|
||||
});
|
||||
@@ -260,6 +277,9 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
|
||||
.update(subtasks)
|
||||
.set({
|
||||
taskId: sub.taskId,
|
||||
parentSubtaskId: sub.parentSubtaskId ?? null,
|
||||
categoryId: sub.categoryId ?? null,
|
||||
tags: sub.tags ?? '',
|
||||
title: sub.title,
|
||||
description: sub.description ?? '',
|
||||
priority: sub.priority ?? 'none',
|
||||
@@ -274,7 +294,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
|
||||
seriesId: sub.seriesId ?? '',
|
||||
reminder: sub.reminder ?? 'none',
|
||||
reminders: sub.reminders ?? '',
|
||||
assigneeId: sub.assigneeId ?? null,
|
||||
assigneeId: sanitizeAssignee(sub.assigneeId),
|
||||
order: sub.order,
|
||||
updatedAt: sub.updatedAt,
|
||||
})
|
||||
@@ -282,6 +302,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
|
||||
} else {
|
||||
await tx.insert(subtasks).values({
|
||||
...sub,
|
||||
assigneeId: sanitizeAssignee(sub.assigneeId),
|
||||
userId,
|
||||
});
|
||||
}
|
||||
@@ -365,6 +386,14 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process deletions (tombstones) - last so they see the state produced
|
||||
// by the upserts above and resolve by last-writer-wins.
|
||||
if (data.deleted && data.deleted.length > 0) {
|
||||
for (const deleted of data.deleted) {
|
||||
await applyTombstone(tx, deleted.entity, deleted.id, deleted.updatedAt, userId, conflicts);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Sync push error:', error);
|
||||
@@ -378,4 +407,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;
|
||||
+21
-14
@@ -116,15 +116,17 @@ router.post('/', asyncHandler(async (req: Request, res: Response) => {
|
||||
const userId = req.user!.userId;
|
||||
const now = Date.now();
|
||||
|
||||
// Verify category exists and belongs to user
|
||||
const cat = await db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(and(eq(categories.id, data.categoryId), eq(categories.userId, userId)))
|
||||
.limit(1);
|
||||
// Verify category exists and belongs to user (optional - tasks may be uncategorized)
|
||||
if (data.categoryId) {
|
||||
const cat = await db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(and(eq(categories.id, data.categoryId), eq(categories.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (cat.length === 0) {
|
||||
throw new AppError('NOT_FOUND', 'Category not found', 404);
|
||||
if (cat.length === 0) {
|
||||
throw new AppError('NOT_FOUND', 'Category not found', 404);
|
||||
}
|
||||
}
|
||||
|
||||
const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
@@ -133,6 +135,7 @@ router.post('/', asyncHandler(async (req: Request, res: Response) => {
|
||||
id: taskId,
|
||||
userId,
|
||||
categoryId: data.categoryId,
|
||||
tags: data.tags ?? '',
|
||||
title: data.title,
|
||||
description: data.description ?? '',
|
||||
priority: data.priority ?? 'none',
|
||||
@@ -157,6 +160,8 @@ router.post('/', asyncHandler(async (req: Request, res: Response) => {
|
||||
userId,
|
||||
taskId,
|
||||
title: st.title,
|
||||
categoryId: (st as any).categoryId ?? null,
|
||||
tags: (st as any).tags ?? '',
|
||||
completed: false,
|
||||
order: index,
|
||||
createdAt: now,
|
||||
@@ -261,12 +266,14 @@ router.post('/batch', asyncHandler(async (req: Request, res: Response) => {
|
||||
for (const op of operations) {
|
||||
try {
|
||||
if (op.type === 'create') {
|
||||
const cat = await db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(and(eq(categories.id, op.data.categoryId), eq(categories.userId, userId)))
|
||||
.limit(1);
|
||||
if (cat.length === 0) throw new AppError('NOT_FOUND', 'Category not found', 404);
|
||||
if (op.data.categoryId) {
|
||||
const cat = await db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(and(eq(categories.id, op.data.categoryId), eq(categories.userId, userId)))
|
||||
.limit(1);
|
||||
if (cat.length === 0) throw new AppError('NOT_FOUND', 'Category not found', 404);
|
||||
}
|
||||
|
||||
const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
const now = Date.now();
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send';
|
||||
|
||||
export interface PushNotificationPayload {
|
||||
to: string;
|
||||
title: string;
|
||||
body: string;
|
||||
data?: Record<string, any>;
|
||||
sound?: string;
|
||||
badge?: number;
|
||||
priority?: 'default' | 'normal' | 'high';
|
||||
channelId?: string;
|
||||
}
|
||||
|
||||
export interface PushTicket {
|
||||
id: string;
|
||||
status: 'ok' | 'error';
|
||||
message?: string;
|
||||
details?: {
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PushReceipt {
|
||||
status: 'ok' | 'error';
|
||||
message?: string;
|
||||
details?: {
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ExpoPushResponse {
|
||||
data?: PushTicket[] | Record<string, PushReceipt>;
|
||||
}
|
||||
|
||||
export async function sendPushNotification(payload: PushNotificationPayload): Promise<PushTicket> {
|
||||
const response = await fetch(EXPO_PUSH_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const result = (await response.json()) as ExpoPushResponse;
|
||||
if (result.data && Array.isArray(result.data)) {
|
||||
return result.data[0] ?? { id: '', status: 'error', message: 'No ticket returned' };
|
||||
}
|
||||
return { id: '', status: 'error', message: 'No ticket returned' };
|
||||
}
|
||||
|
||||
export async function sendPushNotifications(payloads: PushNotificationPayload[]): Promise<PushTicket[]> {
|
||||
const chunks: PushNotificationPayload[][] = [];
|
||||
for (let i = 0; i < payloads.length; i += 100) {
|
||||
chunks.push(payloads.slice(i, i + 100));
|
||||
}
|
||||
|
||||
const tickets: PushTicket[] = [];
|
||||
for (const chunk of chunks) {
|
||||
const response = await fetch(EXPO_PUSH_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(chunk),
|
||||
});
|
||||
const result = (await response.json()) as ExpoPushResponse;
|
||||
if (result.data && Array.isArray(result.data)) {
|
||||
tickets.push(...result.data);
|
||||
}
|
||||
}
|
||||
return tickets;
|
||||
}
|
||||
|
||||
export async function getPushReceipts(ticketIds: string[]): Promise<PushReceipt[]> {
|
||||
if (ticketIds.length === 0) return [];
|
||||
|
||||
const response = await fetch(`${EXPO_PUSH_URL}/receipts`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ ids: ticketIds }),
|
||||
});
|
||||
|
||||
const result = (await response.json()) as ExpoPushResponse;
|
||||
const receipts: PushReceipt[] = [];
|
||||
if (result.data && !Array.isArray(result.data)) {
|
||||
for (const id of ticketIds) {
|
||||
receipts.push(result.data[id] ?? { status: 'error', message: 'No receipt found' });
|
||||
}
|
||||
}
|
||||
return receipts;
|
||||
}
|
||||
|
||||
export function isExpoPushToken(token: string): boolean {
|
||||
return token.startsWith('ExponentPushToken[') || token.startsWith('ExpoPushToken[');
|
||||
}
|
||||
|
||||
export function createTaskReminderPayload(
|
||||
pushToken: string,
|
||||
taskTitle: string,
|
||||
dueTime: string,
|
||||
taskId: string,
|
||||
repeat?: string
|
||||
): PushNotificationPayload {
|
||||
return {
|
||||
to: pushToken,
|
||||
title: taskTitle,
|
||||
body: `Due ${dueTime}${repeat && repeat !== 'none' ? ' · repeating' : ''}`,
|
||||
data: { type: 'task_reminder', taskId },
|
||||
sound: 'default',
|
||||
priority: 'high',
|
||||
channelId: 'task-reminders',
|
||||
};
|
||||
}
|
||||
|
||||
export function createFriendRequestPayload(
|
||||
pushToken: string,
|
||||
fromUsername: string,
|
||||
friendshipId: string
|
||||
): PushNotificationPayload {
|
||||
return {
|
||||
to: pushToken,
|
||||
title: 'New Friend Request',
|
||||
body: `${fromUsername} wants to be your friend`,
|
||||
data: { type: 'friend_request', friendshipId },
|
||||
sound: 'default',
|
||||
priority: 'high',
|
||||
channelId: 'friend-requests',
|
||||
};
|
||||
}
|
||||
|
||||
export function createTaskAssignmentPayload(
|
||||
pushToken: string,
|
||||
taskTitle: string,
|
||||
fromUsername: string,
|
||||
taskId: string
|
||||
): PushNotificationPayload {
|
||||
return {
|
||||
to: pushToken,
|
||||
title: 'Task Assigned',
|
||||
body: `${fromUsername} assigned you "${taskTitle}"`,
|
||||
data: { type: 'task_assignment', taskId },
|
||||
sound: 'default',
|
||||
priority: 'high',
|
||||
channelId: 'task-assignments',
|
||||
};
|
||||
}
|
||||
@@ -21,29 +21,46 @@ export const categoryUpdateSchema = z.object({
|
||||
|
||||
export const repeatSchema = z.enum(['none', 'daily', 'weekly', 'monthly', 'custom']);
|
||||
|
||||
// The client stores '' / 0 for unset repeat values; normalize them so stale or
|
||||
// legacy rows never fail validation on push.
|
||||
export const repeatFieldSchema = repeatSchema
|
||||
.or(z.literal(''))
|
||||
.transform((v) => (v === '' ? 'none' : v))
|
||||
.optional();
|
||||
export const repeatIntervalFieldSchema = z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(30)
|
||||
.transform((v) => Math.max(1, v))
|
||||
.optional();
|
||||
|
||||
export const taskCreateSchema = z.object({
|
||||
title: z.string().min(1).max(100),
|
||||
description: z.string().max(1000).optional(),
|
||||
categoryId: z.string().min(1),
|
||||
categoryId: z.string().max(100).transform((v) => (v === '' ? null : v)).nullable(),
|
||||
tags: z.string().max(500).optional(),
|
||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
||||
dueDate: z.number().int().min(0).optional(),
|
||||
dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v ?? ''),
|
||||
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')),
|
||||
allDay: z.boolean().optional(),
|
||||
repeat: repeatSchema.optional(),
|
||||
repeatInterval: z.number().int().min(1).max(30).optional(),
|
||||
repeat: repeatFieldSchema,
|
||||
repeatInterval: repeatIntervalFieldSchema,
|
||||
repeatDays: z.string().max(20).optional(),
|
||||
seriesId: z.string().max(50).optional(),
|
||||
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
|
||||
reminders: z.string().max(100).optional(),
|
||||
assigneeId: z.string().nullable().optional(),
|
||||
subtasks: z.array(z.object({ title: z.string().min(1).max(100) })).optional(),
|
||||
completedAt: z.number().int().min(0).nullable().optional(),
|
||||
subtasks: z.array(z.object({ title: z.string().min(1).max(100), categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)) })).optional(),
|
||||
});
|
||||
|
||||
export const taskUpdateSchema = z.object({
|
||||
title: z.string().min(1).max(100).optional(),
|
||||
description: z.string().max(1000).optional(),
|
||||
categoryId: z.string().min(1).optional(),
|
||||
categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)),
|
||||
tags: z.string().max(500).optional(),
|
||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
||||
completed: z.boolean().optional(),
|
||||
dueDate: z.number().int().min(0).optional(),
|
||||
@@ -51,18 +68,21 @@ export const taskUpdateSchema = z.object({
|
||||
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(),
|
||||
reminders: z.string().max(100).optional(),
|
||||
assigneeId: z.string().nullable().optional(),
|
||||
completedAt: z.number().int().min(0).nullable().optional(),
|
||||
});
|
||||
|
||||
export const subtaskCreateSchema = z.object({
|
||||
title: z.string().min(1).max(100),
|
||||
description: z.string().max(1000).optional(),
|
||||
categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)),
|
||||
tags: z.string().max(500).optional(),
|
||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
||||
dueDate: z.number().int().min(0).optional(),
|
||||
dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v ?? ''),
|
||||
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')),
|
||||
allDay: z.boolean().optional(),
|
||||
repeat: repeatSchema.optional(),
|
||||
repeatInterval: z.number().int().min(1).max(30).optional(),
|
||||
repeat: repeatFieldSchema,
|
||||
repeatInterval: repeatIntervalFieldSchema,
|
||||
repeatDays: z.string().max(20).optional(),
|
||||
seriesId: z.string().max(50).optional(),
|
||||
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
|
||||
@@ -75,14 +95,16 @@ export const subtaskCreateSchema = z.object({
|
||||
export const subtaskUpdateSchema = z.object({
|
||||
title: z.string().min(1).max(100).optional(),
|
||||
description: z.string().max(1000).optional(),
|
||||
categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)),
|
||||
tags: z.string().max(500).optional(),
|
||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
|
||||
completed: z.boolean().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 === '' ? undefined : v),
|
||||
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(),
|
||||
allDay: z.boolean().optional(),
|
||||
repeat: repeatSchema.optional(),
|
||||
repeatInterval: z.number().int().min(1).max(30).optional(),
|
||||
repeat: repeatFieldSchema,
|
||||
repeatInterval: repeatIntervalFieldSchema,
|
||||
repeatDays: z.string().max(20).optional(),
|
||||
seriesId: z.string().max(50).optional(),
|
||||
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
|
||||
@@ -132,14 +154,21 @@ export const friendshipSchema = z.object({
|
||||
updatedAt: z.number(),
|
||||
});
|
||||
|
||||
export const tombstoneSchema = z.object({
|
||||
entity: z.enum(['categories', 'tasks', 'subtasks', 'repeatProfiles', 'friendships']),
|
||||
id: z.string(),
|
||||
updatedAt: z.number().int().min(0),
|
||||
});
|
||||
|
||||
export const pushChangesSchema = z.object({
|
||||
changes: z.object({
|
||||
categories: z.array(categoryCreateSchema.extend({ id: z.string(), createdAt: z.number(), updatedAt: z.number() })).optional(),
|
||||
tasks: z.array(taskCreateSchema.extend({ id: z.string(), completed: z.boolean(), createdAt: z.number(), updatedAt: z.number() })).optional(),
|
||||
tasks: z.array(taskCreateSchema.extend({ id: z.string(), completed: z.boolean(), createdAt: z.number(), updatedAt: z.number(), completedAt: z.number().int().min(0).nullable().optional() })).optional(),
|
||||
subtasks: z.array(subtaskCreateSchema.extend({ id: z.string(), taskId: z.string(), completed: z.boolean(), createdAt: z.number(), updatedAt: z.number() })).optional(),
|
||||
repeatProfiles: z.array(repeatProfileSchema.extend({ id: z.string(), createdAt: z.number(), updatedAt: z.number() })).optional(),
|
||||
friendships: z.array(friendshipSchema).optional(),
|
||||
}),
|
||||
deleted: z.array(tombstoneSchema).optional(),
|
||||
lastPulledAt: z.number().int().min(0),
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
|
||||
# Electron
|
||||
desktop-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "dark",
|
||||
"backgroundColor": "#121212",
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"softwareKeyboardLayoutMode": "resize",
|
||||
"backgroundColor": "#121212",
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { TabBarIcon } from '@/components/TabBarIcon';
|
||||
import { useSettings } from '@/theme';
|
||||
import { setLastVisitedTab } from '@/utils/tabHistory';
|
||||
|
||||
export default function TabLayout() {
|
||||
const { theme } = useSettings();
|
||||
@@ -12,57 +14,111 @@ export default function TabLayout() {
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: theme.accent,
|
||||
tabBarInactiveTintColor: '#8E8E8E',
|
||||
tabBarInactiveTintColor: theme.textSecondary,
|
||||
tabBarStyle: {
|
||||
backgroundColor: theme.tabBarBg,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.border,
|
||||
height: 64 + insets.bottom,
|
||||
borderTopWidth: 0,
|
||||
height: 84 + insets.bottom,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingTop: 12,
|
||||
paddingHorizontal: 16,
|
||||
elevation: 0,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -4 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 20,
|
||||
},
|
||||
tabBarItemStyle: {
|
||||
paddingVertical: 0,
|
||||
},
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
fontWeight: '700',
|
||||
marginTop: 4,
|
||||
},
|
||||
headerShown: false,
|
||||
sceneStyle: { backgroundColor: theme.background },
|
||||
}}
|
||||
tabBar={(props) => {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.tabBarContainer,
|
||||
{
|
||||
backgroundColor: theme.tabBarBg,
|
||||
paddingBottom: Math.max(props.insets.bottom, 8),
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -4 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 20,
|
||||
elevation: 8,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{props.navigation.getState().routes.map((route) => {
|
||||
const focused = props.navigation.getState().index === props.navigation.getState().routes.indexOf(route);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={route.name}
|
||||
style={[
|
||||
styles.tabItem,
|
||||
focused && styles.tabItemActive,
|
||||
{ backgroundColor: focused ? theme.accentSoft : 'transparent' },
|
||||
]}
|
||||
onPress={() => props.navigation.navigate(route.name as never)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={route.name === 'index' ? 'ToDo' : route.name.charAt(0).toUpperCase() + route.name.slice(1)}
|
||||
accessibilityState={{ selected: focused }}
|
||||
>
|
||||
<TabBarIcon
|
||||
name={route.name === 'index' ? 'checklist' : route.name === 'calendar' ? 'calendar' : route.name === 'stats' ? 'stats' : 'gear'}
|
||||
focused={focused}
|
||||
color={focused ? theme.accent : theme.textSecondary}
|
||||
size={24}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
screenListeners={({ route }) => ({
|
||||
focus: () => {
|
||||
if (route.name !== 'settings') {
|
||||
setLastVisitedTab(route.name);
|
||||
}
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Tasks',
|
||||
tabBarIcon: ({ focused, color }) => (
|
||||
<TabBarIcon name="checklist" focused={focused} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="calendar"
|
||||
options={{
|
||||
title: 'Calendar',
|
||||
tabBarIcon: ({ focused, color }) => (
|
||||
<TabBarIcon name="calendar" focused={focused} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="stats"
|
||||
options={{
|
||||
title: 'Stats',
|
||||
tabBarIcon: ({ focused, color }) => (
|
||||
<TabBarIcon name="stats" focused={focused} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: 'Settings',
|
||||
tabBarIcon: ({ focused, color }) => (
|
||||
<TabBarIcon name="gear" focused={focused} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen name="index" />
|
||||
<Tabs.Screen name="calendar" />
|
||||
<Tabs.Screen name="stats" />
|
||||
<Tabs.Screen name="settings" />
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
tabBarContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-around',
|
||||
paddingTop: 10,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
tabItem: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 8,
|
||||
borderRadius: 16,
|
||||
minWidth: 70,
|
||||
},
|
||||
tabItemActive: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
});
|
||||
@@ -1,24 +1,23 @@
|
||||
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions, KeyboardAvoidingView } from 'react-native';
|
||||
import { useRouter, useFocusEffect } from 'expo-router';
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
import { Header } from '@/components/Header';
|
||||
import { useTasksByDate, useTasksInMonth } from '@/hooks/useTasks';
|
||||
import { useSubtasks } from '@/hooks/useSubtasks';
|
||||
import { useTaskModals } from '@/hooks/useTaskModals';
|
||||
import { useSettings } from '@/theme';
|
||||
import { useCategories, useDatabase } from '@/hooks/useDatabase';
|
||||
import { toggleTaskComplete } from '@/utils/taskActions';
|
||||
import { useCategories } from '@/hooks/useDatabase';
|
||||
import { confirmAndToggleTaskComplete } from '@/utils/taskActions';
|
||||
import { QuickAddBar } from '@/components/QuickAddBar';
|
||||
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
||||
import { SubtaskData } from '@/types';
|
||||
import Task from '@/models/Task';
|
||||
import { format, addMonths, addDays, startOfMonth, isSameDay, isSameMonth, isToday } from 'date-fns';
|
||||
import { Q } from '@nozbe/watermelondb';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
import { desaturate } from '@/theme';
|
||||
import type { ThemeColors } from '@/theme';
|
||||
|
||||
const WIDTH = Dimensions.get('window').width;
|
||||
const ORANGE = '#FF7043';
|
||||
const GERMAN_WEEKDAYS = ['mo', 'di', 'mi', 'do', 'fr', 'sa', 'so'];
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
@@ -28,7 +27,7 @@ const MONTH_NAMES = [
|
||||
export default function CalendarScreen() {
|
||||
const router = useRouter();
|
||||
const { theme } = useSettings();
|
||||
const { collections } = useDatabase();
|
||||
const styles = React.useMemo(() => createStyles(theme), [theme]);
|
||||
const categories = useCategories();
|
||||
const { modals, openTaskEdit } = useTaskModals();
|
||||
|
||||
@@ -36,19 +35,33 @@ export default function CalendarScreen() {
|
||||
const [selectedDate, setSelectedDate] = useState(() => new Date());
|
||||
const [monthPickerVisible, setMonthPickerVisible] = useState(false);
|
||||
const [yearPickerVisible, setYearPickerVisible] = useState(false);
|
||||
const [subtasksMap, setSubtasksMap] = useState<Record<string, SubtaskData[]>>({});
|
||||
const { map: subtasksByTask, refresh: refreshSubtasks } = useSubtasks();
|
||||
|
||||
const visibleMonthRef = useRef(visibleMonth);
|
||||
visibleMonthRef.current = visibleMonth;
|
||||
const selectedDateRef = useRef(selectedDate);
|
||||
selectedDateRef.current = selectedDate;
|
||||
|
||||
const translateX = useRef(new Animated.Value(0)).current;
|
||||
const translateX = useRef<Animated.Value>(new Animated.Value(0));
|
||||
const animatingRef = useRef(false);
|
||||
const gridWidthRef = useRef<number>(WIDTH);
|
||||
|
||||
const { tasks: selectedDayTasks } = useTasksByDate(selectedDate);
|
||||
useEffect(() => {
|
||||
visibleMonthRef.current = visibleMonth;
|
||||
}, [visibleMonth]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedDateRef.current = selectedDate;
|
||||
}, [selectedDate]);
|
||||
|
||||
const { tasks: selectedDayTasks, refresh: refreshDayTasks } = useTasksByDate(selectedDate);
|
||||
const monthTasks = useTasksInMonth(visibleMonth);
|
||||
const refreshMonthTasks = monthTasks.refresh;
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
refreshDayTasks();
|
||||
refreshMonthTasks();
|
||||
refreshSubtasks();
|
||||
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks])
|
||||
);
|
||||
|
||||
const weeks = useMemo(() => {
|
||||
const first = startOfMonth(visibleMonth);
|
||||
@@ -60,70 +73,25 @@ export default function CalendarScreen() {
|
||||
return rows;
|
||||
}, [visibleMonth]);
|
||||
|
||||
const loadSubtasks = useCallback(
|
||||
async (taskId: string): Promise<[string, SubtaskData[]]> => {
|
||||
const subs = await collections.subtasks
|
||||
.query(Q.where('task_id', taskId), Q.where('parent_subtask_id', null), Q.sortBy('order', 'asc'))
|
||||
.fetch();
|
||||
const mapped: SubtaskData[] = subs.map((s: any) => ({
|
||||
id: s.id,
|
||||
taskId: s.taskId,
|
||||
parentSubtaskId: s.parentSubtaskId || null,
|
||||
title: s.title,
|
||||
description: s.description || '',
|
||||
priority: (s.priority || 'none') as SubtaskData['priority'],
|
||||
completed: s.completed,
|
||||
dueDate: s.dueDate || 0,
|
||||
dueTime: s.dueTime || '',
|
||||
endTime: s.endTime || '',
|
||||
allDay: s.allDay ?? false,
|
||||
repeat: (s.repeat || 'none') as SubtaskData['repeat'],
|
||||
repeatInterval: s.repeatInterval ?? 1,
|
||||
repeatDays: s.repeatDays || '',
|
||||
seriesId: s.seriesId || '',
|
||||
reminder: (s.reminder || 'none') as SubtaskData['reminder'],
|
||||
assigneeId: s.assigneeId ?? null,
|
||||
order: s.order,
|
||||
subtasks: [],
|
||||
}));
|
||||
return [taskId, mapped];
|
||||
},
|
||||
[collections.subtasks]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setSubtasksMap({});
|
||||
Promise.all(selectedDayTasks.map((t) => loadSubtasks(t.id))).then((entries) => {
|
||||
if (!active) return;
|
||||
setSubtasksMap(Object.fromEntries(entries));
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [selectedDayTasks, loadSubtasks]);
|
||||
|
||||
const handleDayPress = useCallback((day: Date) => {
|
||||
setSelectedDate(day);
|
||||
if (!isSameMonth(day, visibleMonthRef.current)) {
|
||||
setVisibleMonth(day);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const transitionTo = useCallback(
|
||||
(dir: 1 | -1) => {
|
||||
(dir: 1 | -1, animate = true) => {
|
||||
if (animatingRef.current) return;
|
||||
const next = addMonths(visibleMonthRef.current, dir);
|
||||
if (!isSameMonth(selectedDateRef.current, next)) {
|
||||
setSelectedDate(startOfMonth(next));
|
||||
}
|
||||
if (!animate) {
|
||||
setVisibleMonth(next);
|
||||
translateX.current.setValue(0);
|
||||
return;
|
||||
}
|
||||
animatingRef.current = true;
|
||||
const w = gridWidthRef.current || WIDTH;
|
||||
const target = dir === 1 ? -w : w;
|
||||
Animated.timing(translateX, { toValue: target, duration: 220, useNativeDriver: false }).start(() => {
|
||||
const next = addMonths(visibleMonthRef.current, dir);
|
||||
if (!isSameMonth(selectedDateRef.current, next)) {
|
||||
setSelectedDate(startOfMonth(next));
|
||||
}
|
||||
Animated.timing(translateX.current, { toValue: target, duration: 220, useNativeDriver: false }).start(() => {
|
||||
setVisibleMonth(next);
|
||||
translateX.setValue(-target);
|
||||
Animated.timing(translateX, { toValue: 0, duration: 180, useNativeDriver: false }).start(() => {
|
||||
translateX.current.setValue(-target);
|
||||
Animated.timing(translateX.current, { toValue: 0, duration: 180, useNativeDriver: false }).start(() => {
|
||||
animatingRef.current = false;
|
||||
});
|
||||
});
|
||||
@@ -131,27 +99,41 @@ export default function CalendarScreen() {
|
||||
[translateX]
|
||||
);
|
||||
|
||||
const handleDayPress = useCallback(
|
||||
(day: Date) => {
|
||||
setSelectedDate(day);
|
||||
if (!isSameMonth(day, visibleMonthRef.current)) {
|
||||
transitionTo(day > visibleMonthRef.current ? 1 : -1, true);
|
||||
} else {
|
||||
router.push({ pathname: '/day-view', params: { date: day.toISOString() } });
|
||||
}
|
||||
},
|
||||
[router, transitionTo]
|
||||
);
|
||||
|
||||
const pan = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.activeOffsetX([-16, 16])
|
||||
.minDistance(6)
|
||||
.runOnJS(true)
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
.onUpdate((e) => {
|
||||
if (!animatingRef.current) translateX.setValue(e.translationX);
|
||||
if (!animatingRef.current) translateX.current.setValue(e.translationX);
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
.onEnd((e) => {
|
||||
if (animatingRef.current) return;
|
||||
const w = gridWidthRef.current || WIDTH;
|
||||
const dx = e.translationX;
|
||||
if (dx <= -w / 4) {
|
||||
translateX.stopAnimation();
|
||||
transitionTo(1);
|
||||
translateX.current.stopAnimation();
|
||||
transitionTo(1, false);
|
||||
} else if (dx >= w / 4) {
|
||||
translateX.stopAnimation();
|
||||
transitionTo(-1);
|
||||
translateX.current.stopAnimation();
|
||||
transitionTo(-1, false);
|
||||
} else {
|
||||
Animated.spring(translateX, { toValue: 0, useNativeDriver: false }).start();
|
||||
Animated.spring(translateX.current, { toValue: 0, useNativeDriver: false }).start();
|
||||
}
|
||||
}),
|
||||
[translateX, transitionTo]
|
||||
@@ -176,22 +158,27 @@ export default function CalendarScreen() {
|
||||
}, [currentYear]);
|
||||
|
||||
const handleToggleComplete = useCallback(async (taskId: string) => {
|
||||
await toggleTaskComplete(taskId);
|
||||
}, []);
|
||||
await confirmAndToggleTaskComplete(taskId, () => {
|
||||
refreshDayTasks();
|
||||
refreshMonthTasks();
|
||||
refreshSubtasks();
|
||||
});
|
||||
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
<GestureDetector gesture={pan}>
|
||||
<View style={styles.flex}>
|
||||
<Header title="Calendar" showLogo={false} />
|
||||
<Header title="Calendar" showLogo={false} />
|
||||
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
|
||||
<Animated.View
|
||||
onLayout={(e) => {
|
||||
gridWidthRef.current = e.nativeEvent.layout.width;
|
||||
}}
|
||||
style={[styles.calendarArea, { transform: [{ translateX }] }]}
|
||||
>
|
||||
<KeyboardAvoidingView style={styles.kbAvoid} behavior="padding">
|
||||
<ScrollView style={styles.scrollBody} showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
|
||||
<GestureDetector gesture={pan}>
|
||||
<Animated.View
|
||||
onLayout={(e) => {
|
||||
gridWidthRef.current = e.nativeEvent.layout.width;
|
||||
}}
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
style={[styles.calendarArea, { transform: [{ translateX: translateX.current }] }]}
|
||||
>
|
||||
<View style={styles.monthRow}>
|
||||
<TouchableOpacity
|
||||
onPress={() => transitionTo(-1)}
|
||||
@@ -199,7 +186,7 @@ export default function CalendarScreen() {
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<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>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -207,21 +194,22 @@ export default function CalendarScreen() {
|
||||
<TouchableOpacity onPress={() => setMonthPickerVisible(true)} activeOpacity={0.7} style={styles.monthButton}>
|
||||
<Text style={[styles.monthLabel, { color: theme.text }]}>{format(visibleMonth, 'MMMM')}</Text>
|
||||
<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>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setYearPickerVisible(true)} activeOpacity={0.7} style={styles.yearButton}>
|
||||
<Text style={[styles.yearLabel, { color: theme.textMuted }]}>{format(visibleMonth, 'yyyy')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity onPress={() => setYearPickerVisible(true)} activeOpacity={0.7} style={styles.yearButton}>
|
||||
<Text style={[styles.yearLabel, { color: theme.textMuted }]}>{format(visibleMonth, 'yyyy')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => transitionTo(1)}
|
||||
style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<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>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -241,7 +229,7 @@ export default function CalendarScreen() {
|
||||
key={day.toISOString()}
|
||||
day={day}
|
||||
label={cellTitle(byDayOf(monthTasks.byDay, day))}
|
||||
dotColor={cellColor(byDayOf(monthTasks.byDay, day), categories)}
|
||||
dotColor={cellColor(byDayOf(monthTasks.byDay, day), categories, theme)}
|
||||
selected={isSameDay(day, selectedDate)}
|
||||
today={isToday(day)}
|
||||
inMonth={isSameMonth(day, visibleMonth)}
|
||||
@@ -252,8 +240,9 @@ export default function CalendarScreen() {
|
||||
</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>
|
||||
{selectedDayTasks.length > 0 && (
|
||||
<Text style={[styles.panelCount, { color: theme.textFaint }]}>
|
||||
@@ -276,14 +265,14 @@ export default function CalendarScreen() {
|
||||
onPress={() => handleToggleComplete(task.id)}
|
||||
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 ? (
|
||||
<>
|
||||
<Circle cx={12} cy={12} r={10} fill={theme.accent} />
|
||||
<Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</>
|
||||
) : (
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.textMuted} strokeWidth={2} fill="none" />
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2.5} fill="none" />
|
||||
)}
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
@@ -303,22 +292,15 @@ export default function CalendarScreen() {
|
||||
|
||||
<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">
|
||||
<Circle cx={12} cy={5} r={1.5} fill={theme.textMuted} />
|
||||
<Circle cx={12} cy={12} r={1.5} fill={theme.textMuted} />
|
||||
<Circle cx={12} cy={19} 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" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{(subtasksMap[task.id] ?? []).length > 0 && (
|
||||
{(subtasksByTask.get(task.id) ?? []).length > 0 && (
|
||||
<View style={styles.bullets}>
|
||||
{(subtasksMap[task.id] ?? []).map((sub) => (
|
||||
<TouchableOpacity
|
||||
key={sub.id}
|
||||
style={styles.bulletRow}
|
||||
onPress={() => router.push({ pathname: '/subtask-detail', params: { id: sub.id } })}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{(subtasksByTask.get(task.id) ?? []).map((sub) => (
|
||||
<View key={sub.id} style={styles.bulletRow}>
|
||||
<Svg width={5} height={5} viewBox="0 0 6 6" style={styles.bulletDot as any}>
|
||||
<Circle cx={3} cy={3} r={3} fill={theme.textMuted} />
|
||||
</Svg>
|
||||
@@ -328,7 +310,7 @@ export default function CalendarScreen() {
|
||||
>
|
||||
{sub.title}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
@@ -337,28 +319,30 @@ export default function CalendarScreen() {
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<QuickAddBar dueDate={selectedDate.getTime()} placeholder={`Add event for ${format(selectedDate, 'MMM d')}`} />
|
||||
<QuickAddBar
|
||||
dueDate={selectedDate.getTime()}
|
||||
placeholder={`Add event for ${format(selectedDate, 'MMM d')}`}
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{modals(() => {})}
|
||||
{modals(() => {})}
|
||||
|
||||
<OptionPickerModal
|
||||
visible={monthPickerVisible}
|
||||
title="Select Month"
|
||||
options={monthOptions}
|
||||
selectedValue={String(visibleMonth.getMonth())}
|
||||
onSelect={handleSelectMonth}
|
||||
onClose={() => setMonthPickerVisible(false)}
|
||||
/>
|
||||
<OptionPickerModal
|
||||
visible={yearPickerVisible}
|
||||
title="Select Year"
|
||||
options={yearOptions}
|
||||
selectedValue={String(visibleMonth.getFullYear())}
|
||||
onSelect={handleSelectYear}
|
||||
onClose={() => setYearPickerVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
</GestureDetector>
|
||||
<OptionPickerModal
|
||||
visible={monthPickerVisible}
|
||||
title="Select Month"
|
||||
options={monthOptions}
|
||||
selectedValue={String(visibleMonth.getMonth())}
|
||||
onSelect={handleSelectMonth}
|
||||
onClose={() => setMonthPickerVisible(false)}
|
||||
/>
|
||||
<OptionPickerModal
|
||||
visible={yearPickerVisible}
|
||||
title="Select Year"
|
||||
options={yearOptions}
|
||||
selectedValue={String(visibleMonth.getFullYear())}
|
||||
onSelect={handleSelectYear}
|
||||
onClose={() => setYearPickerVisible(false)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -373,10 +357,10 @@ function cellTitle(tasks: Task[]): string | null {
|
||||
return `${tasks[0].title} +${tasks.length - 1}`;
|
||||
}
|
||||
|
||||
function cellColor(tasks: Task[], categories: { id: string; color: string }[]): string {
|
||||
if (tasks.length === 0) return '#8E8E8E';
|
||||
function cellColor(tasks: Task[], categories: { id: string; color: string }[], theme: ThemeColors): string {
|
||||
if (tasks.length === 0) return theme.textMuted;
|
||||
const cat = categories.find((c) => c.id === tasks[0].categoryId);
|
||||
return cat?.color ?? '#8E8E8E';
|
||||
return cat ? desaturate(cat.color, 0.3) : theme.textMuted;
|
||||
}
|
||||
|
||||
interface DayCellProps {
|
||||
@@ -391,9 +375,10 @@ interface DayCellProps {
|
||||
}
|
||||
|
||||
const DayCell = React.memo(function DayCell({ day, label, dotColor, selected, today, inMonth, theme, onPress }: DayCellProps) {
|
||||
const styles = React.useMemo(() => createStyles(theme), [theme]);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.dayCell, selected && { backgroundColor: ORANGE }]}
|
||||
style={[styles.dayCell, selected && { backgroundColor: theme.accentSoft, borderColor: theme.accentBorder }]}
|
||||
onPress={() => onPress(day)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
@@ -401,8 +386,8 @@ const DayCell = React.memo(function DayCell({ day, label, dotColor, selected, to
|
||||
style={[
|
||||
styles.dayNumber,
|
||||
{ color: theme.text },
|
||||
!inMonth && { color: theme.borderStrong },
|
||||
today && !selected && { color: ORANGE },
|
||||
!inMonth && { color: theme.textMuted },
|
||||
today && !selected && { color: theme.accent },
|
||||
selected && styles.dayNumberSelected,
|
||||
]}
|
||||
>
|
||||
@@ -421,16 +406,19 @@ const DayCell = React.memo(function DayCell({ day, label, dotColor, selected, to
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const createStyles = (theme: ThemeColors) => StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
flex: {
|
||||
scrollContent: {
|
||||
paddingBottom: 16,
|
||||
flexGrow: 1,
|
||||
},
|
||||
scrollBody: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 96,
|
||||
flexGrow: 1,
|
||||
kbAvoid: {
|
||||
flex: 1,
|
||||
},
|
||||
calendarArea: {
|
||||
paddingHorizontal: 12,
|
||||
@@ -453,7 +441,8 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
},
|
||||
monthSelectorGroup: {
|
||||
alignItems: 'center',
|
||||
alignItems: 'flex-start',
|
||||
flex: 1,
|
||||
},
|
||||
monthButton: {
|
||||
flexDirection: 'row',
|
||||
@@ -468,10 +457,11 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
yearButton: {
|
||||
marginTop: -2,
|
||||
paddingHorizontal: 6,
|
||||
},
|
||||
yearLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
},
|
||||
weekdayRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -504,7 +494,7 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
},
|
||||
dayNumberSelected: {
|
||||
color: '#FFFFFF',
|
||||
color: theme.text,
|
||||
},
|
||||
chip: {
|
||||
marginTop: 3,
|
||||
@@ -565,8 +555,8 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
},
|
||||
checkCircle: {
|
||||
width: 24,
|
||||
marginRight: 10,
|
||||
width: 30,
|
||||
marginRight: 12,
|
||||
},
|
||||
eventTitleTouch: {
|
||||
flex: 1,
|
||||
@@ -577,7 +567,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
eventCompleted: {
|
||||
textDecorationLine: 'line-through',
|
||||
color: '#9E9E9E',
|
||||
color: theme.textMuted,
|
||||
},
|
||||
menuButton: {
|
||||
padding: 4,
|
||||
@@ -604,6 +594,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
bulletCompleted: {
|
||||
textDecorationLine: 'line-through',
|
||||
color: '#6E6E6E',
|
||||
color: theme.textMuted,
|
||||
},
|
||||
});
|
||||
@@ -1,16 +1,27 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView } from 'react-native';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, KeyboardAvoidingView, TouchableOpacity, BackHandler } from 'react-native';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
import { Header } from '@/components/Header';
|
||||
import { CategoryFilter } from '@/components/CategoryFilter';
|
||||
import { TaskList } from '@/components/TaskList';
|
||||
import { QuickAddBar } from '@/components/QuickAddBar';
|
||||
import { useDatabase } from '@/hooks/useDatabase';
|
||||
import { useSettings } from '@/theme';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
|
||||
export default function TasksScreen() {
|
||||
const { isReady } = useDatabase();
|
||||
const { theme } = useSettings();
|
||||
const [selectedCategory, setSelectedCategory] = React.useState<string>('all');
|
||||
const { theme, showCompleted, setShowCompleted } = useSettings();
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
|
||||
const categoryIds = useMemo(() => selectedCategories, [selectedCategories]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => false);
|
||||
return () => sub.remove();
|
||||
}, [])
|
||||
);
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
@@ -22,12 +33,45 @@ export default function TasksScreen() {
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
<Header title="TODO" showLogo={false} />
|
||||
<Header title="ToDo" showLogo={false} />
|
||||
<View style={styles.categoryFilterWrapper}>
|
||||
<CategoryFilter selected={selectedCategory} onSelect={setSelectedCategory} />
|
||||
<CategoryFilter selected={selectedCategories} onSelect={setSelectedCategories} />
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.completedToggle,
|
||||
{ backgroundColor: theme.card, borderColor: showCompleted ? theme.accent : theme.borderStrong },
|
||||
]}
|
||||
onPress={() => setShowCompleted(!showCompleted)}
|
||||
activeOpacity={0.8}
|
||||
accessibilityRole="switch"
|
||||
accessibilityLabel="Show completed tasks"
|
||||
accessibilityState={{ checked: showCompleted }}
|
||||
>
|
||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||
<Circle
|
||||
cx={12}
|
||||
cy={12}
|
||||
r={9}
|
||||
stroke={showCompleted ? theme.accent : theme.textMuted}
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
/>
|
||||
{showCompleted && (
|
||||
<Path d="M7 12.5l3.5 3.5 6.5-7" stroke={theme.accent} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
)}
|
||||
</Svg>
|
||||
<Text style={[styles.completedToggleText, { color: showCompleted ? theme.text : theme.textMuted }]}>
|
||||
Completed
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TaskList categoryId={selectedCategory} />
|
||||
<QuickAddBar />
|
||||
<KeyboardAvoidingView
|
||||
style={styles.kbAvoid}
|
||||
behavior="padding"
|
||||
>
|
||||
<TaskList categoryIds={categoryIds} showCompleted={showCompleted} />
|
||||
<QuickAddBar />
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -37,11 +81,26 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
},
|
||||
categoryFilterWrapper: {
|
||||
height: 36,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingRight: 12,
|
||||
},
|
||||
});
|
||||
completedToggle: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 18,
|
||||
borderWidth: 1.5,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
completedToggleText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
kbAvoid: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, Switch, TouchableOpacity, Alert, Linking, Modal, Pressable } from 'react-native';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
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 { ListItem } from '@/components/ListItem';
|
||||
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
||||
@@ -17,9 +18,11 @@ import { getLastSyncTime } from '@/database/sync';
|
||||
import Category from '@/models/Category';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
import ColorWheel from '@/components/ColorWheel';
|
||||
import { getLastVisitedTab, tabHref } from '@/utils/tabHistory';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, apiUrl, accentColor, setAccentColor, todoAheadDays, setTodoAheadDays } = useSettings();
|
||||
const router = useRouter();
|
||||
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, apiUrl, accentColor, setAccentColor, todoAheadDays, setTodoAheadDays, showCompleted, setShowCompleted } = useSettings();
|
||||
const categories = useCategories();
|
||||
const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder' | 'todoAhead'>(null);
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null);
|
||||
@@ -75,8 +78,21 @@ export default function SettingsScreen() {
|
||||
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 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 openCustomAccent = () => {
|
||||
@@ -103,12 +119,12 @@ export default function SettingsScreen() {
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
|
||||
<Header title="Settings" showLogo={false} />
|
||||
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Categories</Text>
|
||||
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Tags</Text>
|
||||
{categories.map((category) => (
|
||||
<ListItem
|
||||
key={category.id}
|
||||
title={category.name}
|
||||
subtitle={category.id === defaultCategoryId ? 'Default category' : undefined}
|
||||
subtitle={category.id === defaultCategoryId ? 'Default tag' : undefined}
|
||||
leftElement={
|
||||
<View style={[styles.categoryDot, { backgroundColor: category.color }]} />
|
||||
}
|
||||
@@ -123,10 +139,10 @@ export default function SettingsScreen() {
|
||||
>
|
||||
<View style={[styles.addIcon, { backgroundColor: theme.accentSoft }]}>
|
||||
<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>
|
||||
</View>
|
||||
<Text style={[styles.addCategoryText, { color: theme.accent }]}>Add Category</Text>
|
||||
<Text style={[styles.addCategoryText, { color: theme.accent }]}>Add Tag</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Notifications</Text>
|
||||
<ListItem
|
||||
@@ -149,7 +165,7 @@ export default function SettingsScreen() {
|
||||
/>
|
||||
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Tasks</Text>
|
||||
<ListItem
|
||||
title="Default Category"
|
||||
title="Default Tag"
|
||||
subtitle={defaultCategoryLabel}
|
||||
onPress={() => setPicker('category')}
|
||||
showChevron
|
||||
@@ -160,6 +176,18 @@ export default function SettingsScreen() {
|
||||
onPress={() => setPicker('sort')}
|
||||
showChevron
|
||||
/>
|
||||
<ListItem
|
||||
title="Show Completed Tasks"
|
||||
subtitle="Hide or show finished tasks in the Todo list"
|
||||
rightElement={
|
||||
<Switch
|
||||
value={showCompleted}
|
||||
onValueChange={setShowCompleted}
|
||||
thumbColor="#FFFFFF"
|
||||
trackColor={{ false: theme.borderStrong, true: theme.accent }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title="Show Calendar Tasks"
|
||||
subtitle={todoAheadDays === 0 ? 'Only today' : `Up to ${todoAheadDays} days ahead`}
|
||||
@@ -241,9 +269,9 @@ export default function SettingsScreen() {
|
||||
|
||||
<OptionPickerModal
|
||||
visible={picker === 'category'}
|
||||
title="Default Category"
|
||||
options={categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))}
|
||||
selectedValue={defaultCategoryId || categories[0]?.id}
|
||||
title="Default Tag"
|
||||
options={[{ value: '', label: 'None', color: theme.textMuted }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
|
||||
selectedValue={defaultCategoryId}
|
||||
onSelect={handleDefaultCategory}
|
||||
onClose={() => setPicker(null)}
|
||||
/>
|
||||
@@ -298,7 +326,7 @@ export default function SettingsScreen() {
|
||||
<Text style={[styles.accentModalTitle, { color: theme.text }]}>Custom Accent Color</Text>
|
||||
<TouchableOpacity onPress={() => setCustomAccentVisible(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
<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>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import React from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView } from 'react-native';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
import { Header } from '@/components/Header';
|
||||
import { useStats } from '@/hooks/useStats';
|
||||
import { useSettings } from '@/theme';
|
||||
import { useSettings, type ThemeColors } from '@/theme';
|
||||
|
||||
export default function StatsScreen() {
|
||||
const { theme } = useSettings();
|
||||
const stats = useStats();
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
return () => {};
|
||||
}, [])
|
||||
);
|
||||
|
||||
const maxDaily = Math.max(1, ...stats.daily.map((d) => d.count));
|
||||
|
||||
return (
|
||||
@@ -69,7 +76,7 @@ export default function StatsScreen() {
|
||||
</View>
|
||||
|
||||
<View style={[styles.card, { backgroundColor: theme.card }]}>
|
||||
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>By category</Text>
|
||||
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>By tags</Text>
|
||||
{stats.byCategory.length === 0 ? (
|
||||
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No completed tasks yet</Text>
|
||||
) : (
|
||||
@@ -90,7 +97,7 @@ export default function StatsScreen() {
|
||||
) : (
|
||||
stats.byPriority.map((p) => (
|
||||
<View key={p.priority} style={styles.row}>
|
||||
<View style={[styles.dot, { backgroundColor: priorityColor(p.priority) }]} />
|
||||
<View style={[styles.dot, { backgroundColor: priorityColor(theme, p.priority) }]} />
|
||||
<Text style={[styles.rowLabel, { color: theme.text }]}>{p.label}</Text>
|
||||
<Text style={[styles.rowValue, { color: theme.textMuted }]}>{p.count}</Text>
|
||||
</View>
|
||||
@@ -104,16 +111,17 @@ export default function StatsScreen() {
|
||||
|
||||
const BASE_HEIGHT = 90;
|
||||
|
||||
function priorityColor(priority: string): string {
|
||||
function priorityColor(theme: ThemeColors, priority: string): string {
|
||||
switch (priority) {
|
||||
case 'critical':
|
||||
return theme.dangerStrong;
|
||||
case 'high':
|
||||
return '#EF5350';
|
||||
case 'medium':
|
||||
return '#FFA726';
|
||||
return theme.warning;
|
||||
case 'low':
|
||||
return '#66BB6A';
|
||||
case 'medium':
|
||||
return theme.success;
|
||||
default:
|
||||
return '#9E9E9E';
|
||||
return theme.textMuted;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,46 @@ import { UpdateNotifier } from '@/components/UpdateNotifier';
|
||||
import { Linking } from 'react-native';
|
||||
import { useEffect } from 'react';
|
||||
import { requestQuickAddFocus } from '@/utils/quickAddFocus';
|
||||
import { registerForPushNotificationsAsync, setupNotificationChannels, addNotificationResponseReceivedListener, removeNotificationSubscription } from '@/services/pushNotifications';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: true,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
}),
|
||||
});
|
||||
|
||||
function RootNavigator() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
setupNotificationChannels();
|
||||
registerForPushNotificationsAsync().catch(() => {});
|
||||
|
||||
const responseListener = addNotificationResponseReceivedListener((response) => {
|
||||
const data = response.notification.request.content.data as Record<string, any> | undefined;
|
||||
if (!data) return;
|
||||
|
||||
switch (data.type) {
|
||||
case 'task_reminder':
|
||||
case 'task_assignment':
|
||||
if (data.taskId) {
|
||||
router.push({ pathname: '/task-detail', params: { id: data.taskId } });
|
||||
}
|
||||
break;
|
||||
case 'friend_request':
|
||||
if (data.friendshipId) {
|
||||
router.push({ pathname: '/(tabs)/settings' });
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const handleUrl = (url: string | null) => {
|
||||
if (!url || !url.includes('://quick-add')) return;
|
||||
router.replace('/(tabs)');
|
||||
@@ -21,8 +56,10 @@ function RootNavigator() {
|
||||
|
||||
Linking.getInitialURL().then(handleUrl).catch(() => {});
|
||||
const subscription = Linking.addEventListener('url', ({ url }) => handleUrl(url));
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
removeNotificationSubscription(responseListener);
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
@@ -33,6 +70,7 @@ function RootNavigator() {
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="add-task" />
|
||||
<Stack.Screen name="task-detail" />
|
||||
<Stack.Screen name="day-view" />
|
||||
</Stack>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform } from 'react-native';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, Keyboard } from 'react-native';
|
||||
import { Header } from '@/components/Header';
|
||||
import { CategorySelector } from '@/components/CategorySelector';
|
||||
import { TaskNameInput } from '@/components/TaskNameInput';
|
||||
@@ -17,15 +17,17 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useDatabase } from '@/hooks/useDatabase';
|
||||
import { database, collections } from '@/database';
|
||||
import { TaskFormData } from '@/types';
|
||||
import { TaskFormData, tagsToString } from '@/types';
|
||||
import { useSettings } from '@/theme';
|
||||
import { scheduleTaskReminder } from '@/services/notifications';
|
||||
import { ReminderPreference } from '@/types';
|
||||
import { useFriends } from '@/hooks/useFriends';
|
||||
|
||||
const taskSchema = z.object({
|
||||
title: z.string().trim().min(1, 'Task name is required').max(100),
|
||||
description: z.string().max(1000).optional(),
|
||||
categoryId: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
|
||||
dueDate: z.date().nullable().optional(),
|
||||
dueTime: z.string().optional(),
|
||||
@@ -47,12 +49,14 @@ export default function AddTaskScreen() {
|
||||
const { theme } = useSettings();
|
||||
const { date: dateParam } = useLocalSearchParams<{ date?: string }>();
|
||||
const { friends } = useFriends();
|
||||
const { notifications: notificationsEnabled, reminderPreference } = useSettings();
|
||||
const initialCategory = defaultCategoryId || '';
|
||||
const initialDate = useMemo(() => {
|
||||
if (!dateParam) return null;
|
||||
const parsed = new Date(Array.isArray(dateParam) ? dateParam[0] : dateParam);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}, [dateParam]);
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
|
||||
const methods = useForm<TaskFormData>({
|
||||
resolver: zodResolver(taskSchema),
|
||||
@@ -60,6 +64,7 @@ export default function AddTaskScreen() {
|
||||
title: '',
|
||||
description: '',
|
||||
categoryId: initialCategory,
|
||||
tags: initialCategory ? [initialCategory] : [],
|
||||
priority: 'none',
|
||||
dueDate: initialDate,
|
||||
dueTime: '',
|
||||
@@ -83,7 +88,7 @@ export default function AddTaskScreen() {
|
||||
formState: { errors },
|
||||
} = methods;
|
||||
|
||||
const categoryId = watch('categoryId');
|
||||
const tags = watch('tags') ?? [];
|
||||
const priority = watch('priority');
|
||||
const repeat = watch('repeat');
|
||||
const repeatInterval = watch('repeatInterval') ?? 1;
|
||||
@@ -94,20 +99,23 @@ export default function AddTaskScreen() {
|
||||
const assigneeId = watch('assigneeId');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!categoryId && initialCategory) {
|
||||
setValue('categoryId', initialCategory);
|
||||
if (tags.length === 0 && initialCategory) {
|
||||
setValue('tags', [initialCategory]);
|
||||
}
|
||||
}, [initialCategory, categoryId, setValue]);
|
||||
}, [initialCategory, tags, setValue]);
|
||||
|
||||
const onSubmit = async (data: TaskFormData) => {
|
||||
if (!isReady) return;
|
||||
|
||||
Keyboard.dismiss();
|
||||
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
|
||||
|
||||
const now = new Date();
|
||||
const dueDateTimestamp = data.dueDate ? data.dueDate.getTime() : 0;
|
||||
const seriesId = data.repeat !== 'none'
|
||||
? `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`
|
||||
: '';
|
||||
const resolvedCategoryId = data.categoryId || '';
|
||||
const resolvedCategoryId = (data.tags && data.tags[0]) || '';
|
||||
|
||||
let createdTask: any = null;
|
||||
|
||||
@@ -116,6 +124,7 @@ export default function AddTaskScreen() {
|
||||
t.title = data.title.trim();
|
||||
t.description = data.description || '';
|
||||
t.categoryId = resolvedCategoryId;
|
||||
t.tags = tagsToString(data.tags || []);
|
||||
t.priority = data.priority;
|
||||
t.completed = false;
|
||||
t.dueDate = dueDateTimestamp;
|
||||
@@ -157,7 +166,7 @@ export default function AddTaskScreen() {
|
||||
}
|
||||
});
|
||||
|
||||
await scheduleTaskReminder(createdTask);
|
||||
await scheduleTaskReminder(createdTask, notificationsEnabled, reminderPreference);
|
||||
|
||||
router.back();
|
||||
};
|
||||
@@ -176,16 +185,19 @@ export default function AddTaskScreen() {
|
||||
<FormProvider {...methods}>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.keyboardAvoiding}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
|
||||
>
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="interactive"
|
||||
>
|
||||
<CategorySelector
|
||||
value={categoryId}
|
||||
onChange={(value) => setValue('categoryId', value)}
|
||||
value={tags}
|
||||
onChange={(value) => setValue('tags', value)}
|
||||
error={errors.categoryId?.message}
|
||||
/>
|
||||
<Controller
|
||||
@@ -260,7 +272,7 @@ const styles = StyleSheet.create({
|
||||
scrollContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 120,
|
||||
paddingBottom: 100,
|
||||
gap: 20,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, KeyboardAvoidingView, BackHandler } from 'react-native';
|
||||
import { useRouter, useLocalSearchParams, useFocusEffect } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Header } from '@/components/Header';
|
||||
import { useTasksByDate } from '@/hooks/useTasks';
|
||||
import { useSubtasks } from '@/hooks/useSubtasks';
|
||||
import { useTaskModals } from '@/hooks/useTaskModals';
|
||||
import { useSettings } from '@/theme';
|
||||
import { useCategories } from '@/hooks/useDatabase';
|
||||
import { confirmAndToggleTaskComplete, toggleSubtaskComplete } from '@/utils/taskActions';
|
||||
import { QuickAddBar } from '@/components/QuickAddBar';
|
||||
import { SubtaskData } from '@/types';
|
||||
import { format, startOfDay } from 'date-fns';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
import { desaturate, type ThemeColors } from '@/theme';
|
||||
|
||||
function isDayMatch(timestamp: number, day: Date): boolean {
|
||||
const d = new Date(timestamp);
|
||||
return d.getFullYear() === day.getFullYear() && d.getMonth() === day.getMonth() && d.getDate() === day.getDate();
|
||||
}
|
||||
|
||||
export default function DayViewScreen() {
|
||||
const router = useRouter();
|
||||
const { theme } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const styles = useMemo(() => createStyles(theme), [theme]);
|
||||
const categories = useCategories();
|
||||
const { modals, openTaskEdit } = useTaskModals();
|
||||
const { date: dateParam } = useLocalSearchParams<{ date?: string }>();
|
||||
|
||||
const day = useMemo(() => {
|
||||
const parsed = dateParam ? new Date(dateParam) : new Date();
|
||||
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
||||
}, [dateParam]);
|
||||
const dayStart = useMemo(() => startOfDay(day), [day]);
|
||||
|
||||
const { map: subtasksByTask, refresh: refreshSubtasks } = useSubtasks();
|
||||
const { tasks: dayTasks, loading, refresh } = useTasksByDate(day);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => false);
|
||||
refreshSubtasks();
|
||||
refresh();
|
||||
return () => sub.remove();
|
||||
}, [refreshSubtasks, refresh])
|
||||
);
|
||||
|
||||
const subtasksOnDay = useMemo(() => {
|
||||
const map = new Map<string, SubtaskData[]>();
|
||||
for (const [taskId, roots] of subtasksByTask) {
|
||||
const due = roots.filter((s) => s.dueDate && isDayMatch(s.dueDate, day));
|
||||
if (due.length > 0) map.set(taskId, due);
|
||||
}
|
||||
return map;
|
||||
}, [subtasksByTask, day]);
|
||||
|
||||
const handleToggleComplete = useCallback(async (taskId: string) => {
|
||||
await confirmAndToggleTaskComplete(taskId, () => {
|
||||
refresh();
|
||||
refreshSubtasks();
|
||||
});
|
||||
}, [refresh, refreshSubtasks]);
|
||||
|
||||
const handleToggleSubtask = useCallback(async (subtaskId: string) => {
|
||||
await toggleSubtaskComplete(subtaskId);
|
||||
refreshSubtasks();
|
||||
}, [refreshSubtasks]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: theme.background, paddingBottom: insets.bottom }]}>
|
||||
<Header title={format(day, 'EEEE, MMM d')} showLogo={false} />
|
||||
<KeyboardAvoidingView style={styles.kbAvoid} behavior="padding">
|
||||
<ScrollView style={styles.scrollBody} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||
{loading ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>Loading...</Text>
|
||||
</View>
|
||||
) : dayTasks.length === 0 && subtasksOnDay.size === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No tasks on this day</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{dayTasks.map((task) => {
|
||||
const subs = subtasksOnDay.get(task.id) ?? [];
|
||||
const openCount = subs.filter((s) => !s.completed).length;
|
||||
const cat = categories.find((c) => c.id === task.categoryId);
|
||||
return (
|
||||
<View key={task.id} style={[styles.eventCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||
<View style={styles.eventRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.checkCircle}
|
||||
onPress={() => handleToggleComplete(task.id)}
|
||||
activeOpacity={0.7}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityState={{ checked: task.completed }}
|
||||
>
|
||||
<Svg width={30} height={30} viewBox="0 0 24 24">
|
||||
{task.completed ? (
|
||||
<>
|
||||
<Circle cx={12} cy={12} r={10} fill={theme.accent} />
|
||||
<Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</>
|
||||
) : (
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2.5} fill="none" />
|
||||
)}
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.eventTouch}
|
||||
onPress={() => router.push({ pathname: '/task-detail', params: { id: task.id } })}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
style={[styles.eventTitle, { color: theme.text }, task.completed && styles.eventCompleted]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{task.title}
|
||||
</Text>
|
||||
{openCount > 0 && (
|
||||
<Text style={[styles.eventSub, { color: theme.textMuted }]} numberOfLines={1}>
|
||||
{openCount} open subtask{openCount === 1 ? '' : 's'}
|
||||
</Text>
|
||||
)}
|
||||
{!task.completed && (
|
||||
<View style={styles.metaRow}>
|
||||
{cat && (
|
||||
<View style={[styles.tagChip, { backgroundColor: desaturate(cat.color, 0.3) }]}>
|
||||
<Text style={styles.tagText} numberOfLines={1}>{cat.name}</Text>
|
||||
</View>
|
||||
)}
|
||||
{task.dueTime ? (
|
||||
<Text style={[styles.timeText, { color: theme.textSecondary }]}>{task.dueTime}{task.endTime ? `–${task.endTime}` : ''}</Text>
|
||||
) : task.dueDate ? (
|
||||
<Text style={[styles.timeText, { color: theme.textSecondary }]}>{format(task.dueDate, 'HH:mm')}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.menuButton}
|
||||
onPress={() => openTaskEdit(task.id)}
|
||||
activeOpacity={0.7}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Edit ${task.title}`}
|
||||
>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M9 6l6 6-6 6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{Array.from(subtasksOnDay.entries()).flatMap(([taskId, subs]) => {
|
||||
const parent = dayTasks.find((t) => t.id === taskId);
|
||||
if (parent) return [];
|
||||
return subs.map((sub) => (
|
||||
<View key={sub.id} style={[styles.eventCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||
<View style={styles.eventRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.checkCircle}
|
||||
onPress={() => handleToggleSubtask(sub.id)}
|
||||
activeOpacity={0.7}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityState={{ checked: sub.completed }}
|
||||
>
|
||||
<Svg width={30} height={30} viewBox="0 0 24 24">
|
||||
{sub.completed ? (
|
||||
<>
|
||||
<Circle cx={12} cy={12} r={10} fill={theme.accent} />
|
||||
<Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</>
|
||||
) : (
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2.5} fill="none" />
|
||||
)}
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.eventTouch}
|
||||
onPress={() => router.push({ pathname: '/subtask-detail', params: { id: sub.id } })}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
style={[styles.eventTitle, { color: theme.text }, sub.completed && styles.eventCompleted]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{sub.title}
|
||||
</Text>
|
||||
{sub.dueTime ? (
|
||||
<Text style={[styles.timeText, { color: theme.textSecondary }]}>{sub.dueTime}{sub.endTime ? `–${sub.endTime}` : ''}</Text>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
));
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
<QuickAddBar dueDate={dayStart.getTime()} placeholder={`Add event for ${format(day, 'MMM d')}`} />
|
||||
</KeyboardAvoidingView>
|
||||
{modals(() => {})}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const createStyles = (theme: ThemeColors) => StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
kbAvoid: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollBody: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingTop: 12,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 64,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
eventCard: {
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 8,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
eventRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
checkCircle: {
|
||||
width: 30,
|
||||
marginRight: 12,
|
||||
},
|
||||
eventTouch: {
|
||||
flex: 1,
|
||||
},
|
||||
eventTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
},
|
||||
eventCompleted: {
|
||||
textDecorationLine: 'line-through',
|
||||
color: theme.textMuted,
|
||||
},
|
||||
eventSub: {
|
||||
fontSize: 13,
|
||||
marginTop: 2,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginTop: 4,
|
||||
},
|
||||
tagChip: {
|
||||
paddingHorizontal: 7,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 6,
|
||||
maxWidth: 140,
|
||||
},
|
||||
tagText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
},
|
||||
timeText: {
|
||||
fontSize: 12.5,
|
||||
fontWeight: '500',
|
||||
},
|
||||
menuButton: {
|
||||
padding: 4,
|
||||
marginLeft: 6,
|
||||
},
|
||||
});
|
||||
@@ -15,15 +15,17 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useDatabase } from '@/hooks/useDatabase';
|
||||
import { collections } from '@/database';
|
||||
import { SubtaskFormData, Reminder, parseReminders, toRemindersString } from '@/types';
|
||||
import { SubtaskFormData, Reminder, parseReminders, toRemindersString, parseTaskTags, tagsToString } from '@/types';
|
||||
import { useSettings } from '@/theme';
|
||||
import { updateSubtask, deleteSubtask } from '@/utils/taskActions';
|
||||
import { CategorySelector } from '@/components/CategorySelector';
|
||||
import { useFriends } from '@/hooks/useFriends';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
const subtaskSchema = z.object({
|
||||
title: z.string().trim().min(1, 'Subtask name is required').max(100),
|
||||
description: z.string().max(1000).optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
|
||||
dueDate: z.date().nullable().optional(),
|
||||
dueTime: z.string().optional(),
|
||||
@@ -51,6 +53,7 @@ export default function SubtaskDetailScreen() {
|
||||
defaultValues: {
|
||||
title: '',
|
||||
description: '',
|
||||
tags: [],
|
||||
priority: 'none',
|
||||
dueDate: null,
|
||||
dueTime: '',
|
||||
@@ -68,6 +71,7 @@ export default function SubtaskDetailScreen() {
|
||||
const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
|
||||
|
||||
const priority = watch('priority');
|
||||
const tags = watch('tags') ?? [];
|
||||
const repeat = watch('repeat');
|
||||
const repeatInterval = watch('repeatInterval') ?? 1;
|
||||
const repeatDays = watch('repeatDays') ?? [];
|
||||
@@ -80,13 +84,13 @@ export default function SubtaskDetailScreen() {
|
||||
if (!id || !isReady) return;
|
||||
let mounted = true;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const subtask = await collections.subtasks.find(id);
|
||||
const subscription = collections.subtasks.findAndObserve(id).subscribe({
|
||||
next: (subtask: any) => {
|
||||
if (!mounted) return;
|
||||
reset({
|
||||
title: subtask.title,
|
||||
description: subtask.description,
|
||||
tags: parseTaskTags(subtask.tags, subtask.categoryId),
|
||||
priority: subtask.priority,
|
||||
dueDate: subtask.dueDate ? new Date(subtask.dueDate) : null,
|
||||
dueTime: subtask.dueTime,
|
||||
@@ -94,18 +98,25 @@ export default function SubtaskDetailScreen() {
|
||||
allDay: subtask.allDay ?? false,
|
||||
repeat: subtask.repeat,
|
||||
repeatInterval: subtask.repeatInterval || 1,
|
||||
repeatDays: (subtask.repeatDays || '').split(',').map(Number).filter((d) => !Number.isNaN(d)),
|
||||
repeatDays: ((subtask.repeatDays || '') as string).split(',').map(Number).filter((d) => !Number.isNaN(d)),
|
||||
reminder: (subtask.reminder || 'none') as Reminder,
|
||||
reminders: subtask.reminders || '',
|
||||
assigneeId: subtask.assigneeId ?? null,
|
||||
});
|
||||
setLoaded(true);
|
||||
} catch {
|
||||
},
|
||||
error: () => {
|
||||
if (mounted) setNotFound(true);
|
||||
}
|
||||
})();
|
||||
},
|
||||
complete: () => {
|
||||
if (mounted) setNotFound(true);
|
||||
},
|
||||
});
|
||||
|
||||
return () => { mounted = false; };
|
||||
return () => {
|
||||
mounted = false;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [id, isReady, reset]);
|
||||
|
||||
const onSubmit = async (data: SubtaskFormData) => {
|
||||
@@ -114,6 +125,8 @@ export default function SubtaskDetailScreen() {
|
||||
await updateSubtask(id, {
|
||||
title: data.title,
|
||||
description: data.description || '',
|
||||
categoryId: (data.tags && data.tags[0]) || '',
|
||||
tags: tagsToString(data.tags || []),
|
||||
priority: data.priority,
|
||||
dueDate: data.dueDate ? data.dueDate.getTime() : 0,
|
||||
dueTime: data.dueTime || '',
|
||||
@@ -160,7 +173,7 @@ export default function SubtaskDetailScreen() {
|
||||
rightAction={
|
||||
<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">
|
||||
<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>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
@@ -187,6 +200,10 @@ export default function SubtaskDetailScreen() {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<CategorySelector
|
||||
value={tags}
|
||||
onChange={(value) => setValue('tags', value)}
|
||||
/>
|
||||
<PrioritySelector
|
||||
value={priority}
|
||||
onChange={(value) => setValue('priority', value)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, TouchableOpacity } from 'react-native';
|
||||
import React, { useRef } from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, TouchableOpacity, Keyboard } from 'react-native';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { Header } from '@/components/Header';
|
||||
import { CategorySelector } from '@/components/CategorySelector';
|
||||
@@ -19,10 +19,12 @@ import { z } from 'zod';
|
||||
import { useDatabase } from '@/hooks/useDatabase';
|
||||
import { database, collections } from '@/database';
|
||||
import { Q } from '@nozbe/watermelondb';
|
||||
import { TaskFormData } from '@/types';
|
||||
import { TaskFormData, parseTaskTags, tagsToString } from '@/types';
|
||||
import { useSettings } from '@/theme';
|
||||
import { deleteTaskOccurrences } from '@/utils/taskActions';
|
||||
import { scheduleTaskReminder } from '@/services/notifications';
|
||||
import { ReminderPreference } from '@/types';
|
||||
import { recordTombstonesInBatch } from '@/database/tombstones';
|
||||
import { useFriends } from '@/hooks/useFriends';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
|
||||
@@ -30,6 +32,7 @@ const taskSchema = z.object({
|
||||
title: z.string().trim().min(1, 'Task name is required').max(100),
|
||||
description: z.string().max(1000).optional(),
|
||||
categoryId: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
|
||||
dueDate: z.date().nullable().optional(),
|
||||
dueTime: z.string().optional(),
|
||||
@@ -58,8 +61,8 @@ function CollapsibleSection({ title, children, defaultExpanded = false, icon }:
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M6 9l6 6 6-6"
|
||||
stroke={theme.textMuted}
|
||||
strokeWidth={2}
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
@@ -77,8 +80,10 @@ export default function TaskDetailScreen() {
|
||||
const router = useRouter();
|
||||
const { theme } = useSettings();
|
||||
const { friends } = useFriends();
|
||||
const { notifications: notificationsEnabled, reminderPreference } = useSettings();
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const [notFound, setNotFound] = React.useState(false);
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
|
||||
const methods = useForm<TaskFormData>({
|
||||
resolver: zodResolver(taskSchema),
|
||||
@@ -86,6 +91,7 @@ export default function TaskDetailScreen() {
|
||||
title: '',
|
||||
description: '',
|
||||
categoryId: '',
|
||||
tags: [],
|
||||
priority: 'none',
|
||||
dueDate: null,
|
||||
dueTime: '',
|
||||
@@ -102,7 +108,7 @@ export default function TaskDetailScreen() {
|
||||
|
||||
const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
|
||||
|
||||
const categoryId = watch('categoryId');
|
||||
const tags = watch('tags') ?? [];
|
||||
const priority = watch('priority');
|
||||
const repeat = watch('repeat');
|
||||
const repeatInterval = watch('repeatInterval') ?? 1;
|
||||
@@ -123,10 +129,13 @@ export default function TaskDetailScreen() {
|
||||
const subtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch();
|
||||
|
||||
if (!mounted) return;
|
||||
const remindersValue = task.reminders || (task.reminder && task.reminder !== 'none' ? task.reminder : '');
|
||||
|
||||
reset({
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
categoryId: task.categoryId,
|
||||
tags: parseTaskTags(task.tags, task.categoryId),
|
||||
priority: task.priority,
|
||||
dueDate: task.dueDate ? new Date(task.dueDate) : null,
|
||||
dueTime: task.dueTime,
|
||||
@@ -136,6 +145,7 @@ export default function TaskDetailScreen() {
|
||||
repeatInterval: task.repeatInterval || 1,
|
||||
repeatDays: (task.repeatDays || '').split(',').map(Number).filter((d) => !Number.isNaN(d)),
|
||||
reminder: (task.reminder || 'none') as TaskFormData['reminder'],
|
||||
reminders: remindersValue,
|
||||
assigneeId: task.assigneeId ?? null,
|
||||
subtasks: subtasks.map((s) => ({ title: s.title, _key: s.id })),
|
||||
});
|
||||
@@ -151,6 +161,9 @@ export default function TaskDetailScreen() {
|
||||
const onSubmit = async (data: TaskFormData) => {
|
||||
if (!id || !isReady) return;
|
||||
|
||||
Keyboard.dismiss();
|
||||
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
|
||||
|
||||
const now = new Date();
|
||||
const dueDateTimestamp = data.dueDate ? data.dueDate.getTime() : 0;
|
||||
|
||||
@@ -160,14 +173,12 @@ export default function TaskDetailScreen() {
|
||||
const task = await collections.tasks.find(id);
|
||||
savedTask = task;
|
||||
const existingSubtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch();
|
||||
for (const subtask of existingSubtasks) {
|
||||
await subtask.destroyPermanently();
|
||||
}
|
||||
|
||||
await task.update((t) => {
|
||||
t.title = data.title.trim();
|
||||
t.description = data.description || '';
|
||||
t.categoryId = data.categoryId || task.categoryId || '';
|
||||
t.categoryId = (data.tags && data.tags[0]) || task.categoryId || '';
|
||||
t.tags = tagsToString(data.tags || []);
|
||||
t.priority = data.priority;
|
||||
t.dueDate = dueDateTimestamp;
|
||||
t.dueTime = data.dueTime || '';
|
||||
@@ -177,6 +188,7 @@ export default function TaskDetailScreen() {
|
||||
t.repeatInterval = data.repeatInterval || 1;
|
||||
t.repeatDays = (data.repeatDays || []).join(',');
|
||||
t.reminder = data.reminder || 'none';
|
||||
t.reminders = data.reminders || '';
|
||||
t.assigneeId = data.assigneeId ?? null;
|
||||
if (data.repeat !== 'none' && !t.seriesId) {
|
||||
t.seriesId = `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`;
|
||||
@@ -184,36 +196,69 @@ export default function TaskDetailScreen() {
|
||||
t.updatedAt = now;
|
||||
});
|
||||
|
||||
if (data.subtasks && data.subtasks.length > 0) {
|
||||
for (let i = 0; i < data.subtasks.length; i++) {
|
||||
const subtask = data.subtasks[i];
|
||||
if (subtask.title.trim()) {
|
||||
await collections.subtasks.create((s) => {
|
||||
s.taskId = task.id;
|
||||
s.title = subtask.title.trim();
|
||||
s.description = '';
|
||||
s.priority = 'none';
|
||||
s.completed = false;
|
||||
s.dueDate = 0;
|
||||
s.dueTime = '';
|
||||
s.endTime = '';
|
||||
s.allDay = false;
|
||||
s.repeat = 'none';
|
||||
s.repeatInterval = 1;
|
||||
s.repeatDays = '';
|
||||
s.seriesId = '';
|
||||
s.reminder = 'none';
|
||||
s.assigneeId = null;
|
||||
s.order = i;
|
||||
s.createdAt = now;
|
||||
const keptIds = new Set<string>();
|
||||
let order = 0;
|
||||
for (const formItem of data.subtasks ?? []) {
|
||||
const trimmed = formItem.title.trim();
|
||||
if (!trimmed) continue;
|
||||
if (formItem._key) {
|
||||
const existing = existingSubtasks.find((s) => s.id === formItem._key && !s.parentSubtaskId);
|
||||
if (existing) {
|
||||
keptIds.add(existing.id);
|
||||
await existing.update((s) => {
|
||||
s.title = trimmed;
|
||||
s.order = order;
|
||||
s.updatedAt = now;
|
||||
});
|
||||
order++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
await collections.subtasks.create((s) => {
|
||||
s.taskId = task.id;
|
||||
s.categoryId = task.categoryId || '';
|
||||
s.tags = task.tags || '';
|
||||
s.title = trimmed;
|
||||
s.description = '';
|
||||
s.priority = 'none';
|
||||
s.completed = false;
|
||||
s.dueDate = 0;
|
||||
s.dueTime = '';
|
||||
s.endTime = '';
|
||||
s.allDay = false;
|
||||
s.repeat = 'none';
|
||||
s.repeatInterval = 1;
|
||||
s.repeatDays = '';
|
||||
s.seriesId = '';
|
||||
s.reminder = 'none';
|
||||
s.reminders = '';
|
||||
s.assigneeId = null;
|
||||
s.order = order++;
|
||||
s.createdAt = now;
|
||||
s.updatedAt = now;
|
||||
});
|
||||
}
|
||||
|
||||
const removedIds: string[] = [];
|
||||
for (const existing of existingSubtasks) {
|
||||
if (existing.parentSubtaskId) continue;
|
||||
if (keptIds.has(existing.id)) continue;
|
||||
removedIds.push(existing.id);
|
||||
const children = await collections.subtasks.query(Q.where('parent_subtask_id', existing.id)).fetch();
|
||||
for (const child of children) {
|
||||
await child.update((c) => {
|
||||
c.parentSubtaskId = null;
|
||||
c.updatedAt = now;
|
||||
});
|
||||
}
|
||||
await existing.destroyPermanently();
|
||||
}
|
||||
if (removedIds.length > 0) {
|
||||
await recordTombstonesInBatch('subtasks', removedIds);
|
||||
}
|
||||
});
|
||||
|
||||
await scheduleTaskReminder(savedTask);
|
||||
await scheduleTaskReminder(savedTask, notificationsEnabled, reminderPreference);
|
||||
|
||||
router.back();
|
||||
};
|
||||
@@ -245,7 +290,7 @@ export default function TaskDetailScreen() {
|
||||
rightAction={
|
||||
<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">
|
||||
<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>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
@@ -253,16 +298,19 @@ export default function TaskDetailScreen() {
|
||||
<FormProvider {...methods}>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.keyboardAvoiding}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
|
||||
>
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="interactive"
|
||||
>
|
||||
<CategorySelector
|
||||
value={categoryId}
|
||||
onChange={(value) => setValue('categoryId', value)}
|
||||
value={tags}
|
||||
onChange={(value) => setValue('tags', value)}
|
||||
error={errors.categoryId?.message}
|
||||
/>
|
||||
<Controller
|
||||
@@ -285,8 +333,8 @@ export default function TaskDetailScreen() {
|
||||
|
||||
<CollapsibleSection title="Date & Time" icon={
|
||||
<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" />
|
||||
<Path d="M12 6v6l4 2" stroke={theme.accent} strokeWidth={1.5} strokeLinecap="round" />
|
||||
<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.8} strokeLinecap="round" />
|
||||
</Svg>
|
||||
} defaultExpanded={!!dueDate}>
|
||||
<DateTimePickerComponent control={control} />
|
||||
@@ -297,7 +345,7 @@ export default function TaskDetailScreen() {
|
||||
<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"
|
||||
stroke={theme.accent}
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
@@ -321,7 +369,7 @@ export default function TaskDetailScreen() {
|
||||
<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"
|
||||
stroke={theme.accent}
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
@@ -337,8 +385,8 @@ export default function TaskDetailScreen() {
|
||||
|
||||
<CollapsibleSection title="Assignee" icon={
|
||||
<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" />
|
||||
<Path d="M12 10v6M12 19v1" stroke={theme.accent} strokeWidth={1.5} strokeLinecap="round" />
|
||||
<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.8} strokeLinecap="round" />
|
||||
</Svg>
|
||||
} defaultExpanded={!!assigneeId}>
|
||||
<AssigneeSelector
|
||||
@@ -350,7 +398,7 @@ export default function TaskDetailScreen() {
|
||||
|
||||
<CollapsibleSection title="Description" icon={
|
||||
<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>
|
||||
} defaultExpanded={!!methods.getValues('description')}>
|
||||
<Controller
|
||||
@@ -397,7 +445,7 @@ const styles = StyleSheet.create({
|
||||
scrollContent: {
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 80,
|
||||
paddingBottom: 60,
|
||||
gap: 12,
|
||||
},
|
||||
section: {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
@@ -0,0 +1,46 @@
|
||||
module.exports = {
|
||||
appId: 'com.carryyourlive.desktop',
|
||||
productName: 'Carry Your Live',
|
||||
copyright: 'Copyright © 2026',
|
||||
|
||||
directories: {
|
||||
output: 'desktop-build',
|
||||
buildResources: 'build',
|
||||
},
|
||||
|
||||
// Override the entry point in the packaged app (repo keeps expo-router/entry
|
||||
// as `main` so `expo start` keeps working).
|
||||
extraMetadata: {
|
||||
main: 'electron/main.js',
|
||||
},
|
||||
|
||||
// The Electron shell needs zero runtime dependencies: main/preload use Node
|
||||
// built-ins only, and the renderer is fully bundled by Metro into dist/.
|
||||
// Returning false tells electron-builder that node_modules are handled
|
||||
// externally — i.e. don't bundle production deps (expo, react-native, ...)
|
||||
// into the package. This keeps the deb ~100x smaller.
|
||||
// NOTE: do not set npmRebuild:false here — installAppDependencies()
|
||||
// short-circuits on it *before* evaluating beforeBuild, which would
|
||||
// re-enable bundling of node_modules.
|
||||
beforeBuild: async () => false,
|
||||
// Sanity check validates the ORIGINAL package.json `main` (expo-router/entry)
|
||||
// against the asar, which no longer exists once deps are excluded.
|
||||
// The real entry point is set via extraMetadata.main above.
|
||||
disableSanityCheckAsar: true,
|
||||
|
||||
files: ['dist/**/*', 'electron/**/*', 'assets/icon.png', 'package.json'],
|
||||
|
||||
// Icons are generated from build/icon.png (1024x1024) for each platform.
|
||||
mac: {
|
||||
category: 'public.app-category.productivity',
|
||||
target: ['dmg', 'zip'],
|
||||
},
|
||||
win: {
|
||||
target: ['nsis'],
|
||||
},
|
||||
linux: {
|
||||
category: 'Productivity',
|
||||
maintainer: 'carry-your-live',
|
||||
target: ['deb', 'AppImage'],
|
||||
},
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
/* global __dirname */
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const { app, BrowserWindow, ipcMain } = require('electron')
|
||||
const path = require('path')
|
||||
|
||||
const DEV_SERVER_URL = process.env.ELECTRON_START_URL
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
@@ -11,24 +13,39 @@ function createWindow() {
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
},
|
||||
icon: path.join(__dirname, '../assets/icon.png'),
|
||||
titleBarStyle: 'default',
|
||||
show: false,
|
||||
})
|
||||
|
||||
win.loadFile(path.join(__dirname, '../dist/index.html'))
|
||||
if (DEV_SERVER_URL) {
|
||||
win.loadURL(DEV_SERVER_URL)
|
||||
} else {
|
||||
win.loadFile(path.join(__dirname, '../dist/index.html'))
|
||||
}
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
win.show()
|
||||
})
|
||||
|
||||
win.on('closed', () => {
|
||||
app.quit()
|
||||
// In dev, reload instead of quitting when the renderer crashes / navigates away
|
||||
win.webContents.on('didFailLoad', (_event, code, desc) => {
|
||||
console.error(`[electron] failed to load: ${code} ${desc}`)
|
||||
})
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
ipcMain.handle('app:get-info', () => ({
|
||||
version: app.getVersion(),
|
||||
name: app.getName(),
|
||||
platform: process.platform,
|
||||
}))
|
||||
|
||||
app.whenReady().then(createWindow)
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
@@ -41,4 +58,4 @@ app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron')
|
||||
|
||||
contextBridge.exposeInMainWorld('electron', {
|
||||
isElectron: true,
|
||||
getAppInfo: () => ipcRenderer.invoke('app:get-info'),
|
||||
})
|
||||
@@ -1,21 +0,0 @@
|
||||
server {
|
||||
listen 8081;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
Generated
+2794
-68
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
"name": "carry-your-live",
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"homepage": "https://gitea.techmos.org/tech08mag/carry-your-live",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@nozbe/watermelondb": "^0.28.1-0",
|
||||
@@ -9,19 +10,23 @@
|
||||
"@react-native-community/datetimepicker": "9.1.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"expo": "~57.0.10",
|
||||
"expo-blur": "~57.0.2",
|
||||
"expo-constants": "~57.0.9",
|
||||
"expo-dev-client": "~57.0.1",
|
||||
"expo-device": "^57.0.1",
|
||||
"expo-linking": "~57.0.5",
|
||||
"expo-notifications": "~57.0.8",
|
||||
"expo-router": "~57.0.10",
|
||||
"expo-sqlite": "~57.0.1",
|
||||
"expo-status-bar": "~57.0.1",
|
||||
"expo-system-ui": "~57.0.2",
|
||||
"expo-updates": "~57.0.12",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-hook-form": "^7.51.5",
|
||||
"react-native": "0.86.2",
|
||||
"react-native-gesture-handler": "2.32.0",
|
||||
"react-native-keyboard-controller": "1.21.9",
|
||||
"react-native-safe-area-context": "5.7.0",
|
||||
"react-native-screens": "4.26.0",
|
||||
"react-native-svg": "^15.15.4",
|
||||
@@ -31,6 +36,9 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@types/react": "~19.2.2",
|
||||
"babel-preset-expo": "~57.0.0",
|
||||
"electron": "^41.7.1",
|
||||
"electron-builder": "^26.15.3",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-expo": "~57.0.1",
|
||||
"patch-package": "^8.0.1",
|
||||
@@ -45,6 +53,11 @@
|
||||
"lint": "expo lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,json,md}\"",
|
||||
"export:web": "expo export --platform web",
|
||||
"desktop": "electron electron/main.js",
|
||||
"desktop:dev": "ELECTRON_START_URL=http://localhost:8081 electron electron/main.js",
|
||||
"desktop:dist": "npm run export:web && electron-builder --config electron-builder.config.js",
|
||||
"desktop:dir": "npm run export:web && electron-builder --dir --config electron-builder.config.js",
|
||||
"build:ios": "eas build --platform ios",
|
||||
"build:android": "eas build --platform android",
|
||||
"build:web": "eas build --platform web",
|
||||
|
||||
@@ -60,8 +60,11 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
||||
await sendFriendRequest(username);
|
||||
onChange(friendId);
|
||||
setShowModal(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to add friend:', err);
|
||||
} catch (err: any) {
|
||||
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)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[styles.selectorIcon, { color: theme.accent }]}>👤</Text>
|
||||
<Text style={[styles.selectorIcon, { color: theme.accentStrong }]}>👤</Text>
|
||||
<View style={styles.selectorContent}>
|
||||
<Text style={[styles.selectorLabel, { color: theme.textMuted }]}>Assignee</Text>
|
||||
<Text
|
||||
@@ -128,7 +131,7 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
||||
</View>
|
||||
<Text style={[styles.optionText, { color: theme.text }]}>Unassigned</Text>
|
||||
{selectedFriend && (
|
||||
<Text style={[styles.checkmark, { color: theme.accent }]}>✓</Text>
|
||||
<Text style={[styles.checkmark, { color: theme.accentStrong }]}>✓</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -158,7 +161,7 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
|
||||
</View>
|
||||
<Text style={[styles.optionText, { color: theme.text }]}>{item.username}</Text>
|
||||
{value === item.id && (
|
||||
<Text style={[styles.checkmark, { color: theme.accent }]}>✓</Text>
|
||||
<Text style={[styles.checkmark, { color: theme.accentStrong }]}>✓</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView, KeyboardAvoidingView } from 'react-native';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView, KeyboardAvoidingView, Platform, Pressable, useWindowDimensions } from 'react-native';
|
||||
import Category from '@/models/Category';
|
||||
import { useSettings } from '@/theme';
|
||||
import { CATEGORY_COLORS } from '@/constants';
|
||||
import { createCategory, updateCategory, deleteCategory } from '@/utils/categoryActions';
|
||||
import { confirmAction } from '@/utils/confirm';
|
||||
import ColorPickerInput from '@/components/ColorPickerInput';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
|
||||
interface CategoryEditorModalProps {
|
||||
visible: boolean;
|
||||
@@ -20,6 +20,7 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
||||
const [name, setName] = useState('');
|
||||
const [color, setColor] = useState(CATEGORY_COLORS[0]);
|
||||
const [prevVisible, setPrevVisible] = useState(visible);
|
||||
const { height: screenHeight } = useWindowDimensions();
|
||||
|
||||
if (prevVisible !== visible) {
|
||||
setPrevVisible(visible);
|
||||
@@ -45,8 +46,8 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
||||
const handleDelete = () => {
|
||||
if (!category || !canDelete) return;
|
||||
confirmAction({
|
||||
title: 'Delete Category',
|
||||
message: `Tasks in "${category.name}" will be moved to another category.`,
|
||||
title: 'Delete Tag',
|
||||
message: `Tasks in "${category.name}" will be moved to another tag.`,
|
||||
confirmLabel: 'Delete',
|
||||
destructive: true,
|
||||
onConfirm: async () => {
|
||||
@@ -59,96 +60,114 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.overlay}
|
||||
behavior="padding"
|
||||
style={styles.fill}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={0}
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.overlay} keyboardShouldPersistTaps="handled">
|
||||
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: theme.text }]}>
|
||||
{category ? 'Edit Category' : 'New Category'}
|
||||
</Text>
|
||||
<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.textMuted} strokeWidth={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}
|
||||
<Pressable
|
||||
style={styles.overlay}
|
||||
onPress={onClose}
|
||||
accessibilityRole="none"
|
||||
>
|
||||
<Pressable
|
||||
style={[styles.sheet, { backgroundColor: theme.sheetBg }]}
|
||||
onPress={() => {}}
|
||||
>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
<Text style={[styles.saveButtonText, { color: theme.accentText }]}>Save</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: theme.text }]}>
|
||||
{category ? 'Edit Tag' : 'New Tag'}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} accessibilityRole="button" accessibilityLabel="Close tag editor">
|
||||
<Svg width={22} height={22} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={10} fill={theme.cardAlt} />
|
||||
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.5} 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="Tag 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>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
fill: {
|
||||
flex: 1,
|
||||
},
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||
backgroundColor: 'rgba(0,0,0,0.55)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
@@ -156,9 +175,18 @@ const styles = StyleSheet.create({
|
||||
sheet: {
|
||||
width: '100%',
|
||||
maxWidth: 380,
|
||||
maxHeight: '90%',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
gap: 8,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 16,
|
||||
elevation: 12,
|
||||
},
|
||||
scrollContent: {
|
||||
minHeight: 200,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
@@ -172,6 +200,8 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
closeButton: {
|
||||
padding: 4,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
label: {
|
||||
fontSize: 13,
|
||||
|
||||
@@ -1,21 +1,42 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native';
|
||||
import React, { useState } from 'react';
|
||||
import { Text, StyleSheet, ScrollView, TouchableOpacity, Animated } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useUniqueCategories } from '@/hooks/useDatabase';
|
||||
import { useSettings, ThemeColors } from '@/theme';
|
||||
import Category from '@/models/Category';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
|
||||
interface CategoryFilterProps {
|
||||
selected: string;
|
||||
onSelect: (categoryId: string) => void;
|
||||
selected: string[];
|
||||
onSelect: (categoryIds: string[]) => void;
|
||||
}
|
||||
|
||||
export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
||||
const categories = useUniqueCategories();
|
||||
const { theme } = useSettings();
|
||||
const router = useRouter();
|
||||
|
||||
if (categories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const selectedSet = new Set(selected);
|
||||
const isAnythingSelected = selected.length > 0;
|
||||
const [scaleAnim] = useState(new Animated.Value(1));
|
||||
|
||||
const toggle = (id: string) => {
|
||||
const next = new Set(selectedSet);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
onSelect(Array.from(next));
|
||||
};
|
||||
|
||||
const animatePress = (id: string, isPressing: boolean) => {
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: isPressing ? 0.92 : 1,
|
||||
friction: 8,
|
||||
tension: 100,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
@@ -27,10 +48,14 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
||||
<CategoryButton
|
||||
id="all"
|
||||
name="All"
|
||||
color="#9E9E9E"
|
||||
selected={selected === 'all'}
|
||||
onPress={() => onSelect('all')}
|
||||
color={theme.textMuted}
|
||||
selected={!isAnythingSelected}
|
||||
onPress={() => onSelect([])}
|
||||
onPressIn={() => animatePress('all', true)}
|
||||
onPressOut={() => animatePress('all', false)}
|
||||
theme={theme}
|
||||
isAllCategory={true}
|
||||
scaleAnim={scaleAnim}
|
||||
/>
|
||||
{categories.map((category) => (
|
||||
<CategoryButton
|
||||
@@ -38,11 +63,30 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
|
||||
id={category.id}
|
||||
name={category.name}
|
||||
color={category.color}
|
||||
selected={selected === category.id}
|
||||
onPress={() => onSelect(category.id)}
|
||||
selected={selectedSet.has(category.id)}
|
||||
onPress={() => toggle(category.id)}
|
||||
onPressIn={() => animatePress(category.id, true)}
|
||||
onPressOut={() => animatePress(category.id, false)}
|
||||
theme={theme}
|
||||
scaleAnim={scaleAnim}
|
||||
/>
|
||||
))}
|
||||
<TouchableOpacity
|
||||
style={[styles.manageButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
|
||||
onPress={() => router.push('/settings')}
|
||||
activeOpacity={0.7}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Manage tags"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -53,73 +97,94 @@ interface CategoryButtonProps {
|
||||
color: string;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
onPressIn: () => void;
|
||||
onPressOut: () => void;
|
||||
theme: ThemeColors;
|
||||
isAllCategory?: boolean;
|
||||
scaleAnim: Animated.Value;
|
||||
}
|
||||
|
||||
function CategoryButton({ name, color, selected, onPress, theme }: CategoryButtonProps) {
|
||||
function CategoryButton({ color, selected, onPress, onPressIn, onPressOut, theme, isAllCategory, scaleAnim }: CategoryButtonProps) {
|
||||
const animatedStyle = {
|
||||
transform: [{ scale: scaleAnim }],
|
||||
shadowColor: selected ? theme.accent : '#000',
|
||||
shadowOffset: { width: 0, height: selected ? 4 : 2 },
|
||||
shadowOpacity: selected ? 0.25 : 0.1,
|
||||
shadowRadius: selected ? 8 : 4,
|
||||
elevation: selected ? 6 : 2,
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
||||
selected && { borderColor: theme.accent, backgroundColor: theme.accentSoft },
|
||||
{ backgroundColor: color },
|
||||
selected && styles.buttonSelected,
|
||||
animatedStyle,
|
||||
]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.8}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
activeOpacity={1}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 8, right: 8 }}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.colorDot,
|
||||
{ backgroundColor: color },
|
||||
selected && styles.colorDotSelected,
|
||||
]}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.buttonText,
|
||||
{ color: theme.textSecondary },
|
||||
selected && { color: theme.accent, fontWeight: '600' },
|
||||
]}>
|
||||
{name}
|
||||
</Text>
|
||||
{isAllCategory ? (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={9} stroke="#FFFFFF" strokeWidth={2.5} fill="none" opacity={0.9} />
|
||||
<Path d="M6 12l4 4 8-8" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" opacity={0.9} />
|
||||
</Svg>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollView: {
|
||||
paddingVertical: 0,
|
||||
marginBottom: 0,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
container: {
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
gap: 6,
|
||||
alignItems: 'flex-start',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
button: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
borderWidth: 2,
|
||||
borderColor: 'transparent',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
buttonSelected: {
|
||||
borderColor: '#FFFFFF',
|
||||
borderWidth: 3,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 8,
|
||||
elevation: 6,
|
||||
},
|
||||
manageButton: {
|
||||
alignSelf: 'center',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
minWidth: 64,
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 18,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'dashed',
|
||||
minHeight: 38,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
colorDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
colorDotSelected: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
},
|
||||
buttonText: {
|
||||
manageLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,11 @@ import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Modal, Pressable, KeyboardAvoidingView } from 'react-native';
|
||||
import { useCategories } from '@/hooks/useDatabase';
|
||||
import { useSettings } from '@/theme';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
interface CategorySelectorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -14,11 +15,30 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
||||
const { theme } = useSettings();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const selectedCategory = categories.find(c => c.id === value);
|
||||
const selected = new Set(value);
|
||||
const selectedCategories = categories.filter((c) => selected.has(c.id));
|
||||
|
||||
const toggle = (id: string) => {
|
||||
const next = new Set(selected);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
onChange(Array.from(next));
|
||||
};
|
||||
|
||||
const summary =
|
||||
selectedCategories.length === 0
|
||||
? 'None'
|
||||
: selectedCategories
|
||||
.slice(0, 2)
|
||||
.map((c) => c.name)
|
||||
.join(', ') + (selectedCategories.length > 2 ? ` +${selectedCategories.length - 2}` : '');
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={[styles.label, { color: theme.text }]}>Category</Text>
|
||||
<Text style={[styles.label, { color: theme.text }]}>Tags</Text>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.selectorButton,
|
||||
@@ -27,20 +47,28 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
||||
onPress={() => setShowModal(true)}
|
||||
activeOpacity={0.8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select category"
|
||||
accessibilityHint="Opens a list of categories to choose from"
|
||||
accessibilityLabel="Select tags"
|
||||
accessibilityHint="Opens a list of tags to choose from"
|
||||
>
|
||||
<View style={styles.selectorContent}>
|
||||
<View style={styles.selectorRow}>
|
||||
<View style={[styles.colorCircle, { backgroundColor: selectedCategory?.color || '#9E9E9E' }]} />
|
||||
<Text style={[styles.selectorValue, { color: theme.text }]}>{selectedCategory?.name || 'None'}</Text>
|
||||
</View>
|
||||
{selectedCategories.length > 0 ? (
|
||||
<View style={styles.chipRow}>
|
||||
{selectedCategories.slice(0, 3).map((c) => (
|
||||
<View key={c.id} style={[styles.chip, { backgroundColor: theme.cardAlt, borderColor: theme.border }]}>
|
||||
<View style={[styles.colorCircle, { backgroundColor: c.color }]} />
|
||||
<Text style={[styles.chipText, { color: theme.textSecondary }]} numberOfLines={1}>{c.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text style={[styles.selectorValue, { color: theme.textMuted }]}>None</Text>
|
||||
)}
|
||||
</View>
|
||||
<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>
|
||||
</TouchableOpacity>
|
||||
{error && <Text style={[styles.errorText, { color: '#E53935' }]}>{error}</Text>}
|
||||
{error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>}
|
||||
|
||||
<Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}>
|
||||
<KeyboardAvoidingView
|
||||
@@ -50,67 +78,62 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
||||
<Pressable style={styles.modalOverlay} onPress={() => setShowModal(false)}>
|
||||
<Pressable style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={[styles.modalTitle, { color: theme.text }]}>Select Category</Text>
|
||||
<TouchableOpacity onPress={() => setShowModal(false)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} accessibilityRole="button" accessibilityLabel="Close category picker">
|
||||
<Text style={[styles.closeText, { color: theme.textMuted }]}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.modalTitle, { color: theme.text }]}>Select Tags</Text>
|
||||
<View style={styles.modalHeaderRight}>
|
||||
<TouchableOpacity
|
||||
onPress={() => { onChange([]); setShowModal(false); }}
|
||||
activeOpacity={0.7}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Clear all tags"
|
||||
>
|
||||
<Text style={[styles.clearText, { color: theme.textMuted }]}>Clear</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowModal(false)}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Done selecting tags"
|
||||
>
|
||||
<Text style={[styles.doneText, { color: theme.accent }]}>Done</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={styles.modalContent}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.modalOption,
|
||||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
||||
!value && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
|
||||
]}
|
||||
onPress={() => { onChange(''); setShowModal(false); }}
|
||||
activeOpacity={0.8}
|
||||
accessibilityRole="radio"
|
||||
accessibilityLabel="No category"
|
||||
accessibilityState={{ selected: !value }}
|
||||
>
|
||||
<View style={[styles.colorCircle, { backgroundColor: '#9E9E9E' }, !value && styles.colorCircleSelected]} />
|
||||
<Text style={[
|
||||
styles.categoryName,
|
||||
{ color: theme.textSecondary },
|
||||
!value && { color: theme.accent, fontWeight: '600' },
|
||||
]}>
|
||||
None
|
||||
</Text>
|
||||
{!value && (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
{categories.map((category) => (
|
||||
<TouchableOpacity
|
||||
key={category.id}
|
||||
style={[
|
||||
styles.modalOption,
|
||||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
||||
value === category.id && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
|
||||
]}
|
||||
onPress={() => { onChange(category.id); setShowModal(false); }}
|
||||
activeOpacity={0.8}
|
||||
accessibilityRole="radio"
|
||||
accessibilityLabel={`Category ${category.name}`}
|
||||
accessibilityState={{ selected: value === category.id }}
|
||||
>
|
||||
<View style={[styles.colorCircle, { backgroundColor: category.color }, value === category.id && styles.colorCircleSelected]} />
|
||||
<Text style={[
|
||||
styles.categoryName,
|
||||
{ color: theme.textSecondary },
|
||||
value === category.id && { color: theme.accent, fontWeight: '600' },
|
||||
]}>
|
||||
{category.name}
|
||||
</Text>
|
||||
{value === category.id && (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
<Text style={[styles.modalHint, { color: theme.textMuted }]}>
|
||||
A task can have multiple tags. Selected: {selected.size}
|
||||
</Text>
|
||||
{categories.map((category) => {
|
||||
const isSelected = selected.has(category.id);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={category.id}
|
||||
style={[
|
||||
styles.modalOption,
|
||||
{ backgroundColor: theme.card, borderColor: theme.borderStrong },
|
||||
isSelected && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 },
|
||||
]}
|
||||
onPress={() => toggle(category.id)}
|
||||
activeOpacity={0.8}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityLabel={`Tag ${category.name}`}
|
||||
accessibilityState={{ checked: isSelected }}
|
||||
>
|
||||
<View style={[styles.colorCircle, { backgroundColor: category.color }, isSelected && styles.colorCircleSelected]} />
|
||||
<Text style={[
|
||||
styles.categoryName,
|
||||
{ color: theme.textSecondary },
|
||||
isSelected && { color: theme.text, fontWeight: '600' },
|
||||
]}>
|
||||
{category.name}
|
||||
</Text>
|
||||
{isSelected && (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
@@ -120,8 +143,6 @@ export function CategorySelector({ value, onChange, error }: CategorySelectorPro
|
||||
);
|
||||
}
|
||||
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
gap: 6,
|
||||
@@ -142,11 +163,25 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
selectorContent: {
|
||||
flex: 1,
|
||||
paddingRight: 8,
|
||||
},
|
||||
selectorRow: {
|
||||
chipRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 6,
|
||||
},
|
||||
chip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
gap: 6,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
},
|
||||
chipText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
colorCircle: {
|
||||
width: 12,
|
||||
@@ -186,13 +221,27 @@ const styles = StyleSheet.create({
|
||||
padding: 20,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
modalHeaderRight: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
closeText: {
|
||||
fontSize: 22,
|
||||
fontWeight: '300',
|
||||
clearText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
doneText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
modalHint: {
|
||||
fontSize: 12,
|
||||
paddingHorizontal: 4,
|
||||
paddingBottom: 4,
|
||||
},
|
||||
modalContent: {
|
||||
padding: 12,
|
||||
|
||||
@@ -69,31 +69,44 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
|
||||
onChange(newColor);
|
||||
}, [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({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponder: () => true,
|
||||
onPanResponderMove: (_, gesture) => {
|
||||
const center = WHEEL_SIZE / 2;
|
||||
const dx = gesture.moveX - center;
|
||||
const dy = gesture.moveY - 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);
|
||||
onPanResponderGrant: (event) => {
|
||||
updateFromWheel(event.nativeEvent.locationX, event.nativeEvent.locationY);
|
||||
},
|
||||
onPanResponderMove: (event) => {
|
||||
updateFromWheel(event.nativeEvent.locationX, event.nativeEvent.locationY);
|
||||
},
|
||||
}), []);
|
||||
|
||||
const setHueFromLocation = (locationX: number) => {
|
||||
setHue(Math.min(360, Math.max(0, (locationX / WHEEL_SIZE) * 360)));
|
||||
};
|
||||
|
||||
const huePanResponder = React.useMemo(() => PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponder: () => true,
|
||||
onPanResponderMove: (_, gesture) => {
|
||||
const h = (gesture.moveX / WHEEL_SIZE) * 360;
|
||||
setHue(Math.min(360, Math.max(0, h)));
|
||||
onPanResponderGrant: (event) => {
|
||||
setHueFromLocation(event.nativeEvent.locationX);
|
||||
},
|
||||
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 { useSettings } from '@/theme';
|
||||
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 [value, setValue] = useState(initialHsv.v);
|
||||
const [prevColor, setPrevColor] = useState(color);
|
||||
const wheelRef = useRef<Svg>(null);
|
||||
const hueRef = useRef<Svg>(null);
|
||||
|
||||
if (prevColor !== color) {
|
||||
setPrevColor(color);
|
||||
@@ -71,14 +69,13 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
|
||||
}, [hue, saturation, value, onChange]);
|
||||
|
||||
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) => {
|
||||
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 dy = moveEvent.clientY - rect.top - 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;
|
||||
@@ -97,9 +94,8 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
|
||||
};
|
||||
|
||||
const handleHueMouseDown = (e: React.MouseEvent) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
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;
|
||||
setHue(Math.min(360, Math.max(0, h)));
|
||||
};
|
||||
@@ -120,7 +116,7 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<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>
|
||||
<LinearGradient id="satGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<Stop offset="0%" stopColor={`hsl(${hue}, 100%, 50%)`} stopOpacity={1} />
|
||||
@@ -155,7 +151,7 @@ export default function ColorWheel({ color, onChange }: ColorWheelProps) {
|
||||
</View>
|
||||
|
||||
<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>
|
||||
<LinearGradient id="hueGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<Stop offset="0%" stopColor="#FF0000" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, Modal } from 'react-native';
|
||||
import { DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
||||
import { useSettings } from '@/theme';
|
||||
|
||||
interface WebDateTimeInputProps {
|
||||
value: Date;
|
||||
@@ -32,6 +33,7 @@ export default function WebDateTimeInput({
|
||||
onDismiss,
|
||||
is24Hour,
|
||||
}: WebDateTimeInputProps) {
|
||||
const { theme } = useSettings();
|
||||
const [inputValue, setInputValue] = useState(() => dateInputValue(value));
|
||||
const [prevValue, setPrevValue] = useState(value);
|
||||
|
||||
@@ -57,11 +59,11 @@ export default function WebDateTimeInput({
|
||||
onChange(event, date);
|
||||
};
|
||||
|
||||
const bgColor = '#1E1E1E';
|
||||
const textColor = '#FFFFFF';
|
||||
const borderColor = '#333333';
|
||||
const overlayColor = 'rgba(0,0,0,0.6)';
|
||||
const mutedColor = '#9E9E9E';
|
||||
const bgColor = theme.sheetBg;
|
||||
const textColor = theme.text;
|
||||
const borderColor = theme.borderStrong;
|
||||
const overlayColor = theme.overlay;
|
||||
const mutedColor = theme.textFaint;
|
||||
|
||||
return (
|
||||
<Modal visible={isVisible} transparent animationType="fade" onRequestClose={onDismiss}>
|
||||
@@ -90,7 +92,7 @@ export default function WebDateTimeInput({
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${borderColor}`,
|
||||
outline: 'none',
|
||||
backgroundColor: '#2C2C2C',
|
||||
backgroundColor: theme.inputBg,
|
||||
color: textColor,
|
||||
fontFamily: 'inherit',
|
||||
marginVertical: 16,
|
||||
@@ -101,14 +103,14 @@ export default function WebDateTimeInput({
|
||||
</View>
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.clearButton, { borderColor, backgroundColor: '#2C2C2C' }]}
|
||||
style={[styles.clearButton, { borderColor, backgroundColor: theme.inputBg }]}
|
||||
onPress={onDismiss}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.clearButtonText, { color: mutedColor }]}>Clear</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.doneButton} onPress={onDismiss} activeOpacity={0.8}>
|
||||
<Text style={styles.doneButtonText}>Done</Text>
|
||||
<TouchableOpacity style={[styles.doneButton, { backgroundColor: theme.accent }]} onPress={onDismiss} activeOpacity={0.8}>
|
||||
<Text style={[styles.doneButtonText, { color: theme.accentText }]}>Done</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
@@ -159,11 +161,9 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#E53935',
|
||||
},
|
||||
doneButtonText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
@@ -49,14 +49,14 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
||||
styles.pickerButton,
|
||||
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong },
|
||||
field.value && { borderColor: theme.accent, backgroundColor: theme.accentSoft },
|
||||
fieldName === 'endTime' && endTimeInvalid && { borderColor: '#E53935', backgroundColor: theme.accentSoft },
|
||||
fieldName === 'endTime' && endTimeInvalid && { borderColor: theme.dangerStrong, backgroundColor: theme.accentSoft },
|
||||
]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<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" />
|
||||
<Path d="M12 6v6l4 2" stroke={field.value ? theme.accent : theme.textMuted} strokeWidth={1.5} strokeLinecap="round" />
|
||||
<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.textFaint} strokeWidth={1.8} strokeLinecap="round" />
|
||||
</Svg>
|
||||
<Text
|
||||
style={[
|
||||
@@ -68,7 +68,7 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
||||
>
|
||||
{field.value ? formatTime12(field.value) : placeholder}
|
||||
</Text>
|
||||
{field.value && (
|
||||
{field.value ? (
|
||||
<TouchableOpacity
|
||||
style={styles.clearButton}
|
||||
onPress={() => {
|
||||
@@ -82,10 +82,10 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<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>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}}
|
||||
@@ -114,14 +114,14 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M8 2v4M16 2v4M3 10h18M3 18h18"
|
||||
stroke={field.value ? theme.accent : theme.textMuted}
|
||||
strokeWidth={1.5}
|
||||
stroke={field.value ? theme.accent : theme.textFaint}
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<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"
|
||||
stroke={field.value ? theme.accent : theme.textMuted}
|
||||
strokeWidth={1.5}
|
||||
stroke={field.value ? theme.accent : theme.textFaint}
|
||||
strokeWidth={1.8}
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
@@ -135,21 +135,43 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
||||
>
|
||||
{field.value ? format(field.value, 'MMM d, yyyy') : 'Due Date'}
|
||||
</Text>
|
||||
{field.value && (
|
||||
{field.value ? (
|
||||
<TouchableOpacity
|
||||
style={styles.clearButton}
|
||||
onPress={() => setDateValue(null)}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<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>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.calendarButton, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong }]}
|
||||
onPress={() => setPicker('date')}
|
||||
activeOpacity={0.8}
|
||||
accessibilityLabel="Open calendar"
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<Svg width={22} height={22} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M8 2v4M16 2v4M3 10h18M3 18h18"
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<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"
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={1.8}
|
||||
fill="none"
|
||||
/>
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Controller
|
||||
control={control}
|
||||
@@ -181,12 +203,12 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
|
||||
</View>
|
||||
|
||||
{endTimeInvalid && (
|
||||
<View style={[styles.warningRow, { backgroundColor: theme.accentSoft, borderColor: '#E53935' }]}>
|
||||
<View style={[styles.warningRow, { backgroundColor: theme.accentSoft, borderColor: theme.dangerStrong }]}>
|
||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={10} stroke="#E53935" strokeWidth={1.5} fill="none" />
|
||||
<Path d="M12 8v5M12 16.5v.5" stroke="#E53935" strokeWidth={1.8} strokeLinecap="round" />
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.dangerStrong} strokeWidth={1.8} fill="none" />
|
||||
<Path d="M12 8v5M12 16.5v.5" stroke={theme.dangerStrong} strokeWidth={1.8} strokeLinecap="round" />
|
||||
</Svg>
|
||||
<Text style={[styles.warningText, { color: '#E53935' }]}>
|
||||
<Text style={[styles.warningText, { color: theme.dangerStrong }]}>
|
||||
{endTime === startTime
|
||||
? 'The end time is the same as the start time.'
|
||||
: 'The end time is before the start time.'}
|
||||
@@ -299,6 +321,14 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
},
|
||||
calendarButton: {
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
warningText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { View, Text, StyleSheet, TextInput , TextInputProps } from 'react-native';
|
||||
import { useSettings } from '@/theme';
|
||||
import { useSettings, type ThemeColors } from '@/theme';
|
||||
|
||||
interface DescriptionInputProps extends TextInputProps {
|
||||
error?: string;
|
||||
@@ -8,6 +8,7 @@ interface DescriptionInputProps extends TextInputProps {
|
||||
|
||||
export function DescriptionInput({ error, ...props }: DescriptionInputProps) {
|
||||
const { theme } = useSettings();
|
||||
const styles = useMemo(() => createStyles(theme), [theme]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -35,7 +36,7 @@ export function DescriptionInput({ error, ...props }: DescriptionInputProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const createStyles = (theme: ThemeColors) => StyleSheet.create({
|
||||
container: {
|
||||
gap: 5,
|
||||
},
|
||||
@@ -51,7 +52,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 14,
|
||||
},
|
||||
inputError: {
|
||||
borderColor: '#E53935',
|
||||
borderColor: theme.dangerStrong,
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
charCount: {
|
||||
@@ -61,7 +62,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 11,
|
||||
color: '#E53935',
|
||||
color: theme.dangerStrong,
|
||||
marginLeft: 4,
|
||||
},
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import { StyleSheet, TouchableOpacity, Animated, Easing } from 'react-native';
|
||||
import { StyleSheet, TouchableOpacity, Animated, Easing, View, Text } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
import { useSettings } from '@/theme';
|
||||
|
||||
export function FloatingActionButton() {
|
||||
const router = useRouter();
|
||||
const { theme } = useSettings();
|
||||
const [scaleAnim] = React.useState(new Animated.Value(1));
|
||||
const [rotateAnim] = React.useState(new Animated.Value(0));
|
||||
const [rotation, setRotation] = useState(0);
|
||||
@@ -52,7 +54,7 @@ export function FloatingActionButton() {
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
style={[styles.button, { backgroundColor: theme.accent, shadowColor: theme.accent }]}
|
||||
onPress={handlePress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
@@ -68,8 +70,8 @@ export function FloatingActionButton() {
|
||||
>
|
||||
<Svg width={24} height={24} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M12 5v14M5 12h14"
|
||||
stroke="#FFFFFF"
|
||||
d="M12 19V5M12 5l-5 5M12 5l5 5"
|
||||
stroke={theme.accentText}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -77,6 +79,9 @@ export function FloatingActionButton() {
|
||||
</Svg>
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.labelContainer}>
|
||||
<Text style={[styles.label, { color: theme.accentText }]}>Add a Task</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@@ -86,18 +91,29 @@ const styles = StyleSheet.create({
|
||||
position: 'absolute',
|
||||
bottom: 24,
|
||||
right: 24,
|
||||
shadowColor: '#E53935',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
elevation: 6,
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
button: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#E53935',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
labelContainer: {
|
||||
marginTop: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
},
|
||||
label: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
KeyboardAvoidingView,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useSettings } from '@/theme';
|
||||
import { forgotPassword, resetPassword } from '@/services/auth';
|
||||
|
||||
interface ForgotPasswordModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type ForgotPasswordStep = 'enterUsername' | 'enterResetCode';
|
||||
|
||||
export function ForgotPasswordModal({ visible, onClose }: ForgotPasswordModalProps) {
|
||||
const { theme } = useSettings();
|
||||
const [step, setStep] = useState<ForgotPasswordStep>('enterUsername');
|
||||
const [username, setUsername] = useState('');
|
||||
const [resetToken, setResetToken] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const handleRequestReset = async () => {
|
||||
if (busy) return;
|
||||
if (!username.trim()) {
|
||||
setError('Username is required');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await forgotPassword(username.trim());
|
||||
if (result.success) {
|
||||
if (result.resetToken) {
|
||||
Alert.alert('Reset Token (Dev Mode)', `Your reset token is: ${result.resetToken}\n\nEnter this token on the next screen.`, [
|
||||
{ text: 'OK', onPress: () => setStep('enterResetCode') },
|
||||
]);
|
||||
} else {
|
||||
Alert.alert('Reset Email Sent', 'If the account exists, a password reset link has been sent.', [
|
||||
{ text: 'OK', onPress: () => setStep('enterResetCode') },
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
setError('Failed to request password reset');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? 'Failed to request password reset');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (busy) return;
|
||||
if (!resetToken.trim()) {
|
||||
setError('Reset token is required');
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
setError('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await resetPassword(resetToken.trim(), newPassword);
|
||||
if (result.success) {
|
||||
Alert.alert('Password Reset', 'Your password has been reset successfully.', [
|
||||
{ text: 'OK', onPress: onClose },
|
||||
]);
|
||||
} else {
|
||||
setError('Failed to reset password');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? 'Failed to reset password. Token may be invalid or expired.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (step === 'enterResetCode') {
|
||||
setStep('enterUsername');
|
||||
setResetToken('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setError(null);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const renderEnterUsername = () => (
|
||||
<View style={styles.body}>
|
||||
<Text style={[styles.subtitle, { color: theme.textSecondary }]}>
|
||||
Enter your username to receive a password reset link.
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
|
||||
placeholder="Username"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
{error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>}
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryButton, { backgroundColor: theme.accent }]}
|
||||
onPress={handleRequestReset}
|
||||
disabled={busy}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator color={theme.accentText} />
|
||||
) : (
|
||||
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>Send Reset Link</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderEnterResetCode = () => (
|
||||
<View style={styles.body}>
|
||||
<Text style={[styles.subtitle, { color: theme.textSecondary }]}>
|
||||
Enter the reset token and your new password.
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
|
||||
placeholder="Reset Token"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={resetToken}
|
||||
onChangeText={setResetToken}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<TextInput
|
||||
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
|
||||
placeholder="New Password (min 8 chars)"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={newPassword}
|
||||
onChangeText={setNewPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
<TextInput
|
||||
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
|
||||
placeholder="Confirm New Password"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
{error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>}
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryButton, { backgroundColor: theme.accent }]}
|
||||
onPress={handleResetPassword}
|
||||
disabled={busy}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator color={theme.accentText} />
|
||||
) : (
|
||||
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>Reset Password</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={handleBack}>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.overlay}
|
||||
behavior="padding"
|
||||
>
|
||||
<TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={handleBack} />
|
||||
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
|
||||
<View style={[styles.handle, { backgroundColor: theme.borderStrong }]} />
|
||||
<Text style={[styles.title, { color: theme.text }]}>Reset Password</Text>
|
||||
|
||||
{step === 'enterUsername' ? renderEnterUsername() : renderEnterResetCode()}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.secondaryButton, { borderColor: theme.borderStrong }]}
|
||||
onPress={handleBack}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.secondaryButtonText, { color: theme.textSecondary }]}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||
},
|
||||
sheet: {
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
paddingTop: 8,
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
handle: {
|
||||
width: 40,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
alignSelf: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
body: {
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
},
|
||||
input: {
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
fontSize: 15,
|
||||
},
|
||||
primaryButton: {
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 50,
|
||||
},
|
||||
primaryButtonText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
secondaryButton: {
|
||||
paddingVertical: 12,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
secondaryButtonText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
@@ -38,7 +38,7 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
|
||||
<Text style={[styles.cancelButtonText, { color: theme.textSecondary }]}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.submitButton, isSubmitting && styles.buttonDisabled]}
|
||||
style={[styles.submitButton, { backgroundColor: theme.accent }, isSubmitting && styles.buttonDisabled]}
|
||||
onPress={handleSubmit(onSubmit)}
|
||||
disabled={isSubmitting}
|
||||
activeOpacity={0.8}
|
||||
@@ -46,7 +46,7 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
|
||||
accessibilityLabel={isSubmitting ? 'Saving' : submitLabel}
|
||||
accessibilityState={{ disabled: isSubmitting, busy: isSubmitting }}
|
||||
>
|
||||
<Text style={styles.submitButtonText}>
|
||||
<Text style={[styles.submitButtonText, { color: theme.accentText }]}>
|
||||
{isSubmitting ? 'Saving...' : submitLabel}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -88,19 +88,12 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#E53935',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#E53935',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 6,
|
||||
elevation: 3,
|
||||
},
|
||||
submitButtonText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.6,
|
||||
|
||||
@@ -148,7 +148,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
|
||||
style={[styles.removeBtn, { backgroundColor: theme.accentSoft, borderColor: theme.accent }]}
|
||||
onPress={() => handleRemove(item.id)}
|
||||
>
|
||||
<Text style={[styles.removeBtnText, { color: theme.accent }]}>Remove</Text>
|
||||
<Text style={[styles.removeBtnText, { color: theme.accentStrong }]}>Remove</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -16,8 +16,8 @@ export function Header({ title, showLogo, rightAction }: HeaderProps) {
|
||||
<View style={[styles.header, { backgroundColor: theme.background, paddingTop: topInset }]}>
|
||||
<View style={styles.headerContent}>
|
||||
{showLogo && (
|
||||
<View style={styles.logoContainer}>
|
||||
<Text style={styles.logoText}>✓</Text>
|
||||
<View style={[styles.logoContainer, { backgroundColor: theme.accent }]}>
|
||||
<Text style={[styles.logoText, { color: theme.accentText }]}>✓</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text style={[styles.title, { color: theme.text }]} accessibilityRole="header">{title}</Text>
|
||||
@@ -48,12 +48,10 @@ const styles = StyleSheet.create({
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#E53935',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
logoText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
|
||||
@@ -136,7 +136,7 @@ export function LegalModal({ visible, type, onClose }: LegalModalProps) {
|
||||
<Text style={[styles.title, { color: theme.text }]}>{title}</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton} activeOpacity={0.7}>
|
||||
<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>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -42,8 +42,8 @@ export function ListItem({ title, subtitle, leftElement, rightElement, onPress,
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M9 18l6-6-6-6"
|
||||
stroke={theme.textMuted}
|
||||
strokeWidth={2}
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
|
||||
@@ -22,7 +22,7 @@ interface OptionPickerModalProps {
|
||||
export function OptionPickerModal({ visible, title, options, selectedValue, onSelect, onClose, multiSelect = false }: OptionPickerModalProps) {
|
||||
const { theme } = useSettings();
|
||||
|
||||
const selectedValues = Array.isArray(selectedValue) ? selectedValue : selectedValue ? [selectedValue] : [];
|
||||
const selectedValues = Array.isArray(selectedValue) ? selectedValue : selectedValue == null ? [] : [selectedValue];
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
@@ -87,7 +87,7 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Cancel"
|
||||
>
|
||||
<Text style={[styles.cancelText, { color: theme.textFaint }]}>Cancel</Text>
|
||||
<Text style={[styles.cancelText, { color: theme.textSecondary }]}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
contentContainerStyle={styles.listContent}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function PrioritySelector({ value, onChange }: PrioritySelectorProps) {
|
||||
<Text style={[
|
||||
styles.optionText,
|
||||
{ color: theme.textFaint },
|
||||
value === priority.value && { color: theme.accent, fontWeight: '600' },
|
||||
value === priority.value && { color: theme.accentStrong, fontWeight: '600' },
|
||||
]}>
|
||||
{priority.label}
|
||||
</Text>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { View, StyleSheet, TextInput, TouchableOpacity, Platform, Keyboard, Animated, Easing } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { View, StyleSheet, TextInput, TouchableOpacity, Keyboard, Text, Platform } from 'react-native';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { database, collections } from '@/database';
|
||||
import { useCategories } from '@/hooks/useDatabase';
|
||||
import { useSettings } from '@/theme';
|
||||
import { OptionPickerModal } from '@/components/OptionPickerModal';
|
||||
import { tagsToString } from '@/types';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
import { subscribeToQuickAdd } from '@/utils/quickAddFocus';
|
||||
|
||||
@@ -13,17 +14,34 @@ interface QuickAddBarProps {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const BlurContainer = ({ children, style, theme }: { children: React.ReactNode; style: any; theme: any }) => {
|
||||
if (Platform.OS === 'web') {
|
||||
return (
|
||||
<View style={[style, { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.border }]}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<BlurView style={style} intensity={80} tint="dark">
|
||||
{children}
|
||||
</BlurView>
|
||||
);
|
||||
};
|
||||
|
||||
export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
||||
const { theme, defaultCategoryId } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const categories = useCategories();
|
||||
const [title, setTitle] = useState('');
|
||||
const [categoryId, setCategoryId] = useState(() => defaultCategoryId || '');
|
||||
const [categoryPickerVisible, setCategoryPickerVisible] = useState(false);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const keyboardHeight = useRef(new Animated.Value(0)).current;
|
||||
|
||||
const categoryColor = categories.find((c) => c.id === categoryId)?.color ?? '#9E9E9E';
|
||||
const visibleCategories = useMemo(
|
||||
() => categories.filter((c) => c.name.toLowerCase() !== 'calendar'),
|
||||
[categories]
|
||||
);
|
||||
const categoryColor = visibleCategories.find((c) => c.id === categoryId)?.color ?? theme.textMuted;
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeToQuickAdd(() => {
|
||||
@@ -33,32 +51,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 trimmed = title.trim();
|
||||
if (!trimmed) return;
|
||||
@@ -69,6 +61,7 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
||||
t.title = trimmed;
|
||||
t.description = '';
|
||||
t.categoryId = categoryId || '';
|
||||
t.tags = tagsToString(categoryId ? [categoryId] : []);
|
||||
t.priority = 'none';
|
||||
t.completed = false;
|
||||
t.dueDate = dueDate;
|
||||
@@ -84,79 +77,79 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
|
||||
});
|
||||
});
|
||||
setTitle('');
|
||||
Keyboard.dismiss();
|
||||
};
|
||||
|
||||
const animatedBottom = keyboardHeight.interpolate({
|
||||
inputRange: [0, 500],
|
||||
outputRange: [0, insets.bottom + 500],
|
||||
extrapolate: 'clamp',
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.wrapper, { bottom: animatedBottom }]}>
|
||||
<View style={[styles.bar, { backgroundColor: theme.card, borderColor: theme.border }]}>
|
||||
<TouchableOpacity
|
||||
style={[styles.categoryButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
|
||||
onPress={() => setCategoryPickerVisible(true)}
|
||||
activeOpacity={0.7}
|
||||
accessibilityLabel="Select category"
|
||||
>
|
||||
<View style={[styles.categoryButtonDot, { backgroundColor: categoryColor }]} />
|
||||
<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" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={[styles.input, { color: theme.text }]}
|
||||
placeholder={placeholder ?? 'Add a task'}
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
onSubmitEditing={handleAdd}
|
||||
returnKeyType="done"
|
||||
accessibilityLabel="Quick add task"
|
||||
accessibilityHint="Enter a task name and press the add button"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.submit, { backgroundColor: theme.accent }, title.trim() ? {} : styles.submitDisabled]}
|
||||
onPress={title.trim() ? handleAdd : undefined}
|
||||
disabled={!title.trim()}
|
||||
activeOpacity={0.8}
|
||||
accessibilityLabel="Add task"
|
||||
accessibilityHint="Adds the entered task to the list"
|
||||
accessibilityState={{ disabled: !title.trim() }}
|
||||
>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M12 5v14M5 12h14"
|
||||
stroke={theme.accentText}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.wrapper}>
|
||||
<BlurContainer theme={theme} style={styles.blurContainer}>
|
||||
<View style={[styles.bar, { backgroundColor: 'rgba(255,255,255,0.05)', borderColor: 'rgba(255,255,255,0.1)' }]}>
|
||||
<TouchableOpacity
|
||||
style={[styles.categoryButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
|
||||
onPress={() => setCategoryPickerVisible(true)}
|
||||
activeOpacity={0.7}
|
||||
accessibilityLabel="Select tags"
|
||||
>
|
||||
<View style={[styles.categoryButtonDot, { backgroundColor: categoryColor }]} />
|
||||
<Svg width={12} height={12} viewBox="0 0 24 24">
|
||||
<Path d="M6 9l6 6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={[styles.input, { color: theme.text }]}
|
||||
placeholder={placeholder ?? 'Add a task'}
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
onSubmitEditing={handleAdd}
|
||||
returnKeyType="done"
|
||||
accessibilityLabel="Quick add task"
|
||||
accessibilityHint="Enter a task name and press the add button"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.submit, { backgroundColor: theme.accent }, title.trim() ? {} : styles.submitDisabled]}
|
||||
onPress={title.trim() ? handleAdd : undefined}
|
||||
disabled={!title.trim()}
|
||||
activeOpacity={0.8}
|
||||
accessibilityLabel="Add task"
|
||||
accessibilityHint="Adds the entered task to the list"
|
||||
accessibilityState={{ disabled: !title.trim() }}
|
||||
>
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M12 19V5M12 5l-5 5M12 5l5 5"
|
||||
stroke={theme.accentText}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</BlurContainer>
|
||||
|
||||
<OptionPickerModal
|
||||
visible={categoryPickerVisible}
|
||||
title="Select Category"
|
||||
options={[{ value: '', label: 'None', color: '#9E9E9E' }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
|
||||
title="Select Tags"
|
||||
options={[{ value: '', label: 'None', color: theme.textMuted }, ...visibleCategories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
|
||||
selectedValue={categoryId}
|
||||
onSelect={(value) => setCategoryId(Array.isArray(value) ? value[0] : value)}
|
||||
onClose={() => setCategoryPickerVisible(false)}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
position: 'absolute',
|
||||
left: 8,
|
||||
right: 8,
|
||||
bottom: 12,
|
||||
paddingHorizontal: 8,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
blurContainer: {
|
||||
borderRadius: 14,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
bar: {
|
||||
flexDirection: 'row',
|
||||
@@ -166,11 +159,13 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 8,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
shadowOpacity: 0.10,
|
||||
shadowRadius: 6,
|
||||
elevation: 4,
|
||||
elevation: 6,
|
||||
minHeight: 56,
|
||||
},
|
||||
categoryButton: {
|
||||
flexDirection: 'row',
|
||||
@@ -178,7 +173,7 @@ const styles = StyleSheet.create({
|
||||
gap: 3,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 6,
|
||||
borderRadius: 8,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
height: 40,
|
||||
},
|
||||
@@ -194,13 +189,23 @@ const styles = StyleSheet.create({
|
||||
minHeight: 40,
|
||||
},
|
||||
submit: {
|
||||
width: 40,
|
||||
minWidth: 88,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
submitLabelRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
submitText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
},
|
||||
submitDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -43,13 +43,13 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24">
|
||||
<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"
|
||||
stroke={selectedReminders.length > 0 ? theme.accent : theme.textMuted}
|
||||
strokeWidth={1.5}
|
||||
stroke={selectedReminders.length > 0 ? theme.accentText : theme.textSecondary}
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
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>
|
||||
<Text
|
||||
style={[
|
||||
@@ -62,7 +62,7 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
|
||||
</Text>
|
||||
<View style={styles.chevron}>
|
||||
<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>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
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 { useRepeatProfiles } from '@/hooks/useDatabase';
|
||||
import { createRepeatProfile, deleteRepeatProfile } from '@/utils/repeatProfileActions';
|
||||
@@ -168,7 +168,7 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
||||
style={[
|
||||
styles.chipText,
|
||||
{ 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')}
|
||||
@@ -212,9 +212,9 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
||||
|
||||
{isDaysBased(value) && (
|
||||
<View style={styles.dayRow}>
|
||||
{WEEKDAY_LABELS.map((label, day) => (
|
||||
{WEEKDAY_ORDER.map((day) => (
|
||||
<TouchableOpacity
|
||||
key={`${label}-${day}`}
|
||||
key={`${WEEKDAY_LABELS[day]}-${day}`}
|
||||
style={[
|
||||
styles.dayChip,
|
||||
{ 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' },
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
{WEEKDAY_LABELS[day]}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
@@ -252,7 +252,7 @@ export function RepeatSelector({ value, interval, days, onChange }: RepeatSelect
|
||||
<Svg width={13} height={13} viewBox="0 0 24 24">
|
||||
<Path d="M12 5v14M5 12h14" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" />
|
||||
</Svg>
|
||||
<Text style={[styles.saveProfileText, { color: theme.accent }]}>Save as profile</Text>
|
||||
<Text style={[styles.saveProfileText, { color: theme.accentStrong }]}>Save as profile</Text>
|
||||
</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.modalHint, { color: theme.textFaint }]}>
|
||||
{`${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>
|
||||
<TextInput
|
||||
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>
|
||||
<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">
|
||||
<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>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -1,68 +1,86 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { SubtaskData } from '@/types';
|
||||
import { TaskItem } from './TaskItem';
|
||||
import { useSettings } from '@/theme';
|
||||
|
||||
interface SubtaskItemProps {
|
||||
subtask: SubtaskData;
|
||||
onToggle: () => void | Promise<void>;
|
||||
onDelete?: () => void;
|
||||
onPress?: () => void;
|
||||
onLongPress?: () => void;
|
||||
onMenuOpen?: () => void;
|
||||
onToggle?: (subtask: SubtaskData) => void | Promise<void>;
|
||||
onDelete?: (subtask: SubtaskData) => void;
|
||||
onPress?: (subtask: SubtaskData) => void;
|
||||
onLongPress?: (subtask: SubtaskData) => void;
|
||||
onMenuOpen?: (subtask: SubtaskData) => void;
|
||||
selected?: boolean;
|
||||
selectionMode?: boolean;
|
||||
draggable?: boolean;
|
||||
onDragStart?: () => void;
|
||||
onDragStart?: (subtask: SubtaskData) => void;
|
||||
onDragUpdate?: (absoluteY: number) => void;
|
||||
onDragEnd?: (absoluteY: number) => void;
|
||||
depth?: number;
|
||||
categoryColor?: string;
|
||||
categoryColorResolver?: (categoryId: string | undefined) => string | undefined;
|
||||
registerRef?: (subtaskId: string, parentTaskId: string, ref: View | null) => void;
|
||||
hoveredId?: string | null;
|
||||
}
|
||||
|
||||
export const SubtaskItem = React.memo(function SubtaskItem({
|
||||
subtask,
|
||||
onToggle,
|
||||
onDelete,
|
||||
onPress,
|
||||
onLongPress,
|
||||
onMenuOpen,
|
||||
export const SubtaskItem = React.memo(function SubtaskItem({
|
||||
subtask,
|
||||
onToggle,
|
||||
onDelete,
|
||||
onPress,
|
||||
onLongPress,
|
||||
onMenuOpen,
|
||||
selected,
|
||||
selectionMode,
|
||||
draggable,
|
||||
onDragStart,
|
||||
onDragUpdate,
|
||||
onDragEnd,
|
||||
draggable,
|
||||
onDragStart,
|
||||
onDragUpdate,
|
||||
onDragEnd,
|
||||
depth = 1,
|
||||
categoryColor
|
||||
categoryColor,
|
||||
categoryColorResolver,
|
||||
registerRef,
|
||||
hoveredId,
|
||||
}: SubtaskItemProps) {
|
||||
const { theme } = useSettings();
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasChildren = subtask.subtasks && subtask.subtasks.length > 0;
|
||||
const hovered = hoveredId === subtask.id;
|
||||
const effectiveColor = subtask.categoryId
|
||||
? categoryColorResolver?.(subtask.categoryId) ?? categoryColor
|
||||
: categoryColor;
|
||||
|
||||
const handleExpand = () => setExpanded(!expanded);
|
||||
|
||||
useEffect(() => {
|
||||
if (hovered && hasChildren && !expanded) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [hovered, hasChildren, expanded]);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<TaskItem
|
||||
task={subtask}
|
||||
indented
|
||||
depth={depth}
|
||||
onToggle={onToggle}
|
||||
onDelete={onDelete}
|
||||
onPress={onPress ?? (() => {})}
|
||||
onLongPress={onLongPress}
|
||||
onMenuOpen={onMenuOpen}
|
||||
selected={selected}
|
||||
selectionMode={selectionMode}
|
||||
completedSection={subtask.completed}
|
||||
draggable={draggable}
|
||||
onDragStart={onDragStart}
|
||||
onDragUpdate={onDragUpdate}
|
||||
onDragEnd={onDragEnd}
|
||||
categoryColor={categoryColor}
|
||||
/>
|
||||
<View ref={(ref) => registerRef?.(subtask.id, subtask.taskId, ref)}>
|
||||
<TaskItem
|
||||
task={subtask}
|
||||
indented
|
||||
depth={depth}
|
||||
expanded={hasChildren && expanded}
|
||||
onToggle={() => onToggle?.(subtask)}
|
||||
onDelete={() => onDelete?.(subtask)}
|
||||
onPress={onPress ? () => onPress(subtask) : handleExpand}
|
||||
onLongPress={() => onLongPress?.(subtask)}
|
||||
onMenuOpen={() => onMenuOpen?.(subtask)}
|
||||
selected={selected}
|
||||
selectionMode={selectionMode}
|
||||
completedSection={subtask.completed}
|
||||
draggable={draggable}
|
||||
hovered={hovered}
|
||||
onDragStart={() => onDragStart?.(subtask)}
|
||||
onDragUpdate={onDragUpdate}
|
||||
onDragEnd={onDragEnd}
|
||||
categoryColor={effectiveColor}
|
||||
/>
|
||||
</View>
|
||||
{hasChildren && expanded && (
|
||||
<View style={[styles.nestedSubtasks, { marginLeft: depth * 12 }]}>
|
||||
{subtask.subtasks
|
||||
@@ -72,7 +90,7 @@ export const SubtaskItem = React.memo(function SubtaskItem({
|
||||
<SubtaskItem
|
||||
key={child.id}
|
||||
subtask={child}
|
||||
onToggle={() => {}}
|
||||
onToggle={onToggle}
|
||||
onDelete={onDelete}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
@@ -84,7 +102,10 @@ export const SubtaskItem = React.memo(function SubtaskItem({
|
||||
onDragUpdate={onDragUpdate}
|
||||
onDragEnd={onDragEnd}
|
||||
depth={depth + 1}
|
||||
categoryColor={categoryColor}
|
||||
categoryColor={effectiveColor}
|
||||
categoryColorResolver={categoryColorResolver}
|
||||
registerRef={registerRef}
|
||||
hoveredId={hoveredId}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
@@ -97,4 +118,4 @@ const styles = StyleSheet.create({
|
||||
nestedSubtasks: {
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -48,7 +48,7 @@ export function SubtasksSection({ control }: SubtasksSectionProps) {
|
||||
<Text style={[styles.label, { color: theme.text }]}>Subtasks</Text>
|
||||
{items.length > 0 && (
|
||||
<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>
|
||||
@@ -67,8 +67,8 @@ export function SubtasksSection({ control }: SubtasksSectionProps) {
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M6 9l6 6 6-6"
|
||||
stroke={theme.textMuted}
|
||||
strokeWidth={2}
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
@@ -105,7 +105,7 @@ export function SubtasksSection({ control }: SubtasksSectionProps) {
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||
<Path d="M12 5v14M5 12h14" stroke={theme.accent} strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
<Text style={[styles.addButtonText, { color: theme.accent }]}>Add Subtask</Text>
|
||||
<Text style={[styles.addButtonText, { color: theme.accentStrong }]}>Add Subtask</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</View>
|
||||
@@ -131,8 +131,8 @@ function SubtaskItem({ index, value, onChange, onRemove }: SubtaskItemProps) {
|
||||
<Svg width={22} height={22} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M4 12.5l5 5 10-10"
|
||||
stroke={theme.borderStrong}
|
||||
strokeWidth={2}
|
||||
stroke={theme.textFaint}
|
||||
strokeWidth={2.2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
@@ -150,7 +150,7 @@ function SubtaskItem({ index, value, onChange, onRemove }: SubtaskItemProps) {
|
||||
/>
|
||||
<TouchableOpacity style={styles.removeButton} onPress={onRemove} activeOpacity={0.7}>
|
||||
<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>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
import { useSettings } from '@/theme';
|
||||
import { AuthUser, getAuthToken, getAuthUser, login, register, signOutAuth } from '@/services/auth';
|
||||
import { runSyncGuarded, startAutoSync, stopAutoSync, getLastSyncTime, SyncResult } from '@/database/sync';
|
||||
import { ForgotPasswordModal } from './ForgotPasswordModal';
|
||||
import { registerForPushNotificationsAsync, unregisterForPushNotificationsAsync } from '@/services/pushNotifications';
|
||||
|
||||
interface SyncModalProps {
|
||||
visible: boolean;
|
||||
@@ -34,6 +36,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
||||
const [lastSync, setLastSync] = useState<number | null>(null);
|
||||
const [result, setResult] = useState<SyncResult | null>(null);
|
||||
const [prevVisible, setPrevVisible] = useState(visible);
|
||||
const [showForgotPassword, setShowForgotPassword] = useState(false);
|
||||
|
||||
if (prevVisible !== visible) {
|
||||
setPrevVisible(visible);
|
||||
@@ -83,6 +86,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
||||
: await login(username.trim(), password);
|
||||
setUser(authedUser);
|
||||
startAutoSync();
|
||||
registerForPushNotificationsAsync().catch(() => {});
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? 'Sign in failed');
|
||||
} finally {
|
||||
@@ -115,6 +119,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
||||
const handleSignOut = async () => {
|
||||
await signOutAuth();
|
||||
stopAutoSync();
|
||||
unregisterForPushNotificationsAsync().catch(() => {});
|
||||
setUser(null);
|
||||
setError(null);
|
||||
setStatus('idle');
|
||||
@@ -159,7 +164,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
||||
</TouchableOpacity>
|
||||
|
||||
{status === 'success' && result && (
|
||||
<Text style={[styles.successText, { color: '#43A047' }]}>
|
||||
<Text style={[styles.successText, { color: theme.successStrong }]}>
|
||||
Synced! {result.pushed.tasks + result.pulled.tasks} tasks,{' '}
|
||||
{result.pushed.categories + result.pulled.categories} categories,{' '}
|
||||
{result.pushed.subtasks + result.pulled.subtasks} subtasks,{' '}
|
||||
@@ -168,7 +173,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
||||
</Text>
|
||||
)}
|
||||
{status === 'error' && error && (
|
||||
<Text style={[styles.errorText, { color: '#E53935' }]}>{error}</Text>
|
||||
<Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
@@ -176,7 +181,7 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
||||
onPress={handleSignOut}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.secondaryButtonText, { color: '#E53935' }]}>Sign Out</Text>
|
||||
<Text style={[styles.secondaryButtonText, { color: theme.dangerStrong }]}>Sign Out</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
@@ -234,7 +239,17 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
||||
secureTextEntry
|
||||
/>
|
||||
|
||||
{error && <Text style={[styles.errorText, { color: '#E53935' }]}>{error}</Text>}
|
||||
{mode === 'login' && (
|
||||
<TouchableOpacity
|
||||
style={styles.forgotPasswordLink}
|
||||
onPress={() => setShowForgotPassword(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.forgotPasswordText, { color: theme.accent }]}>Forgot Password?</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryButton, { backgroundColor: theme.accent }]}
|
||||
@@ -253,6 +268,8 @@ export function SyncModal({ visible, onClose }: SyncModalProps) {
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ForgotPasswordModal visible={showForgotPassword} onClose={() => setShowForgotPassword(false)} />
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.secondaryButton, { borderColor: theme.borderStrong }]}
|
||||
onPress={onClose}
|
||||
@@ -374,4 +391,13 @@ const styles = StyleSheet.create({
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
},
|
||||
forgotPasswordLink: {
|
||||
alignSelf: 'flex-end',
|
||||
marginTop: -4,
|
||||
marginBottom: 8,
|
||||
},
|
||||
forgotPasswordText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -66,21 +66,21 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
|
||||
case 'success':
|
||||
return (
|
||||
<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={theme.success} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</Svg>
|
||||
);
|
||||
case 'error':
|
||||
return (
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||
<Circle cx={12} cy={12} r={10} stroke="#E53935" strokeWidth={2} fill="none" />
|
||||
<Path d="M12 8v4M12 16h.01" stroke="#E53935" strokeWidth={2} strokeLinecap="round" />
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.danger} strokeWidth={2} fill="none" />
|
||||
<Path d="M12 8v4M12 16h.01" stroke={theme.danger} strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<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="M12 22V12" stroke={theme.textMuted} strokeWidth={1.5} strokeLinecap="round" />
|
||||
<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.textFaint} strokeWidth={1.8} strokeLinecap="round" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -128,7 +128,7 @@ export default function SyncStatus({ compact = false }: SyncStatusProps) {
|
||||
</View>
|
||||
{error && (
|
||||
<View style={styles.error}>
|
||||
<Text style={[styles.errorText, { color: '#E53935' }]}>{error}</Text>
|
||||
<Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text style={[styles.lastSync, { color: theme.textFaint }]}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { ColorValue } from 'react-native';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
import Svg, { Path, Circle, Rect } from 'react-native-svg';
|
||||
|
||||
interface TabBarIconProps {
|
||||
name: 'checklist' | 'calendar' | 'gear' | 'stats';
|
||||
@@ -15,48 +15,64 @@ export function TabBarIcon({ name, focused, color, size = 24 }: TabBarIconProps)
|
||||
{name === 'checklist' && (
|
||||
<>
|
||||
<Path
|
||||
d="M3 5h18M3 12h18M3 19h18"
|
||||
d="M8.5 6h12.5M8.5 12h12.5M8.5 18h12.5"
|
||||
stroke={color}
|
||||
strokeWidth={focused ? 2.5 : 2}
|
||||
strokeWidth={focused ? 3.5 : 3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{focused && (
|
||||
<Circle cx={6} cy={5} r={2} fill={color} />
|
||||
)}
|
||||
<Circle cx={4} cy={6} r={focused ? 1.75 : 1.5} 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' && (
|
||||
<>
|
||||
<Path
|
||||
d="M8 2v4M16 2v4M3 10h18M3 18h18"
|
||||
d="M8 2v4M16 2v4M3 10h18"
|
||||
stroke={color}
|
||||
strokeWidth={focused ? 2.5 : 2}
|
||||
strokeWidth={focused ? 3.5 : 3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<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"
|
||||
<Rect
|
||||
x={3}
|
||||
y={4}
|
||||
width={18}
|
||||
height={18}
|
||||
rx={2}
|
||||
stroke={color}
|
||||
strokeWidth={focused ? 2.5 : 2}
|
||||
strokeWidth={focused ? 3.5 : 3}
|
||||
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' && (
|
||||
<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"
|
||||
stroke={color}
|
||||
strokeWidth={focused ? 2.5 : 2}
|
||||
fill="none"
|
||||
/>
|
||||
<>
|
||||
<Circle cx={12} cy={12} r={3.5} stroke={color} strokeWidth={focused ? 3.5 : 3} fill="none" />
|
||||
<Path
|
||||
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"
|
||||
stroke={color}
|
||||
strokeWidth={focused ? 3 : 2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{name === 'stats' && (
|
||||
<>
|
||||
<Path
|
||||
d="M4 20V10M10 20V4M16 20v-7M21 20H3"
|
||||
stroke={color}
|
||||
strokeWidth={focused ? 2.5 : 2}
|
||||
strokeWidth={focused ? 3.5 : 3}
|
||||
strokeLinecap="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>
|
||||
<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">
|
||||
<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>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -71,7 +71,7 @@ export function TaskDeleteModal({ visible, taskId, taskTitle, isRepeating, subta
|
||||
onPress={() => onDelete(option.scope)}
|
||||
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>}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { View, Text, StyleSheet, TouchableOpacity, Animated, Platform, Dimension
|
||||
import { Swipeable, Gesture, GestureDetector, PanGestureHandler } from 'react-native-gesture-handler';
|
||||
import { TaskData, SubtaskData, Priority, Repeat, Reminder } from '@/types';
|
||||
import { PRIORITY_COLORS } from '@/constants';
|
||||
import { useSettings } from '@/theme';
|
||||
import { useSettings, mixHex } from '@/theme';
|
||||
import type { ThemeColors } from '@/theme';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
|
||||
export type TaskLike = {
|
||||
@@ -42,10 +43,12 @@ interface TaskItemProps {
|
||||
indented?: boolean;
|
||||
depth?: number;
|
||||
categoryColor?: string;
|
||||
categoryColors?: (string | undefined)[];
|
||||
}
|
||||
|
||||
export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMenuOpen, selected, selectionMode, completedSection, draggable, hovered, onDragStart, onDragUpdate, onDragEnd, onReorderStart, onReorderUpdate, onReorderEnd, expanded, indented, depth = 0, categoryColor }: TaskItemProps) {
|
||||
export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete, onPress, onLongPress, onMenuOpen, selected, selectionMode, completedSection, draggable, hovered, onDragStart, onDragUpdate, onDragEnd, onReorderStart, onReorderUpdate, onReorderEnd, expanded, indented, depth = 0, categoryColor, categoryColors }: TaskItemProps) {
|
||||
const { theme } = useSettings();
|
||||
const styles = React.useMemo(() => createStyles(theme), [theme]);
|
||||
const [opacityAnim] = React.useState(new Animated.Value(task.completed ? 0.5 : 1));
|
||||
const [dragTranslateX] = React.useState(new Animated.Value(0));
|
||||
const [dragTranslateY] = React.useState(new Animated.Value(0));
|
||||
@@ -133,11 +136,10 @@ export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete,
|
||||
const hasDueDate = task.dueDate > 0;
|
||||
const isOverdue = hasDueDate && !task.completed && task.dueDate < startOfToday.getTime();
|
||||
const isDueToday = hasDueDate && !task.completed && task.dueDate >= startOfToday.getTime() && task.dueDate <= endOfToday.getTime();
|
||||
const canComplete = !hasDueDate || isOverdue || isDueToday;
|
||||
return { hasDueDate, isOverdue, isDueToday, canComplete };
|
||||
return { hasDueDate, isOverdue, isDueToday };
|
||||
}, [task.dueDate, task.completed]);
|
||||
|
||||
const { hasDueDate, isOverdue, isDueToday, canComplete } = dueInfo;
|
||||
const { hasDueDate, isOverdue, isDueToday } = dueInfo;
|
||||
|
||||
const formattedDueDate = React.useMemo(
|
||||
() => formatDueDate(task.dueDate, task.dueTime, task.endTime || ''),
|
||||
@@ -158,17 +160,6 @@ export const TaskItem = React.memo(function TaskItem({ task, onToggle, onDelete,
|
||||
|
||||
const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) => {
|
||||
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 (
|
||||
<Animated.View style={[styles.swipeAction, styles.swipeComplete, { transform: [{ translateX }] }]}>
|
||||
<Svg width={24} height={24} viewBox="0 0 24 24">
|
||||
@@ -189,9 +180,7 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
||||
swipeableRef.current?.close();
|
||||
}}
|
||||
onSwipeableLeftOpen={() => {
|
||||
if (canComplete || task.completed) {
|
||||
onToggle();
|
||||
}
|
||||
onToggle();
|
||||
swipeableRef.current?.close();
|
||||
}}
|
||||
overshootRight={false}
|
||||
@@ -229,26 +218,26 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.titleRow}>
|
||||
<View style={styles.categoryDotSlot}>
|
||||
{categoryColor ? (
|
||||
{categoryColor && (
|
||||
<View style={[styles.categoryDot, { backgroundColor: categoryColor }]} />
|
||||
) : null}
|
||||
)}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.checkCircle, !canComplete && !task.completed && styles.checkCircleDisabled]}
|
||||
onPress={canComplete || task.completed ? onToggle : undefined}
|
||||
style={styles.checkCircle}
|
||||
onPress={onToggle}
|
||||
activeOpacity={0.7}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityLabel={task.completed ? 'Mark incomplete' : canComplete ? 'Mark complete' : 'Task not due yet'}
|
||||
accessibilityState={{ checked: task.completed, disabled: !canComplete && !task.completed }}
|
||||
accessibilityLabel={task.completed ? 'Mark incomplete' : 'Mark complete'}
|
||||
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 ? (
|
||||
<>
|
||||
<Circle cx={12} cy={12} r={10} fill={theme.accent} />
|
||||
<Path d="M7 12l3 3 7-7" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</>
|
||||
) : (
|
||||
<Circle cx={12} cy={12} r={10} stroke={!canComplete ? theme.textFaint : isOverdue ? '#E53935' : theme.textMuted} strokeWidth={2} fill="none" />
|
||||
<Circle cx={12} cy={12} r={10} stroke={isOverdue ? theme.danger : theme.textSecondary} strokeWidth={2.5} fill="none" />
|
||||
)}
|
||||
</Svg>
|
||||
</TouchableOpacity>
|
||||
@@ -286,8 +275,8 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
||||
<Svg width={14} height={14} viewBox="0 0 24 24">
|
||||
<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"
|
||||
stroke={theme.textMuted}
|
||||
strokeWidth={2}
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
@@ -301,7 +290,7 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
||||
<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"
|
||||
stroke={theme.accent}
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
@@ -314,8 +303,8 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24">
|
||||
<Path
|
||||
d="M6 9l6 6 6-6"
|
||||
stroke={theme.textMuted}
|
||||
strokeWidth={2}
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
@@ -326,8 +315,8 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
||||
{hasDueDate && (
|
||||
<View style={styles.dueRow}>
|
||||
<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" />
|
||||
<Path d="M12 6v6l4 2" stroke={theme.textMuted} strokeWidth={1.5} strokeLinecap="round" />
|
||||
<Circle cx={12} cy={12} r={10} stroke={theme.textSecondary} strokeWidth={2} fill="none" />
|
||||
<Path d="M12 6v6l4 2" stroke={theme.textSecondary} strokeWidth={2} strokeLinecap="round" />
|
||||
</Svg>
|
||||
<Animated.Text
|
||||
style={[
|
||||
@@ -344,8 +333,8 @@ const renderLeftActions = (progress: Animated.AnimatedInterpolation<number>) =>
|
||||
<Svg width={13} height={13} viewBox="0 0 24 24">
|
||||
<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"
|
||||
stroke={theme.textFaint}
|
||||
strokeWidth={1.6}
|
||||
stroke={theme.textSecondary}
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
@@ -409,7 +398,7 @@ function formatTime12(time: string): string {
|
||||
return `${h}:${String(minutes).padStart(2, '0')} ${period}`;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const createStyles = (theme: ThemeColors) => StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -432,10 +421,10 @@ const styles = StyleSheet.create({
|
||||
marginBottom: 4,
|
||||
},
|
||||
taskOverdue: {
|
||||
borderColor: '#4A2B2B',
|
||||
borderColor: theme.dangerBorder,
|
||||
},
|
||||
taskDueToday: {
|
||||
borderColor: '#1E88E5',
|
||||
borderColor: mixHex(theme.info, theme.background, 0.55),
|
||||
},
|
||||
taskSelected: {
|
||||
shadowColor: '#000',
|
||||
@@ -444,8 +433,8 @@ const styles = StyleSheet.create({
|
||||
elevation: 2,
|
||||
},
|
||||
taskHovered: {
|
||||
borderColor: '#1E88E5',
|
||||
backgroundColor: 'rgba(30, 136, 229, 0.05)',
|
||||
borderColor: mixHex(theme.info, theme.background, 0.55),
|
||||
backgroundColor: mixHex(theme.info, theme.background, 0.85),
|
||||
},
|
||||
dragLifted: {
|
||||
zIndex: 100,
|
||||
@@ -482,9 +471,9 @@ const styles = StyleSheet.create({
|
||||
marginRight: 8,
|
||||
},
|
||||
categoryDot: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
},
|
||||
title: {
|
||||
fontSize: 17,
|
||||
@@ -494,7 +483,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
titleCompleted: {
|
||||
textDecorationLine: 'line-through',
|
||||
color: '#9E9E9E',
|
||||
color: theme.textMuted,
|
||||
},
|
||||
repeatIcon: {
|
||||
marginLeft: 8,
|
||||
@@ -518,7 +507,7 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
},
|
||||
titleOverdue: {
|
||||
color: '#E53935',
|
||||
color: theme.danger,
|
||||
},
|
||||
priorityBadge: {
|
||||
paddingHorizontal: 6,
|
||||
@@ -536,14 +525,14 @@ const styles = StyleSheet.create({
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#4A2B2B',
|
||||
backgroundColor: theme.dangerSoft,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
assigneeText: {
|
||||
fontSize: 9,
|
||||
fontWeight: '700',
|
||||
color: '#EF5350',
|
||||
color: theme.danger,
|
||||
},
|
||||
dueRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -554,11 +543,11 @@ const styles = StyleSheet.create({
|
||||
fontSize: 13,
|
||||
},
|
||||
dueTextOverdue: {
|
||||
color: '#E53935',
|
||||
color: theme.danger,
|
||||
fontWeight: '600',
|
||||
},
|
||||
dueTextDueToday: {
|
||||
color: '#1E88E5',
|
||||
color: theme.info,
|
||||
fontWeight: '600',
|
||||
},
|
||||
menuButton: {
|
||||
@@ -573,24 +562,21 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'rgba(30, 136, 229, 0.15)',
|
||||
backgroundColor: mixHex(theme.info, theme.background, 0.85),
|
||||
},
|
||||
allDayText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: '#1E88E5',
|
||||
color: theme.info,
|
||||
},
|
||||
checkCircle: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
checkCircleDisabled: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
swipeAction: {
|
||||
width: 80,
|
||||
alignItems: 'center',
|
||||
@@ -598,12 +584,12 @@ const styles = StyleSheet.create({
|
||||
gap: 4,
|
||||
},
|
||||
swipeDelete: {
|
||||
backgroundColor: '#E53935',
|
||||
backgroundColor: theme.dangerStrong,
|
||||
borderTopRightRadius: 16,
|
||||
borderBottomRightRadius: 16,
|
||||
},
|
||||
swipeComplete: {
|
||||
backgroundColor: '#43A047',
|
||||
backgroundColor: theme.successStrong,
|
||||
borderTopLeftRadius: 16,
|
||||
borderBottomLeftRadius: 16,
|
||||
},
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import React, { useState, useCallback, useMemo, useRef } from 'react';
|
||||
import { View, Text, StyleSheet, Animated, RefreshControl, Alert, TouchableOpacity } from 'react-native';
|
||||
import { useTasks } from '@/hooks/useTasks';
|
||||
import { useSubtasks } from '@/hooks/useSubtasks';
|
||||
import { useCategories } from '@/hooks/useDatabase';
|
||||
import { useTaskModals } from '@/hooks/useTaskModals';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
import { TaskItem } from './TaskItem';
|
||||
import { SubtaskItem } from './SubtaskItem';
|
||||
import { TaskData, SubtaskData } from '@/types';
|
||||
import { TaskData, SubtaskData, parseTaskTags } from '@/types';
|
||||
import Task from '@/models/Task';
|
||||
import { useSettings } from '@/theme';
|
||||
import { runSyncGuarded } from '@/database/sync';
|
||||
import {
|
||||
toggleTaskComplete,
|
||||
confirmAndToggleTaskComplete,
|
||||
deleteTask,
|
||||
setTaskCompleted,
|
||||
convertTaskToSubtask,
|
||||
convertSubtaskToTask,
|
||||
moveSubtaskToTask,
|
||||
setSubtaskParent,
|
||||
toggleSubtaskComplete,
|
||||
fetchSubtaskTree,
|
||||
} from '@/utils/taskActions';
|
||||
import Svg, { Path, Rect } from 'react-native-svg';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
interface TaskListProps {
|
||||
categoryId?: string;
|
||||
categoryIds?: string[];
|
||||
showCompleted?: boolean;
|
||||
onSelectionChange?: (active: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -38,10 +42,10 @@ const DropIndicator = ({ theme }: { theme: any }) => (
|
||||
</View>
|
||||
);
|
||||
|
||||
export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProps) {
|
||||
export function TaskList({ categoryIds = [], showCompleted = false, onSelectionChange }: TaskListProps) {
|
||||
const { theme, sortBy, todoAheadDays } = useSettings();
|
||||
|
||||
const { tasks, loading } = useTasks(categoryId, 'all', todoAheadDays);
|
||||
const { tasks, loading, refresh: refreshTasks } = useTasks(categoryIds, showCompleted ? 'all' : false, todoAheadDays);
|
||||
|
||||
const categories = useCategories();
|
||||
const categoryColors = useMemo(() => {
|
||||
@@ -57,11 +61,12 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [hoverTaskId, setHoverTaskId] = useState<string | null>(null);
|
||||
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
|
||||
const [subtasksMap, setSubtasksMap] = useState<Map<string, SubtaskData[]>>(new Map());
|
||||
const { map: subtasksMap, refresh: refreshSubtasks } = useSubtasks();
|
||||
const [dropIndicator, setDropIndicator] = useState<{ targetId: string | null; position: 'above' | 'below' } | null>(null);
|
||||
const itemRefs = useRef<Map<string, View>>(new Map());
|
||||
const dragStateRef = useRef<{ taskId: string; positions: Record<string, { top: number; bottom: number }> } | null>(null);
|
||||
const subtaskDragRef = useRef<{ subtaskId: string; parentTaskId: string; positions: Record<string, { top: number; bottom: number }> } | null>(null);
|
||||
const subtaskRefs = useRef<Map<string, { ref: View; parentTaskId: string }>>(new Map());
|
||||
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 {
|
||||
modals,
|
||||
openTaskDelete,
|
||||
@@ -78,6 +83,19 @@ 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 categoryColorResolver = useCallback(
|
||||
(categoryId: string | undefined) => (categoryId ? categoryColors.get(categoryId) : undefined),
|
||||
[categoryColors]
|
||||
);
|
||||
|
||||
const sortedTasks = useMemo(() => {
|
||||
const sorted = [...tasks];
|
||||
switch (sortBy) {
|
||||
@@ -94,13 +112,32 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
sorted.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
const active: typeof sorted = [];
|
||||
const done: typeof sorted = [];
|
||||
for (const t of sorted) {
|
||||
(t.completed ? done : active).push(t);
|
||||
}
|
||||
return [...active, ...done];
|
||||
}, [tasks, sortBy]);
|
||||
|
||||
const onRefresh = useCallback(() => {
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
refreshTasks();
|
||||
refreshSubtasks();
|
||||
}, [refreshTasks, refreshSubtasks])
|
||||
);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await runSyncGuarded();
|
||||
} catch (error) {
|
||||
if (__DEV__) console.warn('Sync failed:', error);
|
||||
}
|
||||
refreshTasks();
|
||||
refreshSubtasks();
|
||||
setTimeout(() => setRefreshing(false), 600);
|
||||
}, []);
|
||||
}, [refreshTasks, refreshSubtasks]);
|
||||
|
||||
const exitSelection = useCallback(() => {
|
||||
setSelectionMode(false);
|
||||
@@ -130,22 +167,9 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
});
|
||||
}, [onSelectionChange]);
|
||||
|
||||
const fetchSubtasks = useCallback(async (taskId: string) => {
|
||||
const withNested = await fetchSubtaskTree(taskId);
|
||||
setSubtasksMap((prev) => new Map(prev).set(taskId, withNested));
|
||||
return withNested;
|
||||
}, []);
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
for (const taskId of expandedTasks) {
|
||||
fetchSubtasks(taskId);
|
||||
}
|
||||
}, [expandedTasks, fetchSubtasks]);
|
||||
|
||||
const handleToggle = useCallback(async (taskId: string) => {
|
||||
await toggleTaskComplete(taskId);
|
||||
refreshAll();
|
||||
}, [refreshAll]);
|
||||
await confirmAndToggleTaskComplete(taskId, () => refreshTasks());
|
||||
}, [refreshTasks]);
|
||||
|
||||
const toggleExpand = useCallback(async (taskId: string) => {
|
||||
setExpandedTasks((prev) => {
|
||||
@@ -157,23 +181,13 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isCurrentlyExpanded = expandedTasks.has(taskId);
|
||||
if (isCurrentlyExpanded) {
|
||||
setSubtasksMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(taskId);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
await fetchSubtasks(taskId);
|
||||
}
|
||||
}, [expandedTasks, fetchSubtasks]);
|
||||
|
||||
const handleSubtaskToggle = useCallback(async (subtaskId: string, taskId: string) => {
|
||||
const handleSubtaskToggle = useCallback(async (subtaskId: string) => {
|
||||
await toggleSubtaskComplete(subtaskId);
|
||||
await fetchSubtasks(taskId);
|
||||
}, [fetchSubtasks]);
|
||||
refreshTasks();
|
||||
refreshSubtasks();
|
||||
}, [refreshTasks, refreshSubtasks]);
|
||||
|
||||
const handleBulkDelete = useCallback(() => {
|
||||
Alert.alert(`Delete ${selectedIds.size} task${selectedIds.size > 1 ? 's' : ''}?`, 'This cannot be undone.', [
|
||||
@@ -184,17 +198,15 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
onPress: async () => {
|
||||
await Promise.all(Array.from(selectedIds).map(taskId => deleteTask(taskId)));
|
||||
exitSelection();
|
||||
refreshAll();
|
||||
},
|
||||
},
|
||||
]);
|
||||
}, [selectedIds, exitSelection, refreshAll]);
|
||||
}, [selectedIds, exitSelection]);
|
||||
|
||||
const handleBulkComplete = useCallback(async () => {
|
||||
await Promise.all(Array.from(selectedIds).map(taskId => setTaskCompleted(taskId, true)));
|
||||
exitSelection();
|
||||
refreshAll();
|
||||
}, [selectedIds, exitSelection, refreshAll]);
|
||||
}, [selectedIds, exitSelection]);
|
||||
|
||||
const measureItems = useCallback(async () => {
|
||||
const positions: Record<string, { top: number; bottom: number }> = {};
|
||||
@@ -210,7 +222,30 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
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)) {
|
||||
if (id === draggedId) continue;
|
||||
if (absoluteY >= p.top && absoluteY <= p.bottom) {
|
||||
@@ -228,17 +263,43 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
}, []);
|
||||
|
||||
const handleDragStart = useCallback(async (taskId: string) => {
|
||||
dragStateRef.current = { taskId, positions: await measureItems() };
|
||||
}, [measureItems]);
|
||||
dragStateRef.current = { taskId, ...(await measureAll()) };
|
||||
}, [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 state = dragStateRef.current;
|
||||
if (!state) return;
|
||||
const target = findHoverTarget(absoluteY, state.taskId, state.positions);
|
||||
setHoverTaskId((prev) => (prev === target ? prev : target));
|
||||
const target = findHoverTarget(absoluteY, state.taskId, state.positions, state.subPositions);
|
||||
updateHoverTarget(target);
|
||||
if (target) {
|
||||
const position = calculateDropPosition(absoluteY, target, state.positions);
|
||||
setDropIndicator({ targetId: target, position });
|
||||
if (state.subPositions[target]) {
|
||||
setDropIndicator(null);
|
||||
} else {
|
||||
const position = calculateDropPosition(absoluteY, target, state.positions);
|
||||
setDropIndicator({ targetId: target, position });
|
||||
}
|
||||
} else {
|
||||
// Check if below last item
|
||||
const positions = state.positions;
|
||||
@@ -249,7 +310,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
setDropIndicator(null);
|
||||
}
|
||||
}
|
||||
}, [findHoverTarget, calculateDropPosition]);
|
||||
}, [findHoverTarget, calculateDropPosition, updateHoverTarget]);
|
||||
|
||||
const handleDragEnd = useCallback((absoluteY: number) => {
|
||||
const state = dragStateRef.current;
|
||||
@@ -258,25 +319,47 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
setDropIndicator(null);
|
||||
if (!state) return;
|
||||
|
||||
const target = findHoverTarget(absoluteY, state.taskId, state.positions);
|
||||
const target = findHoverTarget(absoluteY, state.taskId, state.positions, state.subPositions);
|
||||
if (target) {
|
||||
(async () => {
|
||||
await convertTaskToSubtask(state.taskId, target);
|
||||
refreshAll();
|
||||
})();
|
||||
if (state.subPositions[target]) {
|
||||
const parentTaskId = subtaskRefs.current.get(target)?.parentTaskId ?? state.taskId;
|
||||
(async () => {
|
||||
await convertTaskToSubtask(state.taskId, parentTaskId, target);
|
||||
})();
|
||||
} else {
|
||||
(async () => {
|
||||
await convertTaskToSubtask(state.taskId, target);
|
||||
})();
|
||||
}
|
||||
}
|
||||
}, [findHoverTarget, refreshAll]);
|
||||
}, [findHoverTarget]);
|
||||
|
||||
const handleSubtaskDragStart = useCallback(async (subtaskId: string, parentTaskId: string) => {
|
||||
subtaskDragRef.current = { subtaskId, parentTaskId, positions: await measureItems() };
|
||||
}, [measureItems]);
|
||||
subtaskDragRef.current = { subtaskId, parentTaskId, ...(await measureAll()) };
|
||||
}, [measureAll]);
|
||||
|
||||
const handleSubtaskDragUpdate = useCallback((absoluteY: number) => {
|
||||
const state = subtaskDragRef.current;
|
||||
if (!state) return;
|
||||
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions);
|
||||
setHoverTaskId((prev) => (prev === target ? prev : target));
|
||||
}, [findHoverTarget]);
|
||||
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions, state.subPositions);
|
||||
updateHoverTarget(target);
|
||||
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 state = subtaskDragRef.current;
|
||||
@@ -285,18 +368,19 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
setDropIndicator(null);
|
||||
if (!state) return;
|
||||
|
||||
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions);
|
||||
if (target === state.parentTaskId) return;
|
||||
const target = findHoverTarget(absoluteY, state.subtaskId, state.positions, state.subPositions);
|
||||
if (target === state.subtaskId) return;
|
||||
|
||||
if (target) {
|
||||
await moveSubtaskToTask(state.subtaskId, target);
|
||||
await fetchSubtasks(target);
|
||||
if (state.subPositions[target]) {
|
||||
await setSubtaskParent(state.subtaskId, target);
|
||||
} else if (target !== state.parentTaskId) {
|
||||
await moveSubtaskToTask(state.subtaskId, target);
|
||||
}
|
||||
} else {
|
||||
await convertSubtaskToTask(state.subtaskId);
|
||||
}
|
||||
await fetchSubtasks(state.parentTaskId);
|
||||
refreshAll();
|
||||
}, [findHoverTarget, fetchSubtasks, refreshAll]);
|
||||
}, [findHoverTarget]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: Task; index: number }) => {
|
||||
@@ -304,6 +388,7 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
const itemSubtasks = subtasksMap.get(item.id) ?? [];
|
||||
const showDropAbove = dropIndicator?.targetId === item.id && dropIndicator?.position === 'above';
|
||||
const showDropBelow = dropIndicator?.targetId === item.id && dropIndicator?.position === 'below';
|
||||
const tagColors = parseTaskTags(item.tags, item.categoryId).map((id) => categoryColors.get(id));
|
||||
return (
|
||||
<View>
|
||||
{showDropAbove && <DropIndicator theme={theme} />}
|
||||
@@ -315,6 +400,8 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
selectionMode={selectionMode}
|
||||
hovered={hoverTaskId === item.id}
|
||||
registerRef={registerRef}
|
||||
registerSubtaskRef={registerSubtaskRef}
|
||||
hoverTaskId={hoverTaskId}
|
||||
onToggle={handleToggle}
|
||||
onDelete={openTaskDelete}
|
||||
onExpand={toggleExpand}
|
||||
@@ -323,7 +410,6 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
onMenuOpen={(task) => openTaskEdit(task.id)}
|
||||
onSubtaskToggle={handleSubtaskToggle}
|
||||
onSubtaskDelete={openSubtaskDelete}
|
||||
onSubtaskEdit={openSubtaskEdit}
|
||||
onSubtaskMenuOpen={(subtask) => openSubtaskEdit(subtask.id)}
|
||||
onDragStart={handleDragStart}
|
||||
onDragUpdate={handleDragUpdate}
|
||||
@@ -336,6 +422,8 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
onReorderEnd={handleDragEnd}
|
||||
selectedIds={selectedIds}
|
||||
categoryColor={categoryColors.get(item.categoryId)}
|
||||
categoryColors={tagColors}
|
||||
categoryColorResolver={categoryColorResolver}
|
||||
/>
|
||||
{showDropBelow && <DropIndicator theme={theme} />}
|
||||
</View>
|
||||
@@ -365,6 +453,8 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
handleSubtaskDragStart,
|
||||
handleSubtaskDragUpdate,
|
||||
handleSubtaskDragEnd,
|
||||
registerSubtaskRef,
|
||||
categoryColorResolver,
|
||||
categoryColors,
|
||||
]
|
||||
);
|
||||
@@ -414,20 +504,20 @@ export function TaskList({ categoryId = 'all', onSelectionChange }: TaskListProp
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
|
||||
{modals(refreshAll)}
|
||||
{modals(() => {})}
|
||||
|
||||
{selectionMode && (
|
||||
<View style={[styles.selectionBar, { backgroundColor: theme.sheetBg, borderTopColor: theme.border }]}>
|
||||
<TouchableOpacity onPress={exitSelection} style={styles.selectionCancel} activeOpacity={0.7}>
|
||||
<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>
|
||||
<Text style={[styles.selectionCancelText, { color: theme.textFaint }]}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.selectionCount, { color: theme.text }]}>{selectedIds.size} selected</Text>
|
||||
<View style={styles.selectionActions}>
|
||||
<SelectionButton label="Complete" color="#43A047" onPress={handleBulkComplete} />
|
||||
<SelectionButton label="Delete" color="#E53935" onPress={handleBulkDelete} />
|
||||
<SelectionButton label="Complete" color={theme.successStrong} onPress={handleBulkComplete} />
|
||||
<SelectionButton label="Delete" color={theme.dangerStrong} onPress={handleBulkDelete} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
@@ -455,6 +545,8 @@ interface TaskRowProps {
|
||||
selectionMode: boolean;
|
||||
hovered: boolean;
|
||||
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>;
|
||||
onDelete: (task: Task) => void;
|
||||
onExpand: (taskId: string) => void | Promise<void>;
|
||||
@@ -463,7 +555,6 @@ interface TaskRowProps {
|
||||
onMenuOpen: (task: Task) => void;
|
||||
onSubtaskToggle: (subtaskId: string, taskId: string) => void;
|
||||
onSubtaskDelete: (subtask: SubtaskData) => void;
|
||||
onSubtaskEdit: (subtaskId: string) => void;
|
||||
onSubtaskMenuOpen: (subtask: SubtaskData) => void;
|
||||
onDragStart: (taskId: string) => void;
|
||||
onDragUpdate: (absoluteY: number) => void;
|
||||
@@ -476,6 +567,8 @@ interface TaskRowProps {
|
||||
onReorderEnd: (absoluteY: number, translationY: number) => void;
|
||||
selectedIds: Set<string>;
|
||||
categoryColor?: string;
|
||||
categoryColors?: (string | undefined)[];
|
||||
categoryColorResolver?: (categoryId: string | undefined) => string | undefined;
|
||||
}
|
||||
|
||||
const TaskRow = React.memo(function TaskRow({
|
||||
@@ -485,7 +578,9 @@ const TaskRow = React.memo(function TaskRow({
|
||||
selected,
|
||||
selectionMode,
|
||||
hovered,
|
||||
hoverTaskId,
|
||||
registerRef,
|
||||
registerSubtaskRef,
|
||||
onToggle,
|
||||
onDelete,
|
||||
onExpand,
|
||||
@@ -494,7 +589,6 @@ const TaskRow = React.memo(function TaskRow({
|
||||
onMenuOpen,
|
||||
onSubtaskToggle,
|
||||
onSubtaskDelete,
|
||||
onSubtaskEdit,
|
||||
onSubtaskMenuOpen,
|
||||
onDragStart,
|
||||
onDragUpdate,
|
||||
@@ -507,6 +601,8 @@ const TaskRow = React.memo(function TaskRow({
|
||||
onReorderEnd,
|
||||
selectedIds,
|
||||
categoryColor,
|
||||
categoryColors,
|
||||
categoryColorResolver,
|
||||
}: TaskRowProps) {
|
||||
const sortedSubtasks = useMemo(
|
||||
() => subtasks.slice().sort((a, b) => a.order - b.order),
|
||||
@@ -536,24 +632,27 @@ const TaskRow = React.memo(function TaskRow({
|
||||
onReorderUpdate={onReorderUpdate}
|
||||
onReorderEnd={onReorderEnd}
|
||||
categoryColor={categoryColor}
|
||||
categoryColors={categoryColors}
|
||||
/>
|
||||
{expanded && subtasks.length > 0 && (
|
||||
<View style={styles.subtaskList}>
|
||||
{sortedSubtasks.map((sub) => (
|
||||
<SubtaskItem
|
||||
key={sub.id}
|
||||
subtask={sub}
|
||||
onToggle={() => onSubtaskToggle(sub.id, task.id)}
|
||||
<SubtaskItem
|
||||
key={sub.id}
|
||||
subtask={sub}
|
||||
hoveredId={hoverTaskId}
|
||||
registerRef={registerSubtaskRef}
|
||||
onToggle={(sub) => onSubtaskToggle(sub.id, task.id)}
|
||||
onDelete={() => onSubtaskDelete(sub)}
|
||||
onPress={() => onSubtaskEdit(sub.id)}
|
||||
onMenuOpen={() => onSubtaskMenuOpen(sub)}
|
||||
selected={selectedIds.has(sub.id)}
|
||||
selectionMode={selectionMode}
|
||||
draggable
|
||||
onDragStart={() => onSubtaskDragStart(sub.id, task.id)}
|
||||
onDragStart={(sub) => onSubtaskDragStart(sub.id, task.id)}
|
||||
onDragUpdate={onSubtaskDragUpdate}
|
||||
onDragEnd={onSubtaskDragEnd}
|
||||
categoryColor={categoryColor}
|
||||
categoryColorResolver={categoryColorResolver}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
@@ -569,7 +668,7 @@ const styles = StyleSheet.create({
|
||||
listContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 120,
|
||||
paddingBottom: 12,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TextInput , TextInputProps } from 'react-native';
|
||||
import { useSettings } from '@/theme';
|
||||
import { useSettings, type ThemeColors } from '@/theme';
|
||||
|
||||
interface TaskNameInputProps extends TextInputProps {
|
||||
error?: string;
|
||||
@@ -8,6 +8,7 @@ interface TaskNameInputProps extends TextInputProps {
|
||||
|
||||
export function TaskNameInput({ error, ...props }: TaskNameInputProps) {
|
||||
const { theme } = useSettings();
|
||||
const styles = React.useMemo(() => createStyles(theme), [theme]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -34,7 +35,7 @@ export function TaskNameInput({ error, ...props }: TaskNameInputProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const createStyles = (theme: ThemeColors) => StyleSheet.create({
|
||||
container: {
|
||||
gap: 5,
|
||||
},
|
||||
@@ -47,7 +48,7 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
},
|
||||
required: {
|
||||
color: '#E53935',
|
||||
color: theme.dangerStrong,
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
@@ -59,12 +60,12 @@ const styles = StyleSheet.create({
|
||||
fontSize: 15,
|
||||
},
|
||||
inputError: {
|
||||
borderColor: '#E53935',
|
||||
borderColor: theme.dangerStrong,
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 11,
|
||||
color: '#E53935',
|
||||
color: theme.dangerStrong,
|
||||
marginLeft: 4,
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user