7 Commits
v1.9 ... v1.15

Author SHA1 Message Date
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
44 changed files with 5245 additions and 240 deletions

View File

@@ -5,7 +5,36 @@ on:
branches: [main] branches: [main]
jobs: jobs:
check:
# Fast fail: lockfile sync, types, lint — catches in ~2 min what would
# otherwise kill the APK build twenty minutes in.
runs-on: desktop
container:
image: node:22
steps:
- name: Checkout
run: |
git init -q .
git fetch -q --depth 1 "https://git.rehbock.xyz/${{ github.repository }}.git" "${{ github.sha }}"
git checkout -q FETCH_HEAD
- name: Lockfile + typecheck + lint
run: |
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: build:
needs: check
# RN/NDK builds starve the 4-core VPS — run on the desktop runner # 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 # (marcusDesktop). Jobs queue while the desktop is off and start when it
# comes online; switch back to ubuntu-latest to build on the VPS. # comes online; switch back to ubuntu-latest to build on the VPS.
@@ -81,3 +110,13 @@ jobs:
-H "Authorization: token $TOKEN" \ -H "Authorization: token $TOKEN" \
-F "attachment=@$APK;type=application/vnd.android.package-archive" >/dev/null -F "attachment=@$APK;type=application/vnd.android.package-archive" >/dev/null
echo "published $TAG" 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"

View File

@@ -9,7 +9,11 @@ Minimal Getting Things Done app. One Expo/React Native codebase for iPhone, Andr
## Layout ## 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. 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.
- **Weekly review**: overdue, stalled (14d), aging waiting-fors (7d), someday sweep, deferred preview, stuck projects — with one-tap triage per row. Done and Trash (restorable) live at the bottom of Review.
- `server/` — Bun API + Dockerfile + compose. Deployed at `~/personal/gtd` on rehbock.xyz, joined to `personal-db_default` + `caddy_net`. - `server/` — Bun API + Dockerfile + compose. Deployed at `~/personal/gtd` on rehbock.xyz, joined to `personal-db_default` + `caddy_net`.
## Develop ## 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) 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 ## Deploy
```sh ```sh

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.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_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/> <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"> <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> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
@@ -28,5 +29,12 @@
<data android:scheme="gtd"/> <data android:scheme="gtd"/>
</intent-filter> </intent-filter>
</activity> </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> </application>
</manifest> </manifest>

View File

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

View File

@@ -1,18 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<vector xmlns:android="http://schemas.android.com/apk/res/android" <item android:drawable="@color/splashscreen_background"/>
xmlns:aapt="http://schemas.android.com/aapt" <item>
android:width="108dp" <bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
android:height="108dp" </item>
android:viewportWidth="108" </layer-list>
android:viewportHeight="108">
<path android:pathData="M0,0h108v108H0z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="54" android:startY="0"
android:endX="54" android:endY="108"
android:startColor="#1A1F2B"
android:endColor="#0B0D12" />
</aapt:attr>
</path>
</vector>

View File

@@ -1,63 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The four GTD quadrants (next/waiting/someday/projects) with a check in
"next" — matches the app's home screen. Grid spans 32..76 to stay inside
the adaptive-icon safe zone. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- top-left: next (green) -->
<path android:pathData="M37,32 h11 a5,5 0 0 1 5,5 v11 a5,5 0 0 1 -5,5 h-11 a5,5 0 0 1 -5,-5 v-11 a5,5 0 0 1 5,-5 z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="34" android:startY="34"
android:endX="53" android:endY="53"
android:startColor="#4FD48C"
android:endColor="#2E9E60" />
</aapt:attr>
</path>
<!-- top-right: waiting (amber) -->
<path android:pathData="M60,32 h11 a5,5 0 0 1 5,5 v11 a5,5 0 0 1 -5,5 h-11 a5,5 0 0 1 -5,-5 v-11 a5,5 0 0 1 5,-5 z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="57" android:startY="34"
android:endX="76" android:endY="53"
android:startColor="#EDBB55"
android:endColor="#C8912B" />
</aapt:attr>
</path>
<!-- bottom-left: someday (violet) -->
<path android:pathData="M37,55 h11 a5,5 0 0 1 5,5 v11 a5,5 0 0 1 -5,5 h-11 a5,5 0 0 1 -5,-5 v-11 a5,5 0 0 1 5,-5 z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="34" android:startY="57"
android:endX="53" android:endY="76"
android:startColor="#AC9FF0"
android:endColor="#7E6DD6" />
</aapt:attr>
</path>
<!-- bottom-right: projects (blue) -->
<path android:pathData="M60,55 h11 a5,5 0 0 1 5,5 v11 a5,5 0 0 1 -5,5 h-11 a5,5 0 0 1 -5,-5 v-11 a5,5 0 0 1 5,-5 z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="57" android:startY="57"
android:endX="76" android:endY="76"
android:startColor="#6FAAEE"
android:endColor="#3F7DC4" />
</aapt:attr>
</path>
<!-- checkmark in the green quadrant -->
<path
android:pathData="M38,42.8 L42,46.8 L48.5,38.8"
android:strokeColor="#FFFFFF"
android:strokeWidth="3"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:fillColor="#00000000" />
</vector>

View File

@@ -1,27 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M37,33.5 h11 a3.5,3.5 0 0 1 3.5,3.5 v11 a3.5,3.5 0 0 1 -3.5,3.5 h-11 a3.5,3.5 0 0 1 -3.5,-3.5 v-11 a3.5,3.5 0 0 1 3.5,-3.5 z"
android:strokeColor="#FFFFFF"
android:strokeWidth="3"
android:fillColor="#00000000" />
<path
android:pathData="M60,33.5 h11 a3.5,3.5 0 0 1 3.5,3.5 v11 a3.5,3.5 0 0 1 -3.5,3.5 h-11 a3.5,3.5 0 0 1 -3.5,-3.5 v-11 a3.5,3.5 0 0 1 3.5,-3.5 z"
android:strokeColor="#FFFFFF"
android:strokeWidth="3"
android:fillColor="#00000000" />
<path
android:pathData="M37,56.5 h11 a3.5,3.5 0 0 1 3.5,3.5 v11 a3.5,3.5 0 0 1 -3.5,3.5 h-11 a3.5,3.5 0 0 1 -3.5,-3.5 v-11 a3.5,3.5 0 0 1 3.5,-3.5 z"
android:strokeColor="#FFFFFF"
android:strokeWidth="3"
android:fillColor="#00000000" />
<path
android:pathData="M60,56.5 h11 a3.5,3.5 0 0 1 3.5,3.5 v11 a3.5,3.5 0 0 1 -3.5,3.5 h-11 a3.5,3.5 0 0 1 -3.5,-3.5 v-11 a3.5,3.5 0 0 1 3.5,-3.5 z"
android:strokeColor="#FFFFFF"
android:strokeWidth="3"
android:fillColor="#FFFFFF" />
</vector>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/> <background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/> <monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon> </adaptive-icon>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/> <background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/> <monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon> </adaptive-icon>

View File

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

@@ -28,8 +28,6 @@ android.enablePngCrunchInReleaseBuilds=true
# Use this property to specify which architecture you want to build. # Use this property to specify which architecture you want to build.
# You can also override it from the CLI using # You can also override it from the CLI using
# ./gradlew <task> -PreactNativeArchitectures=x86_64 # ./gradlew <task> -PreactNativeArchitectures=x86_64
# arm64 only: Pixel is arm64-v8a; x86/v7a builds are wasted time + APK bloat.
# Add back x86_64 temporarily if an emulator build is ever needed.
reactNativeArchitectures=arm64-v8a reactNativeArchitectures=arm64-v8a
# Use this property to enable support to the new architecture. # Use this property to enable support to the new architecture.

View File

