37 Commits
v1.4 ... v1.24

Author SHA1 Message Date
marcuspaico
dab6cb3347 feat(nav): three tabs and a Lists board; drop Review
Some checks failed
Build & Release APK / check (push) Has been cancelled
Build & Release APK / build (push) Has been cancelled
Next up, Today and Lists replace the six-button launcher. Lists is a 2x2
board (Next up / Waiting / Someday / Projects): full scrolling panes on a
desktop window, preview cards on a phone, each header opening its list and
each project opening its tasks. The Needs-attention card collapses to one
row of status pills under the title. Review and its screen are gone; Done
and Trash move under the Settings gear. v1.24.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SW5iVXkZ2JBHgyweMCRymY
2026-09-08 17:38:51 +10:00
marcuspaico
f9c3195552 build(ios): bundle id, build number, EAS ios profile
Some checks failed
Build & Release APK / check (push) Has been cancelled
Build & Release APK / build (push) Has been cancelled
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SW5iVXkZ2JBHgyweMCRymY
2026-09-08 17:21:15 +10:00
marcuspaico
7af9b5bbc4 chore: v1.23
Some checks failed
Build & Release APK / check (push) Has been cancelled
Build & Release APK / build (push) Has been cancelled
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SW5iVXkZ2JBHgyweMCRymY
2026-09-08 17:12:49 +10:00
marcuspaico
abc380bb84 feat: personal/work area toggle
Some checks failed
Build & Release APK / check (push) Has been cancelled
Build & Release APK / build (push) Has been cancelled
Global lens in the home header: every list, Today, Projects and Review
show only the active area; new rows are born into it. Tasks and projects
gain an area column (personal default); the task form's Area chip
re-files across areas.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SW5iVXkZ2JBHgyweMCRymY
2026-09-08 17:05:50 +10:00
marcuspaico
26922af997 ci: run check + APK build natively on the MacBook runner
Some checks failed
Build & Release APK / check (push) Has been cancelled
Build & Release APK / build (push) Has been cancelled
The desktop runner is gone. Jobs now target label "macbook": a
gitea-runner in host mode on the MacBook Air using Homebrew JDK 21,
the local Android SDK and node 22, so no container or SDK download.
eas.json bumped to 1.22 to match the locally built release.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmf3K9nCoEsXvvwkmMnuiM
2026-09-05 17:44:25 +10:00
marcuspaico
18a66aba58 feat(desktop): Tauri mac shell around the web export
Some checks failed
Build & Release APK / check (push) Has been cancelled
Build & Release APK / build (push) Has been cancelled
Black screen root cause: Tauri injects nonces into style-src, which
makes WebKit drop 'unsafe-inline' and block react-native-web's inline
style attributes. style-src is now excluded from CSP modification.
Window opens on the home screen so the section menu is reachable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmf3K9nCoEsXvvwkmMnuiM
2026-09-05 01:36:42 +10:00
marcuspaico
4dd8d743a7 ci: sign with the EAS-managed keystore (new alias + separate key password)
Some checks failed
Build & Release APK / check (push) Has been cancelled
Build & Release APK / build (push) Has been cancelled
Requires updated repo secrets KEYSTORE_B64, KEYSTORE_PASSWORD, KEY_PASSWORD.
Until they are set, CI release builds fail instead of shipping an APK signed
with the old key that Obtainium would reject.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YE5FmDZmFnWxH6RXCZLcjj
2026-09-02 21:29:14 +10:00
marcuspaico
a7efc4079d chore: v1.18
Some checks failed
Build & Release APK / check (push) Has been cancelled
Build & Release APK / build (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01THT7qz2Nt5ibWULZ4nvfTa
2026-09-02 18:37:27 +10:00
marcuspaico
1ff78418ec feat(when): Someday chip in the When picker — files into the someday pile
Some checks failed
Build & Release APK / check (push) Has been cancelled
Build & Release APK / build (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01THT7qz2Nt5ibWULZ4nvfTa
2026-09-02 18:34:24 +10:00
marcuspaico
eec9a431df chore: EAS build config (v1.17, signed with the EAS-managed keystore)
Some checks failed
Build & Release APK / check (push) Has been cancelled
Build & Release APK / build (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YE5FmDZmFnWxH6RXCZLcjj
2026-09-02 18:07:43 +10:00
marcuspaico
af1bf5a514 feat(home): draggable top-3 with a visually distinct NOW slot; web drag via handles
Home preview reorders through the same reorderTask as the Next list, and row
0 gets the NOW treatment (tint, bigger title). On web the drag library's
drop handling never fires, so lists reorder through WebSortableList's
pointer-event grab handles instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YE5FmDZmFnWxH6RXCZLcjj
2026-09-02 18:07:43 +10:00
marcuspaico
125fb7df35 feat(task): undo toast for completing — mis-taps are one tap to take back
completeTask returns an undo closure that restores status + sort slot and
trashes the occurrence a repeat spawned. One global snackbar (5s,
replace-on-new) mounted in the root layout; every checkbox and Clarify's
save-as-done go through completeWithUndo. bun tests for the store.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YE5FmDZmFnWxH6RXCZLcjj
2026-09-02 18:07:43 +10:00
dc49854072 feat(web): installable PWA shell so the web build works as an iPhone home-screen app
Some checks failed
Build & Release APK / check (push) Has been cancelled
Build & Release APK / build (push) Has been cancelled
There is no iOS binary — CI only produces a signed APK — so Safari's Add to
Home Screen is the only way onto an iPhone today. Without a manifest and the
apple-* meta tags that lands a bookmark that opens inside browser chrome, so
add both, plus a viewport that covers the notch and stops focus-zoom.
2026-09-01 23:36:55 +10:00
c990ce243b refactor(projects): reuse cardEdges for the project list
Some checks failed
Build & Release APK / check (push) Has been cancelled
Build & Release APK / build (push) Has been cancelled
2026-09-01 23:24:32 +10:00
8019e7c4f0 refactor(ui): one cardEdges helper for the three list card stacks 2026-09-01 22:57:13 +10:00
d951971d59 refactor(task): one TaskForm behind both capture and clarify 2026-09-01 22:55:48 +10:00
e299ab69bd feat(add): collapse the capture form into a title card plus an attribute bar 2026-09-01 22:53:17 +10:00
5638aa9030 refactor(ui): one chip primitive for all four pickers; DateField reuses lib/dates 2026-09-01 22:48:35 +10:00
5f610e506b feat(icons): add flag, repeat, tag and close glyphs 2026-09-01 22:46:57 +10:00
9aba89a2a9 fix(ui): drop unsupported letterSpacing from headerTitleStyle 2026-09-01 22:44:33 +10:00
Claude
1398a5d54b UI: align home-screen widget and TaskRow with the palette tokens
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 22:39:46 +10:00
Claude
31934c7c27 UI: restyle weekly review, project detail and settings
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 22:39:12 +10:00
Claude
b183c2849b UI: restyle task detail onto the new tokens
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 22:37:59 +10:00
Claude
dff9142e2e UI: restyle Today, Add and Projects onto the new tokens
Also fixes Field silently dropping its base input style when a caller
passed their own (the multiline Description box was unstyled).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 22:37:26 +10:00
Claude
1af86cd27c UI: light Things-3 design system — tokens, task rows, chips, home
Replaces the dark navy palette with a light grouped-background system:
- theme.ts: full token set (space/radius/type/shadow) + light+dark palettes
- ui.tsx: Screen/Title/SectionHeader/Card/Separator/Pill/Empty primitives
- TaskRow: rounded-square checkbox carrying priority colour, inset separators
- TaskListScreen: rows grouped into a card, drag lift, refined deferred footer
- Icon.tsx: dependency-free geometric icon set (emoji don't take a tint)
- home: large title, Next-up card, "needs attention" rows, icon tab bar
- Priority/Estimate/Date chips unified on the accent-soft active state

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 22:36:26 +10:00
65efb632db Settings shows the installed build version (CI-stamped versionName) — confirms which release Obtainium delivered
All checks were successful
Build & Release APK / check (push) Successful in 16s
Build & Release APK / build (push) Successful in 2m16s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 00:53:50 -07:00
b260e4e762 CI hardening: fast-fail check job (lockfile/tsc/eslint) gates the APK build + optional ntfy alerts; config plugin makes 'expo prebuild' preserve release signing/versioning; node 22 pinned with docker lock script + pre-push hook
All checks were successful
Build & Release APK / check (push) Successful in 16s
Build & Release APK / build (push) Successful in 2m27s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 00:39:29 -07:00
e02ef05c02 Regenerate package-lock with npm 10 (node 22) so CI's npm ci accepts it — local npm 11 omitted top-level @emnapi peer-dep entries
All checks were successful
Build & Release APK / build (push) Successful in 2m26s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 00:26:27 -07:00
d54d5b32f1 Android home-screen widget: top-3 next actions (4x2 'Next up' card) — tap the priority ring to complete via the offline queue, rows deep-link to Clarify, + opens capture; redraws on every store change plus a 30-min self-refresh
Some checks failed
Build & Release APK / build (push) Failing after 24s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 00:06:45 -07:00
4ef3f8c63c Repeats, estimates, weekly review: completing a repeating task spawns the next occurrence; estimate chips + quick-add tokens (~30m, monthly); review screen with one-tap triage; server gains repeat column and applies its idempotent schema at boot
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 00:06:39 -07:00
6ccd417f2a Offline UX: network failures show quiet 'offline, n queued' not errors; auto-sync on connectivity regain + 45s heartbeat
All checks were successful
Build & Release APK / build (push) Successful in 2m10s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 23:50:34 -07:00
64967c19a0 API: don't null-out empty notes/title (NOT NULL columns); constraint violations return 400 so offline clients drop bad ops
All checks were successful
Build & Release APK / build (push) Successful in 2m1s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 23:40:31 -07:00
8091c7d94d Drag-to-reorder lists: manual sort_order is canonical, fractional re-slotting, haptic pickup/drop
All checks were successful
Build & Release APK / build (push) Successful in 2m8s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 20:47:28 -07:00
e7c092f35d Interactive date picker: quick chips + inline month calendar (due & defer)
All checks were successful
Build & Release APK / build (push) Successful in 2m4s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 20:45:15 -07:00
d6bd8500e3 Minimal home: top-3 next actions only, bottom icon menu, FAB-only capture, inbox abolished (priority+due required at creation)
All checks were successful
Build & Release APK / build (push) Successful in 1m52s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 20:27:36 -07:00
0487bc7e4e Todoist-style: priorities P1-P4, estimates, FAB add form, Today view, natural quick add, drag-and-drop with haptics
All checks were successful
Build & Release APK / build (push) Successful in 1m53s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 19:46:11 -07:00
035a114e58 GTD quadrant launcher icon; build speedups: arm64-only, 8G gradle heap, cached SDK step
Some checks failed
Build & Release APK / build (push) Failing after 14m4s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 19:31:46 -07:00
83 changed files with 12861 additions and 503 deletions

View File

@@ -5,55 +5,81 @@ on:
branches: [main]
jobs:
build:
# RN/NDK builds starve the 4-core VPS — run on the desktop runner
# (marcusDesktop). Jobs queue while the desktop is off and start when it
# comes online; switch back to ubuntu-latest to build on the VPS.
runs-on: desktop
container:
image: eclipse-temurin:21-jdk
check:
# Fast fail: lockfile sync, types, lint — catches in ~2 min what would
# otherwise kill the APK build twenty minutes in.
runs-on: macbook
steps:
# actions/checkout needs node, which this container lacks — clone directly
- name: Checkout
run: |
apt-get update -qq && apt-get install -y -qq git unzip curl >/dev/null
git init -q .
git fetch -q --depth 1 "https://git.rehbock.xyz/${{ github.repository }}.git" "${{ github.sha }}"
git checkout -q FETCH_HEAD
- name: Install Node.js
git checkout -q -f FETCH_HEAD
git clean -qfd
- name: Lockfile + typecheck + lint
run: |
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - >/dev/null 2>&1
apt-get install -y -qq nodejs >/dev/null
export PATH="$HOME/.hermes/node/bin:$PATH"
cd app
npm ci --no-audit --no-fund
npx tsc --noEmit
npx eslint .
- name: Notify failure
if: failure()
env:
NTFY_TOKEN: ${{ secrets.NTFY_TOKEN }}
run: |
[ -n "$NTFY_TOKEN" ] || { echo "NTFY_TOKEN not set — skipping notification"; exit 0; }
curl -s -H "Authorization: Bearer $NTFY_TOKEN" -H "Title: gtd CI: check failed" -H "Priority: high" \
-d "typecheck/lint/lockfile failed at ${{ github.sha }} — https://git.rehbock.xyz/marcus/gtd/actions" \
https://ntfy.rehbock.xyz/gtd-ci
build:
needs: check
# Runs natively on the MacBook Air (gitea-runner in host mode, label
# "macbook"): Java 21 from Homebrew, the Android SDK from Android Studio,
# node 22 from ~/.hermes. No container, no SDK download.
runs-on: macbook
env:
ANDROID_HOME: /Users/marcus/Library/Android/sdk
JAVA_HOME: /opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
steps:
- name: Checkout
run: |
git init -q .
git fetch -q --depth 1 "https://git.rehbock.xyz/${{ github.repository }}.git" "${{ github.sha }}"
git checkout -q -f FETCH_HEAD
git clean -qfd
- name: Toolchain
run: |
export PATH="$HOME/.hermes/node/bin:$PATH"
node --version
- name: Install Android SDK
run: |
mkdir -p "$HOME/android-sdk/cmdline-tools"
curl -sLo /tmp/ct.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip -q /tmp/ct.zip -d /tmp
mv /tmp/cmdline-tools "$HOME/android-sdk/cmdline-tools/latest"
yes | "$HOME/android-sdk/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$HOME/android-sdk" --licenses >/dev/null 2>&1 || true
"$HOME/android-sdk/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$HOME/android-sdk" \
"platform-tools" "platforms;android-36" "build-tools;36.0.0" >/dev/null
"$JAVA_HOME/bin/java" -version
test -d "$ANDROID_HOME/platforms/android-36"
test -d "$ANDROID_HOME/build-tools/36.0.0"
- name: Install app dependencies
run: cd app && npm ci --no-audit --no-fund
run: |
export PATH="$HOME/.hermes/node/bin:$PATH"
cd app && npm ci --no-audit --no-fund
- name: Decode signing keystore
env:
KEYSTORE_B64: ${{ secrets.KEYSTORE_B64 }}
run: echo "$KEYSTORE_B64" | base64 -d > /tmp/release.jks
run: echo "$KEYSTORE_B64" | base64 --decode > /tmp/release.jks
- name: Build signed release APK
env:
KEYSTORE_FILE: /tmp/release.jks
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: gtd
# EAS-managed keystore (since v1.16) — alias is EAS-generated, and the
# key password can differ from the store password.
KEY_ALIAS: a5ff13aa8571055bcaecbaca29a548ae
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
VERSION_CODE: ${{ github.run_number }}
VERSION_NAME: 1.${{ github.run_number }}
run: |
export ANDROID_HOME="$HOME/android-sdk"
export PATH="$HOME/.hermes/node/bin:$PATH"
cd app/android
echo "sdk.dir=$ANDROID_HOME" > local.properties
./gradlew assembleRelease --no-daemon --console=plain
@@ -76,3 +102,13 @@ jobs:
-H "Authorization: token $TOKEN" \
-F "attachment=@$APK;type=application/vnd.android.package-archive" >/dev/null
echo "published $TAG"
- name: Notify failure
if: failure()
env:
NTFY_TOKEN: ${{ secrets.NTFY_TOKEN }}
run: |
[ -n "$NTFY_TOKEN" ] || { echo "NTFY_TOKEN not set — skipping notification"; exit 0; }
curl -s -H "Authorization: Bearer $NTFY_TOKEN" -H "Title: gtd CI: APK build failed" -H "Priority: high" \
-d "release build failed at ${{ github.sha }} — https://git.rehbock.xyz/marcus/gtd/actions" \
https://ntfy.rehbock.xyz/gtd-ci

12
.githooks/pre-push Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# Reject pushes whose lockfile CI's npm (node 22) would refuse — a lock
# written by a newer local npm fails `npm ci` on the runner half a build in.
# Enable with: git config core.hooksPath .githooks
set -e
root=$(git rev-parse --show-toplevel)
if git diff --quiet origin/main...HEAD -- app/package.json app/package-lock.json 2>/dev/null; then
exit 0
fi
echo "pre-push: package.json/lock changed — validating with CI's npm (node:22)…"
docker run --rm -v "$root/app:/w" -w /w node:22 npm ci --dry-run --no-audit --no-fund >/dev/null
echo "pre-push: lockfile OK"

2
.gitignore vendored
View File

@@ -5,3 +5,5 @@ server/.env
server/.env.token.local
*.log
.DS_Store
credentials.json
*.jks

View File

@@ -9,7 +9,11 @@ Minimal Getting Things Done app. One Expo/React Native codebase for iPhone, Andr
## Layout
- `app/` — Expo app (expo-router, TypeScript). Tabs: Inbox / Next / Waiting / Someday / Projects; tap a task to clarify (list, project, context, waiting-for, due/defer dates).
- `app/` — Expo app (expo-router, TypeScript). Home shows the top 3 next actions plus a launcher menu: Today / Next / Waiting / Someday / Projects / Review.
- **Personal / Work**: the toggle in the home header is a global lens — every list, Today, Projects and Review show only the active area, and new tasks/projects are born into it. Tasks and projects carry an `area` column (`personal` default); the task form's Area chip re-files a task across areas. The choice persists locally (`area_v1`). There is no inbox — every task is born fully scoped (title, priority, due date). Tap a task to clarify (list, project, context, waiting-for, due/defer dates, repeat).
- **Quick add**: the title field parses tokens live — `Pay rent tmr p1 @home #Sydney ~30m monthly` — and mirrors them into the form.
- **Repeat**: daily / weekdays / weekly / monthly / yearly; completing spawns the next occurrence (lapsed tasks catch up to today).
- **Defer**: deferred tasks are hidden from action lists until their date; a footer link reveals them.
- `server/` — Bun API + Dockerfile + compose. Deployed at `~/personal/gtd` on rehbock.xyz, joined to `personal-db_default` + `caddy_net`.
## Develop
@@ -18,6 +22,15 @@ Minimal Getting Things Done app. One Expo/React Native codebase for iPhone, Andr
cd app && npx expo start # scan QR with Expo Go (iPhone or Pixel)
```
- **Node 22 only** (`app/.nvmrc`): CI's npm 10 rejects lockfiles written by newer npm. Never run bare `npm install` to change deps — use `npm run lock` (regenerates the lock via a `node:22` container), or run npm from node 22.
- **Hook**: `git config core.hooksPath .githooks` enables a pre-push check that validates the lockfile against CI's npm before it can break a build.
- **Local Android builds** need JDK 21 (AGP chokes on newer): pinned via `org.gradle.java.home` in `~/.gradle/gradle.properties` → `~/.local/share/java/jdk-21.0.12+8`.
- **`expo prebuild` is safe to re-run**: `app/plugins/withAndroidRelease.js` re-injects the release signing, env-driven versionCode/Name, and gradle.properties tuning into the committed `android/`.
## Release (Android APK → Obtainium)
Every push to `main` runs `.gitea/workflows/release.yml` on the desktop runner: fast check job (lockfile/types/lint), then a signed `assembleRelease`, published as Gitea release `v1.<run>` with the APK attached. Phones track it with Obtainium → source `https://git.rehbock.xyz/marcus/gtd` (Forgejo/Gitea source type). CI failures push to ntfy topic `gtd-ci` when the `NTFY_TOKEN` repo secret is set.
## Deploy
```sh

1
app/.gitignore vendored
View File

@@ -40,3 +40,4 @@ example
# generated native folders (android/ is committed — CI builds it)
/ios
*.apk

1
app/.nvmrc Normal file
View File

@@ -0,0 +1 @@
22

View File

@@ -16,6 +16,7 @@
<meta-data android:name="expo.modules.updates.ENABLE_BSDIFF_PATCH_SUPPORT" android:value="true"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<service android:name="com.reactnativeandroidwidget.RNWidgetCollectionService" android:permission="android.permission.BIND_REMOTEVIEWS"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode|smallestScreenSize|assetsPaths" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
@@ -28,5 +29,12 @@
<data android:scheme="gtd"/>
</intent-filter>
</activity>
<receiver android:name=".widget.NextUp" android:exported="false" android:label="GTD — Next up">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.marcus.gtd.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_nextup"/>
</receiver>
</application>
</manifest>

View File

@@ -0,0 +1,6 @@
package com.marcus.gtd.widget;
import com.reactnativeandroidwidget.RNWidgetProvider;
public class NextUp extends RNWidgetProvider {
}

View File

@@ -2,4 +2,5 @@
<string name="app_name">GTD</string>
<string name="expo_system_ui_user_interface_style" translatable="false">automatic</string>
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
<string name="widget_nextup_description" translatable="false">Your top three next actions</string>
</resources>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:description="@string/widget_nextup_description"
android:initialLayout="@layout/rn_widget"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>

View File

@@ -10,7 +10,7 @@
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
org.gradle.jvmargs=-Xmx8192m -XX:MaxMetaspaceSize=1024m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
@@ -28,7 +28,7 @@ android.enablePngCrunchInReleaseBuilds=true
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
# ./gradlew <task> -PreactNativeArchitectures=x86_64
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
reactNativeArchitectures=arm64-v8a
# Use this property to enable support to the new architecture.
# This will allow you to use TurboModules and the Fabric render in

View File

@@ -2,13 +2,19 @@
"expo": {
"name": "GTD",
"slug": "gtd",
"version": "1.0.0",
"version": "1.24",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "gtd",
"userInterfaceStyle": "automatic",
"ios": {
"icon": "./assets/expo.icon"
"icon": "./assets/expo.icon",
"bundleIdentifier": "xyz.rehbock.gtd",
"buildNumber": "24",
"supportsTablet": false,
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
},
"android": {
"adaptiveIcon": {
@@ -25,7 +31,26 @@
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"./plugins/withAndroidRelease",
"expo-router",
[
"react-native-android-widget",
{
"widgets": [
{
"name": "NextUp",
"label": "GTD \u2014 Next up",
"description": "Your top three next actions",
"minWidth": "250dp",
"minHeight": "110dp",
"targetCellWidth": 4,
"targetCellHeight": 2,
"resizeMode": "horizontal|vertical",
"updatePeriodMillis": 1800000
}
]
}
],
[
"expo-splash-screen",
{
@@ -38,6 +63,12 @@
"experiments": {
"typedRoutes": true,
"reactCompiler": true
},
"extra": {
"router": {},
"eas": {
"projectId": "7a0325dd-ba60-415e-b367-e414890bacb3"
}
}
}
}

26
app/eas.json Normal file
View File

@@ -0,0 +1,26 @@
{
"cli": {
"version": ">= 20.0.0",
"appVersionSource": "local"
},
"build": {
"production": {
"android": {
"buildType": "apk"
},
"env": {
"VERSION_CODE": "24",
"VERSION_NAME": "1.24"
},
"node": "22.20.0",
"ios": {
"autoIncrement": false
}
}
},
"submit": {
"production": {
"ios": {}
}
}
}

10
app/eslint.config.js Normal file
View File

@@ -0,0 +1,10 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require('eslint/config');
const expoConfig = require("eslint-config-expo/flat");
module.exports = defineConfig([
expoConfig,
{
ignores: ["dist/*"],
}
]);

10
app/index.js Normal file
View File

@@ -0,0 +1,10 @@
// Custom entry: register the widget's headless task handler before the app
// starts, so widget events work even when the app UI never mounts.
import "expo-router/entry";
import { Platform } from "react-native";
import { registerWidgetTaskHandler } from "react-native-android-widget";
import { widgetTaskHandler } from "./src/widget/widget-task-handler";
if (Platform.OS === "android") {
registerWidgetTaskHandler(widgetTaskHandler);
}

4134
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,19 @@
{
"name": "app",
"main": "expo-router/entry",
"main": "index.js",
"version": "1.0.0",
"dependencies": {
"@expo/ui": "~57.0.9",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/netinfo": "12.0.1",
"expo": "~57.0.11",
"expo-application": "~57.0.2",
"expo-constants": "~57.0.9",
"expo-crypto": "~57.0.1",
"expo-device": "~57.0.1",
"expo-font": "~57.0.1",
"expo-glass-effect": "~57.0.1",
"expo-haptics": "~57.0.1",
"expo-image": "~57.0.2",
"expo-linking": "~57.0.5",
"expo-router": "~57.0.11",
@@ -22,6 +25,8 @@
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.2",
"react-native-android-widget": "^0.22.1",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-gesture-handler": "~2.32.0",
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0",
@@ -31,9 +36,15 @@
},
"devDependencies": {
"@types/react": "~19.2.2",
"eslint": "^9.0.0",
"eslint-config-expo": "~57.0.1",
"typescript": "~6.0.3"
},
"engines": {
"node": "22.x"
},
"scripts": {
"lock": "docker run --rm -v $PWD:/w -w /w node:22 npm install --package-lock-only --no-audit --no-fund",
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo run:android",

View File

@@ -0,0 +1,59 @@
// Makes `expo prebuild` safe to re-run: re-applies the hand-maintained Android
// release config that a fresh prebuild would otherwise reset to template
// defaults (android/ is committed; CI builds it and relies on all of this).
const { withAppBuildGradle, withGradleProperties } = require("expo/config-plugins");
const RELEASE_SIGNING = ` release {
// Keystore path + password come from env (CI) or ~/.android-keys (local).
def ksPath = System.getenv("KEYSTORE_FILE") ?: "\${System.properties['user.home']}/.android-keys/gtd-release.jks"
def passFile = new File("\${System.properties['user.home']}/.android-keys/gtd-release.password")
def ksPass = System.getenv("KEYSTORE_PASSWORD") ?: (passFile.exists() ? passFile.text.trim() : null)
if (new File(ksPath).exists() && ksPass != null) {
storeFile file(ksPath)
storePassword ksPass
keyAlias System.getenv("KEY_ALIAS") ?: "gtd"
keyPassword System.getenv("KEY_PASSWORD") ?: ksPass
}
}
`;
function patchBuildGradle(gradle) {
gradle = gradle.replace(/versionCode \d+\n/, 'versionCode ((System.getenv("VERSION_CODE") ?: "1").toInteger())\n');
gradle = gradle.replace(/versionName "[^"]*"\n/, 'versionName (System.getenv("VERSION_NAME") ?: "1.0-dev")\n');
if (!gradle.includes("KEYSTORE_FILE")) {
// Insert the release signing config right after the debug one.
gradle = gradle.replace(
/(signingConfigs \{\n debug \{[\s\S]*?\n \}\n)/,
`$1${RELEASE_SIGNING}`,
);
}
// Release builds use the release key when present, debug key otherwise.
gradle = gradle.replace(
/(release \{\n)(?:\s*\/\/[^\n]*\n)*\s*signingConfig signingConfigs\.debug\n/,
"$1 signingConfig signingConfigs.release.storeFile ? signingConfigs.release : signingConfigs.debug\n",
);
return gradle;
}
const GRADLE_PROPS = [
// RN/NDK builds want headroom; the template default is 2 GB.
{ key: "org.gradle.jvmargs", value: "-Xmx8192m -XX:MaxMetaspaceSize=1024m" },
// arm64 only: Pixel is arm64-v8a; x86/v7a builds are wasted time + APK bloat.
{ key: "reactNativeArchitectures", value: "arm64-v8a" },
];
module.exports = function withAndroidRelease(config) {
config = withAppBuildGradle(config, (c) => {
c.modResults.contents = patchBuildGradle(c.modResults.contents);
return c;
});
config = withGradleProperties(config, (c) => {
for (const { key, value } of GRADLE_PROPS) {
const existing = c.modResults.find((p) => p.type === "property" && p.key === key);
if (existing) existing.value = value;
else c.modResults.push({ type: "property", key, value });
}
return c;
});
return config;
};

BIN
app/public/icon-180.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
app/public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
app/public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

16
app/public/manifest.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "GTD",
"short_name": "GTD",
"description": "Capture, clarify, do.",
"start_url": "/today",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#F7F7F9",
"theme_color": "#F7F7F9",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}

View File

@@ -0,0 +1,78 @@
import { Link, useRouter } from "expo-router";
import { Tabs } from "expo-router/js-tabs";
import { Pressable, StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import AreaToggle from "../../components/AreaToggle";
import Icon from "../../components/Icon";
import TabBar, { TAB_BAR_CONTENT_HEIGHT, tabBarInset } from "../../components/TabBar";
import { haptic } from "../../lib/haptics";
import { C, shadow, space } from "../../lib/theme";
/**
* The three top-level screens share one header (the personal/work pill plus
* the settings gear) and one capture button. The FAB lives here rather than
* on each screen so it never moves: same spot above the bar whichever tab is
* open, which is what makes it a reflex.
*/
export default function TabsLayout() {
const router = useRouter();
const insets = useSafeAreaInsets();
return (
<View style={{ flex: 1 }}>
<Tabs
tabBar={(props) => <TabBar {...props} />}
screenOptions={{
headerStyle: { backgroundColor: C.bg },
headerTintColor: C.text,
headerShadowVisible: false,
headerTitle: () => <AreaToggle />,
headerTitleAlign: "center",
headerRight: () => (
<Link href="/settings" asChild>
<Pressable
hitSlop={12}
style={({ pressed }) => [{ paddingHorizontal: space.sm }, pressed && { opacity: 0.5 }]}
>
<Icon name="settings" size={22} color={C.textSecondary} />
</Pressable>
</Link>
),
sceneStyle: { backgroundColor: C.bg },
}}
>
<Tabs.Screen name="index" options={{ title: "Next up" }} />
<Tabs.Screen name="today" options={{ title: "Today" }} />
<Tabs.Screen name="lists" options={{ title: "Lists" }} />
</Tabs>
<Pressable
accessibilityLabel="New task"
style={({ pressed }) => [
s.fab,
{ bottom: TAB_BAR_CONTENT_HEIGHT + tabBarInset(insets.bottom) + space.lg },
pressed && { transform: [{ scale: 0.94 }] },
]}
onPress={() => {
haptic.tap();
router.push("/add");
}}
>
<Icon name="plus" size={26} color={C.onAccent} />
</Pressable>
</View>
);
}
const s = StyleSheet.create({
fab: {
position: "absolute",
right: space.xl,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: C.accent,
alignItems: "center",
justifyContent: "center",
...shadow.fab,
},
});

View File

@@ -0,0 +1,144 @@
import { useRouter } from "expo-router";
import { Platform, Pressable, StyleSheet, Text, View } from "react-native";
import { NestableDraggableFlatList, NestableScrollContainer, ScaleDecorator } from "react-native-draggable-flatlist";
import WebSortableList from "../../components/WebSortableList";
import Icon from "../../components/Icon";
import TaskRow, { TaskSeparator } from "../../components/TaskRow";
import { cardEdges, Empty, Screen, Title } from "../../components/ui";
import { haptic } from "../../lib/haptics";
import { reorderTask, todayTasks, visibleTasksByStatus } from "../../lib/store";
import { C, radius, shadow, space, type } from "../../lib/theme";
import { useStore } from "../../lib/useStore";
const PREVIEW = 3;
/**
* One small pill of status — overdue count, offline, sync trouble. Sits under
* the title instead of in its own section, so a bad day costs one line, not a
* card, and a good day costs nothing.
*/
function StatusPill({ tone, text, onPress }: { tone: string; text: string; onPress?: () => void }) {
return (
<Pressable
disabled={!onPress}
onPress={onPress}
style={({ pressed }) => [s.pill, { backgroundColor: `${tone}14` }, pressed && { opacity: 0.6 }]}
>
<View style={[s.dot, { backgroundColor: tone }]} />
<Text style={[s.pillText, { color: tone }]}>{text}</Text>
{!!onPress && <Icon name="chevron" size={12} color={tone} />}
</Pressable>
);
}
export default function Home() {
const router = useRouter();
const store = useStore();
const next = visibleTasksByStatus(store, "next");
const { overdue } = todayTasks(store);
const preview = next.slice(0, PREVIEW);
const attention = overdue.length > 0 || store.offline || !!store.syncError;
return (
<Screen>
<NestableScrollContainer contentContainerStyle={s.content} showsVerticalScrollIndicator={false}>
<Title count={next.length}>Next up</Title>
{attention && (
<View style={s.strip}>
{overdue.length > 0 && (
<StatusPill tone={C.danger} text={`${overdue.length} overdue`} onPress={() => router.push("/today")} />
)}
{store.offline && (
<StatusPill
tone={C.textSecondary}
text={store.pending > 0 ? `Offline · ${store.pending} queued` : "Offline"}
/>
)}
{!!store.syncError && (
<StatusPill tone={C.danger} text="Sync failed" onPress={() => router.push("/settings")} />
)}
</View>
)}
{preview.length === 0 ? (
<Empty>Nothing queued. Add the next thing on your mind.</Empty>
) : Platform.OS === "web" ? (
<WebSortableList
data={preview}
onReorder={(id, to) => reorderTask(id, "next", to)}
separator={<TaskSeparator />}
renderItem={(t, i) => (
<View style={cardEdges(i, preview.length)}>
<TaskRow task={t} emphasized={i === 0} />
</View>
)}
/>
) : (
// Draggable so the "do this now" slot is a hand-picked choice, not
// just whatever sorted first. Preview indices are next[0..N], so a
// drop reorders the real Next list.
<NestableDraggableFlatList
data={preview}
keyExtractor={(t) => t.id}
activationDistance={12}
onDragBegin={() => haptic.pickup()}
onDragEnd={({ data, from, to }) => {
if (from !== to) {
haptic.success();
reorderTask(data[to].id, "next", to);
}
}}
renderItem={({ item, drag, isActive, getIndex }) => {
const i = getIndex() ?? 0;
return (
<ScaleDecorator activeScale={1.02}>
<View style={[...cardEdges(i, preview.length), isActive && s.lifted]}>
<TaskRow task={item} emphasized={i === 0} onLongPress={drag} />
</View>
</ScaleDecorator>
);
}}
ItemSeparatorComponent={TaskSeparator}
/>
)}
{next.length > PREVIEW && (
<Pressable
onPress={() => router.push("/list/next")}
style={({ pressed }) => [s.viewAll, pressed && { opacity: 0.6 }]}
>
<Text style={s.viewAllText}>All {next.length} next actions</Text>
<Icon name="chevron" size={16} color={C.accent} />
</Pressable>
)}
</NestableScrollContainer>
</Screen>
);
}
const s = StyleSheet.create({
content: { paddingHorizontal: space.lg, paddingBottom: 96 },
lifted: { borderRadius: radius.lg, ...shadow.fab },
strip: { flexDirection: "row", flexWrap: "wrap", gap: space.sm, marginBottom: space.md },
pill: {
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingVertical: 5,
paddingHorizontal: space.md,
borderRadius: radius.pill,
},
dot: { width: 6, height: 6, borderRadius: 999 },
pillText: { ...type.meta, fontWeight: "600" },
viewAll: {
flexDirection: "row",
alignItems: "center",
gap: space.xs,
paddingVertical: space.md,
paddingHorizontal: space.xs,
},
viewAllText: { ...type.body, color: C.accent, fontWeight: "500" },
});

View File

@@ -0,0 +1,230 @@
// The Lists board: four quadrants, one screen.
//
// Next up, Waiting, Someday and Projects used to be four separate launcher
// buttons. Here they share a 2×2 board so the whole open-loop picture fits
// in one glance. On a desktop window each quadrant is a full pane with its
// own scroll; on a phone each is a card showing the first few rows, and the
// quadrant header opens the full list.
import { useRouter, type Href } from "expo-router";
import { Pressable, ScrollView, StyleSheet, Text, useWindowDimensions, View, type StyleProp, type ViewStyle } from "react-native";
import Icon, { type IconName } from "../../components/Icon";
import { Card, Separator } from "../../components/ui";
import { dueMeta } from "../../lib/dates";
import { haptic } from "../../lib/haptics";
import { PRIORITY_COLOR } from "../../lib/priority";
import { activeProjects, openTaskCount, visibleTasksByStatus } from "../../lib/store";
import { C, space, type } from "../../lib/theme";
import { completeWithUndo } from "../../lib/undo";
import { useStore } from "../../lib/useStore";
import type { Project, Task } from "../../lib/types";
/** Two panes side by side need roughly this much to stay readable. */
const WIDE = 720;
/** Rows a phone-sized quadrant shows before handing off to the full list. */
const PREVIEW = 4;
function TaskMini({ task, wide }: { task: Task; wide: boolean }) {
const router = useRouter();
const due = task.due_date ? dueMeta(task.due_date) : null;
const dueColor = due?.tone === "overdue" ? C.danger : due?.tone === "today" ? C.warn : C.muted;
return (
<Pressable
style={({ pressed }) => [s.row, pressed && s.rowPressed]}
onPress={() => router.push(`/task/${task.id}`)}
>
<Pressable
accessibilityRole="checkbox"
accessibilityState={{ checked: false }}
style={[s.check, { borderColor: PRIORITY_COLOR[task.priority] }]}
hitSlop={10}
onPress={() => completeWithUndo(task)}
/>
<Text style={s.rowTitle} numberOfLines={1}>
{task.title}
</Text>
{/* Phone-width cards can't spare the room; overdue still shows as a dot. */}
{due && wide ? (
<Text style={[s.rowMeta, { color: dueColor }]}>{due.label}</Text>
) : due?.tone === "overdue" ? (
<View style={[s.dueDot, { backgroundColor: C.danger }]} />
) : null}
</Pressable>
);
}
function ProjectMini({ project, open }: { project: Project; open: number }) {
const router = useRouter();
return (
<Pressable
style={({ pressed }) => [s.row, pressed && s.rowPressed]}
onPress={() => router.push(`/project/${project.id}`)}
>
<View style={s.projectDot} />
<Text style={s.rowTitle} numberOfLines={1}>
{project.name}
</Text>
<Text style={s.rowMeta}>{open === 0 ? "" : String(open)}</Text>
<Icon name="chevron" size={13} color={C.muted} />
</Pressable>
);
}
type Pane = {
key: string;
icon: IconName;
title: string;
/** Full-screen list the header opens. */
href: Href;
count: number;
empty: string;
rows: React.ReactNode[];
};
function Quadrant({ pane, wide, style }: { pane: Pane; wide: boolean; style?: StyleProp<ViewStyle> }) {
const router = useRouter();
const shown = wide ? pane.rows : pane.rows.slice(0, PREVIEW);
const hidden = pane.rows.length - shown.length;
const open = () => {
haptic.tap();
router.push(pane.href);
};
const body = (
<>
{shown.length === 0 && <Text style={s.empty}>{pane.empty}</Text>}
{shown.map((row, i) => (
<View key={i}>
{i > 0 && <Separator inset={space.lg + 18 + space.sm} />}
{row}
</View>
))}
</>
);
return (
<Card style={[s.pane, style]}>
<Pressable
accessibilityRole="button"
onPress={open}
style={({ pressed }) => [s.paneHeader, pressed && { backgroundColor: C.surface2 }]}
>
<Icon name={pane.icon} size={16} color={C.textSecondary} bg={C.surface} />
<Text style={s.paneTitle}>{pane.title}</Text>
{pane.count > 0 && <Text style={s.paneCount}>{pane.count}</Text>}
<View style={{ flex: 1 }} />
<Icon name="chevron" size={14} color={C.muted} />
</Pressable>
<Separator />
{wide ? (
<ScrollView style={{ flex: 1 }} showsVerticalScrollIndicator={false}>
{body}
</ScrollView>
) : (
<View>
{body}
{hidden > 0 && (
<Pressable onPress={open} style={({ pressed }) => [s.more, pressed && { opacity: 0.6 }]}>
<Text style={s.moreText}>{hidden} more</Text>
</Pressable>
)}
</View>
)}
</Card>
);
}
export default function Lists() {
const store = useStore();
const { width } = useWindowDimensions();
const wide = width >= WIDE;
const next = visibleTasksByStatus(store, "next");
const waiting = visibleTasksByStatus(store, "waiting");
const someday = visibleTasksByStatus(store, "someday");
const projects = activeProjects(store).filter((p) => p.status === "active");
const taskRows = (tasks: Task[]) => tasks.map((t) => <TaskMini key={t.id} task={t} wide={wide} />);
const panes: Pane[] = [
{ key: "next", icon: "next", title: "Next up", href: "/list/next", count: next.length, empty: "Nothing next.", rows: taskRows(next) },
{ key: "waiting", icon: "waiting", title: "Waiting", href: "/list/waiting", count: waiting.length, empty: "Not waiting on anyone.", rows: taskRows(waiting) },
{ key: "someday", icon: "someday", title: "Someday", href: "/list/someday", count: someday.length, empty: "No someday items.", rows: taskRows(someday) },
{
key: "projects",
icon: "projects",
title: "Projects",
href: "/projects",
count: projects.length,
empty: "No projects yet.",
rows: projects.map((p) => <ProjectMini key={p.id} project={p} open={openTaskCount(store, p.id)} />),
},
];
if (wide) {
return (
<View style={s.board}>
<View style={s.boardRow}>
<Quadrant pane={panes[0]} wide style={{ flex: 1 }} />
<Quadrant pane={panes[1]} wide style={{ flex: 1 }} />
</View>
<View style={s.boardRow}>
<Quadrant pane={panes[2]} wide style={{ flex: 1 }} />
<Quadrant pane={panes[3]} wide style={{ flex: 1 }} />
</View>
</View>
);
}
// Two columns that wrap. The container grows to the viewport and the two
// lines share its height (alignContent: stretch), so the board always looks
// like four quadrants — even when every list is empty.
const col = (width - space.lg * 2 - space.md) / 2;
return (
<ScrollView contentContainerStyle={s.grid} showsVerticalScrollIndicator={false}>
{panes.map((p) => (
<Quadrant key={p.key} pane={p} wide={false} style={{ width: col }} />
))}
</ScrollView>
);
}
const s = StyleSheet.create({
board: { flex: 1, padding: space.lg, gap: space.md },
boardRow: { flex: 1, flexDirection: "row", gap: space.md },
grid: {
flexGrow: 1,
flexDirection: "row",
flexWrap: "wrap",
alignContent: "stretch",
gap: space.md,
paddingHorizontal: space.lg,
paddingTop: space.md,
paddingBottom: 96,
},
pane: { minHeight: 150 },
paneHeader: {
flexDirection: "row",
alignItems: "center",
gap: space.sm,
paddingHorizontal: space.lg,
paddingVertical: space.md,
},
paneTitle: { fontSize: 15, fontWeight: "600", letterSpacing: -0.1, color: C.text },
paneCount: { ...type.meta, color: C.muted, fontWeight: "600" },
row: {
flexDirection: "row",
alignItems: "center",
gap: space.sm,
paddingHorizontal: space.lg,
paddingVertical: space.sm + 3,
},
rowPressed: { backgroundColor: C.surface2 },
check: { width: 18, height: 18, borderRadius: 6, borderWidth: 1.5 },
projectDot: { width: 18, height: 18, borderRadius: 5, backgroundColor: C.surface2 },
rowTitle: { flex: 1, fontSize: 15, letterSpacing: -0.1, color: C.text },
rowMeta: { ...type.meta, color: C.muted },
dueDot: { width: 6, height: 6, borderRadius: 999 },
empty: { ...type.meta, color: C.muted, paddingHorizontal: space.lg, paddingVertical: space.md },
more: { paddingHorizontal: space.lg, paddingVertical: space.sm + 2 },
moreText: { ...type.meta, color: C.accent, fontWeight: "500" },
});

View File

@@ -0,0 +1,45 @@
import { SectionList, StyleSheet, View } from "react-native";
import TaskRow, { TaskSeparator } from "../../components/TaskRow";
import { cardEdges, Empty, SectionHeader, Title } from "../../components/ui";
import { todayTasks } from "../../lib/store";
import { C, space } from "../../lib/theme";
import { useStore } from "../../lib/useStore";
export default function Today() {
const store = useStore();
const { overdue, today } = todayTasks(store);
const sections = [
...(overdue.length ? [{ title: "Overdue", data: overdue, color: C.danger }] : []),
{ title: "Due today", data: today, color: C.textSecondary },
];
const empty = overdue.length === 0 && today.length === 0;
return (
<SectionList
style={s.screen}
sections={sections}
keyExtractor={(t) => t.id}
ListHeaderComponent={<Title count={overdue.length + today.length}>Today</Title>}
// Each section is its own rounded card, so the first and last rows in a
// section round their outer corners.
renderItem={({ item, index, section }) => (
<View style={cardEdges(index, section.data.length)}>
<TaskRow task={item} showStatus />
</View>
)}
renderSectionHeader={({ section }) => (
<SectionHeader tone={section.color}>{section.title}</SectionHeader>
)}
ItemSeparatorComponent={TaskSeparator}
ListFooterComponent={empty ? <Empty>Nothing due. Clear runway.</Empty> : null}
contentContainerStyle={s.content}
stickySectionHeadersEnabled={false}
showsVerticalScrollIndicator={false}
/>
);
}
const s = StyleSheet.create({
screen: { flex: 1, backgroundColor: C.bg },
content: { paddingHorizontal: space.lg, paddingBottom: 96 },
});

49
app/src/app/+html.tsx Normal file
View File

@@ -0,0 +1,49 @@
// The HTML shell for the web build.
//
// The web export is the iPhone story — there is no iOS binary, so Safari's "Add
// to Home Screen" has to produce something that opens full-screen and looks
// installed rather than a bookmark with a browser chrome on top. That takes the
// manifest, the apple-* meta tags and a viewport that covers the notch; none of
// them are reachable from app.json, so the shell is written out by hand.
import { ScrollViewStyleReset } from "expo-router/html";
import type { PropsWithChildren } from "react";
// Match the app background on both schemes so the status bar and the rubber-band
// overscroll never flash white against a dark list.
const background = `
:root { color-scheme: light dark; }
body { background-color: #F7F7F9; overscroll-behavior-y: none; }
@media (prefers-color-scheme: dark) { body { background-color: #0C0C0F; } }
`;
export default function Root({ children }: PropsWithChildren) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
{/* viewport-fit=cover lets the layout run under the notch; disabling
user scaling stops iOS zooming in every time a title field focuses. */}
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<link rel="manifest" href="/manifest.json" />
<meta name="application-name" content="GTD" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="GTD" />
{/* "default" keeps the iOS status bar legible over a light header. */}
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<link rel="apple-touch-icon" sizes="180x180" href="/icon-180.png" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#F7F7F9" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0C0C0F" />
<ScrollViewStyleReset />
<style dangerouslySetInnerHTML={{ __html: background }} />
</head>
<body>{children}</body>
</html>
);
}

View File

@@ -1,48 +1,63 @@
import { Link, Stack } from "expo-router";
import NetInfo from "@react-native-community/netinfo";
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { useEffect } from "react";
import { AppState } from "react-native";
import { hydrate, sync } from "../lib/store";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { getState, hydrate, subscribe, sync } from "../lib/store";
import { ToastHost } from "../components/Toast";
import { C } from "../lib/theme";
import { refreshNextUpWidget } from "../widget/refresh";
export default function RootLayout() {
useEffect(() => {
void hydrate();
// Re-sync whenever the app returns to the foreground.
const sub = AppState.addEventListener("change", (s) => {
const appState = AppState.addEventListener("change", (s) => {
if (s === "active") void sync();
});
return () => sub.remove();
// Drain the queue the moment connectivity returns (wifi/cellular).
const net = NetInfo.addEventListener((s) => {
if (s.isConnected) void sync();
});
// Keep the home-screen widget in step with every mutation and sync.
const widget = subscribe(refreshNextUpWidget);
// Slow heartbeat as a belt-and-braces retry while changes are queued.
const heartbeat = setInterval(() => {
if (getState().pending > 0 || getState().offline) void sync();
}, 45_000);
return () => {
appState.remove();
net();
widget();
clearInterval(heartbeat);
};
}, []);
return (
<>
<StatusBar style="light" />
<GestureHandlerRootView style={{ flex: 1 }}>
<StatusBar style="dark" />
<Stack
screenOptions={{
headerStyle: { backgroundColor: C.bg },
headerTintColor: C.text,
// No hairline under the bar: the header should melt into the grouped
// background so the in-content Title carries the hierarchy instead.
headerShadowVisible: false,
headerTitleStyle: { fontSize: 17, fontWeight: "600", color: C.text },
headerBackButtonDisplayMode: "minimal",
contentStyle: { backgroundColor: C.bg },
}}
>
<Stack.Screen
name="index"
options={{
title: "GTD",
headerRight: () => (
<Link href="/settings" style={{ paddingHorizontal: 8, fontSize: 18 }}>
⚙️
</Link>
),
}}
/>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="list/[status]" />
<Stack.Screen name="add" options={{ title: "New task", presentation: "modal" }} />
<Stack.Screen name="projects" options={{ title: "Projects" }} />
<Stack.Screen name="task/[id]" options={{ title: "Clarify", presentation: "modal" }} />
<Stack.Screen name="project/[id]" options={{ title: "Project" }} />
<Stack.Screen name="settings" options={{ title: "Settings", presentation: "modal" }} />
</Stack>
</>
<ToastHost />
</GestureHandlerRootView>
);
}

108
app/src/app/add.tsx Normal file
View File

@@ -0,0 +1,108 @@
import { useRouter } from "expo-router";
import { useRef, useState } from "react";
import { Pressable, ScrollView, StyleSheet, Text } from "react-native";
import TaskForm, { BOX_LABEL, emptyDraft, type TaskDraft } from "../components/TaskForm";
import { haptic } from "../lib/haptics";
import { parseQuickAdd, type ParsedQuickAdd } from "../lib/quickadd";
import { activeProjects, createTask } from "../lib/store";
import { C, radius, space } from "../lib/theme";
import { useStore } from "../lib/useStore";
/**
* Capture screen.
*
* Deliberately one card and one row of chips. Everything optional lives behind
* a chip that shows its own value, so the common case — type a title, hit Add —
* is two interactions and never scrolls, while a fully specified task is still
* reachable without leaving the screen.
*/
export default function AddTask() {
const router = useRouter();
const store = useStore();
const [draft, setDraft] = useState<TaskDraft>(emptyDraft);
const projects = activeProjects(store).filter((p) => p.status === "active");
// The title field understands tokens ("Pay rent tmr p1 @home ~30m monthly")
// and mirrors each one into the chips below as you type, so capture is one
// line but every inference stays visible and correctable. A token only
// writes its field when it changes — chips tapped by hand stick.
const prevParsed = useRef<ParsedQuickAdd>({ title: "" });
const change = (patch: Partial<TaskDraft>) => {
if (patch.title === undefined) return setDraft((d) => ({ ...d, ...patch }));
const p = parseQuickAdd(patch.title, projects);
const prev = prevParsed.current;
const mirrored: Partial<TaskDraft> = {};
if (p.priority !== prev.priority && p.priority) mirrored.priority = p.priority;
if (p.due_date !== prev.due_date && p.due_date) mirrored.due_date = p.due_date;
if (p.estimate_min !== prev.estimate_min && p.estimate_min) mirrored.estimate_min = p.estimate_min;
if (p.repeat !== prev.repeat && p.repeat) mirrored.repeat = p.repeat;
if (p.project_id !== prev.project_id && p.project_id) mirrored.project_id = p.project_id;
if (p.context !== prev.context) mirrored.context = p.context ?? null;
prevParsed.current = p;
setDraft((d) => ({ ...d, ...patch, ...mirrored }));
};
// The field keeps the tokens visible; only the saved task loses them.
const cleanTitle = parseQuickAdd(draft.title, projects).title.trim() || draft.title.trim();
const tokensUsed = draft.title.trim() !== cleanTitle;
const ready = cleanTitle.length > 0;
const save = () => {
if (!ready) return;
createTask({
...draft,
title: cleanTitle,
// Unset priority means "ordinary", not "urgent" — pick the middle rung
// rather than blocking capture on a decision that can wait for review.
priority: draft.priority ?? 3,
waiting_for: draft.status === "waiting" ? draft.waiting_for?.trim() || null : null,
});
haptic.success();
router.back();
};
return (
<ScrollView
style={{ flex: 1, backgroundColor: C.bg }}
contentContainerStyle={s.content}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<TaskForm
value={draft}
onChange={change}
autoFocus
onSubmitTitle={save}
hint={
tokensUsed
? `Saving as “${cleanTitle}” — tokens moved into the chips below.`
: "Type p1 · tmr · @home · ~30m · #project to fill these in as you go."
}
/>
<Pressable
style={({ pressed }) => [s.save, !ready && s.saveDisabled, pressed && ready && { opacity: 0.85 }]}
onPress={save}
disabled={!ready}
>
<Text style={[s.saveText, !ready && s.saveTextDisabled]}>Add to {BOX_LABEL[draft.status]}</Text>
</Pressable>
</ScrollView>
);
}
const s = StyleSheet.create({
content: { padding: space.lg, paddingBottom: space.xxl },
save: {
backgroundColor: C.accent,
borderRadius: radius.md,
paddingVertical: space.lg - 2,
alignItems: "center",
marginTop: space.xl,
},
saveDisabled: { backgroundColor: C.surface2 },
saveText: { fontSize: 16, fontWeight: "600", color: C.onAccent },
saveTextDisabled: { color: C.muted },
});

View File

@@ -1,127 +0,0 @@
import { useRouter } from "expo-router";
import { useState } from "react";
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
import { activeProjects, createTask, tasksByStatus } from "../lib/store";
import { C } from "../lib/theme";
import { useStore } from "../lib/useStore";
const Q = {
next: { title: "Next up", color: "#3DBF77", bg: "#15301F", border: "#1E4D33" },
waiting: { title: "Waiting for", color: "#E0A93E", bg: "#33270F", border: "#544019" },
someday: { title: "Someday", color: "#9D8FE8", bg: "#221E38", border: "#38315C" },
projects: { title: "Projects", color: "#5B9DE8", bg: "#152538", border: "#1F3D5C" },
};
function Quadrant({
kind,
count,
preview,
onPress,
}: {
kind: keyof typeof Q;
count: number;
preview: string[];
onPress: () => void;
}) {
const q = Q[kind];
return (
<Pressable style={[s.quad, { backgroundColor: q.bg, borderColor: q.border }]} onPress={onPress}>
<View style={s.quadHead}>
<Text style={[s.quadTitle, { color: q.color }]}>{q.title}</Text>
<Text style={[s.quadCount, { color: q.color }]}>{count}</Text>
</View>
{preview.map((p, i) => (
<Text key={i} style={s.quadItem} numberOfLines={1}>
{p}
</Text>
))}
</Pressable>
);
}
export default function Home() {
const router = useRouter();
const store = useStore();
const [draft, setDraft] = useState("");
const capture = () => {
const title = draft.trim();
if (!title) return;
setDraft("");
createTask({ title });
};
const inbox = tasksByStatus(store, "inbox");
const next = tasksByStatus(store, "next");
const waiting = tasksByStatus(store, "waiting");
const someday = tasksByStatus(store, "someday");
const projects = activeProjects(store).filter((p) => p.status === "active");
return (
<View style={s.screen}>
<TextInput
style={s.capture}
placeholder="Capture anything…"
placeholderTextColor={C.muted}
value={draft}
onChangeText={setDraft}
onSubmitEditing={capture}
submitBehavior="submit"
returnKeyType="done"
/>
<Pressable style={s.inboxPill} onPress={() => router.push("/list/inbox")}>
<Text style={s.inboxText}>📥 Inbox</Text>
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
{store.pending > 0 && <Text style={s.pendingDot}>⇅ {store.pending}</Text>}
<Text style={[s.inboxText, { color: inbox.length ? C.text : C.muted }]}>{inbox.length}</Text>
</View>
</Pressable>
{store.syncError && <Text style={s.error}>{store.syncError}</Text>}
<View style={s.grid}>
<View style={s.gridRow}>
<Quadrant kind="next" count={next.length} preview={next.slice(0, 3).map((t) => t.title)} onPress={() => router.push("/list/next")} />
<Quadrant kind="waiting" count={waiting.length} preview={waiting.slice(0, 3).map((t) => t.title)} onPress={() => router.push("/list/waiting")} />
</View>
<View style={s.gridRow}>
<Quadrant kind="someday" count={someday.length} preview={someday.slice(0, 3).map((t) => t.title)} onPress={() => router.push("/list/someday")} />
<Quadrant kind="projects" count={projects.length} preview={projects.slice(0, 3).map((p) => p.name)} onPress={() => router.push("/projects")} />
</View>
</View>
</View>
);
}
const s = StyleSheet.create({
screen: { flex: 1, backgroundColor: C.bg, padding: 12 },
capture: {
padding: 14,
borderRadius: 12,
backgroundColor: C.surface,
color: C.text,
fontSize: 16,
borderWidth: 1,
borderColor: C.border,
},
inboxPill: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginTop: 10,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 12,
backgroundColor: C.surface,
borderWidth: 1,
borderColor: C.border,
},
inboxText: { color: C.text, fontSize: 16, fontWeight: "600" },
pendingDot: { color: C.muted, fontSize: 13 },
grid: { flex: 1, marginTop: 10, gap: 10 },
gridRow: { flex: 1, flexDirection: "row", gap: 10 },
quad: { flex: 1, borderRadius: 16, borderWidth: 1, padding: 14 },
quadHead: { flexDirection: "row", justifyContent: "space-between", marginBottom: 8 },
quadTitle: { fontSize: 17, fontWeight: "700" },
quadCount: { fontSize: 17, fontWeight: "700" },
quadItem: { color: C.muted, fontSize: 13, marginBottom: 4 },
error: { color: C.danger, marginTop: 8 },
});

View File

@@ -3,30 +3,28 @@ import TaskListScreen from "../../components/TaskListScreen";
import type { TaskStatus } from "../../lib/types";
const TITLES: Record<string, string> = {
inbox: "Inbox",
next: "Next up",
waiting: "Waiting for",
scheduled: "Scheduled",
someday: "Someday",
done: "Done",
trashed: "Trash",
};
const HINTS: Record<string, string> = {
inbox: "Inbox zero. Capture from the home screen.",
next: "No next actions. Clarify your inbox.",
next: "Nothing next. Tap + on the home screen.",
waiting: "Not waiting on anyone.",
scheduled: "Nothing scheduled.",
someday: "No someday/maybe items.",
done: "Nothing done yet.",
trashed: "Trash is empty.",
};
export default function ListByStatus() {
const { status } = useLocalSearchParams<{ status: string }>();
const st = (status in TITLES ? status : "inbox") as TaskStatus;
const st = (status in TITLES ? status : "next") as TaskStatus;
return (
<>
<Stack.Screen options={{ title: TITLES[st] }} />
<TaskListScreen status={st} capture={st === "inbox" || st === "next"} emptyHint={HINTS[st]} />
<TaskListScreen status={st} emptyHint={HINTS[st]} />
</>
);
}

View File

@@ -1,9 +1,12 @@
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useState } from "react";
import { FlatList, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
import { Button, Chips } from "../../components/ui";
import { createTask, dropProject, updateProject, updateTask } from "../../lib/store";
import { C } from "../../lib/theme";
import Icon from "../../components/Icon";
import { Button, cardEdges, Chips, Empty, Separator } from "../../components/ui";
import { haptic } from "../../lib/haptics";
import { createTask, dropProject, updateProject } from "../../lib/store";
import { completeWithUndo } from "../../lib/undo";
import { C, radius, space, type } from "../../lib/theme";
import { useStore } from "../../lib/useStore";
import type { ProjectStatus } from "../../lib/types";
@@ -16,7 +19,7 @@ export default function ProjectDetail() {
const [draft, setDraft] = useState("");
const project = store.projects.find((p) => p.id === id);
if (!project) return <Text style={{ color: C.muted, margin: 16 }}>Project not found.</Text>;
if (!project) return <Empty>Project not found.</Empty>;
const tasks = store.tasks
.filter((t) => t.project_id === id && t.status !== "done" && t.status !== "trashed")
@@ -26,13 +29,14 @@ export default function ProjectDetail() {
const title = draft.trim();
if (!title) return;
setDraft("");
createTask({ title, status: "next", project_id: id });
haptic.success();
createTask({ title, status: "next", project_id: id, area: project.area ?? "personal" });
};
return (
<View style={{ flex: 1, backgroundColor: C.bg }}>
<Stack.Screen options={{ title: project.name }} />
<View style={{ padding: 16, paddingBottom: 0 }}>
<View style={{ paddingHorizontal: space.lg, paddingTop: space.md }}>
<Chips
label="Status"
options={STATUSES}
@@ -53,22 +57,35 @@ export default function ProjectDetail() {
<FlatList
data={tasks}
keyExtractor={(t) => t.id}
renderItem={({ item }) => (
<Pressable style={s.row} onPress={() => router.push(`/task/${item.id}`)}>
renderItem={({ item, index }) => (
<Pressable
style={({ pressed }) => [
s.row,
...cardEdges(index, tasks.length),
pressed && { backgroundColor: C.surface2 },
]}
onPress={() => router.push(`/task/${item.id}`)}
>
<Pressable
style={s.checkbox}
hitSlop={10}
onPress={() => updateTask(item.id, { status: "done" })}
onPress={() => completeWithUndo(item)}
/>
<View style={{ flex: 1 }}>
<Text style={s.title}>{item.title}</Text>
<Text style={s.title} numberOfLines={2}>
{item.title}
</Text>
<Text style={s.meta}>{[item.status, item.context].filter(Boolean).join(" · ")}</Text>
</View>
<Icon name="chevron" size={16} color={C.muted} />
</Pressable>
)}
ListEmptyComponent={<Text style={s.empty}>No open tasks in this project.</Text>}
ItemSeparatorComponent={() => <Separator inset={space.lg + 21 + space.md} />}
ListEmptyComponent={<Empty>No open tasks in this project.</Empty>}
contentContainerStyle={s.listContent}
showsVerticalScrollIndicator={false}
/>
<View style={{ padding: 16 }}>
<View style={{ paddingHorizontal: space.lg, paddingBottom: space.lg }}>
<Button
title="Drop project"
danger
@@ -84,27 +101,26 @@ export default function ProjectDetail() {
const s = StyleSheet.create({
capture: {
margin: 12,
marginBottom: 4,
padding: 14,
borderRadius: 12,
marginHorizontal: space.lg,
marginBottom: space.lg,
paddingHorizontal: space.lg,
paddingVertical: space.md + 2,
borderRadius: radius.md,
backgroundColor: C.surface,
color: C.text,
fontSize: 16,
borderWidth: 1,
borderWidth: StyleSheet.hairlineWidth,
borderColor: C.border,
},
listContent: { paddingHorizontal: space.lg, paddingBottom: space.lg },
row: {
flexDirection: "row",
alignItems: "center",
gap: 12,
paddingVertical: 14,
paddingHorizontal: 16,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: C.border,
gap: space.md,
paddingVertical: space.md + 2,
paddingHorizontal: space.lg,
},
checkbox: { width: 22, height: 22, borderRadius: 11, borderWidth: 2, borderColor: C.muted },
title: { color: C.text, fontSize: 16 },
meta: { color: C.muted, fontSize: 13, marginTop: 2 },
empty: { color: C.muted, textAlign: "center", marginTop: 48 },
checkbox: { width: 21, height: 21, borderRadius: 7, borderWidth: 1.5, borderColor: C.muted },
title: { ...type.row, color: C.text },
meta: { ...type.meta, color: C.muted, marginTop: 2 },
});

View File

@@ -1,8 +1,11 @@
import { useRouter } from "expo-router";
import { useState } from "react";
import { FlatList, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
import Icon from "../components/Icon";
import { cardEdges, Empty, Pill, Separator } from "../components/ui";
import { haptic } from "../lib/haptics";
import { activeProjects, createProject, openTaskCount } from "../lib/store";
import { C } from "../lib/theme";
import { C, radius, space, type } from "../lib/theme";
import { useStore } from "../lib/useStore";
export default function Projects() {
@@ -15,6 +18,7 @@ export default function Projects() {
const name = draft.trim();
if (!name) return;
setDraft("");
haptic.success();
createProject({ name });
};
@@ -32,18 +36,34 @@ export default function Projects() {
<FlatList
data={projects}
keyExtractor={(p) => p.id}
renderItem={({ item }) => (
<Pressable style={s.row} onPress={() => router.push(`/project/${item.id}`)}>
<View style={{ flex: 1 }}>
<Text style={s.title}>{item.name}</Text>
<Text style={s.meta}>
{item.status} · {openTaskCount(store, item.id)} open
</Text>
</View>
<Text style={{ color: C.muted }}>›</Text>
</Pressable>
)}
ListEmptyComponent={<Text style={s.empty}>No projects yet.</Text>}
renderItem={({ item, index }) => {
const open = openTaskCount(store, item.id);
return (
<Pressable
style={({ pressed }) => [
s.row,
...cardEdges(index, projects.length),
pressed && { backgroundColor: C.surface2 },
]}
onPress={() => router.push(`/project/${item.id}`)}
>
<View style={{ flex: 1 }}>
<Text style={s.title} numberOfLines={1}>
{item.name}
</Text>
<Text style={s.meta}>
{open === 0 ? "Nothing open" : `${open} open`}
</Text>
</View>
{item.status !== "active" && <Pill label={item.status} />}
<Icon name="chevron" size={16} color={C.muted} />
</Pressable>
);
}}
ItemSeparatorComponent={() => <Separator inset={space.lg} />}
ListEmptyComponent={<Empty>No projects yet. Name one above.</Empty>}
contentContainerStyle={s.content}
showsVerticalScrollIndicator={false}
/>
</View>
);
@@ -51,26 +71,27 @@ export default function Projects() {
const s = StyleSheet.create({
screen: { flex: 1, backgroundColor: C.bg },
content: { paddingHorizontal: space.lg, paddingBottom: 40 },
capture: {
margin: 12,
marginBottom: 4,
padding: 14,
borderRadius: 12,
marginHorizontal: space.lg,
marginTop: space.md,
marginBottom: space.lg,
paddingHorizontal: space.lg,
paddingVertical: space.md + 2,
borderRadius: radius.md,
backgroundColor: C.surface,
color: C.text,
fontSize: 16,
borderWidth: 1,
borderWidth: StyleSheet.hairlineWidth,
borderColor: C.border,
},
row: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 14,
paddingHorizontal: 16,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: C.border,
gap: space.md,
paddingVertical: space.md + 2,
paddingHorizontal: space.lg,
},
title: { color: C.text, fontSize: 16 },
meta: { color: C.muted, fontSize: 13, marginTop: 2 },
empty: { color: C.muted, textAlign: "center", marginTop: 48 },
title: { ...type.row, color: C.text },
meta: { ...type.meta, color: C.muted, marginTop: 2 },
});

View File

@@ -1,11 +1,13 @@
import * as Application from "expo-application";
import { useRouter } from "expo-router";
import { useEffect, useState } from "react";
import { ScrollView, Text } from "react-native";
import { Button, Field } from "../components/ui";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import Icon from "../components/Icon";
import { Button, Card, Field, SectionHeader, Separator } from "../components/ui";
import { api } from "../lib/api";
import { DEFAULT_URL, getConfig, setConfig } from "../lib/config";
import { sync } from "../lib/store";
import { C } from "../lib/theme";
import { C, space, type } from "../lib/theme";
export default function Settings() {
const router = useRouter();
@@ -23,7 +25,7 @@ export default function Settings() {
const save = async () => {
await setConfig(url, token);
try {
await api.tasks.list({ status: "inbox" });
await api.tasks.list({ status: "next" });
void sync();
router.back();
} catch (e) {
@@ -32,7 +34,7 @@ export default function Settings() {
};
return (
<ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={{ padding: 16 }}>
<ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={s.content}>
<Field label="Server" value={url} onChangeText={setUrl} autoCapitalize="none" autoCorrect={false} />
<Field
label="API token"
@@ -43,7 +45,41 @@ export default function Settings() {
secureTextEntry
/>
<Button title="Save & test" onPress={save} />
{status && <Text style={{ color: C.danger }}>{status}</Text>}
{status && <Text style={s.error}>{status}</Text>}
{/* Done and Trash are archives, not lists you work from — they live
behind the gear rather than on the bar. */}
<SectionHeader>Archive</SectionHeader>
<Card>
{(["done", "trashed"] as const).map((st, i) => (
<View key={st}>
{i > 0 && <Separator inset={space.lg} />}
<Pressable
onPress={() => router.push(`/list/${st}`)}
style={({ pressed }) => [s.archiveRow, pressed && { backgroundColor: C.surface2 }]}
>
<Text style={s.archiveText}>{st === "done" ? "Done" : "Trash"}</Text>
<Icon name="chevron" size={16} color={C.muted} />
</Pressable>
</View>
))}
</Card>
{/* CI stamps versionName from the release tag — confirms which build Obtainium installed. */}
<Text style={s.version}>GTD v{Application.nativeApplicationVersion ?? "dev"}</Text>
</ScrollView>
);
}
const s = StyleSheet.create({
content: { padding: space.lg },
error: { ...type.meta, color: C.danger, marginTop: space.xs },
archiveRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: space.lg,
paddingVertical: space.md + 2,
},
archiveText: { ...type.body, color: C.text },
version: { ...type.meta, color: C.muted, textAlign: "center", marginTop: space.xxl },
});

View File

@@ -1,13 +1,32 @@
import { useLocalSearchParams, useRouter } from "expo-router";
import { useState } from "react";
import { ScrollView, Text } from "react-native";
import { Button, Chips, Field } from "../../components/ui";
import { activeProjects, trashTask, updateTask } from "../../lib/store";
import { C } from "../../lib/theme";
import { ScrollView, StyleSheet, Text } from "react-native";
import TaskForm, { CAPTURE_BOXES, type TaskDraft } from "../../components/TaskForm";
import { Button, Empty } from "../../components/ui";
import { haptic } from "../../lib/haptics";
import { trashTask, updateTask } from "../../lib/store";
import { completeWithUndo } from "../../lib/undo";
import { C, space, type } from "../../lib/theme";
import { useStore } from "../../lib/useStore";
import type { Task, TaskStatus } from "../../lib/types";
const STATUSES: TaskStatus[] = ["inbox", "next", "waiting", "scheduled", "someday", "done"];
// Clarify can also finish a task outright; capture never needs to.
const BOXES: TaskStatus[] = [...CAPTURE_BOXES, "done"];
const toDraft = (t: Task): TaskDraft => ({
title: t.title,
notes: t.notes,
status: t.status,
project_id: t.project_id,
context: t.context,
waiting_for: t.waiting_for,
due_date: t.due_date,
defer_date: t.defer_date,
estimate_min: t.estimate_min,
repeat: t.repeat,
priority: t.priority,
area: t.area ?? "personal",
});
export default function TaskDetail() {
const { id } = useLocalSearchParams<{ id: string }>();
@@ -15,86 +34,68 @@ export default function TaskDetail() {
const store = useStore();
const original = store.tasks.find((t) => t.id === id);
// Draft is local to the screen; nothing is written until Save.
const [draft, setDraft] = useState<Task | null>(original ?? null);
const [draft, setDraft] = useState<TaskDraft | null>(original ? toDraft(original) : null);
if (!draft) return <Text style={{ color: C.muted, margin: 16 }}>Task not found.</Text>;
const set = (patch: Partial<Task>) => setDraft({ ...draft, ...patch });
if (!original || !draft) return <Empty>Task not found.</Empty>;
const save = () => {
updateTask(draft.id, {
title: draft.title,
notes: draft.notes,
status: draft.status,
project_id: draft.project_id,
context: draft.context,
waiting_for: draft.waiting_for,
due_date: draft.due_date,
defer_date: draft.defer_date,
});
const { status, ...rest } = draft;
const completing = status === "done" && original.status !== "done";
// Completing from Clarify gets the same Undo as a checkbox; the toast
// lives in the root layout so it outlives this modal.
updateTask(original.id, { ...rest, ...(completing ? {} : { status }), priority: draft.priority ?? 3 });
if (completing) completeWithUndo(original, original.status);
else haptic.success();
router.back();
};
const projects = activeProjects(store).filter((p) => p.status === "active" || p.id === draft.project_id);
const projectOptions = ["none", ...projects.map((p) => p.id)];
const projectLabels = Object.fromEntries([["none", "None"], ...projects.map((p) => [p.id, p.name])]);
if (original.status === "trashed") {
return (
<ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={s.content}>
<Text style={s.trashedTitle}>{original.title}</Text>
<Text style={s.trashedMeta}>In the trash.</Text>
<Button
title="Restore to Next"
onPress={() => {
haptic.success();
updateTask(original.id, { status: "next" });
router.back();
}}
/>
</ScrollView>
);
}
return (
<ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={{ padding: 16 }}>
<Field label="Title" value={draft.title} onChangeText={(v) => set({ title: v })} />
<Chips label="List" options={STATUSES} value={draft.status} onChange={(status) => set({ status })} />
<Chips
label="Project"
options={projectOptions}
labels={projectLabels}
value={draft.project_id ?? "none"}
onChange={(v) => set({ project_id: v === "none" ? null : v })}
<ScrollView
style={{ flex: 1, backgroundColor: C.bg }}
contentContainerStyle={s.content}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<TaskForm
value={draft}
onChange={(patch) => setDraft({ ...draft, ...patch })}
boxes={BOXES}
showDefer
onSubmitTitle={save}
/>
<Field
label="Context"
value={draft.context ?? ""}
onChangeText={(v) => set({ context: v || null })}
placeholder="@home, @computer, @errands…"
autoCapitalize="none"
/>
{draft.status === "waiting" && (
<Field
label="Waiting for"
value={draft.waiting_for ?? ""}
onChangeText={(v) => set({ waiting_for: v || null })}
placeholder="Who or what?"
/>
)}
<Field
label="Due date"
value={draft.due_date ?? ""}
onChangeText={(v) => set({ due_date: v || null })}
placeholder="YYYY-MM-DD"
autoCapitalize="none"
/>
<Field
label="Defer until"
value={draft.defer_date ?? ""}
onChangeText={(v) => set({ defer_date: v || null })}
placeholder="YYYY-MM-DD"
autoCapitalize="none"
/>
<Field
label="Notes"
value={draft.notes}
onChangeText={(v) => set({ notes: v })}
multiline
style={{ minHeight: 80, textAlignVertical: "top" }}
/>
<Button title="Save" onPress={save} />
<Button title="Save" onPress={save} style={s.save} />
<Button
title="Delete"
danger
onPress={() => {
trashTask(draft.id);
trashTask(original.id);
router.back();
}}
/>
</ScrollView>
);
}
const s = StyleSheet.create({
content: { padding: space.lg, paddingBottom: space.xxl },
save: { marginTop: space.xl },
trashedTitle: { ...type.title, fontSize: 22, color: C.text, marginBottom: space.xs },
trashedMeta: { ...type.body, color: C.muted, marginBottom: space.xl },
});

View File

@@ -0,0 +1,64 @@
// The personal / work switch that lives in the home header.
//
// A two-segment pill rather than a menu or a settings row: the whole point is
// that flipping halves of your life is one tap and its state is always
// visible. Work gets its own tone so a glance at the header tells you which
// lens is on even before you read the label.
import { Pressable, StyleSheet, Text, View } from "react-native";
import { haptic } from "../lib/haptics";
import { setArea } from "../lib/store";
import { C, radius, type } from "../lib/theme";
import { useStore } from "../lib/useStore";
import { AREA_LABEL, AREAS, type Area } from "../lib/types";
export const AREA_TONE: Record<Area, string> = { personal: C.accent, work: C.work };
export default function AreaToggle() {
const { area } = useStore();
return (
<View style={s.track} accessibilityRole="tablist">
{AREAS.map((a) => {
const active = a === area;
return (
<Pressable
key={a}
accessibilityRole="tab"
accessibilityState={{ selected: active }}
hitSlop={6}
onPress={() => {
if (active) return;
haptic.select();
setArea(a);
}}
style={({ pressed }) => [
s.segment,
active && { backgroundColor: AREA_TONE[a] },
pressed && !active && s.segmentPressed,
]}
>
<Text style={[s.label, active && s.labelActive]}>{AREA_LABEL[a]}</Text>
</Pressable>
);
})}
</View>
);
}
const s = StyleSheet.create({
track: {
flexDirection: "row",
padding: 3,
borderRadius: radius.pill,
backgroundColor: C.surface2,
borderWidth: StyleSheet.hairlineWidth,
borderColor: C.border,
},
segment: {
paddingVertical: 5,
paddingHorizontal: 16,
borderRadius: radius.pill,
},
segmentPressed: { backgroundColor: C.border },
label: { ...type.chip, color: C.textSecondary },
labelActive: { color: C.onAccent, fontWeight: "600" },
});

View File

@@ -0,0 +1,126 @@
// The attribute bar: one wrapping row of chips that doubles as the task's
// summary and as the entry point to every optional field.
//
// The old form stacked seven labelled control groups down the screen, so
// capturing "call Sam" meant scrolling past a calendar, a repeat picker and an
// estimate picker to reach the save button. Here each field collapses to a
// single chip: unset chips read as a quiet placeholder, set chips show their
// value in the accent. Exactly one panel is open at a time, so the screen
// never grows past roughly a phone height.
import { useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { haptic } from "../lib/haptics";
import { C, radius, space, type } from "../lib/theme";
import Icon, { type IconName } from "./Icon";
export type Attr = {
key: string;
icon: IconName;
/** Chip label while the field is unset. */
placeholder: string;
/** Chip label once set — this is what makes the bar readable as a summary. */
value?: string | null;
/** Chip accent when set. Defaults to the app accent. */
tone?: string;
/** Revealed under the bar when the chip is tapped. */
panel: React.ReactNode;
/** Offered as "Clear" in the open panel's header. Omit for required fields. */
onClear?: () => void;
};
export default function AttrBar({ attrs }: { attrs: Attr[] }) {
const [open, setOpen] = useState<string | null>(null);
const active = attrs.find((a) => a.key === open) ?? null;
return (
<View>
<View style={s.row}>
{attrs.map((a) => {
const set = !!a.value;
const tone = a.tone ?? C.accent;
const isOpen = a.key === open;
return (
<Pressable
key={a.key}
onPress={() => {
haptic.tap();
setOpen(isOpen ? null : a.key);
}}
style={({ pressed }) => [
s.chip,
set && { borderColor: tone, backgroundColor: `${tone}14` },
isOpen && { borderColor: tone, backgroundColor: `${tone}22` },
pressed && { opacity: 0.7 },
]}
>
<Icon name={a.icon} size={15} color={set || isOpen ? tone : C.muted} bg={C.bg} />
<Text style={[s.chipText, (set || isOpen) && { color: tone, fontWeight: "600" }]}>
{a.value || a.placeholder}
</Text>
</Pressable>
);
})}
</View>
{!!active && (
<View style={s.panel}>
<View style={s.panelHead}>
<Text style={s.panelTitle}>{active.placeholder}</Text>
{!!active.onClear && !!active.value && (
<Pressable
onPress={() => {
haptic.select();
active.onClear?.();
}}
hitSlop={8}
style={({ pressed }) => pressed && { opacity: 0.6 }}
>
<Text style={s.clear}>Clear</Text>
</Pressable>
)}
<Pressable onPress={() => setOpen(null)} hitSlop={10} style={({ pressed }) => pressed && { opacity: 0.6 }}>
<Icon name="close" size={17} color={C.muted} />
</Pressable>
</View>
{active.panel}
</View>
)}
</View>
);
}
const s = StyleSheet.create({
row: { flexDirection: "row", flexWrap: "wrap", gap: space.sm },
chip: {
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingVertical: space.sm - 1,
paddingLeft: space.md - 2,
paddingRight: space.lg - 2,
borderRadius: radius.pill,
borderWidth: StyleSheet.hairlineWidth,
borderColor: C.border,
backgroundColor: C.surface,
},
chipText: { ...type.chip, color: C.textSecondary },
panel: {
marginTop: space.md,
backgroundColor: C.surface,
borderRadius: radius.lg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: C.border,
paddingHorizontal: space.lg,
paddingBottom: space.md,
},
panelHead: {
flexDirection: "row",
alignItems: "center",
gap: space.md,
paddingTop: space.md,
paddingBottom: space.xs,
},
panelTitle: { ...type.section, color: C.textSecondary, flex: 1 },
clear: { ...type.meta, color: C.accent, fontWeight: "600" },
});

View File

@@ -0,0 +1,208 @@
// Interactive date field: quick chips (Today / Tomorrow / Next week, plus an
// optional Someday) and an expandable month calendar. Pure RN views —
// identical on Android, iOS, web.
import { useMemo, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { haptic } from "../lib/haptics";
import { friendlyDate, fromIso, toIso as iso, todayIso } from "../lib/dates";
import { C, radius, space, type } from "../lib/theme";
import { ChipRow, OptionChip, SectionHeader } from "./ui";
const DAY_MS = 86_400_000;
const WEEKDAYS = ["M", "T", "W", "T", "F", "S", "S"];
const MONTHS = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
function Calendar({ value, onPick }: { value: string | null; onPick: (v: string) => void }) {
const initial = value ? fromIso(value) : new Date();
const [year, setYear] = useState(initial.getFullYear());
const [month, setMonth] = useState(initial.getMonth());
const today = todayIso();
const weeks = useMemo(() => {
const first = new Date(year, month, 1);
const start = new Date(first);
start.setDate(1 - ((first.getDay() + 6) % 7)); // back to Monday
return Array.from({ length: 6 }, (_, w) =>
Array.from({ length: 7 }, (_, i) => {
const d = new Date(start);
d.setDate(start.getDate() + w * 7 + i);
return d;
}),
);
}, [year, month]);
const shift = (delta: number) => {
haptic.select();
const d = new Date(year, month + delta, 1);
setYear(d.getFullYear());
setMonth(d.getMonth());
};
return (
<View style={s.calendar}>
<View style={s.calHead}>
<Pressable onPress={() => shift(-1)} hitSlop={10} style={s.arrow}>
<Text style={s.arrowText}>‹</Text>
</Pressable>
<Text style={s.calTitle}>
{MONTHS[month]} {year}
</Text>
<Pressable onPress={() => shift(1)} hitSlop={10} style={s.arrow}>
<Text style={s.arrowText}>›</Text>
</Pressable>
</View>
<View style={s.week}>
{WEEKDAYS.map((w, i) => (
<Text key={i} style={s.weekday}>
{w}
</Text>
))}
</View>
{weeks.map((week, wi) => (
<View key={wi} style={s.week}>
{week.map((d) => {
const dIso = iso(d);
const inMonth = d.getMonth() === month;
const selected = dIso === value;
const isToday = dIso === today;
return (
<Pressable
key={dIso}
style={[s.day, selected && s.daySelected, !selected && isToday && s.dayToday]}
onPress={() => {
haptic.select();
onPick(dIso);
}}
>
<Text
style={[
s.dayText,
!inMonth && { color: C.muted, opacity: 0.45 },
selected && { color: C.onAccent, fontWeight: "700" },
!selected && isToday && { color: C.accent },
]}
>
{d.getDate()}
</Text>
</Pressable>
);
})}
</View>
))}
</View>
);
}
export default function DateField({
label,
value,
onChange,
clearable,
someday,
}: {
label?: string;
value: string | null;
onChange: (v: string | null) => void;
clearable?: boolean;
/** Offers a dateless "Someday" chip — the pile, not a day on the calendar. */
someday?: { active: boolean; onPress: () => void };
}) {
const [open, setOpen] = useState(false);
const today = new Date();
const quick = [
{ label: "Today", v: iso(today) },
{ label: "Tomorrow", v: iso(new Date(today.getTime() + DAY_MS)) },
{ label: "Next week", v: iso(new Date(today.getTime() + 7 * DAY_MS)) },
];
return (
<View style={label ? { marginBottom: space.lg } : undefined}>
{!!label && (
<View style={s.labelRow}>
<SectionHeader>{label}</SectionHeader>
{value != null && <Text style={s.chosen}>{friendlyDate(value)}</Text>}
</View>
)}
<ChipRow>
{quick.map((q) => (
<OptionChip
key={q.label}
label={q.label}
active={value === q.v}
onPress={() => {
haptic.select();
onChange(value === q.v && clearable ? null : q.v);
}}
/>
))}
{someday && (
<OptionChip
label="Someday"
active={someday.active}
onPress={() => {
haptic.select();
someday.onPress();
}}
/>
)}
<OptionChip
label={value && !quick.some((q) => q.v === value) ? friendlyDate(value) : "Pick a date…"}
active={open}
onPress={() => {
haptic.select();
setOpen(!open);
}}
/>
{/* Standalone only — inside an AttrBar panel the header owns "Clear". */}
{clearable && !!label && value != null && (
<OptionChip label="Clear" onPress={() => {
haptic.tap();
onChange(null);
}} />
)}
</ChipRow>
{open && (
<Calendar
value={value}
onPick={(v) => {
onChange(v);
setOpen(false);
}}
/>
)}
</View>
);
}
const s = StyleSheet.create({
labelRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
chosen: { ...type.chip, color: C.accent, fontWeight: "600" },
calendar: {
marginTop: space.md,
backgroundColor: C.surface,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: C.border,
padding: space.md,
},
calHead: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: space.sm },
calTitle: { color: C.text, fontSize: 16, fontWeight: "600", letterSpacing: -0.2 },
arrow: { paddingHorizontal: 14, paddingVertical: 4 },
arrowText: { color: C.accent, fontSize: 24, fontWeight: "500" },
week: { flexDirection: "row" },
weekday: { flex: 1, textAlign: "center", color: C.muted, fontSize: 11, fontWeight: "600", letterSpacing: 0.4, paddingVertical: 6 },
day: {
flex: 1,
aspectRatio: 1.15,
alignItems: "center",
justifyContent: "center",
borderRadius: 999,
margin: 1,
},
daySelected: { backgroundColor: C.accent },
dayToday: { backgroundColor: C.accentSoft },
dayText: { color: C.text, fontSize: 15 },
});

View File

@@ -0,0 +1,41 @@
// Preset estimate chips — one tap beats a numeric keyboard. A non-preset
// value (from quick-add, e.g. ~25m) shows up as its own chip so it's never
// invisible or lost.
import { View } from "react-native";
import { haptic } from "../lib/haptics";
import { formatEstimate } from "../lib/priority";
import { space } from "../lib/theme";
import { ChipRow, OptionChip, SectionHeader } from "./ui";
const PRESETS = [15, 30, 45, 60, 90, 120];
/** Omit `label` when the surrounding panel already titles the group. */
export default function EstimateChips({
value,
onChange,
label,
}: {
value: number | null;
onChange: (v: number | null) => void;
label?: string;
}) {
const options = value && !PRESETS.includes(value) ? [...PRESETS, value].sort((a, b) => a - b) : PRESETS;
return (
<View style={label ? { marginBottom: space.lg } : undefined}>
{!!label && <SectionHeader>{label}</SectionHeader>}
<ChipRow>
{options.map((m) => (
<OptionChip
key={m}
label={formatEstimate(m) ?? ""}
active={value === m}
onPress={() => {
haptic.select();
onChange(value === m ? null : m);
}}
/>
))}
</ChipRow>
</View>
);
}

336
app/src/components/Icon.tsx Normal file
View File

@@ -0,0 +1,336 @@
// A tiny geometric icon set drawn from plain Views.
//
// The app ships no vector-icon dependency and emoji render differently on
// every platform (and can't take the accent colour), so these are built from
// borders and rectangles instead: monochrome, tintable, pixel-identical on
// Android and web, and zero bytes of new dependency.
import { StyleSheet, View } from "react-native";
import { C } from "../lib/theme";
export type IconName =
| "today"
| "next"
| "waiting"
| "someday"
| "projects"
| "grid"
| "settings"
| "flag"
| "repeat"
| "tag"
| "area"
| "plus"
| "close"
| "chevron";
type Props = {
name: IconName;
size?: number;
color?: string;
/** Used to carve the crescent out of the "someday" moon. */
bg?: string;
};
export default function Icon({ name, size = 22, color = C.textSecondary, bg = C.bg }: Props) {
const box = { width: size, height: size };
const w = 1.7; // one consistent stroke weight across the whole set
switch (name) {
case "today":
return (
<View style={[box, s.center]}>
<View
style={{
width: size * 0.86,
height: size * 0.82,
borderWidth: w,
borderColor: color,
borderRadius: 5,
overflow: "hidden",
}}
>
<View style={{ height: size * 0.2, backgroundColor: color }} />
<View style={[s.center, { flex: 1 }]}>
<View
style={{ width: w * 2, height: w * 2, borderRadius: 999, backgroundColor: color }}
/>
</View>
</View>
</View>
);
case "next":
return (
<View style={[box, s.center]}>
<View
style={{
width: 0,
height: 0,
borderTopWidth: size * 0.32,
borderBottomWidth: size * 0.32,
borderLeftWidth: size * 0.52,
borderTopColor: "transparent",
borderBottomColor: "transparent",
borderLeftColor: color,
marginLeft: size * 0.12,
}}
/>
</View>
);
case "waiting":
return (
<View style={[box, s.center]}>
<View
style={{
width: size * 0.84,
height: size * 0.84,
borderRadius: 999,
borderWidth: w,
borderColor: color,
}}
>
{/* hour hand, then minute hand — anchored at the dial centre */}
<View
style={{
position: "absolute",
left: size * 0.42 - w / 2,
top: size * 0.2,
width: w,
height: size * 0.22,
backgroundColor: color,
borderRadius: w,
}}
/>
<View
style={{
position: "absolute",
left: size * 0.42 - w / 2,
top: size * 0.42 - w / 2,
width: size * 0.24,
height: w,
backgroundColor: color,
borderRadius: w,
}}
/>
</View>
</View>
);
case "someday":
return (
<View style={[box, s.center]}>
<View
style={{
width: size * 0.84,
height: size * 0.84,
borderRadius: 999,
backgroundColor: color,
overflow: "hidden",
}}
>
<View
style={{
position: "absolute",
right: -size * 0.22,
top: -size * 0.26,
width: size * 0.84,
height: size * 0.84,
borderRadius: 999,
backgroundColor: bg,
}}
/>
</View>
</View>
);
case "projects":
return (
<View style={[box, s.center]}>
<View style={{ width: size * 0.86, height: size * 0.78 }}>
<View
style={{
width: size * 0.4,
height: size * 0.16,
borderTopLeftRadius: 3,
borderTopRightRadius: 3,
backgroundColor: color,
}}
/>
<View
style={{
flex: 1,
borderWidth: w,
borderColor: color,
borderRadius: 5,
borderTopLeftRadius: 0,
}}
/>
</View>
</View>
);
case "grid":
// Four squares — the Lists board's own shape.
return (
<View style={[box, s.center]}>
<View style={{ width: size * 0.8, height: size * 0.8, flexDirection: "row", flexWrap: "wrap", gap: size * 0.1 }}>
{[0, 1, 2, 3].map((i) => (
<View
key={i}
style={{ width: size * 0.35, height: size * 0.35, borderWidth: w, borderColor: color, borderRadius: 3 }}
/>
))}
</View>
</View>
);
case "settings":
// Sliders, not a cog: three strokes with offset knobs stays legible at 20px.
return (
<View style={[box, s.center]}>
<View style={{ width: size * 0.88, gap: size * 0.16 }}>
{[0.62, 0.3, 0.48].map((knob, i) => (
<View key={i} style={s.sliderRow}>
<View style={{ flex: 1, height: w, backgroundColor: color, borderRadius: w }} />
<View
style={{
position: "absolute",
left: `${knob * 100}%`,
width: w * 3,
height: w * 3,
marginLeft: -w * 1.5,
marginTop: -w,
borderRadius: 999,
borderWidth: w,
borderColor: color,
backgroundColor: bg,
}}
/>
</View>
))}
</View>
</View>
);
case "flag":
return (
<View style={box}>
{/* Pole plus a pennant — reads as a priority flag at 20px where a
detailed banner outline would smear into a blob. */}
<View style={{ position: "absolute", left: size * 0.26, top: size * 0.12, width: w, height: size * 0.76, borderRadius: 1, backgroundColor: color }} />
<View
style={{
position: "absolute",
left: size * 0.26 + w,
top: size * 0.14,
width: size * 0.44,
height: size * 0.34,
borderTopRightRadius: 2,
borderBottomRightRadius: 2,
backgroundColor: color,
}}
/>
</View>
);
case "repeat":
return (
<View style={box}>
{/* Two offset bars with opposing arrowheads: a loop, without the
anti-aliasing mush a thin circular arc gets at this size. */}
<View style={{ position: "absolute", left: size * 0.2, top: size * 0.3, width: size * 0.5, height: w, backgroundColor: color }} />
<View style={{ position: "absolute", left: size * 0.62, top: size * 0.3 - size * 0.09 + w / 2, width: size * 0.18, height: size * 0.18, borderRightWidth: w, borderTopWidth: w, borderColor: color, transform: [{ rotate: "45deg" }] }} />
<View style={{ position: "absolute", left: size * 0.3, top: size * 0.66, width: size * 0.5, height: w, backgroundColor: color }} />
<View style={{ position: "absolute", left: size * 0.2, top: size * 0.66 - size * 0.09 + w / 2, width: size * 0.18, height: size * 0.18, borderLeftWidth: w, borderBottomWidth: w, borderColor: color, transform: [{ rotate: "45deg" }] }} />
</View>
);
case "tag":
return (
<View style={[box, s.center]}>
<View
style={{
width: size * 0.6,
height: size * 0.6,
borderWidth: w,
borderColor: color,
borderRadius: 3,
transform: [{ rotate: "45deg" }],
}}
/>
<View style={{ position: "absolute", left: size * 0.3, top: size * 0.3, width: w + 0.6, height: w + 0.6, borderRadius: 999, backgroundColor: color }} />
</View>
);
case "area":
// Briefcase: a body with a short handle on top.
return (
<View style={[box, s.center]}>
<View
style={{
position: "absolute",
top: size * 0.12,
width: size * 0.36,
height: size * 0.26,
borderWidth: w,
borderBottomWidth: 0,
borderColor: color,
borderTopLeftRadius: 3,
borderTopRightRadius: 3,
}}
/>
<View
style={{
position: "absolute",
top: size * 0.32,
width: size * 0.84,
height: size * 0.56,
borderWidth: w,
borderColor: color,
borderRadius: 4,
}}
/>
<View style={{ position: "absolute", top: size * 0.32 + size * 0.2, width: size * 0.84, height: w, backgroundColor: color }} />
</View>
);
case "close":
return (
<View style={[box, s.center]}>
<View style={{ position: "absolute", width: size * 0.6, height: w, borderRadius: 2, backgroundColor: color, transform: [{ rotate: "45deg" }] }} />
<View style={{ position: "absolute", width: size * 0.6, height: w, borderRadius: 2, backgroundColor: color, transform: [{ rotate: "-45deg" }] }} />
</View>
);
case "plus":
return (
<View style={[box, s.center]}>
<View style={{ position: "absolute", width: size * 0.7, height: w + 0.5, borderRadius: 2, backgroundColor: color }} />
<View style={{ position: "absolute", width: w + 0.5, height: size * 0.7, borderRadius: 2, backgroundColor: color }} />
</View>
);
case "chevron":
return (
<View style={[box, s.center]}>
<View
style={{
width: size * 0.34,
height: size * 0.34,
borderRightWidth: w,
borderTopWidth: w,
borderColor: color,
transform: [{ rotate: "45deg" }],
marginLeft: -size * 0.06,
}}
/>
</View>
);
}
}
const s = StyleSheet.create({
center: { alignItems: "center", justifyContent: "center" },
sliderRow: { justifyContent: "center" },
});

View File

@@ -0,0 +1,43 @@
import { StyleSheet, View } from "react-native";
import { haptic } from "../lib/haptics";
import { PRIORITIES, PRIORITY_COLOR, PRIORITY_LABEL, PRIORITY_TINT } from "../lib/priority";
import { C, space } from "../lib/theme";
import type { Task } from "../lib/types";
import { ChipRow, OptionChip, SectionHeader } from "./ui";
/** Omit `label` when the surrounding panel already titles the group. */
export default function PriorityChips({
value,
onChange,
label,
}: {
value: Task["priority"] | null;
onChange: (p: Task["priority"]) => void;
label?: string;
}) {
return (
<View style={label ? { marginBottom: space.lg } : undefined}>
{!!label && <SectionHeader>{label}</SectionHeader>}
<ChipRow>
{PRIORITIES.map((p) => (
<OptionChip
key={p}
label={PRIORITY_LABEL[p]}
active={value === p}
tone={PRIORITY_COLOR[p]}
tint={PRIORITY_TINT[p]}
leading={<View style={[s.flag, { backgroundColor: PRIORITY_COLOR[p] }]} />}
onPress={() => {
haptic.select();
onChange(p);
}}
/>
))}
</ChipRow>
</View>
);
}
const s = StyleSheet.create({
flag: { width: 9, height: 9, borderRadius: 999, backgroundColor: C.muted },
});

View File

@@ -0,0 +1,70 @@
// The three-tab bar under every top-level screen: Next up, Today, Lists.
//
// Three is the number a thumb can hit without looking. The old launcher had
// six buttons and a second row of alerts; everything that was there is still
// reachable, but through the Lists board or the header, so the bar only
// carries the places you go many times a day.
import type { BottomTabBarProps } from "expo-router/js-tabs";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { haptic } from "../lib/haptics";
import { C, space } from "../lib/theme";
import { useStore } from "../lib/useStore";
import { AREA_TONE } from "./AreaToggle";
import Icon, { type IconName } from "./Icon";
const TABS: Record<string, { icon: IconName; label: string }> = {
index: { icon: "next", label: "Next up" },
today: { icon: "today", label: "Today" },
lists: { icon: "grid", label: "Lists" },
};
const ICON = 22;
const LABEL = 14;
/** Bar height above the safe-area inset — the FAB sits this far up plus a gutter. */
export const TAB_BAR_CONTENT_HEIGHT = space.md + ICON + 5 + LABEL;
export const tabBarInset = (bottom: number) => Math.max(bottom, space.md);
export default function TabBar({ state, navigation }: BottomTabBarProps) {
const insets = useSafeAreaInsets();
// The active tab takes the lens colour, so Work mode reads as Work all the
// way down the screen, not just in the header pill.
const tone = AREA_TONE[useStore().area];
return (
<View style={[s.bar, { paddingBottom: tabBarInset(insets.bottom) }]}>
{state.routes.map((route, i) => {
const focused = state.index === i;
const tab = TABS[route.name] ?? { icon: "next" as IconName, label: route.name };
return (
<Pressable
key={route.key}
accessibilityRole="tab"
accessibilityState={{ selected: focused }}
style={({ pressed }) => [s.tab, pressed && { opacity: 0.5 }]}
onPress={() => {
const e = navigation.emit({ type: "tabPress", target: route.key, canPreventDefault: true });
if (focused || e.defaultPrevented) return;
haptic.tap();
navigation.navigate(route.name);
}}
>
<Icon name={tab.icon} size={ICON} color={focused ? tone : C.textSecondary} bg={C.surface} />
<Text style={[s.label, focused && { color: tone, fontWeight: "600" }]}>{tab.label}</Text>
</Pressable>
);
})}
</View>
);
}
const s = StyleSheet.create({
bar: {
flexDirection: "row",
backgroundColor: C.surface,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: C.border,
paddingTop: space.md,
},
tab: { flex: 1, alignItems: "center", gap: 5 },
label: { fontSize: 11, lineHeight: LABEL, color: C.textSecondary, letterSpacing: 0.1 },
});

View File

@@ -0,0 +1,334 @@
// The task editor, shared by capture and clarify.
//
// Both screens are the same object seen at two moments, so they get the same
// shape: a title/notes card, then one wrapping row of attribute chips. Keeping
// it in one file means a change to how "When" or "Repeat" is picked lands in
// both places at once, and the two screens can never drift into looking like
// different apps.
import { StyleSheet, Text, TextInput, View } from "react-native";
import AttrBar, { type Attr } from "./AttrBar";
import DateField from "./DateField";
import EstimateChips from "./EstimateChips";
import PriorityChips from "./PriorityChips";
import { Chips, Separator } from "./ui";
import { friendlyDate } from "../lib/dates";
import { haptic } from "../lib/haptics";
import { formatEstimate, PRIORITY_COLOR, PRIORITY_LABEL } from "../lib/priority";
import { REPEAT_LABEL, REPEATS } from "../lib/repeat";
import { getState, knownContexts } from "../lib/store";
import { C, radius, space, type } from "../lib/theme";
import { useStore } from "../lib/useStore";
import { AREA_LABEL, AREAS, type Task, type TaskStatus } from "../lib/types";
import { AREA_TONE } from "./AreaToggle";
/** Everything a task screen edits. Priority is nullable while undecided. */
export type TaskDraft = Pick<
Task,
"title" | "notes" | "status" | "project_id" | "context" | "waiting_for" | "due_date" | "defer_date" | "estimate_min" | "repeat" | "area"
> & { priority: Task["priority"] | null };
export const BOX_LABEL: Record<string, string> = {
next: "Next up",
waiting: "Waiting",
someday: "Someday",
done: "Done",
};
/** The boxes a task can be filed into by hand. */
export const CAPTURE_BOXES: TaskStatus[] = ["next", "waiting", "someday"];
export const emptyDraft = (): TaskDraft => ({
title: "",
notes: "",
status: "next",
project_id: null,
context: null,
waiting_for: null,
due_date: null,
defer_date: null,
estimate_min: null,
repeat: null,
priority: null,
// Born into whichever lens is on — that's what "add" means in work mode.
area: getState().area,
});
export default function TaskForm({
value: d,
onChange,
boxes = CAPTURE_BOXES,
showDefer = false,
autoFocus = false,
onSubmitTitle,
hint,
}: {
value: TaskDraft;
onChange: (patch: Partial<TaskDraft>) => void;
boxes?: TaskStatus[];
/** Clarify offers a start date; capture keeps the bar short. */
showDefer?: boolean;
autoFocus?: boolean;
onSubmitTitle?: () => void;
hint?: string;
}) {
const store = useStore();
// Keep a project that has since been archived visible, or editing an old task
// would silently drop it.
// Projects follow the draft's area, not the global lens, so re-filing a task
// to Work offers Work projects. An archived project stays visible while the
// task still points at it.
const projects = store.projects
.filter((p) => (p.status === "active" && (p.area ?? "personal") === d.area) || p.id === d.project_id)
.sort((a, b) => a.sort_order - b.sort_order || b.created_at.localeCompare(a.created_at));
const contexts = knownContexts(store);
const projectName = projects.find((p) => p.id === d.project_id)?.name ?? null;
const attrs: Attr[] = [
{
key: "when",
icon: "today",
placeholder: "When",
value: d.due_date ? friendlyDate(d.due_date) : null,
panel: (
<DateField
value={d.due_date}
onChange={(due_date) => onChange({ due_date })}
clearable
someday={{
active: d.status === "someday",
onPress: () =>
d.status === "someday"
? onChange({ status: "next" })
: onChange({ status: "someday", due_date: null }),
}}
/>
),
onClear: () => onChange({ due_date: null }),
},
{
key: "priority",
icon: "flag",
placeholder: "Priority",
value: d.priority ? PRIORITY_LABEL[d.priority] : null,
tone: d.priority ? PRIORITY_COLOR[d.priority] : undefined,
panel: <PriorityChips value={d.priority} onChange={(priority) => onChange({ priority })} />,
onClear: () => onChange({ priority: null }),
},
{
key: "box",
icon: "next",
// "Next up" is the default, so it reads as a placeholder until changed —
// only a deliberate choice earns the accent.
placeholder: BOX_LABEL.next,
value: d.status === "next" ? null : BOX_LABEL[d.status],
panel: (
<View>
<Chips
options={boxes}
labels={BOX_LABEL}
value={d.status}
onChange={(status) => {
haptic.select();
onChange({ status });
}}
/>
{d.status === "waiting" && (
<TextInput
style={s.panelInput}
value={d.waiting_for ?? ""}
onChangeText={(v) => onChange({ waiting_for: v || null })}
placeholder="Waiting for who or what?"
placeholderTextColor={C.muted}
/>
)}
</View>
),
},
{
key: "project",
icon: "projects",
placeholder: "Project",
value: projectName,
panel:
projects.length === 0 ? (
<Text style={s.panelEmpty}>No active projects yet.</Text>
) : (
<Chips
options={projects.map((p) => p.id)}
labels={Object.fromEntries(projects.map((p) => [p.id, p.name]))}
value={d.project_id}
onChange={(v) => {
haptic.select();
onChange({ project_id: v === d.project_id ? null : v });
}}
/>
),
onClear: () => onChange({ project_id: null }),
},
{
key: "estimate",
icon: "waiting",
placeholder: "Estimate",
value: formatEstimate(d.estimate_min),
panel: <EstimateChips value={d.estimate_min} onChange={(estimate_min) => onChange({ estimate_min })} />,
onClear: () => onChange({ estimate_min: null }),
},
{
key: "repeat",
icon: "repeat",
placeholder: "Repeat",
value: d.repeat ? REPEAT_LABEL[d.repeat] : null,
panel: (
<Chips
options={REPEATS}
labels={REPEAT_LABEL}
value={d.repeat}
onChange={(v) => {
haptic.select();
onChange({ repeat: v === d.repeat ? null : v });
}}
/>
),
onClear: () => onChange({ repeat: null }),
},
];
// Area reads as a placeholder while it matches the lens you're in — only a
// deliberate cross-filing (a work task captured from personal) earns colour.
attrs.push({
key: "area",
icon: "area",
placeholder: AREA_LABEL[store.area],
value: d.area === store.area ? null : AREA_LABEL[d.area],
tone: AREA_TONE[d.area],
panel: (
<View>
<Chips
options={AREAS}
labels={AREA_LABEL}
value={d.area}
onChange={(area) => {
if (area === d.area) return;
haptic.select();
// A project belongs to one area; drop it when the task moves.
onChange({ area, project_id: null });
}}
/>
<Text style={s.panelEmpty}>Only shows up when that lens is on.</Text>
</View>
),
});
if (showDefer) {
attrs.push({
key: "defer",
icon: "someday",
placeholder: "Start",
value: d.defer_date ? friendlyDate(d.defer_date) : null,
panel: (
<View>
<DateField value={d.defer_date} onChange={(defer_date) => onChange({ defer_date })} clearable />
<Text style={s.panelEmpty}>Hidden from lists until then — it resurfaces on the day.</Text>
</View>
),
onClear: () => onChange({ defer_date: null }),
});
}
// Only offer the tag picker once there's something to pick — an empty chip
// that opens an empty panel is worse than no chip.
if (contexts.length > 0 || d.context) {
const options = d.context && !contexts.includes(d.context) ? [...contexts, d.context] : contexts;
attrs.push({
key: "context",
icon: "tag",
placeholder: "Tag",
value: d.context,
panel: (
<Chips
options={options}
value={d.context}
onChange={(v) => {
haptic.select();
onChange({ context: v === d.context ? null : v });
}}
/>
),
onClear: () => onChange({ context: null }),
});
}
return (
<View>
<View style={s.card}>
<TextInput
style={s.titleInput}
value={d.title}
onChangeText={(title) => onChange({ title })}
placeholder="New to-do"
placeholderTextColor={C.muted}
autoFocus={autoFocus}
returnKeyType="done"
onSubmitEditing={onSubmitTitle}
/>
<Separator />
<TextInput
style={s.notesInput}
value={d.notes}
onChangeText={(notes) => onChange({ notes })}
placeholder="Notes"
placeholderTextColor={C.muted}
multiline
/>
</View>
{!!hint && <Text style={s.hint}>{hint}</Text>}
<AttrBar attrs={attrs} />
</View>
);
}
const s = StyleSheet.create({
card: {
backgroundColor: C.surface,
borderRadius: radius.lg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: C.border,
overflow: "hidden",
},
titleInput: {
color: C.text,
fontSize: 20,
fontWeight: "600",
letterSpacing: -0.3,
paddingHorizontal: space.lg,
paddingVertical: space.lg - 2,
},
notesInput: {
color: C.text,
fontSize: 15,
minHeight: 76,
textAlignVertical: "top",
paddingHorizontal: space.lg,
paddingVertical: space.md,
},
hint: {
...type.meta,
color: C.muted,
paddingHorizontal: space.xs,
paddingVertical: space.md,
},
panelInput: {
backgroundColor: C.surface2,
color: C.text,
borderRadius: radius.md,
paddingHorizontal: space.md,
paddingVertical: space.md - 2,
fontSize: 15,
marginTop: space.md,
},
panelEmpty: { ...type.meta, color: C.muted, paddingBottom: space.sm },
});

View File

@@ -1,82 +1,111 @@
import { useRouter } from "expo-router";
import { useState } from "react";
import {
FlatList,
Pressable,
RefreshControl,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { createTask, sync, tasksByStatus, updateTask } from "../lib/store";
import { C } from "../lib/theme";
import { Platform, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native";
import DraggableFlatList, { ScaleDecorator } from "react-native-draggable-flatlist";
import WebSortableList from "./WebSortableList";
import { haptic } from "../lib/haptics";
import { reorderTask, sync, tasksByStatus, visibleTasksByStatus } from "../lib/store";
import { C, radius, shadow, space, type } from "../lib/theme";
import { useStore } from "../lib/useStore";
import type { Task, TaskStatus } from "../lib/types";
import type { TaskStatus } from "../lib/types";
import TaskRow, { TaskSeparator } from "./TaskRow";
import { cardEdges } from "./ui";
function TaskRow({ task, listStatus }: { task: Task; listStatus: TaskStatus }) {
const router = useRouter();
const meta = [task.context, task.waiting_for && `→ ${task.waiting_for}`, task.due_date && `due ${task.due_date}`]
.filter(Boolean)
.join(" · ");
return (
<Pressable style={s.row} onPress={() => router.push(`/task/${task.id}`)}>
<Pressable
style={s.checkbox}
hitSlop={10}
onPress={() => updateTask(task.id, { status: task.status === "done" ? listStatus : "done" })}
>
{task.status === "done" && <View style={s.checkboxFill} />}
</Pressable>
<View style={{ flex: 1 }}>
<Text style={s.title} numberOfLines={2}>
{task.title}
</Text>
{!!meta && <Text style={s.meta}>{meta}</Text>}
</View>
</Pressable>
);
}
// Deferred tasks are hidden from these lists until their date — you can't act
// on them yet, so they shouldn't cost attention. A one-line footer keeps them
// discoverable (trust needs "hidden, not gone").
const HIDES_DEFERRED: TaskStatus[] = ["next", "waiting", "someday"];
// Archives read newest-first and aren't hand-ordered.
const ARCHIVE: TaskStatus[] = ["done", "trashed"];
export default function TaskListScreen({
status,
capture,
emptyHint,
}: {
status: TaskStatus;
capture?: boolean;
emptyHint: string;
}) {
const store = useStore();
const [refreshing, setRefreshing] = useState(false);
const [draft, setDraft] = useState("");
const tasks = tasksByStatus(store, status);
const [showDeferred, setShowDeferred] = useState(false);
const hidesDeferred = HIDES_DEFERRED.includes(status);
const archive = ARCHIVE.includes(status);
const add = () => {
const title = draft.trim();
if (!title) return;
setDraft("");
createTask({ title, status });
};
const all = tasksByStatus(store, status);
let tasks = hidesDeferred && !showDeferred ? visibleTasksByStatus(store, status) : all;
if (archive) {
tasks = [...all].sort((a, b) =>
(b.completed_at ?? b.updated_at).localeCompare(a.completed_at ?? a.updated_at),
);
}
const hiddenCount = all.length - tasks.length;
const deferredFooter =
hidesDeferred && (hiddenCount > 0 || showDeferred) ? (
<Pressable
onPress={() => setShowDeferred((v) => !v)}
style={({ pressed }) => [s.deferredBtn, pressed && { opacity: 0.6 }]}
>
<Text style={s.deferredText}>
{showDeferred ? "Hide deferred" : `${hiddenCount} deferred — show`}
</Text>
</Pressable>
) : null;
// The native drag library's drop handling is dead on react-native-web, so
// the web build reorders through WebSortableList's grab handles instead.
if (Platform.OS === "web") {
return (
<View style={s.screen}>
<ScrollView contentContainerStyle={s.content} showsVerticalScrollIndicator={false}>
{tasks.length === 0 && <Text style={s.empty}>{emptyHint}</Text>}
<WebSortableList
data={tasks}
disabled={archive}
onReorder={(id, to) => reorderTask(id, status, to)}
separator={<TaskSeparator />}
renderItem={(item, i) => (
<View style={cardEdges(i, tasks.length)}>
<TaskRow task={item} uncheckTo={status === "done" ? "next" : status} />
</View>
)}
/>
{deferredFooter}
</ScrollView>
</View>
);
}
return (
<View style={s.screen}>
{capture && (
<TextInput
style={s.capture}
placeholder="Capture anything…"
placeholderTextColor={C.muted}
value={draft}
onChangeText={setDraft}
onSubmitEditing={add}
submitBehavior="submit"
returnKeyType="done"
/>
)}
<FlatList
<DraggableFlatList
data={tasks}
keyExtractor={(t) => t.id}
renderItem={({ item }) => <TaskRow task={item} listStatus={status} />}
activationDistance={12}
onDragBegin={() => haptic.pickup()}
onDragEnd={({ data, from, to }) => {
if (!archive && from !== to) {
haptic.success();
reorderTask(data[to].id, status, to);
}
}}
// The card is drawn by the rows themselves so drag-and-drop still owns
// the whole list: first/last rows round their outer corners, and a
// lifted row goes full-radius so it reads as detached from the stack.
renderItem={({ item, drag, isActive, getIndex }) => {
const i = getIndex() ?? 0;
return (
<ScaleDecorator activeScale={1.02}>
<View style={[...cardEdges(i, tasks.length), isActive && s.lifted]}>
<TaskRow
task={item}
uncheckTo={status === "done" ? "next" : status}
onLongPress={archive ? undefined : drag}
/>
</View>
</ScaleDecorator>
);
}}
ItemSeparatorComponent={TaskSeparator}
refreshControl={
<RefreshControl
refreshing={refreshing}
@@ -89,7 +118,8 @@ export default function TaskListScreen({
/>
}
ListEmptyComponent={<Text style={s.empty}>{emptyHint}</Text>}
contentContainerStyle={{ paddingBottom: 32 }}
ListFooterComponent={deferredFooter}
contentContainerStyle={s.content}
/>
</View>
);
@@ -97,37 +127,16 @@ export default function TaskListScreen({
const s = StyleSheet.create({
screen: { flex: 1, backgroundColor: C.bg },
capture: {
margin: 12,
marginBottom: 4,
padding: 14,
borderRadius: 12,
backgroundColor: C.surface,
color: C.text,
fontSize: 16,
borderWidth: 1,
borderColor: C.border,
content: { paddingHorizontal: space.lg, paddingTop: space.md, paddingBottom: 96 },
lifted: { borderRadius: radius.lg, ...shadow.fab },
empty: {
...type.body,
color: C.muted,
textAlign: "center",
marginTop: 72,
paddingHorizontal: space.xxl,
lineHeight: 22,
},
row: {
flexDirection: "row",
alignItems: "center",
gap: 12,
paddingVertical: 14,
paddingHorizontal: 16,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: C.border,
},
checkbox: {
width: 22,
height: 22,
borderRadius: 11,
borderWidth: 2,
borderColor: C.muted,
alignItems: "center",
justifyContent: "center",
},
checkboxFill: { width: 12, height: 12, borderRadius: 6, backgroundColor: C.done },
title: { color: C.text, fontSize: 16 },
meta: { color: C.muted, fontSize: 13, marginTop: 2 },
empty: { color: C.muted, textAlign: "center", marginTop: 48, fontSize: 15 },
deferredBtn: { paddingVertical: space.lg, alignItems: "center" },
deferredText: { ...type.meta, color: C.textSecondary, fontWeight: "500" },
});

View File

@@ -0,0 +1,141 @@
import { useRouter } from "expo-router";
import { Pressable, StyleSheet, Text, View } from "react-native";
import Animated, { FadeIn } from "react-native-reanimated";
import { dueMeta, friendlyDate, isDeferred } from "../lib/dates";
import { haptic } from "../lib/haptics";
import { formatEstimate, PRIORITY_COLOR } from "../lib/priority";
import { updateTask } from "../lib/store";
import { C, radius, space, type } from "../lib/theme";
import { completeWithUndo } from "../lib/undo";
import type { Task, TaskStatus } from "../lib/types";
export default function TaskRow({
task,
uncheckTo = "next",
showStatus,
onLongPress,
emphasized,
}: {
task: Task;
/** Status to revert to when un-checking a done task. */
uncheckTo?: TaskStatus;
showStatus?: boolean;
/** Drag handle for reorderable lists. */
onLongPress?: () => void;
/** The one thing to do now — bigger, tinted, labelled. */
emphasized?: boolean;
}) {
const router = useRouter();
const done = task.status === "done";
// Due date is rendered separately so it can carry a tone color; the reader
// sees "due today" in amber, never does date math on an ISO string.
const due = task.due_date ? dueMeta(task.due_date) : null;
const dueColor = due?.tone === "overdue" ? C.danger : due?.tone === "today" ? C.warn : C.muted;
const base = [
showStatus ? task.status : null,
task.repeat && "↻",
task.context,
task.waiting_for && `→ ${task.waiting_for}`,
formatEstimate(task.estimate_min),
isDeferred(task.defer_date) && `⏸ until ${friendlyDate(task.defer_date!)}`,
]
.filter(Boolean)
.join(" · ");
const meta = base || due;
const flag = PRIORITY_COLOR[task.priority];
const toggle = () => {
if (!done) {
completeWithUndo(task);
return;
}
haptic.tap();
updateTask(task.id, { status: uncheckTo });
};
return (
<Animated.View entering={FadeIn.duration(150)}>
<Pressable
style={({ pressed }) => [s.row, emphasized && s.rowEmphasized, pressed && s.rowPressed]}
onPress={() => router.push(`/task/${task.id}`)}
onLongPress={onLongPress}
delayLongPress={200}
>
{/* Rounded square rather than a circle — the Things checkbox shape.
The border carries the priority colour, so priority is legible at a
glance without spending a whole column on a flag. */}
<Pressable
style={[
s.checkbox,
emphasized && s.checkboxEmphasized,
{ borderColor: done ? C.done : flag },
done && s.checkboxDone,
]}
hitSlop={10}
onPress={toggle}
>
{done && <Text style={s.check}>✓</Text>}
</Pressable>
<View style={{ flex: 1 }}>
{emphasized && <Text style={s.nowLabel}>NOW</Text>}
<Text style={[s.title, emphasized && s.titleEmphasized, done && s.titleDone]} numberOfLines={2}>
{task.title}
</Text>
{!!meta && (
<Text style={s.meta} numberOfLines={1}>
{base}
{!!base && !!due && " · "}
{due && <Text style={{ color: dueColor, fontWeight: "500" }}>{due.label}</Text>}
</Text>
)}
</View>
</Pressable>
</Animated.View>
);
}
const s = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
gap: space.md,
paddingVertical: space.md + 2,
paddingHorizontal: space.lg,
backgroundColor: C.surface,
},
rowPressed: { backgroundColor: C.surface2 },
rowEmphasized: { backgroundColor: C.accentSoft, paddingVertical: space.lg + 2 },
checkboxEmphasized: { width: 26, height: 26, borderRadius: 8 },
nowLabel: { fontSize: 10, fontWeight: "700", letterSpacing: 1.2, color: C.accent, marginBottom: 2 },
titleEmphasized: { fontSize: 19, fontWeight: "600", lineHeight: 24 },
checkbox: {
width: 22,
height: 22,
borderRadius: 7,
borderWidth: 1.5,
alignItems: "center",
justifyContent: "center",
},
checkboxDone: { backgroundColor: C.done },
check: { color: C.onAccent, fontSize: 13, lineHeight: 16, fontWeight: "700" },
title: { ...type.row, color: C.text },
titleDone: { color: C.muted, textDecorationLine: "line-through" },
meta: { ...type.meta, color: C.muted, marginTop: 3 },
});
/** Inset hairline sized to line up under the task title, not the checkbox. */
export const TASK_ROW_INSET = space.lg + 22 + space.md;
export function TaskSeparator() {
return (
<View
style={{
height: StyleSheet.hairlineWidth,
backgroundColor: C.separator,
marginLeft: TASK_ROW_INSET,
// Keeps the hairline from showing through a card's rounded corner.
borderRadius: radius.sm,
}}
/>
);
}

View File

@@ -0,0 +1,98 @@
// One app-wide snackbar, mounted once in the root layout. `showToast` is a
// plain function so stores and rows can call it without a context hop. A new
// toast replaces the current one (last action wins) and the timer restarts.
import { useEffect, useState } from "react";
import { Pressable, StyleSheet, Text } from "react-native";
import Animated, { FadeInDown, FadeOutDown } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { haptic } from "../lib/haptics";
import { C, radius, shadow, space, type } from "../lib/theme";
export interface ToastSpec {
message: string;
action?: { label: string; onPress: () => void };
}
const DURATION_MS = 5000;
// Sits on the home FAB's baseline (index.tsx `fab.bottom`) — above the
// launcher bar, and the right padding leaves the FAB itself uncovered.
const FAB_BOTTOM = 104;
const FAB_CLEARANCE = space.xl + 56 + space.md;
let setCurrent: ((t: ToastSpec | null) => void) | null = null;
export function showToast(spec: ToastSpec) {
setCurrent?.(spec);
}
export function ToastHost() {
const [toast, setToast] = useState<ToastSpec | null>(null);
const insets = useSafeAreaInsets();
useEffect(() => {
setCurrent = setToast;
return () => {
if (setCurrent === setToast) setCurrent = null;
};
}, []);
useEffect(() => {
if (!toast) return;
const timer = setTimeout(() => setToast(null), DURATION_MS);
return () => clearTimeout(timer);
}, [toast]);
if (!toast) return null;
const { action } = toast;
return (
<Animated.View
entering={FadeInDown.duration(180)}
exiting={FadeOutDown.duration(150)}
style={[s.wrap, { bottom: FAB_BOTTOM + insets.bottom }]}
pointerEvents="box-none"
>
<Pressable style={s.toast} onPress={() => setToast(null)}>
<Text style={s.message} numberOfLines={1}>
{toast.message}
</Text>
{action && (
<Pressable
hitSlop={12}
onPress={() => {
haptic.tap();
setToast(null);
action.onPress();
}}
style={({ pressed }) => [s.action, pressed && { opacity: 0.6 }]}
>
<Text style={s.actionText}>{action.label}</Text>
</Pressable>
)}
</Pressable>
</Animated.View>
);
}
const s = StyleSheet.create({
wrap: {
position: "absolute",
left: space.lg,
right: FAB_CLEARANCE,
alignItems: "flex-start",
},
toast: {
flexDirection: "row",
alignItems: "center",
gap: space.lg,
maxWidth: 420,
paddingVertical: space.md,
paddingLeft: space.lg,
paddingRight: space.md,
borderRadius: radius.pill,
backgroundColor: C.text,
...shadow.fab,
},
message: { ...type.row, color: C.bg, flexShrink: 1 },
action: { paddingVertical: space.xs, paddingHorizontal: space.sm },
actionText: { ...type.row, color: C.accentSoft, fontWeight: "600" },
});

View File

@@ -0,0 +1,115 @@
// Web-only reorderable list. react-native-draggable-flatlist's drop handling
// never completes on react-native-web (onDragEnd doesn't fire), and the
// PanResponder path is unreliable there too — so each row gets a grab handle
// driven by raw pointer events: pointerdown on the handle, window-level
// move/up listeners, and on release the target slot is computed from measured
// row heights and handed to onReorder (the same store mutation native uses).
import { useState, type ReactNode } from "react";
import { StyleSheet, Text, View, type GestureResponderEvent, type ViewStyle } from "react-native";
import { C, space } from "../lib/theme";
export default function WebSortableList<T extends { id: string }>({
data,
renderItem,
onReorder,
separator,
disabled,
}: {
data: T[];
renderItem: (item: T, index: number) => ReactNode;
/** Called with the dragged row's id and the index it was dropped at. */
onReorder: (id: string, to: number) => void;
separator?: ReactNode;
disabled?: boolean;
}) {
// Stable mutable array (not a ref: refs may not be read during render).
const [heights] = useState<number[]>([]);
const [drag, setDrag] = useState<{ index: number; dy: number } | null>(null);
const targetIndex = (from: number, dy: number) => {
let idx = from;
let acc = 0;
while (dy > 0 && idx < data.length - 1 && dy > acc + (heights[idx + 1] ?? 48) / 2) {
acc += heights[idx + 1] ?? 48;
idx++;
}
while (dy < 0 && idx > 0 && -dy > acc + (heights[idx - 1] ?? 48) / 2) {
acc += heights[idx - 1] ?? 48;
idx--;
}
return idx;
};
const startDrag = (i: number, e: GestureResponderEvent) => {
if (disabled) return;
// RNW delivers a real PointerEvent; keep it off the row's Pressable.
e.stopPropagation();
e.preventDefault();
const native = e.nativeEvent as unknown as { clientY?: number; pageY: number };
const startY = native.clientY ?? native.pageY;
setDrag({ index: i, dy: 0 });
let dy = 0;
const move = (ev: PointerEvent) => {
dy = ev.clientY - startY;
setDrag({ index: i, dy });
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
setDrag(null);
const to = targetIndex(i, dy);
if (to !== i) onReorder(data[i].id, to);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
};
return (
<View>
{data.map((item, i) => {
const dragging = drag?.index === i;
return (
<View key={item.id}>
{i > 0 && separator}
<View
onLayout={(e) => (heights[i] = e.nativeEvent.layout.height)}
style={dragging && [s.dragging, { transform: [{ translateY: drag.dy }] }]}
>
{renderItem(item, i)}
{!disabled && (
<View onPointerDown={(e) => startDrag(i, e as unknown as GestureResponderEvent)} style={handleStyle}>
<Text style={s.handleGlyph}>⠿</Text>
</View>
)}
</View>
</View>
);
})}
</View>
);
}
const s = StyleSheet.create({
dragging: {
zIndex: 10,
elevation: 10,
shadowColor: "#000",
shadowOpacity: 0.15,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
},
handle: {
position: "absolute",
right: 0,
top: 0,
bottom: 0,
width: 44,
alignItems: "center",
justifyContent: "center",
},
handleGlyph: { color: C.muted, fontSize: 15, paddingHorizontal: space.sm },
});
// Pointer affordances react-native-web honours but RN's ViewStyle doesn't
// type ("grab" is not a CursorValue) — cast once, here, instead of per use.
const handleStyle = { ...s.handle, cursor: "grab", touchAction: "none", userSelect: "none" } as unknown as ViewStyle;

View File

@@ -1,15 +1,121 @@
import { Pressable, StyleSheet, Text, TextInput, View, type TextInputProps } from "react-native";
import { C } from "../lib/theme";
import { Pressable, StyleSheet, Text, TextInput, View, type TextInputProps, type StyleProp, type ViewStyle } from "react-native";
import { C, radius, shadow, space, type } from "../lib/theme";
export function Field({ label, ...props }: TextInputProps & { label: string }) {
/**
* Screen background + consistent gutter. Every screen sits on the grouped
* background so white cards read as raised paper.
*/
export function Screen({ children, style }: { children: React.ReactNode; style?: StyleProp<ViewStyle> }) {
return <View style={[u.screen, style]}>{children}</View>;
}
/**
* Large in-content heading. Nav-bar titles are tiny and centred; Things puts
* the screen name in the content at display size, which is what gives each
* screen its sense of place.
*/
export function Title({ children, count }: { children: React.ReactNode; count?: number }) {
return (
<View style={{ marginBottom: 14 }}>
<Text style={u.label}>{label}</Text>
<TextInput style={u.input} placeholderTextColor={C.muted} {...props} />
<View style={u.titleRow}>
<Text style={u.title}>{children}</Text>
{count !== undefined && count > 0 && <Text style={u.titleCount}>{count}</Text>}
</View>
);
}
/** Small caps label above a group. Sentence case — SHOUTING CAPS reads dated. */
export function SectionHeader({ children, tone }: { children: React.ReactNode; tone?: string }) {
return <Text style={[u.sectionHeader, !!tone && { color: tone }]}>{children}</Text>;
}
/**
* Rounded white group. Rows inside are separated by inset hairlines rather
* than full-bleed dividers, so the card keeps a clean silhouette.
*/
export function Card({ children, style }: { children: React.ReactNode; style?: StyleProp<ViewStyle> }) {
return <View style={[u.card, style]}>{children}</View>;
}
/**
* Row background plus rounded outer corners for the first and last row of a
* list, so a plain FlatList reads as one card without wrapping it in a `Card`
* (which would break drag-and-drop and section headers).
*/
export function cardEdges(index: number, count: number) {
return [u.cardRow, index === 0 && u.cardTop, index === count - 1 && u.cardBottom];
}
/** Inset hairline for use between rows inside a `Card`. */
export function Separator({ inset = 0 }: { inset?: number }) {
return <View style={[u.separator, { marginLeft: inset }]} />;
}
/** Compact count/status pill. */
export function Pill({ label, tone }: { label: string; tone?: string }) {
return (
<View style={[u.pill, !!tone && { backgroundColor: `${tone}18` }]}>
<Text style={[u.pillText, !!tone && { color: tone }]}>{label}</Text>
</View>
);
}
/** Centred empty state. One quiet line — no illustration, no exclamation. */
export function Empty({ children }: { children: React.ReactNode }) {
return <Text style={u.empty}>{children}</Text>;
}
export function Field({ label, style, ...props }: TextInputProps & { label: string }) {
return (
<View style={{ marginBottom: space.lg }}>
<Text style={u.label}>{label}</Text>
{/* style last so callers can extend the input (e.g. multiline height)
without silently dropping the base styling. */}
<TextInput style={[u.input, style]} placeholderTextColor={C.muted} {...props} />
</View>
);
}
/** One wrapping row of option chips. The single source of chip geometry. */
export function ChipRow({ children }: { children: React.ReactNode }) {
return <View style={u.chipRow}>{children}</View>;
}
/**
* A single selectable chip. `tone` colours the selected border/text and `tint`
* its fill — priority chips pass their flag colour, everything else falls back
* to the app accent.
*/
export function OptionChip({
label,
active,
onPress,
tone = C.accent,
tint = C.accentSoft,
leading,
}: {
label: string;
active?: boolean;
onPress: () => void;
tone?: string;
tint?: string;
leading?: React.ReactNode;
}) {
return (
<Pressable
style={({ pressed }) => [
u.chip,
active && { borderColor: tone, backgroundColor: tint },
pressed && !active && u.chipPressed,
]}
onPress={onPress}
>
{leading}
<Text style={[u.chipText, active && { color: tone, fontWeight: "600" }]}>{label}</Text>
</Pressable>
);
}
/** Labelled chip group. Omit `label` when the surrounding panel already titles it. */
export function Chips<T extends string>({
label,
options,
@@ -17,26 +123,25 @@ export function Chips<T extends string>({
onChange,
labels,
}: {
label: string;
label?: string;
options: T[];
value: T | null;
onChange: (v: T) => void;
labels?: Record<string, string>;
}) {
return (
<View style={{ marginBottom: 14 }}>
<Text style={u.label}>{label}</Text>
<View style={u.chipRow}>
<View style={label ? { marginBottom: space.lg } : undefined}>
{!!label && <Text style={u.label}>{label}</Text>}
<ChipRow>
{options.map((o) => (
<Pressable
<OptionChip
key={o}
style={[u.chip, value === o && u.chipActive]}
label={labels?.[o] ?? o}
active={value === o}
onPress={() => onChange(o)}
>
<Text style={[u.chipText, value === o && { color: C.text }]}>{labels?.[o] ?? o}</Text>
</Pressable>
/>
))}
</View>
</ChipRow>
</View>
);
}
@@ -45,50 +150,119 @@ export function Button({
title,
onPress,
danger,
secondary,
style,
}: {
title: string;
onPress: () => void;
danger?: boolean;
secondary?: boolean;
style?: StyleProp<ViewStyle>;
}) {
const variant = danger ? u.buttonDanger : secondary ? u.buttonSecondary : null;
const textVariant = danger ? u.buttonTextDanger : secondary ? u.buttonTextSecondary : null;
return (
<Pressable
style={[u.button, danger && { backgroundColor: "transparent", borderColor: C.danger, borderWidth: 1 }]}
style={({ pressed }) => [u.button, variant, style, pressed && { opacity: 0.75 }]}
onPress={onPress}
>
<Text style={[u.buttonText, danger && { color: C.danger }]}>{title}</Text>
<Text style={[u.buttonText, textVariant]}>{title}</Text>
</Pressable>
);
}
const u = StyleSheet.create({
label: { color: C.muted, fontSize: 13, marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 },
screen: { flex: 1, backgroundColor: C.bg },
cardRow: { backgroundColor: C.surface },
cardTop: { borderTopLeftRadius: radius.lg, borderTopRightRadius: radius.lg },
cardBottom: {
borderBottomLeftRadius: radius.lg,
borderBottomRightRadius: radius.lg,
...shadow.card,
},
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: space.md,
paddingHorizontal: space.lg,
paddingTop: space.md,
paddingBottom: space.lg,
},
title: { ...type.title, color: C.text },
titleCount: { ...type.title, color: C.muted, fontWeight: "600" },
sectionHeader: {
...type.section,
color: C.textSecondary,
paddingHorizontal: space.xs,
paddingTop: space.xl,
paddingBottom: space.sm,
},
card: {
backgroundColor: C.surface,
borderRadius: radius.lg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: C.border,
overflow: "hidden",
...shadow.card,
},
separator: { height: StyleSheet.hairlineWidth, backgroundColor: C.separator },
pill: {
paddingHorizontal: space.sm,
paddingVertical: 3,
borderRadius: radius.pill,
backgroundColor: C.surface2,
},
pillText: { ...type.meta, color: C.textSecondary, fontWeight: "500" },
empty: { color: C.muted, textAlign: "center", marginTop: 56, ...type.body },
label: {
...type.section,
color: C.textSecondary,
marginBottom: space.sm,
},
input: {
backgroundColor: C.surface,
color: C.text,
borderRadius: 10,
borderWidth: 1,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: C.border,
padding: 12,
paddingHorizontal: space.md,
paddingVertical: space.md,
fontSize: 16,
},
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: space.sm },
chip: {
paddingVertical: 8,
paddingHorizontal: 14,
borderRadius: 20,
flexDirection: "row",
alignItems: "center",
gap: 7,
paddingVertical: 9,
paddingHorizontal: 15,
borderRadius: radius.pill,
backgroundColor: C.surface,
borderWidth: 1,
borderWidth: StyleSheet.hairlineWidth,
borderColor: C.border,
},
chipActive: { backgroundColor: C.surface2, borderColor: C.accent },
chipText: { color: C.muted, fontSize: 14 },
chipPressed: { backgroundColor: C.surface2 },
chipText: { ...type.chip, color: C.textSecondary },
button: {
backgroundColor: C.accent,
borderRadius: 10,
padding: 14,
borderRadius: radius.md,
paddingVertical: space.lg - 2,
alignItems: "center",
marginTop: 8,
marginBottom: 12,
marginTop: space.sm,
marginBottom: space.md,
},
buttonText: { color: C.text, fontSize: 16, fontWeight: "600" },
buttonDanger: { backgroundColor: "transparent", borderWidth: StyleSheet.hairlineWidth, borderColor: C.danger },
buttonSecondary: { backgroundColor: C.surface, borderWidth: StyleSheet.hairlineWidth, borderColor: C.border },
buttonText: { fontSize: 16, fontWeight: "600", color: C.onAccent },
buttonTextDanger: { color: C.danger },
buttonTextSecondary: { color: C.text },
});

51
app/src/lib/dates.ts Normal file
View File

@@ -0,0 +1,51 @@
// Date helpers for human-friendly display. All task dates are local ISO
// "YYYY-MM-DD" strings; comparisons are lexicographic.
const DAY_MS = 86_400_000;
export function todayIso(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
export function fromIso(s: string): Date {
const [y, m, d] = s.split("-").map(Number);
return new Date(y, m - 1, d);
}
export function toIso(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
/** Deferred = hidden from action lists until the date arrives. */
export function isDeferred(deferDate: string | null, today = todayIso()): boolean {
return !!deferDate && deferDate > today;
}
export function daysFromToday(dateIso: string): number {
return Math.round((fromIso(dateIso).getTime() - fromIso(todayIso()).getTime()) / DAY_MS);
}
/** "Today" / "Tomorrow" / "Mon 18 Aug" — no ISO date math for the reader. */
export function friendlyDate(dateIso: string): string {
const diff = daysFromToday(dateIso);
if (diff === 0) return "Today";
if (diff === 1) return "Tomorrow";
if (diff === -1) return "Yesterday";
const d = fromIso(dateIso);
const wd = d.toLocaleDateString("en-AU", { weekday: "short" });
const mo = d.toLocaleDateString("en-AU", { month: "short" });
const yr = d.getFullYear() !== new Date().getFullYear() ? ` ${d.getFullYear()}` : "";
return `${wd} ${d.getDate()} ${mo}${yr}`;
}
export type DueTone = "overdue" | "today" | "later";
/** Row label for a due date, with a tone the UI can color. */
export function dueMeta(dueIso: string): { label: string; tone: DueTone } {
const diff = daysFromToday(dueIso);
if (diff < 0) return { label: diff === -1 ? "1d overdue" : `${-diff}d overdue`, tone: "overdue" };
if (diff === 0) return { label: "due today", tone: "today" };
if (diff === 1) return { label: "due tomorrow", tone: "later" };
return { label: `due ${friendlyDate(dueIso)}`, tone: "later" };
}

24
app/src/lib/haptics.ts Normal file
View File

@@ -0,0 +1,24 @@
// Haptics that silently no-op on web.
import * as Haptics from "expo-haptics";
import { Platform } from "react-native";
const canBuzz = Platform.OS !== "web";
export const haptic = {
/** Light tick — captures, small confirmations. */
tap: () => {
if (canBuzz) void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
},
/** Medium thunk — picking up a draggable. */
pickup: () => {
if (canBuzz) void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
},
/** Success notification — completing a task, landing a drop. */
success: () => {
if (canBuzz) void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
},
/** Selection change — flicking through chips. */
select: () => {
if (canBuzz) void Haptics.selectionAsync();
},
};

34
app/src/lib/priority.ts Normal file
View File

@@ -0,0 +1,34 @@
import type { Task } from "./types";
export const PRIORITIES: Task["priority"][] = [1, 2, 3, 4];
export const PRIORITY_LABEL: Record<Task["priority"], string> = {
1: "Urgent",
2: "High",
3: "Medium",
4: "Low",
};
// Todoist's flag mapping (urgent red → low grey), retuned to sit calmly on a
// light canvas. `PRIORITY_TINT` is the same hue at wash strength, for the
// selected state of a chip — a saturated fill would shout on a white card.
export const PRIORITY_COLOR: Record<Task["priority"], `#${string}`> = {
1: "#E5484D",
2: "#D98324",
3: "#2F6FED",
4: "#9A9AA2",
};
export const PRIORITY_TINT: Record<Task["priority"], `#${string}`> = {
1: "#FDECEC",
2: "#FBF0E2",
3: "#E8F0FE",
4: "#F0F0F3",
};
export function formatEstimate(min: number | null): string | null {
if (!min) return null;
const h = Math.floor(min / 60);
const m = min % 60;
return h ? (m ? `${h}h${m}` : `${h}h`) : `${m}m`;
}

110
app/src/lib/quickadd.ts Normal file
View File

@@ -0,0 +1,110 @@
// Natural-language quick add, Todoist style:
// "Pay rent tomorrow p1 @home #Sydney ~30m monthly"
// → title "Pay rent", due tomorrow, priority 1, context @home,
// project "Sydney" (matched by name prefix), estimate 30 min, repeats monthly.
//
// The add form parses the title field live and mirrors every recognized token
// into a visible form control, so a false positive (e.g. "fri" in a name) is
// seen and fixable, never silent.
import type { Project, Repeat, Task } from "./types";
export interface ParsedQuickAdd {
title: string;
priority?: Task["priority"];
due_date?: string;
context?: string;
project_id?: string;
estimate_min?: number;
repeat?: Repeat;
}
const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
const REPEAT_WORDS: Record<string, Repeat> = {
daily: "daily",
weekdays: "weekdays",
weekly: "weekly",
monthly: "monthly",
yearly: "yearly",
};
function iso(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function parseDateWord(word: string): string | undefined {
const w = word.toLowerCase();
const today = new Date();
if (w === "today" || w === "tod") return iso(today);
// No "tom" alias — it's a name far more often than a date.
if (w === "tomorrow" || w === "tmr") {
const d = new Date(today);
d.setDate(d.getDate() + 1);
return iso(d);
}
if (/^\d{4}-\d{2}-\d{2}$/.test(w)) return w;
const wd = WEEKDAYS.findIndex((n) => n === w || n.slice(0, 3) === w);
if (wd >= 0) {
const d = new Date(today);
const delta = (wd - d.getDay() + 7) % 7 || 7; // always the *next* occurrence
d.setDate(d.getDate() + delta);
return iso(d);
}
return undefined;
}
function parseEstimate(token: string): number | undefined {
const m = token.match(/^~(?:(\d+)h)?(\d+)?m?$/i);
if (!m || (!m[1] && !m[2])) return undefined;
return (Number(m[1] ?? 0) * 60 + Number(m[2] ?? 0)) || undefined;
}
export function parseQuickAdd(input: string, projects: Project[]): ParsedQuickAdd {
const out: ParsedQuickAdd = { title: "" };
const rest: string[] = [];
for (const token of input.trim().split(/\s+/)) {
const p = token.match(/^p([1-4])$/i);
if (p && out.priority === undefined) {
out.priority = Number(p[1]) as Task["priority"];
continue;
}
if (token.startsWith("@") && token.length > 1 && out.context === undefined) {
out.context = token;
continue;
}
if (token.startsWith("#") && token.length > 1 && out.project_id === undefined) {
const name = token.slice(1).toLowerCase();
const match = projects.find(
(pr) => pr.status === "active" && pr.name.toLowerCase().startsWith(name),
);
if (match) {
out.project_id = match.id;
continue;
}
}
const est = parseEstimate(token);
if (est && out.estimate_min === undefined) {
out.estimate_min = est;
continue;
}
const rep = REPEAT_WORDS[token.toLowerCase()];
if (rep && out.repeat === undefined) {
out.repeat = rep;
continue;
}
if (out.due_date === undefined) {
const due = parseDateWord(token);
if (due) {
out.due_date = due;
continue;
}
}
rest.push(token);
}
out.title = rest.join(" ");
return out;
}

65
app/src/lib/repeat.ts Normal file
View File

@@ -0,0 +1,65 @@
// Recurrence rules. Completing a repeating task spawns the next occurrence
// (see store.updateTask) — the done row keeps its history, the clone carries
// the rule forward.
import { fromIso, toIso } from "./dates";
import type { Repeat } from "./types";
export const REPEATS: Repeat[] = ["daily", "weekdays", "weekly", "monthly", "yearly"];
export const REPEAT_LABEL: Record<Repeat, string> = {
daily: "Daily",
weekdays: "Weekdays",
weekly: "Weekly",
monthly: "Monthly",
yearly: "Yearly",
};
function addDays(d: Date, n: number): Date {
const r = new Date(d);
r.setDate(r.getDate() + n);
return r;
}
/** Same day-of-month n months later, clamped to the month's last day. */
function addMonthsClamped(d: Date, n: number): Date {
const r = new Date(d.getFullYear(), d.getMonth() + n, 1);
const last = new Date(r.getFullYear(), r.getMonth() + 1, 0).getDate();
r.setDate(Math.min(d.getDate(), last));
return r;
}
function step(d: Date, repeat: Repeat): Date {
switch (repeat) {
case "daily":
return addDays(d, 1);
case "weekdays": {
let n = addDays(d, 1);
while (n.getDay() === 0 || n.getDay() === 6) n = addDays(n, 1);
return n;
}
case "weekly":
return addDays(d, 7);
case "monthly":
return addMonthsClamped(d, 1);
case "yearly":
return addMonthsClamped(d, 12);
}
}
/**
* Next occurrence strictly after the base date, catching up to today for
* lapsed tasks (a monthly bill 3 months overdue advances to the next
* anchor-day on or after today, not three stale dates in the past).
*/
export function nextOccurrence(baseIso: string | null, repeat: Repeat, todayIsoStr: string): string {
let d = fromIso(baseIso ?? todayIsoStr);
let guard = 0;
do {
d = step(d, repeat);
} while (toIso(d) < todayIsoStr && ++guard < 5000);
return toIso(d);
}
export function shiftDays(dateIso: string, n: number): string {
return toIso(addDays(fromIso(dateIso), n));
}

126
app/src/lib/store.test.ts Normal file
View File

@@ -0,0 +1,126 @@
// Store unit tests — run with `bun test src/lib`. Expo/native modules are
// mocked so the local-first mutations run in plain Bun.
import { beforeEach, describe, expect, mock, test } from "bun:test";
const storage = new Map<string, string>();
mock.module("@react-native-async-storage/async-storage", () => ({
default: {
getItem: async (k: string) => storage.get(k) ?? null,
setItem: async (k: string, v: string) => void storage.set(k, v),
},
}));
let seq = 0;
mock.module("expo-crypto", () => ({
randomUUID: () => `00000000-0000-4000-8000-${String(++seq).padStart(12, "0")}`,
}));
mock.module("./api", () => ({ api: {} }));
mock.module("./config", () => ({ getConfig: async () => ({ token: "" }) }));
const store = await import("./store");
beforeEach(async () => {
storage.clear();
await store.hydrate();
store.setArea("personal");
});
const find = (id: string) => store.getState().tasks.find((t) => t.id === id)!;
describe("completeTask", () => {
test("marks done and undo restores the original status in place", () => {
const a = store.createTask({ title: "a", status: "waiting", sort_order: 5 });
const undo = store.completeTask(a.id);
expect(find(a.id).status).toBe("done");
expect(find(a.id).completed_at).not.toBeNull();
undo!();
expect(find(a.id).status).toBe("waiting");
expect(find(a.id).completed_at).toBeNull();
expect(find(a.id).sort_order).toBe(5);
});
test("revertTo overrides the status undo returns to", () => {
const a = store.createTask({ title: "a", status: "next" });
store.completeTask(a.id, "someday")!();
expect(find(a.id).status).toBe("someday");
});
test("undo of a repeating task trashes the spawned occurrence", () => {
const a = store.createTask({ title: "rent", repeat: "monthly", due_date: "2026-09-01" });
const known = new Set(store.getState().tasks.map((t) => t.id));
const undo = store.completeTask(a.id)!;
const spawned = store.getState().tasks.filter((t) => !known.has(t.id));
expect(spawned).toHaveLength(1);
expect(spawned[0].status).toBe("next");
undo();
expect(find(a.id).status).toBe("next");
expect(find(spawned[0].id).status).toBe("trashed");
});
test("returns null for an already-done or unknown task", () => {
const a = store.createTask({ title: "a", status: "done" });
expect(store.completeTask(a.id)).toBeNull();
expect(store.completeTask("nope")).toBeNull();
});
});
describe("area lens", () => {
test("new rows are born into the active area and selectors filter by it", () => {
const home = store.createTask({ title: "home" });
store.setArea("work");
const office = store.createTask({ title: "office" });
const proj = store.createProject({ name: "Q4" });
expect(home.area).toBe("personal");
expect(office.area).toBe("work");
expect(proj.area).toBe("work");
// The store is module state shared across tests, so assert on membership
// and on every visible row's area rather than on exact lists.
const ids = (s: ReturnType<typeof store.getState>) => store.tasksByStatus(s, "next").map((t) => t.id);
let s = store.getState();
expect(ids(s)).toContain(office.id);
expect(ids(s)).not.toContain(home.id);
expect(store.tasksByStatus(s, "next").every((t) => t.area === "work")).toBe(true);
expect(store.activeProjects(s).map((p) => p.id)).toEqual([proj.id]);
store.setArea("personal");
s = store.getState();
expect(ids(s)).toContain(home.id);
expect(ids(s)).not.toContain(office.id);
expect(store.activeProjects(s).some((p) => p.id === proj.id)).toBe(false);
});
test("area persists across hydrate", async () => {
store.setArea("work");
expect(storage.get("area_v1")).toBe("work");
});
test("rows cached without an area count as personal", () => {
// Simulate a pre-migration cache row.
const legacy = store.createTask({ title: "old" });
delete (legacy as { area?: string }).area;
store.setArea("work");
expect(store.todayTasks(store.getState()).overdue).toEqual([]);
store.setArea("personal");
expect(store.tasksByStatus(store.getState(), "next").some((t) => t.id === legacy.id)).toBe(true);
});
test("today and contexts respect the lens", () => {
store.createTask({ title: "p", due_date: "2000-01-01", context: "@home" });
store.setArea("work");
store.createTask({ title: "w", due_date: "2000-01-01", context: "@office" });
const s = store.getState();
expect(store.todayTasks(s).overdue.every((t) => t.area === "work")).toBe(true);
expect(store.todayTasks(s).overdue.map((t) => t.title)).toContain("w");
expect(store.todayTasks(s).overdue.map((t) => t.title)).not.toContain("p");
expect(store.knownContexts(s)).toEqual(["@office"]);
});
test("completing a repeating work task spawns into work", () => {
store.setArea("work");
const t = store.createTask({ title: "standup", repeat: "daily", due_date: "2000-01-01" });
store.setArea("personal");
store.completeTask(t.id);
const spawned = store.getState().tasks.find((x) => x.title === "standup" && x.status === "next")!;
expect(spawned.area).toBe("work");
});
});

View File

@@ -10,14 +10,24 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Crypto from "expo-crypto";
import { api } from "./api";
import { getConfig } from "./config";
import type { Project, Task } from "./types";
import { daysFromToday, isDeferred, todayIso } from "./dates";
import { nextOccurrence, shiftDays } from "./repeat";
import type { Area, Project, Task } from "./types";
export interface State {
tasks: Task[];
projects: Project[];
/**
* Which half of life is on screen. Every selector filters by it, so the
* toggle acts as a global lens: work mode shows no personal rows and vice
* versa. New tasks and projects are born into the active area.
*/
area: Area;
hydrated: boolean;
syncing: boolean;
pending: number;
/** True when the last sync failed for network reasons — expected offline. */
offline: boolean;
syncError: string | null;
}
@@ -27,13 +37,16 @@ type Op =
const DATA_KEY = "store_v1";
const QUEUE_KEY = "queue_v1";
const AREA_KEY = "area_v1";
let state: State = {
tasks: [],
projects: [],
area: "personal",
hydrated: false,
syncing: false,
pending: 0,
offline: false,
syncError: null,
};
let queue: Op[] = [];
@@ -63,13 +76,39 @@ function persistQueue() {
export async function hydrate() {
if (state.hydrated) return;
const [data, q] = await Promise.all([AsyncStorage.getItem(DATA_KEY), AsyncStorage.getItem(QUEUE_KEY)]);
const [data, q, area] = await Promise.all([
AsyncStorage.getItem(DATA_KEY),
AsyncStorage.getItem(QUEUE_KEY),
AsyncStorage.getItem(AREA_KEY),
]);
if (q) queue = JSON.parse(q);
const parsed = data ? JSON.parse(data) : { tasks: [], projects: [] };
setState({ tasks: parsed.tasks, projects: parsed.projects, hydrated: true });
setState({
tasks: parsed.tasks,
projects: parsed.projects,
area: area === "work" ? "work" : "personal",
hydrated: true,
});
void sync();
}
/** Flip the personal/work lens. Persists so the app reopens where you left it. */
export function setArea(area: Area) {
if (area === state.area) return;
setState({ area });
void AsyncStorage.setItem(AREA_KEY, area);
}
// Rows cached before the area column existed have no `area`; they are
// personal, matching the server's column default.
function areaOf(row: { area?: Area }): Area {
return row.area ?? "personal";
}
function inArea<T extends { area?: Area }>(s: State, rows: T[]): T[] {
return rows.filter((r) => areaOf(r) === s.area);
}
// ---- mutations (instant; network happens later) ----
function now() {
@@ -78,8 +117,8 @@ function now() {
// Fields the API accepts — everything else (timestamps, completed_at) is local
// bookkeeping until the server's authoritative row arrives via pull().
const TASK_FIELDS = ["title", "notes", "status", "project_id", "context", "waiting_for", "due_date", "defer_date", "sort_order"] as const;
const PROJECT_FIELDS = ["name", "status", "notes", "sort_order"] as const;
const TASK_FIELDS = ["title", "notes", "status", "project_id", "context", "waiting_for", "due_date", "defer_date", "priority", "estimate_min", "repeat", "sort_order", "area"] as const;
const PROJECT_FIELDS = ["name", "status", "notes", "sort_order", "area"] as const;
function pickFields<T>(data: Partial<T>, fields: readonly (keyof T & string)[]): Partial<T> {
const out: Partial<T> = {};
@@ -101,18 +140,27 @@ function enqueue(op: Op) {
}
export function createTask(data: Partial<Task> & { title: string }): Task {
// "next" — this app has no inbox; a task without an explicit box is actionable.
const status = data.status ?? "next";
const task: Task = {
id: Crypto.randomUUID(),
title: data.title,
notes: data.notes ?? "",
status: data.status ?? "inbox",
status,
project_id: data.project_id ?? null,
context: data.context ?? null,
waiting_for: data.waiting_for ?? null,
due_date: data.due_date ?? null,
defer_date: data.defer_date ?? null,
priority: data.priority ?? 4,
estimate_min: data.estimate_min ?? null,
repeat: data.repeat ?? null,
completed_at: null,
sort_order: data.sort_order ?? 0,
area: data.area ?? state.area,
// New tasks land at the top of their list.
sort_order:
data.sort_order ??
Math.min(0, ...state.tasks.filter((t) => t.status === status).map((t) => t.sort_order)) - 1,
created_at: now(),
updated_at: now(),
};
@@ -123,6 +171,12 @@ export function createTask(data: Partial<Task> & { title: string }): Task {
}
export function updateTask(id: string, patch: Partial<Task>) {
applyTaskUpdate(id, patch);
}
/** Applies a patch; returns the next occurrence if completing spawned one. */
function applyTaskUpdate(id: string, patch: Partial<Task>): Task | null {
const before = state.tasks.find((t) => t.id === id);
setState({
tasks: state.tasks.map((t) => {
if (t.id !== id) return t;
@@ -134,6 +188,57 @@ export function updateTask(id: string, patch: Partial<Task>) {
});
persistData();
enqueue({ kind: "task.update", id, data: pickFields(patch, TASK_FIELDS) });
// Completing a repeating task spawns the next occurrence. Un-checking the
// done row later leaves the spawn in place — it's one tap to trash, versus
// silently deleting a row the user may have edited.
if (before && before.repeat && before.status !== "done" && patch.status === "done") {
return spawnNextOccurrence(before);
}
return null;
}
/**
* Complete a task and hand back an undo. Undo puts the task back where it was
* (same status and sort slot) and trashes the occurrence a repeat spawned, so
* a mis-tap leaves no trace. Returns null when there's nothing to complete.
*/
export function completeTask(id: string, revertTo?: Task["status"]): (() => void) | null {
const before = state.tasks.find((t) => t.id === id);
if (!before || before.status === "done") return null;
const spawned = applyTaskUpdate(id, { status: "done" });
const back = revertTo ?? before.status;
return () => {
updateTask(id, { status: back });
if (spawned) trashTask(spawned.id);
};
}
function spawnNextOccurrence(t: Task): Task {
const today = todayIso();
const due = t.due_date ? nextOccurrence(t.due_date, t.repeat!, today) : null;
// Keep the same lead time between defer and due (e.g. "surfaces 3 days before").
const defer =
due && t.due_date && t.defer_date
? shiftDays(t.defer_date, daysFromToday(due) - daysFromToday(t.due_date))
: t.due_date
? null
: t.defer_date
? nextOccurrence(t.defer_date, t.repeat!, today)
: null;
return createTask({
title: t.title,
notes: t.notes,
status: t.status,
project_id: t.project_id,
context: t.context,
waiting_for: t.waiting_for,
due_date: due,
defer_date: defer,
priority: t.priority,
estimate_min: t.estimate_min,
repeat: t.repeat,
area: areaOf(t),
});
}
export function trashTask(id: string) {
@@ -147,6 +252,7 @@ export function createProject(data: Partial<Project> & { name: string }): Projec
status: data.status ?? "active",
notes: data.notes ?? "",
sort_order: data.sort_order ?? 0,
area: data.area ?? state.area,
created_at: now(),
updated_at: now(),
};
@@ -188,9 +294,13 @@ export async function sync() {
setState({});
}
await pull();
setState({ syncError: null });
setState({ syncError: null, offline: false });
} catch (e) {
setState({ syncError: e instanceof Error ? e.message : String(e) });
const msg = e instanceof Error ? e.message : String(e);
// fetch() network failures (airplane mode, no wifi, DNS) are the expected
// offline case, not an error — the queue simply waits for connectivity.
const isNetwork = e instanceof TypeError || /network request failed|unable to resolve|fetch failed|timeout/i.test(msg);
setState(isNetwork ? { offline: true, syncError: null } : { syncError: msg });
} finally {
syncRunning = false;
setState({ syncing: false });
@@ -217,7 +327,14 @@ async function push(op: Op) {
}
async function pull() {
const [tasks, projects] = await Promise.all([api.tasks.list(), api.projects.list()]);
// The default list excludes trashed; fetch trash separately so the Trash
// screen (and restore) survive a sync.
const [open, trashed, projects] = await Promise.all([
api.tasks.list(),
api.tasks.list({ status: "trashed" }),
api.projects.list(),
]);
const tasks = [...open, ...trashed];
// Rows with ops still queued keep their local version; the next sync pass
// pushes those ops and then this merge converges to the server state.
const dirty = new Set(queue.map((o) => o.id));
@@ -237,14 +354,62 @@ async function pull() {
// ---- selectors ----
function byPriorityDue(a: Task, b: Task): number {
return (
a.priority - b.priority ||
(a.due_date ?? "9999").localeCompare(b.due_date ?? "9999") ||
a.sort_order - b.sort_order ||
b.created_at.localeCompare(a.created_at)
);
}
// Manual arrangement wins: sort_order is the canonical list order (set by
// drag-to-reorder); priority/due only break ties for rows that never moved.
function byManualOrder(a: Task, b: Task): number {
return a.sort_order - b.sort_order || byPriorityDue(a, b);
}
export function tasksByStatus(s: State, status: Task["status"]): Task[] {
return s.tasks
.filter((t) => t.status === status)
.sort((a, b) => a.sort_order - b.sort_order || b.created_at.localeCompare(a.created_at));
return inArea(s, s.tasks).filter((t) => t.status === status).sort(byManualOrder);
}
/**
* The action lists hide deferred tasks until their date arrives — that's the
* whole point of a tickler. Review and the "show deferred" toggle see them.
*/
export function visibleTasksByStatus(s: State, status: Task["status"]): Task[] {
const today = todayIso();
return tasksByStatus(s, status).filter((t) => !isDeferred(t.defer_date, today));
}
/** Re-slot a task at `index` within its status list (fractional sort_order). */
export function reorderTask(id: string, status: Task["status"], index: number) {
const list = tasksByStatus(getState(), status).filter((t) => t.id !== id);
const prev = list[index - 1]?.sort_order;
const next = list[index]?.sort_order;
const sort_order =
prev !== undefined && next !== undefined ? (prev + next) / 2
: prev !== undefined ? prev + 1
: next !== undefined ? next - 1
: 0;
updateTask(id, { sort_order });
}
/** Overdue + due-today + deferred-arriving-today, across all open lists. */
export function todayTasks(s: State): { overdue: Task[]; today: Task[] } {
const now = new Date();
const todayIso = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
const open = inArea(s, s.tasks).filter((t) => t.status !== "done" && t.status !== "trashed");
return {
overdue: open.filter((t) => t.due_date && t.due_date < todayIso).sort(byPriorityDue),
today: open
.filter((t) => t.due_date === todayIso || (t.defer_date === todayIso && !t.due_date))
.sort(byPriorityDue),
};
}
export function activeProjects(s: State): Project[] {
return s.projects
return inArea(s, s.projects)
.filter((p) => p.status !== "dropped")
.sort((a, b) => a.sort_order - b.sort_order || b.created_at.localeCompare(a.created_at));
}
@@ -252,3 +417,12 @@ export function activeProjects(s: State): Project[] {
export function openTaskCount(s: State, projectId: string): number {
return s.tasks.filter((t) => t.project_id === projectId && t.status !== "done" && t.status !== "trashed").length;
}
/** Distinct contexts on open tasks — suggestion chips beat retyping "@homr". */
export function knownContexts(s: State): string[] {
const set = new Set<string>();
for (const t of inArea(s, s.tasks)) {
if (t.context && t.status !== "done" && t.status !== "trashed") set.add(t.context);
}
return [...set].sort();
}

View File

@@ -1,11 +1,118 @@
export const C = {
bg: "#0F1115",
surface: "#1A1D24",
surface2: "#232733",
border: "#2C313D",
text: "#E6E8EC",
muted: "#8A909C",
accent: "#5B8DEF",
done: "#4CAF7D",
danger: "#E05B5B",
};
// Design tokens.
//
// The look is modelled on Things 3: a light, calm canvas, generous whitespace,
// hairline inset separators instead of boxes, and exactly one accent colour
// used sparingly. Everything visual in the app resolves through this file —
// no component hard-codes a hex value (priority flags are the one exception,
// and they live in `priority.ts` alongside their labels).
/** Named palette. `dark` is complete and ready to wire to a theme toggle. */
const light = {
/** Screen background behind grouped content. */
bg: "#F7F7F9",
/** Card / row surface that sits on `bg`. */
surface: "#FFFFFF",
/** Inputs, pressed states, subtle fills. */
surface2: "#EFEFF3",
/** Card outline. Deliberately barely-there. */
border: "#E6E6EB",
/** Inset hairline between rows inside a card. */
separator: "#ECECF0",
/** Primary reading colour. Near-black, never pure #000. */
text: "#16161A",
/** Supporting copy that should still be comfortably readable. */
textSecondary: "#6E6E76",
/** Metadata, placeholders, de-emphasised chrome. */
muted: "#9A9AA2",
accent: "#2F6FED",
/** Tinted accent wash for selected chips and active states. */
accentSoft: "#E8F0FE",
/** Accent for the work lens — the one place the app changes colour. */
work: "#0F766E",
done: "#2FA36B",
danger: "#E5484D",
warn: "#D98324",
/** Text that sits on top of `accent`. */
onAccent: "#FFFFFF",
} as const;
const dark = {
bg: "#0C0C0F",
surface: "#17171B",
surface2: "#212127",
border: "#2A2A31",
separator: "#26262C",
text: "#F2F2F5",
textSecondary: "#A6A6B0",
muted: "#7A7A85",
accent: "#4C86F0",
accentSoft: "#1B2740",
work: "#2DD4BF",
done: "#3FBF84",
danger: "#F0605F",
warn: "#E0A93E",
onAccent: "#FFFFFF",
} as const;
export const PALETTES = { light, dark } as const;
/**
* Active palette. Styles are created at module scope (StyleSheet.create) so
* this is a static choice rather than a hook — switching themes at runtime
* would mean moving every stylesheet inside its component.
*/
export const C = light;
/** 4pt spacing grid. Screen gutter is `space.lg`. */
export const space = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 20,
xxl: 28,
} as const;
export const radius = {
sm: 8,
md: 12,
/** Cards and grouped lists. */
lg: 16,
/** Chips and pills. */
pill: 999,
} as const;
/**
* Type scale. `title` is the large in-content screen heading that replaces a
* cramped nav-bar title; `row` is the task title size — 17pt, matching the
* platform body size Things and Todoist both settle on.
*/
export const type = {
title: { fontSize: 30, fontWeight: "700", letterSpacing: -0.6 },
section: { fontSize: 13, fontWeight: "600", letterSpacing: 0.3 },
row: { fontSize: 17, letterSpacing: -0.2 },
body: { fontSize: 15 },
meta: { fontSize: 13 },
chip: { fontSize: 14, fontWeight: "500" },
} as const;
/**
* Card elevation. Kept extremely soft — a Things-like surface reads as paper,
* not as a floating panel.
*/
export const shadow = {
card: {
shadowColor: "#0B0B12",
shadowOpacity: 0.05,
shadowRadius: 10,
shadowOffset: { width: 0, height: 2 },
elevation: 1,
},
fab: {
shadowColor: "#0B0B12",
shadowOpacity: 0.22,
shadowRadius: 14,
shadowOffset: { width: 0, height: 6 },
elevation: 6,
},
} as const;

View File

@@ -1,5 +1,11 @@
export type TaskStatus = "inbox" | "next" | "waiting" | "scheduled" | "someday" | "done" | "trashed";
export type ProjectStatus = "active" | "someday" | "completed" | "dropped";
export type Repeat = "daily" | "weekdays" | "weekly" | "monthly" | "yearly";
/** Personal vs work — the app-wide toggle shows one area at a time. */
export type Area = "personal" | "work";
export const AREAS: Area[] = ["personal", "work"];
export const AREA_LABEL: Record<Area, string> = { personal: "Personal", work: "Work" };
export interface Task {
id: string;
@@ -11,8 +17,12 @@ export interface Task {
waiting_for: string | null;
due_date: string | null;
defer_date: string | null;
priority: 1 | 2 | 3 | 4;
estimate_min: number | null;
repeat: Repeat | null;
completed_at: string | null;
sort_order: number;
area: Area;
created_at: string;
updated_at: string;
}
@@ -23,6 +33,7 @@ export interface Project {
status: ProjectStatus;
notes: string;
sort_order: number;
area: Area;
created_at: string;
updated_at: string;
open_tasks?: number;

13
app/src/lib/undo.ts Normal file
View File

@@ -0,0 +1,13 @@
// Complete + "Undo" toast, shared by every checkbox in the app so a mis-tap
// is one tap to take back wherever it happened.
import { showToast } from "../components/Toast";
import { haptic } from "./haptics";
import { completeTask } from "./store";
import type { Task, TaskStatus } from "./types";
export function completeWithUndo(task: Pick<Task, "id" | "title">, revertTo?: TaskStatus) {
const undo = completeTask(task.id, revertTo);
if (!undo) return;
haptic.success();
showToast({ message: `Completed “${task.title}”`, action: { label: "Undo", onPress: undo } });
}

View File

@@ -0,0 +1,98 @@
"use no memo"; // widget trees are built by calling the component as a plain function — the React Compiler must not transform it
// Home-screen widget: the same top-three "Next up" list as the home screen.
// Built only from react-native-android-widget primitives (no RN views, no
// hooks) — it renders to Android RemoteViews in a headless task.
//
// Interactions mirror the app: tap the checkbox to complete, tap the row to
// open Clarify, tap + to capture, tap anywhere else to open the app.
import { FlexWidget, TextWidget } from "react-native-android-widget";
import { dueMeta, type DueTone } from "../lib/dates";
import { formatEstimate, PRIORITY_COLOR } from "../lib/priority";
import { C } from "../lib/theme";
import type { Task } from "../lib/types";
const DUE_COLOR: Record<DueTone, `#${string}`> = { overdue: C.danger, today: C.warn, later: C.muted };
function Row({ task }: { task: Task }) {
const due = task.due_date ? dueMeta(task.due_date) : null;
// One line per row so three rows fit the 2-cell height: right-side meta is
// the due label when there is one, otherwise context/estimate.
const side = due ? due.label : [task.context, formatEstimate(task.estimate_min)].filter(Boolean).join(" · ");
return (
<FlexWidget
style={{ width: "match_parent", flex: 1, flexDirection: "row", alignItems: "center" }}
clickAction="OPEN_URI"
clickActionData={{ uri: `gtd://task/${task.id}` }}
>
<FlexWidget
clickAction="COMPLETE_TASK"
clickActionData={{ taskId: task.id }}
accessibilityLabel={`Complete ${task.title}`}
style={{ width: 32, height: 32, alignItems: "center", justifyContent: "center" }}
>
<FlexWidget
style={{ width: 19, height: 19, borderRadius: 6, borderWidth: 2, borderColor: PRIORITY_COLOR[task.priority] }}
/>
</FlexWidget>
<FlexWidget style={{ flex: 1, marginLeft: 6 }}>
<TextWidget text={task.title} truncate="END" maxLines={1} style={{ fontSize: 15, color: C.text }} />
</FlexWidget>
{!!side && <TextWidget text={side} truncate="END" maxLines={1} style={{ fontSize: 11, color: due ? DUE_COLOR[due.tone] : C.muted, marginLeft: 8 }} />}
</FlexWidget>
);
}
export function NextUpWidget({ tasks, totalNext, overdueCount }: { tasks: Task[]; totalNext: number; overdueCount: number }) {
return (
<FlexWidget
clickAction="OPEN_APP"
style={{
width: "match_parent",
height: "match_parent",
flexDirection: "column",
backgroundColor: C.surface,
borderRadius: 24,
paddingHorizontal: 16,
paddingVertical: 12,
}}
>
<FlexWidget
style={{ width: "match_parent", flexDirection: "row", alignItems: "center", justifyContent: "space-between", marginBottom: 4 }}
>
<FlexWidget style={{ flexDirection: "row", alignItems: "center" }}>
<TextWidget text="Next up" style={{ fontSize: 13, fontWeight: "600", letterSpacing: 0.3, color: C.textSecondary }} />
{totalNext > tasks.length && (
<TextWidget text={` +${totalNext - tasks.length} more`} style={{ fontSize: 12, color: C.muted }} />
)}
{overdueCount > 0 && (
<TextWidget
text={` · ${overdueCount} overdue`}
clickAction="OPEN_URI"
clickActionData={{ uri: "gtd://today" }}
style={{ fontSize: 12, color: C.danger }}
/>
)}
</FlexWidget>
<FlexWidget
clickAction="OPEN_URI"
clickActionData={{ uri: "gtd://add" }}
accessibilityLabel="Add task"
style={{ width: 28, height: 28, borderRadius: 14, backgroundColor: C.accent, alignItems: "center", justifyContent: "center" }}
>
<TextWidget text="+" style={{ fontSize: 16, color: C.onAccent }} />
</FlexWidget>
</FlexWidget>
{tasks.length === 0 ? (
<FlexWidget style={{ width: "match_parent", flex: 1, alignItems: "center", justifyContent: "center" }}>
<TextWidget text="Nothing next. Tap + to add." style={{ fontSize: 14, color: C.muted }} />
</FlexWidget>
) : (
<FlexWidget style={{ width: "match_parent", flex: 1, flexDirection: "column" }}>
{tasks.map((t) => (
<Row key={t.id} task={t} />
))}
</FlexWidget>
)}
</FlexWidget>
);
}

18
app/src/widget/refresh.ts Normal file
View File

@@ -0,0 +1,18 @@
import { Platform } from "react-native";
import { requestWidgetUpdate } from "react-native-android-widget";
import { renderNextUpWidget, WIDGET_NAME } from "./render";
let timer: ReturnType<typeof setTimeout> | undefined;
/**
* Redraw the home-screen widget from current store state. Debounced because
* it's driven by the store listener, which fires on every mutation and sync
* step; drawing RemoteViews once per burst is plenty.
*/
export function refreshNextUpWidget() {
if (Platform.OS !== "android") return;
clearTimeout(timer);
timer = setTimeout(() => {
void requestWidgetUpdate({ widgetName: WIDGET_NAME, renderWidget: renderNextUpWidget });
}, 300);
}

14
app/src/widget/render.tsx Normal file
View File

@@ -0,0 +1,14 @@
"use no memo";
// One source of truth for what the widget shows: the same selectors the home
// screen uses, so widget and app always agree on the top three.
import { getState, todayTasks, visibleTasksByStatus } from "../lib/store";
import { NextUpWidget } from "./NextUpWidget";
/** Must match the widget `name` in app.json's react-native-android-widget plugin config. */
export const WIDGET_NAME = "NextUp";
export function renderNextUpWidget() {
const s = getState();
const next = visibleTasksByStatus(s, "next");
return <NextUpWidget tasks={next.slice(0, 3)} totalNext={next.length} overdueCount={todayTasks(s).overdue.length} />;
}

View File

@@ -0,0 +1,19 @@
// Headless entry point for widget events. When the app's JS context is
// alive this shares the in-memory store; when launched cold, hydrate() loads
// AsyncStorage — so completing from the widget goes through the exact same
// local-first mutation path (instant local write, op queued, synced later).
import type { WidgetTaskHandlerProps } from "react-native-android-widget";
import { hydrate, updateTask } from "../lib/store";
import { renderNextUpWidget } from "./render";
export async function widgetTaskHandler(props: WidgetTaskHandlerProps) {
if (props.widgetAction === "WIDGET_DELETED") return;
await hydrate();
if (props.widgetAction === "WIDGET_CLICK") {
// OPEN_APP / OPEN_URI are handled natively; only completion reaches JS.
if (props.clickAction !== "COMPLETE_TASK") return;
const taskId = props.clickActionData?.taskId;
if (typeof taskId === "string") updateTask(taskId, { status: "done" });
}
props.renderWidget(renderNextUpWidget());
}

View File

@@ -16,5 +16,8 @@
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
],
"exclude": [
"**/*.test.ts"
]
}

22
desktop/scripts/dev-web.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/sh
# Start the Expo web dev server for `tauri dev`.
#
# Runs from src-tauri/ as Tauri's beforeDevCommand, or from desktop/ by hand.
# Serves the GTD web UI on :8081 (devUrl in tauri.conf.json) with hot reload;
# the Tauri window loads it and talks to https://gtd.rehbock.xyz/api.
set -eu
if [ -x "$HOME/.hermes/node/bin/node" ]; then
export PATH="$HOME/.hermes/node/bin:$PATH"
fi
HERE="$(cd "$(dirname "$0")" && pwd)"
case "$HERE" in
*/desktop/src-tauri/scripts) REPO="$(cd "$HERE/../../.." && pwd)" ;;
*/desktop/scripts) REPO="$(cd "$HERE/../.." && pwd)" ;;
*) REPO="$(cd "$HERE/../.." && pwd)" ;;
esac
APP_DIR="$REPO/app"
cd "$APP_DIR"
exec npx expo start --web --port 8081

37
desktop/scripts/export-web.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/bin/sh
# Re-export the Expo web frontend for the Tauri desktop shell.
#
# Runs from src-tauri/ as Tauri's beforeBuildCommand, or from desktop/ by hand.
# `frontendDist` points at ../app/dist in the repo root, so a plain release
# build of an unchanged tree simply re-bundles the last committed export —
# exactly like the web deploy copies app/dist to the VPS.
#
# Node 22 only (matches app/.nvmrc and CI): newer npm writes lockfiles that
# CI's npm 10 refuses, so force the hermes node 22 toolchain if present.
set -eu
HERE="$(cd "$(dirname "$0")" && pwd)"
case "$HERE" in
*/desktop/src-tauri/scripts)
# invoked as src-tauri/scripts/export-web.sh (Tauri hook cwd): repo is two up
ROOT="$(cd "$HERE/../../.." && pwd)" ;;
*/desktop/scripts)
# invoked as desktop/scripts/export-web.sh: repo is two up
ROOT="$(cd "$HERE/../.." && pwd)" ;;
*)
ROOT="$(cd "$HERE/../.." && pwd)" ;;
esac
if [ -x "$HOME/.hermes/node/bin/node" ]; then
export PATH="$HOME/.hermes/node/bin:$PATH"
fi
NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
if [ "$NODE_MAJOR" != "22" ]; then
echo "export-web.sh: need node 22 (have $(node -v)); refusing to run" >&2
exit 1
fi
cd "$ROOT/app"
npx expo export --platform web
echo "export-web.sh: exported to $ROOT/app/dist"

2
desktop/src-tauri/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target/
/gen/schemas

4512
desktop/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
[package]
name = "gtd"
version = "1.0.0"
description = "GTD desktop app — Tauri shell around the Expo web export"
edition = "2021"
authors = ["Marcus"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-capability",
"description": "Capabilities for the GTD main window. The Expo web frontend needs no Tauri plugin APIs — it only talks to the remote API over fetch — so this grants the read-only core defaults (window/event/app metadata) and nothing privileged.",
"windows": ["main"],
"permissions": ["core:default"]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

View File

@@ -0,0 +1,22 @@
#!/bin/sh
# Start the Expo web dev server for `tauri dev`.
#
# Runs from src-tauri/ as Tauri's beforeDevCommand, or from desktop/ by hand.
# Serves the GTD web UI on :8081 (devUrl in tauri.conf.json) with hot reload;
# the Tauri window loads it and talks to https://gtd.rehbock.xyz/api.
set -eu
if [ -x "$HOME/.hermes/node/bin/node" ]; then
export PATH="$HOME/.hermes/node/bin:$PATH"
fi
HERE="$(cd "$(dirname "$0")" && pwd)"
case "$HERE" in
*/desktop/src-tauri/scripts) REPO="$(cd "$HERE/../../.." && pwd)" ;;
*/desktop/scripts) REPO="$(cd "$HERE/../.." && pwd)" ;;
*) REPO="$(cd "$HERE/../.." && pwd)" ;;
esac
APP_DIR="$REPO/app"
cd "$APP_DIR"
exec npx expo start --web --port 8081

View File

@@ -0,0 +1,37 @@
#!/bin/sh
# Re-export the Expo web frontend for the Tauri desktop shell.
#
# Runs from src-tauri/ as Tauri's beforeBuildCommand, or from desktop/ by hand.
# `frontendDist` points at ../app/dist in the repo root, so a plain release
# build of an unchanged tree simply re-bundles the last committed export —
# exactly like the web deploy copies app/dist to the VPS.
#
# Node 22 only (matches app/.nvmrc and CI): newer npm writes lockfiles that
# CI's npm 10 refuses, so force the hermes node 22 toolchain if present.
set -eu
HERE="$(cd "$(dirname "$0")" && pwd)"
case "$HERE" in
*/desktop/src-tauri/scripts)
# invoked as src-tauri/scripts/export-web.sh (Tauri hook cwd): repo is two up
ROOT="$(cd "$HERE/../../.." && pwd)" ;;
*/desktop/scripts)
# invoked as desktop/scripts/export-web.sh: repo is two up
ROOT="$(cd "$HERE/../.." && pwd)" ;;
*)
ROOT="$(cd "$HERE/../.." && pwd)" ;;
esac
if [ -x "$HOME/.hermes/node/bin/node" ]; then
export PATH="$HOME/.hermes/node/bin:$PATH"
fi
NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
if [ "$NODE_MAJOR" != "22" ]; then
echo "export-web.sh: need node 22 (have $(node -v)); refusing to run" >&2
exit 1
fi
cd "$ROOT/app"
npx expo export --platform web
echo "export-web.sh: exported to $ROOT/app/dist"

View File

@@ -0,0 +1,22 @@
// GTD desktop shell — Tauri 2 backend.
//
// The UI is the Expo web export (rebuilt from `app/` by
// `scripts/export-web.sh` into `app/dist`, which this crate bundles via
// `frontendDist`). All app logic, offline queueing, and API sync live in
// that frontend; Rust only hosts the window. Native hooks (global
// quick-add, menu-bar toggle) can attach to the builder below later
// without touching the web code.
//
// CSP note (tauri.conf.json > app.security): `dangerousDisableAssetCspModification`
// lists "style-src" because Tauri otherwise injects nonces into that directive,
// which makes WebKit ignore 'unsafe-inline' and block every react-native-web
// inline `style=""` attribute plus Reanimated's runtime <style> tags. The
// result is a collapsed, unstyled page — a black screen in dark mode.
// script-src still receives Tauri's hashes/nonces.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
tauri::Builder::default()
.run(tauri::generate_context!())
.expect("failed to run GTD");
}

View File

@@ -0,0 +1,48 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "GTD",
"version": "1.0.0",
"identifier": "xyz.rehbock.gtd",
"build": {
"devUrl": "http://localhost:8081",
"beforeDevCommand": "scripts/dev-web.sh",
"beforeBuildCommand": "scripts/export-web.sh",
"frontendDist": "../../app/dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "GTD",
"url": "index.html",
"width": 1100,
"height": 750,
"minWidth": 800,
"minHeight": 600,
"center": true
}
],
"security": {
"dangerousDisableAssetCspModification": ["style-src"],
"csp": {
"default-src": "'self' tauri: asset: http://asset.localhost",
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' tauri: asset: http://asset.localhost blob: data:",
"style-src": "'self' 'unsafe-inline' tauri: asset: http://asset.localhost blob: data:",
"img-src": "'self' tauri: asset: http://asset.localhost blob: data:",
"font-src": "'self' tauri: asset: http://asset.localhost blob: data:",
"connect-src": "'self' ipc: http://ipc.localhost tauri: asset: http://asset.localhost https://gtd.rehbock.xyz",
"media-src": "'self' tauri: asset: http://asset.localhost blob: data:"
}
}
},
"bundle": {
"active": true,
"targets": ["app", "dmg"],
"icon": ["icons/icon.icns", "icons/icon.ico", "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png"],
"category": "Productivity",
"macOS": {
"minimumSystemVersion": "11.0",
"hardenedRuntime": true
}
}
}

View File

@@ -5,13 +5,19 @@ const sql = new SQL(process.env.DATABASE_URL!);
const API_TOKEN = process.env.API_TOKEN!;
if (!API_TOKEN) throw new Error("API_TOKEN not set");
// schema.sql is idempotent (create/alter-if-not-exists) — apply it at boot so
// a deployed server is never newer than the database it queries.
await sql.unsafe(await Bun.file(new URL("schema.sql", import.meta.url)).text());
const TASK_COLS = `id, title, notes, status, project_id, context, waiting_for,
due_date::text as due_date, defer_date::text as defer_date,
completed_at, sort_order, created_at, updated_at`;
const PROJECT_COLS = `id, name, status, notes, sort_order, created_at, updated_at`;
priority, estimate_min, repeat, completed_at, sort_order, area, created_at, updated_at`;
const PROJECT_COLS = `id, name, status, notes, sort_order, area, created_at, updated_at`;
const TASK_STATUSES = ["inbox", "next", "waiting", "scheduled", "someday", "done", "trashed"];
const REPEATS = ["daily", "weekdays", "weekly", "monthly", "yearly"];
const PROJECT_STATUSES = ["active", "someday", "completed", "dropped"];
const AREAS = ["personal", "work"];
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const CORS = {
@@ -28,12 +34,15 @@ function err(message: string, status: number) {
}
// Fields a client may set. completed_at is derived from status server-side.
const TASK_FIELDS = ["title", "notes", "status", "project_id", "context", "waiting_for", "due_date", "defer_date", "sort_order"];
const PROJECT_FIELDS = ["name", "status", "notes", "sort_order"];
const TASK_FIELDS = ["title", "notes", "status", "project_id", "context", "waiting_for", "due_date", "defer_date", "priority", "estimate_min", "repeat", "sort_order", "area"];
const PROJECT_FIELDS = ["name", "status", "notes", "sort_order", "area"];
// Only these may be cleared to NULL; notes/title are NOT NULL and keep "".
const NULLABLE = new Set(["project_id", "context", "waiting_for", "due_date", "defer_date", "estimate_min", "repeat"]);
function pick(body: Record<string, unknown>, fields: string[]) {
const out: Record<string, unknown> = {};
for (const f of fields) if (f in body) out[f] = body[f] === "" ? null : body[f];
for (const f of fields) if (f in body) out[f] = body[f] === "" && NULLABLE.has(f) ? null : body[f];
return out;
}
@@ -54,12 +63,15 @@ async function handle(req: Request): Promise<Response> {
if (path === "/v1/tasks" && method === "GET") {
const status = url.searchParams.get("status");
const projectId = url.searchParams.get("project_id");
const area = url.searchParams.get("area");
if (status && !TASK_STATUSES.includes(status)) return err("bad status", 400);
if (area && !AREAS.includes(area)) return err("bad area", 400);
const where: string[] = [];
const params: unknown[] = [];
if (status) { params.push(status); where.push(`status = $${params.length}`); }
else where.push(`status != 'trashed'`);
if (projectId) { params.push(projectId); where.push(`project_id = $${params.length}`); }
if (area) { params.push(area); where.push(`area = $${params.length}`); }
const rows = await sql.unsafe(
`select ${TASK_COLS} from tasks where ${where.join(" and ")}
order by sort_order, created_at desc`, params);
@@ -70,15 +82,18 @@ async function handle(req: Request): Promise<Response> {
const t = pick(body, TASK_FIELDS);
if (typeof t.title !== "string" || !t.title.trim()) return err("title required", 400);
if (t.status && !TASK_STATUSES.includes(t.status as string)) return err("bad status", 400);
if (t.repeat && !REPEATS.includes(t.repeat as string)) return err("bad repeat", 400);
if (t.area && !AREAS.includes(t.area as string)) return err("bad area", 400);
const id = typeof body.id === "string" && UUID_RE.test(body.id) ? body.id : null;
// Client-supplied id + on-conflict no-op make offline-queue retries idempotent.
const rows = await sql.unsafe(
`insert into tasks (id, title, notes, status, project_id, context, waiting_for, due_date, defer_date, sort_order)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10)
`insert into tasks (id, title, notes, status, project_id, context, waiting_for, due_date, defer_date, priority, estimate_min, repeat, sort_order, area)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
on conflict (id) do nothing
returning ${TASK_COLS}`,
[id, t.title, t.notes ?? "", t.status ?? "inbox", t.project_id ?? null, t.context ?? null,
t.waiting_for ?? null, t.due_date ?? null, t.defer_date ?? null, t.sort_order ?? 0]);
[id, t.title, t.notes ?? "", t.status ?? "next", t.project_id ?? null, t.context ?? null,
t.waiting_for ?? null, t.due_date ?? null, t.defer_date ?? null, t.priority ?? 4,
t.estimate_min ?? null, t.repeat ?? null, t.sort_order ?? 0, t.area ?? "personal"]);
if (rows.length) return json(rows[0], 201);
const existing = await sql.unsafe(`select ${TASK_COLS} from tasks where id = $1`, [id]);
return json(existing[0], 200);
@@ -93,16 +108,19 @@ async function handle(req: Request): Promise<Response> {
const id = m[1];
const patch = method === "DELETE" ? { status: "trashed" } : pick(body, TASK_FIELDS);
if (patch.status && !TASK_STATUSES.includes(patch.status as string)) return err("bad status", 400);
if (patch.repeat && !REPEATS.includes(patch.repeat as string)) return err("bad repeat", 400);
if (patch.area && !AREAS.includes(patch.area as string)) return err("bad area", 400);
const existing = await sql.unsafe(`select ${TASK_COLS} from tasks where id = $1`, [id]);
if (!existing.length) return err("not found", 404);
const cur = { ...existing[0], ...patch };
const completedAt = cur.status === "done" ? (existing[0].completed_at ?? new Date()) : null;
const rows = await sql.unsafe(
`update tasks set title=$2, notes=$3, status=$4, project_id=$5, context=$6, waiting_for=$7,
due_date=$8, defer_date=$9, completed_at=$10, sort_order=$11, updated_at=now()
due_date=$8, defer_date=$9, priority=$10, estimate_min=$11, repeat=$12, completed_at=$13,
sort_order=$14, area=$15, updated_at=now()
where id=$1 returning ${TASK_COLS}`,
[id, cur.title, cur.notes, cur.status, cur.project_id, cur.context, cur.waiting_for,
cur.due_date, cur.defer_date, completedAt, cur.sort_order]);
cur.due_date, cur.defer_date, cur.priority, cur.estimate_min, cur.repeat, completedAt, cur.sort_order, cur.area]);
return json(rows[0]);
}
@@ -121,13 +139,14 @@ async function handle(req: Request): Promise<Response> {
const p = pick(body, PROJECT_FIELDS);
if (typeof p.name !== "string" || !p.name.trim()) return err("name required", 400);
if (p.status && !PROJECT_STATUSES.includes(p.status as string)) return err("bad status", 400);
if (p.area && !AREAS.includes(p.area as string)) return err("bad area", 400);
const id = typeof body.id === "string" && UUID_RE.test(body.id) ? body.id : null;
const rows = await sql.unsafe(
`insert into projects (id, name, status, notes, sort_order)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5)
`insert into projects (id, name, status, notes, sort_order, area)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6)
on conflict (id) do nothing
returning ${PROJECT_COLS}`,
[id, p.name, p.status ?? "active", p.notes ?? "", p.sort_order ?? 0]);
[id, p.name, p.status ?? "active", p.notes ?? "", p.sort_order ?? 0, p.area ?? "personal"]);
if (rows.length) return json(rows[0], 201);
const existing = await sql.unsafe(`select ${PROJECT_COLS} from projects where id = $1`, [id]);
return json(existing[0], 200);
@@ -138,13 +157,14 @@ async function handle(req: Request): Promise<Response> {
const id = m[1];
const patch = method === "DELETE" ? { status: "dropped" } : pick(body, PROJECT_FIELDS);
if (patch.status && !PROJECT_STATUSES.includes(patch.status as string)) return err("bad status", 400);
if (patch.area && !AREAS.includes(patch.area as string)) return err("bad area", 400);
const existing = await sql.unsafe(`select ${PROJECT_COLS} from projects where id = $1`, [id]);
if (!existing.length) return err("not found", 404);
const cur = { ...existing[0], ...patch };
const rows = await sql.unsafe(
`update projects set name=$2, status=$3, notes=$4, sort_order=$5, updated_at=now()
`update projects set name=$2, status=$3, notes=$4, sort_order=$5, area=$6, updated_at=now()
where id=$1 returning ${PROJECT_COLS}`,
[id, cur.name, cur.status, cur.notes, cur.sort_order]);
[id, cur.name, cur.status, cur.notes, cur.sort_order, cur.area]);
return json(rows[0]);
}
@@ -164,6 +184,10 @@ Bun.serve({
fetch: (req) =>
handle(req).catch((e) => {
console.error(e);
// Constraint violations are bad input, not server faults — 400 so
// offline clients drop the op instead of retrying it forever.
const errno = (e as { errno?: string }).errno ?? "";
if (errno.startsWith("23")) return err(`constraint violation: ${(e as Error).message}`, 400);
return err("internal error", 500);
}),
});

View File

@@ -23,6 +23,9 @@ create table if not exists tasks (
waiting_for text, -- who/what is being waited on when status = 'waiting'
due_date date,
defer_date date, -- hide from lists until this date
priority int not null default 4 check (priority between 1 and 4), -- 1=urgent … 4=low
estimate_min int check (estimate_min > 0),
repeat text check (repeat in ('daily', 'weekdays', 'weekly', 'monthly', 'yearly')),
completed_at timestamptz,
sort_order double precision not null default 0,
created_at timestamptz not null default now(),
@@ -31,3 +34,14 @@ create table if not exists tasks (
create index if not exists tasks_status_idx on tasks (status);
create index if not exists tasks_project_idx on tasks (project_id);
-- Columns added after the initial deploy (create-if-not-exists won't alter).
alter table tasks add column if not exists repeat text
check (repeat in ('daily', 'weekdays', 'weekly', 'monthly', 'yearly'));
-- Personal vs work: the app's top-level toggle filters every list by area.
alter table tasks add column if not exists area text not null default 'personal'
check (area in ('personal', 'work'));
alter table projects add column if not exists area text not null default 'personal'
check (area in ('personal', 'work'));
create index if not exists tasks_area_idx on tasks (area);