Compare commits

...
40 Commits
Author SHA1 Message Date
tech08mag e86aa5e535 Merge pull request '- ./gradlew: No such file or directory - fixed by running from carry-your-live/android/' (#12) from dev into main
Build APK / build (push) Failing after 26s
Build Desktop (deb) / build (push) Failing after 5m12s
release / release (push) Successful in 7m20s
Reviewed-on: #12
2026-08-25 20:18:34 +00:00
tech08mag 59f95d8174 - ./gradlew: No such file or directory - fixed by running from carry-your-live/android/
- eas: not found - the workflow was reverted to use ./gradlew instead (since the project has native Android setup)
- local.properties hardcoded path - fixed by dynamically updating it in the workflow
2026-08-25 22:17:51 +02:00
tech08mag 159777818d Merge pull request 'updated pipe reverted back to gradle' (#11) from dev into main
Build APK / build (push) Failing after 53s
Build Desktop (deb) / build (push) Failing after 5m4s
release / release (push) Canceled after 8m10s
Reviewed-on: #11
2026-08-25 20:13:15 +00:00
tech08mag 00ac23160e updated pipe reverted back to gradle 2026-08-25 22:11:26 +02:00
tech08mag 46f9b4434e Merge pull request 'new pipe' (#10) from dev into main
Build APK / build (push) Failing after 43s
Build Desktop (deb) / build (push) Failing after 5m4s
release / release (push) Successful in 9m47s
Reviewed-on: #10
2026-08-25 20:05:45 +00:00
tech08mag d0cb43435b new pipe 2026-08-25 22:03:38 +02:00
tech08mag 34bc152817 Merge pull request 'Dev merge main' (#9) from dev-merge-main into main
Build APK / build (push) Failing after 36s
Build Desktop (deb) / build (push) Failing after 5m5s
release / release (push) Successful in 7m42s
Reviewed-on: #9
2026-08-25 19:53:03 +00:00
tech08mag fc8c5976f8 Merge rewritten server history (content unchanged) 2026-08-25 21:41:17 +02:00
tech08mag 259d21c968 custom tags 2026-08-25 16:26:10 +02:00
tech08mag 21a2cffcc0 Merge branch 'dev' 2026-08-24 22:02:11 +02:00
tech08mag 865e0b3301 gitignore: add dist/ to reduce repo size 2026-08-24 21:45:15 +02:00
tech08mag 7fad4b5eec fix: create android/app directory before generating debug keystore
The keytool command fails without the directory existing first.
Add mkdir -p before the keytool genkeypair command.
2026-08-24 19:50:01 +02:00
tech08mag 3a5b5a68e4 Merge pull request 'fix: avoid SIGPIPE when accepting Android SDK licenses' (#6) from dev into main
Build Desktop (deb) / build (push) Successful in 11m47s
release / release (push) Successful in 12m16s
Build APK / build (push) Failing after 13m22s
Reviewed-on: #6
2026-08-24 17:34:53 +00:00
tech08mag 2773255b38 Merge pull request 'fix: avoid SIGPIPE when accepting Android SDK licenses' (#6) from dev into main
Reviewed-on: #6
2026-08-24 17:34:53 +00:00
tech08mag 85ef3dc38b fix: avoid SIGPIPE when accepting Android SDK licenses
Replace 'yes | sdkmanager --licenses' with 'printf y | sdkmanager --licenses'
to prevent exit code 141 (SIGPIPE) when the pipe closes after sdkmanager exits.
2026-08-24 19:33:27 +02:00
tech08mag 87e3f59ee1 fix: avoid SIGPIPE when accepting Android SDK licenses
Replace 'yes | sdkmanager --licenses' with 'printf y | sdkmanager --licenses'
to prevent exit code 141 (SIGPIPE) when the pipe closes after sdkmanager exits.
2026-08-24 19:33:27 +02:00
tech08mag 80663f7d39 Merge pull request 'fix(ci): install android-36 platform and build-tools for release build' (#5) from fix/ci-android-sdk-version into main
Build APK / build (push) Failing after 9m35s
Build Desktop (deb) / build (push) Failing after 14m56s
release / release (push) Successful in 6m16s
Reviewed-on: #5
2026-08-24 12:52:26 +00:00
tech08mag ab4ddb9f8b Merge pull request 'fix(ci): install android-36 platform and build-tools for release build' (#5) from fix/ci-android-sdk-version into main
Reviewed-on: #5
2026-08-24 12:52:26 +00:00
tech08mag 6969e79029 fix(ci): install android-36 platform and build-tools for release build 2026-08-24 14:46:15 +02:00
tech08mag 448279d98c fix(ci): install android-36 platform and build-tools for release build 2026-08-24 14:46:15 +02:00
tech08mag 7e224301cd Merge pull request 'fix(ci): add babel-preset-expo dep and make Android SDK setup idempotent' (#4) from feat/subtask-categories-refresh into main
Reviewed-on: #4
2026-08-23 14:05:26 +00:00
tech08mag df97937948 Merge pull request 'fix(ci): add babel-preset-expo dep and make Android SDK setup idempotent' (#4) from feat/subtask-categories-refresh into main
Build APK / build (push) Failing after 9m52s
Build Desktop (deb) / build (push) Failing after 14m48s
release / release (push) Successful in 6m0s
Reviewed-on: #4
2026-08-23 14:05:26 +00:00
tech08mag ed8b406b7d fix(ci): add babel-preset-expo dep and make Android SDK setup idempotent 2026-08-23 16:02:36 +02:00
tech08mag e87d31aded fix(ci): add babel-preset-expo dep and make Android SDK setup idempotent 2026-08-23 16:02:36 +02:00
tech08mag 964a4fe0f9 Merge pull request 'electron app' (#3) from feat/subtask-categories-refresh into main
Reviewed-on: #3
2026-08-23 13:39:14 +00:00
tech08mag e07d28d6b1 Merge pull request 'electron app' (#3) from feat/subtask-categories-refresh into main
Build APK / build (push) Failing after 10m21s
Build Desktop (deb) / build (push) Failing after 11m14s
release / release (push) Successful in 7m7s
Reviewed-on: #3
2026-08-23 13:39:14 +00:00
tech08mag ab26148a67 electron app 2026-08-23 15:26:52 +02:00
tech08mag fd1c473cb6 electron app 2026-08-23 15:26:52 +02:00
tech08mag 56e6225dd1 Merge pull request 'Feat/subtask categories refresh' (#2) from feat/subtask-categories-refresh into main
release / release (push) Successful in 3s
Build APK / build (push) Failing after 11s
Reviewed-on: #2
2026-08-23 09:37:20 +00:00
tech08mag bdc6562116 working push notifiations 2026-08-23 11:36:17 +02:00
tech08mag 9312783793 ci: trigger release on any merge to main (works with squash/rebase merges) 2026-08-10 12:26:09 +02:00
tech08mag b78ada0865 Merge pull request 'feat: subtask categories + live refresh fixes + release automation' (#1) from feat/subtask-categories-refresh into main
Build APK / build (push) Failing after 3m41s
release / release (push) Successful in 6s
Reviewed-on: #1
2026-08-10 09:56:09 +00:00
tech08mag be2f77dbf1 ci: auto-create app release on approved PR merge to main 2026-08-10 11:42:49 +02:00
tech08mag 2ca23e276f feat(app): subtask categories, live refresh on save, nested subtask fixes 2026-08-10 11:41:25 +02:00
tech08mag f3bcd78e49 feat(backend): subtask category support (schema, validation, sync, routes) 2026-08-10 11:41:25 +02:00
tech08mag 4c3d1a118c fixxed color selector in the category settings and fixxed drag and drop
Build APK / build (push) Canceled after 2m0s
subtask
2026-08-09 21:42:54 +02:00
tech08mag 4b6e87c979 docker compose for backend +frontend
Build APK / build (push) Canceled after 0s
2026-08-07 22:30:00 +02:00
tech08mag ed31f24b23 functioning apk with reworked calendar 2026-08-07 22:29:46 +02:00
tech08mag ecb7fbefb1 fixxed big ui cavia
Build APK / build (push) Canceled after 0s
2026-08-07 01:06:03 +02:00
tech08mag 3d594670b0 working backend connectivity 2026-08-06 11:43:38 +02:00
155 changed files with 22090 additions and 2758 deletions
+38
View File
@@ -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}
+15 -6
View File
@@ -29,16 +29,24 @@ jobs:
- name: Install Android SDK - name: Install Android SDK
run: | run: |
mkdir -p "$ANDROID_HOME/cmdline-tools" if [ -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdtools.zip echo "Android SDK already present in cache"
unzip -q /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools" else
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" rm -rf "$ANDROID_HOME/cmdline-tools"
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null 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
printf 'y\n' | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \ "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
"platform-tools" \ "platform-tools" \
"platforms;android-35" \ "platforms;android-35" \
"platforms;android-36" \
"build-tools;35.0.0" \ "build-tools;35.0.0" \
"build-tools;36.0.0" \
"ndk;27.1.12297006" "ndk;27.1.12297006"
echo "sdk.dir=$ANDROID_HOME" > carry-your-live/android/app/local.properties
- name: Install JS dependencies - name: Install JS dependencies
working-directory: carry-your-live working-directory: carry-your-live
@@ -46,6 +54,7 @@ jobs:
- name: Generate debug keystore (signs the release APK) - name: Generate debug keystore (signs the release APK)
run: | run: |
mkdir -p carry-your-live/android/app
keytool -genkeypair -v \ keytool -genkeypair -v \
-keystore carry-your-live/android/app/debug.keystore \ -keystore carry-your-live/android/app/debug.keystore \
-alias androiddebugkey -storepass android -keypass android \ -alias androiddebugkey -storepass android -keypass android \
@@ -60,4 +69,4 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: carry-your-live-release name: carry-your-live-release
path: carry-your-live/android/app/build/outputs/apk/release/*.apk path: carry-your-live/android/app/build/outputs/release/app-release.apk
+36
View File
@@ -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
+56
View File
@@ -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
+2 -1
View File
@@ -1 +1,2 @@
node_modules node_modules/
dist/
+55
View File
@@ -57,6 +57,7 @@ GET /sync
{ {
"id": "sub_1", "id": "sub_1",
"taskId": "task_1", "taskId": "task_1",
"parentSubtaskId": null,
"title": "Setup Expo project", "title": "Setup Expo project",
"completed": true, "completed": true,
"order": 0, "order": 0,
@@ -64,10 +65,21 @@ GET /sync
"updatedAt": 1699000000000 "updatedAt": 1699000000000
} }
], ],
"repeatProfiles": [],
"friendships": [],
"deleted": [
{
"entity": "tasks",
"id": "task_5",
"updatedAt": 1699150000000
}
],
"timestamp": 1699150000000 "timestamp": 1699150000000
} }
``` ```
`deleted` contains every row tombstoned (deleted) since `since` — one entry per entity type/id. Clients must remove the row locally (and any related rows: a task deletion implies its subtasks, a category deletion implies its tasks and their subtasks, a parent subtask deletion implies its children).
**Error Responses** **Error Responses**
| Status | Code | Message | | Status | Code | Message |
|--------|------|---------| |--------|------|---------|
@@ -114,6 +126,7 @@ POST /sync/push
{ {
"id": "sub_new", "id": "sub_new",
"taskId": "task_new", "taskId": "task_new",
"parentSubtaskId": null,
"title": "Subtask 1", "title": "Subtask 1",
"completed": false, "completed": false,
"order": 0, "order": 0,
@@ -122,10 +135,19 @@ POST /sync/push
} }
] ]
}, },
"deleted": [
{
"entity": "categories",
"id": "cat_gone",
"updatedAt": 1699150000000
}
],
"lastPulledAt": 1699100000000 "lastPulledAt": 1699100000000
} }
``` ```
`deleted` is a list of tombstones the client recorded locally since its last successful sync. Deletions are resolved by last-writer-wins: if the server row's `updatedAt` is newer than the tombstone's `updatedAt`, the deletion is rejected and reported as a `server_wins` conflict (with the server row as `serverVersion`) so the client re-pulls it. Accepted deletions remove the rows server-side; cascaded rows (subtasks of a deleted task, tasks/subtasks of a deleted category, children of a deleted parent subtask) are tombstoned automatically so every device removes them.
**Response** (200 OK) **Response** (200 OK)
```json ```json
{ {
@@ -568,6 +590,16 @@ interface Task {
completed: boolean; completed: boolean;
dueDate: number; // Unix timestamp (ms), 0 if not set dueDate: number; // Unix timestamp (ms), 0 if not set
dueTime: string; // HH:MM format, empty if not set dueTime: string; // HH:MM format, empty if not set
endTime: string; // HH:MM format, empty if not set
allDay: boolean;
repeat: 'none' | 'daily' | 'weekly' | 'monthly' | 'custom';
repeatInterval: number;
repeatDays: string; // comma separated weekday numbers, e.g. "0,2,4"
seriesId: string; // repeating-series id, empty if not part of a series
reminder: 'none' | 'at_time' | '15' | '30' | '60' | '120' | '1440';
reminders: string; // comma separated reminder values, empty if none
assigneeId: string | null;
completedAt: number | null; // Unix timestamp (ms) when completed
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
subtasks?: Subtask[]; subtasks?: Subtask[];
@@ -579,14 +611,37 @@ interface Task {
interface Subtask { interface Subtask {
id: string; id: string;
taskId: string; taskId: string;
parentSubtaskId: string | null; // id of the parent subtask, null for top-level
title: string; title: string;
description: string;
priority: 'none' | 'low' | 'medium' | 'high' | 'critical';
completed: boolean; completed: boolean;
dueDate: number;
dueTime: string;
endTime: string;
allDay: boolean;
repeat: 'none' | 'daily' | 'weekly' | 'monthly' | 'custom';
repeatInterval: number;
repeatDays: string;
seriesId: string;
reminder: 'none' | 'at_time' | '15' | '30' | '60' | '120' | '1440';
reminders: string;
assigneeId: string | null;
order: number; order: number;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
} }
``` ```
### Tombstone
```typescript
interface Tombstone {
entity: 'categories' | 'tasks' | 'subtasks' | 'repeatProfiles' | 'friendships';
id: string; // id of the deleted row
updatedAt: number; // Unix timestamp (ms) of the deletion
}
```
### UserSettings ### UserSettings
```typescript ```typescript
interface UserSettings { interface UserSettings {
+49 -1
View File
@@ -76,7 +76,8 @@ A minimalist, offline-first task management app built with Expo, React Native, a
| UI Components | React Native Paper + Custom SVG icons | | UI Components | React Native Paper + Custom SVG icons |
| Animations | React Native Reanimated | | Animations | React Native Reanimated |
| Date/Time | @react-native-community/datetimepicker + date-fns | | Date/Time | @react-native-community/datetimepicker + date-fns |
| Build | Expo Dev Client / EAS Build | | Build | EAS Build / Gradle (local APK) |
| CI/CD | Gitea Actions (`.gitea/workflows/build-apk.yml`) |
## Project Structure ## Project Structure
@@ -210,6 +211,8 @@ npx expo start --web
``` ```
### Building ### Building
#### EAS Build (cloud)
```bash ```bash
# Install EAS CLI # Install EAS CLI
npm install -g eas-cli npm install -g eas-cli
@@ -222,6 +225,51 @@ eas build --platform ios
eas build --platform android eas build --platform android
eas build --platform web eas build --platform web
``` ```
Profiles are defined in `eas.json` (`development`, `preview`, `production`; production auto-increments version code).
#### Gradle (local Android APK)
The native Android project lives in `carry-your-live/android/`. Requires JDK 17+ and the Android SDK (platform 35, build-tools 35.0.0, NDK 27.1).
```bash
cd carry-your-live/android
# Debug APK (unsigned)
./gradlew assembleDebug
# Release APK (currently signed with the debug keystore)
./gradlew assembleRelease
```
Output:
```
android/app/build/outputs/apk/debug/app-debug.apk
android/app/build/outputs/apk/release/app-release.apk
```
Key notes:
- The debug keystore (`android/app/debug.keystore`) is generated by the CI pipeline and must exist for release builds — create it locally with the same command used in the pipeline (see below) if it's missing.
- Release builds use `signingConfig signingConfigs.debug` until a production keystore is configured.
- Versions are set in `android/app/build.gradle` (`versionCode`, `versionName`).
## CI/CD Pipeline (Gitea Actions)
`.gitea/workflows/build-apk.yml` builds a release APK on every push to `main` (also manually triggerable via workflow_dispatch):
1. **Checkout** and setup Node 22, JDK 17 (Temurin)
2. **Android SDK**: installs cmdline-tools, licenses, platform-tools, `platforms;android-35`, `build-tools;35.0.0`, `ndk;27.1.12297006`
3. **JS deps**: `npm ci` in `carry-your-live/`
4. **Debug keystore**: generates `carry-your-live/android/app/debug.keystore` with `keytool` (alias `androiddebugkey`, passwords `android`)
5. **Build**: `./gradlew assembleRelease` in `carry-your-live/android/`
6. **Upload**: the APK is saved as the `carry-your-live-release` artifact (downloadable from the run's artifacts page)
To run the build locally exactly as CI does:
```bash
keytool -genkeypair -v \
-keystore carry-your-live/android/app/debug.keystore \
-alias androiddebugkey -storepass android -keypass android \
-keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=Android Debug,O=Android,C=US"
cd carry-your-live/android && ./gradlew assembleRelease
```
## Sync API Specification ## Sync API Specification
+157
View File
@@ -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."
+513 -136
View File
@@ -1,3 +1,5 @@
export declare const ENTITIES: readonly ["categories", "tasks", "subtasks", "repeatProfiles", "friendships"];
export type EntityName = (typeof ENTITIES)[number];
export declare const users: import("drizzle-orm/pg-core").PgTableWithColumns<{ export declare const users: import("drizzle-orm/pg-core").PgTableWithColumns<{
name: "users"; name: "users";
schema: undefined; schema: undefined;
@@ -50,6 +52,38 @@ export declare const users: import("drizzle-orm/pg-core").PgTableWithColumns<{
baseColumn: never; baseColumn: never;
generated: undefined; generated: undefined;
}, {}, {}>; }, {}, {}>;
resetToken: import("drizzle-orm/pg-core").PgColumn<{
name: "reset_token";
tableName: "users";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: false;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
resetTokenExpiry: import("drizzle-orm/pg-core").PgColumn<{
name: "reset_token_expiry";
tableName: "users";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: false;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
createdAt: import("drizzle-orm/pg-core").PgColumn<{ createdAt: import("drizzle-orm/pg-core").PgColumn<{
name: "created_at"; name: "created_at";
tableName: "users"; tableName: "users";
@@ -334,7 +368,7 @@ export declare const tasks: import("drizzle-orm/pg-core").PgTableWithColumns<{
columnType: "PgText"; columnType: "PgText";
data: string; data: string;
driverParam: string; driverParam: string;
notNull: true; notNull: false;
hasDefault: false; hasDefault: false;
isPrimaryKey: false; isPrimaryKey: false;
isAutoincrement: false; isAutoincrement: false;
@@ -455,6 +489,22 @@ export declare const tasks: import("drizzle-orm/pg-core").PgTableWithColumns<{
baseColumn: never; baseColumn: never;
generated: undefined; generated: undefined;
}, {}, {}>; }, {}, {}>;
allDay: import("drizzle-orm/pg-core").PgColumn<{
name: "all_day";
tableName: "tasks";
dataType: "boolean";
columnType: "PgBoolean";
data: boolean;
driverParam: boolean;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
repeat: import("drizzle-orm/pg-core").PgColumn<{ repeat: import("drizzle-orm/pg-core").PgColumn<{
name: "repeat"; name: "repeat";
tableName: "tasks"; tableName: "tasks";
@@ -551,6 +601,38 @@ export declare const tasks: import("drizzle-orm/pg-core").PgTableWithColumns<{
baseColumn: never; baseColumn: never;
generated: undefined; generated: undefined;
}, {}, {}>; }, {}, {}>;
reminders: import("drizzle-orm/pg-core").PgColumn<{
name: "reminders";
tableName: "tasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
completedAt: import("drizzle-orm/pg-core").PgColumn<{
name: "completed_at";
tableName: "tasks";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: false;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
createdAt: import("drizzle-orm/pg-core").PgColumn<{ createdAt: import("drizzle-orm/pg-core").PgColumn<{
name: "created_at"; name: "created_at";
tableName: "tasks"; tableName: "tasks";
@@ -586,6 +668,436 @@ export declare const tasks: import("drizzle-orm/pg-core").PgTableWithColumns<{
}; };
dialect: "pg"; dialect: "pg";
}>; }>;
export declare const tombstones: import("drizzle-orm/pg-core").PgTableWithColumns<{
name: "tombstones";
schema: undefined;
columns: {
entity: import("drizzle-orm/pg-core").PgColumn<{
name: "entity";
tableName: "tombstones";
dataType: "string";
columnType: "PgText";
data: "categories" | "tasks" | "subtasks" | "repeatProfiles" | "friendships";
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: ["categories", "tasks", "subtasks", "repeatProfiles", "friendships"];
baseColumn: never;
generated: undefined;
}, {}, {}>;
entityId: import("drizzle-orm/pg-core").PgColumn<{
name: "entity_id";
tableName: "tombstones";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
userId: import("drizzle-orm/pg-core").PgColumn<{
name: "user_id";
tableName: "tombstones";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
updatedAt: import("drizzle-orm/pg-core").PgColumn<{
name: "updated_at";
tableName: "tombstones";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
};
dialect: "pg";
}>;
export declare const subtasks: import("drizzle-orm/pg-core").PgTableWithColumns<{
name: "subtasks";
schema: undefined;
columns: {
id: import("drizzle-orm/pg-core").PgColumn<{
name: "id";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: true;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
userId: import("drizzle-orm/pg-core").PgColumn<{
name: "user_id";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
taskId: import("drizzle-orm/pg-core").PgColumn<{
name: "task_id";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
parentSubtaskId: import("drizzle-orm/pg-core").PgColumn<{
name: "parent_subtask_id";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: false;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
title: import("drizzle-orm/pg-core").PgColumn<{
name: "title";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
description: import("drizzle-orm/pg-core").PgColumn<{
name: "description";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
priority: import("drizzle-orm/pg-core").PgColumn<{
name: "priority";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: "none" | "low" | "medium" | "high" | "critical";
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: ["none", "low", "medium", "high", "critical"];
baseColumn: never;
generated: undefined;
}, {}, {}>;
completed: import("drizzle-orm/pg-core").PgColumn<{
name: "completed";
tableName: "subtasks";
dataType: "boolean";
columnType: "PgBoolean";
data: boolean;
driverParam: boolean;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
dueDate: import("drizzle-orm/pg-core").PgColumn<{
name: "due_date";
tableName: "subtasks";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
dueTime: import("drizzle-orm/pg-core").PgColumn<{
name: "due_time";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
endTime: import("drizzle-orm/pg-core").PgColumn<{
name: "end_time";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
allDay: import("drizzle-orm/pg-core").PgColumn<{
name: "all_day";
tableName: "subtasks";
dataType: "boolean";
columnType: "PgBoolean";
data: boolean;
driverParam: boolean;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
repeat: import("drizzle-orm/pg-core").PgColumn<{
name: "repeat";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: "custom" | "none" | "daily" | "weekly" | "monthly";
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: ["none", "daily", "weekly", "monthly", "custom"];
baseColumn: never;
generated: undefined;
}, {}, {}>;
repeatInterval: import("drizzle-orm/pg-core").PgColumn<{
name: "repeat_interval";
tableName: "subtasks";
dataType: "number";
columnType: "PgInteger";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
repeatDays: import("drizzle-orm/pg-core").PgColumn<{
name: "repeat_days";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
seriesId: import("drizzle-orm/pg-core").PgColumn<{
name: "series_id";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
reminder: import("drizzle-orm/pg-core").PgColumn<{
name: "reminder";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440";
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: ["none", "at_time", "15", "30", "60", "120", "1440"];
baseColumn: never;
generated: undefined;
}, {}, {}>;
reminders: import("drizzle-orm/pg-core").PgColumn<{
name: "reminders";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
assigneeId: import("drizzle-orm/pg-core").PgColumn<{
name: "assignee_id";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: false;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
order: import("drizzle-orm/pg-core").PgColumn<{
name: "order";
tableName: "subtasks";
dataType: "number";
columnType: "PgInteger";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
createdAt: import("drizzle-orm/pg-core").PgColumn<{
name: "created_at";
tableName: "subtasks";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
updatedAt: import("drizzle-orm/pg-core").PgColumn<{
name: "updated_at";
tableName: "subtasks";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
};
dialect: "pg";
}>;
export declare const repeatProfiles: import("drizzle-orm/pg-core").PgTableWithColumns<{ export declare const repeatProfiles: import("drizzle-orm/pg-core").PgTableWithColumns<{
name: "repeat_profiles"; name: "repeat_profiles";
schema: undefined; schema: undefined;
@@ -721,141 +1233,6 @@ export declare const repeatProfiles: import("drizzle-orm/pg-core").PgTableWithCo
}; };
dialect: "pg"; dialect: "pg";
}>; }>;
export declare const subtasks: import("drizzle-orm/pg-core").PgTableWithColumns<{
name: "subtasks";
schema: undefined;
columns: {
id: import("drizzle-orm/pg-core").PgColumn<{
name: "id";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: true;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
userId: import("drizzle-orm/pg-core").PgColumn<{
name: "user_id";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
taskId: import("drizzle-orm/pg-core").PgColumn<{
name: "task_id";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
title: import("drizzle-orm/pg-core").PgColumn<{
name: "title";
tableName: "subtasks";
dataType: "string";
columnType: "PgText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
generated: undefined;
}, {}, {}>;
completed: import("drizzle-orm/pg-core").PgColumn<{
name: "completed";
tableName: "subtasks";
dataType: "boolean";
columnType: "PgBoolean";
data: boolean;
driverParam: boolean;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
order: import("drizzle-orm/pg-core").PgColumn<{
name: "order";
tableName: "subtasks";
dataType: "number";
columnType: "PgInteger";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
createdAt: import("drizzle-orm/pg-core").PgColumn<{
name: "created_at";
tableName: "subtasks";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
updatedAt: import("drizzle-orm/pg-core").PgColumn<{
name: "updated_at";
tableName: "subtasks";
dataType: "number";
columnType: "PgBigInt53";
data: number;
driverParam: string | number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
generated: undefined;
}, {}, {}>;
};
dialect: "pg";
}>;
export declare const userSettings: import("drizzle-orm/pg-core").PgTableWithColumns<{ export declare const userSettings: import("drizzle-orm/pg-core").PgTableWithColumns<{
name: "user_settings"; name: "user_settings";
schema: undefined; schema: undefined;
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/db/schema.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,KAAK;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAKhB,CAAC;AAEH,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAevB,CAAC;AAEF,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAQrB,CAAC;AAEH,eAAO,MAAM,KAAK;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAmBhB,CAAC;AAEH,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EASzB,CAAC;AAEH,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EASnB,CAAC;AAEH,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EASvB,CAAC"} {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/db/schema.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,QAAQ,+EAAgF,CAAC;AACtG,MAAM,MAAM,UAAU,GAAG,CAAC,OAAO,QAAQ,CAAC,CAAC,MAAM,CAAC,CAAC;AAEnD,eAAO,MAAM,KAAK;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAOhB,CAAC;AAEH,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAevB,CAAC;AAEF,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAQrB,CAAC;AAEH,eAAO,MAAM,KAAK;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAsBhB,CAAC;AAEH,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAYtB,CAAC;AAGF,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA2BlB,CAAC;AAEJ,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EASzB,CAAC;AAEH,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EASvB,CAAC"}
+46 -12
View File
@@ -1,12 +1,15 @@
"use strict"; "use strict";
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.userSettings = exports.subtasks = exports.repeatProfiles = exports.tasks = exports.categories = exports.friendships = exports.users = void 0; exports.userSettings = exports.repeatProfiles = exports.subtasks = exports.tombstones = exports.tasks = exports.categories = exports.friendships = exports.users = exports.ENTITIES = void 0;
const pg_core_1 = require("drizzle-orm/pg-core"); const pg_core_1 = require("drizzle-orm/pg-core");
const drizzle_orm_1 = require("drizzle-orm"); const drizzle_orm_1 = require("drizzle-orm");
exports.ENTITIES = ['categories', 'tasks', 'subtasks', 'repeatProfiles', 'friendships'];
exports.users = (0, pg_core_1.pgTable)('users', { exports.users = (0, pg_core_1.pgTable)('users', {
id: (0, pg_core_1.text)('id').primaryKey(), id: (0, pg_core_1.text)('id').primaryKey(),
username: (0, pg_core_1.text)('username').notNull().unique(), username: (0, pg_core_1.text)('username').notNull().unique(),
passwordHash: (0, pg_core_1.text)('password_hash').notNull(), passwordHash: (0, pg_core_1.text)('password_hash').notNull(),
resetToken: (0, pg_core_1.text)('reset_token'),
resetTokenExpiry: (0, pg_core_1.bigint)('reset_token_expiry', { mode: 'number' }),
createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`), createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
}); });
exports.friendships = (0, pg_core_1.pgTable)('friendships', { exports.friendships = (0, pg_core_1.pgTable)('friendships', {
@@ -33,7 +36,7 @@ exports.categories = (0, pg_core_1.pgTable)('categories', {
exports.tasks = (0, pg_core_1.pgTable)('tasks', { exports.tasks = (0, pg_core_1.pgTable)('tasks', {
id: (0, pg_core_1.text)('id').primaryKey(), id: (0, pg_core_1.text)('id').primaryKey(),
userId: (0, pg_core_1.text)('user_id').notNull().references(() => exports.users.id, { onDelete: 'cascade' }), userId: (0, pg_core_1.text)('user_id').notNull().references(() => exports.users.id, { onDelete: 'cascade' }),
categoryId: (0, pg_core_1.text)('category_id').notNull().references(() => exports.categories.id, { onDelete: 'cascade' }), categoryId: (0, pg_core_1.text)('category_id').references(() => exports.categories.id, { onDelete: 'cascade' }),
title: (0, pg_core_1.text)('title').notNull(), title: (0, pg_core_1.text)('title').notNull(),
description: (0, pg_core_1.text)('description').notNull().default(''), description: (0, pg_core_1.text)('description').notNull().default(''),
priority: (0, pg_core_1.text)('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'), priority: (0, pg_core_1.text)('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
@@ -41,15 +44,56 @@ exports.tasks = (0, pg_core_1.pgTable)('tasks', {
dueDate: (0, pg_core_1.bigint)('due_date', { mode: 'number' }).notNull().default(0), dueDate: (0, pg_core_1.bigint)('due_date', { mode: 'number' }).notNull().default(0),
dueTime: (0, pg_core_1.text)('due_time').notNull().default(''), dueTime: (0, pg_core_1.text)('due_time').notNull().default(''),
endTime: (0, pg_core_1.text)('end_time').notNull().default(''), endTime: (0, pg_core_1.text)('end_time').notNull().default(''),
allDay: (0, pg_core_1.boolean)('all_day').notNull().default(false),
repeat: (0, pg_core_1.text)('repeat', { enum: ['none', 'daily', 'weekly', 'monthly', 'custom'] }).notNull().default('none'), repeat: (0, pg_core_1.text)('repeat', { enum: ['none', 'daily', 'weekly', 'monthly', 'custom'] }).notNull().default('none'),
repeatInterval: (0, pg_core_1.integer)('repeat_interval').notNull().default(1), repeatInterval: (0, pg_core_1.integer)('repeat_interval').notNull().default(1),
repeatDays: (0, pg_core_1.text)('repeat_days').notNull().default(''), repeatDays: (0, pg_core_1.text)('repeat_days').notNull().default(''),
seriesId: (0, pg_core_1.text)('series_id').notNull().default(''), seriesId: (0, pg_core_1.text)('series_id').notNull().default(''),
assigneeId: (0, pg_core_1.text)('assignee_id').references(() => exports.users.id, { onDelete: 'set null' }), assigneeId: (0, pg_core_1.text)('assignee_id').references(() => exports.users.id, { onDelete: 'set null' }),
reminder: (0, pg_core_1.text)('reminder', { enum: ['none', 'at_time', '15', '30', '60', '120', '1440'] }).notNull().default('none'), reminder: (0, pg_core_1.text)('reminder', { enum: ['none', 'at_time', '15', '30', '60', '120', '1440'] }).notNull().default('none'),
reminders: (0, pg_core_1.text)('reminders').notNull().default(''),
completedAt: (0, pg_core_1.bigint)('completed_at', { mode: 'number' }),
createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`), createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
updatedAt: (0, pg_core_1.bigint)('updated_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`), updatedAt: (0, pg_core_1.bigint)('updated_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
}); });
exports.tombstones = (0, pg_core_1.pgTable)('tombstones', {
entity: (0, pg_core_1.text)('entity', { enum: exports.ENTITIES }).notNull(),
entityId: (0, pg_core_1.text)('entity_id').notNull(),
userId: (0, pg_core_1.text)('user_id').notNull().references(() => exports.users.id, { onDelete: 'cascade' }),
updatedAt: (0, pg_core_1.bigint)('updated_at', { mode: 'number' }).notNull(),
}, (t) => ({
pk: (0, pg_core_1.primaryKey)({ columns: [t.entity, t.entityId] }),
userIdx: (0, pg_core_1.index)('tombstones_user_updated_idx').on(t.userId, t.updatedAt),
}));
// Define subtasks with explicit type to avoid circular reference
exports.subtasks = (0, pg_core_1.pgTable)('subtasks', {
id: (0, pg_core_1.text)('id').primaryKey(),
userId: (0, pg_core_1.text)('user_id').notNull().references(() => exports.users.id, { onDelete: 'cascade' }),
taskId: (0, pg_core_1.text)('task_id').notNull().references(() => exports.tasks.id, { onDelete: 'cascade' }),
parentSubtaskId: (0, pg_core_1.text)('parent_subtask_id').references(() => exports.subtasks.id, { onDelete: 'cascade' }),
title: (0, pg_core_1.text)('title').notNull(),
description: (0, pg_core_1.text)('description').notNull().default(''),
priority: (0, pg_core_1.text)('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
completed: (0, pg_core_1.boolean)('completed').notNull().default(false),
dueDate: (0, pg_core_1.bigint)('due_date', { mode: 'number' }).notNull().default(0),
dueTime: (0, pg_core_1.text)('due_time').notNull().default(''),
endTime: (0, pg_core_1.text)('end_time').notNull().default(''),
allDay: (0, pg_core_1.boolean)('all_day').notNull().default(false),
repeat: (0, pg_core_1.text)('repeat', { enum: ['none', 'daily', 'weekly', 'monthly', 'custom'] }).notNull().default('none'),
repeatInterval: (0, pg_core_1.integer)('repeat_interval').notNull().default(1),
repeatDays: (0, pg_core_1.text)('repeat_days').notNull().default(''),
seriesId: (0, pg_core_1.text)('series_id').notNull().default(''),
reminder: (0, pg_core_1.text)('reminder', { enum: ['none', 'at_time', '15', '30', '60', '120', '1440'] }).notNull().default('none'),
reminders: (0, pg_core_1.text)('reminders').notNull().default(''),
assigneeId: (0, pg_core_1.text)('assignee_id').references(() => exports.users.id, { onDelete: 'set null' }),
order: (0, pg_core_1.integer)('order').notNull().default(0),
createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
updatedAt: (0, pg_core_1.bigint)('updated_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
}, (t) => ({
parentIdx: (0, pg_core_1.index)('subtasks_parent_idx').on(t.parentSubtaskId),
taskIdx: (0, pg_core_1.index)('subtasks_task_idx').on(t.taskId),
userIdx: (0, pg_core_1.index)('subtasks_user_idx').on(t.userId),
}));
exports.repeatProfiles = (0, pg_core_1.pgTable)('repeat_profiles', { exports.repeatProfiles = (0, pg_core_1.pgTable)('repeat_profiles', {
id: (0, pg_core_1.text)('id').primaryKey(), id: (0, pg_core_1.text)('id').primaryKey(),
userId: (0, pg_core_1.text)('user_id').notNull().references(() => exports.users.id, { onDelete: 'cascade' }), userId: (0, pg_core_1.text)('user_id').notNull().references(() => exports.users.id, { onDelete: 'cascade' }),
@@ -60,16 +104,6 @@ exports.repeatProfiles = (0, pg_core_1.pgTable)('repeat_profiles', {
createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`), createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
updatedAt: (0, pg_core_1.bigint)('updated_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`), updatedAt: (0, pg_core_1.bigint)('updated_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
}); });
exports.subtasks = (0, pg_core_1.pgTable)('subtasks', {
id: (0, pg_core_1.text)('id').primaryKey(),
userId: (0, pg_core_1.text)('user_id').notNull().references(() => exports.users.id, { onDelete: 'cascade' }),
taskId: (0, pg_core_1.text)('task_id').notNull().references(() => exports.tasks.id, { onDelete: 'cascade' }),
title: (0, pg_core_1.text)('title').notNull(),
completed: (0, pg_core_1.boolean)('completed').notNull().default(false),
order: (0, pg_core_1.integer)('order').notNull().default(0),
createdAt: (0, pg_core_1.bigint)('created_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
updatedAt: (0, pg_core_1.bigint)('updated_at', { mode: 'number' }).notNull().default((0, drizzle_orm_1.sql) `EXTRACT(EPOCH FROM NOW()) * 1000`),
});
exports.userSettings = (0, pg_core_1.pgTable)('user_settings', { exports.userSettings = (0, pg_core_1.pgTable)('user_settings', {
userId: (0, pg_core_1.text)('user_id').primaryKey().references(() => exports.users.id, { onDelete: 'cascade' }), userId: (0, pg_core_1.text)('user_id').primaryKey().references(() => exports.users.id, { onDelete: 'cascade' }),
darkMode: (0, pg_core_1.boolean)('dark_mode').notNull().default(false), darkMode: (0, pg_core_1.boolean)('dark_mode').notNull().default(false),
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,eAAe,CAAC;AAYvB,QAAA,MAAM,GAAG,6CAAY,CAAC;AAkCtB,eAAe,GAAG,CAAC"} {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,eAAe,CAAC;AAavB,QAAA,MAAM,GAAG,6CAAY,CAAC;AAmCtB,eAAe,GAAG,CAAC"}
+2
View File
@@ -12,6 +12,7 @@ const tasks_1 = __importDefault(require("./routes/tasks"));
const subtasks_1 = __importDefault(require("./routes/subtasks")); const subtasks_1 = __importDefault(require("./routes/subtasks"));
const users_1 = __importDefault(require("./routes/users")); const users_1 = __importDefault(require("./routes/users"));
const friends_1 = __importDefault(require("./routes/friends")); const friends_1 = __importDefault(require("./routes/friends"));
const repeatProfiles_1 = __importDefault(require("./routes/repeatProfiles"));
const sync_1 = __importDefault(require("./routes/sync")); const sync_1 = __importDefault(require("./routes/sync"));
const errorHandler_1 = require("./middleware/errorHandler"); const errorHandler_1 = require("./middleware/errorHandler");
const app = (0, express_1.default)(); const app = (0, express_1.default)();
@@ -31,6 +32,7 @@ app.use('/api/categories', categories_1.default);
app.use('/api/tasks', tasks_1.default); app.use('/api/tasks', tasks_1.default);
app.use('/api/subtasks', subtasks_1.default); app.use('/api/subtasks', subtasks_1.default);
app.use('/api/users', users_1.default); app.use('/api/users', users_1.default);
app.use('/api/repeat-profiles', repeatProfiles_1.default);
app.use('/api/sync', sync_1.default); app.use('/api/sync', sync_1.default);
app.use('/api/friends', friends_1.default); app.use('/api/friends', friends_1.default);
// 404 handler // 404 handler
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;AAAA,yBAAuB;AACvB,sDAA8B;AAC9B,gDAAwB;AACxB,yDAAuC;AACvC,qEAAiD;AACjD,2DAAwC;AACxC,iEAA8C;AAC9C,2DAAwC;AACxC,+DAA4C;AAC5C,yDAAuC;AACvC,4DAA0E;AAE1E,MAAM,GAAG,GAAG,IAAA,iBAAO,GAAE,CAAC;AACtB,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC;AAEtC,GAAG,CAAC,GAAG,CAAC,IAAA,cAAI,EAAC;IACX,MAAM,EAAE,IAAI;IACZ,WAAW,EAAE,IAAI;CAClB,CAAC,CAAC,CAAC;AACJ,GAAG,CAAC,GAAG,CAAC,iBAAO,CAAC,IAAI,EAAE,CAAC,CAAC;AAExB,eAAe;AACf,GAAG,CAAC,GAAG,CAAC,SAAS,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAC9B,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;AACpD,CAAC,CAAC,CAAC;AAEH,aAAa;AACb,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,cAAU,CAAC,CAAC;AACjC,GAAG,CAAC,GAAG,CAAC,iBAAiB,EAAE,oBAAc,CAAC,CAAC;AAC3C,GAAG,CAAC,GAAG,CAAC,YAAY,EAAE,eAAU,CAAC,CAAC;AAClC,GAAG,CAAC,GAAG,CAAC,eAAe,EAAE,kBAAa,CAAC,CAAC;AACxC,GAAG,CAAC,GAAG,CAAC,YAAY,EAAE,eAAU,CAAC,CAAC;AAClC,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,cAAU,CAAC,CAAC;AACjC,GAAG,CAAC,GAAG,CAAC,cAAc,EAAE,iBAAY,CAAC,CAAC;AAEtC,cAAc;AACd,GAAG,CAAC,GAAG,CAAC,8BAAe,CAAC,CAAC;AAEzB,gBAAgB;AAChB,GAAG,CAAC,GAAG,CAAC,2BAAY,CAAC,CAAC;AAEtB,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,EAAE;IACpB,OAAO,CAAC,GAAG,CAAC,yCAAyC,IAAI,EAAE,CAAC,CAAC;IAC7D,OAAO,CAAC,GAAG,CAAC,wCAAwC,IAAI,MAAM,CAAC,CAAC;AAClE,CAAC,CAAC,CAAC;AAEH,kBAAe,GAAG,CAAC"} {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;AAAA,yBAAuB;AACvB,sDAA8B;AAC9B,gDAAwB;AACxB,yDAAuC;AACvC,qEAAiD;AACjD,2DAAwC;AACxC,iEAA8C;AAC9C,2DAAwC;AACxC,+DAA4C;AAC5C,6EAA0D;AAC1D,yDAAuC;AACvC,4DAA0E;AAE1E,MAAM,GAAG,GAAG,IAAA,iBAAO,GAAE,CAAC;AACtB,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC;AAEtC,GAAG,CAAC,GAAG,CAAC,IAAA,cAAI,EAAC;IACX,MAAM,EAAE,IAAI;IACZ,WAAW,EAAE,IAAI;CAClB,CAAC,CAAC,CAAC;AACJ,GAAG,CAAC,GAAG,CAAC,iBAAO,CAAC,IAAI,EAAE,CAAC,CAAC;AAExB,eAAe;AACf,GAAG,CAAC,GAAG,CAAC,SAAS,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAC9B,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;AACpD,CAAC,CAAC,CAAC;AAEH,aAAa;AACb,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,cAAU,CAAC,CAAC;AACjC,GAAG,CAAC,GAAG,CAAC,iBAAiB,EAAE,oBAAc,CAAC,CAAC;AAC3C,GAAG,CAAC,GAAG,CAAC,YAAY,EAAE,eAAU,CAAC,CAAC;AAClC,GAAG,CAAC,GAAG,CAAC,eAAe,EAAE,kBAAa,CAAC,CAAC;AACxC,GAAG,CAAC,GAAG,CAAC,YAAY,EAAE,eAAU,CAAC,CAAC;AAClC,GAAG,CAAC,GAAG,CAAC,sBAAsB,EAAE,wBAAmB,CAAC,CAAC;AACrD,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,cAAU,CAAC,CAAC;AACjC,GAAG,CAAC,GAAG,CAAC,cAAc,EAAE,iBAAY,CAAC,CAAC;AAEtC,cAAc;AACd,GAAG,CAAC,GAAG,CAAC,8BAAe,CAAC,CAAC;AAEzB,gBAAgB;AAChB,GAAG,CAAC,GAAG,CAAC,2BAAY,CAAC,CAAC;AAEtB,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,EAAE;IACpB,OAAO,CAAC,GAAG,CAAC,yCAAyC,IAAI,EAAE,CAAC,CAAC;IAC7D,OAAO,CAAC,GAAG,CAAC,wCAAwC,IAAI,MAAM,CAAC,CAAC;AAClE,CAAC,CAAC,CAAC;AAEH,kBAAe,GAAG,CAAC"}
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/routes/auth.ts"],"names":[],"mappings":"AASA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAmFxB,eAAe,MAAM,CAAC"} {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/routes/auth.ts"],"names":[],"mappings":"AAYA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA0LxB,eAAe,MAAM,CAAC"}
+101 -9
View File
@@ -1,4 +1,7 @@
"use strict"; "use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express"); const express_1 = require("express");
const asyncHandler_1 = require("../utils/asyncHandler"); const asyncHandler_1 = require("../utils/asyncHandler");
@@ -8,7 +11,47 @@ const drizzle_orm_1 = require("drizzle-orm");
const auth_1 = require("../utils/auth"); const auth_1 = require("../utils/auth");
const errorHandler_1 = require("../middleware/errorHandler"); const errorHandler_1 = require("../middleware/errorHandler");
const zod_1 = require("zod"); const zod_1 = require("zod");
const bcryptjs_1 = __importDefault(require("bcryptjs"));
const express_rate_limit_1 = __importDefault(require("express-rate-limit"));
const crypto_1 = __importDefault(require("crypto"));
const router = (0, express_1.Router)(); const router = (0, express_1.Router)();
// Rate limiting for auth endpoints
const authLimiter = (0, express_rate_limit_1.default)({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // limit each IP to 10 requests per windowMs
message: {
error: {
code: 'RATE_LIMITED',
message: 'Too many attempts. Please try again later.',
},
},
standardHeaders: true,
legacyHeaders: false,
});
const loginLimiter = (0, express_rate_limit_1.default)({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // stricter limit for login
message: {
error: {
code: 'RATE_LIMITED',
message: 'Too many login attempts. Please try again later.',
},
},
standardHeaders: true,
legacyHeaders: false,
});
const passwordResetLimiter = (0, express_rate_limit_1.default)({
windowMs: 60 * 60 * 1000, // 1 hour
max: 3, // very strict for password reset
message: {
error: {
code: 'RATE_LIMITED',
message: 'Too many password reset requests. Please try again later.',
},
},
standardHeaders: true,
legacyHeaders: false,
});
const usernameSchema = zod_1.z const usernameSchema = zod_1.z
.string() .string()
.min(3) .min(3)
@@ -22,15 +65,24 @@ const loginSchema = zod_1.z.object({
username: zod_1.z.string(), username: zod_1.z.string(),
password: zod_1.z.string(), password: zod_1.z.string(),
}); });
// In production, use bcrypt or argon2 for password hashing const forgotPasswordSchema = zod_1.z.object({
function hashPassword(password) { username: zod_1.z.string().min(1),
// Simple hash for demo - replace with bcrypt in production });
return Buffer.from(password).toString('base64'); const resetPasswordSchema = zod_1.z.object({
token: zod_1.z.string().min(1),
password: zod_1.z.string().min(8),
});
const BCRYPT_ROUNDS = 12;
async function hashPassword(password) {
return bcryptjs_1.default.hash(password, BCRYPT_ROUNDS);
} }
function verifyPassword(password, hash) { async function verifyPassword(password, hash) {
return hashPassword(password) === hash; return bcryptjs_1.default.compare(password, hash);
} }
router.post('/register', (0, asyncHandler_1.asyncHandler)(async (req, res) => { function generateResetToken() {
return crypto_1.default.randomBytes(32).toString('hex');
}
router.post('/register', authLimiter, (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const data = registerSchema.parse(req.body); const data = registerSchema.parse(req.body);
const existing = await db_1.db.select().from(schema_1.users).where((0, drizzle_orm_1.eq)(schema_1.users.username, data.username)).limit(1); const existing = await db_1.db.select().from(schema_1.users).where((0, drizzle_orm_1.eq)(schema_1.users.username, data.username)).limit(1);
if (existing.length > 0) { if (existing.length > 0) {
@@ -41,7 +93,7 @@ router.post('/register', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
await db_1.db.insert(schema_1.users).values({ await db_1.db.insert(schema_1.users).values({
id: userId, id: userId,
username: data.username, username: data.username,
passwordHash: hashPassword(data.password), passwordHash: await hashPassword(data.password),
createdAt: now, createdAt: now,
}); });
const token = (0, auth_1.generateToken)({ userId, username: data.username }); const token = (0, auth_1.generateToken)({ userId, username: data.username });
@@ -50,7 +102,7 @@ router.post('/register', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
token, token,
}); });
})); }));
router.post('/login', (0, asyncHandler_1.asyncHandler)(async (req, res) => { router.post('/login', loginLimiter, (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const data = loginSchema.parse(req.body); const data = loginSchema.parse(req.body);
const user = await db_1.db.select().from(schema_1.users).where((0, drizzle_orm_1.eq)(schema_1.users.username, data.username)).limit(1); const user = await db_1.db.select().from(schema_1.users).where((0, drizzle_orm_1.eq)(schema_1.users.username, data.username)).limit(1);
if (user.length === 0 || !verifyPassword(data.password, user[0].passwordHash)) { if (user.length === 0 || !verifyPassword(data.password, user[0].passwordHash)) {
@@ -72,5 +124,45 @@ router.get('/me', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
} }
res.json({ id: user[0].id, username: user[0].username }); res.json({ id: user[0].id, username: user[0].username });
})); }));
router.post('/forgot-password', passwordResetLimiter, (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const data = forgotPasswordSchema.parse(req.body);
const user = await db_1.db.select().from(schema_1.users).where((0, drizzle_orm_1.eq)(schema_1.users.username, data.username)).limit(1);
// Always return success to prevent username enumeration
if (user.length === 0) {
res.json({ success: true });
return;
}
const resetToken = generateResetToken();
const resetTokenExpiry = Date.now() + 3600000; // 1 hour
await db_1.db
.update(schema_1.users)
.set({ resetToken, resetTokenExpiry })
.where((0, drizzle_orm_1.eq)(schema_1.users.id, user[0].id));
// In production, send email with reset link
// For now, return token in response (dev only)
if (process.env.NODE_ENV !== 'production') {
res.json({ success: true, resetToken });
}
else {
res.json({ success: true });
}
}));
router.post('/reset-password', passwordResetLimiter, (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const data = resetPasswordSchema.parse(req.body);
const user = await db_1.db
.select()
.from(schema_1.users)
.where((0, drizzle_orm_1.eq)(schema_1.users.resetToken, data.token))
.limit(1);
if (user.length === 0 || !user[0].resetTokenExpiry || user[0].resetTokenExpiry < Date.now()) {
throw new errorHandler_1.AppError('INVALID_TOKEN', 'Invalid or expired reset token', 400);
}
const newPasswordHash = await hashPassword(data.password);
await db_1.db
.update(schema_1.users)
.set({ passwordHash: newPasswordHash, resetToken: null, resetTokenExpiry: null })
.where((0, drizzle_orm_1.eq)(schema_1.users.id, user[0].id));
res.json({ success: true });
}));
exports.default = router; exports.default = router;
//# sourceMappingURL=auth.js.map //# sourceMappingURL=auth.js.map
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"friends.d.ts","sourceRoot":"","sources":["../../src/routes/friends.ts"],"names":[],"mappings":"AAUA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAsKxB,eAAe,MAAM,CAAC"} {"version":3,"file":"friends.d.ts","sourceRoot":"","sources":["../../src/routes/friends.ts"],"names":[],"mappings":"AAUA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAuLxB,eAAe,MAAM,CAAC"}
+11
View File
@@ -53,6 +53,17 @@ router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
})); }));
res.json({ friends, incoming, outgoing }); res.json({ friends, incoming, outgoing });
})); }));
router.get('/search', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const { q } = validation_1.searchQuerySchema.parse(req.query);
const userId = req.user.userId;
const results = await db_1.db
.select({ id: schema_1.users.id, username: schema_1.users.username })
.from(schema_1.users)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.ne)(schema_1.users.id, userId), (0, drizzle_orm_1.ilike)(schema_1.users.username, `%${q}%`)))
.orderBy((0, drizzle_orm_1.asc)(schema_1.users.username))
.limit(20);
res.json(results);
}));
router.post('/requests', (0, asyncHandler_1.asyncHandler)(async (req, res) => { router.post('/requests', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const { username } = validation_1.friendRequestSchema.parse(req.body); const { username } = validation_1.friendRequestSchema.parse(req.body);
const userId = req.user.userId; const userId = req.user.userId;
+1 -1
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
declare const router: import("express-serve-static-core").Router;
export default router;
//# sourceMappingURL=repeatProfiles.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"repeatProfiles.d.ts","sourceRoot":"","sources":["../../src/routes/repeatProfiles.ts"],"names":[],"mappings":"AAUA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA+ExB,eAAe,MAAM,CAAC"}
+74
View File
@@ -0,0 +1,74 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
const asyncHandler_1 = require("../utils/asyncHandler");
const db_1 = require("../db");
const schema_1 = require("../db/schema");
const drizzle_orm_1 = require("drizzle-orm");
const auth_1 = require("../utils/auth");
const auth_2 = require("../utils/auth");
const errorHandler_1 = require("../middleware/errorHandler");
const validation_1 = require("../utils/validation");
const router = (0, express_1.Router)();
router.use(auth_1.authMiddleware);
router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const profiles = await db_1.db
.select()
.from(schema_1.repeatProfiles)
.where((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, req.user.userId))
.orderBy((0, drizzle_orm_1.asc)(schema_1.repeatProfiles.createdAt));
res.json({ profiles });
}));
router.post('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const data = validation_1.repeatProfileSchema.parse(req.body);
const userId = req.user.userId;
const now = Date.now();
const newProfile = {
id: (0, auth_2.generateId)('rp'),
userId,
name: data.name,
repeat: data.repeat,
repeatInterval: data.repeatInterval,
repeatDays: data.repeatDays,
createdAt: now,
updatedAt: now,
};
await db_1.db.insert(schema_1.repeatProfiles).values(newProfile);
res.status(201).json(newProfile);
}));
router.patch('/:id', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const data = validation_1.repeatProfileUpdateSchema.parse(req.body);
const userId = req.user.userId;
const existing = await db_1.db
.select()
.from(schema_1.repeatProfiles)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, req.params.id), (0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId)))
.limit(1);
if (existing.length === 0) {
throw new errorHandler_1.AppError('NOT_FOUND', 'Repeat profile not found', 404);
}
const now = Date.now();
const updated = await db_1.db
.update(schema_1.repeatProfiles)
.set({ ...data, updatedAt: now })
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, req.params.id), (0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId)))
.returning();
res.json(updated[0]);
}));
router.delete('/:id', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const userId = req.user.userId;
const existing = await db_1.db
.select()
.from(schema_1.repeatProfiles)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, req.params.id), (0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId)))
.limit(1);
if (existing.length === 0) {
throw new errorHandler_1.AppError('NOT_FOUND', 'Repeat profile not found', 404);
}
await db_1.db
.delete(schema_1.repeatProfiles)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, req.params.id), (0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId)));
res.status(204).send();
}));
exports.default = router;
//# sourceMappingURL=repeatProfiles.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"repeatProfiles.js","sourceRoot":"","sources":["../../src/routes/repeatProfiles.ts"],"names":[],"mappings":";;AAAA,qCAAoD;AACpD,wDAAqD;AACrD,8BAA2B;AAC3B,yCAA8C;AAC9C,6CAA2C;AAC3C,wCAA+C;AAC/C,wCAA2C;AAC3C,6DAAsD;AACtD,oDAAqF;AAErF,MAAM,MAAM,GAAG,IAAA,gBAAM,GAAE,CAAC;AAExB,MAAM,CAAC,GAAG,CAAC,qBAAc,CAAC,CAAC;AAE3B,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,IAAA,2BAAY,EAAC,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;IACjE,MAAM,QAAQ,GAAG,MAAM,OAAE;SACtB,MAAM,EAAE;SACR,IAAI,CAAC,uBAAc,CAAC;SACpB,KAAK,CAAC,IAAA,gBAAE,EAAC,uBAAc,CAAC,MAAM,EAAE,GAAG,CAAC,IAAK,CAAC,MAAM,CAAC,CAAC;SAClD,OAAO,CAAC,IAAA,iBAAG,EAAC,uBAAc,CAAC,SAAS,CAAC,CAAC,CAAC;IAE1C,GAAG,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC;AACzB,CAAC,CAAC,CAAC,CAAC;AAEJ,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,IAAA,2BAAY,EAAC,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;IAClE,MAAM,IAAI,GAAG,gCAAmB,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IACjD,MAAM,MAAM,GAAG,GAAG,CAAC,IAAK,CAAC,MAAM,CAAC;IAChC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAEvB,MAAM,UAAU,GAAG;QACjB,EAAE,EAAE,IAAA,iBAAU,EAAC,IAAI,CAAC;QACpB,MAAM;QACN,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,MAAM,EAAE,IAAI,CAAC,MAAM;QACnB,cAAc,EAAE,IAAI,CAAC,cAAc;QACnC,UAAU,EAAE,IAAI,CAAC,UAAU;QAC3B,SAAS,EAAE,GAAG;QACd,SAAS,EAAE,GAAG;KACf,CAAC;IAEF,MAAM,OAAE,CAAC,MAAM,CAAC,uBAAc,CAAC,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;IAEnD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;AACnC,CAAC,CAAC,CAAC,CAAC;AAEJ,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,IAAA,2BAAY,EAAC,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;IACtE,MAAM,IAAI,GAAG,sCAAyB,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IACvD,MAAM,MAAM,GAAG,GAAG,CAAC,IAAK,CAAC,MAAM,CAAC;IAEhC,MAAM,QAAQ,GAAG,MAAM,OAAE;SACtB,MAAM,EAAE;SACR,IAAI,CAAC,uBAAc,CAAC;SACpB,KAAK,CAAC,IAAA,iBAAG,EAAC,IAAA,gBAAE,EAAC,uBAAc,CAAC,EAAE,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,IAAA,gBAAE,EAAC,uBAAc,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;SACnF,KAAK,CAAC,CAAC,CAAC,CAAC;IAEZ,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC1B,MAAM,IAAI,uBAAQ,CAAC,WAAW,EAAE,0BAA0B,EAAE,GAAG,CAAC,CAAC;IACnE,CAAC;IAED,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IACvB,MAAM,OAAO,GAAG,MAAM,OAAE;SACrB,MAAM,CAAC,uBAAc,CAAC;SACtB,GAAG,CAAC,EAAE,GAAG,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE,CAAC;SAChC,KAAK,CAAC,IAAA,iBAAG,EAAC,IAAA,gBAAE,EAAC,uBAAc,CAAC,EAAE,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,IAAA,gBAAE,EAAC,uBAAc,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;SACnF,SAAS,EAAE,CAAC;IAEf,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC;AACvB,CAAC,CAAC,CAAC,CAAC;AAEJ,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,IAAA,2BAAY,EAAC,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;IACvE,MAAM,MAAM,GAAG,GAAG,CAAC,IAAK,CAAC,MAAM,CAAC;IAEhC,MAAM,QAAQ,GAAG,MAAM,OAAE;SACtB,MAAM,EAAE;SACR,IAAI,CAAC,uBAAc,CAAC;SACpB,KAAK,CAAC,IAAA,iBAAG,EAAC,IAAA,gBAAE,EAAC,uBAAc,CAAC,EAAE,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,IAAA,gBAAE,EAAC,uBAAc,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;SACnF,KAAK,CAAC,CAAC,CAAC,CAAC;IAEZ,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC1B,MAAM,IAAI,uBAAQ,CAAC,WAAW,EAAE,0BAA0B,EAAE,GAAG,CAAC,CAAC;IACnE,CAAC;IAED,MAAM,OAAE;SACL,MAAM,CAAC,uBAAc,CAAC;SACtB,KAAK,CAAC,IAAA,iBAAG,EAAC,IAAA,gBAAE,EAAC,uBAAc,CAAC,EAAE,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,IAAA,gBAAE,EAAC,uBAAc,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC;IAEvF,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC;AACzB,CAAC,CAAC,CAAC,CAAC;AAEJ,kBAAe,MAAM,CAAC"}
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"subtasks.d.ts","sourceRoot":"","sources":["../../src/routes/subtasks.ts"],"names":[],"mappings":"AASA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAmIxB,eAAe,MAAM,CAAC"} {"version":3,"file":"subtasks.d.ts","sourceRoot":"","sources":["../../src/routes/subtasks.ts"],"names":[],"mappings":"AASA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAoKxB,eAAe,MAAM,CAAC"}
+28 -2
View File
@@ -10,6 +10,16 @@ const errorHandler_1 = require("../middleware/errorHandler");
const validation_1 = require("../utils/validation"); const validation_1 = require("../utils/validation");
const router = (0, express_1.Router)(); const router = (0, express_1.Router)();
router.use(auth_1.authMiddleware); router.use(auth_1.authMiddleware);
// Helper to build nested subtask tree
const buildSubtaskTree = (allSubtasks, parentId = null) => {
return allSubtasks
.filter((s) => s.parentSubtaskId === parentId)
.sort((a, b) => a.order - b.order)
.map((s) => ({
...s,
subtasks: buildSubtaskTree(allSubtasks, s.id),
}));
};
router.get('/task/:taskId', (0, asyncHandler_1.asyncHandler)(async (req, res) => { router.get('/task/:taskId', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const userId = req.user.userId; const userId = req.user.userId;
// Verify task exists and belongs to user // Verify task exists and belongs to user
@@ -26,7 +36,8 @@ router.get('/task/:taskId', (0, asyncHandler_1.asyncHandler)(async (req, res) =>
.from(schema_1.subtasks) .from(schema_1.subtasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.taskId, req.params.taskId), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId))) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.taskId, req.params.taskId), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)))
.orderBy((0, drizzle_orm_1.asc)(schema_1.subtasks.order)); .orderBy((0, drizzle_orm_1.asc)(schema_1.subtasks.order));
res.json({ subtasks: taskSubtasks }); const nestedSubtasks = buildSubtaskTree(taskSubtasks);
res.json({ subtasks: nestedSubtasks });
})); }));
router.post('/task/:taskId', (0, asyncHandler_1.asyncHandler)(async (req, res) => { router.post('/task/:taskId', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const data = validation_1.subtaskCreateSchema.parse(req.body); const data = validation_1.subtaskCreateSchema.parse(req.body);
@@ -40,10 +51,11 @@ router.post('/task/:taskId', (0, asyncHandler_1.asyncHandler)(async (req, res) =
throw new errorHandler_1.AppError('NOT_FOUND', 'Task not found', 404); throw new errorHandler_1.AppError('NOT_FOUND', 'Task not found', 404);
} }
const now = Date.now(); const now = Date.now();
const parentSubtaskId = data.parentSubtaskId || null;
const maxOrder = await db_1.db const maxOrder = await db_1.db
.select({ order: schema_1.subtasks.order }) .select({ order: schema_1.subtasks.order })
.from(schema_1.subtasks) .from(schema_1.subtasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.taskId, req.params.taskId), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId))) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.taskId, req.params.taskId), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId), parentSubtaskId ? (0, drizzle_orm_1.eq)(schema_1.subtasks.parentSubtaskId, parentSubtaskId) : (0, drizzle_orm_1.isNull)(schema_1.subtasks.parentSubtaskId)))
.orderBy((0, drizzle_orm_1.desc)(schema_1.subtasks.order)) .orderBy((0, drizzle_orm_1.desc)(schema_1.subtasks.order))
.limit(1); .limit(1);
const subtaskId = `sub_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; const subtaskId = `sub_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
@@ -51,8 +63,22 @@ router.post('/task/:taskId', (0, asyncHandler_1.asyncHandler)(async (req, res) =
id: subtaskId, id: subtaskId,
userId, userId,
taskId: req.params.taskId, taskId: req.params.taskId,
parentSubtaskId,
title: data.title, title: data.title,
description: data.description ?? '',
priority: data.priority ?? 'none',
completed: false, completed: false,
dueDate: data.dueDate ?? 0,
dueTime: data.dueTime ?? '',
endTime: data.endTime ?? '',
allDay: data.allDay ?? false,
repeat: data.repeat ?? 'none',
repeatInterval: data.repeatInterval ?? 1,
repeatDays: data.repeatDays ?? '',
seriesId: data.seriesId ?? '',
reminder: data.reminder ?? 'none',
reminders: data.reminders ?? '',
assigneeId: data.assigneeId ?? null,
order: data.order ?? (maxOrder[0]?.order ?? -1) + 1, order: data.order ?? (maxOrder[0]?.order ?? -1) + 1,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../../src/routes/sync.ts"],"names":[],"mappings":"AASA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA8SxB,eAAe,MAAM,CAAC"} {"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../../src/routes/sync.ts"],"names":[],"mappings":"AASA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA2lBxB,eAAe,MAAM,CAAC"}
+281 -5
View File
@@ -27,9 +27,8 @@ router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId), (0, drizzle_orm_1.gte)(schema_1.tasks.updatedAt, sinceDate))) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId), (0, drizzle_orm_1.gte)(schema_1.tasks.updatedAt, sinceDate)))
.orderBy((0, drizzle_orm_1.asc)(schema_1.tasks.updatedAt)); .orderBy((0, drizzle_orm_1.asc)(schema_1.tasks.updatedAt));
// Fetch subtasks changed since timestamp // Fetch subtasks changed since timestamp
const taskIds = changedTasks.map(t => t.id);
let changedSubtasks = []; let changedSubtasks = [];
if (taskIds.length > 0) { if (changedTasks.length > 0) {
changedSubtasks = await db_1.db changedSubtasks = await db_1.db
.select() .select()
.from(schema_1.subtasks) .from(schema_1.subtasks)
@@ -37,7 +36,7 @@ router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
.orderBy((0, drizzle_orm_1.asc)(schema_1.subtasks.updatedAt)); .orderBy((0, drizzle_orm_1.asc)(schema_1.subtasks.updatedAt));
} }
else { else {
// Also fetch subtasks for tasks that might have been deleted (we track by updatedAt) // Also fetch subtasks for tasks that might have been deleted
changedSubtasks = await db_1.db changedSubtasks = await db_1.db
.select() .select()
.from(schema_1.subtasks) .from(schema_1.subtasks)
@@ -56,6 +55,12 @@ router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
.from(schema_1.friendships) .from(schema_1.friendships)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.or)((0, drizzle_orm_1.eq)(schema_1.friendships.userId, userId), (0, drizzle_orm_1.eq)(schema_1.friendships.friendId, userId)), (0, drizzle_orm_1.gte)(schema_1.friendships.updatedAt, sinceDate))) .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.or)((0, drizzle_orm_1.eq)(schema_1.friendships.userId, userId), (0, drizzle_orm_1.eq)(schema_1.friendships.friendId, userId)), (0, drizzle_orm_1.gte)(schema_1.friendships.updatedAt, sinceDate)))
.orderBy((0, drizzle_orm_1.asc)(schema_1.friendships.updatedAt)); .orderBy((0, drizzle_orm_1.asc)(schema_1.friendships.updatedAt));
// Fetch tombstones (deletions) changed since timestamp
const changedTombstones = await db_1.db
.select()
.from(schema_1.tombstones)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tombstones.userId, userId), (0, drizzle_orm_1.gte)(schema_1.tombstones.updatedAt, sinceDate)))
.orderBy((0, drizzle_orm_1.asc)(schema_1.tombstones.updatedAt));
const timestamp = Date.now(); const timestamp = Date.now();
res.json({ res.json({
categories: changedCategories, categories: changedCategories,
@@ -63,6 +68,7 @@ router.get('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
subtasks: changedSubtasks, subtasks: changedSubtasks,
repeatProfiles: changedRepeatProfiles, repeatProfiles: changedRepeatProfiles,
friendships: changedFriendships, friendships: changedFriendships,
deleted: changedTombstones.map((t) => ({ entity: t.entity, id: t.entityId, updatedAt: t.updatedAt })),
timestamp, timestamp,
}); });
})); }));
@@ -73,6 +79,65 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const timestamp = Date.now(); const timestamp = Date.now();
try { try {
await db_1.db.transaction(async (tx) => { await db_1.db.transaction(async (tx) => {
// Ensure every referenced category exists (FK integrity) so a stale or
// never-synced category reference cannot fail the entire push. Missing
// categories are recreated as a fallback and the client heals on pull.
const referencedCategoryIds = new Set();
for (const task of data.changes.tasks ?? []) {
if (task.categoryId)
referencedCategoryIds.add(task.categoryId);
}
for (const sub of data.changes.subtasks ?? []) {
const task = (data.changes.tasks ?? []).find((t) => t.id === sub.taskId);
if (task?.categoryId)
referencedCategoryIds.add(task.categoryId);
}
if (referencedCategoryIds.size > 0) {
const existing = await tx
.select({ id: schema_1.categories.id })
.from(schema_1.categories)
.where((0, drizzle_orm_1.inArray)(schema_1.categories.id, [...referencedCategoryIds]));
const existingIds = new Set(existing.map((c) => c.id));
const missing = [...referencedCategoryIds].filter((id) => !existingIds.has(id));
if (missing.length > 0) {
const rows = await tx
.select({ max: (0, drizzle_orm_1.sql) `max(${schema_1.categories.order})` })
.from(schema_1.categories)
.where((0, drizzle_orm_1.eq)(schema_1.categories.userId, userId));
const startOrder = (rows[0]?.max ?? -1) + 1;
await tx.insert(schema_1.categories).values(missing.map((id, i) => ({
id,
userId,
name: 'Default',
color: '#9E9E9E',
order: startOrder + i,
createdAt: Date.now(),
updatedAt: Date.now(),
})));
}
}
// Sanitize assignee references: only real user ids may be stored (FK).
// Stale/unknown assignee ids are silently dropped to null instead of
// failing the whole push transaction.
const assigneeIds = new Set();
for (const task of data.changes.tasks ?? []) {
if (task.assigneeId)
assigneeIds.add(task.assigneeId);
}
for (const sub of data.changes.subtasks ?? []) {
if (sub.assigneeId)
assigneeIds.add(sub.assigneeId);
}
const validAssignees = new Set();
if (assigneeIds.size > 0) {
const rows = await tx
.select({ id: schema_1.users.id })
.from(schema_1.users)
.where((0, drizzle_orm_1.inArray)(schema_1.users.id, [...assigneeIds]));
for (const r of rows)
validAssignees.add(r.id);
}
const sanitizeAssignee = (a) => a && validAssignees.has(a) ? a : null;
// Process categories // Process categories
if (data.changes.categories && data.changes.categories.length > 0) { if (data.changes.categories && data.changes.categories.length > 0) {
for (const cat of data.changes.categories) { for (const cat of data.changes.categories) {
@@ -131,6 +196,20 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
}); });
continue; // Server wins continue; // Server wins
} }
// Prevent completing tasks with future due dates
if (task.completed === true) {
const effectiveDueDate = task.dueDate ?? existing[0].dueDate;
if (!(0, validation_1.canCompleteTask)(effectiveDueDate)) {
conflicts.push({
entity: 'tasks',
id: task.id,
serverVersion: existing[0],
clientVersion: task,
resolution: 'server_wins',
});
continue;
}
}
await tx await tx
.update(schema_1.tasks) .update(schema_1.tasks)
.set({ .set({
@@ -140,14 +219,17 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
priority: task.priority, priority: task.priority,
completed: task.completed, completed: task.completed,
dueDate: task.dueDate, dueDate: task.dueDate,
dueTime: task.dueTime, dueTime: task.dueTime ?? '',
endTime: task.endTime ?? '', endTime: task.endTime ?? '',
allDay: task.allDay ?? false,
repeat: task.repeat ?? 'none', repeat: task.repeat ?? 'none',
repeatInterval: task.repeatInterval ?? 1, repeatInterval: task.repeatInterval ?? 1,
repeatDays: task.repeatDays ?? '', repeatDays: task.repeatDays ?? '',
seriesId: task.seriesId ?? '', seriesId: task.seriesId ?? '',
reminder: task.reminder ?? 'none', reminder: task.reminder ?? 'none',
assigneeId: task.assigneeId ?? null, reminders: task.reminders ?? '',
assigneeId: sanitizeAssignee(task.assigneeId),
completedAt: task.completedAt ?? null,
updatedAt: task.updatedAt, updatedAt: task.updatedAt,
}) })
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, task.id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId))); .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, task.id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)));
@@ -155,6 +237,8 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
else { else {
await tx.insert(schema_1.tasks).values({ await tx.insert(schema_1.tasks).values({
...task, ...task,
assigneeId: sanitizeAssignee(task.assigneeId),
allDay: task.allDay ?? false,
userId, userId,
}); });
} }
@@ -184,8 +268,22 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
.update(schema_1.subtasks) .update(schema_1.subtasks)
.set({ .set({
taskId: sub.taskId, taskId: sub.taskId,
parentSubtaskId: sub.parentSubtaskId ?? null,
title: sub.title, title: sub.title,
description: sub.description ?? '',
priority: sub.priority ?? 'none',
completed: sub.completed, completed: sub.completed,
dueDate: sub.dueDate ?? 0,
dueTime: sub.dueTime ?? '',
endTime: sub.endTime ?? '',
allDay: sub.allDay ?? false,
repeat: sub.repeat ?? 'none',
repeatInterval: sub.repeatInterval ?? 1,
repeatDays: sub.repeatDays ?? '',
seriesId: sub.seriesId ?? '',
reminder: sub.reminder ?? 'none',
reminders: sub.reminders ?? '',
assigneeId: sanitizeAssignee(sub.assigneeId),
order: sub.order, order: sub.order,
updatedAt: sub.updatedAt, updatedAt: sub.updatedAt,
}) })
@@ -194,6 +292,7 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
else { else {
await tx.insert(schema_1.subtasks).values({ await tx.insert(schema_1.subtasks).values({
...sub, ...sub,
assigneeId: sanitizeAssignee(sub.assigneeId),
userId, userId,
}); });
} }
@@ -273,6 +372,13 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
} }
} }
} }
// Process deletions (tombstones) - last so they see the state produced
// by the upserts above and resolve by last-writer-wins.
if (data.deleted && data.deleted.length > 0) {
for (const deleted of data.deleted) {
await applyTombstone(tx, deleted.entity, deleted.id, deleted.updatedAt, userId, conflicts);
}
}
}); });
} }
catch (error) { catch (error) {
@@ -285,5 +391,175 @@ router.post('/push', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
conflicts, conflicts,
}); });
})); }));
// Upsert a tombstone row, keeping the latest updatedAt.
async function upsertTombstone(tx, entity, entityId, updatedAt, userId) {
const existing = await tx
.select({ updatedAt: schema_1.tombstones.updatedAt })
.from(schema_1.tombstones)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tombstones.entity, entity), (0, drizzle_orm_1.eq)(schema_1.tombstones.entityId, entityId)))
.limit(1);
const merged = Math.max(existing[0]?.updatedAt ?? 0, updatedAt);
if (existing.length > 0) {
await tx
.update(schema_1.tombstones)
.set({ updatedAt: merged })
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tombstones.entity, entity), (0, drizzle_orm_1.eq)(schema_1.tombstones.entityId, entityId)));
}
else {
await tx.insert(schema_1.tombstones).values({ entity, entityId, userId, updatedAt: merged });
}
}
// Apply a client deletion. LWW: if the server row is newer than the deletion
// timestamp, the deletion is rejected (server_wins conflict) so the client
// re-pulls the row. Accepted deletions cascade tombstones to every FK-cascaded
// child so all devices remove them too.
async function applyTombstone(tx, entity, id, deletedAt, userId, conflicts) {
const tombstoneOf = (e, ids) => ids.forEach((i) => upsertTombstone(tx, e, i, deletedAt, userId));
if (entity === 'tasks') {
const row = await tx
.select()
.from(schema_1.tasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)))
.limit(1);
if (row.length === 0) {
await upsertTombstone(tx, entity, id, deletedAt, userId);
return;
}
if (row[0].updatedAt > deletedAt) {
conflicts.push({
entity: 'tasks',
id,
serverVersion: row[0],
clientVersion: { id, updatedAt: deletedAt },
resolution: 'server_wins',
});
return;
}
const children = await tx
.select({ id: schema_1.subtasks.id })
.from(schema_1.subtasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.taskId, id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)));
tombstoneOf('subtasks', children.map((c) => c.id));
await tx.delete(schema_1.tasks).where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)));
await upsertTombstone(tx, entity, id, deletedAt, userId);
return;
}
if (entity === 'categories') {
const row = await tx
.select()
.from(schema_1.categories)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, id), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId)))
.limit(1);
if (row.length === 0) {
await upsertTombstone(tx, entity, id, deletedAt, userId);
return;
}
if (row[0].updatedAt > deletedAt) {
conflicts.push({
entity: 'categories',
id,
serverVersion: row[0],
clientVersion: { id, updatedAt: deletedAt },
resolution: 'server_wins',
});
return;
}
// Deleting the category cascades its tasks (and their subtasks) -
// tombstone all of them so every client removes them.
const catTasks = await tx
.select()
.from(schema_1.tasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.categoryId, id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)));
for (const taskRow of catTasks) {
const subIds = await tx
.select({ id: schema_1.subtasks.id })
.from(schema_1.subtasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.taskId, taskRow.id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)));
tombstoneOf('subtasks', subIds.map((s) => s.id));
tombstoneOf('tasks', [taskRow.id]);
}
await tx.delete(schema_1.categories).where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, id), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId)));
await upsertTombstone(tx, entity, id, deletedAt, userId);
return;
}
if (entity === 'subtasks') {
const row = await tx
.select()
.from(schema_1.subtasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.id, id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)))
.limit(1);
if (row.length === 0) {
await upsertTombstone(tx, entity, id, deletedAt, userId);
return;
}
if (row[0].updatedAt > deletedAt) {
conflicts.push({
entity: 'subtasks',
id,
serverVersion: row[0],
clientVersion: { id, updatedAt: deletedAt },
resolution: 'server_wins',
});
return;
}
// Deleting a parent subtask cascades its children in PG - tombstone them.
const children = await tx
.select({ id: schema_1.subtasks.id })
.from(schema_1.subtasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.parentSubtaskId, id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)));
tombstoneOf('subtasks', children.map((c) => c.id));
await tx.delete(schema_1.subtasks).where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.subtasks.id, id), (0, drizzle_orm_1.eq)(schema_1.subtasks.userId, userId)));
await upsertTombstone(tx, entity, id, deletedAt, userId);
return;
}
if (entity === 'repeatProfiles') {
const row = await tx
.select()
.from(schema_1.repeatProfiles)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, id), (0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId)))
.limit(1);
if (row.length === 0) {
await upsertTombstone(tx, entity, id, deletedAt, userId);
return;
}
if (row[0].updatedAt > deletedAt) {
conflicts.push({
entity: 'repeatProfiles',
id,
serverVersion: row[0],
clientVersion: { id, updatedAt: deletedAt },
resolution: 'server_wins',
});
return;
}
await tx.delete(schema_1.repeatProfiles).where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.repeatProfiles.id, id), (0, drizzle_orm_1.eq)(schema_1.repeatProfiles.userId, userId)));
await upsertTombstone(tx, entity, id, deletedAt, userId);
return;
}
if (entity === 'friendships') {
const row = await tx
.select()
.from(schema_1.friendships)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.friendships.id, id), (0, drizzle_orm_1.or)((0, drizzle_orm_1.eq)(schema_1.friendships.userId, userId), (0, drizzle_orm_1.eq)(schema_1.friendships.friendId, userId))))
.limit(1);
if (row.length === 0) {
await upsertTombstone(tx, entity, id, deletedAt, userId);
return;
}
if (row[0].updatedAt > deletedAt) {
conflicts.push({
entity: 'friendships',
id,
serverVersion: row[0],
clientVersion: { id, updatedAt: deletedAt },
resolution: 'server_wins',
});
return;
}
await tx.delete(schema_1.friendships).where((0, drizzle_orm_1.eq)(schema_1.friendships.id, id));
await upsertTombstone(tx, entity, id, deletedAt, userId);
return;
}
}
exports.default = router; exports.default = router;
//# sourceMappingURL=sync.js.map //# sourceMappingURL=sync.js.map
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"tasks.d.ts","sourceRoot":"","sources":["../../src/routes/tasks.ts"],"names":[],"mappings":"AAUA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA8RxB,eAAe,MAAM,CAAC"} {"version":3,"file":"tasks.d.ts","sourceRoot":"","sources":["../../src/routes/tasks.ts"],"names":[],"mappings":"AAUA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA+TxB,eAAe,MAAM,CAAC"}
+48 -15
View File
@@ -97,14 +97,16 @@ router.post('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
const data = validation_1.taskCreateSchema.parse(req.body); const data = validation_1.taskCreateSchema.parse(req.body);
const userId = req.user.userId; const userId = req.user.userId;
const now = Date.now(); const now = Date.now();
// Verify category exists and belongs to user // Verify category exists and belongs to user (optional - tasks may be uncategorized)
const cat = await db_1.db if (data.categoryId) {
.select() const cat = await db_1.db
.from(schema_1.categories) .select()
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, data.categoryId), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId))) .from(schema_1.categories)
.limit(1); .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, data.categoryId), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId)))
if (cat.length === 0) { .limit(1);
throw new errorHandler_1.AppError('NOT_FOUND', 'Category not found', 404); if (cat.length === 0) {
throw new errorHandler_1.AppError('NOT_FOUND', 'Category not found', 404);
}
} }
const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const newTask = { const newTask = {
@@ -118,7 +120,10 @@ router.post('/', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
dueDate: data.dueDate ?? 0, dueDate: data.dueDate ?? 0,
dueTime: data.dueTime ?? '', dueTime: data.dueTime ?? '',
endTime: data.endTime ?? '', endTime: data.endTime ?? '',
allDay: data.allDay ?? false,
assigneeId: data.assigneeId ?? null, assigneeId: data.assigneeId ?? null,
reminder: data.reminder ?? 'none',
reminders: data.reminders ?? '',
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
@@ -166,6 +171,13 @@ router.patch('/:id', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
throw new errorHandler_1.AppError('NOT_FOUND', 'Category not found', 404); throw new errorHandler_1.AppError('NOT_FOUND', 'Category not found', 404);
} }
} }
// Prevent completing tasks with future due dates
if (data.completed === true) {
const effectiveDueDate = data.dueDate ?? existing[0].dueDate;
if (!(0, validation_1.canCompleteTask)(effectiveDueDate)) {
throw new errorHandler_1.AppError('VALIDATION_ERROR', 'Cannot mark a task as completed if its due date is in the future', 400);
}
}
const now = Date.now(); const now = Date.now();
const updated = await db_1.db const updated = await db_1.db
.update(schema_1.tasks) .update(schema_1.tasks)
@@ -207,13 +219,15 @@ router.post('/batch', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
for (const op of operations) { for (const op of operations) {
try { try {
if (op.type === 'create') { if (op.type === 'create') {
const cat = await db_1.db if (op.data.categoryId) {
.select() const cat = await db_1.db
.from(schema_1.categories) .select()
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, op.data.categoryId), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId))) .from(schema_1.categories)
.limit(1); .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.categories.id, op.data.categoryId), (0, drizzle_orm_1.eq)(schema_1.categories.userId, userId)))
if (cat.length === 0) .limit(1);
throw new errorHandler_1.AppError('NOT_FOUND', 'Category not found', 404); if (cat.length === 0)
throw new errorHandler_1.AppError('NOT_FOUND', 'Category not found', 404);
}
const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const now = Date.now(); const now = Date.now();
await db_1.db.insert(schema_1.tasks).values({ await db_1.db.insert(schema_1.tasks).values({
@@ -227,6 +241,25 @@ router.post('/batch', (0, asyncHandler_1.asyncHandler)(async (req, res) => {
results.push({ id: taskId, success: true }); results.push({ id: taskId, success: true });
} }
else if (op.type === 'update') { else if (op.type === 'update') {
if (op.data.completed === true) {
let effectiveDueDate;
if (op.data.dueDate !== undefined) {
effectiveDueDate = op.data.dueDate;
}
else {
const existingTask = await db_1.db
.select({ dueDate: schema_1.tasks.dueDate })
.from(schema_1.tasks)
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(schema_1.tasks.id, op.id), (0, drizzle_orm_1.eq)(schema_1.tasks.userId, userId)))
.limit(1);
if (existingTask.length === 0)
throw new errorHandler_1.AppError('NOT_FOUND', 'Task not found', 404);
effectiveDueDate = existingTask[0].dueDate;
}
if (!(0, validation_1.canCompleteTask)(effectiveDueDate)) {
throw new errorHandler_1.AppError('VALIDATION_ERROR', 'Cannot mark a task as completed if its due date is in the future', 400);
}
}
await db_1.db await db_1.db
.update(schema_1.tasks) .update(schema_1.tasks)
.set({ ...op.data, updatedAt: Date.now() }) .set({ ...op.data, updatedAt: Date.now() })
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from 'express';
import { AuthPayload } from '../types'; import { AuthPayload } from '../types';
export declare function generateToken(payload: AuthPayload): string; export declare function generateToken(payload: AuthPayload): string;
export declare function verifyToken(token: string): AuthPayload | null; export declare function verifyToken(token: string): AuthPayload | null;
export declare function authMiddleware(req: Request, res: Response, next: NextFunction): void; export declare function authMiddleware(req: Request, res: Response, next: NextFunction): Promise<void>;
export declare function optionalAuthMiddleware(req: Request, res: Response, next: NextFunction): void; export declare function optionalAuthMiddleware(req: Request, res: Response, next: NextFunction): void;
export declare function generateId(prefix?: string): string; export declare function generateId(prefix?: string): string;
export declare function getCurrentTimestamp(): number; export declare function getCurrentTimestamp(): number;
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/utils/auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE1D,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAKvC,wBAAgB,aAAa,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,CAE1D;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAM7D;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,GAAG,IAAI,CA4BpF;AAED,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,GAAG,IAAI,CAW5F;AAED,wBAAgB,UAAU,CAAC,MAAM,GAAE,MAAW,GAAG,MAAM,CAItD;AAED,wBAAgB,mBAAmB,IAAI,MAAM,CAE5C"} {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/utils/auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAK1D,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAKvC,wBAAgB,aAAa,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,CAE1D;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAM7D;AAED,wBAAsB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAuCnG;AAED,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,GAAG,IAAI,CAW5F;AAED,wBAAgB,UAAU,CAAC,MAAM,GAAE,MAAW,GAAG,MAAM,CAItD;AAED,wBAAgB,mBAAmB,IAAI,MAAM,CAE5C"}
+14 -1
View File
@@ -10,6 +10,9 @@ exports.optionalAuthMiddleware = optionalAuthMiddleware;
exports.generateId = generateId; exports.generateId = generateId;
exports.getCurrentTimestamp = getCurrentTimestamp; exports.getCurrentTimestamp = getCurrentTimestamp;
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken")); const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
const db_1 = require("../db");
const schema_1 = require("../db/schema");
const drizzle_orm_1 = require("drizzle-orm");
const JWT_SECRET = process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-in-production'; const JWT_SECRET = process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-in-production';
const JWT_EXPIRES_IN = '7d'; const JWT_EXPIRES_IN = '7d';
function generateToken(payload) { function generateToken(payload) {
@@ -23,7 +26,7 @@ function verifyToken(token) {
return null; return null;
} }
} }
function authMiddleware(req, res, next) { async function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) { if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({ res.status(401).json({
@@ -45,6 +48,16 @@ function authMiddleware(req, res, next) {
}); });
return; return;
} }
const user = await db_1.db.select({ id: schema_1.users.id }).from(schema_1.users).where((0, drizzle_orm_1.eq)(schema_1.users.id, payload.userId)).limit(1);
if (user.length === 0) {
res.status(401).json({
error: {
code: 'UNAUTHORIZED',
message: 'User no longer exists, please log in again',
},
});
return;
}
req.user = payload; req.user = payload;
next(); next();
} }
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"auth.js","sourceRoot":"","sources":["../../src/utils/auth.ts"],"names":[],"mappings":";;;;;AAOA,sCAEC;AAED,kCAMC;AAED,wCA4BC;AAED,wDAWC;AAED,gCAIC;AAED,kDAEC;AArED,gEAA+B;AAG/B,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,gDAAgD,CAAC;AAC9F,MAAM,cAAc,GAAG,IAAI,CAAC;AAE5B,SAAgB,aAAa,CAAC,OAAoB;IAChD,OAAO,sBAAG,CAAC,IAAI,CAAC,OAAO,EAAE,UAAU,EAAE,EAAE,SAAS,EAAE,cAAc,EAAE,CAAC,CAAC;AACtE,CAAC;AAED,SAAgB,WAAW,CAAC,KAAa;IACvC,IAAI,CAAC;QACH,OAAO,sBAAG,CAAC,MAAM,CAAC,KAAK,EAAE,UAAU,CAAgB,CAAC;IACtD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,SAAgB,cAAc,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB;IAC5E,MAAM,UAAU,GAAG,GAAG,CAAC,OAAO,CAAC,aAAa,CAAC;IAE7C,IAAI,CAAC,UAAU,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;QACrD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;YACnB,KAAK,EAAE;gBACL,IAAI,EAAE,cAAc;gBACpB,OAAO,EAAE,yCAAyC;aACnD;SACF,CAAC,CAAC;QACH,OAAO;IACT,CAAC;IAED,MAAM,KAAK,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAClC,MAAM,OAAO,GAAG,WAAW,CAAC,KAAK,CAAC,CAAC;IAEnC,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;YACnB,KAAK,EAAE;gBACL,IAAI,EAAE,cAAc;gBACpB,OAAO,EAAE,0BAA0B;aACpC;SACF,CAAC,CAAC;QACH,OAAO;IACT,CAAC;IAED,GAAG,CAAC,IAAI,GAAG,OAAO,CAAC;IACnB,IAAI,EAAE,CAAC;AACT,CAAC;AAED,SAAgB,sBAAsB,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB;IACpF,MAAM,UAAU,GAAG,GAAG,CAAC,OAAO,CAAC,aAAa,CAAC;IAE7C,IAAI,UAAU,IAAI,UAAU,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;QACnD,MAAM,KAAK,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QAClC,MAAM,OAAO,GAAG,WAAW,CAAC,KAAK,CAAC,CAAC;QACnC,IAAI,OAAO,EAAE,CAAC;YACZ,GAAG,CAAC,IAAI,GAAG,OAAO,CAAC;QACrB,CAAC;IACH,CAAC;IACD,IAAI,EAAE,CAAC;AACT,CAAC;AAED,SAAgB,UAAU,CAAC,SAAiB,EAAE;IAC5C,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;IAC1C,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IACvD,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,SAAS,GAAG,MAAM,EAAE,CAAC;AAC9D,CAAC;AAED,SAAgB,mBAAmB;IACjC,OAAO,IAAI,CAAC,GAAG,EAAE,CAAC;AACpB,CAAC"} {"version":3,"file":"auth.js","sourceRoot":"","sources":["../../src/utils/auth.ts"],"names":[],"mappings":";;;;;AAUA,sCAEC;AAED,kCAMC;AAED,wCAuCC;AAED,wDAWC;AAED,gCAIC;AAED,kDAEC;AAnFD,gEAA+B;AAC/B,8BAA2B;AAC3B,yCAAqC;AACrC,6CAAiC;AAGjC,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,gDAAgD,CAAC;AAC9F,MAAM,cAAc,GAAG,IAAI,CAAC;AAE5B,SAAgB,aAAa,CAAC,OAAoB;IAChD,OAAO,sBAAG,CAAC,IAAI,CAAC,OAAO,EAAE,UAAU,EAAE,EAAE,SAAS,EAAE,cAAc,EAAE,CAAC,CAAC;AACtE,CAAC;AAED,SAAgB,WAAW,CAAC,KAAa;IACvC,IAAI,CAAC;QACH,OAAO,sBAAG,CAAC,MAAM,CAAC,KAAK,EAAE,UAAU,CAAgB,CAAC;IACtD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAEM,KAAK,UAAU,cAAc,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB;IAClF,MAAM,UAAU,GAAG,GAAG,CAAC,OAAO,CAAC,aAAa,CAAC;IAE7C,IAAI,CAAC,UAAU,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;QACrD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;YACnB,KAAK,EAAE;gBACL,IAAI,EAAE,cAAc;gBACpB,OAAO,EAAE,yCAAyC;aACnD;SACF,CAAC,CAAC;QACH,OAAO;IACT,CAAC;IAED,MAAM,KAAK,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAClC,MAAM,OAAO,GAAG,WAAW,CAAC,KAAK,CAAC,CAAC;IAEnC,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;YACnB,KAAK,EAAE;gBACL,IAAI,EAAE,cAAc;gBACpB,OAAO,EAAE,0BAA0B;aACpC;SACF,CAAC,CAAC;QACH,OAAO;IACT,CAAC;IAED,MAAM,IAAI,GAAG,MAAM,OAAE,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,cAAK,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,cAAK,CAAC,CAAC,KAAK,CAAC,IAAA,gBAAE,EAAC,cAAK,CAAC,EAAE,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IACxG,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACtB,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;YACnB,KAAK,EAAE;gBACL,IAAI,EAAE,cAAc;gBACpB,OAAO,EAAE,4CAA4C;aACtD;SACF,CAAC,CAAC;QACH,OAAO;IACT,CAAC;IAED,GAAG,CAAC,IAAI,GAAG,OAAO,CAAC;IACnB,IAAI,EAAE,CAAC;AACT,CAAC;AAED,SAAgB,sBAAsB,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB;IACpF,MAAM,UAAU,GAAG,GAAG,CAAC,OAAO,CAAC,aAAa,CAAC;IAE7C,IAAI,UAAU,IAAI,UAAU,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;QACnD,MAAM,KAAK,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QAClC,MAAM,OAAO,GAAG,WAAW,CAAC,KAAK,CAAC,CAAC;QACnC,IAAI,OAAO,EAAE,CAAC;YACZ,GAAG,CAAC,IAAI,GAAG,OAAO,CAAC;QACrB,CAAC;IACH,CAAC;IACD,IAAI,EAAE,CAAC;AACT,CAAC;AAED,SAAgB,UAAU,CAAC,SAAiB,EAAE;IAC5C,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;IAC1C,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IACvD,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,SAAS,GAAG,MAAM,EAAE,CAAC;AAC9D,CAAC;AAED,SAAgB,mBAAmB;IACjC,OAAO,IAAI,CAAC,GAAG,EAAE,CAAC;AACpB,CAAC"}
+370 -90
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
export declare function canCompleteTask(dueDate: number): boolean;
export declare const categoryCreateSchema: z.ZodObject<{ export declare const categoryCreateSchema: z.ZodObject<{
name: z.ZodString; name: z.ZodString;
color: z.ZodString; color: z.ZodString;
@@ -26,20 +27,25 @@ export declare const categoryUpdateSchema: z.ZodObject<{
order?: number | undefined; order?: number | undefined;
}>; }>;
export declare const repeatSchema: z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>; export declare const repeatSchema: z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>;
export declare const repeatFieldSchema: z.ZodOptional<z.ZodEffects<z.ZodUnion<[z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>, z.ZodLiteral<"">]>, "custom" | "none" | "daily" | "weekly" | "monthly", "" | "custom" | "none" | "daily" | "weekly" | "monthly">>;
export declare const repeatIntervalFieldSchema: z.ZodOptional<z.ZodEffects<z.ZodNumber, number, number>>;
export declare const taskCreateSchema: z.ZodObject<{ export declare const taskCreateSchema: z.ZodObject<{
title: z.ZodString; title: z.ZodString;
description: z.ZodOptional<z.ZodString>; description: z.ZodOptional<z.ZodString>;
categoryId: z.ZodString; categoryId: z.ZodNullable<z.ZodEffects<z.ZodString, string | null, string>>;
priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>; priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>;
dueDate: z.ZodOptional<z.ZodNumber>; dueDate: z.ZodOptional<z.ZodNumber>;
dueTime: z.ZodNullable<z.ZodOptional<z.ZodString>>; dueTime: z.ZodEffects<z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>, string, string | undefined>;
endTime: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>; endTime: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
repeat: z.ZodOptional<z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>>; allDay: z.ZodOptional<z.ZodBoolean>;
repeatInterval: z.ZodOptional<z.ZodNumber>; repeat: z.ZodOptional<z.ZodEffects<z.ZodUnion<[z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>, z.ZodLiteral<"">]>, "custom" | "none" | "daily" | "weekly" | "monthly", "" | "custom" | "none" | "daily" | "weekly" | "monthly">>;
repeatInterval: z.ZodOptional<z.ZodEffects<z.ZodNumber, number, number>>;
repeatDays: z.ZodOptional<z.ZodString>; repeatDays: z.ZodOptional<z.ZodString>;
seriesId: z.ZodOptional<z.ZodString>; seriesId: z.ZodOptional<z.ZodString>;
reminder: z.ZodOptional<z.ZodEnum<["none", "at_time", "15", "30", "60", "120", "1440"]>>; reminder: z.ZodOptional<z.ZodEnum<["none", "at_time", "15", "30", "60", "120", "1440"]>>;
reminders: z.ZodOptional<z.ZodString>;
assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>; assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
completedAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
subtasks: z.ZodOptional<z.ZodArray<z.ZodObject<{ subtasks: z.ZodOptional<z.ZodArray<z.ZodObject<{
title: z.ZodString; title: z.ZodString;
}, "strip", z.ZodTypeAny, { }, "strip", z.ZodTypeAny, {
@@ -48,93 +54,189 @@ export declare const taskCreateSchema: z.ZodObject<{
title: string; title: string;
}>, "many">>; }>, "many">>;
}, "strip", z.ZodTypeAny, { }, "strip", z.ZodTypeAny, {
categoryId: string; categoryId: string | null;
title: string; title: string;
dueTime: string;
subtasks?: {
title: string;
}[] | undefined;
description?: string | undefined; description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined; priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined; dueDate?: number | undefined;
dueTime?: string | null | undefined;
endTime?: string | undefined; endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined; repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined; repeatInterval?: number | undefined;
repeatDays?: string | undefined; repeatDays?: string | undefined;
seriesId?: string | undefined; seriesId?: string | undefined;
assigneeId?: string | null | undefined; assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined; reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
subtasks?: { reminders?: string | undefined;
title: string; completedAt?: number | null | undefined;
}[] | undefined;
}, { }, {
categoryId: string; categoryId: string | null;
title: string; title: string;
subtasks?: {
title: string;
}[] | undefined;
description?: string | undefined; description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined; priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined; dueDate?: number | undefined;
dueTime?: string | null | undefined; dueTime?: string | undefined;
endTime?: string | undefined; endTime?: string | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined; allDay?: boolean | undefined;
repeat?: "" | "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined; repeatInterval?: number | undefined;
repeatDays?: string | undefined; repeatDays?: string | undefined;
seriesId?: string | undefined; seriesId?: string | undefined;
assigneeId?: string | null | undefined; assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined; reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
subtasks?: { reminders?: string | undefined;
title: string; completedAt?: number | null | undefined;
}[] | undefined;
}>; }>;
export declare const taskUpdateSchema: z.ZodObject<{ export declare const taskUpdateSchema: z.ZodObject<{
title: z.ZodOptional<z.ZodString>; title: z.ZodOptional<z.ZodString>;
description: z.ZodOptional<z.ZodString>; description: z.ZodOptional<z.ZodString>;
categoryId: z.ZodOptional<z.ZodString>; categoryId: z.ZodEffects<z.ZodOptional<z.ZodNullable<z.ZodString>>, string | null | undefined, string | null | undefined>;
priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>; priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>;
completed: z.ZodOptional<z.ZodBoolean>; completed: z.ZodOptional<z.ZodBoolean>;
dueDate: z.ZodOptional<z.ZodNumber>; dueDate: z.ZodOptional<z.ZodNumber>;
dueTime: z.ZodNullable<z.ZodOptional<z.ZodString>>; dueTime: z.ZodEffects<z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>, string | undefined, string | undefined>;
endTime: z.ZodOptional<z.ZodString>; endTime: z.ZodOptional<z.ZodString>;
reminders: z.ZodOptional<z.ZodString>;
assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>; assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
completedAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
}, "strip", z.ZodTypeAny, { }, "strip", z.ZodTypeAny, {
categoryId?: string | undefined; categoryId?: string | null | undefined;
title?: string | undefined; title?: string | undefined;
description?: string | undefined; description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined; priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
completed?: boolean | undefined; completed?: boolean | undefined;
dueDate?: number | undefined; dueDate?: number | undefined;
dueTime?: string | null | undefined; dueTime?: string | undefined;
endTime?: string | undefined; endTime?: string | undefined;
assigneeId?: string | null | undefined; assigneeId?: string | null | undefined;
reminders?: string | undefined;
completedAt?: number | null | undefined;
}, { }, {
categoryId?: string | undefined; categoryId?: string | null | undefined;
title?: string | undefined; title?: string | undefined;
description?: string | undefined; description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined; priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
completed?: boolean | undefined; completed?: boolean | undefined;
dueDate?: number | undefined; dueDate?: number | undefined;
dueTime?: string | null | undefined; dueTime?: string | undefined;
endTime?: string | undefined; endTime?: string | undefined;
assigneeId?: string | null | undefined; assigneeId?: string | null | undefined;
reminders?: string | undefined;
completedAt?: number | null | undefined;
}>; }>;
export declare const subtaskCreateSchema: z.ZodObject<{ export declare const subtaskCreateSchema: z.ZodObject<{
title: z.ZodString; title: z.ZodString;
description: z.ZodOptional<z.ZodString>;
priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>;
dueDate: z.ZodOptional<z.ZodNumber>;
dueTime: z.ZodEffects<z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>, string, string | undefined>;
endTime: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
allDay: z.ZodOptional<z.ZodBoolean>;
repeat: z.ZodOptional<z.ZodEffects<z.ZodUnion<[z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>, z.ZodLiteral<"">]>, "custom" | "none" | "daily" | "weekly" | "monthly", "" | "custom" | "none" | "daily" | "weekly" | "monthly">>;
repeatInterval: z.ZodOptional<z.ZodEffects<z.ZodNumber, number, number>>;
repeatDays: z.ZodOptional<z.ZodString>;
seriesId: z.ZodOptional<z.ZodString>;
reminder: z.ZodOptional<z.ZodEnum<["none", "at_time", "15", "30", "60", "120", "1440"]>>;
reminders: z.ZodOptional<z.ZodString>;
assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
order: z.ZodOptional<z.ZodNumber>; order: z.ZodOptional<z.ZodNumber>;
parentSubtaskId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, "strip", z.ZodTypeAny, { }, "strip", z.ZodTypeAny, {
title: string; title: string;
dueTime: string;
order?: number | undefined; order?: number | undefined;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined;
endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
reminders?: string | undefined;
parentSubtaskId?: string | null | undefined;
}, { }, {
title: string; title: string;
order?: number | undefined; order?: number | undefined;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined;
dueTime?: string | undefined;
endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "" | "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
reminders?: string | undefined;
parentSubtaskId?: string | null | undefined;
}>; }>;
export declare const subtaskUpdateSchema: z.ZodObject<{ export declare const subtaskUpdateSchema: z.ZodObject<{
title: z.ZodOptional<z.ZodString>; title: z.ZodOptional<z.ZodString>;
description: z.ZodOptional<z.ZodString>;
priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>;
completed: z.ZodOptional<z.ZodBoolean>; completed: z.ZodOptional<z.ZodBoolean>;
dueDate: z.ZodOptional<z.ZodNumber>;
dueTime: z.ZodEffects<z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>, string | undefined, string | undefined>;
endTime: z.ZodOptional<z.ZodString>;
allDay: z.ZodOptional<z.ZodBoolean>;
repeat: z.ZodOptional<z.ZodEffects<z.ZodUnion<[z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>, z.ZodLiteral<"">]>, "custom" | "none" | "daily" | "weekly" | "monthly", "" | "custom" | "none" | "daily" | "weekly" | "monthly">>;
repeatInterval: z.ZodOptional<z.ZodEffects<z.ZodNumber, number, number>>;
repeatDays: z.ZodOptional<z.ZodString>;
seriesId: z.ZodOptional<z.ZodString>;
reminder: z.ZodOptional<z.ZodEnum<["none", "at_time", "15", "30", "60", "120", "1440"]>>;
reminders: z.ZodOptional<z.ZodString>;
assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
order: z.ZodOptional<z.ZodNumber>; order: z.ZodOptional<z.ZodNumber>;
parentSubtaskId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, "strip", z.ZodTypeAny, { }, "strip", z.ZodTypeAny, {
order?: number | undefined; order?: number | undefined;
title?: string | undefined; title?: string | undefined;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
completed?: boolean | undefined; completed?: boolean | undefined;
dueDate?: number | undefined;
dueTime?: string | undefined;
endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
reminders?: string | undefined;
parentSubtaskId?: string | null | undefined;
}, { }, {
order?: number | undefined; order?: number | undefined;
title?: string | undefined; title?: string | undefined;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
completed?: boolean | undefined; completed?: boolean | undefined;
dueDate?: number | undefined;
dueTime?: string | undefined;
endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "" | "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
reminders?: string | undefined;
parentSubtaskId?: string | null | undefined;
}>; }>;
export declare const userSettingsSchema: z.ZodObject<{ export declare const userSettingsSchema: z.ZodObject<{
darkMode: z.ZodOptional<z.ZodBoolean>; darkMode: z.ZodOptional<z.ZodBoolean>;
@@ -174,6 +276,29 @@ export declare const repeatProfileSchema: z.ZodObject<{
repeatInterval: number; repeatInterval: number;
repeatDays: string; repeatDays: string;
}>; }>;
export declare const repeatProfileUpdateSchema: z.ZodObject<{
name: z.ZodOptional<z.ZodString>;
repeat: z.ZodOptional<z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>>;
repeatInterval: z.ZodOptional<z.ZodNumber>;
repeatDays: z.ZodOptional<z.ZodString>;
}, "strip", z.ZodTypeAny, {
name?: string | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
}, {
name?: string | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
}>;
export declare const searchQuerySchema: z.ZodObject<{
q: z.ZodString;
}, "strip", z.ZodTypeAny, {
q: string;
}, {
q: string;
}>;
export declare const friendRequestSchema: z.ZodObject<{ export declare const friendRequestSchema: z.ZodObject<{
username: z.ZodString; username: z.ZodString;
}, "strip", z.ZodTypeAny, { }, "strip", z.ZodTypeAny, {
@@ -203,6 +328,19 @@ export declare const friendshipSchema: z.ZodObject<{
status: "pending" | "accepted"; status: "pending" | "accepted";
updatedAt: number; updatedAt: number;
}>; }>;
export declare const tombstoneSchema: z.ZodObject<{
entity: z.ZodEnum<["categories", "tasks", "subtasks", "repeatProfiles", "friendships"]>;
id: z.ZodString;
updatedAt: z.ZodNumber;
}, "strip", z.ZodTypeAny, {
id: string;
updatedAt: number;
entity: "categories" | "tasks" | "subtasks" | "repeatProfiles" | "friendships";
}, {
id: string;
updatedAt: number;
entity: "categories" | "tasks" | "subtasks" | "repeatProfiles" | "friendships";
}>;
export declare const pushChangesSchema: z.ZodObject<{ export declare const pushChangesSchema: z.ZodObject<{
changes: z.ZodObject<{ changes: z.ZodObject<{
categories: z.ZodOptional<z.ZodArray<z.ZodObject<{ categories: z.ZodOptional<z.ZodArray<z.ZodObject<{
@@ -231,16 +369,18 @@ export declare const pushChangesSchema: z.ZodObject<{
tasks: z.ZodOptional<z.ZodArray<z.ZodObject<{ tasks: z.ZodOptional<z.ZodArray<z.ZodObject<{
title: z.ZodString; title: z.ZodString;
description: z.ZodOptional<z.ZodString>; description: z.ZodOptional<z.ZodString>;
categoryId: z.ZodString; categoryId: z.ZodNullable<z.ZodEffects<z.ZodString, string | null, string>>;
priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>; priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>;
dueDate: z.ZodOptional<z.ZodNumber>; dueDate: z.ZodOptional<z.ZodNumber>;
dueTime: z.ZodNullable<z.ZodOptional<z.ZodString>>; dueTime: z.ZodEffects<z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>, string, string | undefined>;
endTime: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>; endTime: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
repeat: z.ZodOptional<z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>>; allDay: z.ZodOptional<z.ZodBoolean>;
repeatInterval: z.ZodOptional<z.ZodNumber>; repeat: z.ZodOptional<z.ZodEffects<z.ZodUnion<[z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>, z.ZodLiteral<"">]>, "custom" | "none" | "daily" | "weekly" | "monthly", "" | "custom" | "none" | "daily" | "weekly" | "monthly">>;
repeatInterval: z.ZodOptional<z.ZodEffects<z.ZodNumber, number, number>>;
repeatDays: z.ZodOptional<z.ZodString>; repeatDays: z.ZodOptional<z.ZodString>;
seriesId: z.ZodOptional<z.ZodString>; seriesId: z.ZodOptional<z.ZodString>;
reminder: z.ZodOptional<z.ZodEnum<["none", "at_time", "15", "30", "60", "120", "1440"]>>; reminder: z.ZodOptional<z.ZodEnum<["none", "at_time", "15", "30", "60", "120", "1440"]>>;
reminders: z.ZodOptional<z.ZodString>;
assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>; assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
subtasks: z.ZodOptional<z.ZodArray<z.ZodObject<{ subtasks: z.ZodOptional<z.ZodArray<z.ZodObject<{
title: z.ZodString; title: z.ZodString;
@@ -254,52 +394,73 @@ export declare const pushChangesSchema: z.ZodObject<{
completed: z.ZodBoolean; completed: z.ZodBoolean;
createdAt: z.ZodNumber; createdAt: z.ZodNumber;
updatedAt: z.ZodNumber; updatedAt: z.ZodNumber;
completedAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
}, "strip", z.ZodTypeAny, { }, "strip", z.ZodTypeAny, {
id: string; id: string;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
categoryId: string; categoryId: string | null;
title: string; title: string;
completed: boolean; completed: boolean;
dueTime: string;
subtasks?: {
title: string;
}[] | undefined;
description?: string | undefined; description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined; priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined; dueDate?: number | undefined;
dueTime?: string | null | undefined;
endTime?: string | undefined; endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined; repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined; repeatInterval?: number | undefined;
repeatDays?: string | undefined; repeatDays?: string | undefined;
seriesId?: string | undefined; seriesId?: string | undefined;
assigneeId?: string | null | undefined; assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined; reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
subtasks?: { reminders?: string | undefined;
title: string; completedAt?: number | null | undefined;
}[] | undefined;
}, { }, {
id: string; id: string;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
categoryId: string; categoryId: string | null;
title: string; title: string;
completed: boolean; completed: boolean;
subtasks?: {
title: string;
}[] | undefined;
description?: string | undefined; description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined; priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined; dueDate?: number | undefined;
dueTime?: string | null | undefined; dueTime?: string | undefined;
endTime?: string | undefined; endTime?: string | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined; allDay?: boolean | undefined;
repeat?: "" | "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined; repeatInterval?: number | undefined;
repeatDays?: string | undefined; repeatDays?: string | undefined;
seriesId?: string | undefined; seriesId?: string | undefined;
assigneeId?: string | null | undefined; assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined; reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
subtasks?: { reminders?: string | undefined;
title: string; completedAt?: number | null | undefined;
}[] | undefined;
}>, "many">>; }>, "many">>;
subtasks: z.ZodOptional<z.ZodArray<z.ZodObject<{ subtasks: z.ZodOptional<z.ZodArray<z.ZodObject<{
title: z.ZodString; title: z.ZodString;
description: z.ZodOptional<z.ZodString>;
priority: z.ZodOptional<z.ZodEnum<["none", "low", "medium", "high", "critical"]>>;
dueDate: z.ZodOptional<z.ZodNumber>;
dueTime: z.ZodEffects<z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>, string, string | undefined>;
endTime: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
allDay: z.ZodOptional<z.ZodBoolean>;
repeat: z.ZodOptional<z.ZodEffects<z.ZodUnion<[z.ZodEnum<["none", "daily", "weekly", "monthly", "custom"]>, z.ZodLiteral<"">]>, "custom" | "none" | "daily" | "weekly" | "monthly", "" | "custom" | "none" | "daily" | "weekly" | "monthly">>;
repeatInterval: z.ZodOptional<z.ZodEffects<z.ZodNumber, number, number>>;
repeatDays: z.ZodOptional<z.ZodString>;
seriesId: z.ZodOptional<z.ZodString>;
reminder: z.ZodOptional<z.ZodEnum<["none", "at_time", "15", "30", "60", "120", "1440"]>>;
reminders: z.ZodOptional<z.ZodString>;
assigneeId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
order: z.ZodOptional<z.ZodNumber>; order: z.ZodOptional<z.ZodNumber>;
parentSubtaskId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
} & { } & {
id: z.ZodString; id: z.ZodString;
taskId: z.ZodString; taskId: z.ZodString;
@@ -312,8 +473,22 @@ export declare const pushChangesSchema: z.ZodObject<{
updatedAt: number; updatedAt: number;
title: string; title: string;
completed: boolean; completed: boolean;
dueTime: string;
taskId: string; taskId: string;
order?: number | undefined; order?: number | undefined;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined;
endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
reminders?: string | undefined;
parentSubtaskId?: string | null | undefined;
}, { }, {
id: string; id: string;
createdAt: number; createdAt: number;
@@ -322,6 +497,20 @@ export declare const pushChangesSchema: z.ZodObject<{
completed: boolean; completed: boolean;
taskId: string; taskId: string;
order?: number | undefined; order?: number | undefined;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined;
dueTime?: string | undefined;
endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "" | "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
reminders?: string | undefined;
parentSubtaskId?: string | null | undefined;
}>, "many">>; }>, "many">>;
repeatProfiles: z.ZodOptional<z.ZodArray<z.ZodObject<{ repeatProfiles: z.ZodOptional<z.ZodArray<z.ZodObject<{
name: z.ZodString; name: z.ZodString;
@@ -372,14 +561,6 @@ export declare const pushChangesSchema: z.ZodObject<{
updatedAt: number; updatedAt: number;
}>, "many">>; }>, "many">>;
}, "strip", z.ZodTypeAny, { }, "strip", z.ZodTypeAny, {
friendships?: {
id: string;
createdAt: number;
userId: string;
friendId: string;
status: "pending" | "accepted";
updatedAt: number;
}[] | undefined;
categories?: { categories?: {
id: string; id: string;
name: string; name: string;
@@ -392,23 +573,26 @@ export declare const pushChangesSchema: z.ZodObject<{
id: string; id: string;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
categoryId: string; categoryId: string | null;
title: string; title: string;
completed: boolean; completed: boolean;
dueTime: string;
subtasks?: {
title: string;
}[] | undefined;
description?: string | undefined; description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined; priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined; dueDate?: number | undefined;
dueTime?: string | null | undefined;
endTime?: string | undefined; endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined; repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined; repeatInterval?: number | undefined;
repeatDays?: string | undefined; repeatDays?: string | undefined;
seriesId?: string | undefined; seriesId?: string | undefined;
assigneeId?: string | null | undefined; assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined; reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
subtasks?: { reminders?: string | undefined;
title: string; completedAt?: number | null | undefined;
}[] | undefined;
}[] | undefined; }[] | undefined;
subtasks?: { subtasks?: {
id: string; id: string;
@@ -416,8 +600,22 @@ export declare const pushChangesSchema: z.ZodObject<{
updatedAt: number; updatedAt: number;
title: string; title: string;
completed: boolean; completed: boolean;
dueTime: string;
taskId: string; taskId: string;
order?: number | undefined; order?: number | undefined;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined;
endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
reminders?: string | undefined;
parentSubtaskId?: string | null | undefined;
}[] | undefined; }[] | undefined;
repeatProfiles?: { repeatProfiles?: {
id: string; id: string;
@@ -428,15 +626,15 @@ export declare const pushChangesSchema: z.ZodObject<{
repeatInterval: number; repeatInterval: number;
repeatDays: string; repeatDays: string;
}[] | undefined; }[] | undefined;
friendships?: {
id: string;
createdAt: number;
userId: string;
friendId: string;
status: "pending" | "accepted";
updatedAt: number;
}[] | undefined;
}, { }, {
friendships?: {
id: string;
createdAt: number;
userId: string;
friendId: string;
status: "pending" | "accepted";
updatedAt: number;
}[] | undefined;
categories?: { categories?: {
id: string; id: string;
name: string; name: string;
@@ -449,23 +647,26 @@ export declare const pushChangesSchema: z.ZodObject<{
id: string; id: string;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
categoryId: string; categoryId: string | null;
title: string; title: string;
completed: boolean; completed: boolean;
subtasks?: {
title: string;
}[] | undefined;
description?: string | undefined; description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined; priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined; dueDate?: number | undefined;
dueTime?: string | null | undefined; dueTime?: string | undefined;
endTime?: string | undefined; endTime?: string | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined; allDay?: boolean | undefined;
repeat?: "" | "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined; repeatInterval?: number | undefined;
repeatDays?: string | undefined; repeatDays?: string | undefined;
seriesId?: string | undefined; seriesId?: string | undefined;
assigneeId?: string | null | undefined; assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined; reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
subtasks?: { reminders?: string | undefined;
title: string; completedAt?: number | null | undefined;
}[] | undefined;
}[] | undefined; }[] | undefined;
subtasks?: { subtasks?: {
id: string; id: string;
@@ -475,6 +676,20 @@ export declare const pushChangesSchema: z.ZodObject<{
completed: boolean; completed: boolean;
taskId: string; taskId: string;
order?: number | undefined; order?: number | undefined;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined;
dueTime?: string | undefined;
endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "" | "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
reminders?: string | undefined;
parentSubtaskId?: string | null | undefined;
}[] | undefined; }[] | undefined;
repeatProfiles?: { repeatProfiles?: {
id: string; id: string;
@@ -485,18 +700,31 @@ export declare const pushChangesSchema: z.ZodObject<{
repeatInterval: number; repeatInterval: number;
repeatDays: string; repeatDays: string;
}[] | undefined; }[] | undefined;
friendships?: {
id: string;
createdAt: number;
userId: string;
friendId: string;
status: "pending" | "accepted";
updatedAt: number;
}[] | undefined;
}>; }>;
deleted: z.ZodOptional<z.ZodArray<z.ZodObject<{
entity: z.ZodEnum<["categories", "tasks", "subtasks", "repeatProfiles", "friendships"]>;
id: z.ZodString;
updatedAt: z.ZodNumber;
}, "strip", z.ZodTypeAny, {
id: string;
updatedAt: number;
entity: "categories" | "tasks" | "subtasks" | "repeatProfiles" | "friendships";
}, {
id: string;
updatedAt: number;
entity: "categories" | "tasks" | "subtasks" | "repeatProfiles" | "friendships";
}>, "many">>;
lastPulledAt: z.ZodNumber; lastPulledAt: z.ZodNumber;
}, "strip", z.ZodTypeAny, { }, "strip", z.ZodTypeAny, {
changes: { changes: {
friendships?: {
id: string;
createdAt: number;
userId: string;
friendId: string;
status: "pending" | "accepted";
updatedAt: number;
}[] | undefined;
categories?: { categories?: {
id: string; id: string;
name: string; name: string;
@@ -509,23 +737,26 @@ export declare const pushChangesSchema: z.ZodObject<{
id: string; id: string;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
categoryId: string; categoryId: string | null;
title: string; title: string;
completed: boolean; completed: boolean;
dueTime: string;
subtasks?: {
title: string;
}[] | undefined;
description?: string | undefined; description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined; priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined; dueDate?: number | undefined;
dueTime?: string | null | undefined;
endTime?: string | undefined; endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined; repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined; repeatInterval?: number | undefined;
repeatDays?: string | undefined; repeatDays?: string | undefined;
seriesId?: string | undefined; seriesId?: string | undefined;
assigneeId?: string | null | undefined; assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined; reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
subtasks?: { reminders?: string | undefined;
title: string; completedAt?: number | null | undefined;
}[] | undefined;
}[] | undefined; }[] | undefined;
subtasks?: { subtasks?: {
id: string; id: string;
@@ -533,8 +764,22 @@ export declare const pushChangesSchema: z.ZodObject<{
updatedAt: number; updatedAt: number;
title: string; title: string;
completed: boolean; completed: boolean;
dueTime: string;
taskId: string; taskId: string;
order?: number | undefined; order?: number | undefined;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined;
endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
reminders?: string | undefined;
parentSubtaskId?: string | null | undefined;
}[] | undefined; }[] | undefined;
repeatProfiles?: { repeatProfiles?: {
id: string; id: string;
@@ -545,18 +790,23 @@ export declare const pushChangesSchema: z.ZodObject<{
repeatInterval: number; repeatInterval: number;
repeatDays: string; repeatDays: string;
}[] | undefined; }[] | undefined;
friendships?: {
id: string;
createdAt: number;
userId: string;
friendId: string;
status: "pending" | "accepted";
updatedAt: number;
}[] | undefined;
}; };
lastPulledAt: number; lastPulledAt: number;
deleted?: {
id: string;
updatedAt: number;
entity: "categories" | "tasks" | "subtasks" | "repeatProfiles" | "friendships";
}[] | undefined;
}, { }, {
changes: { changes: {
friendships?: {
id: string;
createdAt: number;
userId: string;
friendId: string;
status: "pending" | "accepted";
updatedAt: number;
}[] | undefined;
categories?: { categories?: {
id: string; id: string;
name: string; name: string;
@@ -569,23 +819,26 @@ export declare const pushChangesSchema: z.ZodObject<{
id: string; id: string;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
categoryId: string; categoryId: string | null;
title: string; title: string;
completed: boolean; completed: boolean;
subtasks?: {
title: string;
}[] | undefined;
description?: string | undefined; description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined; priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined; dueDate?: number | undefined;
dueTime?: string | null | undefined; dueTime?: string | undefined;
endTime?: string | undefined; endTime?: string | undefined;
repeat?: "custom" | "none" | "daily" | "weekly" | "monthly" | undefined; allDay?: boolean | undefined;
repeat?: "" | "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined; repeatInterval?: number | undefined;
repeatDays?: string | undefined; repeatDays?: string | undefined;
seriesId?: string | undefined; seriesId?: string | undefined;
assigneeId?: string | null | undefined; assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined; reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
subtasks?: { reminders?: string | undefined;
title: string; completedAt?: number | null | undefined;
}[] | undefined;
}[] | undefined; }[] | undefined;
subtasks?: { subtasks?: {
id: string; id: string;
@@ -595,6 +848,20 @@ export declare const pushChangesSchema: z.ZodObject<{
completed: boolean; completed: boolean;
taskId: string; taskId: string;
order?: number | undefined; order?: number | undefined;
description?: string | undefined;
priority?: "none" | "low" | "medium" | "high" | "critical" | undefined;
dueDate?: number | undefined;
dueTime?: string | undefined;
endTime?: string | undefined;
allDay?: boolean | undefined;
repeat?: "" | "custom" | "none" | "daily" | "weekly" | "monthly" | undefined;
repeatInterval?: number | undefined;
repeatDays?: string | undefined;
seriesId?: string | undefined;
assigneeId?: string | null | undefined;
reminder?: "none" | "at_time" | "15" | "30" | "60" | "120" | "1440" | undefined;
reminders?: string | undefined;
parentSubtaskId?: string | null | undefined;
}[] | undefined; }[] | undefined;
repeatProfiles?: { repeatProfiles?: {
id: string; id: string;
@@ -605,8 +872,21 @@ export declare const pushChangesSchema: z.ZodObject<{
repeatInterval: number; repeatInterval: number;
repeatDays: string; repeatDays: string;
}[] | undefined; }[] | undefined;
friendships?: {
id: string;
createdAt: number;
userId: string;
friendId: string;
status: "pending" | "accepted";
updatedAt: number;
}[] | undefined;
}; };
lastPulledAt: number; lastPulledAt: number;
deleted?: {
id: string;
updatedAt: number;
entity: "categories" | "tasks" | "subtasks" | "repeatProfiles" | "friendships";
}[] | undefined;
}>; }>;
export declare const syncQuerySchema: z.ZodObject<{ export declare const syncQuerySchema: z.ZodObject<{
since: z.ZodPipeline<z.ZodEffects<z.ZodString, number, string>, z.ZodNumber>; since: z.ZodPipeline<z.ZodEffects<z.ZodString, number, string>, z.ZodNumber>;
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../../src/utils/validation.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,eAAO,MAAM,oBAAoB;;;;;;;;;;;;EAI/B,CAAC;AAEH,eAAO,MAAM,oBAAoB;;;;;;;;;;;;EAI/B,CAAC;AAEH,eAAO,MAAM,YAAY,6DAA2D,CAAC;AAErF,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAe3B,CAAC;AAEH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAU3B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;EAG9B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;EAI9B,CAAC;AAEH,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;EAO7B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;EAK9B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;EAE9B,CAAC;AAEH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;EAO3B,CAAC;AAEH,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAS5B,CAAC;AAEH,eAAO,MAAM,eAAe;;;;;;EAE1B,CAAC;AAEH,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAU1B,CAAC"} {"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../../src/utils/validation.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAKxD;AAED,eAAO,MAAM,oBAAoB;;;;;;;;;;;;EAI/B,CAAC;AAEH,eAAO,MAAM,oBAAoB;;;;;;;;;;;;EAI/B,CAAC;AAEH,eAAO,MAAM,YAAY,6DAA2D,CAAC;AAIrF,eAAO,MAAM,iBAAiB,uOAGjB,CAAC;AACd,eAAO,MAAM,yBAAyB,0DAMzB,CAAC;AAEd,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAkB3B,CAAC;AAEH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAY3B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAiB9B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAkB9B,CAAC;AAEH,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;EAO7B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;EAK9B,CAAC;AAEH,eAAO,MAAM,yBAAyB;;;;;;;;;;;;;;;EAKpC,CAAC;AAEH,eAAO,MAAM,iBAAiB;;;;;;EAE5B,CAAC;AAEH,eAAO,MAAM,mBAAmB;;;;;;EAE9B,CAAC;AAEH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;EAO3B,CAAC;AAEH,eAAO,MAAM,eAAe;;;;;;;;;;;;EAI1B,CAAC;AAEH,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAU5B,CAAC;AAEH,eAAO,MAAM,eAAe;;;;;;EAE1B,CAAC;AAEH,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAU1B,CAAC"}
+77 -8
View File
@@ -1,7 +1,15 @@
"use strict"; "use strict";
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.taskQuerySchema = exports.syncQuerySchema = exports.pushChangesSchema = exports.friendshipSchema = exports.friendRequestSchema = exports.repeatProfileSchema = exports.userSettingsSchema = exports.subtaskUpdateSchema = exports.subtaskCreateSchema = exports.taskUpdateSchema = exports.taskCreateSchema = exports.repeatSchema = exports.categoryUpdateSchema = exports.categoryCreateSchema = void 0; exports.taskQuerySchema = exports.syncQuerySchema = exports.pushChangesSchema = exports.tombstoneSchema = exports.friendshipSchema = exports.friendRequestSchema = exports.searchQuerySchema = exports.repeatProfileUpdateSchema = exports.repeatProfileSchema = exports.userSettingsSchema = exports.subtaskUpdateSchema = exports.subtaskCreateSchema = exports.taskUpdateSchema = exports.taskCreateSchema = exports.repeatIntervalFieldSchema = exports.repeatFieldSchema = exports.repeatSchema = exports.categoryUpdateSchema = exports.categoryCreateSchema = void 0;
exports.canCompleteTask = canCompleteTask;
const zod_1 = require("zod"); const zod_1 = require("zod");
function canCompleteTask(dueDate) {
if (dueDate === 0)
return true;
const now = new Date();
const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999).getTime();
return dueDate <= endOfToday;
}
exports.categoryCreateSchema = zod_1.z.object({ exports.categoryCreateSchema = zod_1.z.object({
name: zod_1.z.string().min(1).max(50), name: zod_1.z.string().min(1).max(50),
color: zod_1.z.string().regex(/^#[0-9A-Fa-f]{6}$/), color: zod_1.z.string().regex(/^#[0-9A-Fa-f]{6}$/),
@@ -13,41 +21,87 @@ exports.categoryUpdateSchema = zod_1.z.object({
order: zod_1.z.number().int().min(0).optional(), order: zod_1.z.number().int().min(0).optional(),
}); });
exports.repeatSchema = zod_1.z.enum(['none', 'daily', 'weekly', 'monthly', 'custom']); exports.repeatSchema = zod_1.z.enum(['none', 'daily', 'weekly', 'monthly', 'custom']);
// The client stores '' / 0 for unset repeat values; normalize them so stale or
// legacy rows never fail validation on push.
exports.repeatFieldSchema = exports.repeatSchema
.or(zod_1.z.literal(''))
.transform((v) => (v === '' ? 'none' : v))
.optional();
exports.repeatIntervalFieldSchema = zod_1.z
.number()
.int()
.min(0)
.max(30)
.transform((v) => Math.max(1, v))
.optional();
exports.taskCreateSchema = zod_1.z.object({ exports.taskCreateSchema = zod_1.z.object({
title: zod_1.z.string().min(1).max(100), title: zod_1.z.string().min(1).max(100),
description: zod_1.z.string().max(1000).optional(), description: zod_1.z.string().max(1000).optional(),
categoryId: zod_1.z.string().min(1), categoryId: zod_1.z.string().max(100).transform((v) => (v === '' ? null : v)).nullable(),
priority: zod_1.z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(), priority: zod_1.z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
dueDate: zod_1.z.number().int().min(0).optional(), dueDate: zod_1.z.number().int().min(0).optional(),
dueTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().nullable(), dueTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(zod_1.z.literal('')).transform(v => v ?? ''),
endTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(zod_1.z.literal('')), endTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(zod_1.z.literal('')),
repeat: exports.repeatSchema.optional(), allDay: zod_1.z.boolean().optional(),
repeatInterval: zod_1.z.number().int().min(1).max(30).optional(), repeat: exports.repeatFieldSchema,
repeatInterval: exports.repeatIntervalFieldSchema,
repeatDays: zod_1.z.string().max(20).optional(), repeatDays: zod_1.z.string().max(20).optional(),
seriesId: zod_1.z.string().max(50).optional(), seriesId: zod_1.z.string().max(50).optional(),
reminder: zod_1.z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(), reminder: zod_1.z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
reminders: zod_1.z.string().max(100).optional(),
assigneeId: zod_1.z.string().nullable().optional(), assigneeId: zod_1.z.string().nullable().optional(),
completedAt: zod_1.z.number().int().min(0).nullable().optional(),
subtasks: zod_1.z.array(zod_1.z.object({ title: zod_1.z.string().min(1).max(100) })).optional(), subtasks: zod_1.z.array(zod_1.z.object({ title: zod_1.z.string().min(1).max(100) })).optional(),
}); });
exports.taskUpdateSchema = zod_1.z.object({ exports.taskUpdateSchema = zod_1.z.object({
title: zod_1.z.string().min(1).max(100).optional(), title: zod_1.z.string().min(1).max(100).optional(),
description: zod_1.z.string().max(1000).optional(), description: zod_1.z.string().max(1000).optional(),
categoryId: zod_1.z.string().min(1).optional(), categoryId: zod_1.z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)),
priority: zod_1.z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(), priority: zod_1.z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
completed: zod_1.z.boolean().optional(), completed: zod_1.z.boolean().optional(),
dueDate: zod_1.z.number().int().min(0).optional(), dueDate: zod_1.z.number().int().min(0).optional(),
dueTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().nullable(), dueTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(zod_1.z.literal('')).transform(v => v === '' ? undefined : v),
endTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(), endTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(),
reminders: zod_1.z.string().max(100).optional(),
assigneeId: zod_1.z.string().nullable().optional(), assigneeId: zod_1.z.string().nullable().optional(),
completedAt: zod_1.z.number().int().min(0).nullable().optional(),
}); });
exports.subtaskCreateSchema = zod_1.z.object({ exports.subtaskCreateSchema = zod_1.z.object({
title: zod_1.z.string().min(1).max(100), title: zod_1.z.string().min(1).max(100),
description: zod_1.z.string().max(1000).optional(),
priority: zod_1.z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
dueDate: zod_1.z.number().int().min(0).optional(),
dueTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(zod_1.z.literal('')).transform(v => v ?? ''),
endTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(zod_1.z.literal('')),
allDay: zod_1.z.boolean().optional(),
repeat: exports.repeatFieldSchema,
repeatInterval: exports.repeatIntervalFieldSchema,
repeatDays: zod_1.z.string().max(20).optional(),
seriesId: zod_1.z.string().max(50).optional(),
reminder: zod_1.z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
reminders: zod_1.z.string().max(100).optional(),
assigneeId: zod_1.z.string().nullable().optional(),
order: zod_1.z.number().int().min(0).optional(), order: zod_1.z.number().int().min(0).optional(),
parentSubtaskId: zod_1.z.string().nullable().optional(),
}); });
exports.subtaskUpdateSchema = zod_1.z.object({ exports.subtaskUpdateSchema = zod_1.z.object({
title: zod_1.z.string().min(1).max(100).optional(), title: zod_1.z.string().min(1).max(100).optional(),
description: zod_1.z.string().max(1000).optional(),
priority: zod_1.z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
completed: zod_1.z.boolean().optional(), completed: zod_1.z.boolean().optional(),
dueDate: zod_1.z.number().int().min(0).optional(),
dueTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(zod_1.z.literal('')).transform(v => v === '' ? undefined : v),
endTime: zod_1.z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(),
allDay: zod_1.z.boolean().optional(),
repeat: exports.repeatFieldSchema,
repeatInterval: exports.repeatIntervalFieldSchema,
repeatDays: zod_1.z.string().max(20).optional(),
seriesId: zod_1.z.string().max(50).optional(),
reminder: zod_1.z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
reminders: zod_1.z.string().max(100).optional(),
assigneeId: zod_1.z.string().nullable().optional(),
order: zod_1.z.number().int().min(0).optional(), order: zod_1.z.number().int().min(0).optional(),
parentSubtaskId: zod_1.z.string().nullable().optional(),
}); });
exports.userSettingsSchema = zod_1.z.object({ exports.userSettingsSchema = zod_1.z.object({
darkMode: zod_1.z.boolean().optional(), darkMode: zod_1.z.boolean().optional(),
@@ -63,6 +117,15 @@ exports.repeatProfileSchema = zod_1.z.object({
repeatInterval: zod_1.z.number().int().min(1).max(30), repeatInterval: zod_1.z.number().int().min(1).max(30),
repeatDays: zod_1.z.string().max(20), repeatDays: zod_1.z.string().max(20),
}); });
exports.repeatProfileUpdateSchema = zod_1.z.object({
name: zod_1.z.string().min(1).max(50).optional(),
repeat: exports.repeatSchema.optional(),
repeatInterval: zod_1.z.number().int().min(1).max(30).optional(),
repeatDays: zod_1.z.string().max(20).optional(),
});
exports.searchQuerySchema = zod_1.z.object({
q: zod_1.z.string().min(1).max(50),
});
exports.friendRequestSchema = zod_1.z.object({ exports.friendRequestSchema = zod_1.z.object({
username: zod_1.z.string().min(1).max(50), username: zod_1.z.string().min(1).max(50),
}); });
@@ -74,14 +137,20 @@ exports.friendshipSchema = zod_1.z.object({
createdAt: zod_1.z.number(), createdAt: zod_1.z.number(),
updatedAt: zod_1.z.number(), updatedAt: zod_1.z.number(),
}); });
exports.tombstoneSchema = zod_1.z.object({
entity: zod_1.z.enum(['categories', 'tasks', 'subtasks', 'repeatProfiles', 'friendships']),
id: zod_1.z.string(),
updatedAt: zod_1.z.number().int().min(0),
});
exports.pushChangesSchema = zod_1.z.object({ exports.pushChangesSchema = zod_1.z.object({
changes: zod_1.z.object({ changes: zod_1.z.object({
categories: zod_1.z.array(exports.categoryCreateSchema.extend({ id: zod_1.z.string(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number() })).optional(), categories: zod_1.z.array(exports.categoryCreateSchema.extend({ id: zod_1.z.string(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number() })).optional(),
tasks: zod_1.z.array(exports.taskCreateSchema.extend({ id: zod_1.z.string(), completed: zod_1.z.boolean(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number() })).optional(), tasks: zod_1.z.array(exports.taskCreateSchema.extend({ id: zod_1.z.string(), completed: zod_1.z.boolean(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number(), completedAt: zod_1.z.number().int().min(0).nullable().optional() })).optional(),
subtasks: zod_1.z.array(exports.subtaskCreateSchema.extend({ id: zod_1.z.string(), taskId: zod_1.z.string(), completed: zod_1.z.boolean(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number() })).optional(), subtasks: zod_1.z.array(exports.subtaskCreateSchema.extend({ id: zod_1.z.string(), taskId: zod_1.z.string(), completed: zod_1.z.boolean(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number() })).optional(),
repeatProfiles: zod_1.z.array(exports.repeatProfileSchema.extend({ id: zod_1.z.string(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number() })).optional(), repeatProfiles: zod_1.z.array(exports.repeatProfileSchema.extend({ id: zod_1.z.string(), createdAt: zod_1.z.number(), updatedAt: zod_1.z.number() })).optional(),
friendships: zod_1.z.array(exports.friendshipSchema).optional(), friendships: zod_1.z.array(exports.friendshipSchema).optional(),
}), }),
deleted: zod_1.z.array(exports.tombstoneSchema).optional(),
lastPulledAt: zod_1.z.number().int().min(0), lastPulledAt: zod_1.z.number().int().min(0),
}); });
exports.syncQuerySchema = zod_1.z.object({ exports.syncQuerySchema = zod_1.z.object({
File diff suppressed because one or more lines are too long
+176 -2
View File
@@ -8,6 +8,7 @@
"name": "carry-your-live-backend", "name": "carry-your-live-backend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@types/node-fetch": "^2.6.13",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
@@ -15,6 +16,7 @@
"express": "^4.21.0", "express": "^4.21.0",
"express-rate-limit": "^8.6.2", "express-rate-limit": "^8.6.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"node-fetch": "^3.3.2",
"pg": "^8.13.0", "pg": "^8.13.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
@@ -1015,12 +1017,21 @@
"version": "22.20.1", "version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "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": { "node_modules/@types/pg": {
"version": "8.20.4", "version": "8.20.4",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.4.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.4.tgz",
@@ -1099,6 +1110,12 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "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": { "node_modules/bcryptjs": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
@@ -1198,6 +1215,18 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -1251,6 +1280,15 @@
"url": "https://opencollective.com/express" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "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": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -1504,6 +1551,21 @@
"node": ">= 0.4" "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": { "node_modules/esbuild": {
"version": "0.19.12", "version": "0.19.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz",
@@ -1651,6 +1713,29 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "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": { "node_modules/finalhandler": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -1684,6 +1769,34 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "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": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1800,6 +1913,21 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/hasown": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@@ -2037,6 +2165,44 @@
"node": ">= 0.6" "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": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -2977,7 +3143,6 @@
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unpipe": { "node_modules/unpipe": {
@@ -3007,6 +3172,15 @@
"node": ">= 0.8" "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": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+2
View File
@@ -13,6 +13,7 @@
"db:studio": "drizzle-kit studio" "db:studio": "drizzle-kit studio"
}, },
"dependencies": { "dependencies": {
"@types/node-fetch": "^2.6.13",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
@@ -20,6 +21,7 @@
"express": "^4.21.0", "express": "^4.21.0",
"express-rate-limit": "^8.6.2", "express-rate-limit": "^8.6.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"node-fetch": "^3.3.2",
"pg": "^8.13.0", "pg": "^8.13.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
+41 -12
View File
@@ -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'; import { sql } from 'drizzle-orm';
export const ENTITIES = ['categories', 'tasks', 'subtasks', 'repeatProfiles', 'friendships'] as const;
export type EntityName = (typeof ENTITIES)[number];
export const users = pgTable('users', { export const users = pgTable('users', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
username: text('username').notNull().unique(), username: text('username').notNull().unique(),
passwordHash: text('password_hash').notNull(), passwordHash: text('password_hash').notNull(),
resetToken: text('reset_token'), resetToken: text('reset_token'),
resetTokenExpiry: bigint('reset_token_expiry', { mode: 'number' }), 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`), 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', { export const tasks = pgTable('tasks', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
categoryId: text('category_id').notNull().references(() => categories.id, { onDelete: 'cascade' }), categoryId: text('category_id').references(() => categories.id, { onDelete: 'cascade' }),
tags: text('tags').notNull().default(''),
title: text('title').notNull(), title: text('title').notNull(),
description: text('description').notNull().default(''), description: text('description').notNull().default(''),
priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'), priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
@@ -56,25 +62,33 @@ export const tasks = pgTable('tasks', {
assigneeId: text('assignee_id').references(() => users.id, { onDelete: 'set null' }), assigneeId: text('assignee_id').references(() => users.id, { onDelete: 'set null' }),
reminder: text('reminder', { enum: ['none', 'at_time', '15', '30', '60', '120', '1440'] }).notNull().default('none'), reminder: text('reminder', { enum: ['none', 'at_time', '15', '30', '60', '120', '1440'] }).notNull().default('none'),
reminders: text('reminders').notNull().default(''), reminders: text('reminders').notNull().default(''),
completedAt: bigint('completed_at', { mode: 'number' }),
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`), createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`), updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
}); });
export const repeatProfiles = pgTable('repeat_profiles', { export const tombstones = pgTable(
id: text('id').primaryKey(), 'tombstones',
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), {
name: text('name').notNull(), entity: text('entity', { enum: ENTITIES }).notNull(),
repeat: text('repeat', { enum: ['none', 'daily', 'weekly', 'monthly', 'custom'] }).notNull().default('none'), entityId: text('entity_id').notNull(),
repeatInterval: integer('repeat_interval').notNull().default(1), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
repeatDays: text('repeat_days').notNull().default(''), updatedAt: bigint('updated_at', { mode: 'number' }).notNull(),
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`), },
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`), (t) => ({
}); pk: primaryKey({ columns: [t.entity, t.entityId] }),
userIdx: index('tombstones_user_updated_idx').on(t.userId, t.updatedAt),
})
);
// Define subtasks with explicit type to avoid circular reference
export const subtasks = pgTable('subtasks', { export const subtasks = pgTable('subtasks', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
taskId: text('task_id').notNull().references(() => tasks.id, { onDelete: 'cascade' }), taskId: text('task_id').notNull().references(() => tasks.id, { onDelete: 'cascade' }),
parentSubtaskId: text('parent_subtask_id').references((): AnyPgColumn => subtasks.id, { onDelete: 'cascade' }),
categoryId: text('category_id').references(() => categories.id, { onDelete: 'set null' }),
tags: text('tags').notNull().default(''),
title: text('title').notNull(), title: text('title').notNull(),
description: text('description').notNull().default(''), description: text('description').notNull().default(''),
priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'), priority: text('priority', { enum: ['none', 'low', 'medium', 'high', 'critical'] }).notNull().default('none'),
@@ -93,6 +107,21 @@ export const subtasks = pgTable('subtasks', {
order: integer('order').notNull().default(0), order: integer('order').notNull().default(0),
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`), createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`), updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
}, (t) => ({
parentIdx: index('subtasks_parent_idx').on(t.parentSubtaskId),
taskIdx: index('subtasks_task_idx').on(t.taskId),
userIdx: index('subtasks_user_idx').on(t.userId),
}));
export const repeatProfiles = pgTable('repeat_profiles', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
repeat: text('repeat', { enum: ['none', 'daily', 'weekly', 'monthly', 'custom'] }).notNull().default('none'),
repeatInterval: integer('repeat_interval').notNull().default(1),
repeatDays: text('repeat_days').notNull().default(''),
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(sql`EXTRACT(EPOCH FROM NOW()) * 1000`),
}); });
export const userSettings = pgTable('user_settings', { export const userSettings = pgTable('user_settings', {
+2
View File
@@ -9,6 +9,7 @@ import userRoutes from './routes/users';
import friendRoutes from './routes/friends'; import friendRoutes from './routes/friends';
import repeatProfileRoutes from './routes/repeatProfiles'; import repeatProfileRoutes from './routes/repeatProfiles';
import syncRoutes from './routes/sync'; import syncRoutes from './routes/sync';
import pushNotificationRoutes from './routes/pushNotifications';
import { errorHandler, notFoundHandler } from './middleware/errorHandler'; import { errorHandler, notFoundHandler } from './middleware/errorHandler';
const app = express(); const app = express();
@@ -34,6 +35,7 @@ app.use('/api/users', userRoutes);
app.use('/api/repeat-profiles', repeatProfileRoutes); app.use('/api/repeat-profiles', repeatProfileRoutes);
app.use('/api/sync', syncRoutes); app.use('/api/sync', syncRoutes);
app.use('/api/friends', friendRoutes); app.use('/api/friends', friendRoutes);
app.use('/api/push', pushNotificationRoutes);
// 404 handler // 404 handler
app.use(notFoundHandler); app.use(notFoundHandler);
+97
View File
@@ -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;
+26 -4
View File
@@ -2,7 +2,7 @@ import { Router, Request, Response } from 'express';
import { asyncHandler } from '../utils/asyncHandler'; import { asyncHandler } from '../utils/asyncHandler';
import { db } from '../db'; import { db } from '../db';
import { subtasks, tasks } from '../db/schema'; import { subtasks, tasks } from '../db/schema';
import { eq, and, asc, desc } from 'drizzle-orm'; import { eq, and, asc, desc, isNull } from 'drizzle-orm';
import { authMiddleware } from '../utils/auth'; import { authMiddleware } from '../utils/auth';
import { AppError } from '../middleware/errorHandler'; import { AppError } from '../middleware/errorHandler';
import { subtaskCreateSchema, subtaskUpdateSchema } from '../utils/validation'; import { subtaskCreateSchema, subtaskUpdateSchema } from '../utils/validation';
@@ -11,6 +11,17 @@ const router = Router();
router.use(authMiddleware); router.use(authMiddleware);
// Helper to build nested subtask tree
const buildSubtaskTree = (allSubtasks: any[], parentId: string | null = null): any[] => {
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', asyncHandler(async (req: Request, res: Response) => { router.get('/task/:taskId', asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.userId; const userId = req.user!.userId;
@@ -31,7 +42,9 @@ router.get('/task/:taskId', asyncHandler(async (req: Request, res: Response) =>
.where(and(eq(subtasks.taskId, req.params.taskId), eq(subtasks.userId, userId))) .where(and(eq(subtasks.taskId, req.params.taskId), eq(subtasks.userId, userId)))
.orderBy(asc(subtasks.order)); .orderBy(asc(subtasks.order));
res.json({ subtasks: taskSubtasks }); const nestedSubtasks = buildSubtaskTree(taskSubtasks);
res.json({ subtasks: nestedSubtasks });
})); }));
router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) => { router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) => {
@@ -49,10 +62,16 @@ router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) =>
} }
const now = Date.now(); const now = Date.now();
const parentSubtaskId = data.parentSubtaskId || null;
const maxOrder = await db const maxOrder = await db
.select({ order: subtasks.order }) .select({ order: subtasks.order })
.from(subtasks) .from(subtasks)
.where(and(eq(subtasks.taskId, req.params.taskId), eq(subtasks.userId, userId))) .where(and(
eq(subtasks.taskId, req.params.taskId),
eq(subtasks.userId, userId),
parentSubtaskId ? eq(subtasks.parentSubtaskId, parentSubtaskId) : isNull(subtasks.parentSubtaskId)
))
.orderBy(desc(subtasks.order)) .orderBy(desc(subtasks.order))
.limit(1); .limit(1);
@@ -62,6 +81,9 @@ router.post('/task/:taskId', asyncHandler(async (req: Request, res: Response) =>
id: subtaskId, id: subtaskId,
userId, userId,
taskId: req.params.taskId, taskId: req.params.taskId,
parentSubtaskId,
categoryId: data.categoryId ?? null,
tags: data.tags ?? '',
title: data.title, title: data.title,
description: data.description ?? '', description: data.description ?? '',
priority: data.priority ?? 'none', priority: data.priority ?? 'none',
@@ -97,7 +119,7 @@ router.patch('/:id', asyncHandler(async (req: Request, res: Response) => {
const data = subtaskUpdateSchema.parse(req.body); const data = subtaskUpdateSchema.parse(req.body);
const userId = req.user!.userId; const userId = req.user!.userId;
const existing = await db const existing: any[] = await db
.select() .select()
.from(subtasks) .from(subtasks)
.where(and(eq(subtasks.id, req.params.id), eq(subtasks.userId, userId))) .where(and(eq(subtasks.id, req.params.id), eq(subtasks.userId, userId)))
+288 -66
View File
@@ -1,8 +1,8 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { asyncHandler } from '../utils/asyncHandler'; import { asyncHandler } from '../utils/asyncHandler';
import { db } from '../db'; import { db } from '../db';
import { categories, tasks, subtasks, repeatProfiles, users, friendships } from '../db/schema'; import { categories, tasks, subtasks, repeatProfiles, users, friendships, tombstones, type EntityName } from '../db/schema';
import { eq, and, gte, lte, asc, or, inArray, sql } from 'drizzle-orm'; import { eq, and, gte, asc, or, inArray, sql } from 'drizzle-orm';
import { authMiddleware } from '../utils/auth'; import { authMiddleware } from '../utils/auth';
import { AppError } from '../middleware/errorHandler'; import { AppError } from '../middleware/errorHandler';
import { syncQuerySchema, pushChangesSchema, canCompleteTask } from '../utils/validation'; import { syncQuerySchema, pushChangesSchema, canCompleteTask } from '../utils/validation';
@@ -17,55 +17,47 @@ router.get('/', asyncHandler(async (req: Request, res: Response) => {
const sinceDate = since; const sinceDate = since;
// Fetch categories changed since timestamp // Fetch all changed entities in parallel
const changedCategories = await db const [changedCategories, changedTasks, changedSubtasks, changedRepeatProfiles, changedFriendships, changedTombstones] = await Promise.all([
.select() db
.from(categories) .select()
.where(and(eq(categories.userId, userId), gte(categories.updatedAt, sinceDate))) .from(categories)
.orderBy(asc(categories.updatedAt)); .where(and(eq(categories.userId, userId), gte(categories.updatedAt, sinceDate)))
.orderBy(asc(categories.updatedAt)),
// Fetch tasks changed since timestamp db
const changedTasks = await db .select()
.select() .from(tasks)
.from(tasks) .where(and(eq(tasks.userId, userId), gte(tasks.updatedAt, sinceDate)))
.where(and(eq(tasks.userId, userId), gte(tasks.updatedAt, sinceDate))) .orderBy(asc(tasks.updatedAt)),
.orderBy(asc(tasks.updatedAt));
// Fetch subtasks changed since timestamp db
const taskIds = changedTasks.map(t => t.id);
let changedSubtasks: any[] = [];
if (taskIds.length > 0) {
changedSubtasks = await db
.select() .select()
.from(subtasks) .from(subtasks)
.where(and(eq(subtasks.userId, userId), gte(subtasks.updatedAt, sinceDate))) .where(and(eq(subtasks.userId, userId), gte(subtasks.updatedAt, sinceDate)))
.orderBy(asc(subtasks.updatedAt)); .orderBy(asc(subtasks.updatedAt)),
} else {
// Also fetch subtasks for tasks that might have been deleted (we track by updatedAt) db
changedSubtasks = await db
.select() .select()
.from(subtasks) .from(repeatProfiles)
.where(and(eq(subtasks.userId, userId), gte(subtasks.updatedAt, sinceDate))) .where(and(eq(repeatProfiles.userId, userId), gte(repeatProfiles.updatedAt, sinceDate)))
.orderBy(asc(subtasks.updatedAt)); .orderBy(asc(repeatProfiles.updatedAt)),
}
// Fetch repeat profiles changed since timestamp db
const changedRepeatProfiles = await db .select()
.select() .from(friendships)
.from(repeatProfiles) .where(and(
.where(and(eq(repeatProfiles.userId, userId), gte(repeatProfiles.updatedAt, sinceDate))) or(eq(friendships.userId, userId), eq(friendships.friendId, userId)),
.orderBy(asc(repeatProfiles.updatedAt)); gte(friendships.updatedAt, sinceDate)
))
.orderBy(asc(friendships.updatedAt)),
// Fetch friendships changed since timestamp db
const changedFriendships = await db .select()
.select() .from(tombstones)
.from(friendships) .where(and(eq(tombstones.userId, userId), gte(tombstones.updatedAt, sinceDate)))
.where(and( .orderBy(asc(tombstones.updatedAt)),
or(eq(friendships.userId, userId), eq(friendships.friendId, userId)), ]);
gte(friendships.updatedAt, sinceDate)
))
.orderBy(asc(friendships.updatedAt));
const timestamp = Date.now(); const timestamp = Date.now();
@@ -75,6 +67,7 @@ router.get('/', asyncHandler(async (req: Request, res: Response) => {
subtasks: changedSubtasks, subtasks: changedSubtasks,
repeatProfiles: changedRepeatProfiles, repeatProfiles: changedRepeatProfiles,
friendships: changedFriendships, friendships: changedFriendships,
deleted: changedTombstones.map((t) => ({ entity: t.entity, id: t.entityId, updatedAt: t.updatedAt })),
timestamp, timestamp,
}); });
})); }));
@@ -125,6 +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 // Process categories
if (data.changes.categories && data.changes.categories.length > 0) { if (data.changes.categories && data.changes.categories.length > 0) {
for (const cat of data.changes.categories) { for (const cat of data.changes.categories) {
@@ -185,7 +199,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
resolution: 'server_wins', resolution: 'server_wins',
}); });
continue; // Server wins continue; // Server wins
} }
// Prevent completing tasks with future due dates // Prevent completing tasks with future due dates
if (task.completed === true) { if (task.completed === true) {
@@ -203,30 +217,33 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
} }
await tx await tx
.update(tasks) .update(tasks)
.set({ .set({
title: task.title, title: task.title,
description: task.description, description: task.description,
categoryId: task.categoryId, categoryId: task.categoryId,
priority: task.priority, tags: task.tags ?? '',
completed: task.completed, priority: task.priority,
dueDate: task.dueDate, completed: task.completed,
dueTime: task.dueTime ?? '', dueDate: task.dueDate,
endTime: task.endTime ?? '', dueTime: task.dueTime ?? '',
allDay: task.allDay ?? false, endTime: task.endTime ?? '',
repeat: task.repeat ?? 'none', allDay: task.allDay ?? false,
repeatInterval: task.repeatInterval ?? 1, repeat: task.repeat ?? 'none',
repeatDays: task.repeatDays ?? '', repeatInterval: task.repeatInterval ?? 1,
seriesId: task.seriesId ?? '', repeatDays: task.repeatDays ?? '',
reminder: task.reminder ?? 'none', seriesId: task.seriesId ?? '',
reminders: task.reminders ?? '', reminder: task.reminder ?? 'none',
assigneeId: task.assigneeId ?? null, reminders: task.reminders ?? '',
updatedAt: task.updatedAt, assigneeId: sanitizeAssignee(task.assigneeId),
}) completedAt: task.completedAt ?? null,
.where(and(eq(tasks.id, task.id), eq(tasks.userId, userId))); updatedAt: task.updatedAt,
})
.where(and(eq(tasks.id, task.id), eq(tasks.userId, userId)));
} else { } else {
await tx.insert(tasks).values({ await tx.insert(tasks).values({
...task, ...task,
assigneeId: sanitizeAssignee(task.assigneeId),
allDay: task.allDay ?? false, allDay: task.allDay ?? false,
userId, userId,
}); });
@@ -260,6 +277,9 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
.update(subtasks) .update(subtasks)
.set({ .set({
taskId: sub.taskId, taskId: sub.taskId,
parentSubtaskId: sub.parentSubtaskId ?? null,
categoryId: sub.categoryId ?? null,
tags: sub.tags ?? '',
title: sub.title, title: sub.title,
description: sub.description ?? '', description: sub.description ?? '',
priority: sub.priority ?? 'none', priority: sub.priority ?? 'none',
@@ -274,7 +294,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
seriesId: sub.seriesId ?? '', seriesId: sub.seriesId ?? '',
reminder: sub.reminder ?? 'none', reminder: sub.reminder ?? 'none',
reminders: sub.reminders ?? '', reminders: sub.reminders ?? '',
assigneeId: sub.assigneeId ?? null, assigneeId: sanitizeAssignee(sub.assigneeId),
order: sub.order, order: sub.order,
updatedAt: sub.updatedAt, updatedAt: sub.updatedAt,
}) })
@@ -282,6 +302,7 @@ router.post('/push', asyncHandler(async (req: Request, res: Response) => {
} else { } else {
await tx.insert(subtasks).values({ await tx.insert(subtasks).values({
...sub, ...sub,
assigneeId: sanitizeAssignee(sub.assigneeId),
userId, userId,
}); });
} }
@@ -365,6 +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) { } catch (error) {
console.error('Sync push error:', 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; export default router;
+21 -14
View File
@@ -116,15 +116,17 @@ router.post('/', asyncHandler(async (req: Request, res: Response) => {
const userId = req.user!.userId; const userId = req.user!.userId;
const now = Date.now(); const now = Date.now();
// Verify category exists and belongs to user // Verify category exists and belongs to user (optional - tasks may be uncategorized)
const cat = await db if (data.categoryId) {
.select() const cat = await db
.from(categories) .select()
.where(and(eq(categories.id, data.categoryId), eq(categories.userId, userId))) .from(categories)
.limit(1); .where(and(eq(categories.id, data.categoryId), eq(categories.userId, userId)))
.limit(1);
if (cat.length === 0) { if (cat.length === 0) {
throw new AppError('NOT_FOUND', 'Category not found', 404); throw new AppError('NOT_FOUND', 'Category not found', 404);
}
} }
const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
@@ -133,6 +135,7 @@ router.post('/', asyncHandler(async (req: Request, res: Response) => {
id: taskId, id: taskId,
userId, userId,
categoryId: data.categoryId, categoryId: data.categoryId,
tags: data.tags ?? '',
title: data.title, title: data.title,
description: data.description ?? '', description: data.description ?? '',
priority: data.priority ?? 'none', priority: data.priority ?? 'none',
@@ -157,6 +160,8 @@ router.post('/', asyncHandler(async (req: Request, res: Response) => {
userId, userId,
taskId, taskId,
title: st.title, title: st.title,
categoryId: (st as any).categoryId ?? null,
tags: (st as any).tags ?? '',
completed: false, completed: false,
order: index, order: index,
createdAt: now, createdAt: now,
@@ -261,12 +266,14 @@ router.post('/batch', asyncHandler(async (req: Request, res: Response) => {
for (const op of operations) { for (const op of operations) {
try { try {
if (op.type === 'create') { if (op.type === 'create') {
const cat = await db if (op.data.categoryId) {
.select() const cat = await db
.from(categories) .select()
.where(and(eq(categories.id, op.data.categoryId), eq(categories.userId, userId))) .from(categories)
.limit(1); .where(and(eq(categories.id, op.data.categoryId), eq(categories.userId, userId)))
if (cat.length === 0) throw new AppError('NOT_FOUND', 'Category not found', 404); .limit(1);
if (cat.length === 0) throw new AppError('NOT_FOUND', 'Category not found', 404);
}
const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; const taskId = `task_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const now = Date.now(); const now = Date.now();
+153
View File
@@ -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',
};
}
+41 -10
View File
@@ -21,29 +21,46 @@ export const categoryUpdateSchema = z.object({
export const repeatSchema = z.enum(['none', 'daily', 'weekly', 'monthly', 'custom']); export const repeatSchema = z.enum(['none', 'daily', 'weekly', 'monthly', 'custom']);
// The client stores '' / 0 for unset repeat values; normalize them so stale or
// legacy rows never fail validation on push.
export const repeatFieldSchema = repeatSchema
.or(z.literal(''))
.transform((v) => (v === '' ? 'none' : v))
.optional();
export const repeatIntervalFieldSchema = z
.number()
.int()
.min(0)
.max(30)
.transform((v) => Math.max(1, v))
.optional();
export const taskCreateSchema = z.object({ export const taskCreateSchema = z.object({
title: z.string().min(1).max(100), title: z.string().min(1).max(100),
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
categoryId: z.string().min(1), categoryId: z.string().max(100).transform((v) => (v === '' ? null : v)).nullable(),
tags: z.string().max(500).optional(),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(), priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
dueDate: z.number().int().min(0).optional(), dueDate: z.number().int().min(0).optional(),
dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v ?? ''), dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v ?? ''),
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')), endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')),
allDay: z.boolean().optional(), allDay: z.boolean().optional(),
repeat: repeatSchema.optional(), repeat: repeatFieldSchema,
repeatInterval: z.number().int().min(1).max(30).optional(), repeatInterval: repeatIntervalFieldSchema,
repeatDays: z.string().max(20).optional(), repeatDays: z.string().max(20).optional(),
seriesId: z.string().max(50).optional(), seriesId: z.string().max(50).optional(),
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(), reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
reminders: z.string().max(100).optional(), reminders: z.string().max(100).optional(),
assigneeId: z.string().nullable().optional(), assigneeId: z.string().nullable().optional(),
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({ export const taskUpdateSchema = z.object({
title: z.string().min(1).max(100).optional(), title: z.string().min(1).max(100).optional(),
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
categoryId: z.string().min(1).optional(), categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)),
tags: z.string().max(500).optional(),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(), priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
completed: z.boolean().optional(), completed: z.boolean().optional(),
dueDate: z.number().int().min(0).optional(), dueDate: z.number().int().min(0).optional(),
@@ -51,43 +68,50 @@ export const taskUpdateSchema = z.object({
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(), endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(),
reminders: z.string().max(100).optional(), reminders: z.string().max(100).optional(),
assigneeId: z.string().nullable().optional(), assigneeId: z.string().nullable().optional(),
completedAt: z.number().int().min(0).nullable().optional(),
}); });
export const subtaskCreateSchema = z.object({ export const subtaskCreateSchema = z.object({
title: z.string().min(1).max(100), title: z.string().min(1).max(100),
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)),
tags: z.string().max(500).optional(),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(), priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
dueDate: z.number().int().min(0).optional(), dueDate: z.number().int().min(0).optional(),
dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v ?? ''), dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v ?? ''),
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')), endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')),
allDay: z.boolean().optional(), allDay: z.boolean().optional(),
repeat: repeatSchema.optional(), repeat: repeatFieldSchema,
repeatInterval: z.number().int().min(1).max(30).optional(), repeatInterval: repeatIntervalFieldSchema,
repeatDays: z.string().max(20).optional(), repeatDays: z.string().max(20).optional(),
seriesId: z.string().max(50).optional(), seriesId: z.string().max(50).optional(),
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(), reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
reminders: z.string().max(100).optional(), reminders: z.string().max(100).optional(),
assigneeId: z.string().nullable().optional(), assigneeId: z.string().nullable().optional(),
order: z.number().int().min(0).optional(), order: z.number().int().min(0).optional(),
parentSubtaskId: z.string().nullable().optional(),
}); });
export const subtaskUpdateSchema = z.object({ export const subtaskUpdateSchema = z.object({
title: z.string().min(1).max(100).optional(), title: z.string().min(1).max(100).optional(),
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
categoryId: z.string().max(100).nullable().optional().transform((v) => (v === '' ? null : v)),
tags: z.string().max(500).optional(),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(), priority: z.enum(['none', 'low', 'medium', 'high', 'critical']).optional(),
completed: z.boolean().optional(), completed: z.boolean().optional(),
dueDate: z.number().int().min(0).optional(), dueDate: z.number().int().min(0).optional(),
dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v === '' ? undefined : v), dueTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional().or(z.literal('')).transform(v => v === '' ? undefined : v),
endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(), endTime: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/).optional(),
allDay: z.boolean().optional(), allDay: z.boolean().optional(),
repeat: repeatSchema.optional(), repeat: repeatFieldSchema,
repeatInterval: z.number().int().min(1).max(30).optional(), repeatInterval: repeatIntervalFieldSchema,
repeatDays: z.string().max(20).optional(), repeatDays: z.string().max(20).optional(),
seriesId: z.string().max(50).optional(), seriesId: z.string().max(50).optional(),
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(), reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']).optional(),
reminders: z.string().max(100).optional(), reminders: z.string().max(100).optional(),
assigneeId: z.string().nullable().optional(), assigneeId: z.string().nullable().optional(),
order: z.number().int().min(0).optional(), order: z.number().int().min(0).optional(),
parentSubtaskId: z.string().nullable().optional(),
}); });
export const userSettingsSchema = z.object({ export const userSettingsSchema = z.object({
@@ -130,14 +154,21 @@ export const friendshipSchema = z.object({
updatedAt: z.number(), updatedAt: z.number(),
}); });
export const tombstoneSchema = z.object({
entity: z.enum(['categories', 'tasks', 'subtasks', 'repeatProfiles', 'friendships']),
id: z.string(),
updatedAt: z.number().int().min(0),
});
export const pushChangesSchema = z.object({ export const pushChangesSchema = z.object({
changes: z.object({ changes: z.object({
categories: z.array(categoryCreateSchema.extend({ id: z.string(), createdAt: z.number(), updatedAt: z.number() })).optional(), categories: z.array(categoryCreateSchema.extend({ id: z.string(), createdAt: z.number(), updatedAt: z.number() })).optional(),
tasks: z.array(taskCreateSchema.extend({ id: z.string(), completed: z.boolean(), createdAt: z.number(), updatedAt: z.number() })).optional(), tasks: z.array(taskCreateSchema.extend({ id: z.string(), completed: z.boolean(), createdAt: z.number(), updatedAt: z.number(), completedAt: z.number().int().min(0).nullable().optional() })).optional(),
subtasks: z.array(subtaskCreateSchema.extend({ id: z.string(), taskId: z.string(), completed: z.boolean(), createdAt: z.number(), updatedAt: z.number() })).optional(), subtasks: z.array(subtaskCreateSchema.extend({ id: z.string(), taskId: z.string(), completed: z.boolean(), createdAt: z.number(), updatedAt: z.number() })).optional(),
repeatProfiles: z.array(repeatProfileSchema.extend({ id: z.string(), createdAt: z.number(), updatedAt: z.number() })).optional(), repeatProfiles: z.array(repeatProfileSchema.extend({ id: z.string(), createdAt: z.number(), updatedAt: z.number() })).optional(),
friendships: z.array(friendshipSchema).optional(), friendships: z.array(friendshipSchema).optional(),
}), }),
deleted: z.array(tombstoneSchema).optional(),
lastPulledAt: z.number().int().min(0), lastPulledAt: z.number().int().min(0),
}); });
+12
View File
@@ -0,0 +1,12 @@
./gradlew assembleRelease
/home/tech08mag/Android/Sdk/platform-tools/adb push /home/tech08mag/Code/carry-your-live/carry-your-live/android/app/build/outputs/apk/release/app-release.apk /sdcard/Download/
./gradlew installDebug
./gradlew assembleRelease Build release APK (what you ran)
./gradlew bundleRelease Build release AAB (for Play Store)
./gradlew assembleDebug Build debug APK
./gradlew installRelease Build + install release APK to connected device
./gradlew installDebug Build + install debug APK
./gradlew clean Clean build outputs
./gradlew tasks List all available tasks
+10
View File
@@ -0,0 +1,10 @@
node_modules
dist
.expo
.git
*.log
.DS_Store
android
ios
coverage
*.local
+3
View File
@@ -7,6 +7,9 @@ node_modules/
.expo/ .expo/
dist/ dist/
web-build/ web-build/
# Electron
desktop-build/
expo-env.d.ts expo-env.d.ts
# Native # Native
+18
View File
@@ -0,0 +1,18 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx expo export -p web
FROM nginx:alpine AS runner
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8081
CMD ["nginx", "-g", "daemon off;"]
+11 -1
View File
@@ -6,10 +6,13 @@
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
"backgroundColor": "#121212",
"ios": { "ios": {
"supportsTablet": true "supportsTablet": true
}, },
"android": { "android": {
"softwareKeyboardLayoutMode": "resize",
"backgroundColor": "#121212",
"adaptiveIcon": { "adaptiveIcon": {
"backgroundColor": "#E6F4FE", "backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png", "foregroundImage": "./assets/android-icon-foreground.png",
@@ -30,12 +33,19 @@
"icon": "./assets/android-icon-foreground.png", "icon": "./assets/android-icon-foreground.png",
"color": "#1E88E5" "color": "#1E88E5"
} }
] ],
"./plugins/withQuickAddWidget"
], ],
"extra": { "extra": {
"eas": { "eas": {
"projectId": "93a56631-01e5-45ab-9de1-7e6fb863c0a9" "projectId": "93a56631-01e5-45ab-9de1-7e6fb863c0a9"
} }
},
"runtimeVersion": {
"policy": "appVersion"
},
"updates": {
"url": "https://u.expo.dev/93a56631-01e5-45ab-9de1-7e6fb863c0a9"
} }
} }
} }
+100 -33
View File
@@ -1,57 +1,124 @@
import { Tabs } from 'expo-router'; import { Tabs } from 'expo-router';
import React from 'react'; 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 { TabBarIcon } from '@/components/TabBarIcon';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { setLastVisitedTab } from '@/utils/tabHistory';
export default function TabLayout() { export default function TabLayout() {
const { theme } = useSettings(); const { theme } = useSettings();
const insets = useSafeAreaInsets();
return ( return (
<Tabs <Tabs
screenOptions={{ screenOptions={{
tabBarActiveTintColor: theme.accent, tabBarActiveTintColor: theme.accent,
tabBarInactiveTintColor: '#8E8E8E', tabBarInactiveTintColor: theme.textSecondary,
tabBarStyle: { tabBarStyle: {
backgroundColor: theme.tabBarBg, backgroundColor: theme.tabBarBg,
borderTopWidth: 1, borderTopWidth: 0,
borderTopColor: theme.border, height: 84 + insets.bottom,
height: 64, paddingBottom: insets.bottom,
paddingBottom: 0, paddingTop: 12,
paddingHorizontal: 16,
elevation: 0,
shadowColor: '#000',
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.12,
shadowRadius: 20,
},
tabBarItemStyle: {
paddingVertical: 0,
}, },
tabBarLabelStyle: { tabBarLabelStyle: {
fontSize: 11, fontSize: 11,
fontWeight: '500', fontWeight: '700',
marginTop: 4,
}, },
headerShown: false, 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 <Tabs.Screen name="index" />
name="index" <Tabs.Screen name="calendar" />
options={{ <Tabs.Screen name="stats" />
title: 'Tasks', <Tabs.Screen name="settings" />
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="settings"
options={{
title: 'Settings',
tabBarIcon: ({ focused, color }) => (
<TabBarIcon name="gear" focused={focused} color={color} />
),
}}
/>
</Tabs> </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,
},
});
+560 -170
View File
@@ -1,194 +1,503 @@
import React, { useMemo, useRef, useState } from 'react'; import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, FlatList } from 'react-native'; import { View, Text, StyleSheet, SafeAreaView, ScrollView, TouchableOpacity, Animated, Dimensions, KeyboardAvoidingView } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter, useFocusEffect } from 'expo-router';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { useTasksByDate } from '@/hooks/useTasks'; import { useTasksByDate, useTasksInMonth } from '@/hooks/useTasks';
import { toggleTaskComplete } from '@/utils/taskActions'; import { useSubtasks } from '@/hooks/useSubtasks';
import { useTaskModals } from '@/hooks/useTaskModals';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { TaskItem } from '@/components/TaskItem'; import { useCategories } from '@/hooks/useDatabase';
import { useICalImport } from '@/hooks/useICalImport';
import { confirmAndToggleTaskComplete, toggleSubtaskComplete } from '@/utils/taskActions';
import { QuickAddBar } from '@/components/QuickAddBar'; import { QuickAddBar } from '@/components/QuickAddBar';
import { TaskData } from '@/types'; import { OptionPickerModal } from '@/components/OptionPickerModal';
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, addMonths, isToday } from 'date-fns'; import { TaskItem } from '@/components/TaskItem';
import { SubtaskItem } from '@/components/SubtaskItem';
import Task from '@/models/Task';
import { format, addMonths, addDays, startOfMonth, isSameDay, isSameMonth, isToday } from 'date-fns';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
import type { ThemeColors } from '@/theme';
import type { TaskData, SubtaskData } from '@/types';
const DAY_WIDTH = 44; const WIDTH = Dimensions.get('window').width;
const DAY_GAP = 6; const GERMAN_WEEKDAYS = ['mo', 'di', 'mi', 'do', 'fr', 'sa', 'so'];
const PRIORITY_RANK: Record<string, number> = { none: 0, low: 1, medium: 2, high: 3, critical: 4 };
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
export default function CalendarScreen() { export default function CalendarScreen() {
const router = useRouter(); const router = useRouter();
const { theme } = useSettings(); const { theme, sortBy } = useSettings();
const styles = React.useMemo(() => createStyles(theme), [theme]);
const categories = useCategories();
const categoryColors = useMemo(() => {
const map = new Map<string, string>();
for (const c of categories) {
map.set(c.id, c.color);
}
return map;
}, [categories]);
const { modals, openTaskEdit } = useTaskModals();
const [visibleMonth, setVisibleMonth] = useState(() => new Date()); const [visibleMonth, setVisibleMonth] = useState(() => new Date());
const [selectedDate, setSelectedDate] = useState(() => new Date()); const [selectedDate, setSelectedDate] = useState(() => new Date());
const stripRef = useRef<ScrollView>(null); const [monthPickerVisible, setMonthPickerVisible] = useState(false);
const [yearPickerVisible, setYearPickerVisible] = useState(false);
const { map: subtasksByTask, refresh: refreshSubtasks } = useSubtasks();
const days = useMemo( const visibleMonthRef = useRef(visibleMonth);
() => eachDayOfInterval({ start: startOfMonth(visibleMonth), end: endOfMonth(visibleMonth) }), const selectedDateRef = useRef(selectedDate);
[visibleMonth] const translateX = useRef<Animated.Value>(new Animated.Value(0));
const animatingRef = useRef(false);
const gridWidthRef = useRef<number>(WIDTH);
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;
const sortedDayTasks = useMemo(() => {
const sorted = [...selectedDayTasks];
switch (sortBy) {
case 'date':
sorted.sort((a, b) => (a.dueDate || Number.MAX_SAFE_INTEGER) - (b.dueDate || Number.MAX_SAFE_INTEGER));
break;
case 'priority':
sorted.sort((a, b) => PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority]);
break;
case 'alpha':
sorted.sort((a, b) => a.title.localeCompare(b.title));
break;
case 'created':
sorted.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
break;
}
return sorted;
}, [selectedDayTasks, sortBy]);
useFocusEffect(
useCallback(() => {
refreshDayTasks();
refreshMonthTasks();
refreshSubtasks();
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks])
); );
const { tasks, loading } = useTasksByDate(selectedDate); const weeks = useMemo(() => {
const first = startOfMonth(visibleMonth);
const handleDayPress = (day: Date) => { const offset = (first.getDay() + 6) % 7; // week starts Monday
setSelectedDate(day); const gridStart = addDays(first, -offset);
if (!isSameMonth(day, visibleMonth)) { const cells = Array.from({ length: 42 }, (_, i) => addDays(gridStart, i));
setVisibleMonth(day); const rows: Date[][] = [];
} for (let i = 0; i < 42; i += 7) rows.push(cells.slice(i, i + 7));
}; return rows;
const handlePrevMonth = () => {
const prev = addMonths(visibleMonth, -1);
setVisibleMonth(prev);
if (!isSameMonth(selectedDate, prev)) {
setSelectedDate(startOfMonth(prev));
}
};
const handleNextMonth = () => {
const next = addMonths(visibleMonth, 1);
setVisibleMonth(next);
if (!isSameMonth(selectedDate, next)) {
setSelectedDate(startOfMonth(next));
}
};
const handleToggleComplete = async (taskId: string) => {
await toggleTaskComplete(taskId);
};
const today = new Date();
const scrollToDay = (day: Date) => {
const index = days.findIndex((d) => isSameDay(d, day));
if (index >= 0) {
stripRef.current?.scrollTo({ x: Math.max(0, index * (DAY_WIDTH + DAY_GAP) - 24), animated: true });
}
};
React.useEffect(() => {
scrollToDay(isSameMonth(selectedDate, visibleMonth) ? selectedDate : today);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visibleMonth]); }, [visibleMonth]);
const transitionTo = useCallback(
(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.current, { toValue: target, duration: 220, useNativeDriver: false }).start(() => {
setVisibleMonth(next);
translateX.current.setValue(-target);
Animated.timing(translateX.current, { toValue: 0, duration: 180, useNativeDriver: false }).start(() => {
animatingRef.current = false;
});
});
},
[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.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.current.stopAnimation();
transitionTo(1, false);
} else if (dx >= w / 4) {
translateX.current.stopAnimation();
transitionTo(-1, false);
} else {
Animated.spring(translateX.current, { toValue: 0, useNativeDriver: false }).start();
}
}),
[translateX, transitionTo]
);
const handleSelectMonth = useCallback((value: string | string[]) => {
const monthIndex = parseInt(Array.isArray(value) ? value[0] : value, 10);
setVisibleMonth(new Date(visibleMonthRef.current.getFullYear(), monthIndex, 1));
}, []);
const handleSelectYear = useCallback((value: string | string[]) => {
const year = parseInt(Array.isArray(value) ? value[0] : value, 10);
setVisibleMonth(new Date(year, visibleMonthRef.current.getMonth(), 1));
}, []);
const currentYear = new Date().getFullYear();
const monthOptions = MONTH_NAMES.map((label, i) => ({ value: String(i), label }));
const yearOptions = useMemo(() => {
const options: { value: string; label: string }[] = [];
for (let y = currentYear - 20; y <= currentYear + 10; y++) options.push({ value: String(y), label: String(y) });
return options;
}, [currentYear]);
const handleToggleComplete = useCallback(async (taskId: string) => {
await confirmAndToggleTaskComplete(taskId, () => {
refreshDayTasks();
refreshMonthTasks();
refreshSubtasks();
});
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]);
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
const toggleExpand = useCallback((taskId: string) => {
setExpandedTasks((prev) => {
const next = new Set(prev);
if (next.has(taskId)) {
next.delete(taskId);
} else {
next.add(taskId);
}
return next;
});
}, []);
const handleSubtaskToggle = useCallback(async (subtaskId: string) => {
await toggleSubtaskComplete(subtaskId);
refreshDayTasks();
refreshMonthTasks();
refreshSubtasks();
}, [refreshDayTasks, refreshMonthTasks, refreshSubtasks]);
return ( return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}> <SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title="Calendar" showLogo={false} /> <Header title="Calendar" showLogo={false} />
<ScrollView <KeyboardAvoidingView style={styles.kbAvoid} behavior="padding">
ref={stripRef} <ScrollView style={styles.scrollBody} showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
horizontal <GestureDetector gesture={pan}>
showsHorizontalScrollIndicator={false} <Animated.View
contentContainerStyle={styles.dateStrip} onLayout={(e) => {
> gridWidthRef.current = e.nativeEvent.layout.width;
{days.map((day) => { }}
const isSelected = isSameDay(day, selectedDate); // eslint-disable-next-line react-hooks/refs
const isCurrent = isToday(day); style={[styles.calendarArea, { transform: [{ translateX: translateX.current }] }]}
return ( >
<TouchableOpacity <View style={styles.monthRow}>
key={day.toISOString()} <TouchableOpacity
style={[ onPress={() => transitionTo(-1)}
styles.dayButton, style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
{ backgroundColor: theme.card, borderColor: theme.borderStrong }, activeOpacity={0.7}
isCurrent && !isSelected && { borderColor: theme.accent, borderWidth: 1.5 }, >
isSelected && { backgroundColor: theme.accent, borderColor: theme.accent }, <Svg width={20} height={20} viewBox="0 0 24 24">
]} <Path d="M15 18l-6-6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
onPress={() => handleDayPress(day)} </Svg>
activeOpacity={0.7} </TouchableOpacity>
>
<Text style={[styles.dayWeekday, { color: theme.textMuted }, isSelected && styles.dayTextSelected]}>
{format(day, 'EEE').charAt(0)}
</Text>
<Text style={[styles.dayNumber, { color: theme.text }, isSelected && styles.dayTextSelected]}>
{format(day, 'd')}
</Text>
</TouchableOpacity>
);
})}
</ScrollView>
<View style={styles.monthRow}> <View style={styles.monthSelectorGroup}>
<TouchableOpacity onPress={handlePrevMonth} style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]} activeOpacity={0.7}> <TouchableOpacity onPress={() => setMonthPickerVisible(true)} activeOpacity={0.7} style={styles.monthButton}>
<Svg width={20} height={20} viewBox="0 0 24 24"> <Text style={[styles.monthLabel, { color: theme.text }]}>{format(visibleMonth, 'MMMM')}</Text>
<Path d="M15 18l-6-6 6-6" stroke={theme.textFaint} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" /> <Svg width={14} height={14} viewBox="0 0 24 24">
</Svg> <Path d="M6 9l6 6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</TouchableOpacity> </Svg>
<Text style={[styles.monthLabel, { color: theme.text }]}>{format(visibleMonth, 'MMM yyyy').toUpperCase()}</Text> </TouchableOpacity>
<TouchableOpacity onPress={handleNextMonth} style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]} activeOpacity={0.7}> </View>
<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" />
</Svg>
</TouchableOpacity>
</View>
<FlatList <TouchableOpacity onPress={() => setYearPickerVisible(true)} activeOpacity={0.7} style={styles.yearButton}>
data={tasks} <Text style={[styles.yearLabel, { color: theme.textMuted }]}>{format(visibleMonth, 'yyyy')}</Text>
keyExtractor={(item) => item.id} </TouchableOpacity>
renderItem={({ item }) => (
<TaskItem <TouchableOpacity
task={item as TaskData} onPress={() => transitionTo(1)}
onToggle={() => handleToggleComplete(item.id)} style={[styles.monthNav, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
onPress={() => router.push({ pathname: '/task-detail', params: { id: item.id } })} activeOpacity={0.7}
/> >
)} <Svg width={20} height={20} viewBox="0 0 24 24">
ItemSeparatorComponent={() => <View style={styles.separator} />} <Path d="M9 6l6 6-6 6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
ListEmptyComponent={ </Svg>
loading ? ( </TouchableOpacity>
<View style={styles.emptyState}> </View>
<Text style={[styles.emptyText, { color: theme.textFaint }]}>Loading...</Text>
<View style={styles.weekdayRow}>
{GERMAN_WEEKDAYS.map((day, i) => (
<Text key={day + i} style={[styles.weekday, { color: theme.textMuted }]}>
{day}
</Text>
))}
</View>
{weeks.map((week, wi) => (
<View key={wi} style={styles.weekRow}>
{week.map((day) => (
<DayCell
key={day.toISOString()}
day={day}
label={cellTitle(byDayOf(monthTasks.byDay, day))}
dotColor={cellColor(byDayOf(monthTasks.byDay, day), categories, theme)}
selected={isSameDay(day, selectedDate)}
today={isToday(day)}
inMonth={isSameMonth(day, visibleMonth)}
theme={theme}
onPress={handleDayPress}
/>
))}
</View>
))}
</Animated.View>
</GestureDetector>
<View style={styles.panelHeader}>
<Text style={[styles.panelDate, { color: theme.text }]}>{format(selectedDate, 'EEEE, MMMM d')}</Text>
{sortedDayTasks.length > 0 && (
<Text style={[styles.panelCount, { color: theme.textFaint }]}>
{sortedDayTasks.length} event{sortedDayTasks.length === 1 ? '' : 's'}
</Text>
)}
</View> </View>
) : (
<View style={styles.emptyState}> {sortedDayTasks.length === 0 ? (
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>No tasks scheduled.</Text> <View style={styles.emptyState}>
<Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Use the bar below to add one</Text> <Text style={[styles.emptyText, { color: theme.textSecondary }]}>No events on this day</Text>
</View> <Text style={[styles.emptySubtext, { color: theme.textMuted }]}>Tap a date or use the bar below</Text>
) </View>
} ) : (
contentContainerStyle={styles.listContent} sortedDayTasks.map((task) => {
/> const isExpanded = expandedTasks.has(task.id);
const itemSubtasks = subtasksByTask.get(task.id) ?? [];
return (
<View key={task.id} style={styles.eventCard}>
<CalendarTaskRow
task={task}
expanded={isExpanded}
subtasks={itemSubtasks}
categoryColor={categoryColors.get(task.categoryId)}
onToggle={() => handleToggleComplete(task.id)}
onPress={(id) =>
expandedTasks.has(id)
? router.push({ pathname: '/task-detail', params: { id } })
: toggleExpand(id)
}
onMenuOpen={(id) => openTaskEdit(id)}
onSubtaskToggle={handleSubtaskToggle}
/>
</View>
);
})
)}
</ScrollView>
<QuickAddBar <QuickAddBar
dueDate={selectedDate.getTime()} dueDate={selectedDate.getTime()}
placeholder={`Add task for ${format(selectedDate, 'MMM d')}`} placeholder={`Add event for ${format(selectedDate, 'MMM d')}`}
/>
</KeyboardAvoidingView>
{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)}
/> />
</SafeAreaView> </SafeAreaView>
); );
} }
const styles = StyleSheet.create({ function byDayOf(byDay: Record<number, Task[]>, day: Date): Task[] {
return byDay[day.getDate()] ?? [];
}
interface CalendarTaskRowProps {
task: Task;
expanded: boolean;
subtasks: SubtaskData[];
categoryColor?: string;
onToggle: (taskId: string) => void | Promise<void>;
onPress: (taskId: string) => void;
onMenuOpen: (taskId: string) => void;
onSubtaskToggle: (subtaskId: string) => void | Promise<void>;
}
const CalendarTaskRow = React.memo(function CalendarTaskRow({
task,
expanded,
subtasks,
categoryColor,
onToggle,
onPress,
onMenuOpen,
onSubtaskToggle,
}: CalendarTaskRowProps) {
const { theme } = useSettings();
const styles = React.useMemo(() => createStyles(theme), [theme]);
const sortedSubtasks = useMemo(
() => subtasks.slice().sort((a, b) => a.order - b.order),
[subtasks]
);
return (
<View>
<TaskItem
task={task as unknown as TaskData}
onToggle={() => onToggle(task.id)}
onPress={() => onPress(task.id)}
onMenuOpen={() => onMenuOpen(task.id)}
expanded={expanded}
categoryColor={categoryColor}
/>
{expanded && sortedSubtasks.length > 0 && (
<View style={styles.subtaskList}>
{sortedSubtasks.map((sub) => (
<SubtaskItem
key={sub.id}
subtask={sub}
depth={1}
categoryColor={categoryColor}
onToggle={() => onSubtaskToggle(sub.id)}
/>
))}
</View>
)}
</View>
);
});
function cellTitle(tasks: Task[]): string | null {
if (tasks.length === 0) return null;
if (tasks.length === 1) return tasks[0].title;
return `${tasks[0].title} +${tasks.length - 1}`;
}
function cellColor(tasks: Task[], categories: { id: string; color: string }[], theme: ThemeColors): string {
if (tasks.length === 0) return theme.textMuted;
return theme.accent;
}
interface DayCellProps {
day: Date;
label: string | null;
dotColor: string;
selected: boolean;
today: boolean;
inMonth: boolean;
theme: ThemeColors;
onPress: (day: Date) => void;
}
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: theme.accentSoft, borderColor: theme.accentBorder }]}
onPress={() => onPress(day)}
activeOpacity={0.7}
>
<Text
style={[
styles.dayNumber,
{ color: theme.text },
!inMonth && { color: theme.textMuted },
today && !selected && { color: theme.accent },
selected && styles.dayNumberSelected,
]}
>
{format(day, 'd')}
</Text>
{label ? (
<View style={[styles.chip, { backgroundColor: selected ? 'rgba(0,0,0,0.22)' : dotColor }]}>
<Text style={styles.chipText} numberOfLines={1}>
{label}
</Text>
</View>
) : !inMonth ? (
<View style={[styles.chipPlaceholder, { backgroundColor: theme.borderStrong }]} />
) : null}
</TouchableOpacity>
);
});
const createStyles = (theme: ThemeColors) => StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, },
dateStrip: { scrollContent: {
paddingHorizontal: 16, paddingBottom: 16,
paddingTop: 12, flexGrow: 1,
gap: DAY_GAP,
}, },
dayButton: { scrollBody: {
width: DAY_WIDTH, flex: 1,
height: 60,
borderRadius: 16,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 2,
}, },
dayTextSelected: { kbAvoid: {
color: '#FFFFFF', flex: 1,
}, },
dayWeekday: { calendarArea: {
fontSize: 11, paddingHorizontal: 12,
fontWeight: '600', paddingTop: 6,
textTransform: 'uppercase', paddingBottom: 4,
},
dayNumber: {
fontSize: 16,
fontWeight: '600',
}, },
monthRow: { monthRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'space-between',
gap: 24, paddingHorizontal: 4,
paddingVertical: 12, paddingBottom: 14,
}, },
monthNav: { monthNav: {
width: 36, width: 36,
@@ -198,34 +507,115 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
monthLabel: { monthSelectorGroup: {
fontSize: 15, alignItems: 'flex-start',
fontWeight: '700',
letterSpacing: 1,
minWidth: 120,
textAlign: 'center',
},
listContent: {
paddingHorizontal: 16,
paddingTop: 4,
paddingBottom: 100,
flexGrow: 1,
},
separator: {
height: 8,
},
emptyState: {
flex: 1, flex: 1,
},
monthButton: {
flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
paddingVertical: 64, gap: 6,
paddingHorizontal: 10,
},
monthLabel: {
fontSize: 22,
fontWeight: '700',
},
yearButton: {
marginTop: -2,
paddingHorizontal: 6,
},
yearLabel: {
fontSize: 17,
fontWeight: '700',
},
weekdayRow: {
flexDirection: 'row',
marginBottom: 4,
paddingHorizontal: 2,
},
weekday: {
flex: 1,
textAlign: 'center',
fontSize: 11,
fontWeight: '600',
textTransform: 'uppercase',
},
weekRow: {
flexDirection: 'row',
gap: 6,
marginBottom: 6,
},
dayCell: {
flex: 1,
height: 56,
borderRadius: 12,
borderWidth: 1,
borderColor: 'transparent',
paddingVertical: 4,
alignItems: 'center',
},
dayNumber: {
fontSize: 14,
fontWeight: '600',
},
dayNumberSelected: {
color: theme.text,
},
chip: {
marginTop: 3,
paddingHorizontal: 4,
paddingVertical: 2,
borderRadius: 5,
maxWidth: '92%',
},
chipText: {
color: '#FFFFFF',
fontSize: 8,
fontWeight: '600',
},
chipPlaceholder: {
marginTop: 3,
width: 6,
height: 2,
borderRadius: 1,
opacity: 0.4,
},
panelHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 18,
paddingTop: 15,
paddingBottom: 4,
},
panelDate: {
fontSize: 16,
fontWeight: '700',
},
panelCount: {
fontSize: 13,
},
emptyState: {
alignItems: 'center',
paddingVertical: 36,
}, },
emptyText: { emptyText: {
fontSize: 16, fontSize: 15,
fontWeight: '600', fontWeight: '600',
marginBottom: 4, marginBottom: 4,
}, },
emptySubtext: { emptySubtext: {
fontSize: 13, fontSize: 13,
}, },
eventCard: {
marginHorizontal: 16,
marginTop: 6,
},
subtaskList: {
paddingLeft: 8,
paddingRight: 4,
paddingTop: 4,
},
}); });
+74 -11
View File
@@ -1,17 +1,27 @@
import React from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { View, Text, StyleSheet, SafeAreaView } from 'react-native'; import { View, Text, StyleSheet, SafeAreaView, KeyboardAvoidingView, TouchableOpacity, BackHandler } from 'react-native';
import { useFocusEffect } from 'expo-router';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { CategoryFilter } from '@/components/CategoryFilter'; import { CategoryFilter } from '@/components/CategoryFilter';
import { TaskList } from '@/components/TaskList'; import { TaskList } from '@/components/TaskList';
import { QuickAddBar } from '@/components/QuickAddBar'; import { QuickAddBar } from '@/components/QuickAddBar';
import { useDatabase } from '@/hooks/useDatabase'; import { useDatabase } from '@/hooks/useDatabase';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import Svg, { Path, Circle } from 'react-native-svg';
export default function TasksScreen() { export default function TasksScreen() {
const { isReady } = useDatabase(); const { isReady } = useDatabase();
const { theme } = useSettings(); const { theme, showCompleted, setShowCompleted } = useSettings();
const [selectedCategory, setSelectedCategory] = React.useState<string>('all'); const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [selectionActive, setSelectionActive] = React.useState(false);
const categoryIds = useMemo(() => selectedCategories, [selectedCategories]);
useFocusEffect(
useCallback(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => false);
return () => sub.remove();
}, [])
);
if (!isReady) { if (!isReady) {
return ( return (
@@ -23,10 +33,45 @@ export default function TasksScreen() {
return ( return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}> <SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title="TODO" showLogo={true} /> <Header title="ToDo" showLogo={false} />
<CategoryFilter selected={selectedCategory} onSelect={setSelectedCategory} /> <View style={styles.categoryFilterWrapper}>
<TaskList categoryId={selectedCategory} onSelectionChange={setSelectionActive} /> <CategoryFilter selected={selectedCategories} onSelect={setSelectedCategories} />
<QuickAddBar /> <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>
<KeyboardAvoidingView
style={styles.kbAvoid}
behavior="padding"
>
<TaskList categoryIds={categoryIds} showCompleted={showCompleted} />
<QuickAddBar />
</KeyboardAvoidingView>
</SafeAreaView> </SafeAreaView>
); );
} }
@@ -35,9 +80,27 @@ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, },
loadingContainer: { categoryFilterWrapper: {
flex: 1,
justifyContent: 'center', justifyContent: 'center',
flexDirection: 'row',
alignItems: 'center', 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,
}, },
}); });
+327 -20
View File
@@ -1,5 +1,6 @@
import React, { useState } from 'react'; import React, { useState, useCallback } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, Switch, TouchableOpacity } from 'react-native'; import { View, Text, StyleSheet, SafeAreaView, ScrollView, Switch, TouchableOpacity, Alert, Linking, Modal, Pressable, BackHandler } from 'react-native';
import { useFocusEffect, useRouter } from 'expo-router';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { ListItem } from '@/components/ListItem'; import { ListItem } from '@/components/ListItem';
import { OptionPickerModal } from '@/components/OptionPickerModal'; import { OptionPickerModal } from '@/components/OptionPickerModal';
@@ -7,23 +8,56 @@ import { CategoryEditorModal } from '@/components/CategoryEditorModal';
import { SyncModal } from '@/components/SyncModal'; import { SyncModal } from '@/components/SyncModal';
import { FriendsModal } from '@/components/FriendsModal'; import { FriendsModal } from '@/components/FriendsModal';
import { LegalModal } from '@/components/LegalModal'; import { LegalModal } from '@/components/LegalModal';
import { ServerUrlModal } from '@/components/ServerUrlModal';
import SyncStatus from '@/components/SyncStatus'; import SyncStatus from '@/components/SyncStatus';
import { useSettings, SORT_OPTIONS, REMINDER_OPTIONS } from '@/theme'; import { useSettings, SORT_OPTIONS, REMINDER_OPTIONS, ACCENT_PRESETS, DEFAULT_ACCENT } from '@/theme';
import { useCategories } from '@/hooks/useDatabase'; import { useCategories } from '@/hooks/useDatabase';
import { getAuthUser, getAuthToken } from '@/services/auth'; import { getAuthUser, getAuthToken } from '@/services/auth';
import { checkForUpdates, getCurrentAppVersion } from '@/services/updates';
import { getLastSyncTime } from '@/database/sync'; import { getLastSyncTime } from '@/database/sync';
import { rescheduleAllReminders } from '@/services/notifications';
import { collections } from '@/database';
import Category from '@/models/Category'; import Category from '@/models/Category';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
import ColorWheel from '@/components/ColorWheel';
import { getLastVisitedTab, tabHref } from '@/utils/tabHistory';
export default function SettingsScreen() { export default function SettingsScreen() {
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference } = useSettings(); const router = useRouter();
const { theme, notifications, setNotifications, defaultCategoryId, setDefaultCategoryId, sortBy, setSortBy, reminderPreference, setReminderPreference, tagReminderPreferences, setTagReminderPreferences, apiUrl, accentColor, setAccentColor, todoAheadDays, setTodoAheadDays, showCompleted, setShowCompleted, deleteBySwipe, setDeleteBySwipe } = useSettings();
const categories = useCategories(); const categories = useCategories();
const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder'>(null); const [picker, setPicker] = useState<null | 'category' | 'sort' | 'reminder' | 'todoAhead'>(null);
const [reminderTagPicker, setReminderTagPicker] = useState<Category | null>(null);
const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null); const [editingCategory, setEditingCategory] = useState<Category | null | 'new'>(null);
const [syncVisible, setSyncVisible] = useState(false); const [syncVisible, setSyncVisible] = useState(false);
const [friendsVisible, setFriendsVisible] = useState(false); const [friendsVisible, setFriendsVisible] = useState(false);
const [legalVisible, setLegalVisible] = useState<null | 'privacy' | 'terms'>(null); const [legalVisible, setLegalVisible] = useState<null | 'privacy' | 'terms'>(null);
const [serverUrlVisible, setServerUrlVisible] = useState(false);
const [customAccentVisible, setCustomAccentVisible] = useState(false);
const [draftAccent, setDraftAccent] = useState(accentColor);
const [syncSubtitle, setSyncSubtitle] = useState('Checking...'); const [syncSubtitle, setSyncSubtitle] = useState('Checking...');
const [updateSubtitle, setUpdateSubtitle] = useState('Tap to check');
const handleCheckUpdates = async () => {
setUpdateSubtitle('Checking...');
const update = await checkForUpdates();
if (!update) {
setUpdateSubtitle('Up to date');
Alert.alert('Up to date', `You're running the latest version (${getCurrentAppVersion()}).`);
return;
}
setUpdateSubtitle(`${update.version} available`);
const url = update.apkUrl ?? update.releaseUrl;
Alert.alert('Update available', `Version ${update.version} is available for download.`, [
{ text: 'Later', style: 'cancel' },
{
text: 'Download',
onPress: () => {
if (url) Linking.openURL(url).catch(() => {});
},
},
]);
};
const refreshSyncStatus = async () => { const refreshSyncStatus = async () => {
const token = await getAuthToken(); const token = await getAuthToken();
@@ -43,19 +77,64 @@ export default function SettingsScreen() {
}; };
React.useEffect(() => { React.useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
refreshSyncStatus(); refreshSyncStatus();
}, []); }, []);
useFocusEffect(
useCallback(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
const target = getLastVisitedTab();
if (target && target !== 'settings') {
router.navigate(tabHref(target));
}
return true;
});
return () => sub.remove();
}, [router])
);
const defaultCategory = categories.find((c) => c.id === defaultCategoryId); const defaultCategory = categories.find((c) => c.id === defaultCategoryId);
const defaultCategoryLabel = defaultCategory?.name ?? (categories.length > 0 ? categories[0].name : 'None'); const defaultCategoryLabel = defaultCategory?.name ?? 'None';
const reminderLabel = REMINDER_OPTIONS.find((o) => o.value === reminderPreference)?.label ?? 'No reminder'; const reminderLabel = REMINDER_OPTIONS.find((o) => o.value === reminderPreference)?.label ?? 'No reminder';
const handleDefaultCategory = (value: string) => { const tagReminderLabel = (categoryId: string) => {
setDefaultCategoryId(value); const selected = tagReminderPreferences[categoryId] ?? [];
if (selected.length === 0) return `Default (${reminderLabel})`;
return selected
.map((v) => REMINDER_OPTIONS.find((o) => o.value === v)?.label ?? v)
.join(', ');
}; };
const handleReminderPreference = (value: string) => { const openCustomAccent = () => {
setReminderPreference(value as typeof reminderPreference); setDraftAccent(accentColor);
setCustomAccentVisible(true);
};
const handleDefaultCategory = (value: string | string[]) => {
setDefaultCategoryId(Array.isArray(value) ? value[0] : value);
};
const handleReminderPreference = (value: string | string[]) => {
setReminderPreference((Array.isArray(value) ? value[0] : value) as typeof reminderPreference);
};
const handleTagReminderPreference = async (categoryId: string, value: string | string[]) => {
const selected = (Array.isArray(value) ? value : [value]).filter(
(v): v is typeof reminderPreference => v !== 'none'
);
setTagReminderPreferences({ ...tagReminderPreferences, [categoryId]: selected });
if (!notifications) return;
try {
const tasks = await collections.tasks.query().fetch();
await rescheduleAllReminders(tasks, notifications, reminderPreference);
} catch {
// ignore reschedule failures
}
};
const handleTodoAheadDays = (value: string | string[]) => {
setTodoAheadDays(parseInt(Array.isArray(value) ? value[0] : value, 10));
}; };
const editorVisible = editingCategory !== null; const editorVisible = editingCategory !== null;
@@ -65,12 +144,12 @@ export default function SettingsScreen() {
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}> <SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title="Settings" showLogo={false} /> <Header title="Settings" showLogo={false} />
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={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) => ( {categories.map((category) => (
<ListItem <ListItem
key={category.id} key={category.id}
title={category.name} title={category.name}
subtitle={category.id === defaultCategoryId ? 'Default category' : undefined} subtitle={category.id === defaultCategoryId ? 'Default tag' : undefined}
leftElement={ leftElement={
<View style={[styles.categoryDot, { backgroundColor: category.color }]} /> <View style={[styles.categoryDot, { backgroundColor: category.color }]} />
} }
@@ -85,10 +164,10 @@ export default function SettingsScreen() {
> >
<View style={[styles.addIcon, { backgroundColor: theme.accentSoft }]}> <View style={[styles.addIcon, { backgroundColor: theme.accentSoft }]}>
<Svg width={16} height={16} viewBox="0 0 24 24"> <Svg width={16} height={16} viewBox="0 0 24 24">
<Path d="M12 5v14M5 12h14" stroke={theme.accent} strokeWidth={2} strokeLinecap="round" /> <Path d="M12 5v14M5 12h14" stroke={theme.accentText} strokeWidth={2} strokeLinecap="round" />
</Svg> </Svg>
</View> </View>
<Text style={[styles.addCategoryText, { color: theme.accent }]}>Add Category</Text> <Text style={[styles.addCategoryText, { color: theme.accent }]}>Add Tag</Text>
</TouchableOpacity> </TouchableOpacity>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Notifications</Text> <Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Notifications</Text>
<ListItem <ListItem
@@ -109,9 +188,21 @@ export default function SettingsScreen() {
onPress={() => notifications && setPicker('reminder')} onPress={() => notifications && setPicker('reminder')}
showChevron showChevron
/> />
{categories.map((category) => (
<ListItem
key={`tag-reminder-${category.id}`}
title={category.name}
subtitle={notifications ? tagReminderLabel(category.id) : 'Enable notifications first'}
leftElement={
<View style={[styles.categoryDot, { backgroundColor: category.color }]} />
}
onPress={() => notifications && setReminderTagPicker(category)}
showChevron
/>
))}
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Tasks</Text> <Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Tasks</Text>
<ListItem <ListItem
title="Default Category" title="Default Tag"
subtitle={defaultCategoryLabel} subtitle={defaultCategoryLabel}
onPress={() => setPicker('category')} onPress={() => setPicker('category')}
showChevron showChevron
@@ -122,6 +213,66 @@ export default function SettingsScreen() {
onPress={() => setPicker('sort')} onPress={() => setPicker('sort')}
showChevron showChevron
/> />
<ListItem
title="Show Completed Tasks"
subtitle="Hide or show finished tasks in the Todo list"
rightElement={
<Switch
value={showCompleted}
onValueChange={setShowCompleted}
thumbColor="#FFFFFF"
trackColor={{ false: theme.borderStrong, true: theme.accent }}
/>
}
/>
<ListItem
title="Delete by Swiping"
subtitle="Swipe right to delete task"
rightElement={
<Switch
value={deleteBySwipe}
onValueChange={setDeleteBySwipe}
thumbColor="#FFFFFF"
trackColor={{ false: theme.borderStrong, true: theme.accent }}
/>
}
showChevron
/>
<ListItem
title="Show Calendar Tasks"
subtitle={todoAheadDays === 0 ? 'Only today' : `Up to ${todoAheadDays} days ahead`}
onPress={() => setPicker('todoAhead')}
showChevron
/>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Appearance</Text>
<Text style={[styles.sectionHint, { color: theme.textFaint }]}>Accent color</Text>
<View style={styles.accentPresets}>
{ACCENT_PRESETS.map((c) => (
<TouchableOpacity
key={c}
style={[
styles.accentSwatch,
{ backgroundColor: c },
accentColor.toUpperCase() === c && styles.accentSwatchSelected,
]}
onPress={() => setAccentColor(c)}
activeOpacity={0.8}
>
{accentColor.toUpperCase() === c && (
<Svg width={16} height={16} viewBox="0 0 24 24">
<Path d="M5 12l5 5 9-10" stroke={contrastOnSwatch(c)} strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
)}
</TouchableOpacity>
))}
</View>
<ListItem
title="Custom Color"
subtitle={accentColor}
leftElement={<View style={[styles.categoryDot, { backgroundColor: accentColor }]} />}
onPress={openCustomAccent}
showChevron
/>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Data</Text> <Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Data</Text>
<SyncStatus /> <SyncStatus />
<ListItem <ListItem
@@ -136,10 +287,23 @@ export default function SettingsScreen() {
onPress={() => setFriendsVisible(true)} onPress={() => setFriendsVisible(true)}
showChevron showChevron
/> />
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>Server</Text>
<ListItem
title="Backend URL"
subtitle={apiUrl}
onPress={() => setServerUrlVisible(true)}
showChevron
/>
<Text style={[styles.sectionTitle, { color: theme.textMuted }]}>About</Text> <Text style={[styles.sectionTitle, { color: theme.textMuted }]}>About</Text>
<ListItem
title="Check for Updates"
subtitle={updateSubtitle}
onPress={handleCheckUpdates}
showChevron
/>
<ListItem <ListItem
title="Version" title="Version"
subtitle="1.0.0" subtitle={getCurrentAppVersion()}
/> />
<ListItem <ListItem
title="Privacy Policy" title="Privacy Policy"
@@ -155,9 +319,9 @@ export default function SettingsScreen() {
<OptionPickerModal <OptionPickerModal
visible={picker === 'category'} visible={picker === 'category'}
title="Default Category" title="Default Tag"
options={categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))} options={[{ value: '', label: 'None', color: theme.textMuted }, ...categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
selectedValue={defaultCategoryId || categories[0]?.id} selectedValue={defaultCategoryId}
onSelect={handleDefaultCategory} onSelect={handleDefaultCategory}
onClose={() => setPicker(null)} onClose={() => setPicker(null)}
/> />
@@ -180,6 +344,28 @@ export default function SettingsScreen() {
onClose={() => setPicker(null)} onClose={() => setPicker(null)}
/> />
{categories.map((category) => (
<OptionPickerModal
key={`tag-reminder-picker-${category.id}`}
visible={reminderTagPicker?.id === category.id}
title={`Reminders · ${category.name}`}
options={REMINDER_OPTIONS.filter((o) => o.value !== 'none')}
selectedValue={tagReminderPreferences[category.id] ?? []}
multiSelect
onSelect={(value) => handleTagReminderPreference(category.id, value)}
onClose={() => setReminderTagPicker(null)}
/>
))}
<OptionPickerModal
visible={picker === 'todoAhead'}
title="Calendar Tasks in Todo"
options={AHEAD_OPTIONS}
selectedValue={String(todoAheadDays)}
onSelect={handleTodoAheadDays}
onClose={() => setPicker(null)}
/>
<CategoryEditorModal <CategoryEditorModal
visible={editorVisible} visible={editorVisible}
category={editorCategory} category={editorCategory}
@@ -191,6 +377,43 @@ export default function SettingsScreen() {
<FriendsModal visible={friendsVisible} onClose={() => setFriendsVisible(false)} /> <FriendsModal visible={friendsVisible} onClose={() => setFriendsVisible(false)} />
<ServerUrlModal visible={serverUrlVisible} onClose={() => setServerUrlVisible(false)} />
<Modal visible={customAccentVisible} transparent animationType="fade" onRequestClose={() => setCustomAccentVisible(false)}>
<Pressable
style={styles.accentModalOverlay}
onPress={() => setCustomAccentVisible(false)}
>
<Pressable style={[styles.accentModalSheet, { backgroundColor: theme.sheetBg }]} onPress={() => {}}>
<View style={styles.accentModalHeader}>
<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.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
</Svg>
</TouchableOpacity>
</View>
<ColorWheel color={draftAccent} onChange={setDraftAccent} />
<View style={styles.accentModalActions}>
<TouchableOpacity
style={[styles.accentModalButton, { borderColor: theme.borderStrong }]}
onPress={() => { setDraftAccent(DEFAULT_ACCENT); setAccentColor(DEFAULT_ACCENT); }}
activeOpacity={0.7}
>
<Text style={[styles.accentModalButtonText, { color: theme.textSecondary }]}>Reset</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.accentModalButton, styles.accentModalButtonPrimary, { backgroundColor: theme.accent }]}
onPress={() => { setAccentColor(draftAccent); setCustomAccentVisible(false); }}
activeOpacity={0.8}
>
<Text style={[styles.accentModalButtonText, { color: theme.accentText }]}>Apply</Text>
</TouchableOpacity>
</View>
</Pressable>
</Pressable>
</Modal>
<LegalModal <LegalModal
visible={legalVisible !== null} visible={legalVisible !== null}
type={legalVisible} type={legalVisible}
@@ -200,6 +423,11 @@ export default function SettingsScreen() {
); );
} }
const AHEAD_OPTIONS: { value: string; label: string }[] = Array.from({ length: 29 }, (_, i) => ({
value: String(i),
label: i === 0 ? 'Only today' : `${i} day${i === 1 ? '' : 's'}`,
}));
function formatSyncTime(timestamp: number): string { function formatSyncTime(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000); const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return 'just now'; if (seconds < 60) return 'just now';
@@ -208,12 +436,22 @@ function formatSyncTime(timestamp: number): string {
return new Date(timestamp).toLocaleDateString(); return new Date(timestamp).toLocaleDateString();
} }
function contrastOnSwatch(hex: string): string {
const h = hex.replace(/^#/, '');
const r = parseInt(h.slice(0, 2), 16) / 255;
const g = parseInt(h.slice(2, 4), 16) / 255;
const b = parseInt(h.slice(4, 6), 16) / 255;
const linear = (v: number) => (v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
const luminance = 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
return luminance > 0.5 ? '#111111' : '#FFFFFF';
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, },
content: { content: {
flex: 1, flexGrow: 1,
paddingHorizontal: 16, paddingHorizontal: 16,
paddingTop: 8, paddingTop: 8,
}, },
@@ -253,4 +491,73 @@ const styles = StyleSheet.create({
fontSize: 15, fontSize: 15,
fontWeight: '600', fontWeight: '600',
}, },
sectionHint: {
fontSize: 12,
marginLeft: 4,
marginBottom: 8,
},
accentPresets: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
paddingHorizontal: 4,
marginBottom: 4,
},
accentSwatch: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
accentSwatchSelected: {
borderWidth: 2,
borderColor: '#FFFFFF',
shadowColor: '#000',
shadowOpacity: 0.3,
shadowRadius: 3,
elevation: 3,
},
accentModalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.6)',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
accentModalSheet: {
width: '100%',
maxWidth: 400,
borderRadius: 16,
padding: 20,
},
accentModalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 16,
},
accentModalTitle: {
fontSize: 18,
fontWeight: '700',
},
accentModalActions: {
flexDirection: 'row',
gap: 12,
marginTop: 16,
},
accentModalButton: {
flex: 1,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
},
accentModalButtonPrimary: {
borderWidth: 0,
},
accentModalButtonText: {
fontSize: 15,
fontWeight: '600',
},
}); });
+209
View File
@@ -0,0 +1,209 @@
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, 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 (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header title="Stats" showLogo={true} />
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<View style={[styles.card, { backgroundColor: theme.card }]}>
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>Overview</Text>
<View style={styles.summaryGrid}>
<View style={styles.summaryItem}>
<Text style={[styles.summaryValue, { color: theme.accent }]}>{stats.totalCompleted}</Text>
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Completed</Text>
</View>
<View style={styles.summaryItem}>
<Text style={[styles.summaryValue, { color: theme.text }]}>{stats.completedLast7}</Text>
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Last 7 days</Text>
</View>
<View style={styles.summaryItem}>
<Text style={[styles.summaryValue, { color: theme.text }]}>{stats.completedLast30}</Text>
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Last 30 days</Text>
</View>
<View style={styles.summaryItem}>
<Text style={[styles.summaryValue, { color: theme.text }]}>{stats.completionRate}%</Text>
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Success rate</Text>
</View>
<View style={styles.summaryItem}>
<Text style={[styles.summaryValue, { color: theme.text }]}>{stats.currentStreak}</Text>
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Day streak</Text>
</View>
<View style={styles.summaryItem}>
<Text style={[styles.summaryValue, { color: stats.overdueCount > 0 ? theme.accent : theme.text }]}>
{stats.overdueCount}
</Text>
<Text style={[styles.summaryLabel, { color: theme.textMuted }]}>Overdue</Text>
</View>
</View>
</View>
<View style={[styles.card, { backgroundColor: theme.card }]}>
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>Last 7 days</Text>
<View style={styles.chartRow}>
{stats.daily.map((d, i) => {
const height = (d.count / maxDaily) * BASE_HEIGHT;
return (
<View key={i} style={styles.chartCol}>
<View style={styles.chartBarTrack}>
<View
style={[
styles.chartBar,
{ height: Math.max(4, height), backgroundColor: d.count > 0 ? theme.accent : theme.cardAlt },
]}
/>
</View>
<Text style={[styles.chartLabel, { color: theme.textFaint }]}>{d.label}</Text>
</View>
);
})}
</View>
</View>
<View style={[styles.card, { backgroundColor: theme.card }]}>
<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>
) : (
stats.byCategory.map((c) => (
<View key={c.id} style={styles.row}>
<View style={[styles.dot, { backgroundColor: c.color }]} />
<Text style={[styles.rowLabel, { color: theme.text }]}>{c.name}</Text>
<Text style={[styles.rowValue, { color: theme.textMuted }]}>{c.count}</Text>
</View>
))
)}
</View>
<View style={[styles.card, { backgroundColor: theme.card }]}>
<Text style={[styles.cardTitle, { color: theme.textMuted }]}>By priority</Text>
{stats.byPriority.length === 0 ? (
<Text style={[styles.emptyText, { color: theme.textFaint }]}>No completed tasks yet</Text>
) : (
stats.byPriority.map((p) => (
<View key={p.priority} style={styles.row}>
<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>
))
)}
</View>
</ScrollView>
</SafeAreaView>
);
}
const BASE_HEIGHT = 90;
function priorityColor(theme: ThemeColors, priority: string): string {
switch (priority) {
case 'critical':
return theme.dangerStrong;
case 'high':
return theme.warning;
case 'low':
case 'medium':
return theme.success;
default:
return theme.textMuted;
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
padding: 16,
paddingBottom: 32,
gap: 12,
},
card: {
borderRadius: 16,
padding: 16,
},
cardTitle: {
fontSize: 13,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 14,
},
summaryGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
},
summaryItem: {
width: '33.33%',
marginBottom: 16,
},
summaryValue: {
fontSize: 26,
fontWeight: '700',
},
summaryLabel: {
fontSize: 12,
marginTop: 2,
},
chartRow: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: 8,
},
chartCol: {
flex: 1,
alignItems: 'center',
},
chartBarTrack: {
height: BASE_HEIGHT,
justifyContent: 'flex-end',
width: '100%',
},
chartBar: {
width: '100%',
borderRadius: 6,
minHeight: 4,
},
chartLabel: {
fontSize: 11,
marginTop: 6,
},
row: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
},
dot: {
width: 10,
height: 10,
borderRadius: 5,
marginRight: 10,
},
rowLabel: {
flex: 1,
fontSize: 15,
},
rowValue: {
fontSize: 15,
fontWeight: '600',
},
emptyText: {
fontSize: 14,
},
});
+60 -1
View File
@@ -1,11 +1,68 @@
import { Stack } from 'expo-router'; import { Stack, useRouter } from 'expo-router';
import { DatabaseProvider } from '@/hooks/useDatabase'; import { DatabaseProvider } from '@/hooks/useDatabase';
import { SettingsProvider } from '@/theme'; import { SettingsProvider } from '@/theme';
import { FriendsProvider } from '@/hooks/useFriends'; import { FriendsProvider } from '@/hooks/useFriends';
import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
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() { 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)');
requestQuickAddFocus();
};
Linking.getInitialURL().then(handleUrl).catch(() => {});
const subscription = Linking.addEventListener('url', ({ url }) => handleUrl(url));
return () => {
subscription.remove();
removeNotificationSubscription(responseListener);
};
}, [router]);
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<StatusBar style="light" /> <StatusBar style="light" />
@@ -13,6 +70,7 @@ function RootNavigator() {
<Stack.Screen name="(tabs)" /> <Stack.Screen name="(tabs)" />
<Stack.Screen name="add-task" /> <Stack.Screen name="add-task" />
<Stack.Screen name="task-detail" /> <Stack.Screen name="task-detail" />
<Stack.Screen name="day-view" />
</Stack> </Stack>
</GestureHandlerRootView> </GestureHandlerRootView>
); );
@@ -23,6 +81,7 @@ export default function RootLayout() {
<SettingsProvider> <SettingsProvider>
<DatabaseProvider> <DatabaseProvider>
<FriendsProvider> <FriendsProvider>
<UpdateNotifier />
<RootNavigator /> <RootNavigator />
</FriendsProvider> </FriendsProvider>
</DatabaseProvider> </DatabaseProvider>
+37 -20
View File
@@ -1,5 +1,5 @@
import React, { useMemo } from 'react'; import React, { useMemo, useRef } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform } from 'react-native'; import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, Keyboard } from 'react-native';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { CategorySelector } from '@/components/CategorySelector'; import { CategorySelector } from '@/components/CategorySelector';
import { TaskNameInput } from '@/components/TaskNameInput'; import { TaskNameInput } from '@/components/TaskNameInput';
@@ -15,17 +15,19 @@ import { AssigneeSelector } from '@/components/AssigneeSelector';
import { useForm, FormProvider, Controller } from 'react-hook-form'; import { useForm, FormProvider, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { useDatabase, useCategories } from '@/hooks/useDatabase'; import { useDatabase } from '@/hooks/useDatabase';
import { database, collections } from '@/database'; import { database, collections } from '@/database';
import { TaskFormData } from '@/types'; import { TaskFormData, tagsToString } from '@/types';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { scheduleTaskReminder } from '@/services/notifications'; import { scheduleTaskReminder } from '@/services/notifications';
import { ReminderPreference } from '@/types';
import { useFriends } from '@/hooks/useFriends'; import { useFriends } from '@/hooks/useFriends';
const taskSchema = z.object({ const taskSchema = z.object({
title: z.string().trim().min(1, 'Task name is required').max(100), title: z.string().trim().min(1, 'Task name is required').max(100),
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
categoryId: z.string().min(1, 'Category is required'), categoryId: z.string().optional(),
tags: z.array(z.string()).optional(),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']), priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
dueDate: z.date().nullable().optional(), dueDate: z.date().nullable().optional(),
dueTime: z.string().optional(), dueTime: z.string().optional(),
@@ -35,24 +37,26 @@ const taskSchema = z.object({
repeatInterval: z.number().int().min(1).max(30).optional(), repeatInterval: z.number().int().min(1).max(30).optional(),
repeatDays: z.array(z.number().int().min(0).max(6)).optional(), repeatDays: z.array(z.number().int().min(0).max(6)).optional(),
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']), reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']),
reminders: z.string().optional(),
assigneeId: z.string().nullable().optional(), assigneeId: z.string().nullable().optional(),
subtasks: z.array(z.object({ title: z.string(), _key: z.string().optional() })).optional(), subtasks: z.array(z.object({ title: z.string(), _key: z.string().optional() })).optional(),
}); });
export default function AddTaskScreen() { export default function AddTaskScreen() {
const { isReady } = useDatabase(); const { isReady } = useDatabase();
const categories = useCategories();
const { defaultCategoryId } = useSettings(); const { defaultCategoryId } = useSettings();
const router = useRouter(); const router = useRouter();
const { theme } = useSettings(); const { theme } = useSettings();
const { date: dateParam } = useLocalSearchParams<{ date?: string }>(); const { date: dateParam } = useLocalSearchParams<{ date?: string }>();
const { friends } = useFriends(); const { friends } = useFriends();
const initialCategory = defaultCategoryId || categories[0]?.id || ''; const { notifications: notificationsEnabled, reminderPreference } = useSettings();
const initialCategory = defaultCategoryId || '';
const initialDate = useMemo(() => { const initialDate = useMemo(() => {
if (!dateParam) return null; if (!dateParam) return null;
const parsed = new Date(Array.isArray(dateParam) ? dateParam[0] : dateParam); const parsed = new Date(Array.isArray(dateParam) ? dateParam[0] : dateParam);
return Number.isNaN(parsed.getTime()) ? null : parsed; return Number.isNaN(parsed.getTime()) ? null : parsed;
}, [dateParam]); }, [dateParam]);
const scrollViewRef = useRef<ScrollView>(null);
const methods = useForm<TaskFormData>({ const methods = useForm<TaskFormData>({
resolver: zodResolver(taskSchema), resolver: zodResolver(taskSchema),
@@ -60,6 +64,7 @@ export default function AddTaskScreen() {
title: '', title: '',
description: '', description: '',
categoryId: initialCategory, categoryId: initialCategory,
tags: initialCategory ? [initialCategory] : [],
priority: 'none', priority: 'none',
dueDate: initialDate, dueDate: initialDate,
dueTime: '', dueTime: '',
@@ -69,6 +74,7 @@ export default function AddTaskScreen() {
repeatInterval: 1, repeatInterval: 1,
repeatDays: [], repeatDays: [],
reminder: 'none', reminder: 'none',
reminders: '',
assigneeId: null, assigneeId: null,
subtasks: [], subtasks: [],
}, },
@@ -82,29 +88,34 @@ export default function AddTaskScreen() {
formState: { errors }, formState: { errors },
} = methods; } = methods;
const categoryId = watch('categoryId'); const tags = watch('tags') ?? [];
const priority = watch('priority'); const priority = watch('priority');
const repeat = watch('repeat'); const repeat = watch('repeat');
const repeatInterval = watch('repeatInterval') ?? 1; const repeatInterval = watch('repeatInterval') ?? 1;
const repeatDays = watch('repeatDays') ?? []; const repeatDays = watch('repeatDays') ?? [];
const reminder = watch('reminder'); const reminder = watch('reminder');
const reminders = watch('reminders');
const dueDate = watch('dueDate'); const dueDate = watch('dueDate');
const assigneeId = watch('assigneeId'); const assigneeId = watch('assigneeId');
React.useEffect(() => { React.useEffect(() => {
if (!categoryId && initialCategory) { if (tags.length === 0 && initialCategory) {
setValue('categoryId', initialCategory); setValue('tags', [initialCategory]);
} }
}, [initialCategory, categoryId, setValue]); }, [initialCategory, tags, setValue]);
const onSubmit = async (data: TaskFormData) => { const onSubmit = async (data: TaskFormData) => {
if (!isReady) return; if (!isReady) return;
Keyboard.dismiss();
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
const now = new Date(); const now = new Date();
const dueDateTimestamp = data.dueDate ? data.dueDate.getTime() : 0; const dueDateTimestamp = data.dueDate ? data.dueDate.getTime() : 0;
const seriesId = data.repeat !== 'none' const seriesId = data.repeat !== 'none'
? `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}` ? `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`
: ''; : '';
const resolvedCategoryId = (data.tags && data.tags[0]) || '';
let createdTask: any = null; let createdTask: any = null;
@@ -112,7 +123,8 @@ export default function AddTaskScreen() {
const task = await collections.tasks.create((t) => { const task = await collections.tasks.create((t) => {
t.title = data.title.trim(); t.title = data.title.trim();
t.description = data.description || ''; t.description = data.description || '';
t.categoryId = data.categoryId; t.categoryId = resolvedCategoryId;
t.tags = tagsToString(data.tags || []);
t.priority = data.priority; t.priority = data.priority;
t.completed = false; t.completed = false;
t.dueDate = dueDateTimestamp; t.dueDate = dueDateTimestamp;
@@ -124,6 +136,7 @@ export default function AddTaskScreen() {
t.repeatDays = (data.repeatDays || []).join(','); t.repeatDays = (data.repeatDays || []).join(',');
t.seriesId = seriesId; t.seriesId = seriesId;
t.reminder = data.reminder || 'none'; t.reminder = data.reminder || 'none';
t.reminders = data.reminders || '';
t.assigneeId = data.assigneeId ?? null; t.assigneeId = data.assigneeId ?? null;
t.createdAt = now; t.createdAt = now;
t.updatedAt = now; t.updatedAt = now;
@@ -153,7 +166,7 @@ export default function AddTaskScreen() {
} }
}); });
await scheduleTaskReminder(createdTask); await scheduleTaskReminder(createdTask, notificationsEnabled, reminderPreference);
router.back(); router.back();
}; };
@@ -172,16 +185,20 @@ export default function AddTaskScreen() {
<FormProvider {...methods}> <FormProvider {...methods}>
<KeyboardAvoidingView <KeyboardAvoidingView
style={styles.keyboardAvoiding} style={styles.keyboardAvoiding}
behavior={Platform.OS === 'ios' ? 'padding' : undefined} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
> >
<ScrollView <ScrollView
ref={scrollViewRef}
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
automaticallyAdjustContentInsets={false}
> >
<CategorySelector <CategorySelector
value={categoryId} value={tags}
onChange={(value) => setValue('categoryId', value)} onChange={(value) => setValue('tags', value)}
error={errors.categoryId?.message} error={errors.categoryId?.message}
/> />
<Controller <Controller
@@ -213,9 +230,9 @@ export default function AddTaskScreen() {
}} }}
/> />
<ReminderSelector <ReminderSelector
value={reminder} value={reminders}
hasDueDate={!!dueDate} hasDueDate={!!dueDate}
onChange={(value) => setValue('reminder', value)} onChange={(value) => setValue('reminders', value)}
/> />
<AssigneeSelector <AssigneeSelector
value={assigneeId} value={assigneeId}
@@ -255,8 +272,8 @@ const styles = StyleSheet.create({
}, },
scrollContent: { scrollContent: {
paddingHorizontal: 16, paddingHorizontal: 16,
paddingTop: 8, paddingTop: 12,
paddingBottom: 100, paddingBottom: 100,
gap: 24, gap: 20,
}, },
}); });
+293
View File
@@ -0,0 +1,293 @@
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', () => {
router.back();
return true;
});
refreshSubtasks();
refresh();
return () => sub.remove();
}, [router, 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} rightAction="Back" />
<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,
},
});
+278
View File
@@ -0,0 +1,278 @@
import React from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, TouchableOpacity, Alert } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Header } from '@/components/Header';
import { TaskNameInput } from '@/components/TaskNameInput';
import { DateTimePickerComponent } from '@/components/DateTimePicker';
import { PrioritySelector } from '@/components/PrioritySelector';
import { RepeatSelector } from '@/components/RepeatSelector';
import { ReminderSelector } from '@/components/ReminderSelector';
import { DescriptionInput } from '@/components/DescriptionInput';
import { FormButtons } from '@/components/FormButtons';
import { AssigneeSelector } from '@/components/AssigneeSelector';
import { useForm, FormProvider, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useDatabase } from '@/hooks/useDatabase';
import { collections } from '@/database';
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(),
endTime: z.string().optional(),
allDay: z.boolean().optional(),
repeat: z.enum(['none', 'daily', 'weekly', 'monthly', 'custom']),
repeatInterval: z.number().int().min(1).max(30).optional(),
repeatDays: z.array(z.number().int().min(0).max(6)).optional(),
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']),
reminders: z.string().optional(),
assigneeId: z.string().nullable().optional(),
});
export default function SubtaskDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { isReady } = useDatabase();
const router = useRouter();
const { theme } = useSettings();
const { friends } = useFriends();
const [loaded, setLoaded] = React.useState(false);
const [notFound, setNotFound] = React.useState(false);
const methods = useForm<SubtaskFormData>({
resolver: zodResolver(subtaskSchema),
defaultValues: {
title: '',
description: '',
tags: [],
priority: 'none',
dueDate: null,
dueTime: '',
endTime: '',
allDay: false,
repeat: 'none',
repeatInterval: 1,
repeatDays: [],
reminder: 'none',
reminders: '',
assigneeId: null,
},
});
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') ?? [];
const reminder = watch('reminder');
const reminders = watch('reminders');
const dueDate = watch('dueDate');
const assigneeId = watch('assigneeId');
React.useEffect(() => {
if (!id || !isReady) return;
let mounted = true;
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,
endTime: subtask.endTime || '',
allDay: subtask.allDay ?? false,
repeat: subtask.repeat,
repeatInterval: subtask.repeatInterval || 1,
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);
},
error: () => {
if (mounted) setNotFound(true);
},
complete: () => {
if (mounted) setNotFound(true);
},
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, [id, isReady, reset]);
const onSubmit = async (data: SubtaskFormData) => {
if (!id || !isReady) return;
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 || '',
endTime: data.endTime || '',
allDay: data.allDay ?? false,
repeat: data.repeat,
repeatInterval: data.repeatInterval || 1,
repeatDays: (data.repeatDays || []).join(','),
reminder: data.reminder || 'none',
assigneeId: data.assigneeId ?? null,
});
router.back();
};
const confirmDelete = () => {
Alert.alert('Delete Subtask', 'This action cannot be undone.', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
if (!id) return;
await deleteSubtask(id);
router.back();
},
},
]);
};
if (!isReady || !loaded) {
return (
<View style={[styles.loadingContainer, { backgroundColor: theme.background }]}>
<Text style={{ color: theme.textFaint }}>{notFound ? 'Subtask not found' : 'Loading...'}</Text>
</View>
);
}
return (
<SafeAreaView style={[styles.container, { backgroundColor: theme.background }]}>
<Header
title="Edit Subtask"
showLogo={true}
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={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
</TouchableOpacity>
}
/>
<FormProvider {...methods}>
<KeyboardAvoidingView
style={styles.keyboardAvoiding}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
>
<Controller
control={control}
name="title"
render={({ field }) => (
<TaskNameInput
value={field.value}
onChangeText={field.onChange}
onBlur={field.onBlur}
error={errors.title?.message}
/>
)}
/>
<CategorySelector
value={tags}
onChange={(value) => setValue('tags', value)}
/>
<PrioritySelector
value={priority}
onChange={(value) => setValue('priority', value)}
/>
<DateTimePickerComponent control={control as any} />
<RepeatSelector
value={repeat}
interval={repeatInterval}
days={repeatDays}
onChange={(nextRepeat, nextInterval, nextDays) => {
setValue('repeat', nextRepeat);
setValue('repeatInterval', nextInterval);
setValue('repeatDays', nextDays);
}}
/>
<ReminderSelector
value={reminders}
hasDueDate={!!dueDate}
onChange={(value) => setValue('reminders', value)}
/>
<AssigneeSelector
value={assigneeId}
onChange={(value: string | null) => setValue('assigneeId', value)}
friends={friends.map((f) => f.username)}
/>
<Controller
control={control}
name="description"
render={({ field }) => (
<DescriptionInput
value={field.value ?? ''}
onChangeText={field.onChange}
onBlur={field.onBlur}
/>
)}
/>
</ScrollView>
</KeyboardAvoidingView>
<FormButtons onSubmit={handleSubmit(onSubmit)} submitLabel="Save" />
</FormProvider>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
keyboardAvoiding: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 16,
paddingTop: 8,
paddingBottom: 100,
gap: 24,
},
deleteButton: {
width: 36,
height: 36,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
});
+228 -75
View File
@@ -1,5 +1,5 @@
import React from 'react'; import React, { useRef } from 'react';
import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, TouchableOpacity } from 'react-native'; import { View, Text, StyleSheet, SafeAreaView, ScrollView, KeyboardAvoidingView, Platform, TouchableOpacity, Keyboard } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { CategorySelector } from '@/components/CategorySelector'; import { CategorySelector } from '@/components/CategorySelector';
@@ -19,17 +19,20 @@ import { z } from 'zod';
import { useDatabase } from '@/hooks/useDatabase'; import { useDatabase } from '@/hooks/useDatabase';
import { database, collections } from '@/database'; import { database, collections } from '@/database';
import { Q } from '@nozbe/watermelondb'; import { Q } from '@nozbe/watermelondb';
import { TaskFormData } from '@/types'; import { TaskFormData, parseTaskTags, tagsToString } from '@/types';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { deleteTaskOccurrences } from '@/utils/taskActions'; import { deleteTaskOccurrences } from '@/utils/taskActions';
import { scheduleTaskReminder, cancelTaskReminder } from '@/services/notifications'; import { scheduleTaskReminder } from '@/services/notifications';
import { ReminderPreference } from '@/types';
import { recordTombstonesInBatch } from '@/database/tombstones';
import { useFriends } from '@/hooks/useFriends'; import { useFriends } from '@/hooks/useFriends';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path, Circle } from 'react-native-svg';
const taskSchema = z.object({ const taskSchema = z.object({
title: z.string().trim().min(1, 'Task name is required').max(100), title: z.string().trim().min(1, 'Task name is required').max(100),
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
categoryId: z.string().min(1, 'Category is required'), categoryId: z.string().optional(),
tags: z.array(z.string()).optional(),
priority: z.enum(['none', 'low', 'medium', 'high', 'critical']), priority: z.enum(['none', 'low', 'medium', 'high', 'critical']),
dueDate: z.date().nullable().optional(), dueDate: z.date().nullable().optional(),
dueTime: z.string().optional(), dueTime: z.string().optional(),
@@ -39,18 +42,48 @@ const taskSchema = z.object({
repeatInterval: z.number().int().min(1).max(30).optional(), repeatInterval: z.number().int().min(1).max(30).optional(),
repeatDays: z.array(z.number().int().min(0).max(6)).optional(), repeatDays: z.array(z.number().int().min(0).max(6)).optional(),
reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']), reminder: z.enum(['none', 'at_time', '15', '30', '60', '120', '1440']),
reminders: z.string().optional(),
assigneeId: z.string().nullable().optional(), assigneeId: z.string().nullable().optional(),
subtasks: z.array(z.object({ title: z.string(), _key: z.string().optional() })).optional(), subtasks: z.array(z.object({ title: z.string(), _key: z.string().optional() })).optional(),
}); });
function CollapsibleSection({ title, children, defaultExpanded = false, icon }: { title: string; children: React.ReactNode; defaultExpanded?: boolean; icon: React.ReactNode }) {
const { theme } = useSettings();
const [expanded, setExpanded] = React.useState(defaultExpanded);
return (
<View style={styles.section}>
<TouchableOpacity style={[styles.sectionHeader, { backgroundColor: theme.card, borderColor: theme.border }]} onPress={() => setExpanded(!expanded)} activeOpacity={0.8}>
<View style={styles.sectionHeaderLeft}>
{icon}
<Text style={[styles.sectionTitle, { color: theme.text }]}>{title}</Text>
</View>
<Svg width={20} height={20} 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>
{expanded && <View style={styles.sectionContent}>{children}</View>}
</View>
);
}
export default function TaskDetailScreen() { export default function TaskDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const { isReady } = useDatabase(); const { isReady } = useDatabase();
const router = useRouter(); const router = useRouter();
const { theme } = useSettings(); const { theme } = useSettings();
const { friends } = useFriends(); const { friends } = useFriends();
const { notifications: notificationsEnabled, reminderPreference } = useSettings();
const [loaded, setLoaded] = React.useState(false); const [loaded, setLoaded] = React.useState(false);
const [notFound, setNotFound] = React.useState(false); const [notFound, setNotFound] = React.useState(false);
const scrollViewRef = useRef<ScrollView>(null);
const methods = useForm<TaskFormData>({ const methods = useForm<TaskFormData>({
resolver: zodResolver(taskSchema), resolver: zodResolver(taskSchema),
@@ -58,6 +91,7 @@ export default function TaskDetailScreen() {
title: '', title: '',
description: '', description: '',
categoryId: '', categoryId: '',
tags: [],
priority: 'none', priority: 'none',
dueDate: null, dueDate: null,
dueTime: '', dueTime: '',
@@ -74,12 +108,13 @@ export default function TaskDetailScreen() {
const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods; const { handleSubmit, watch, setValue, control, reset, formState: { errors } } = methods;
const categoryId = watch('categoryId'); const tags = watch('tags') ?? [];
const priority = watch('priority'); const priority = watch('priority');
const repeat = watch('repeat'); const repeat = watch('repeat');
const repeatInterval = watch('repeatInterval') ?? 1; const repeatInterval = watch('repeatInterval') ?? 1;
const repeatDays = watch('repeatDays') ?? []; const repeatDays = watch('repeatDays') ?? [];
const reminder = watch('reminder'); const reminder = watch('reminder');
const reminders = watch('reminders');
const dueDate = watch('dueDate'); const dueDate = watch('dueDate');
const assigneeId = watch('assigneeId'); const assigneeId = watch('assigneeId');
const [deleteModalVisible, setDeleteModalVisible] = React.useState(false); const [deleteModalVisible, setDeleteModalVisible] = React.useState(false);
@@ -94,10 +129,13 @@ export default function TaskDetailScreen() {
const subtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch(); const subtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch();
if (!mounted) return; if (!mounted) return;
const remindersValue = task.reminders || (task.reminder && task.reminder !== 'none' ? task.reminder : '');
reset({ reset({
title: task.title, title: task.title,
description: task.description, description: task.description,
categoryId: task.categoryId, categoryId: task.categoryId,
tags: parseTaskTags(task.tags, task.categoryId),
priority: task.priority, priority: task.priority,
dueDate: task.dueDate ? new Date(task.dueDate) : null, dueDate: task.dueDate ? new Date(task.dueDate) : null,
dueTime: task.dueTime, dueTime: task.dueTime,
@@ -107,6 +145,7 @@ export default function TaskDetailScreen() {
repeatInterval: task.repeatInterval || 1, repeatInterval: task.repeatInterval || 1,
repeatDays: (task.repeatDays || '').split(',').map(Number).filter((d) => !Number.isNaN(d)), repeatDays: (task.repeatDays || '').split(',').map(Number).filter((d) => !Number.isNaN(d)),
reminder: (task.reminder || 'none') as TaskFormData['reminder'], reminder: (task.reminder || 'none') as TaskFormData['reminder'],
reminders: remindersValue,
assigneeId: task.assigneeId ?? null, assigneeId: task.assigneeId ?? null,
subtasks: subtasks.map((s) => ({ title: s.title, _key: s.id })), subtasks: subtasks.map((s) => ({ title: s.title, _key: s.id })),
}); });
@@ -122,6 +161,9 @@ export default function TaskDetailScreen() {
const onSubmit = async (data: TaskFormData) => { const onSubmit = async (data: TaskFormData) => {
if (!id || !isReady) return; if (!id || !isReady) return;
Keyboard.dismiss();
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
const now = new Date(); const now = new Date();
const dueDateTimestamp = data.dueDate ? data.dueDate.getTime() : 0; const dueDateTimestamp = data.dueDate ? data.dueDate.getTime() : 0;
@@ -131,14 +173,12 @@ export default function TaskDetailScreen() {
const task = await collections.tasks.find(id); const task = await collections.tasks.find(id);
savedTask = task; savedTask = task;
const existingSubtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch(); const existingSubtasks = await collections.subtasks.query(Q.where('task_id', id)).fetch();
for (const subtask of existingSubtasks) {
await subtask.destroyPermanently();
}
await task.update((t) => { await task.update((t) => {
t.title = data.title.trim(); t.title = data.title.trim();
t.description = data.description || ''; t.description = data.description || '';
t.categoryId = data.categoryId; t.categoryId = (data.tags && data.tags[0]) || task.categoryId || '';
t.tags = tagsToString(data.tags || []);
t.priority = data.priority; t.priority = data.priority;
t.dueDate = dueDateTimestamp; t.dueDate = dueDateTimestamp;
t.dueTime = data.dueTime || ''; t.dueTime = data.dueTime || '';
@@ -148,6 +188,7 @@ export default function TaskDetailScreen() {
t.repeatInterval = data.repeatInterval || 1; t.repeatInterval = data.repeatInterval || 1;
t.repeatDays = (data.repeatDays || []).join(','); t.repeatDays = (data.repeatDays || []).join(',');
t.reminder = data.reminder || 'none'; t.reminder = data.reminder || 'none';
t.reminders = data.reminders || '';
t.assigneeId = data.assigneeId ?? null; t.assigneeId = data.assigneeId ?? null;
if (data.repeat !== 'none' && !t.seriesId) { if (data.repeat !== 'none' && !t.seriesId) {
t.seriesId = `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`; t.seriesId = `series_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`;
@@ -155,36 +196,69 @@ export default function TaskDetailScreen() {
t.updatedAt = now; t.updatedAt = now;
}); });
if (data.subtasks && data.subtasks.length > 0) { const keptIds = new Set<string>();
for (let i = 0; i < data.subtasks.length; i++) { let order = 0;
const subtask = data.subtasks[i]; for (const formItem of data.subtasks ?? []) {
if (subtask.title.trim()) { const trimmed = formItem.title.trim();
await collections.subtasks.create((s) => { if (!trimmed) continue;
s.taskId = task.id; if (formItem._key) {
s.title = subtask.title.trim(); const existing = existingSubtasks.find((s) => s.id === formItem._key && !s.parentSubtaskId);
s.description = ''; if (existing) {
s.priority = 'none'; keptIds.add(existing.id);
s.completed = false; await existing.update((s) => {
s.dueDate = 0; s.title = trimmed;
s.dueTime = ''; s.order = order;
s.endTime = '';
s.allDay = false;
s.repeat = 'none';
s.repeatInterval = 1;
s.repeatDays = '';
s.seriesId = '';
s.reminder = 'none';
s.assigneeId = null;
s.order = i;
s.createdAt = now;
s.updatedAt = now; s.updatedAt = now;
}); });
order++;
continue;
} }
} }
await collections.subtasks.create((s) => {
s.taskId = task.id;
s.categoryId = task.categoryId || '';
s.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(); router.back();
}; };
@@ -216,7 +290,7 @@ export default function TaskDetailScreen() {
rightAction={ rightAction={
<TouchableOpacity onPress={handleDelete} activeOpacity={0.7} style={[styles.deleteButton, { borderColor: theme.accentBorder, backgroundColor: theme.accentSoft }]}> <TouchableOpacity onPress={handleDelete} activeOpacity={0.7} style={[styles.deleteButton, { borderColor: theme.accentBorder, backgroundColor: theme.accentSoft }]}>
<Svg width={22} height={22} viewBox="0 0 24 24"> <Svg width={22} height={22} viewBox="0 0 24 24">
<Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" fill="none" /> <Path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" stroke={theme.accent} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
} }
@@ -224,16 +298,19 @@ export default function TaskDetailScreen() {
<FormProvider {...methods}> <FormProvider {...methods}>
<KeyboardAvoidingView <KeyboardAvoidingView
style={styles.keyboardAvoiding} style={styles.keyboardAvoiding}
behavior={Platform.OS === 'ios' ? 'padding' : undefined} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
> >
<ScrollView <ScrollView
ref={scrollViewRef}
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
> >
<CategorySelector <CategorySelector
value={categoryId} value={tags}
onChange={(value) => setValue('categoryId', value)} onChange={(value) => setValue('tags', value)}
error={errors.categoryId?.message} error={errors.categoryId?.message}
/> />
<Controller <Controller
@@ -253,38 +330,89 @@ export default function TaskDetailScreen() {
value={priority} value={priority}
onChange={(value) => setValue('priority', value)} onChange={(value) => setValue('priority', value)}
/> />
<DateTimePickerComponent control={control} />
<RepeatSelector <CollapsibleSection title="Date & Time" icon={
value={repeat} <Svg width={20} height={20} viewBox="0 0 24 24">
interval={repeatInterval} <Circle cx={12} cy={12} r={10} stroke={theme.accent} strokeWidth={1.8} fill="none" />
days={repeatDays} <Path d="M12 6v6l4 2" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" />
onChange={(nextRepeat, nextInterval, nextDays) => { </Svg>
setValue('repeat', nextRepeat); } defaultExpanded={!!dueDate}>
setValue('repeatInterval', nextInterval); <DateTimePickerComponent control={control} />
setValue('repeatDays', nextDays); </CollapsibleSection>
}}
/> <CollapsibleSection title="Repeat" icon={
<ReminderSelector <Svg width={20} height={20} viewBox="0 0 24 24">
value={reminder} <Path
hasDueDate={!!dueDate} 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"
onChange={(value) => setValue('reminder', value)} stroke={theme.accent}
/> strokeWidth={1.8}
<AssigneeSelector strokeLinecap="round"
value={assigneeId} strokeLinejoin="round"
onChange={(value: string | null) => setValue('assigneeId', value)} fill="none"
friends={friends.map((f) => f.username)}
/>
<Controller
control={control}
name="description"
render={({ field }) => (
<DescriptionInput
value={field.value ?? ''}
onChangeText={field.onChange}
onBlur={field.onBlur}
/> />
)} </Svg>
/> } defaultExpanded={repeat !== 'none'}>
<RepeatSelector
value={repeat}
interval={repeatInterval}
days={repeatDays}
onChange={(nextRepeat, nextInterval, nextDays) => {
setValue('repeat', nextRepeat);
setValue('repeatInterval', nextInterval);
setValue('repeatDays', nextDays);
}}
/>
</CollapsibleSection>
<CollapsibleSection title="Reminder" icon={
<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={theme.accent}
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</Svg>
} defaultExpanded={!!reminders && reminders !== ''}>
<ReminderSelector
value={reminders}
hasDueDate={!!dueDate}
onChange={(value) => setValue('reminders', value)}
/>
</CollapsibleSection>
<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.8} fill="none" />
<Path d="M12 10v6M12 19v1" stroke={theme.accent} strokeWidth={1.8} strokeLinecap="round" />
</Svg>
} defaultExpanded={!!assigneeId}>
<AssigneeSelector
value={assigneeId}
onChange={(value: string | null) => setValue('assigneeId', value)}
friends={friends.map((f) => f.username)}
/>
</CollapsibleSection>
<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.8} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg>
} defaultExpanded={!!methods.getValues('description')}>
<Controller
control={control}
name="description"
render={({ field }) => (
<DescriptionInput
value={field.value ?? ''}
onChangeText={field.onChange}
onBlur={field.onBlur}
/>
)}
/>
</CollapsibleSection>
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
<FormButtons onSubmit={handleSubmit(onSubmit)} submitLabel="Save" /> <FormButtons onSubmit={handleSubmit(onSubmit)} submitLabel="Save" />
@@ -315,15 +443,40 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
}, },
scrollContent: { scrollContent: {
paddingHorizontal: 16, paddingHorizontal: 12,
paddingTop: 8, paddingTop: 8,
paddingBottom: 100, paddingBottom: 60,
gap: 24, gap: 12,
},
section: {
gap: 6,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 10,
borderWidth: 1,
},
sectionHeaderLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
sectionTitle: {
fontSize: 14,
fontWeight: '600',
},
sectionContent: {
paddingHorizontal: 2,
gap: 6,
}, },
deleteButton: { deleteButton: {
width: 36, width: 32,
height: 36, height: 32,
borderRadius: 12, borderRadius: 10,
borderWidth: 1, borderWidth: 1,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
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'],
},
}
+61
View File
@@ -0,0 +1,61 @@
/* global __dirname */
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,
height: 800,
minWidth: 800,
minHeight: 600,
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,
})
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()
})
// 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', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
+6
View File
@@ -0,0 +1,6 @@
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electron', {
isElectron: true,
getAppInfo: () => ipcRenderer.invoke('app:get-info'),
})
+16
View File
@@ -2,9 +2,25 @@
const { defineConfig } = require('eslint/config'); const { defineConfig } = require('eslint/config');
const expoConfig = require("eslint-config-expo/flat"); const expoConfig = require("eslint-config-expo/flat");
const platformExtensions = [];
for (const platform of ['.android', '.ios', '.web', '.native', '']) {
for (const base of ['.ts', '.tsx', '.d.ts']) {
platformExtensions.push(`${platform}${base}`);
}
}
module.exports = defineConfig([ module.exports = defineConfig([
expoConfig, expoConfig,
{ {
ignores: ["dist/*"], ignores: ["dist/*"],
},
{
settings: {
'import/resolver': {
typescript: {
extensions: platformExtensions,
},
},
},
} }
]); ]);
+3010 -129
View File
File diff suppressed because it is too large Load Diff
+17 -4
View File
@@ -2,6 +2,7 @@
"name": "carry-your-live", "name": "carry-your-live",
"version": "1.0.0", "version": "1.0.0",
"main": "expo-router/entry", "main": "expo-router/entry",
"homepage": "https://gitea.techmos.org/tech08mag/carry-your-live",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.3.4", "@hookform/resolvers": "^3.3.4",
"@nozbe/watermelondb": "^0.28.1-0", "@nozbe/watermelondb": "^0.28.1-0",
@@ -9,32 +10,38 @@
"@react-native-community/datetimepicker": "9.1.0", "@react-native-community/datetimepicker": "9.1.0",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"expo": "~57.0.10", "expo": "~57.0.10",
"expo-blur": "~57.0.2",
"expo-constants": "~57.0.9", "expo-constants": "~57.0.9",
"expo-dev-client": "~57.0.1", "expo-dev-client": "~57.0.1",
"expo-device": "^57.0.1",
"expo-linking": "~57.0.5", "expo-linking": "~57.0.5",
"expo-notifications": "~57.0.8", "expo-notifications": "~57.0.8",
"expo-router": "~57.0.10", "expo-router": "~57.0.10",
"expo-sqlite": "~57.0.1", "expo-sqlite": "~57.0.1",
"expo-status-bar": "~57.0.1", "expo-status-bar": "~57.0.1",
"expo-system-ui": "~57.0.2",
"expo-updates": "~57.0.12",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-hook-form": "^7.51.5", "react-hook-form": "^7.51.5",
"react-native": "0.86.2", "react-native": "0.86.2",
"react-native-gesture-handler": "2.32.0", "react-native-gesture-handler": "2.32.0",
"react-native-paper": "^5.12.3", "react-native-keyboard-controller": "1.21.9",
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "5.7.0", "react-native-safe-area-context": "5.7.0",
"react-native-screens": "4.26.0", "react-native-screens": "4.26.0",
"react-native-svg": "^15.15.4", "react-native-svg": "^15.15.4",
"react-native-web": "^0.21.2", "react-native-web": "^0.21.2",
"react-native-worklets": "0.10.1",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.20.0", "@babel/core": "^7.20.0",
"@types/react": "~19.2.2", "@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": "^9.0.0",
"eslint-config-expo": "~57.0.1", "eslint-config-expo": "~57.0.1",
"patch-package": "^8.0.1",
"prettier": "^3.9.6", "prettier": "^3.9.6",
"typescript": "~6.0.3" "typescript": "~6.0.3"
}, },
@@ -46,11 +53,17 @@
"lint": "expo lint", "lint": "expo lint",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"format": "prettier --write \"**/*.{ts,tsx,json,md}\"", "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:ios": "eas build --platform ios",
"build:android": "eas build --platform android", "build:android": "eas build --platform android",
"build:web": "eas build --platform web", "build:web": "eas build --platform web",
"submit:ios": "eas submit --platform ios", "submit:ios": "eas submit --platform ios",
"submit:android": "eas submit --platform android" "submit:android": "eas submit --platform android",
"postinstall": "patch-package"
}, },
"private": true "private": true
} }
@@ -0,0 +1,78 @@
diff --git a/node_modules/react-native/src/private/webapis/dom/events/Event.js b/node_modules/react-native/src/private/webapis/dom/events/Event.js
index f918f97..5deab46 100644
--- a/node_modules/react-native/src/private/webapis/dom/events/Event.js
+++ b/node_modules/react-native/src/private/webapis/dom/events/Event.js
@@ -70,7 +70,7 @@ export default class Event {
[CURRENT_TARGET_KEY]: EventTarget | null = null;
// $FlowExpectedError[unsupported-syntax]
- [EVENT_PHASE_KEY]: boolean = Event.NONE;
+ [EVENT_PHASE_KEY]: boolean = 0;
// $FlowExpectedError[unsupported-syntax]
[IN_PASSIVE_LISTENER_FLAG_KEY]: boolean = false;
@@ -193,48 +193,64 @@ export default class Event {
// $FlowExpectedError[cannot-write]
Object.defineProperty(Event, 'NONE', {
+ writable: true,
+ configurable: true,
enumerable: true,
value: 0,
});
// $FlowExpectedError[cannot-write]
Object.defineProperty(Event.prototype, 'NONE', {
+ writable: true,
+ configurable: true,
enumerable: true,
value: 0,
});
// $FlowExpectedError[cannot-write]
Object.defineProperty(Event, 'CAPTURING_PHASE', {
+ writable: true,
+ configurable: true,
enumerable: true,
value: 1,
});
// $FlowExpectedError[cannot-write]
Object.defineProperty(Event.prototype, 'CAPTURING_PHASE', {
+ writable: true,
+ configurable: true,
enumerable: true,
value: 1,
});
// $FlowExpectedError[cannot-write]
Object.defineProperty(Event, 'AT_TARGET', {
+ writable: true,
+ configurable: true,
enumerable: true,
value: 2,
});
// $FlowExpectedError[cannot-write]
Object.defineProperty(Event.prototype, 'AT_TARGET', {
+ writable: true,
+ configurable: true,
enumerable: true,
value: 2,
});
// $FlowExpectedError[cannot-write]
Object.defineProperty(Event, 'BUBBLING_PHASE', {
+ writable: true,
+ configurable: true,
enumerable: true,
value: 3,
});
// $FlowExpectedError[cannot-write]
Object.defineProperty(Event.prototype, 'BUBBLING_PHASE', {
+ writable: true,
+ configurable: true,
enumerable: true,
value: 3,
});
@@ -0,0 +1,213 @@
const {
withAndroidManifest,
withDangerousMod,
withStringsXml,
} = require('expo/config-plugins');
const fs = require('fs');
const path = require('path');
const RECEIVER_NAME = '.QuickAddWidgetProvider';
const WIDGET_PROVIDER_XML = `<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="40dp"
android:targetCellWidth="4"
android:targetCellHeight="1"
android:updatePeriodMillis="0"
android:initialLayout="@layout/widget_quick_add"
android:resizeMode="horizontal"
android:widgetCategory="home_screen"
android:description="@string/widget_quick_add_description" />
`;
const WIDGET_LAYOUT_XML = `<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@drawable/widget_quick_add_bg"
android:padding="14dp">
<TextView
android:id="@+id/widget_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="carry your live"
android:textColor="#8E8E8E"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:id="@+id/quick_add_button"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="8dp"
android:gravity="center"
android:background="@drawable/widget_quick_add_button_bg"
android:text="+ Add task"
android:textColor="#FFFFFF"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
`;
const WIDGET_BG_XML = `<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#1E1E1E" />
<corners android:radius="16dp" />
<stroke android:width="1dp" android:color="#2A2A2A" />
</shape>
`;
const WIDGET_BUTTON_BG_XML = `<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#EF5350" />
<corners android:radius="12dp" />
</shape>
`;
function kotlinProvider(packageName) {
return `package ${packageName}
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.widget.RemoteViews
class QuickAddWidgetProvider : AppWidgetProvider() {
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
for (appWidgetId in appWidgetIds) {
updateWidget(context, appWidgetManager, appWidgetId)
}
}
private fun updateWidget(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int
) {
val views = RemoteViews(context.packageName, R.layout.widget_quick_add)
val openAppIntent = Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = Uri.parse("exp+carry-your-live://quick-add")
}
val pendingIntent = PendingIntent.getActivity(
context,
0,
openAppIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.quick_add_button, pendingIntent)
appWidgetManager.updateAppWidget(appWidgetId, views)
}
}
`;
}
function withQuickAddWidget(config) {
const packageName = config.android?.package ?? 'com.anonymous.carryyourlive';
config = withStringsXml(config, (config) => {
const strings = config.modResults;
if (!strings.resources.string) {
strings.resources.string = [];
}
const existing = (strings.resources.string || []).find(
(s) => s && s['$'] && s['$'].name === 'widget_quick_add_description'
);
if (!existing) {
strings.resources.string.push({
$: { name: 'widget_quick_add_description' },
_: 'Quickly add a task',
});
}
return config;
});
config = withAndroidManifest(config, (config) => {
const manifest = config.modResults;
const application = manifest.manifest.application?.[0];
if (!application) return config;
const receivers = application.receiver || [];
const exists = receivers.some((r) => r && r['$'] && r['$']['android:name'] === RECEIVER_NAME);
if (!exists) {
application.receiver = [
...receivers,
{
$: {
'android:name': RECEIVER_NAME,
'android:exported': 'false',
'android:label': 'Quick Add Task',
},
'intent-filter': [
{
action: [{ $: { 'android:name': 'android.appwidget.action.APPWIDGET_UPDATE' } }],
},
],
'meta-data': [
{
$: {
'android:name': 'android.appwidget.provider',
'android:resource': '@xml/quick_add_widget',
},
},
],
},
];
}
return config;
});
config = withDangerousMod(config, [
'android',
async (config) => {
const projectRoot = config.modRequest.projectRoot;
const resDir = path.join(projectRoot, 'android', 'app', 'src', 'main', 'res');
const javaDir = path.join(
projectRoot,
'android',
'app',
'src',
'main',
'java',
...packageName.split('.')
);
fs.mkdirSync(path.join(resDir, 'xml'), { recursive: true });
fs.mkdirSync(path.join(resDir, 'layout'), { recursive: true });
fs.mkdirSync(path.join(resDir, 'drawable'), { recursive: true });
fs.mkdirSync(javaDir, { recursive: true });
fs.writeFileSync(path.join(resDir, 'xml', 'quick_add_widget.xml'), WIDGET_PROVIDER_XML);
fs.writeFileSync(path.join(resDir, 'layout', 'widget_quick_add.xml'), WIDGET_LAYOUT_XML);
fs.writeFileSync(path.join(resDir, 'drawable', 'widget_quick_add_bg.xml'), WIDGET_BG_XML);
fs.writeFileSync(
path.join(resDir, 'drawable', 'widget_quick_add_button_bg.xml'),
WIDGET_BUTTON_BG_XML
);
fs.writeFileSync(
path.join(javaDir, 'QuickAddWidgetProvider.kt'),
kotlinProvider(packageName)
);
return config;
},
]);
return config;
}
module.exports = withQuickAddWidget;
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Modal, FlatList, ActivityIndicator, TextInput } from 'react-native'; import { View, Text, TouchableOpacity, StyleSheet, Modal, FlatList, ActivityIndicator, TextInput, KeyboardAvoidingView } from 'react-native';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { useFriends } from '@/hooks/useFriends'; import { useFriends } from '@/hooks/useFriends';
@@ -15,11 +15,19 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
const { friends, searchUsers, loading: friendsLoading } = useFriends(); const { friends, searchUsers, loading: friendsLoading } = useFriends();
const [showModal, setShowModal] = React.useState(false); const [showModal, setShowModal] = React.useState(false);
const [searchQuery, setSearchQuery] = React.useState(''); const [searchQuery, setSearchQuery] = React.useState('');
const [searchResults, setSearchResults] = React.useState<Array<{ id: string; username: string }>>([]); const [searchResults, setSearchResults] = React.useState<{ id: string; username: string }[]>([]);
const [searching, setSearching] = React.useState(false); const [searching, setSearching] = React.useState(false);
const selectedFriend = value ? friends.find(f => f.id === value) : null; const selectedFriend = value ? friends.find(f => f.id === value) : null;
const searchKey = `${showModal}:${searchQuery}`;
const [prevSearchKey, setPrevSearchKey] = React.useState(searchKey);
if (prevSearchKey !== searchKey && (!showModal || searchQuery.length < 2)) {
setPrevSearchKey(searchKey);
setSearchResults([]);
}
React.useEffect(() => { React.useEffect(() => {
if (showModal && searchQuery.length >= 2) { if (showModal && searchQuery.length >= 2) {
const timeout = setTimeout(async () => { const timeout = setTimeout(async () => {
@@ -34,8 +42,6 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
} }
}, 300); }, 300);
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
} else {
setSearchResults([]);
} }
}, [searchQuery, showModal, friends, searchUsers]); }, [searchQuery, showModal, friends, searchUsers]);
@@ -54,8 +60,11 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
await sendFriendRequest(username); await sendFriendRequest(username);
onChange(friendId); onChange(friendId);
setShowModal(false); setShowModal(false);
} catch (err) { } catch (err: any) {
console.error('Failed to add friend:', err); console.error('Failed to add friend:', err.message, err.details || '');
if (err.details) {
console.error('Validation details:', JSON.stringify(err.details, null, 2));
}
} }
}; };
@@ -70,7 +79,7 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
onPress={() => !disabled && setShowModal(true)} onPress={() => !disabled && setShowModal(true)}
activeOpacity={0.8} activeOpacity={0.8}
> >
<Text style={[styles.selectorIcon, { color: theme.accent }]}>👤</Text> <Text style={[styles.selectorIcon, { color: theme.accentStrong }]}>👤</Text>
<View style={styles.selectorContent}> <View style={styles.selectorContent}>
<Text style={[styles.selectorLabel, { color: theme.textMuted }]}>Assignee</Text> <Text style={[styles.selectorLabel, { color: theme.textMuted }]}>Assignee</Text>
<Text <Text
@@ -97,7 +106,10 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
</TouchableOpacity> </TouchableOpacity>
<Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}> <Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}>
<View style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}> <KeyboardAvoidingView
style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
behavior="padding"
>
<View style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}> <View style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.modalHeader}> <View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { color: theme.text }]}>Assign Task</Text> <Text style={[styles.modalTitle, { color: theme.text }]}>Assign Task</Text>
@@ -119,7 +131,7 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
</View> </View>
<Text style={[styles.optionText, { color: theme.text }]}>Unassigned</Text> <Text style={[styles.optionText, { color: theme.text }]}>Unassigned</Text>
{selectedFriend && ( {selectedFriend && (
<Text style={[styles.checkmark, { color: theme.accent }]}>✓</Text> <Text style={[styles.checkmark, { color: theme.accentStrong }]}>✓</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -149,7 +161,7 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
</View> </View>
<Text style={[styles.optionText, { color: theme.text }]}>{item.username}</Text> <Text style={[styles.optionText, { color: theme.text }]}>{item.username}</Text>
{value === item.id && ( {value === item.id && (
<Text style={[styles.checkmark, { color: theme.accent }]}>✓</Text> <Text style={[styles.checkmark, { color: theme.accentStrong }]}>✓</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
)} )}
@@ -197,7 +209,7 @@ export function AssigneeSelector({ value, onChange, disabled = false }: Assignee
) : null} ) : null}
</View> </View>
</View> </View>
</View> </KeyboardAvoidingView>
</Modal> </Modal>
</View> </View>
); );
@@ -210,48 +222,48 @@ const styles = StyleSheet.create({
selectorButton: { selectorButton: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 10, gap: 8,
paddingVertical: 14, paddingVertical: 12,
paddingHorizontal: 12, paddingHorizontal: 10,
borderRadius: 12, borderRadius: 10,
borderWidth: 1, borderWidth: 1,
minHeight: 52, minHeight: 48,
}, },
selectorIcon: { selectorIcon: {
fontSize: 20, fontSize: 18,
}, },
selectorContent: { selectorContent: {
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: 'center',
}, },
selectorLabel: { selectorLabel: {
fontSize: 11, fontSize: 10,
fontWeight: '600', fontWeight: '600',
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 0.5, letterSpacing: 0.5,
marginBottom: 2, marginBottom: 1,
}, },
selectorValue: { selectorValue: {
fontSize: 15, fontSize: 14,
fontWeight: '500', fontWeight: '500',
}, },
clearButton: { clearButton: {
padding: 4, padding: 3,
}, },
clearText: { clearText: {
fontSize: 18, fontSize: 16,
fontWeight: '300', fontWeight: '300',
}, },
modalOverlay: { modalOverlay: {
flex: 1, flex: 1,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
padding: 24, padding: 20,
}, },
modalSheet: { modalSheet: {
width: '100%', width: '100%',
maxWidth: 400, maxWidth: 360,
borderRadius: 20, borderRadius: 16,
overflow: 'hidden', overflow: 'hidden',
maxHeight: '85%', maxHeight: '85%',
}, },
@@ -259,81 +271,81 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
padding: 20, padding: 16,
borderBottomWidth: 1, borderBottomWidth: 1,
}, },
modalTitle: { modalTitle: {
fontSize: 18, fontSize: 16,
fontWeight: '700', fontWeight: '700',
}, },
closeText: { closeText: {
fontSize: 22, fontSize: 20,
fontWeight: '300', fontWeight: '300',
}, },
modalSection: { modalSection: {
padding: 12, padding: 10,
paddingBottom: 20, paddingBottom: 16,
borderBottomWidth: 1, borderBottomWidth: 1,
}, },
sectionTitle: { sectionTitle: {
fontSize: 12, fontSize: 11,
fontWeight: '600', fontWeight: '600',
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 0.5, letterSpacing: 0.5,
marginBottom: 8, marginBottom: 6,
paddingHorizontal: 8, paddingHorizontal: 6,
}, },
optionRow: { optionRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 12, gap: 10,
paddingVertical: 12, paddingVertical: 10,
paddingHorizontal: 16, paddingHorizontal: 14,
borderRadius: 10, borderRadius: 9,
borderWidth: 1, borderWidth: 1,
}, },
optionIcon: { optionIcon: {
width: 36, width: 32,
height: 36, height: 32,
borderRadius: 18, borderRadius: 16,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.05)', backgroundColor: 'rgba(0,0,0,0.05)',
}, },
optionIconText: { optionIconText: {
fontSize: 16, fontSize: 15,
}, },
optionText: { optionText: {
fontSize: 16, fontSize: 15,
fontWeight: '500', fontWeight: '500',
flex: 1, flex: 1,
}, },
optionSubtext: { optionSubtext: {
fontSize: 12, fontSize: 11,
marginTop: 2, marginTop: 1,
}, },
checkmark: { checkmark: {
fontSize: 18, fontSize: 16,
fontWeight: '700', fontWeight: '700',
}, },
loading: { loading: {
padding: 20, padding: 16,
alignItems: 'center', alignItems: 'center',
}, },
emptyText: { emptyText: {
fontSize: 14, fontSize: 13,
textAlign: 'center', textAlign: 'center',
paddingHorizontal: 20, paddingHorizontal: 16,
}, },
searchContainer: { searchContainer: {
padding: 12, padding: 10,
paddingBottom: 8, paddingBottom: 6,
}, },
searchInput: { searchInput: {
fontSize: 16, fontSize: 15,
paddingVertical: 12, paddingVertical: 10,
paddingHorizontal: 16, paddingHorizontal: 14,
borderRadius: 12, borderRadius: 10,
borderWidth: 1, borderWidth: 1,
}, },
}); });
@@ -1,12 +1,12 @@
import React, { useEffect, useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView } from 'react-native'; import { Modal, View, Text, StyleSheet, TextInput, TouchableOpacity, ScrollView, KeyboardAvoidingView, Platform, Pressable, useWindowDimensions } from 'react-native';
import Category from '@/models/Category'; import Category from '@/models/Category';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { CATEGORY_COLORS } from '@/constants'; import { CATEGORY_COLORS } from '@/constants';
import { createCategory, updateCategory, deleteCategory } from '@/utils/categoryActions'; import { createCategory, updateCategory, deleteCategory } from '@/utils/categoryActions';
import { confirmAction } from '@/utils/confirm'; import { confirmAction } from '@/utils/confirm';
import ColorPickerInput from '@/components/ColorPickerInput'; import ColorWheel from '@/components/ColorWheel';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path, Circle } from 'react-native-svg';
interface CategoryEditorModalProps { interface CategoryEditorModalProps {
visible: boolean; visible: boolean;
@@ -19,13 +19,16 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
const { theme } = useSettings(); const { theme } = useSettings();
const [name, setName] = useState(''); const [name, setName] = useState('');
const [color, setColor] = useState(CATEGORY_COLORS[0]); const [color, setColor] = useState(CATEGORY_COLORS[0]);
const [prevVisible, setPrevVisible] = useState(visible);
const { height: screenHeight } = useWindowDimensions();
useEffect(() => { if (prevVisible !== visible) {
setPrevVisible(visible);
if (visible) { if (visible) {
setName(category?.name ?? ''); setName(category?.name ?? '');
setColor(category?.color ?? CATEGORY_COLORS[0]); setColor(category?.color ?? CATEGORY_COLORS[0]);
} }
}, [visible, category]); }
const canDelete = category !== null && categoryCount > 1; const canDelete = category !== null && categoryCount > 1;
@@ -43,8 +46,8 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
const handleDelete = () => { const handleDelete = () => {
if (!category || !canDelete) return; if (!category || !canDelete) return;
confirmAction({ confirmAction({
title: 'Delete Category', title: 'Delete Tag',
message: `Tasks in "${category.name}" will be moved to another category.`, message: `Tasks in "${category.name}" will be moved to another tag.`,
confirmLabel: 'Delete', confirmLabel: 'Delete',
destructive: true, destructive: true,
onConfirm: async () => { onConfirm: async () => {
@@ -56,89 +59,115 @@ export function CategoryEditorModal({ visible, category, categoryCount, onClose
return ( return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}> <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={styles.overlay}> <KeyboardAvoidingView
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}> style={styles.fill}
<View style={styles.header}> behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
<Text style={[styles.title, { color: theme.text }]}> keyboardVerticalOffset={0}
{category ? 'Edit Category' : 'New Category'} >
</Text> <Pressable
<TouchableOpacity onPress={onClose} style={styles.closeButton} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}> style={styles.overlay}
<Svg width={18} height={18} viewBox="0 0 24 24"> onPress={onClose}
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" /> accessibilityRole="none"
</Svg> >
</TouchableOpacity> <Pressable
</View> style={[styles.sheet, { backgroundColor: theme.sheetBg }]}
onPress={() => {}}
<Text style={[styles.label, { color: theme.textSecondary }]}>Name</Text> >
<TextInput <ScrollView
style={[ showsVerticalScrollIndicator={false}
styles.input, keyboardShouldPersistTaps="handled"
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }, contentContainerStyle={styles.scrollContent}
]}
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}
>
{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}>Save</Text> <View style={styles.header}>
</TouchableOpacity> <Text style={[styles.title, { color: theme.text }]}>
</View> {category ? 'Edit Tag' : 'New Tag'}
</View> </Text>
</View> <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>
<ColorWheel color={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> </Modal>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
fill: {
flex: 1,
},
overlay: { overlay: {
flex: 1, flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)', backgroundColor: 'rgba(0,0,0,0.55)',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
padding: 24, padding: 24,
@@ -146,9 +175,18 @@ const styles = StyleSheet.create({
sheet: { sheet: {
width: '100%', width: '100%',
maxWidth: 380, maxWidth: 380,
maxHeight: '90%',
borderRadius: 16, borderRadius: 16,
padding: 20, padding: 20,
gap: 8, gap: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.25,
shadowRadius: 16,
elevation: 12,
},
scrollContent: {
minHeight: 200,
}, },
header: { header: {
flexDirection: 'row', flexDirection: 'row',
@@ -162,6 +200,8 @@ const styles = StyleSheet.create({
}, },
closeButton: { closeButton: {
padding: 4, padding: 4,
borderRadius: 12,
backgroundColor: 'transparent',
}, },
label: { label: {
fontSize: 13, fontSize: 13,
@@ -224,6 +264,5 @@ const styles = StyleSheet.create({
saveButtonText: { saveButtonText: {
fontSize: 15, fontSize: 15,
fontWeight: '600', fontWeight: '600',
color: '#FFFFFF',
}, },
}); });
+121 -126
View File
@@ -1,21 +1,42 @@
import React from 'react'; import React, { useState } from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Animated, Easing } from 'react-native'; import { Text, StyleSheet, ScrollView, TouchableOpacity, Animated } from 'react-native';
import { useCategories } from '@/hooks/useDatabase'; import { useRouter } from 'expo-router';
import { useUniqueCategories } from '@/hooks/useDatabase';
import { useSettings, ThemeColors } from '@/theme'; import { useSettings, ThemeColors } from '@/theme';
import Category from '@/models/Category'; import Svg, { Path, Circle } from 'react-native-svg';
interface CategoryFilterProps { interface CategoryFilterProps {
selected: string; selected: string[];
onSelect: (categoryId: string) => void; onSelect: (categoryIds: string[]) => void;
} }
export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) { export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
const categories = useCategories(); const categories = useUniqueCategories();
const { theme } = useSettings(); const { theme } = useSettings();
const router = useRouter();
if (categories.length === 0) { const selectedSet = new Set(selected);
return null; 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 ( return (
<ScrollView <ScrollView
@@ -27,20 +48,45 @@ export function CategoryFilter({ selected, onSelect }: CategoryFilterProps) {
<CategoryButton <CategoryButton
id="all" id="all"
name="All" name="All"
color="#9E9E9E" color={theme.textMuted}
selected={selected === 'all'} selected={!isAnythingSelected}
onPress={() => onSelect('all')} onPress={() => onSelect([])}
onPressIn={() => animatePress('all', true)}
onPressOut={() => animatePress('all', false)}
theme={theme} theme={theme}
isAllCategory={true}
scaleAnim={scaleAnim}
/> />
{categories.map((category) => ( {categories.map((category) => (
<AnimatedCategoryButton <CategoryButton
key={category.id} key={category.id}
category={category} id={category.id}
selected={selected === category.id} name={category.name}
onPress={() => onSelect(category.id)} color={category.color}
selected={selectedSet.has(category.id)}
onPress={() => toggle(category.id)}
onPressIn={() => animatePress(category.id, true)}
onPressOut={() => animatePress(category.id, false)}
theme={theme} 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> </ScrollView>
); );
} }
@@ -51,145 +97,94 @@ interface CategoryButtonProps {
color: string; color: string;
selected: boolean; selected: boolean;
onPress: () => void; onPress: () => void;
onPressIn: () => void;
onPressOut: () => void;
theme: ThemeColors; theme: ThemeColors;
isAllCategory?: boolean;
scaleAnim: Animated.Value;
} }
function CategoryButton({ id, 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 ( return (
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.button, styles.button,
{ backgroundColor: theme.card, borderColor: theme.borderStrong }, { backgroundColor: color },
selected && { borderColor: theme.accent, backgroundColor: theme.accentSoft }, selected && styles.buttonSelected,
animatedStyle,
]} ]}
onPress={onPress} onPress={onPress}
activeOpacity={0.8} onPressIn={onPressIn}
onPressOut={onPressOut}
activeOpacity={1}
hitSlop={{ top: 10, bottom: 10, left: 8, right: 8 }}
> >
<View {isAllCategory ? (
style={[ <Svg width={20} height={20} viewBox="0 0 24 24">
styles.colorDot, <Circle cx={12} cy={12} r={9} stroke="#FFFFFF" strokeWidth={2.5} fill="none" opacity={0.9} />
{ backgroundColor: color }, <Path d="M6 12l4 4 8-8" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" opacity={0.9} />
selected && styles.colorDotSelected, </Svg>
]} ) : null}
/>
<Text style={[
styles.buttonText,
{ color: theme.textSecondary },
selected && { color: theme.accent, fontWeight: '600' },
]}>
{name}
</Text>
</TouchableOpacity> </TouchableOpacity>
); );
} }
interface AnimatedCategoryButtonProps {
category: Category;
selected: boolean;
onPress: () => void;
theme: ThemeColors;
}
function AnimatedCategoryButton({ category, selected, onPress, theme }: AnimatedCategoryButtonProps) {
const scaleAnim = React.useRef(new Animated.Value(selected ? 1.05 : 1)).current;
const borderWidthAnim = React.useRef(new Animated.Value(selected ? 2 : 1)).current;
const shadowOpacityAnim = React.useRef(new Animated.Value(selected ? 0.15 : 0)).current;
React.useEffect(() => {
Animated.timing(scaleAnim, {
toValue: selected ? 1.05 : 1,
duration: 150,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
Animated.timing(borderWidthAnim, {
toValue: selected ? 2 : 1,
duration: 150,
useNativeDriver: false,
}).start();
Animated.timing(shadowOpacityAnim, {
toValue: selected ? 0.15 : 0,
duration: 150,
useNativeDriver: false,
}).start();
}, [selected, scaleAnim, borderWidthAnim, shadowOpacityAnim]);
const animatedStyle = {
transform: [{ scale: scaleAnim }],
borderWidth: borderWidthAnim,
shadowOpacity: shadowOpacityAnim,
};
return (
<Animated.View style={[styles.button, styles.animatedButton, { backgroundColor: theme.card, borderColor: theme.borderStrong }, animatedStyle]}>
<TouchableOpacity
style={styles.buttonInner}
onPress={onPress}
activeOpacity={0.8}
>
<View
style={[
styles.colorDot,
{ backgroundColor: category.color },
selected && styles.colorDotSelected,
]}
/>
<Text style={[
styles.buttonText,
{ color: theme.textSecondary },
selected && { color: theme.accent, fontWeight: '600' },
]}>
{category.name}
</Text>
</TouchableOpacity>
</Animated.View>
);
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
scrollView: { scrollView: {
paddingVertical: 8, paddingVertical: 6,
}, },
container: { container: {
paddingHorizontal: 16, paddingHorizontal: 12,
gap: 8, gap: 8,
alignItems: 'center', alignItems: 'center',
}, },
button: { button: {
flexDirection: 'row', width: 44,
height: 44,
borderRadius: 22,
borderWidth: 2,
borderColor: 'transparent',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
borderWidth: 1,
minWidth: 72,
justifyContent: 'center', justifyContent: 'center',
},
animatedButton: {
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 2 },
shadowRadius: 8, shadowOpacity: 0.1,
elevation: 3, shadowRadius: 4,
elevation: 2,
}, },
buttonInner: { buttonSelected: {
borderColor: '#FFFFFF',
borderWidth: 3,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 6,
},
manageButton: {
alignSelf: 'center',
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 6, gap: 6,
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 18,
borderWidth: 1,
borderStyle: 'dashed',
minHeight: 38,
justifyContent: 'center',
}, },
colorDot: { manageLabel: {
width: 10, fontSize: 12,
height: 10, fontWeight: '600',
borderRadius: 5,
},
colorDotSelected: {
width: 12,
height: 12,
borderRadius: 6,
},
buttonText: {
fontSize: 13,
fontWeight: '500',
}, },
}); });
@@ -1,93 +1,187 @@
import React from 'react'; import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Modal, Pressable, KeyboardAvoidingView } from 'react-native';
import { useCategories } from '@/hooks/useDatabase'; import { useCategories } from '@/hooks/useDatabase';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import Svg, { Path } from 'react-native-svg';
interface CategorySelectorProps { interface CategorySelectorProps {
value: string; value: string[];
onChange: (value: string) => void; onChange: (value: string[]) => void;
error?: string; error?: string;
} }
export function CategorySelector({ value, onChange, error }: CategorySelectorProps) { export function CategorySelector({ value, onChange, error }: CategorySelectorProps) {
const categories = useCategories(); const categories = useCategories();
const { theme } = useSettings(); const { theme } = useSettings();
const [showModal, setShowModal] = useState(false);
const selected = new Set(value);
const selectedCategories = categories.filter((c) => selected.has(c.id));
const toggle = (id: string) => {
const next = new Set(selected);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
onChange(Array.from(next));
};
const summary =
selectedCategories.length === 0
? 'None'
: selectedCategories
.slice(0, 2)
.map((c) => c.name)
.join(', ') + (selectedCategories.length > 2 ? ` +${selectedCategories.length - 2}` : '');
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text style={[styles.label, { color: theme.text }]}>Category</Text> <Text style={[styles.label, { color: theme.text }]}>Tags</Text>
<View style={styles.requiredIndicator} /> <TouchableOpacity
<ScrollView style={[
horizontal styles.selectorButton,
showsHorizontalScrollIndicator={false} { backgroundColor: theme.inputBg, borderColor: theme.borderStrong },
contentContainerStyle={styles.scrollContent} ]}
style={styles.scrollView} onPress={() => setShowModal(true)}
activeOpacity={0.8}
accessibilityRole="button"
accessibilityLabel="Select tags"
accessibilityHint="Opens a list of tags to choose from"
> >
{categories.map((category) => ( <View style={styles.selectorContent}>
<TouchableOpacity {selectedCategories.length > 0 ? (
key={category.id} <View style={styles.chipRow}>
style={[ {selectedCategories.slice(0, 3).map((c) => (
styles.categoryButton, <View key={c.id} style={[styles.chip, { backgroundColor: theme.cardAlt, borderColor: theme.border }]}>
{ backgroundColor: theme.card, borderColor: theme.borderStrong }, <View style={[styles.colorCircle, { backgroundColor: c.color }]} />
value === category.id && { borderColor: theme.accent, backgroundColor: theme.accentSoft, borderWidth: 2 }, <Text style={[styles.chipText, { color: theme.textSecondary }]} numberOfLines={1}>{c.name}</Text>
]} </View>
onPress={() => onChange(category.id)} ))}
activeOpacity={0.8} </View>
> ) : (
<View <Text style={[styles.selectorValue, { color: theme.textMuted }]}>None</Text>
style={[ )}
styles.colorCircle, </View>
{ backgroundColor: category.color }, <Svg width={20} height={20} viewBox="0 0 24 24">
value === category.id && styles.colorCircleSelected, <Path d="M9 18l6-6-6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
]} </Svg>
/> </TouchableOpacity>
<Text style={[ {error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>}
styles.categoryName,
{ color: theme.textSecondary }, <Modal visible={showModal} transparent animationType="fade" onRequestClose={() => setShowModal(false)}>
value === category.id && { color: theme.accent, fontWeight: '600' }, <KeyboardAvoidingView
]}> style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
{category.name} behavior="padding"
</Text> >
</TouchableOpacity> <Pressable style={styles.modalOverlay} onPress={() => setShowModal(false)}>
))} <Pressable style={[styles.modalSheet, { backgroundColor: theme.sheetBg }]}>
</ScrollView> <View style={styles.modalHeader}>
{error && <Text style={styles.errorText}>{error}</Text>} <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}>
<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>
</KeyboardAvoidingView>
</Modal>
</View> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
gap: 8, gap: 6,
}, },
label: { label: {
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: '600',
}, },
requiredIndicator: { selectorButton: {
position: 'absolute',
top: 0,
right: 0,
color: '#E53935',
fontSize: 14,
},
scrollView: {
paddingVertical: 4,
},
scrollContent: {
paddingHorizontal: 16,
gap: 10,
},
categoryButton: {
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 24,
borderWidth: 1,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 8, justifyContent: 'space-between',
minWidth: 90, paddingVertical: 14,
justifyContent: 'center', paddingHorizontal: 12,
borderRadius: 12,
borderWidth: 1,
minHeight: 52,
},
selectorContent: {
flex: 1,
paddingRight: 8,
},
chipRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
},
chip: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
borderWidth: 1,
},
chipText: {
fontSize: 12,
fontWeight: '600',
}, },
colorCircle: { colorCircle: {
width: 12, width: 12,
@@ -99,13 +193,73 @@ const styles = StyleSheet.create({
height: 14, height: 14,
borderRadius: 7, borderRadius: 7,
}, },
categoryName: { selectorValue: {
fontSize: 13, fontSize: 15,
fontWeight: '500', fontWeight: '500',
}, },
errorText: { errorText: {
fontSize: 12, fontSize: 12,
color: '#E53935', marginLeft: 4,
marginLeft: 16, },
modalOverlay: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
modalSheet: {
width: '100%',
maxWidth: 400,
borderRadius: 20,
overflow: 'hidden',
maxHeight: '80%',
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 20,
borderBottomWidth: 1,
},
modalHeaderRight: {
flexDirection: 'row',
alignItems: 'center',
gap: 16,
},
modalTitle: {
fontSize: 18,
fontWeight: '700',
},
clearText: {
fontSize: 14,
fontWeight: '500',
},
doneText: {
fontSize: 15,
fontWeight: '700',
},
modalHint: {
fontSize: 12,
paddingHorizontal: 4,
paddingBottom: 4,
},
modalContent: {
padding: 12,
gap: 8,
},
modalOption: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
paddingVertical: 14,
paddingHorizontal: 16,
borderRadius: 12,
borderWidth: 1,
},
categoryName: {
fontSize: 15,
fontWeight: '500',
flex: 1,
}, },
}); });
@@ -7,31 +7,43 @@ interface ColorPickerInputProps {
onChange: (color: string) => void; onChange: (color: string) => void;
} }
function normalizeHex(input: string): string { const HEX_PATTERN = /^[0-9a-fA-F]{6}$/;
const cleaned = input.replace(/[^0-9a-fA-F]/g, '').slice(0, 6);
return cleaned ? `#${cleaned}` : ''; function isValidHex(color: string): boolean {
return HEX_PATTERN.test(color);
} }
export default function NativeColorPickerInput({ value, onChange }: ColorPickerInputProps) { export default function NativeColorPickerInput({ value, onChange }: ColorPickerInputProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const hex = value.replace(/^#/, '').toUpperCase();
const valid = isValidHex(hex);
const handleChange = (raw: string) => {
const cleaned = raw.replace(/[^0-9a-fA-F]/g, '').slice(0, 6).toUpperCase();
onChange(`#${cleaned}`);
};
return ( return (
<View style={styles.row}> <View style={styles.row}>
<View style={[styles.preview, { backgroundColor: value }]} /> <View style={[
<TextInput styles.preview,
style={[ { backgroundColor: valid ? value : theme.accentSoft, borderColor: theme.accent },
styles.input, ]} />
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }, <View style={styles.inputWrap}>
]} <Text style={styles.hash}>#{/**/}</Text>
value={value} <TextInput
onChangeText={(text) => onChange(normalizeHex(text))} style={[styles.input, { color: theme.text }]}
placeholder="#E53935" value={hex}
placeholderTextColor={theme.textMuted} onChangeText={handleChange}
autoCapitalize="characters" placeholder="E53935"
autoCorrect={false} placeholderTextColor={theme.textMuted}
maxLength={7} autoCapitalize="characters"
/> autoCorrect={false}
<Text style={[styles.hint, { color: theme.textMuted }]}>Hex code</Text> maxLength={6}
/>
</View>
<Text style={styles.hint}>{/**/}Hex code</Text>
</View> </View>
); );
} }
@@ -47,16 +59,26 @@ const styles = StyleSheet.create({
height: 36, height: 36,
borderRadius: 18, borderRadius: 18,
borderWidth: 1, borderWidth: 1,
borderColor: '#E0E0E0',
}, },
input: { inputWrap: {
flex: 1, flex: 1,
height: 44, height: 44,
paddingHorizontal: 14, paddingHorizontal: 14,
borderRadius: 10, borderRadius: 10,
borderWidth: 1, borderWidth: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
hash: {
fontSize: 15,
fontWeight: '600',
},
input: {
flex: 1,
fontSize: 15, fontSize: 15,
fontWeight: '500', fontWeight: '500',
paddingVertical: 0,
}, },
hint: { hint: {
fontSize: 12, fontSize: 12,
@@ -7,26 +7,34 @@ interface ColorPickerInputProps {
onChange: (color: string) => void; onChange: (color: string) => void;
} }
const HEX_PATTERN = /^#[0-9a-fA-F]{6}$/;
export default function WebColorPickerInput({ value, onChange }: ColorPickerInputProps) { export default function WebColorPickerInput({ value, onChange }: ColorPickerInputProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const valid = HEX_PATTERN.test(value);
const normalized = valid ? value.toUpperCase() : '#000000';
return ( return (
<View style={styles.row}> <View style={styles.row}>
{React.createElement('input', { {React.createElement('input', {
type: 'color', type: 'color',
value: value.toUpperCase(), value: normalized,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value), onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
if (HEX_PATTERN.test(e.target.value)) {
onChange(e.target.value.toUpperCase());
}
},
style: { style: {
width: 44, width: 44,
height: 44, height: 44,
padding: 0, padding: 0,
border: `1px solid ${theme.borderStrong}`, border: `1px solid ${theme.accent}`,
borderRadius: 12, borderRadius: 12,
backgroundColor: 'transparent', backgroundColor: 'transparent',
cursor: 'pointer', cursor: 'pointer',
}, },
})} })}
<Text style={[styles.hexText, { color: theme.textSecondary }]}>{value.toUpperCase()}</Text> <Text style={[styles.hexText, { color: theme.accent }]}>{normalized}</Text>
</View> </View>
); );
} }
@@ -0,0 +1,218 @@
import React, { useState, useEffect, useMemo } from 'react';
import { View, Text as RNText, StyleSheet, PanResponder, Dimensions } from 'react-native';
import Svg, { Circle, Rect, Defs, LinearGradient, Stop } from 'react-native-svg';
import { useSettings } from '@/theme';
const DefsElement = Defs as unknown as React.ComponentType<{ children?: React.ReactNode }>;
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const WHEEL_SIZE = Math.min(SCREEN_WIDTH - 64, 280);
const THUMB_SIZE = 24;
function hexToHsv(hex: string) {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const delta = max - min;
let h = 0;
if (delta !== 0) {
if (max === r) h = ((g - b) / delta) % 6;
else if (max === g) h = (b - r) / delta + 2;
else h = (r - g) / delta + 4;
h = Math.round(h * 60);
if (h < 0) h += 360;
}
const s = max === 0 ? 0 : delta / max;
const v = max;
return { h, s, v };
}
function hsvToHex(h: number, s: number, v: number) {
const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c;
let r = 0, g = 0, b = 0;
if (h < 60) { r = c; g = x; b = 0; }
else if (h < 120) { r = x; g = c; b = 0; }
else if (h < 180) { r = 0; g = c; b = x; }
else if (h < 240) { r = 0; g = x; b = c; }
else if (h < 300) { r = x; g = 0; b = c; }
else { r = c; g = 0; b = x; }
const toHex = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
}
interface ColorWheelProps {
color: string;
onChange: (color: string) => void;
}
export default function ColorWheel({ color, onChange }: ColorWheelProps) {
const { theme } = useSettings();
const initialHsv = useMemo(() => hexToHsv(color), [color]);
const [hue, setHue] = useState(initialHsv.h);
const [saturation, setSaturation] = useState(initialHsv.s);
const [value, setValue] = useState(initialHsv.v);
const [prevColor, setPrevColor] = useState(color);
if (prevColor !== color) {
setPrevColor(color);
setHue(initialHsv.h);
setSaturation(initialHsv.s);
setValue(initialHsv.v);
}
useEffect(() => {
const newColor = hsvToHex(hue, saturation, value);
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,
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,
onPanResponderGrant: (event) => {
setHueFromLocation(event.nativeEvent.locationX);
},
onPanResponderMove: (event) => {
setHueFromLocation(event.nativeEvent.locationX);
},
}), []);
const thumbX = ((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.cos((hue - 180) * Math.PI / 180) + (WHEEL_SIZE / 2) - (THUMB_SIZE / 2);
const thumbY = ((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.sin((hue - 180) * Math.PI / 180) + (WHEEL_SIZE / 2) - (THUMB_SIZE / 2);
const hueThumbX = (hue / 360) * WHEEL_SIZE - (THUMB_SIZE / 2);
return (
<View style={styles.container}>
<View style={styles.wheelContainer}>
<Svg width={WHEEL_SIZE} height={WHEEL_SIZE}>
<DefsElement>
<LinearGradient id="satGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<Stop offset="0%" stopColor={`hsl(${hue}, 100%, 50%)`} stopOpacity={1} />
<Stop offset="100%" stopColor="#FFFFFF" stopOpacity={1} />
</LinearGradient>
<LinearGradient id="valGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<Stop offset="0%" stopColor="transparent" stopOpacity={0} />
<Stop offset="100%" stopColor="#000000" stopOpacity={1} />
</LinearGradient>
</DefsElement>
<Circle
cx={WHEEL_SIZE / 2}
cy={WHEEL_SIZE / 2}
r={(WHEEL_SIZE - THUMB_SIZE) / 2}
fill="url(#satGradient)"
/>
<Circle
cx={WHEEL_SIZE / 2}
cy={WHEEL_SIZE / 2}
r={(WHEEL_SIZE - THUMB_SIZE) / 2}
fill="url(#valGradient)"
/>
<Circle
cx={thumbX + THUMB_SIZE / 2}
cy={thumbY + THUMB_SIZE / 2}
r={THUMB_SIZE / 2}
fill="#FFFFFF"
stroke="#000000"
strokeWidth={2}
/>
</Svg>
<View {...wheelPanResponder.panHandlers} style={StyleSheet.absoluteFill} />
</View>
<View style={styles.hueContainer}>
<Svg width={WHEEL_SIZE} height={36}>
<DefsElement>
<LinearGradient id="hueGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<Stop offset="0%" stopColor="#FF0000" />
<Stop offset="17%" stopColor="#FFFF00" />
<Stop offset="33%" stopColor="#00FF00" />
<Stop offset="50%" stopColor="#00FFFF" />
<Stop offset="67%" stopColor="#0000FF" />
<Stop offset="83%" stopColor="#FF00FF" />
<Stop offset="100%" stopColor="#FF0000" />
</LinearGradient>
</DefsElement>
<Rect x={0} y={0} width={WHEEL_SIZE} height={36} rx={18} fill="url(#hueGradient)" />
<Circle
cx={hueThumbX + THUMB_SIZE / 2}
cy={18}
r={THUMB_SIZE / 2}
fill="#FFFFFF"
stroke="#000000"
strokeWidth={2}
/>
</Svg>
<View {...huePanResponder.panHandlers} style={StyleSheet.absoluteFill} />
</View>
<View style={styles.previewContainer}>
<View style={[styles.preview, { backgroundColor: hsvToHex(hue, saturation, value) }]} />
<RNText style={[styles.previewText, { color: theme.text }]}>{hsvToHex(hue, saturation, value)}</RNText>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
gap: 16,
},
wheelContainer: {
position: 'relative',
},
hueContainer: {
position: 'relative',
width: WHEEL_SIZE,
},
previewContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
preview: {
width: 44,
height: 44,
borderRadius: 12,
borderWidth: 1,
borderColor: '#00000020',
},
previewText: {
fontSize: 15,
fontWeight: '500',
fontFamily: 'monospace',
},
});
@@ -0,0 +1 @@
export { default } from './ColorWheel.native';
@@ -0,0 +1,212 @@
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';
const DefsElement = Defs as unknown as React.ComponentType<{ children?: React.ReactNode }>;
const WHEEL_SIZE = 280;
const THUMB_SIZE = 24;
function hexToHsv(hex: string) {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const delta = max - min;
let h = 0;
if (delta !== 0) {
if (max === r) h = ((g - b) / delta) % 6;
else if (max === g) h = (b - r) / delta + 2;
else h = (r - g) / delta + 4;
h = Math.round(h * 60);
if (h < 0) h += 360;
}
const s = max === 0 ? 0 : delta / max;
const v = max;
return { h, s, v };
}
function hsvToHex(h: number, s: number, v: number) {
const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c;
let r = 0, g = 0, b = 0;
if (h < 60) { r = c; g = x; b = 0; }
else if (h < 120) { r = x; g = c; b = 0; }
else if (h < 180) { r = 0; g = c; b = x; }
else if (h < 240) { r = 0; g = x; b = c; }
else if (h < 300) { r = x; g = 0; b = c; }
else { r = c; g = 0; b = x; }
const toHex = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
}
interface ColorWheelProps {
color: string;
onChange: (color: string) => void;
}
export default function ColorWheel({ color, onChange }: ColorWheelProps) {
const { theme } = useSettings();
const initialHsv = useMemo(() => hexToHsv(color), [color]);
const [hue, setHue] = useState(initialHsv.h);
const [saturation, setSaturation] = useState(initialHsv.s);
const [value, setValue] = useState(initialHsv.v);
const [prevColor, setPrevColor] = useState(color);
if (prevColor !== color) {
setPrevColor(color);
setHue(initialHsv.h);
setSaturation(initialHsv.s);
setValue(initialHsv.v);
}
useEffect(() => {
const newColor = hsvToHex(hue, saturation, value);
onChange(newColor);
}, [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) => {
const dx = moveEvent.clientX - rect.left - center;
const dy = moveEvent.clientY - rect.top - center;
const distance = Math.sqrt(dx * dx + dy * dy);
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 handleUp = () => {
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('mouseup', handleUp);
};
window.addEventListener('mousemove', handleMove);
window.addEventListener('mouseup', handleUp);
handleMove(e.nativeEvent);
};
const handleHueMouseDown = (e: React.MouseEvent) => {
const rect = e.currentTarget.getBoundingClientRect();
const handleMove = (moveEvent: MouseEvent) => {
const h = ((moveEvent.clientX - rect.left) / WHEEL_SIZE) * 360;
setHue(Math.min(360, Math.max(0, h)));
};
const handleUp = () => {
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('mouseup', handleUp);
};
window.addEventListener('mousemove', handleMove);
window.addEventListener('mouseup', handleUp);
handleMove(e.nativeEvent);
};
const thumbX = ((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.cos((hue - 180) * Math.PI / 180) + (WHEEL_SIZE / 2) - (THUMB_SIZE / 2);
const thumbY = ((WHEEL_SIZE - THUMB_SIZE) / 2) * saturation * Math.sin((hue - 180) * Math.PI / 180) + (WHEEL_SIZE / 2) - (THUMB_SIZE / 2);
const hueThumbX = (hue / 360) * WHEEL_SIZE - (THUMB_SIZE / 2);
const currentColor = hsvToHex(hue, saturation, value);
return (
<View style={styles.container}>
<View style={styles.wheelContainer}>
<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} />
<Stop offset="100%" stopColor="#FFFFFF" stopOpacity={1} />
</LinearGradient>
<LinearGradient id="valGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<Stop offset="0%" stopColor="transparent" stopOpacity={0} />
<Stop offset="100%" stopColor="#000000" stopOpacity={1} />
</LinearGradient>
</DefsElement>
<Circle
cx={WHEEL_SIZE / 2}
cy={WHEEL_SIZE / 2}
r={(WHEEL_SIZE - THUMB_SIZE) / 2}
fill="url(#satGradient)"
/>
<Circle
cx={WHEEL_SIZE / 2}
cy={WHEEL_SIZE / 2}
r={(WHEEL_SIZE - THUMB_SIZE) / 2}
fill="url(#valGradient)"
/>
<Circle
cx={thumbX + THUMB_SIZE / 2}
cy={thumbY + THUMB_SIZE / 2}
r={THUMB_SIZE / 2}
fill="#FFFFFF"
stroke="#000000"
strokeWidth={2}
/>
</Svg>
</View>
<View style={styles.hueContainer}>
<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" />
<Stop offset="17%" stopColor="#FFFF00" />
<Stop offset="33%" stopColor="#00FF00" />
<Stop offset="50%" stopColor="#00FFFF" />
<Stop offset="67%" stopColor="#0000FF" />
<Stop offset="83%" stopColor="#FF00FF" />
<Stop offset="100%" stopColor="#FF0000" />
</LinearGradient>
</DefsElement>
<Rect x={0} y={0} width={WHEEL_SIZE} height={36} rx={18} fill="url(#hueGradient)" />
<Circle
cx={hueThumbX + THUMB_SIZE / 2}
cy={18}
r={THUMB_SIZE / 2}
fill="#FFFFFF"
stroke="#000000"
strokeWidth={2}
/>
</Svg>
</View>
<View style={styles.previewContainer}>
<View style={[styles.preview, { backgroundColor: currentColor }]} />
<Text style={[styles.previewText, { color: theme.text }]}>{currentColor}</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
gap: 16,
},
wheelContainer: {},
hueContainer: {
width: WHEEL_SIZE,
},
previewContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
preview: {
width: 44,
height: 44,
borderRadius: 12,
borderWidth: 1,
borderColor: '#00000020',
},
previewText: {
fontSize: 15,
fontWeight: '500',
fontFamily: 'monospace',
},
});
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { View, StyleSheet } from 'react-native'; import { StyleSheet } from 'react-native';
import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker'; import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker';
interface NativeDateTimeInputProps { interface NativeDateTimeInputProps {
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Modal } from 'react-native'; import { View, Text, TouchableOpacity, StyleSheet, Modal } from 'react-native';
import { DateTimePickerEvent } from '@react-native-community/datetimepicker'; import { DateTimePickerEvent } from '@react-native-community/datetimepicker';
import { useSettings } from '@/theme';
interface WebDateTimeInputProps { interface WebDateTimeInputProps {
value: Date; value: Date;
@@ -32,11 +33,14 @@ export default function WebDateTimeInput({
onDismiss, onDismiss,
is24Hour, is24Hour,
}: WebDateTimeInputProps) { }: WebDateTimeInputProps) {
const { theme } = useSettings();
const [inputValue, setInputValue] = useState(() => dateInputValue(value)); const [inputValue, setInputValue] = useState(() => dateInputValue(value));
const [prevValue, setPrevValue] = useState(value);
useEffect(() => { if (value !== prevValue) {
setPrevValue(value);
setInputValue(dateInputValue(value)); setInputValue(dateInputValue(value));
}, [value]); }
const emit = (raw: string) => { const emit = (raw: string) => {
if (!raw) return; if (!raw) return;
@@ -55,11 +59,11 @@ export default function WebDateTimeInput({
onChange(event, date); onChange(event, date);
}; };
const bgColor = '#1E1E1E'; const bgColor = theme.sheetBg;
const textColor = '#FFFFFF'; const textColor = theme.text;
const borderColor = '#333333'; const borderColor = theme.borderStrong;
const overlayColor = 'rgba(0,0,0,0.6)'; const overlayColor = theme.overlay;
const mutedColor = '#9E9E9E'; const mutedColor = theme.textFaint;
return ( return (
<Modal visible={isVisible} transparent animationType="fade" onRequestClose={onDismiss}> <Modal visible={isVisible} transparent animationType="fade" onRequestClose={onDismiss}>
@@ -88,7 +92,7 @@ export default function WebDateTimeInput({
borderRadius: 10, borderRadius: 10,
border: `1px solid ${borderColor}`, border: `1px solid ${borderColor}`,
outline: 'none', outline: 'none',
backgroundColor: '#2C2C2C', backgroundColor: theme.inputBg,
color: textColor, color: textColor,
fontFamily: 'inherit', fontFamily: 'inherit',
marginVertical: 16, marginVertical: 16,
@@ -99,14 +103,14 @@ export default function WebDateTimeInput({
</View> </View>
<View style={styles.actions}> <View style={styles.actions}>
<TouchableOpacity <TouchableOpacity
style={[styles.clearButton, { borderColor, backgroundColor: '#2C2C2C' }]} style={[styles.clearButton, { borderColor, backgroundColor: theme.inputBg }]}
onPress={onDismiss} onPress={onDismiss}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.clearButtonText, { color: mutedColor }]}>Clear</Text> <Text style={[styles.clearButtonText, { color: mutedColor }]}>Clear</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={styles.doneButton} onPress={onDismiss} activeOpacity={0.8}> <TouchableOpacity style={[styles.doneButton, { backgroundColor: theme.accent }]} onPress={onDismiss} activeOpacity={0.8}>
<Text style={styles.doneButtonText}>Done</Text> <Text style={[styles.doneButtonText, { color: theme.accentText }]}>Done</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@@ -157,11 +161,9 @@ const styles = StyleSheet.create({
paddingVertical: 10, paddingVertical: 10,
paddingHorizontal: 16, paddingHorizontal: 16,
borderRadius: 10, borderRadius: 10,
backgroundColor: '#E53935',
}, },
doneButtonText: { doneButtonText: {
fontSize: 15, fontSize: 15,
fontWeight: '600', fontWeight: '600',
color: '#FFFFFF',
}, },
}); });
@@ -15,14 +15,12 @@ interface DateTimePickerComponentProps {
export function DateTimePickerComponent({ control }: DateTimePickerComponentProps) { export function DateTimePickerComponent({ control }: DateTimePickerComponentProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const [picker, setPicker] = React.useState<'date' | 'time' | 'endTime' | null>(null); const [picker, setPicker] = React.useState<'date' | 'time' | 'endTime' | null>(null);
const dateValueRef = useRef<Date | null>(null);
const timeValueRef = useRef<string>('');
const endTimeValueRef = useRef<string>('');
const dateRef = useRef<((value: Date | null) => void) | null>(null); const dateRef = useRef<((value: Date | null) => void) | null>(null);
const timeRef = useRef<((value: string) => void) | null>(null); const timeRef = useRef<((value: string) => void) | null>(null);
const endTimeRef = useRef<((value: string) => void) | null>(null); const endTimeRef = useRef<((value: string) => void) | null>(null);
const allDay = useWatch({ control, name: 'allDay' }) ?? false; const allDay = useWatch({ control, name: 'allDay' }) ?? false;
const dueDate = useWatch({ control, name: 'dueDate' }) ?? null;
const startTime = useWatch({ control, name: 'dueTime' }) ?? ''; const startTime = useWatch({ control, name: 'dueTime' }) ?? '';
const endTime = useWatch({ control, name: 'endTime' }) ?? ''; const endTime = useWatch({ control, name: 'endTime' }) ?? '';
@@ -37,7 +35,6 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
const renderTimeButton = ( const renderTimeButton = (
fieldName: 'dueTime' | 'endTime', fieldName: 'dueTime' | 'endTime',
ref: React.MutableRefObject<((value: string) => void) | null>, ref: React.MutableRefObject<((value: string) => void) | null>,
valueRef: React.MutableRefObject<string>,
placeholder: string, placeholder: string,
onPress: () => void onPress: () => void
) => ( ) => (
@@ -46,21 +43,20 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
name={fieldName} name={fieldName}
render={({ field }) => { render={({ field }) => {
ref.current = field.onChange; ref.current = field.onChange;
valueRef.current = field.value ?? '';
return ( return (
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.pickerButton, styles.pickerButton,
{ backgroundColor: theme.inputBg, borderColor: theme.borderStrong }, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong },
field.value && { borderColor: theme.accent, backgroundColor: theme.accentSoft }, 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} onPress={onPress}
activeOpacity={0.8} activeOpacity={0.8}
> >
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke={field.value ? theme.accent : theme.textMuted} strokeWidth={1.5} fill="none" /> <Circle cx={12} cy={12} r={10} stroke={field.value ? theme.accent : theme.textFaint} strokeWidth={1.8} fill="none" />
<Path d="M12 6v6l4 2" stroke={field.value ? theme.accent : theme.textMuted} strokeWidth={1.5} strokeLinecap="round" /> <Path d="M12 6v6l4 2" stroke={field.value ? theme.accent : theme.textFaint} strokeWidth={1.8} strokeLinecap="round" />
</Svg> </Svg>
<Text <Text
style={[ style={[
@@ -72,7 +68,7 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
> >
{field.value ? formatTime12(field.value) : placeholder} {field.value ? formatTime12(field.value) : placeholder}
</Text> </Text>
{field.value && ( {field.value ? (
<TouchableOpacity <TouchableOpacity
style={styles.clearButton} style={styles.clearButton}
onPress={() => { onPress={() => {
@@ -86,10 +82,10 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
> >
<Svg width={14} height={14} viewBox="0 0 24 24"> <Svg width={14} height={14} viewBox="0 0 24 24">
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" /> <Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
)} ) : null}
</TouchableOpacity> </TouchableOpacity>
); );
}} }}
@@ -105,7 +101,6 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
name="dueDate" name="dueDate"
render={({ field }) => { render={({ field }) => {
dateRef.current = field.onChange; dateRef.current = field.onChange;
dateValueRef.current = field.value;
return ( return (
<TouchableOpacity <TouchableOpacity
style={[ style={[
@@ -119,14 +114,14 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Path <Path
d="M8 2v4M16 2v4M3 10h18M3 18h18" d="M8 2v4M16 2v4M3 10h18M3 18h18"
stroke={field.value ? theme.accent : theme.textMuted} stroke={field.value ? theme.accent : theme.textFaint}
strokeWidth={1.5} strokeWidth={1.8}
strokeLinecap="round" strokeLinecap="round"
/> />
<Path <Path
d="M10 2H14a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z" d="M10 2H14a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z"
stroke={field.value ? theme.accent : theme.textMuted} stroke={field.value ? theme.accent : theme.textFaint}
strokeWidth={1.5} strokeWidth={1.8}
fill="none" fill="none"
/> />
</Svg> </Svg>
@@ -140,21 +135,43 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
> >
{field.value ? format(field.value, 'MMM d, yyyy') : 'Due Date'} {field.value ? format(field.value, 'MMM d, yyyy') : 'Due Date'}
</Text> </Text>
{field.value && ( {field.value ? (
<TouchableOpacity <TouchableOpacity
style={styles.clearButton} style={styles.clearButton}
onPress={() => setDateValue(null)} onPress={() => setDateValue(null)}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
> >
<Svg width={14} height={14} viewBox="0 0 24 24"> <Svg width={14} height={14} viewBox="0 0 24 24">
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" /> <Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
)} ) : null}
</TouchableOpacity> </TouchableOpacity>
); );
}} }}
/> />
<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> </View>
<Controller <Controller
control={control} control={control}
@@ -181,17 +198,17 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
{!allDay && ( {!allDay && (
<> <>
<View style={styles.timeRow}> <View style={styles.timeRow}>
{renderTimeButton('dueTime', timeRef, timeValueRef, 'Start Time', () => setPicker('time'))} {renderTimeButton('dueTime', timeRef, 'Start Time', () => setPicker('time'))}
{renderTimeButton('endTime', endTimeRef, endTimeValueRef, 'End Time', () => setPicker('endTime'))} {renderTimeButton('endTime', endTimeRef, 'End Time', () => setPicker('endTime'))}
</View> </View>
{endTimeInvalid && ( {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"> <Svg width={14} height={14} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke="#E53935" strokeWidth={1.5} fill="none" /> <Circle cx={12} cy={12} r={10} stroke={theme.dangerStrong} strokeWidth={1.8} fill="none" />
<Path d="M12 8v5M12 16.5v.5" stroke="#E53935" strokeWidth={1.8} strokeLinecap="round" /> <Path d="M12 8v5M12 16.5v.5" stroke={theme.dangerStrong} strokeWidth={1.8} strokeLinecap="round" />
</Svg> </Svg>
<Text style={[styles.warningText, { color: '#E53935' }]}> <Text style={[styles.warningText, { color: theme.dangerStrong }]}>
{endTime === startTime {endTime === startTime
? 'The end time is the same as the start time.' ? 'The end time is the same as the start time.'
: 'The end time is before the start time.'} : 'The end time is before the start time.'}
@@ -203,7 +220,7 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
<DateTimeInput <DateTimeInput
testID="date-picker" testID="date-picker"
value={dateValueRef.current ?? new Date()} value={dueDate ?? new Date()}
mode="date" mode="date"
is24Hour={false} is24Hour={false}
isVisible={picker === 'date'} isVisible={picker === 'date'}
@@ -220,8 +237,8 @@ export function DateTimePickerComponent({ control }: DateTimePickerComponentProp
title={picker === 'endTime' ? 'End Time' : 'Start Time'} title={picker === 'endTime' ? 'End Time' : 'Start Time'}
initialTime={ initialTime={
picker === 'endTime' picker === 'endTime'
? endTimeValueRef.current || timeValueRef.current ? endTime || startTime
: timeValueRef.current : startTime
} }
onConfirm={(time) => { onConfirm={(time) => {
if (picker === 'endTime') { if (picker === 'endTime') {
@@ -261,7 +278,7 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
paddingVertical: 12, paddingVertical: 14,
}, },
allDayLabel: { allDayLabel: {
fontSize: 15, fontSize: 15,
@@ -293,7 +310,7 @@ const styles = StyleSheet.create({
fontWeight: '500', fontWeight: '500',
}, },
clearButton: { clearButton: {
padding: 2, padding: 4,
}, },
warningRow: { warningRow: {
flexDirection: 'row', flexDirection: 'row',
@@ -304,6 +321,14 @@ const styles = StyleSheet.create({
borderRadius: 10, borderRadius: 10,
borderWidth: 1, borderWidth: 1,
}, },
calendarButton: {
width: 52,
height: 52,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
warningText: { warningText: {
fontSize: 13, fontSize: 13,
fontWeight: '500', fontWeight: '500',
@@ -1,7 +1,6 @@
import React from 'react'; import React, { useMemo } from 'react';
import { View, Text, StyleSheet, TextInput } from 'react-native'; import { View, Text, StyleSheet, TextInput , TextInputProps } from 'react-native';
import { TextInputProps } from 'react-native'; import { useSettings, type ThemeColors } from '@/theme';
import { useSettings } from '@/theme';
interface DescriptionInputProps extends TextInputProps { interface DescriptionInputProps extends TextInputProps {
error?: string; error?: string;
@@ -9,6 +8,7 @@ interface DescriptionInputProps extends TextInputProps {
export function DescriptionInput({ error, ...props }: DescriptionInputProps) { export function DescriptionInput({ error, ...props }: DescriptionInputProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const styles = useMemo(() => createStyles(theme), [theme]);
return ( return (
<View style={styles.container}> <View style={styles.container}>
@@ -36,33 +36,33 @@ export function DescriptionInput({ error, ...props }: DescriptionInputProps) {
); );
} }
const styles = StyleSheet.create({ const createStyles = (theme: ThemeColors) => StyleSheet.create({
container: { container: {
gap: 6, gap: 5,
}, },
label: { label: {
fontSize: 14, fontSize: 13,
fontWeight: '600', fontWeight: '600',
}, },
input: { input: {
minHeight: 100, minHeight: 88,
padding: 16, padding: 14,
borderRadius: 12, borderRadius: 10,
borderWidth: 1, borderWidth: 1,
fontSize: 15, fontSize: 14,
}, },
inputError: { inputError: {
borderColor: '#E53935', borderColor: theme.dangerStrong,
borderWidth: 1.5, borderWidth: 1.5,
}, },
charCount: { charCount: {
fontSize: 11, fontSize: 10,
textAlign: 'right', textAlign: 'right',
marginTop: -4, marginTop: -3,
}, },
errorText: { errorText: {
fontSize: 12, fontSize: 11,
color: '#E53935', color: theme.dangerStrong,
marginLeft: 4, marginLeft: 4,
}, },
}); });
@@ -1,10 +1,12 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { View, StyleSheet, TouchableOpacity, Animated, Easing } from 'react-native'; import { StyleSheet, TouchableOpacity, Animated, Easing, View, Text } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
import { useSettings } from '@/theme';
export function FloatingActionButton() { export function FloatingActionButton() {
const router = useRouter(); const router = useRouter();
const { theme } = useSettings();
const [scaleAnim] = React.useState(new Animated.Value(1)); const [scaleAnim] = React.useState(new Animated.Value(1));
const [rotateAnim] = React.useState(new Animated.Value(0)); const [rotateAnim] = React.useState(new Animated.Value(0));
const [rotation, setRotation] = useState(0); const [rotation, setRotation] = useState(0);
@@ -52,11 +54,14 @@ export function FloatingActionButton() {
]} ]}
> >
<TouchableOpacity <TouchableOpacity
style={styles.button} style={[styles.button, { backgroundColor: theme.accent, shadowColor: theme.accent }]}
onPress={handlePress} onPress={handlePress}
onPressIn={handlePressIn} onPressIn={handlePressIn}
onPressOut={handlePressOut} onPressOut={handlePressOut}
activeOpacity={1} activeOpacity={1}
accessibilityRole="button"
accessibilityLabel="Add new task"
hitSlop={12}
> >
<Animated.View <Animated.View
style={{ style={{
@@ -65,8 +70,8 @@ export function FloatingActionButton() {
> >
<Svg width={24} height={24} viewBox="0 0 24 24"> <Svg width={24} height={24} viewBox="0 0 24 24">
<Path <Path
d="M12 5v14M5 12h14" d="M12 19V5M12 5l-5 5M12 5l5 5"
stroke="#FFFFFF" stroke={theme.accentText}
strokeWidth={2.5} strokeWidth={2.5}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
@@ -74,6 +79,9 @@ export function FloatingActionButton() {
</Svg> </Svg>
</Animated.View> </Animated.View>
</TouchableOpacity> </TouchableOpacity>
<View style={styles.labelContainer}>
<Text style={[styles.label, { color: theme.accentText }]}>Add a Task</Text>
</View>
</Animated.View> </Animated.View>
); );
} }
@@ -83,18 +91,29 @@ const styles = StyleSheet.create({
position: 'absolute', position: 'absolute',
bottom: 24, bottom: 24,
right: 24, right: 24,
shadowColor: '#E53935',
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3, shadowOpacity: 0.3,
shadowRadius: 12, shadowRadius: 12,
elevation: 6, elevation: 6,
alignItems: 'flex-end',
}, },
button: { button: {
width: 56, width: 56,
height: 56, height: 56,
borderRadius: 16, borderRadius: 16,
backgroundColor: '#E53935',
alignItems: 'center', alignItems: 'center',
justifyContent: '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',
},
});
+22 -21
View File
@@ -3,6 +3,7 @@ import { View, StyleSheet, TouchableOpacity, Text } from 'react-native';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
interface FormButtonsProps { interface FormButtonsProps {
onSubmit: (data: any) => void; onSubmit: (data: any) => void;
@@ -13,6 +14,7 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
const router = useRouter(); const router = useRouter();
const { theme } = useSettings(); const { theme } = useSettings();
const { handleSubmit, formState: { isSubmitting } } = useFormContext(); const { handleSubmit, formState: { isSubmitting } } = useFormContext();
const insets = useSafeAreaInsets();
const cancel = () => { const cancel = () => {
if (router.canGoBack()) { if (router.canGoBack()) {
@@ -23,22 +25,28 @@ export function FormButtons({ onSubmit, submitLabel = 'Submit' }: FormButtonsPro
}; };
return ( return (
<View style={[styles.container, { backgroundColor: theme.background, borderTopColor: theme.border }]}> <View style={[styles.container, { backgroundColor: theme.background, borderTopColor: theme.border, paddingBottom: insets.bottom + 12 }]}>
<TouchableOpacity <TouchableOpacity
style={[styles.cancelButton, { borderColor: theme.borderStrong, backgroundColor: theme.card }]} style={[styles.cancelButton, { borderColor: theme.borderStrong, backgroundColor: theme.card }]}
onPress={cancel} onPress={cancel}
disabled={isSubmitting} disabled={isSubmitting}
activeOpacity={0.7} activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel="Cancel"
accessibilityState={{ disabled: isSubmitting }}
> >
<Text style={[styles.cancelButtonText, { color: theme.textSecondary }]}>Cancel</Text> <Text style={[styles.cancelButtonText, { color: theme.textSecondary }]}>Cancel</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.submitButton, isSubmitting && styles.buttonDisabled]} style={[styles.submitButton, { backgroundColor: theme.accent }, isSubmitting && styles.buttonDisabled]}
onPress={handleSubmit(onSubmit)} onPress={handleSubmit(onSubmit)}
disabled={isSubmitting} disabled={isSubmitting}
activeOpacity={0.8} activeOpacity={0.8}
accessibilityRole="button"
accessibilityLabel={isSubmitting ? 'Saving' : submitLabel}
accessibilityState={{ disabled: isSubmitting, busy: isSubmitting }}
> >
<Text style={styles.submitButtonText}> <Text style={[styles.submitButtonText, { color: theme.accentText }]}>
{isSubmitting ? 'Saving...' : submitLabel} {isSubmitting ? 'Saving...' : submitLabel}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -52,47 +60,40 @@ const styles = StyleSheet.create({
bottom: 0, bottom: 0,
left: 0, left: 0,
right: 0, right: 0,
paddingHorizontal: 16, paddingHorizontal: 12,
paddingVertical: 16, paddingVertical: 12,
borderTopWidth: 1, borderTopWidth: 1,
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
gap: 12, gap: 10,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: -2 }, shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.05, shadowOpacity: 0.05,
shadowRadius: 8, shadowRadius: 6,
elevation: 4, elevation: 3,
}, },
cancelButton: { cancelButton: {
flex: 1, flex: 1,
paddingVertical: 14, paddingVertical: 12,
borderRadius: 12, borderRadius: 10,
borderWidth: 1, borderWidth: 1,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
cancelButtonText: { cancelButtonText: {
fontSize: 16, fontSize: 15,
fontWeight: '600', fontWeight: '600',
}, },
submitButton: { submitButton: {
flex: 1, flex: 1,
paddingVertical: 14, paddingVertical: 12,
borderRadius: 12, borderRadius: 10,
backgroundColor: '#E53935',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
shadowColor: '#E53935',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 3,
}, },
submitButtonText: { submitButtonText: {
fontSize: 16, fontSize: 15,
fontWeight: '600', fontWeight: '600',
color: '#FFFFFF',
}, },
buttonDisabled: { buttonDisabled: {
opacity: 0.6, opacity: 0.6,
+27 -33
View File
@@ -9,6 +9,7 @@ import {
FlatList, FlatList,
ActivityIndicator, ActivityIndicator,
Alert, Alert,
KeyboardAvoidingView,
} from 'react-native'; } from 'react-native';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { useFriends } from '@/hooks/useFriends'; import { useFriends } from '@/hooks/useFriends';
@@ -22,10 +23,18 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const { friends, incoming, outgoing, loading, sendRequest, acceptRequest, declineRequest, removeFriend, searchUsers } = useFriends(); const { friends, incoming, outgoing, loading, sendRequest, acceptRequest, declineRequest, removeFriend, searchUsers } = useFriends();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<Array<{ id: string; username: string }>>([]); const [searchResults, setSearchResults] = useState<{ id: string; username: string }[]>([]);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [selectedTab, setSelectedTab] = useState<'friends' | 'incoming' | 'outgoing' | 'add'>('friends'); const [selectedTab, setSelectedTab] = useState<'friends' | 'incoming' | 'outgoing' | 'add'>('friends');
const searchKey = `${selectedTab}:${searchQuery}`;
const [prevSearchKey, setPrevSearchKey] = useState(searchKey);
if (prevSearchKey !== searchKey && (selectedTab !== 'add' || searchQuery.length < 2)) {
setPrevSearchKey(searchKey);
setSearchResults([]);
}
useEffect(() => { useEffect(() => {
if (selectedTab === 'add' && searchQuery.length >= 2) { if (selectedTab === 'add' && searchQuery.length >= 2) {
const timeout = setTimeout(async () => { const timeout = setTimeout(async () => {
@@ -40,23 +49,9 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
} }
}, 300); }, 300);
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
} else {
setSearchResults([]);
} }
}, [searchQuery, selectedTab, friends, outgoing, searchUsers]); }, [searchQuery, selectedTab, friends, outgoing, searchUsers]);
const handleAddFriend = async () => {
if (!searchQuery.trim()) return;
try {
await sendRequest(searchQuery.trim());
setSearchQuery('');
setSearchResults([]);
setSelectedTab('friends');
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to send request');
}
};
const handleAccept = async (requestId: string) => { const handleAccept = async (requestId: string) => {
try { try {
await acceptRequest(requestId); await acceptRequest(requestId);
@@ -90,7 +85,10 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
return ( return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}> <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={[styles.overlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}> <KeyboardAvoidingView
style={[styles.overlay, { backgroundColor: 'rgba(0,0,0,0.5)' }]}
behavior="padding"
>
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}> <View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={styles.header}> <View style={styles.header}>
<Text style={[styles.title, { color: theme.text }]}>Friends</Text> <Text style={[styles.title, { color: theme.text }]}>Friends</Text>
@@ -113,15 +111,15 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
<Text <Text
style={[ style={[
styles.tabText, styles.tabText,
{ color: selectedTab === tab ? '#FFFFFF' : theme.textMuted }, { color: selectedTab === tab ? theme.accentText : theme.textMuted },
]} ]}
> >
{tab.charAt(0).toUpperCase() + tab.slice(1)} {tab.charAt(0).toUpperCase() + tab.slice(1)}
{tab === 'friends' && friends.length > 0 && ( {tab === 'friends' && friends.length > 0 && (
<Text style={[styles.badge, { color: '#FFFFFF' }]}>{friends.length}</Text> <Text style={[styles.badge, { color: theme.accentText }]}>{friends.length}</Text>
)} )}
{tab === 'incoming' && incoming.length > 0 && ( {tab === 'incoming' && incoming.length > 0 && (
<Text style={[styles.badge, { color: '#FFFFFF' }]}>{incoming.length}</Text> <Text style={[styles.badge, { color: theme.accentText }]}>{incoming.length}</Text>
)} )}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -137,7 +135,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
) : friends.length === 0 ? ( ) : friends.length === 0 ? (
<View style={styles.empty}> <View style={styles.empty}>
<Text style={[styles.emptyText, { color: theme.textMuted }]}>No friends yet</Text> <Text style={[styles.emptyText, { color: theme.textMuted }]}>No friends yet</Text>
<Text style={[styles.emptyHint, { color: theme.textFaint }]}>Tap "Add" to find friends by username</Text> <Text style={[styles.emptyHint, { color: theme.textFaint }]}>{'Tap "Add" to find friends by username'}</Text>
</View> </View>
) : ( ) : (
<FlatList <FlatList
@@ -150,7 +148,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
style={[styles.removeBtn, { backgroundColor: theme.accentSoft, borderColor: theme.accent }]} style={[styles.removeBtn, { backgroundColor: theme.accentSoft, borderColor: theme.accent }]}
onPress={() => handleRemove(item.id)} onPress={() => handleRemove(item.id)}
> >
<Text style={[styles.removeBtnText, { color: theme.accent }]}>Remove</Text> <Text style={[styles.removeBtnText, { color: theme.accentStrong }]}>Remove</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)} )}
@@ -181,7 +179,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
style={[styles.actionBtn, { backgroundColor: theme.accent }]} style={[styles.actionBtn, { backgroundColor: theme.accent }]}
onPress={() => handleAccept(item.requestId)} onPress={() => handleAccept(item.requestId)}
> >
<Text style={styles.actionBtnText}>Accept</Text> <Text style={[styles.actionBtnText, { color: theme.accentText }]}>Accept</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.actionBtn, { backgroundColor: 'transparent', borderColor: theme.borderStrong, borderWidth: 1 }]} style={[styles.actionBtn, { backgroundColor: 'transparent', borderColor: theme.borderStrong, borderWidth: 1 }]}
@@ -258,18 +256,16 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
<Text style={[styles.friendName, { color: theme.text }]}>{item.username}</Text> <Text style={[styles.friendName, { color: theme.text }]}>{item.username}</Text>
<TouchableOpacity <TouchableOpacity
style={[styles.addBtn, { backgroundColor: theme.accent }]} style={[styles.addBtn, { backgroundColor: theme.accent }]}
onPress={async () => { onPress={() => sendRequest(item.username)
try { .then(() => {
await sendRequest(item.username);
setSearchQuery(''); setSearchQuery('');
setSearchResults([]); setSearchResults([]);
setSelectedTab('friends'); setSelectedTab('friends');
} catch (err) { })
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to send request'); .catch(err => Alert.alert('Error', err instanceof Error ? err.message : 'Failed to send request'))
} }
}}
> >
<Text style={styles.addBtnText}>Add</Text> <Text style={[styles.addBtnText, { color: theme.accentText }]}>Add</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)} )}
@@ -282,7 +278,7 @@ export function FriendsModal({ visible, onClose }: FriendsModalProps) {
</View> </View>
)} )}
</View> </View>
</View> </KeyboardAvoidingView>
</Modal> </Modal>
); );
} }
@@ -403,7 +399,6 @@ const styles = StyleSheet.create({
addBtnText: { addBtnText: {
fontSize: 13, fontSize: 13,
fontWeight: '600', fontWeight: '600',
color: '#FFFFFF',
}, },
requestActions: { requestActions: {
flexDirection: 'row', flexDirection: 'row',
@@ -417,7 +412,6 @@ const styles = StyleSheet.create({
actionBtnText: { actionBtnText: {
fontSize: 13, fontSize: 13,
fontWeight: '600', fontWeight: '600',
color: '#FFFFFF',
}, },
cancelBtn: { cancelBtn: {
paddingVertical: 6, paddingVertical: 6,
+21 -31
View File
@@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import { View, Text, StyleSheet } from 'react-native'; import { View, Text, StyleSheet, StatusBar, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
interface HeaderProps { interface HeaderProps {
@@ -11,70 +10,61 @@ interface HeaderProps {
export function Header({ title, showLogo, rightAction }: HeaderProps) { export function Header({ title, showLogo, rightAction }: HeaderProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const topInset = Platform.OS === 'android' ? (StatusBar.currentHeight ?? 24) : 0;
return ( return (
<SafeAreaView style={[styles.header, { backgroundColor: theme.background }]}> <View style={[styles.header, { backgroundColor: theme.background, paddingTop: topInset }]}>
<View style={styles.headerContent}> <View style={styles.headerContent}>
{showLogo && ( {showLogo && (
<View style={styles.logoContainer}> <View style={[styles.logoContainer, { backgroundColor: theme.accent }]}>
<Text style={styles.logoText}>✓</Text> <Text style={[styles.logoText, { color: theme.accentText }]}>✓</Text>
</View> </View>
)} )}
<Text style={[styles.title, { color: theme.text }]}>{title}</Text> <Text style={[styles.title, { color: theme.text }]} accessibilityRole="header">{title}</Text>
<View style={styles.spacer}>{rightAction}</View> <View style={styles.spacer}>{rightAction}</View>
</View> </View>
<View style={[styles.bottomRounded, { backgroundColor: theme.background }]} /> </View>
</SafeAreaView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
header: { header: {
borderBottomLeftRadius: 24, borderBottomLeftRadius: 16,
borderBottomRightRadius: 24, borderBottomRightRadius: 16,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05, shadowOpacity: 0.04,
shadowRadius: 8, shadowRadius: 4,
elevation: 2, elevation: 1,
}, },
headerContent: { headerContent: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
paddingHorizontal: 20, paddingHorizontal: 20,
paddingTop: 8, height: 48,
paddingBottom: 16,
height: 80,
}, },
logoContainer: { logoContainer: {
width: 36, width: 32,
height: 36, height: 32,
borderRadius: 10, borderRadius: 10,
backgroundColor: '#E53935',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
logoText: { logoText: {
color: '#FFFFFF', fontSize: 18,
fontSize: 20,
fontWeight: '700', fontWeight: '700',
}, },
title: { title: {
fontSize: 20, fontSize: 20,
fontWeight: '700', fontWeight: '700',
position: 'absolute', position: 'absolute',
left: '50%', left: 0,
marginLeft: -30, right: 0,
textAlign: 'center',
}, },
spacer: { spacer: {
width: 36, width: 32,
alignItems: 'flex-end', alignItems: 'flex-end',
}, },
bottomRounded: {
height: 24,
borderBottomLeftRadius: 24,
borderBottomRightRadius: 24,
marginTop: -24,
},
}); });
@@ -123,12 +123,12 @@ For questions about these Terms, contact us through the app's feedback channel.
`; `;
export function LegalModal({ visible, type, onClose }: LegalModalProps) { export function LegalModal({ visible, type, onClose }: LegalModalProps) {
if (!visible || !type) return null;
const { theme } = useSettings(); const { theme } = useSettings();
const content = type === 'privacy' ? PRIVACY_POLICY : TERMS_OF_SERVICE; const content = type === 'privacy' ? PRIVACY_POLICY : TERMS_OF_SERVICE;
const title = type === 'privacy' ? 'Privacy Policy' : 'Terms of Service'; const title = type === 'privacy' ? 'Privacy Policy' : 'Terms of Service';
if (!visible || !type) return null;
return ( return (
<SafeAreaView style={[styles.overlay, { backgroundColor: theme.overlay }]}> <SafeAreaView style={[styles.overlay, { backgroundColor: theme.overlay }]}>
<View style={[styles.modal, { backgroundColor: theme.card }]}> <View style={[styles.modal, { backgroundColor: theme.card }]}>
@@ -136,7 +136,7 @@ export function LegalModal({ visible, type, onClose }: LegalModalProps) {
<Text style={[styles.title, { color: theme.text }]}>{title}</Text> <Text style={[styles.title, { color: theme.text }]}>{title}</Text>
<TouchableOpacity onPress={onClose} style={styles.closeButton} activeOpacity={0.7}> <TouchableOpacity onPress={onClose} style={styles.closeButton} activeOpacity={0.7}>
<Svg width={24} height={24} viewBox="0 0 24 24"> <Svg width={24} height={24} viewBox="0 0 24 24">
<Path d="M18 6L6 18M6 6l12 12" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" /> <Path d="M18 6L6 18M6 6l12 12" stroke={theme.textSecondary} strokeWidth={2.2} strokeLinecap="round" />
</Svg> </Svg>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -192,6 +192,5 @@ const styles = StyleSheet.create({
body: { body: {
fontSize: 14, fontSize: 14,
lineHeight: 22, lineHeight: 22,
whiteSpace: 'pre-wrap' as const,
}, },
}); });
+6 -2
View File
@@ -26,6 +26,10 @@ export function ListItem({ title, subtitle, leftElement, rightElement, onPress,
onPress={onPress} onPress={onPress}
activeOpacity={0.7} activeOpacity={0.7}
disabled={!isInteractive} disabled={!isInteractive}
accessibilityRole={isInteractive ? 'button' : 'none'}
accessibilityLabel={title}
accessibilityHint={subtitle ? subtitle : undefined}
accessibilityState={{ disabled: !isInteractive }}
> >
{leftElement && <View style={styles.leftElement}>{leftElement}</View>} {leftElement && <View style={styles.leftElement}>{leftElement}</View>}
<View style={styles.leftContent}> <View style={styles.leftContent}>
@@ -38,8 +42,8 @@ export function ListItem({ title, subtitle, leftElement, rightElement, onPress,
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Path <Path
d="M9 18l6-6-6-6" d="M9 18l6-6-6-6"
stroke={theme.textMuted} stroke={theme.textSecondary}
strokeWidth={2} strokeWidth={2.5}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
fill="none" fill="none"
@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { View, Text, StyleSheet, Modal, TouchableOpacity, FlatList } from 'react-native'; import { View, Text, StyleSheet, Modal, TouchableOpacity, FlatList } from 'react-native';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path, Circle } from 'react-native-svg';
export interface PickerOption { export interface PickerOption {
value: string; value: string;
@@ -13,14 +13,17 @@ interface OptionPickerModalProps {
visible: boolean; visible: boolean;
title: string; title: string;
options: PickerOption[]; options: PickerOption[];
selectedValue?: string; selectedValue?: string | string[];
onSelect: (value: string) => void; onSelect: (value: string | string[]) => void;
onClose: () => void; onClose: () => void;
multiSelect?: boolean;
} }
export function OptionPickerModal({ visible, title, options, selectedValue, onSelect, onClose }: OptionPickerModalProps) { export function OptionPickerModal({ visible, title, options, selectedValue, onSelect, onClose, multiSelect = false }: OptionPickerModalProps) {
const { theme } = useSettings(); const { theme } = useSettings();
const selectedValues = Array.isArray(selectedValue) ? selectedValue : selectedValue == null ? [] : [selectedValue];
return ( return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}> <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={[styles.overlay, { backgroundColor: theme.overlay }]}> <View style={[styles.overlay, { backgroundColor: theme.overlay }]}>
@@ -30,7 +33,7 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
data={options} data={options}
keyExtractor={(item) => item.value} keyExtractor={(item) => item.value}
renderItem={({ item }) => { renderItem={({ item }) => {
const selected = item.value === selectedValue; const selected = selectedValues.includes(item.value);
return ( return (
<TouchableOpacity <TouchableOpacity
style={[ style={[
@@ -39,10 +42,20 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
selected && { backgroundColor: theme.accentSoft }, selected && { backgroundColor: theme.accentSoft },
]} ]}
onPress={() => { onPress={() => {
onSelect(item.value); if (multiSelect) {
onClose(); const newValues = selected
? selectedValues.filter((v) => v !== item.value)
: [...selectedValues, item.value];
onSelect(newValues);
} else {
onSelect(item.value);
onClose();
}
}} }}
activeOpacity={0.7} activeOpacity={0.7}
accessibilityRole={multiSelect ? 'checkbox' : 'button'}
accessibilityLabel={item.label}
accessibilityState={multiSelect ? { checked: selected } : { selected }}
> >
{item.color && ( {item.color && (
<View style={[styles.dot, { backgroundColor: item.color }]} /> <View style={[styles.dot, { backgroundColor: item.color }]} />
@@ -50,7 +63,14 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
<Text style={[styles.optionText, { color: selected ? theme.accent : theme.textSecondary }]}> <Text style={[styles.optionText, { color: selected ? theme.accent : theme.textSecondary }]}>
{item.label} {item.label}
</Text> </Text>
{selected && ( {multiSelect ? (
<Svg width={22} height={22} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} stroke={selected ? theme.accent : theme.borderStrong} strokeWidth={2} fill={selected ? theme.accent : 'transparent'} />
{selected && (
<Path d="M8 12l3 3 6-6" stroke="#FFFFFF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
)}
</Svg>
) : selected && (
<Svg width={18} height={18} viewBox="0 0 24 24"> <Svg width={18} height={18} viewBox="0 0 24 24">
<Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" /> <Path d="M5 12l5 5 9-10" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg> </Svg>
@@ -64,8 +84,10 @@ export function OptionPickerModal({ visible, title, options, selectedValue, onSe
style={[styles.cancelButton, { borderColor: theme.borderStrong }]} style={[styles.cancelButton, { borderColor: theme.borderStrong }]}
onPress={onClose} onPress={onClose}
activeOpacity={0.7} activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel="Cancel"
> >
<Text style={[styles.cancelText, { color: theme.textFaint }]}>Cancel</Text> <Text style={[styles.cancelText, { color: theme.textSecondary }]}>Cancel</Text>
</TouchableOpacity> </TouchableOpacity>
} }
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
@@ -3,14 +3,13 @@ import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { PRIORITY_COLORS } from '@/constants'; import { PRIORITY_COLORS } from '@/constants';
import { Priority } from '@/types'; import { Priority } from '@/types';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import Svg, { Circle } from 'react-native-svg';
interface PrioritySelectorProps { interface PrioritySelectorProps {
value: Priority; value: Priority;
onChange: (value: Priority) => void; onChange: (value: Priority) => void;
} }
const priorities: Array<{ value: Priority; label: string }> = [ const priorities: { value: Priority; label: string }[] = [
{ value: 'none', label: 'None' }, { value: 'none', label: 'None' },
{ value: 'low', label: 'Low' }, { value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' }, { value: 'medium', label: 'Medium' },
@@ -24,7 +23,7 @@ export function PrioritySelector({ value, onChange }: PrioritySelectorProps) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text style={[styles.label, { color: theme.text }]}>Priority</Text> <Text style={[styles.label, { color: theme.text }]}>Priority</Text>
<View style={styles.options}> <View style={styles.options} accessibilityRole="radiogroup" accessibilityLabel="Priority">
{priorities.map((priority) => ( {priorities.map((priority) => (
<TouchableOpacity <TouchableOpacity
key={priority.value} key={priority.value}
@@ -35,6 +34,9 @@ export function PrioritySelector({ value, onChange }: PrioritySelectorProps) {
]} ]}
onPress={() => onChange(priority.value)} onPress={() => onChange(priority.value)}
activeOpacity={0.8} activeOpacity={0.8}
accessibilityRole="radio"
accessibilityLabel={`${priority.label} priority`}
accessibilityState={{ selected: value === priority.value }}
> >
<View style={[ <View style={[
styles.colorIndicator, styles.colorIndicator,
@@ -44,7 +46,7 @@ export function PrioritySelector({ value, onChange }: PrioritySelectorProps) {
<Text style={[ <Text style={[
styles.optionText, styles.optionText,
{ color: theme.textFaint }, { color: theme.textFaint },
value === priority.value && { color: theme.accent, fontWeight: '600' }, value === priority.value && { color: theme.accentStrong, fontWeight: '600' },
]}> ]}>
{priority.label} {priority.label}
</Text> </Text>
@@ -57,15 +59,15 @@ export function PrioritySelector({ value, onChange }: PrioritySelectorProps) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
gap: 10, gap: 6,
}, },
label: { label: {
fontSize: 14, fontSize: 13,
fontWeight: '600', fontWeight: '600',
}, },
options: { options: {
flexDirection: 'row', flexDirection: 'row',
gap: 8, gap: 6,
}, },
option: { option: {
flex: 1, flex: 1,
@@ -73,16 +75,17 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: 6, gap: 6,
paddingVertical: 12, paddingVertical: 10,
paddingHorizontal: 16, paddingHorizontal: 8,
borderRadius: 12, borderRadius: 10,
borderWidth: 1, borderWidth: 1,
minHeight: 40,
}, },
colorIndicator: { colorIndicator: {
width: 10, width: 10,
height: 10, height: 10,
borderRadius: 5, borderRadius: 5,
opacity: 0.5, opacity: 0.6,
}, },
colorIndicatorSelected: { colorIndicatorSelected: {
opacity: 1, opacity: 1,
+126 -79
View File
@@ -1,42 +1,67 @@
import React, { useEffect, useState } from 'react'; import React, { useState, useEffect, useMemo, useRef } from 'react';
import { View, Text, StyleSheet, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform } from 'react-native'; import { View, StyleSheet, TextInput, TouchableOpacity, Keyboard, Text, Platform } from 'react-native';
import { BlurView } from 'expo-blur';
import { database, collections } from '@/database'; import { database, collections } from '@/database';
import { useCategories } from '@/hooks/useDatabase'; import { useCategories } from '@/hooks/useDatabase';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { OptionPickerModal } from '@/components/OptionPickerModal'; import { OptionPickerModal } from '@/components/OptionPickerModal';
import { tagsToString } from '@/types';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
import { subscribeToQuickAdd } from '@/utils/quickAddFocus';
interface QuickAddBarProps { interface QuickAddBarProps {
dueDate?: number; dueDate?: number;
placeholder?: string; 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) { export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
const { theme, defaultCategoryId } = useSettings(); const { theme, defaultCategoryId } = useSettings();
const categories = useCategories(); const categories = useCategories();
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [categoryId, setCategoryId] = useState(defaultCategoryId || categories[0]?.id || ''); const [categoryId, setCategoryId] = useState(() => defaultCategoryId || '');
const [categoryPickerVisible, setCategoryPickerVisible] = useState(false); const [categoryPickerVisible, setCategoryPickerVisible] = useState(false);
const inputRef = useRef<TextInput>(null);
const categoryColor = categories.find((c) => c.id === categoryId)?.color ?? '#9E9E9E'; const visibleCategories = useMemo(
() => categories.filter((c) => c.name.toLowerCase() !== 'calendar'),
[categories]
);
const categoryColor = visibleCategories.find((c) => c.id === categoryId)?.color ?? theme.textMuted;
useEffect(() => { useEffect(() => {
if (!categoryId) { return subscribeToQuickAdd(() => {
setCategoryId(defaultCategoryId || categories[0]?.id || ''); setTimeout(() => {
} inputRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps }, 100);
}, [categories, defaultCategoryId]); });
}, []);
const handleAdd = async () => { const handleAdd = async () => {
const trimmed = title.trim(); const trimmed = title.trim();
if (!trimmed || !categoryId) return; if (!trimmed) return;
const now = new Date(); const now = new Date();
await database.write(async () => { await database.write(async () => {
await collections.tasks.create((t) => { await collections.tasks.create((t) => {
t.title = trimmed; t.title = trimmed;
t.description = ''; t.description = '';
t.categoryId = categoryId; t.categoryId = categoryId || '';
t.tags = tagsToString(categoryId ? [categoryId] : []);
t.priority = 'none'; t.priority = 'none';
t.completed = false; t.completed = false;
t.dueDate = dueDate; t.dueDate = dueDate;
@@ -52,112 +77,134 @@ export function QuickAddBar({ dueDate = 0, placeholder }: QuickAddBarProps) {
}); });
}); });
setTitle(''); setTitle('');
Keyboard.dismiss();
}; };
return ( return (
<KeyboardAvoidingView <View style={styles.wrapper}>
behavior={Platform.OS === 'ios' ? 'padding' : undefined} <BlurContainer theme={theme} style={styles.blurContainer}>
style={styles.wrapper} <View style={[styles.bar, { backgroundColor: 'rgba(255,255,255,0.05)', borderColor: 'rgba(255,255,255,0.1)' }]}>
> <TouchableOpacity
<View style={[styles.bar, { backgroundColor: theme.card, borderColor: theme.border }]}> style={[styles.categoryButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]}
<TouchableOpacity onPress={() => setCategoryPickerVisible(true)}
style={[styles.categoryButton, { borderColor: theme.borderStrong, backgroundColor: theme.cardAlt }]} activeOpacity={0.7}
onPress={() => setCategoryPickerVisible(true)} accessibilityLabel="Select tags"
activeOpacity={0.7} >
accessibilityLabel="Select category" <View style={[styles.categoryButtonDot, { backgroundColor: categoryColor }]} />
> <Svg width={12} height={12} viewBox="0 0 24 24">
<View style={[styles.categoryButtonDot, { backgroundColor: categoryColor }]} /> <Path d="M6 9l6 6 6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
<Svg width={12} height={12} viewBox="0 0 24 24"> </Svg>
<Path d="M6 9l6 6 6-6" stroke={theme.textFaint} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" /> </TouchableOpacity>
</Svg> <TextInput
</TouchableOpacity> ref={inputRef}
<TextInput style={[styles.input, { color: theme.text }]}
style={[styles.input, { color: theme.text }]} placeholder={placeholder ?? 'Add a task'}
placeholder={placeholder ?? 'Add a task'} placeholderTextColor={theme.textMuted}
placeholderTextColor={theme.textMuted} value={title}
value={title} onChangeText={setTitle}
onChangeText={setTitle} onSubmitEditing={handleAdd}
onSubmitEditing={handleAdd} returnKeyType="done"
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]} <TouchableOpacity
onPress={handleAdd} style={[styles.submit, { backgroundColor: theme.accent }, title.trim() ? {} : styles.submitDisabled]}
disabled={!title.trim()} onPress={title.trim() ? handleAdd : undefined}
activeOpacity={0.8} disabled={!title.trim()}
accessibilityLabel="Add task" activeOpacity={0.8}
> accessibilityLabel="Add task"
<Svg width={20} height={20} viewBox="0 0 24 24"> accessibilityHint="Adds the entered task to the list"
<Path accessibilityState={{ disabled: !title.trim() }}
d="M12 5v14M5 12h14" >
stroke="#FFFFFF" <Svg width={20} height={20} viewBox="0 0 24 24">
strokeWidth={2.5} <Path
strokeLinecap="round" d="M12 19V5M12 5l-5 5M12 5l5 5"
strokeLinejoin="round" stroke={theme.accentText}
/> strokeWidth={2.5}
</Svg> strokeLinecap="round"
</TouchableOpacity> strokeLinejoin="round"
</View> />
</Svg>
</TouchableOpacity>
</View>
</BlurContainer>
<OptionPickerModal <OptionPickerModal
visible={categoryPickerVisible} visible={categoryPickerVisible}
title="Select Category" title="Select Tags"
options={categories.map((c) => ({ value: c.id, label: c.name, color: c.color }))} options={[{ value: '', label: 'None', color: theme.textMuted }, ...visibleCategories.map((c) => ({ value: c.id, label: c.name, color: c.color }))]}
selectedValue={categoryId} selectedValue={categoryId}
onSelect={(value) => setCategoryId(value)} onSelect={(value) => setCategoryId(Array.isArray(value) ? value[0] : value)}
onClose={() => setCategoryPickerVisible(false)} onClose={() => setCategoryPickerVisible(false)}
/> />
</KeyboardAvoidingView> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
wrapper: { wrapper: {
position: 'absolute', paddingHorizontal: 8,
left: 16, paddingTop: 4,
right: 16, paddingBottom: 8,
bottom: 24, },
blurContainer: {
borderRadius: 14,
overflow: 'hidden',
}, },
bar: { bar: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 10, gap: 6,
paddingHorizontal: 10, paddingHorizontal: 8,
paddingVertical: 10, paddingVertical: 8,
borderRadius: 16, borderRadius: 14,
borderWidth: 1, borderWidth: 1,
borderBottomWidth: 1,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.12, shadowOpacity: 0.10,
shadowRadius: 12, shadowRadius: 6,
elevation: 8, elevation: 6,
minHeight: 56,
}, },
categoryButton: { categoryButton: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 6, gap: 3,
paddingVertical: 10, paddingVertical: 6,
paddingHorizontal: 12, paddingHorizontal: 6,
borderRadius: 12, borderRadius: 10,
borderWidth: 1, borderWidth: 1,
height: 40,
}, },
categoryButtonDot: { categoryButtonDot: {
width: 12, width: 8,
height: 12, height: 8,
borderRadius: 6, borderRadius: 4,
}, },
input: { input: {
flex: 1, flex: 1,
fontSize: 15, fontSize: 15,
paddingVertical: 10, paddingVertical: 10,
minHeight: 40,
}, },
submit: { submit: {
width: 40, minWidth: 88,
height: 40, height: 40,
borderRadius: 12, paddingHorizontal: 12,
borderRadius: 10,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
submitLabelRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
submitText: {
fontSize: 13,
fontWeight: '700',
},
submitDisabled: { submitDisabled: {
opacity: 0.5, opacity: 0.5,
}, },
@@ -1,13 +1,13 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { Reminder, REMINDER_OPTIONS } from '@/types'; import { Reminder, REMINDER_OPTIONS, parseReminders, toRemindersString } from '@/types';
import { useSettings } from '@/theme'; import { useSettings } from '@/theme';
import { OptionPickerModal } from '@/components/OptionPickerModal'; import { OptionPickerModal } from '@/components/OptionPickerModal';
import Svg, { Path, Circle } from 'react-native-svg'; import Svg, { Path, Circle, Rect } from 'react-native-svg';
interface ReminderSelectorProps { interface ReminderSelectorProps {
value: Reminder; value: string;
onChange: (value: Reminder) => void; onChange: (value: string) => void;
hasDueDate: boolean; hasDueDate: boolean;
} }
@@ -15,60 +15,69 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
const { theme } = useSettings(); const { theme } = useSettings();
const [showPicker, setShowPicker] = useState(false); const [showPicker, setShowPicker] = useState(false);
const selected = REMINDER_OPTIONS.find((o) => o.value === value) ?? REMINDER_OPTIONS[0]; const selectedReminders = parseReminders(value);
const disabled = !hasDueDate; const disabled = !hasDueDate;
const reminderLabels = selectedReminders.length > 0
? selectedReminders.map(r => REMINDER_OPTIONS.find(o => o.value === r)?.label).filter(Boolean).join(', ')
: 'No reminder';
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text style={[styles.label, { color: theme.text }]}>Reminder</Text> <Text style={[styles.label, { color: theme.text }]}>Reminders</Text>
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.row, styles.row,
{ backgroundColor: theme.card, borderColor: theme.borderStrong }, { backgroundColor: theme.card, borderColor: theme.borderStrong },
value !== 'none' && { borderColor: theme.accent, backgroundColor: theme.accentSoft }, selectedReminders.length > 0 && { borderColor: theme.accent, backgroundColor: theme.accentSoft },
disabled && { opacity: 0.5 }, disabled && { opacity: 0.5 },
]} ]}
onPress={() => setShowPicker(true)} onPress={() => setShowPicker(true)}
disabled={disabled} disabled={disabled}
activeOpacity={0.8} activeOpacity={0.8}
accessibilityRole="button"
accessibilityLabel="Reminders"
accessibilityHint="Opens a list of reminder options"
accessibilityState={{ disabled }}
> >
<Svg width={20} height={20} viewBox="0 0 24 24"> <Svg width={20} height={20} viewBox="0 0 24 24">
<Path <Path
d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0" d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0"
stroke={value !== 'none' ? theme.accent : theme.textMuted} stroke={selectedReminders.length > 0 ? theme.accentText : theme.textSecondary}
strokeWidth={1.5} strokeWidth={1.8}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
fill="none" fill="none"
/> />
<Circle cx={18.5} cy={5.5} r={3.5} fill={value !== 'none' ? theme.accent : 'transparent'} stroke={value !== 'none' ? theme.accent : theme.textMuted} strokeWidth={1.5} /> <Circle cx={18.5} cy={5.5} r={3.5} fill={selectedReminders.length > 0 ? theme.accent : 'transparent'} stroke={selectedReminders.length > 0 ? theme.accentText : theme.textSecondary} strokeWidth={1.8} />
</Svg> </Svg>
<Text <Text
style={[ style={[
styles.valueText, styles.valueText,
{ color: value !== 'none' ? theme.text : theme.textMuted }, { color: selectedReminders.length > 0 ? theme.text : theme.textMuted },
value !== 'none' && styles.valueTextFilled, selectedReminders.length > 0 && styles.valueTextFilled,
]} ]}
> >
{value !== 'none' ? selected.label : 'No reminder'} {reminderLabels}
</Text> </Text>
<View style={styles.chevron}> <View style={styles.chevron}>
<Svg width={16} height={16} viewBox="0 0 24 24"> <Svg width={16} height={16} viewBox="0 0 24 24">
<Path d="M9 18l6-6-6-6" stroke={theme.textMuted} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" /> <Path d="M9 18l6-6-6-6" stroke={theme.textSecondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</Svg> </Svg>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
{!hasDueDate && ( {!hasDueDate && (
<Text style={[styles.hint, { color: theme.textMuted }]}>Set a due date to add a reminder.</Text> <Text style={[styles.hint, { color: theme.textMuted }]}>Set a due date to add reminders.</Text>
)} )}
<OptionPickerModal <OptionPickerModal
visible={showPicker} visible={showPicker}
title="Reminder" title="Reminders"
options={REMINDER_OPTIONS.map((o) => ({ value: o.value, label: o.label }))} options={REMINDER_OPTIONS.filter((o) => o.value !== 'none').map((o) => ({ value: o.value, label: o.label }))}
selectedValue={value} selectedValue={selectedReminders}
onSelect={(v) => onChange(v as Reminder)} onSelect={(v) => onChange(toRemindersString(v as Reminder[]))}
onClose={() => setShowPicker(false)} onClose={() => setShowPicker(false)}
multiSelect
/> />
</View> </View>
); );
@@ -76,10 +85,10 @@ export function ReminderSelector({ value, onChange, hasDueDate }: ReminderSelect
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
gap: 8, gap: 6,
}, },
label: { label: {
fontSize: 14, fontSize: 13,
fontWeight: '600', fontWeight: '600',
}, },
row: { row: {
@@ -93,7 +102,7 @@ const styles = StyleSheet.create({
}, },
valueText: { valueText: {
flex: 1, flex: 1,
fontSize: 15, fontSize: 14,
}, },
valueTextFilled: { valueTextFilled: {
fontWeight: '500', fontWeight: '500',
@@ -102,7 +111,7 @@ const styles = StyleSheet.create({
transform: [{ rotate: '-90deg' }], transform: [{ rotate: '-90deg' }],
}, },
hint: { hint: {
fontSize: 12, fontSize: 11,
marginLeft: 4, marginLeft: 4,
}, },
}); });

Some files were not shown because too many files have changed in this diff Show More