@@ -25,7 +25,26 @@
"favicon": "./assets/images/favicon.png" "favicon": "./assets/images/favicon.png"
}, },
"plugins": [ "plugins": [
"./plugins/withAndroidRelease",
"expo-router", "expo-router",
[
"react-native-android-widget",
{
"widgets": [
{
"name": "NextUp",
"label": "GTD — Next up",
"description": "Your top three next actions",
"minWidth": "250dp",
"minHeight": "110dp",
"targetCellWidth": 4,
"targetCellHeight": 2,
"resizeMode": "horizontal|vertical",
"updatePeriodMillis": 1800000
}
]
}
],
[ [
"expo-splash-screen", "expo-splash-screen",
{ {

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);
}

4109
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,13 @@
{ {
"name": "app", "name": "app",
"main": "expo-router/entry", "main": "index.js",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@expo/ui": "~57.0.9", "@expo/ui": "~57.0.9",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/netinfo": "12.0.1",
"expo": "~57.0.11", "expo": "~57.0.11",
"expo-application": "~57.0.2",
"expo-constants": "~57.0.9", "expo-constants": "~57.0.9",
"expo-crypto": "~57.0.1", "expo-crypto": "~57.0.1",
"expo-device": "~57.0.1", "expo-device": "~57.0.1",
@@ -23,6 +25,7 @@
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-native": "0.86.2", "react-native": "0.86.2",
"react-native-android-widget": "^0.22.1",
"react-native-draggable-flatlist": "^4.0.3", "react-native-draggable-flatlist": "^4.0.3",
"react-native-gesture-handler": "~2.32.0", "react-native-gesture-handler": "~2.32.0",
"react-native-reanimated": "4.5.1", "react-native-reanimated": "4.5.1",
@@ -33,9 +36,15 @@
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.2.2", "@types/react": "~19.2.2",
"eslint": "^9.0.0",
"eslint-config-expo": "~57.0.1",
"typescript": "~6.0.3" "typescript": "~6.0.3"
}, },
"engines": {
"node": "22.x"
},
"scripts": { "scripts": {
"lock": "docker run --rm -v $PWD:/w -w /w node:22 npm install --package-lock-only --no-audit --no-fund",
"start": "expo start", "start": "expo start",
"reset-project": "node ./scripts/reset-project.js", "reset-project": "node ./scripts/reset-project.js",
"android": "expo run:android", "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;
};

View File

@@ -1,19 +1,36 @@
import NetInfo from "@react-native-community/netinfo";
import { Link, Stack } from "expo-router"; import { Link, Stack } from "expo-router";
import { StatusBar } from "expo-status-bar"; import { StatusBar } from "expo-status-bar";
import { useEffect } from "react"; import { useEffect } from "react";
import { AppState } from "react-native"; import { AppState } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import { hydrate, sync } from "../lib/store"; import { getState, hydrate, subscribe, sync } from "../lib/store";
import { C } from "../lib/theme"; import { C } from "../lib/theme";
import { refreshNextUpWidget } from "../widget/refresh";
export default function RootLayout() { export default function RootLayout() {
useEffect(() => { useEffect(() => {
void hydrate(); void hydrate();
// Re-sync whenever the app returns to the foreground. // 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(); 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 ( return (
@@ -44,6 +61,7 @@ export default function RootLayout() {
<Stack.Screen name="projects" options={{ title: "Projects" }} /> <Stack.Screen name="projects" options={{ title: "Projects" }} />
<Stack.Screen name="task/[id]" options={{ title: "Clarify", presentation: "modal" }} /> <Stack.Screen name="task/[id]" options={{ title: "Clarify", presentation: "modal" }} />
<Stack.Screen name="project/[id]" options={{ title: "Project" }} /> <Stack.Screen name="project/[id]" options={{ title: "Project" }} />
<Stack.Screen name="review" options={{ title: "Weekly review" }} />
<Stack.Screen name="settings" options={{ title: "Settings", presentation: "modal" }} /> <Stack.Screen name="settings" options={{ title: "Settings", presentation: "modal" }} />
</Stack> </Stack>
</GestureHandlerRootView> </GestureHandlerRootView>

View File

@@ -1,14 +1,17 @@
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useState } from "react"; import { useRef, useState } from "react";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; import { Pressable, ScrollView, StyleSheet, Text } from "react-native";
import DateField from "../components/DateField"; import DateField from "../components/DateField";
import EstimateChips from "../components/EstimateChips";
import PriorityChips from "../components/PriorityChips"; import PriorityChips from "../components/PriorityChips";
import { Chips, Field } from "../components/ui"; import { Chips, Field } from "../components/ui";
import { haptic } from "../lib/haptics"; import { haptic } from "../lib/haptics";
import { parseQuickAdd, type ParsedQuickAdd } from "../lib/quickadd";
import { REPEAT_LABEL, REPEATS } from "../lib/repeat";
import { activeProjects, createTask } from "../lib/store"; import { activeProjects, createTask } from "../lib/store";
import { C } from "../lib/theme"; import { C } from "../lib/theme";
import { useStore } from "../lib/useStore"; import { useStore } from "../lib/useStore";
import type { Task, TaskStatus } from "../lib/types"; import type { Repeat, Task, TaskStatus } from "../lib/types";
const BOXES: { key: TaskStatus; label: string }[] = [ const BOXES: { key: TaskStatus; label: string }[] = [
{ key: "next", label: "Next up" }, { key: "next", label: "Next up" },
@@ -16,37 +19,62 @@ const BOXES: { key: TaskStatus; label: string }[] = [
{ key: "someday", label: "Someday" }, { key: "someday", label: "Someday" },
]; ];
const ESTIMATES = [15, 30, 45, 60, 90, 120];
export default function AddTask() { export default function AddTask() {
const router = useRouter(); const router = useRouter();
const store = useStore(); const store = useStore();
const [title, setTitle] = useState(""); const [raw, setRaw] = useState("");
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [priority, setPriority] = useState<Task["priority"] | null>(null); const [priority, setPriority] = useState<Task["priority"] | null>(null);
const [estimate, setEstimate] = useState<number | null>(null); const [estimate, setEstimate] = useState<number | null>(null);
const [due, setDue] = useState(""); const [due, setDue] = useState("");
const [repeat, setRepeat] = useState<Repeat | null>(null);
const [box, setBox] = useState<TaskStatus>("next"); const [box, setBox] = useState<TaskStatus>("next");
const [waitingFor, setWaitingFor] = useState("");
const [projectId, setProjectId] = useState<string | null>(null); const [projectId, setProjectId] = useState<string | null>(null);
const [context, setContext] = useState<string | null>(null);
const projects = activeProjects(store).filter((p) => p.status === "active"); const projects = activeProjects(store).filter((p) => p.status === "active");
const projectOptions = ["none", ...projects.map((p) => p.id)]; const projectOptions = ["none", ...projects.map((p) => p.id)];
const projectLabels = Object.fromEntries([["none", "None"], ...projects.map((p) => [p.id, p.name])]); const projectLabels = Object.fromEntries([["none", "None"], ...projects.map((p) => [p.id, p.name])]);
// The title field understands tokens ("Pay rent tmr p1 @home ~30m monthly")
// and mirrors each one into the form controls 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 onTitleChange = (text: string) => {
setRaw(text);
const p = parseQuickAdd(text, projects);
const prev = prevParsed.current;
if (p.priority !== prev.priority && p.priority) setPriority(p.priority);
if (p.due_date !== prev.due_date && p.due_date) setDue(p.due_date);
if (p.estimate_min !== prev.estimate_min && p.estimate_min) setEstimate(p.estimate_min);
if (p.repeat !== prev.repeat && p.repeat) setRepeat(p.repeat);
if (p.project_id !== prev.project_id && p.project_id) setProjectId(p.project_id);
if (p.context !== prev.context) setContext(p.context ?? null);
prevParsed.current = p;
};
const cleanTitle = parseQuickAdd(raw, projects).title.trim() || raw.trim();
const tokensUsed = raw.trim() !== cleanTitle;
// Every task must be born fully scoped: title + priority + due date. // Every task must be born fully scoped: title + priority + due date.
const dueValid = /^\d{4}-\d{2}-\d{2}$/.test(due.trim()); const dueValid = /^\d{4}-\d{2}-\d{2}$/.test(due.trim());
const ready = title.trim().length > 0 && priority !== null && dueValid; const ready = cleanTitle.length > 0 && priority !== null && dueValid;
const save = () => { const save = () => {
if (!ready || priority === null) return; if (!ready || priority === null) return;
createTask({ createTask({
title: title.trim(), title: cleanTitle,
notes, notes,
status: box, status: box,
priority, priority,
estimate_min: estimate, estimate_min: estimate,
due_date: due.trim(), due_date: due.trim(),
repeat,
project_id: projectId, project_id: projectId,
context,
waiting_for: box === "waiting" ? waitingFor.trim() || null : null,
}); });
haptic.success(); haptic.success();
router.back(); router.back();
@@ -54,39 +82,32 @@ export default function AddTask() {
return ( return (
<ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={{ padding: 16 }}> <ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={{ padding: 16 }}>
<Field label="Task" value={title} onChangeText={setTitle} placeholder="What needs doing?" autoFocus />
<Field <Field
label="Description" label="Task"
value={notes} value={raw}
onChangeText={setNotes} onChangeText={onTitleChange}
placeholder="Details, links, notes…" placeholder="Pay rent tmr p1 @home ~30m"
multiline autoFocus
style={{ minHeight: 64, textAlignVertical: "top" }}
/> />
{tokensUsed && (
<Text style={s.parsedHint}>
→ {cleanTitle || "…"}
{context ? ` · ${context}` : ""}
</Text>
)}
<PriorityChips value={priority} onChange={setPriority} /> <PriorityChips value={priority} onChange={setPriority} />
<DateField label="Due date" value={due || null} onChange={(v) => setDue(v ?? "")} /> <DateField label="Due date" value={due || null} onChange={(v) => setDue(v ?? "")} />
<View style={{ marginBottom: 14 }}> <Chips
<Text style={s.label}>Estimated time</Text> label="Repeat"
<View style={s.rowWrap}> options={["none", ...REPEATS]}
{ESTIMATES.map((m) => { labels={{ none: "None", ...REPEAT_LABEL }}
const active = estimate === m; value={repeat ?? "none"}
return ( onChange={(v) => {
<Pressable haptic.select();
key={m} setRepeat(v === "none" ? null : (v as Repeat));
style={[s.chip, active && s.chipActive]} }}
onPress={() => { />
haptic.select(); <EstimateChips value={estimate} onChange={setEstimate} />
setEstimate(active ? null : m);
}}
>
<Text style={[s.chipText, active && { color: C.text }]}>
{m < 60 ? `${m}m` : m % 60 ? `${Math.floor(m / 60)}h${m % 60}` : `${m / 60}h`}
</Text>
</Pressable>
);
})}
</View>
</View>
<Chips <Chips
label="Box" label="Box"
options={BOXES.map((b) => b.key)} options={BOXES.map((b) => b.key)}
@@ -97,6 +118,9 @@ export default function AddTask() {
setBox(b); setBox(b);
}} }}
/> />
{box === "waiting" && (
<Field label="Waiting for" value={waitingFor} onChangeText={setWaitingFor} placeholder="Who or what?" />
)}
<Chips <Chips
label="Project" label="Project"
options={projectOptions} options={projectOptions}
@@ -104,6 +128,14 @@ export default function AddTask() {
value={projectId ?? "none"} value={projectId ?? "none"}
onChange={(v) => setProjectId(v === "none" ? null : v)} onChange={(v) => setProjectId(v === "none" ? null : v)}
/> />
<Field
label="Description"
value={notes}
onChangeText={setNotes}
placeholder="Details, links, notes…"
multiline
style={{ minHeight: 64, textAlignVertical: "top" }}
/>
<Pressable style={[s.save, !ready && s.saveDisabled]} onPress={save} disabled={!ready}> <Pressable style={[s.save, !ready && s.saveDisabled]} onPress={save} disabled={!ready}>
<Text style={[s.saveText, !ready && { color: C.muted }]}> <Text style={[s.saveText, !ready && { color: C.muted }]}>
{ready ? "Add task" : "Needs a title, priority & due date"} {ready ? "Add task" : "Needs a title, priority & due date"}
@@ -114,18 +146,7 @@ export default function AddTask() {
} }
const s = StyleSheet.create({ const s = StyleSheet.create({
label: { color: C.muted, fontSize: 13, marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 }, parsedHint: { color: C.accent, fontSize: 13, marginTop: -8, marginBottom: 12 },
rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginBottom: 12 },
chip: {
paddingVertical: 8,
paddingHorizontal: 14,
borderRadius: 20,
backgroundColor: C.surface,
borderWidth: 1,
borderColor: C.border,
},
chipActive: { backgroundColor: C.surface2, borderColor: C.accent },
chipText: { color: C.muted, fontSize: 14 },
save: { save: {
backgroundColor: C.accent, backgroundColor: C.accent,
borderRadius: 10, borderRadius: 10,

View File

@@ -1,8 +1,9 @@
import { useRouter } from "expo-router"; import { useFocusEffect, useRouter } from "expo-router";
import { Pressable, StyleSheet, Text, View } from "react-native"; import { Pressable, StyleSheet, Text, View } from "react-native";
import TaskRow from "../components/TaskRow"; import TaskRow from "../components/TaskRow";
import { haptic } from "../lib/haptics"; import { haptic } from "../lib/haptics";
import { tasksByStatus, todayTasks } from "../lib/store"; import { daysSince, useLastReview } from "../lib/review";
import { todayTasks, visibleTasksByStatus } from "../lib/store";
import { C } from "../lib/theme"; import { C } from "../lib/theme";
import { useStore } from "../lib/useStore"; import { useStore } from "../lib/useStore";
@@ -12,13 +13,18 @@ const MENU = [
{ icon: "⏳", label: "Waiting", href: "/list/waiting" }, { icon: "⏳", label: "Waiting", href: "/list/waiting" },
{ icon: "💭", label: "Someday", href: "/list/someday" }, { icon: "💭", label: "Someday", href: "/list/someday" },
{ icon: "📁", label: "Projects", href: "/projects" }, { icon: "📁", label: "Projects", href: "/projects" },
{ icon: "🔄", label: "Review", href: "/review" },
] as const; ] as const;
export default function Home() { export default function Home() {
const router = useRouter(); const router = useRouter();
const store = useStore(); const store = useStore();
const next = tasksByStatus(store, "next"); const next = visibleTasksByStatus(store, "next");
const { overdue } = todayTasks(store); const { overdue } = todayTasks(store);
const [lastReview, reloadReview] = useLastReview();
useFocusEffect(reloadReview);
const reviewDays = daysSince(lastReview);
const reviewDue = lastReview === null || (reviewDays !== null && reviewDays >= 7);
return ( return (
<View style={s.screen}> <View style={s.screen}>
@@ -38,6 +44,18 @@ export default function Home() {
<Text style={s.overdue}>{overdue.length} overdue →</Text> <Text style={s.overdue}>{overdue.length} overdue →</Text>
</Pressable> </Pressable>
)} )}
{reviewDue && (
<Pressable onPress={() => router.push("/review")}>
<Text style={s.reviewNudge}>
{lastReview === null ? "no review yet — start one →" : `review overdue (${reviewDays}d) →`}
</Text>
</Pressable>
)}
{store.offline && (
<Text style={s.offline}>
offline{store.pending > 0 ? ` — ${store.pending} change${store.pending === 1 ? "" : "s"} queued` : ""}
</Text>
)}
{store.syncError && <Text style={s.error}>{store.syncError}</Text>} {store.syncError && <Text style={s.error}>{store.syncError}</Text>}
</View> </View>
<View style={s.menu}> <View style={s.menu}>
@@ -75,7 +93,9 @@ const s = StyleSheet.create({
empty: { color: C.muted, textAlign: "center", marginTop: 48, fontSize: 15 }, empty: { color: C.muted, textAlign: "center", marginTop: 48, fontSize: 15 },
viewAll: { color: C.muted, fontSize: 14, padding: 16 }, viewAll: { color: C.muted, fontSize: 14, padding: 16 },
overdue: { color: C.danger, fontSize: 14, paddingHorizontal: 16, paddingBottom: 8 }, overdue: { color: C.danger, fontSize: 14, paddingHorizontal: 16, paddingBottom: 8 },
reviewNudge: { color: C.warn, fontSize: 14, paddingHorizontal: 16, paddingBottom: 8 },
error: { color: C.danger, paddingHorizontal: 16, paddingTop: 8 }, error: { color: C.danger, paddingHorizontal: 16, paddingTop: 8 },
offline: { color: C.muted, fontSize: 13, paddingHorizontal: 16, paddingTop: 8 },
menu: { menu: {
flexDirection: "row", flexDirection: "row",
borderTopWidth: StyleSheet.hairlineWidth, borderTopWidth: StyleSheet.hairlineWidth,

View File

@@ -5,17 +5,17 @@ import type { TaskStatus } from "../../lib/types";
const TITLES: Record<string, string> = { const TITLES: Record<string, string> = {
next: "Next up", next: "Next up",
waiting: "Waiting for", waiting: "Waiting for",
scheduled: "Scheduled",
someday: "Someday", someday: "Someday",
done: "Done", done: "Done",
trashed: "Trash",
}; };
const HINTS: Record<string, string> = { const HINTS: Record<string, string> = {
next: "Nothing next. Tap + on the home screen.", next: "Nothing next. Tap + on the home screen.",
waiting: "Not waiting on anyone.", waiting: "Not waiting on anyone.",
scheduled: "Nothing scheduled.",
someday: "No someday/maybe items.", someday: "No someday/maybe items.",
done: "Nothing done yet.", done: "Nothing done yet.",
trashed: "Trash is empty.",
}; };
export default function ListByStatus() { export default function ListByStatus() {

171
app/src/app/review.tsx Normal file
View File

@@ -0,0 +1,171 @@
// Weekly review — GTD's keystone habit. One pass, one decision per row:
// each row has exactly three quick actions (do next / someday / trash) plus
// done on the checkbox, so nothing needs a trip through the edit modal.
import { useRouter } from "expo-router";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import { Button } from "../components/ui";
import { friendlyDate } from "../lib/dates";
import { haptic } from "../lib/haptics";
import { PRIORITY_COLOR } from "../lib/priority";
import { daysSince, markReviewed, useLastReview } from "../lib/review";
import { reviewBuckets, trashTask, updateTask } from "../lib/store";
import { C } from "../lib/theme";
import { useStore } from "../lib/useStore";
import type { Task } from "../lib/types";
function ReviewRow({ task, showDefer }: { task: Task; showDefer?: boolean }) {
const router = useRouter();
const act = (fn: () => void) => () => {
haptic.select();
fn();
};
return (
<View style={s.row}>
<Pressable
style={s.check}
hitSlop={8}
onPress={act(() => {
haptic.success();
updateTask(task.id, { status: "done" });
})}
>
<View style={[s.checkCircle, { borderColor: PRIORITY_COLOR[task.priority] }]} />
</Pressable>
<Pressable style={{ flex: 1 }} onPress={() => router.push(`/task/${task.id}`)}>
<Text style={s.title} numberOfLines={2}>
{task.title}
</Text>
<Text style={s.meta}>
{[
task.status,
task.waiting_for && `→ ${task.waiting_for}`,
showDefer && task.defer_date && `resurfaces ${friendlyDate(task.defer_date)}`,
]
.filter(Boolean)
.join(" · ")}
</Text>
</Pressable>
{task.status !== "next" && (
<Pressable style={s.action} hitSlop={6} onPress={act(() => updateTask(task.id, { status: "next" }))}>
<Text style={s.actionText}>▶️</Text>
</Pressable>
)}
{task.status !== "someday" && (
<Pressable style={s.action} hitSlop={6} onPress={act(() => updateTask(task.id, { status: "someday" }))}>
<Text style={s.actionText}>💭</Text>
</Pressable>
)}
<Pressable style={s.action} hitSlop={6} onPress={act(() => trashTask(task.id))}>
<Text style={s.actionText}>🗑️</Text>
</Pressable>
</View>
);
}
function Section({ title, hint, tasks, showDefer }: { title: string; hint: string; tasks: Task[]; showDefer?: boolean }) {
if (tasks.length === 0) return null;
return (
<View style={{ marginBottom: 8 }}>
<Text style={s.sectionTitle}>
{title} ({tasks.length})
</Text>
<Text style={s.sectionHint}>{hint}</Text>
{tasks.map((t) => (
<ReviewRow key={t.id} task={t} showDefer={showDefer} />
))}
</View>
);
}
export default function Review() {
const router = useRouter();
const store = useStore();
const buckets = reviewBuckets(store);
const [last, reload] = useLastReview();
const days = daysSince(last);
const empty =
!buckets.overdue.length &&
!buckets.staleNext.length &&
!buckets.agingWaiting.length &&
!buckets.someday.length &&
!buckets.deferred.length &&
!buckets.stuckProjects.length;
return (
<ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={{ padding: 16, paddingBottom: 48 }}>
<Text style={s.lastReview}>
{last ? (days === 0 ? "Last review: today" : `Last review: ${days}d ago`) : "First review — welcome."}
</Text>
<Section title="Overdue" hint="Reschedule, do, or let go." tasks={buckets.overdue} />
<Section title="Stalled" hint="Next actions untouched for 2+ weeks — still the real next step?" tasks={buckets.staleNext} />
<Section title="Waiting too long" hint="A week without movement — time to nudge?" tasks={buckets.agingWaiting} />
<Section title="Someday sweep" hint="Promote what's ripe, trash what's dead." tasks={buckets.someday} />
<Section title="Deferred" hint="Hidden from lists — just so you know they're coming." tasks={buckets.deferred} showDefer />
{buckets.stuckProjects.length > 0 && (
<View style={{ marginBottom: 8 }}>
<Text style={s.sectionTitle}>Stuck projects ({buckets.stuckProjects.length})</Text>
<Text style={s.sectionHint}>Active, but no next action — decide the next step or park them.</Text>
{buckets.stuckProjects.map((p) => (
<Pressable key={p.id} style={s.row} onPress={() => router.push(`/project/${p.id}`)}>
<Text style={[s.title, { flex: 1 }]}>{p.name}</Text>
<Text style={s.meta}>open →</Text>
</Pressable>
))}
</View>
)}
{empty && <Text style={s.empty}>All clear. Nothing needs a decision.</Text>}
<Button
title="✓ Mark review complete"
onPress={async () => {
haptic.success();
await markReviewed();
reload();
router.back();
}}
/>
<View style={s.archiveRow}>
<Pressable onPress={() => router.push("/list/done")}>
<Text style={s.archiveLink}>Done</Text>
</Pressable>
<Text style={s.archiveDot}>·</Text>
<Pressable onPress={() => router.push("/list/trashed")}>
<Text style={s.archiveLink}>Trash</Text>
</Pressable>
</View>
</ScrollView>
);
}
const s = StyleSheet.create({
lastReview: { color: C.muted, fontSize: 13, marginBottom: 12 },
sectionTitle: {
color: C.text,
fontSize: 15,
fontWeight: "700",
paddingTop: 12,
},
sectionHint: { color: C.muted, fontSize: 13, marginBottom: 4 },
row: {
flexDirection: "row",
alignItems: "center",
gap: 10,
paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: C.border,
},
check: { paddingRight: 2 },
checkCircle: { width: 20, height: 20, borderRadius: 10, borderWidth: 2 },
title: { color: C.text, fontSize: 15 },
meta: { color: C.muted, fontSize: 12, marginTop: 2 },
action: { paddingHorizontal: 4 },
actionText: { fontSize: 17 },
empty: { color: C.muted, textAlign: "center", marginVertical: 32, fontSize: 15 },
archiveRow: { flexDirection: "row", justifyContent: "center", gap: 8, marginTop: 4 },
archiveLink: { color: C.muted, fontSize: 14, padding: 6 },
archiveDot: { color: C.muted, alignSelf: "center" },
});

View File

@@ -1,3 +1,4 @@
import * as Application from "expo-application";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ScrollView, Text } from "react-native"; import { ScrollView, Text } from "react-native";
@@ -23,7 +24,7 @@ export default function Settings() {
const save = async () => { const save = async () => {
await setConfig(url, token); await setConfig(url, token);
try { try {
await api.tasks.list({ status: "inbox" }); await api.tasks.list({ status: "next" });
void sync(); void sync();
router.back(); router.back();
} catch (e) { } catch (e) {
@@ -44,6 +45,10 @@ export default function Settings() {
/> />
<Button title="Save & test" onPress={save} /> <Button title="Save & test" onPress={save} />
{status && <Text style={{ color: C.danger }}>{status}</Text>} {status && <Text style={{ color: C.danger }}>{status}</Text>}
{/* CI stamps versionName from the release tag — confirms which build Obtainium installed. */}
<Text style={{ color: C.muted, fontSize: 13, textAlign: "center", marginTop: 32 }}>
GTD v{Application.nativeApplicationVersion ?? "dev"}
</Text>
</ScrollView> </ScrollView>
); );
} }

View File

@@ -1,13 +1,16 @@
import { useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import { useState } from "react"; import { useState } from "react";
import { ScrollView, Text } from "react-native"; import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import DateField from "../../components/DateField"; import DateField from "../../components/DateField";
import EstimateChips from "../../components/EstimateChips";
import PriorityChips from "../../components/PriorityChips"; import PriorityChips from "../../components/PriorityChips";
import { Button, Chips, Field } from "../../components/ui"; import { Button, Chips, Field } from "../../components/ui";
import { activeProjects, trashTask, updateTask } from "../../lib/store"; import { haptic } from "../../lib/haptics";
import { REPEAT_LABEL, REPEATS } from "../../lib/repeat";
import { activeProjects, knownContexts, trashTask, updateTask } from "../../lib/store";
import { C } from "../../lib/theme"; import { C } from "../../lib/theme";
import { useStore } from "../../lib/useStore"; import { useStore } from "../../lib/useStore";
import type { Task, TaskStatus } from "../../lib/types"; import type { Repeat, Task, TaskStatus } from "../../lib/types";
const STATUSES: TaskStatus[] = ["next", "waiting", "someday", "done"]; const STATUSES: TaskStatus[] = ["next", "waiting", "someday", "done"];
@@ -35,6 +38,7 @@ export default function TaskDetail() {
defer_date: draft.defer_date, defer_date: draft.defer_date,
priority: draft.priority, priority: draft.priority,
estimate_min: draft.estimate_min, estimate_min: draft.estimate_min,
repeat: draft.repeat,
}); });
router.back(); router.back();
}; };
@@ -42,18 +46,30 @@ export default function TaskDetail() {
const projects = activeProjects(store).filter((p) => p.status === "active" || p.id === draft.project_id); const projects = activeProjects(store).filter((p) => p.status === "active" || p.id === draft.project_id);
const projectOptions = ["none", ...projects.map((p) => p.id)]; const projectOptions = ["none", ...projects.map((p) => p.id)];
const projectLabels = Object.fromEntries([["none", "None"], ...projects.map((p) => [p.id, p.name])]); const projectLabels = Object.fromEntries([["none", "None"], ...projects.map((p) => [p.id, p.name])]);
const contexts = knownContexts(store);
if (draft.status === "trashed") {
return (
<ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={{ padding: 16 }}>
<Text style={s.trashedTitle}>{draft.title}</Text>
<Text style={s.trashedMeta}>In the trash.</Text>
<Button
title="Restore to Next"
onPress={() => {
haptic.success();
updateTask(draft.id, { status: "next" });
router.back();
}}
/>
</ScrollView>
);
}
return ( return (
<ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={{ padding: 16 }}> <ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={{ padding: 16 }}>
<Field label="Title" value={draft.title} onChangeText={(v) => set({ title: v })} /> <Field label="Title" value={draft.title} onChangeText={(v) => set({ title: v })} />
<PriorityChips value={draft.priority} onChange={(priority) => set({ priority })} /> <PriorityChips value={draft.priority} onChange={(priority) => set({ priority })} />
<Field <EstimateChips value={draft.estimate_min} onChange={(estimate_min) => set({ estimate_min })} />
label="Estimate (minutes)"
value={draft.estimate_min ? String(draft.estimate_min) : ""}
onChangeText={(v) => set({ estimate_min: Number(v.replace(/\D/g, "")) || null })}
placeholder="30"
keyboardType="number-pad"
/>
<Chips label="List" options={STATUSES} value={draft.status} onChange={(status) => set({ status })} /> <Chips label="List" options={STATUSES} value={draft.status} onChange={(status) => set({ status })} />
<Chips <Chips
label="Project" label="Project"
@@ -69,6 +85,22 @@ export default function TaskDetail() {
placeholder="@home, @computer, @errands…" placeholder="@home, @computer, @errands…"
autoCapitalize="none" autoCapitalize="none"
/> />
{contexts.length > 0 && (
<View style={s.contextRow}>
{contexts.map((c) => (
<Pressable
key={c}
style={[s.contextChip, draft.context === c && s.contextChipActive]}
onPress={() => {
haptic.select();
set({ context: draft.context === c ? null : c });
}}
>
<Text style={[s.contextText, draft.context === c && { color: C.text }]}>{c}</Text>
</Pressable>
))}
</View>
)}
{draft.status === "waiting" && ( {draft.status === "waiting" && (
<Field <Field
label="Waiting for" label="Waiting for"
@@ -78,7 +110,17 @@ export default function TaskDetail() {
/> />
)} )}
<DateField label="Due date" value={draft.due_date} onChange={(v) => set({ due_date: v })} clearable /> <DateField label="Due date" value={draft.due_date} onChange={(v) => set({ due_date: v })} clearable />
<Chips
label="Repeat"
options={["none", ...REPEATS]}
labels={{ none: "None", ...REPEAT_LABEL }}
value={draft.repeat ?? "none"}
onChange={(v) => set({ repeat: v === "none" ? null : (v as Repeat) })}
/>
<DateField label="Defer until" value={draft.defer_date} onChange={(v) => set({ defer_date: v })} clearable /> <DateField label="Defer until" value={draft.defer_date} onChange={(v) => set({ defer_date: v })} clearable />
{!!draft.defer_date && (
<Text style={s.deferHint}>Hidden from lists until then — it resurfaces on the day.</Text>
)}
<Field <Field
label="Notes" label="Notes"
value={draft.notes} value={draft.notes}
@@ -98,3 +140,20 @@ export default function TaskDetail() {
</ScrollView> </ScrollView>
); );
} }
const s = StyleSheet.create({
trashedTitle: { color: C.text, fontSize: 18, marginBottom: 6 },
trashedMeta: { color: C.muted, fontSize: 14, marginBottom: 18 },
contextRow: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: -6, marginBottom: 14 },
contextChip: {
paddingVertical: 6,
paddingHorizontal: 12,
borderRadius: 16,
backgroundColor: C.surface,
borderWidth: 1,
borderColor: C.border,
},
contextChipActive: { backgroundColor: C.surface2, borderColor: C.accent },
contextText: { color: C.muted, fontSize: 13 },
deferHint: { color: C.muted, fontSize: 13, marginTop: -8, marginBottom: 12 },
});

View File

@@ -0,0 +1,56 @@
// 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 { Pressable, StyleSheet, Text, View } from "react-native";
import { haptic } from "../lib/haptics";
import { formatEstimate } from "../lib/priority";
import { C } from "../lib/theme";
const PRESETS = [15, 30, 45, 60, 90, 120];
export default function EstimateChips({
value,
onChange,
}: {
value: number | null;
onChange: (v: number | null) => void;
}) {
const options = value && !PRESETS.includes(value) ? [...PRESETS, value].sort((a, b) => a - b) : PRESETS;
return (
<View style={{ marginBottom: 14 }}>
<Text style={s.label}>Estimated time</Text>
<View style={s.rowWrap}>
{options.map((m) => {
const active = value === m;
return (
<Pressable
key={m}
style={[s.chip, active && s.chipActive]}
onPress={() => {
haptic.select();
onChange(active ? null : m);
}}
>
<Text style={[s.chipText, active && { color: C.text }]}>{formatEstimate(m)}</Text>
</Pressable>
);
})}
</View>
</View>
);
}
const s = StyleSheet.create({
label: { color: C.muted, fontSize: 13, marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 },
rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
chip: {
paddingVertical: 8,
paddingHorizontal: 14,
borderRadius: 20,
backgroundColor: C.surface,
borderWidth: 1,
borderColor: C.border,
},
chipActive: { backgroundColor: C.surface2, borderColor: C.accent },
chipText: { color: C.muted, fontSize: 14 },
});

View File

@@ -1,13 +1,20 @@
import { useState } from "react"; import { useState } from "react";
import { RefreshControl, StyleSheet, Text, View } from "react-native"; import { Pressable, RefreshControl, StyleSheet, Text, View } from "react-native";
import DraggableFlatList, { ScaleDecorator } from "react-native-draggable-flatlist"; import DraggableFlatList, { ScaleDecorator } from "react-native-draggable-flatlist";
import { haptic } from "../lib/haptics"; import { haptic } from "../lib/haptics";
import { reorderTask, sync, tasksByStatus } from "../lib/store"; import { reorderTask, sync, tasksByStatus, visibleTasksByStatus } from "../lib/store";
import { C } from "../lib/theme"; import { C } from "../lib/theme";
import { useStore } from "../lib/useStore"; import { useStore } from "../lib/useStore";
import type { TaskStatus } from "../lib/types"; import type { TaskStatus } from "../lib/types";
import TaskRow from "./TaskRow"; import TaskRow from "./TaskRow";
// 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({ export default function TaskListScreen({
status, status,
emptyHint, emptyHint,
@@ -17,7 +24,18 @@ export default function TaskListScreen({
}) { }) {
const store = useStore(); const store = useStore();
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const tasks = tasksByStatus(store, status); const [showDeferred, setShowDeferred] = useState(false);
const hidesDeferred = HIDES_DEFERRED.includes(status);
const archive = ARCHIVE.includes(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;
return ( return (
<View style={s.screen}> <View style={s.screen}>
@@ -27,7 +45,7 @@ export default function TaskListScreen({
activationDistance={12} activationDistance={12}
onDragBegin={() => haptic.pickup()} onDragBegin={() => haptic.pickup()}
onDragEnd={({ data, from, to }) => { onDragEnd={({ data, from, to }) => {
if (from !== to) { if (!archive && from !== to) {
haptic.success(); haptic.success();
reorderTask(data[to].id, status, to); reorderTask(data[to].id, status, to);
} }
@@ -35,7 +53,7 @@ export default function TaskListScreen({
renderItem={({ item, drag, isActive }) => ( renderItem={({ item, drag, isActive }) => (
<ScaleDecorator activeScale={1.03}> <ScaleDecorator activeScale={1.03}>
<View style={isActive && s.activeRow}> <View style={isActive && s.activeRow}>
<TaskRow task={item} uncheckTo={status} onLongPress={drag} /> <TaskRow task={item} uncheckTo={status === "done" ? "next" : status} onLongPress={archive ? undefined : drag} />
</View> </View>
</ScaleDecorator> </ScaleDecorator>
)} )}
@@ -51,6 +69,15 @@ export default function TaskListScreen({
/> />
} }
ListEmptyComponent={<Text style={s.empty}>{emptyHint}</Text>} ListEmptyComponent={<Text style={s.empty}>{emptyHint}</Text>}
ListFooterComponent={
hidesDeferred && (hiddenCount > 0 || showDeferred) ? (
<Pressable onPress={() => setShowDeferred((v) => !v)}>
<Text style={s.deferredFooter}>
{showDeferred ? "hide deferred" : `⏸ ${hiddenCount} deferred — show`}
</Text>
</Pressable>
) : null
}
contentContainerStyle={{ paddingBottom: 32 }} contentContainerStyle={{ paddingBottom: 32 }}
/> />
</View> </View>
@@ -61,4 +88,5 @@ const s = StyleSheet.create({
screen: { flex: 1, backgroundColor: C.bg }, screen: { flex: 1, backgroundColor: C.bg },
activeRow: { backgroundColor: C.surface2, borderRadius: 12 }, activeRow: { backgroundColor: C.surface2, borderRadius: 12 },
empty: { color: C.muted, textAlign: "center", marginTop: 48, fontSize: 15 }, empty: { color: C.muted, textAlign: "center", marginTop: 48, fontSize: 15 },
deferredFooter: { color: C.muted, fontSize: 13, textAlign: "center", paddingVertical: 14 },
}); });

View File

@@ -1,6 +1,7 @@
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { Pressable, StyleSheet, Text, View } from "react-native"; import { Pressable, StyleSheet, Text, View } from "react-native";
import Animated, { FadeIn } from "react-native-reanimated"; import Animated, { FadeIn } from "react-native-reanimated";
import { dueMeta, friendlyDate, isDeferred } from "../lib/dates";
import { haptic } from "../lib/haptics"; import { haptic } from "../lib/haptics";
import { formatEstimate, PRIORITY_COLOR } from "../lib/priority"; import { formatEstimate, PRIORITY_COLOR } from "../lib/priority";
import { updateTask } from "../lib/store"; import { updateTask } from "../lib/store";
@@ -21,15 +22,21 @@ export default function TaskRow({
onLongPress?: () => void; onLongPress?: () => void;
}) { }) {
const router = useRouter(); const router = useRouter();
const meta = [ // 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, showStatus ? task.status : null,
task.repeat && "↻",
task.context, task.context,
task.waiting_for && `→ ${task.waiting_for}`, task.waiting_for && `→ ${task.waiting_for}`,
formatEstimate(task.estimate_min), formatEstimate(task.estimate_min),
task.due_date && `due ${task.due_date}`, isDeferred(task.defer_date) && `⏸ until ${friendlyDate(task.defer_date!)}`,
] ]
.filter(Boolean) .filter(Boolean)
.join(" · "); .join(" · ");
const meta = base || due;
const toggle = () => { const toggle = () => {
const done = task.status !== "done"; const done = task.status !== "done";
@@ -52,7 +59,13 @@ export default function TaskRow({
<Text style={s.title} numberOfLines={2}> <Text style={s.title} numberOfLines={2}>
{task.title} {task.title}
</Text> </Text>
{!!meta && <Text style={s.meta}>{meta}</Text>} {!!meta && (
<Text style={s.meta}>
{base}
{!!base && !!due && " · "}
{due && <Text style={{ color: dueColor }}>{due.label}</Text>}
</Text>
)}
</View> </View>
</Pressable> </Pressable>
</Animated.View> </Animated.View>

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" };
}

View File

@@ -9,7 +9,7 @@ export const PRIORITY_LABEL: Record<Task["priority"], string> = {
4: "Low", 4: "Low",
}; };
export const PRIORITY_COLOR: Record<Task["priority"], string> = { export const PRIORITY_COLOR: Record<Task["priority"], `#${string}`> = {
1: "#E05B5B", 1: "#E05B5B",
2: "#E0A93E", 2: "#E0A93E",
3: "#5B8DEF", 3: "#5B8DEF",

View File

@@ -1,8 +1,12 @@
// Natural-language quick add, Todoist style: // Natural-language quick add, Todoist style:
// "Pay rent tomorrow p1 @home #Sydney ~30m" // "Pay rent tomorrow p1 @home #Sydney ~30m monthly"
// → title "Pay rent", due tomorrow, priority 1, context @home, // → title "Pay rent", due tomorrow, priority 1, context @home,
// project "Sydney" (matched by name prefix), estimate 30 min. // project "Sydney" (matched by name prefix), estimate 30 min, repeats monthly.
import type { Project, Task } from "./types"; //
// 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 { export interface ParsedQuickAdd {
title: string; title: string;
@@ -11,9 +15,17 @@ export interface ParsedQuickAdd {
context?: string; context?: string;
project_id?: string; project_id?: string;
estimate_min?: number; estimate_min?: number;
repeat?: Repeat;
} }
const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]; 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 { function iso(d: Date): string {
const y = d.getFullYear(); const y = d.getFullYear();
@@ -26,7 +38,8 @@ function parseDateWord(word: string): string | undefined {
const w = word.toLowerCase(); const w = word.toLowerCase();
const today = new Date(); const today = new Date();
if (w === "today" || w === "tod") return iso(today); if (w === "today" || w === "tod") return iso(today);
if (w === "tomorrow" || w === "tmr" || w === "tom") { // No "tom" alias — it's a name far more often than a date.
if (w === "tomorrow" || w === "tmr") {
const d = new Date(today); const d = new Date(today);
d.setDate(d.getDate() + 1); d.setDate(d.getDate() + 1);
return iso(d); return iso(d);
@@ -77,6 +90,11 @@ export function parseQuickAdd(input: string, projects: Project[]): ParsedQuickAd
out.estimate_min = est; out.estimate_min = est;
continue; continue;
} }
const rep = REPEAT_WORDS[token.toLowerCase()];
if (rep && out.repeat === undefined) {
out.repeat = rep;
continue;
}
if (out.due_date === undefined) { if (out.due_date === undefined) {
const due = parseDateWord(token); const due = parseDateWord(token);
if (due) { if (due) {

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));
}

29
app/src/lib/review.ts Normal file
View File

@@ -0,0 +1,29 @@
// Last-completed-review timestamp, local only — the review is a habit, not
// synced data.
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useCallback, useEffect, useState } from "react";
const KEY = "review_last_v1";
export async function getLastReview(): Promise<string | null> {
return AsyncStorage.getItem(KEY);
}
export async function markReviewed(): Promise<void> {
await AsyncStorage.setItem(KEY, new Date().toISOString());
}
export function daysSince(iso: string | null): number | null {
if (!iso) return null;
return Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
}
/** [lastReviewIso, reload] — call reload on focus to pick up changes. */
export function useLastReview(): [string | null, () => void] {
const [last, setLast] = useState<string | null>(null);
const reload = useCallback(() => {
void getLastReview().then(setLast);
}, []);
useEffect(reload, [reload]);
return [last, reload];
}

View File

@@ -10,6 +10,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Crypto from "expo-crypto"; import * as Crypto from "expo-crypto";
import { api } from "./api"; import { api } from "./api";
import { getConfig } from "./config"; import { getConfig } from "./config";
import { daysFromToday, isDeferred, todayIso } from "./dates";
import { nextOccurrence, shiftDays } from "./repeat";
import type { Project, Task } from "./types"; import type { Project, Task } from "./types";
export interface State { export interface State {
@@ -18,6 +20,8 @@ export interface State {
hydrated: boolean; hydrated: boolean;
syncing: boolean; syncing: boolean;
pending: number; pending: number;
/** True when the last sync failed for network reasons — expected offline. */
offline: boolean;
syncError: string | null; syncError: string | null;
} }
@@ -34,6 +38,7 @@ let state: State = {
hydrated: false, hydrated: false,
syncing: false, syncing: false,
pending: 0, pending: 0,
offline: false,
syncError: null, syncError: null,
}; };
let queue: Op[] = []; let queue: Op[] = [];
@@ -78,7 +83,7 @@ function now() {
// Fields the API accepts — everything else (timestamps, completed_at) is local // Fields the API accepts — everything else (timestamps, completed_at) is local
// bookkeeping until the server's authoritative row arrives via pull(). // 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", "priority", "estimate_min", "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"] as const;
const PROJECT_FIELDS = ["name", "status", "notes", "sort_order"] as const; const PROJECT_FIELDS = ["name", "status", "notes", "sort_order"] as const;
function pickFields<T>(data: Partial<T>, fields: readonly (keyof T & string)[]): Partial<T> { function pickFields<T>(data: Partial<T>, fields: readonly (keyof T & string)[]): Partial<T> {
@@ -101,11 +106,13 @@ function enqueue(op: Op) {
} }
export function createTask(data: Partial<Task> & { title: string }): Task { 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 = { const task: Task = {
id: Crypto.randomUUID(), id: Crypto.randomUUID(),
title: data.title, title: data.title,
notes: data.notes ?? "", notes: data.notes ?? "",
status: data.status ?? "inbox", status,
project_id: data.project_id ?? null, project_id: data.project_id ?? null,
context: data.context ?? null, context: data.context ?? null,
waiting_for: data.waiting_for ?? null, waiting_for: data.waiting_for ?? null,
@@ -113,11 +120,12 @@ export function createTask(data: Partial<Task> & { title: string }): Task {
defer_date: data.defer_date ?? null, defer_date: data.defer_date ?? null,
priority: data.priority ?? 4, priority: data.priority ?? 4,
estimate_min: data.estimate_min ?? null, estimate_min: data.estimate_min ?? null,
repeat: data.repeat ?? null,
completed_at: null, completed_at: null,
// New tasks land at the top of their list. // New tasks land at the top of their list.
sort_order: sort_order:
data.sort_order ?? data.sort_order ??
Math.min(0, ...state.tasks.filter((t) => t.status === (data.status ?? "next")).map((t) => t.sort_order)) - 1, Math.min(0, ...state.tasks.filter((t) => t.status === status).map((t) => t.sort_order)) - 1,
created_at: now(), created_at: now(),
updated_at: now(), updated_at: now(),
}; };
@@ -128,6 +136,7 @@ export function createTask(data: Partial<Task> & { title: string }): Task {
} }
export function updateTask(id: string, patch: Partial<Task>) { export function updateTask(id: string, patch: Partial<Task>) {
const before = state.tasks.find((t) => t.id === id);
setState({ setState({
tasks: state.tasks.map((t) => { tasks: state.tasks.map((t) => {
if (t.id !== id) return t; if (t.id !== id) return t;
@@ -139,6 +148,39 @@ export function updateTask(id: string, patch: Partial<Task>) {
}); });
persistData(); persistData();
enqueue({ kind: "task.update", id, data: pickFields(patch, TASK_FIELDS) }); 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") {
spawnNextOccurrence(before);
}
}
function spawnNextOccurrence(t: 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;
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,
});
} }
export function trashTask(id: string) { export function trashTask(id: string) {
@@ -193,9 +235,13 @@ export async function sync() {
setState({}); setState({});
} }
await pull(); await pull();
setState({ syncError: null }); setState({ syncError: null, offline: false });
} catch (e) { } 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 { } finally {
syncRunning = false; syncRunning = false;
setState({ syncing: false }); setState({ syncing: false });
@@ -222,7 +268,14 @@ async function push(op: Op) {
} }
async function pull() { 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 // 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. // pushes those ops and then this merge converges to the server state.
const dirty = new Set(queue.map((o) => o.id)); const dirty = new Set(queue.map((o) => o.id));
@@ -261,6 +314,15 @@ export function tasksByStatus(s: State, status: Task["status"]): Task[] {
return s.tasks.filter((t) => t.status === status).sort(byManualOrder); return 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). */ /** Re-slot a task at `index` within its status list (fractional sort_order). */
export function reorderTask(id: string, status: Task["status"], index: number) { export function reorderTask(id: string, status: Task["status"], index: number) {
const list = tasksByStatus(getState(), status).filter((t) => t.id !== id); const list = tasksByStatus(getState(), status).filter((t) => t.id !== id);
@@ -296,3 +358,52 @@ export function activeProjects(s: State): Project[] {
export function openTaskCount(s: State, projectId: string): number { export function openTaskCount(s: State, projectId: string): number {
return s.tasks.filter((t) => t.project_id === projectId && t.status !== "done" && t.status !== "trashed").length; 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 s.tasks) {
if (t.context && t.status !== "done" && t.status !== "trashed") set.add(t.context);
}
return [...set].sort();
}
// ---- weekly review ----
const STALE_DAYS = 14;
const WAITING_DAYS = 7;
function olderThanDays(ts: string, days: number): boolean {
return Date.now() - new Date(ts).getTime() > days * 86_400_000;
}
export interface ReviewBuckets {
overdue: Task[];
staleNext: Task[];
agingWaiting: Task[];
someday: Task[];
deferred: Task[];
stuckProjects: Project[];
}
export function reviewBuckets(s: State): ReviewBuckets {
const today = todayIso();
const open = s.tasks.filter((t) => t.status !== "done" && t.status !== "trashed");
const overdue = open.filter((t) => t.due_date && t.due_date < today).sort(byPriorityDue);
const overdueIds = new Set(overdue.map((t) => t.id));
const actionable = new Set(["next", "waiting"]);
return {
overdue,
staleNext: open
.filter((t) => t.status === "next" && !overdueIds.has(t.id) && olderThanDays(t.updated_at, STALE_DAYS))
.sort(byPriorityDue),
agingWaiting: open
.filter((t) => t.status === "waiting" && olderThanDays(t.updated_at, WAITING_DAYS))
.sort((a, b) => a.updated_at.localeCompare(b.updated_at)),
someday: tasksByStatus(s, "someday"),
deferred: open.filter((t) => isDeferred(t.defer_date, today)).sort((a, b) => a.defer_date!.localeCompare(b.defer_date!)),
stuckProjects: activeProjects(s).filter(
(p) => p.status === "active" && !s.tasks.some((t) => t.project_id === p.id && actionable.has(t.status)),
),
};
}

View File

@@ -8,4 +8,5 @@ export const C = {
accent: "#5B8DEF", accent: "#5B8DEF",
done: "#4CAF7D", done: "#4CAF7D",
danger: "#E05B5B", danger: "#E05B5B",
}; warn: "#E0A93E",
} as const;

View File

@@ -1,5 +1,6 @@
export type TaskStatus = "inbox" | "next" | "waiting" | "scheduled" | "someday" | "done" | "trashed"; export type TaskStatus = "inbox" | "next" | "waiting" | "scheduled" | "someday" | "done" | "trashed";
export type ProjectStatus = "active" | "someday" | "completed" | "dropped"; export type ProjectStatus = "active" | "someday" | "completed" | "dropped";
export type Repeat = "daily" | "weekdays" | "weekly" | "monthly" | "yearly";
export interface Task { export interface Task {
id: string; id: string;
@@ -13,6 +14,7 @@ export interface Task {
defer_date: string | null; defer_date: string | null;
priority: 1 | 2 | 3 | 4; priority: 1 | 2 | 3 | 4;
estimate_min: number | null; estimate_min: number | null;
repeat: Repeat | null;
completed_at: string | null; completed_at: string | null;
sort_order: number; sort_order: number;
created_at: string; created_at: string;

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 circle 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: 10, 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: 12, letterSpacing: 1, color: C.muted }} />
{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: "#FFFFFF" }} />
</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

@@ -5,12 +5,17 @@ const sql = new SQL(process.env.DATABASE_URL!);
const API_TOKEN = process.env.API_TOKEN!; const API_TOKEN = process.env.API_TOKEN!;
if (!API_TOKEN) throw new Error("API_TOKEN not set"); 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, const TASK_COLS = `id, title, notes, status, project_id, context, waiting_for,
due_date::text as due_date, defer_date::text as defer_date, due_date::text as due_date, defer_date::text as defer_date,
priority, estimate_min, completed_at, sort_order, created_at, updated_at`; priority, estimate_min, repeat, completed_at, sort_order, created_at, updated_at`;
const PROJECT_COLS = `id, name, status, notes, sort_order, created_at, updated_at`; const PROJECT_COLS = `id, name, status, notes, sort_order, created_at, updated_at`;
const TASK_STATUSES = ["inbox", "next", "waiting", "scheduled", "someday", "done", "trashed"]; 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 PROJECT_STATUSES = ["active", "someday", "completed", "dropped"];
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
@@ -28,12 +33,15 @@ function err(message: string, status: number) {
} }
// Fields a client may set. completed_at is derived from status server-side. // 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", "priority", "estimate_min", "sort_order"]; const TASK_FIELDS = ["title", "notes", "status", "project_id", "context", "waiting_for", "due_date", "defer_date", "priority", "estimate_min", "repeat", "sort_order"];
const PROJECT_FIELDS = ["name", "status", "notes", "sort_order"]; const PROJECT_FIELDS = ["name", "status", "notes", "sort_order"];
// 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[]) { function pick(body: Record<string, unknown>, fields: string[]) {
const out: Record<string, unknown> = {}; 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; return out;
} }
@@ -70,16 +78,17 @@ async function handle(req: Request): Promise<Response> {
const t = pick(body, TASK_FIELDS); const t = pick(body, TASK_FIELDS);
if (typeof t.title !== "string" || !t.title.trim()) return err("title required", 400); 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.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);
const id = typeof body.id === "string" && UUID_RE.test(body.id) ? body.id : null; 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. // Client-supplied id + on-conflict no-op make offline-queue retries idempotent.
const rows = await sql.unsafe( const rows = await sql.unsafe(
`insert into tasks (id, title, notes, status, project_id, context, waiting_for, due_date, defer_date, priority, estimate_min, sort_order) `insert into tasks (id, title, notes, status, project_id, context, waiting_for, due_date, defer_date, priority, estimate_min, repeat, sort_order)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) values (coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
on conflict (id) do nothing on conflict (id) do nothing
returning ${TASK_COLS}`, returning ${TASK_COLS}`,
[id, t.title, t.notes ?? "", t.status ?? "inbox", t.project_id ?? null, t.context ?? null, [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.waiting_for ?? null, t.due_date ?? null, t.defer_date ?? null, t.priority ?? 4,
t.estimate_min ?? null, t.sort_order ?? 0]); t.estimate_min ?? null, t.repeat ?? null, t.sort_order ?? 0]);
if (rows.length) return json(rows[0], 201); if (rows.length) return json(rows[0], 201);
const existing = await sql.unsafe(`select ${TASK_COLS} from tasks where id = $1`, [id]); const existing = await sql.unsafe(`select ${TASK_COLS} from tasks where id = $1`, [id]);
return json(existing[0], 200); return json(existing[0], 200);
@@ -94,17 +103,18 @@ async function handle(req: Request): Promise<Response> {
const id = m[1]; const id = m[1];
const patch = method === "DELETE" ? { status: "trashed" } : pick(body, TASK_FIELDS); 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.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);
const existing = await sql.unsafe(`select ${TASK_COLS} from tasks where id = $1`, [id]); const existing = await sql.unsafe(`select ${TASK_COLS} from tasks where id = $1`, [id]);
if (!existing.length) return err("not found", 404); if (!existing.length) return err("not found", 404);
const cur = { ...existing[0], ...patch }; const cur = { ...existing[0], ...patch };
const completedAt = cur.status === "done" ? (existing[0].completed_at ?? new Date()) : null; const completedAt = cur.status === "done" ? (existing[0].completed_at ?? new Date()) : null;
const rows = await sql.unsafe( const rows = await sql.unsafe(
`update tasks set title=$2, notes=$3, status=$4, project_id=$5, context=$6, waiting_for=$7, `update tasks set title=$2, notes=$3, status=$4, project_id=$5, context=$6, waiting_for=$7,
due_date=$8, defer_date=$9, priority=$10, estimate_min=$11, completed_at=$12, sort_order=$13, due_date=$8, defer_date=$9, priority=$10, estimate_min=$11, repeat=$12, completed_at=$13,
updated_at=now() sort_order=$14, updated_at=now()
where id=$1 returning ${TASK_COLS}`, where id=$1 returning ${TASK_COLS}`,
[id, cur.title, cur.notes, cur.status, cur.project_id, cur.context, cur.waiting_for, [id, cur.title, cur.notes, cur.status, cur.project_id, cur.context, cur.waiting_for,
cur.due_date, cur.defer_date, cur.priority, cur.estimate_min, completedAt, cur.sort_order]); cur.due_date, cur.defer_date, cur.priority, cur.estimate_min, cur.repeat, completedAt, cur.sort_order]);
return json(rows[0]); return json(rows[0]);
} }
@@ -166,6 +176,10 @@ Bun.serve({
fetch: (req) => fetch: (req) =>
handle(req).catch((e) => { handle(req).catch((e) => {
console.error(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); return err("internal error", 500);
}), }),
}); });

View File

@@ -25,6 +25,7 @@ create table if not exists tasks (
defer_date date, -- hide from lists until this 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 priority int not null default 4 check (priority between 1 and 4), -- 1=urgent … 4=low
estimate_min int check (estimate_min > 0), estimate_min int check (estimate_min > 0),
repeat text check (repeat in ('daily', 'weekdays', 'weekly', 'monthly', 'yearly')),
completed_at timestamptz, completed_at timestamptz,
sort_order double precision not null default 0, sort_order double precision not null default 0,
created_at timestamptz not null default now(), created_at timestamptz not null default now(),
@@ -33,3 +34,7 @@ create table if not exists tasks (
create index if not exists tasks_status_idx on tasks (status); create index if not exists tasks_status_idx on tasks (status);
create index if not exists tasks_project_idx on tasks (project_id); 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'));