Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65efb632db | |||
| b260e4e762 | |||
| e02ef05c02 | |||
| d54d5b32f1 | |||
| 4ef3f8c63c | |||
| 6ccd417f2a | |||
| 64967c19a0 |
@@ -5,7 +5,36 @@ on:
|
||||
branches: [main]
|
||||
|
||||
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:
|
||||
needs: check
|
||||
# RN/NDK builds starve the 4-core VPS — run on the desktop runner
|
||||
# (marcusDesktop). Jobs queue while the desktop is off and start when it
|
||||
# comes online; switch back to ubuntu-latest to build on the VPS.
|
||||
@@ -81,3 +110,13 @@ jobs:
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@$APK;type=application/vnd.android.package-archive" >/dev/null
|
||||
echo "published $TAG"
|
||||
|
||||
- name: Notify failure
|
||||
if: failure()
|
||||
env:
|
||||
NTFY_TOKEN: ${{ secrets.NTFY_TOKEN }}
|
||||
run: |
|
||||
[ -n "$NTFY_TOKEN" ] || { echo "NTFY_TOKEN not set — skipping notification"; exit 0; }
|
||||
curl -s -H "Authorization: Bearer $NTFY_TOKEN" -H "Title: gtd CI: APK build failed" -H "Priority: high" \
|
||||
-d "release build failed at ${{ github.sha }} — https://git.rehbock.xyz/marcus/gtd/actions" \
|
||||
https://ntfy.rehbock.xyz/gtd-ci
|
||||
|
||||
12
.githooks/pre-push
Executable file
12
.githooks/pre-push
Executable 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"
|
||||
15
README.md
15
README.md
@@ -9,7 +9,11 @@ Minimal Getting Things Done app. One Expo/React Native codebase for iPhone, Andr
|
||||
|
||||
## Layout
|
||||
|
||||
- `app/` — Expo app (expo-router, TypeScript). Tabs: Inbox / Next / Waiting / Someday / Projects; tap a task to clarify (list, project, context, waiting-for, due/defer dates).
|
||||
- `app/` — Expo app (expo-router, TypeScript). Home shows the top 3 next actions plus a launcher menu: Today / Next / Waiting / Someday / Projects / Review. 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`.
|
||||
|
||||
## Develop
|
||||
@@ -18,6 +22,15 @@ Minimal Getting Things Done app. One Expo/React Native codebase for iPhone, Andr
|
||||
cd app && npx expo start # scan QR with Expo Go (iPhone or Pixel)
|
||||
```
|
||||
|
||||
- **Node 22 only** (`app/.nvmrc`): CI's npm 10 rejects lockfiles written by newer npm. Never run bare `npm install` to change deps — use `npm run lock` (regenerates the lock via a `node:22` container), or run npm from node 22.
|
||||
- **Hook**: `git config core.hooksPath .githooks` enables a pre-push check that validates the lockfile against CI's npm before it can break a build.
|
||||
- **Local Android builds** need JDK 21 (AGP chokes on newer): pinned via `org.gradle.java.home` in `~/.gradle/gradle.properties` → `~/.local/share/java/jdk-21.0.12+8`.
|
||||
- **`expo prebuild` is safe to re-run**: `app/plugins/withAndroidRelease.js` re-injects the release signing, env-driven versionCode/Name, and gradle.properties tuning into the committed `android/`.
|
||||
|
||||
## Release (Android APK → Obtainium)
|
||||
|
||||
Every push to `main` runs `.gitea/workflows/release.yml` on the desktop runner: fast check job (lockfile/types/lint), then a signed `assembleRelease`, published as Gitea release `v1.<run>` with the APK attached. Phones track it with Obtainium → source `https://git.rehbock.xyz/marcus/gtd` (Forgejo/Gitea source type). CI failures push to ntfy topic `gtd-ci` when the `NTFY_TOKEN` repo secret is set.
|
||||
|
||||
## Deploy
|
||||
|
||||
```sh
|
||||
|
||||
1
app/.nvmrc
Normal file
1
app/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
22
|
||||
@@ -16,6 +16,7 @@
|
||||
<meta-data android:name="expo.modules.updates.ENABLE_BSDIFF_PATCH_SUPPORT" android:value="true"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
||||
<service android:name="com.reactnativeandroidwidget.RNWidgetCollectionService" android:permission="android.permission.BIND_REMOTEVIEWS"/>
|
||||
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode|smallestScreenSize|assetsPaths" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
@@ -28,5 +29,12 @@
|
||||
<data android:scheme="gtd"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<receiver android:name=".widget.NextUp" android:exported="false" android:label="GTD — Next up">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.marcus.gtd.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_nextup"/>
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.marcus.gtd.widget;
|
||||
|
||||
import com.reactnativeandroidwidget.RNWidgetProvider;
|
||||
|
||||
public class NextUp extends RNWidgetProvider {
|
||||
}
|
||||
@@ -1,18 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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">
|
||||
<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>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/splashscreen_background"/>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
@@ -2,4 +2,5 @@
|
||||
<string name="app_name">GTD</string>
|
||||
<string name="expo_system_ui_user_interface_style" translatable="false">automatic</string>
|
||||
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
|
||||
<string name="widget_nextup_description" translatable="false">Your top three next actions</string>
|
||||
</resources>
|
||||
21
app/android/app/src/main/res/xml/widgetprovider_nextup.xml
Normal file
21
app/android/app/src/main/res/xml/widgetprovider_nextup.xml
Normal 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>
|
||||
@@ -28,8 +28,6 @@ android.enablePngCrunchInReleaseBuilds=true
|
||||
# Use this property to specify which architecture you want to build.
|
||||
# You can also override it from the CLI using
|
||||
# ./gradlew <task> -PreactNativeArchitectures=x86_64
|
||||
# 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
|
||||
|
||||
# Use this property to enable support to the new architecture.
|
||||
|
||||
19
app/app.json
19
app/app.json
@@ -25,7 +25,26 @@
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"./plugins/withAndroidRelease",
|
||||
"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",
|
||||
{
|
||||
|
||||
10
app/eslint.config.js
Normal file
10
app/eslint.config.js
Normal 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
10
app/index.js
Normal 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
4109
app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"name": "app",
|
||||
"main": "expo-router/entry",
|
||||
"main": "index.js",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@expo/ui": "~57.0.9",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/netinfo": "12.0.1",
|
||||
"expo": "~57.0.11",
|
||||
"expo-application": "~57.0.2",
|
||||
"expo-constants": "~57.0.9",
|
||||
"expo-crypto": "~57.0.1",
|
||||
"expo-device": "~57.0.1",
|
||||
@@ -23,6 +25,7 @@
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.86.2",
|
||||
"react-native-android-widget": "^0.22.1",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-gesture-handler": "~2.32.0",
|
||||
"react-native-reanimated": "4.5.1",
|
||||
@@ -33,9 +36,15 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.2",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-expo": "~57.0.1",
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "22.x"
|
||||
},
|
||||
"scripts": {
|
||||
"lock": "docker run --rm -v $PWD:/w -w /w node:22 npm install --package-lock-only --no-audit --no-fund",
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"android": "expo run:android",
|
||||
|
||||
59
app/plugins/withAndroidRelease.js
Normal file
59
app/plugins/withAndroidRelease.js
Normal 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;
|
||||
};
|
||||
@@ -1,19 +1,36 @@
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import { Link, Stack } from "expo-router";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { useEffect } from "react";
|
||||
import { AppState } from "react-native";
|
||||
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 { refreshNextUpWidget } from "../widget/refresh";
|
||||
|
||||
export default function RootLayout() {
|
||||
useEffect(() => {
|
||||
void hydrate();
|
||||
// Re-sync whenever the app returns to the foreground.
|
||||
const sub = AppState.addEventListener("change", (s) => {
|
||||
const appState = AppState.addEventListener("change", (s) => {
|
||||
if (s === "active") void sync();
|
||||
});
|
||||
return () => sub.remove();
|
||||
// Drain the queue the moment connectivity returns (wifi/cellular).
|
||||
const net = NetInfo.addEventListener((s) => {
|
||||
if (s.isConnected) void sync();
|
||||
});
|
||||
// Keep the home-screen widget in step with every mutation and sync.
|
||||
const widget = subscribe(refreshNextUpWidget);
|
||||
// Slow heartbeat as a belt-and-braces retry while changes are queued.
|
||||
const heartbeat = setInterval(() => {
|
||||
if (getState().pending > 0 || getState().offline) void sync();
|
||||
}, 45_000);
|
||||
return () => {
|
||||
appState.remove();
|
||||
net();
|
||||
widget();
|
||||
clearInterval(heartbeat);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -44,6 +61,7 @@ export default function RootLayout() {
|
||||
<Stack.Screen name="projects" options={{ title: "Projects" }} />
|
||||
<Stack.Screen name="task/[id]" options={{ title: "Clarify", presentation: "modal" }} />
|
||||
<Stack.Screen name="project/[id]" options={{ title: "Project" }} />
|
||||
<Stack.Screen name="review" options={{ title: "Weekly review" }} />
|
||||
<Stack.Screen name="settings" options={{ title: "Settings", presentation: "modal" }} />
|
||||
</Stack>
|
||||
</GestureHandlerRootView>
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { useRef, useState } from "react";
|
||||
import { Pressable, ScrollView, StyleSheet, Text } from "react-native";
|
||||
import DateField from "../components/DateField";
|
||||
import EstimateChips from "../components/EstimateChips";
|
||||
import PriorityChips from "../components/PriorityChips";
|
||||
import { Chips, Field } from "../components/ui";
|
||||
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 { C } from "../lib/theme";
|
||||
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 }[] = [
|
||||
{ key: "next", label: "Next up" },
|
||||
@@ -16,37 +19,62 @@ const BOXES: { key: TaskStatus; label: string }[] = [
|
||||
{ key: "someday", label: "Someday" },
|
||||
];
|
||||
|
||||
const ESTIMATES = [15, 30, 45, 60, 90, 120];
|
||||
|
||||
export default function AddTask() {
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const [title, setTitle] = useState("");
|
||||
const [raw, setRaw] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [priority, setPriority] = useState<Task["priority"] | null>(null);
|
||||
const [estimate, setEstimate] = useState<number | null>(null);
|
||||
const [due, setDue] = useState("");
|
||||
const [repeat, setRepeat] = useState<Repeat | null>(null);
|
||||
const [box, setBox] = useState<TaskStatus>("next");
|
||||
const [waitingFor, setWaitingFor] = useState("");
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
const [context, setContext] = useState<string | null>(null);
|
||||
|
||||
const projects = activeProjects(store).filter((p) => p.status === "active");
|
||||
const projectOptions = ["none", ...projects.map((p) => p.id)];
|
||||
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.
|
||||
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 = () => {
|
||||
if (!ready || priority === null) return;
|
||||
createTask({
|
||||
title: title.trim(),
|
||||
title: cleanTitle,
|
||||
notes,
|
||||
status: box,
|
||||
priority,
|
||||
estimate_min: estimate,
|
||||
due_date: due.trim(),
|
||||
repeat,
|
||||
project_id: projectId,
|
||||
context,
|
||||
waiting_for: box === "waiting" ? waitingFor.trim() || null : null,
|
||||
});
|
||||
haptic.success();
|
||||
router.back();
|
||||
@@ -54,39 +82,32 @@ export default function AddTask() {
|
||||
|
||||
return (
|
||||
<ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={{ padding: 16 }}>
|
||||
<Field label="Task" value={title} onChangeText={setTitle} placeholder="What needs doing?" autoFocus />
|
||||
<Field
|
||||
label="Description"
|
||||
value={notes}
|
||||
onChangeText={setNotes}
|
||||
placeholder="Details, links, notes…"
|
||||
multiline
|
||||
style={{ minHeight: 64, textAlignVertical: "top" }}
|
||||
label="Task"
|
||||
value={raw}
|
||||
onChangeText={onTitleChange}
|
||||
placeholder="Pay rent tmr p1 @home ~30m"
|
||||
autoFocus
|
||||
/>
|
||||
{tokensUsed && (
|
||||
<Text style={s.parsedHint}>
|
||||
→ {cleanTitle || "…"}
|
||||
{context ? ` · ${context}` : ""}
|
||||
</Text>
|
||||
)}
|
||||
<PriorityChips value={priority} onChange={setPriority} />
|
||||
<DateField label="Due date" value={due || null} onChange={(v) => setDue(v ?? "")} />
|
||||
<View style={{ marginBottom: 14 }}>
|
||||
<Text style={s.label}>Estimated time</Text>
|
||||
<View style={s.rowWrap}>
|
||||
{ESTIMATES.map((m) => {
|
||||
const active = estimate === m;
|
||||
return (
|
||||
<Pressable
|
||||
key={m}
|
||||
style={[s.chip, active && s.chipActive]}
|
||||
onPress={() => {
|
||||
haptic.select();
|
||||
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
|
||||
label="Repeat"
|
||||
options={["none", ...REPEATS]}
|
||||
labels={{ none: "None", ...REPEAT_LABEL }}
|
||||
value={repeat ?? "none"}
|
||||
onChange={(v) => {
|
||||
haptic.select();
|
||||
setRepeat(v === "none" ? null : (v as Repeat));
|
||||
}}
|
||||
/>
|
||||
<EstimateChips value={estimate} onChange={setEstimate} />
|
||||
<Chips
|
||||
label="Box"
|
||||
options={BOXES.map((b) => b.key)}
|
||||
@@ -97,6 +118,9 @@ export default function AddTask() {
|
||||
setBox(b);
|
||||
}}
|
||||
/>
|
||||
{box === "waiting" && (
|
||||
<Field label="Waiting for" value={waitingFor} onChangeText={setWaitingFor} placeholder="Who or what?" />
|
||||
)}
|
||||
<Chips
|
||||
label="Project"
|
||||
options={projectOptions}
|
||||
@@ -104,6 +128,14 @@ export default function AddTask() {
|
||||
value={projectId ?? "none"}
|
||||
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}>
|
||||
<Text style={[s.saveText, !ready && { color: C.muted }]}>
|
||||
{ready ? "Add task" : "Needs a title, priority & due date"}
|
||||
@@ -114,18 +146,7 @@ export default function AddTask() {
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
label: { color: C.muted, fontSize: 13, marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 },
|
||||
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 },
|
||||
parsedHint: { color: C.accent, fontSize: 13, marginTop: -8, marginBottom: 12 },
|
||||
save: {
|
||||
backgroundColor: C.accent,
|
||||
borderRadius: 10,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { useFocusEffect, useRouter } from "expo-router";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import TaskRow from "../components/TaskRow";
|
||||
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 { useStore } from "../lib/useStore";
|
||||
|
||||
@@ -12,13 +13,18 @@ const MENU = [
|
||||
{ icon: "⏳", label: "Waiting", href: "/list/waiting" },
|
||||
{ icon: "💭", label: "Someday", href: "/list/someday" },
|
||||
{ icon: "📁", label: "Projects", href: "/projects" },
|
||||
{ icon: "🔄", label: "Review", href: "/review" },
|
||||
] as const;
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const next = tasksByStatus(store, "next");
|
||||
const next = visibleTasksByStatus(store, "next");
|
||||
const { overdue } = todayTasks(store);
|
||||
const [lastReview, reloadReview] = useLastReview();
|
||||
useFocusEffect(reloadReview);
|
||||
const reviewDays = daysSince(lastReview);
|
||||
const reviewDue = lastReview === null || (reviewDays !== null && reviewDays >= 7);
|
||||
|
||||
return (
|
||||
<View style={s.screen}>
|
||||
@@ -38,6 +44,18 @@ export default function Home() {
|
||||
<Text style={s.overdue}>{overdue.length} overdue →</Text>
|
||||
</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>}
|
||||
</View>
|
||||
<View style={s.menu}>
|
||||
@@ -75,7 +93,9 @@ const s = StyleSheet.create({
|
||||
empty: { color: C.muted, textAlign: "center", marginTop: 48, fontSize: 15 },
|
||||
viewAll: { color: C.muted, fontSize: 14, padding: 16 },
|
||||
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 },
|
||||
offline: { color: C.muted, fontSize: 13, paddingHorizontal: 16, paddingTop: 8 },
|
||||
menu: {
|
||||
flexDirection: "row",
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
|
||||
@@ -5,17 +5,17 @@ import type { TaskStatus } from "../../lib/types";
|
||||
const TITLES: Record<string, string> = {
|
||||
next: "Next up",
|
||||
waiting: "Waiting for",
|
||||
scheduled: "Scheduled",
|
||||
someday: "Someday",
|
||||
done: "Done",
|
||||
trashed: "Trash",
|
||||
};
|
||||
|
||||
const HINTS: Record<string, string> = {
|
||||
next: "Nothing next. Tap + on the home screen.",
|
||||
waiting: "Not waiting on anyone.",
|
||||
scheduled: "Nothing scheduled.",
|
||||
someday: "No someday/maybe items.",
|
||||
done: "Nothing done yet.",
|
||||
trashed: "Trash is empty.",
|
||||
};
|
||||
|
||||
export default function ListByStatus() {
|
||||
|
||||
171
app/src/app/review.tsx
Normal file
171
app/src/app/review.tsx
Normal 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" },
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as Application from "expo-application";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ScrollView, Text } from "react-native";
|
||||
@@ -23,7 +24,7 @@ export default function Settings() {
|
||||
const save = async () => {
|
||||
await setConfig(url, token);
|
||||
try {
|
||||
await api.tasks.list({ status: "inbox" });
|
||||
await api.tasks.list({ status: "next" });
|
||||
void sync();
|
||||
router.back();
|
||||
} catch (e) {
|
||||
@@ -44,6 +45,10 @@ export default function Settings() {
|
||||
/>
|
||||
<Button title="Save & test" onPress={save} />
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
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 EstimateChips from "../../components/EstimateChips";
|
||||
import PriorityChips from "../../components/PriorityChips";
|
||||
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 { 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"];
|
||||
|
||||
@@ -35,6 +38,7 @@ export default function TaskDetail() {
|
||||
defer_date: draft.defer_date,
|
||||
priority: draft.priority,
|
||||
estimate_min: draft.estimate_min,
|
||||
repeat: draft.repeat,
|
||||
});
|
||||
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 projectOptions = ["none", ...projects.map((p) => p.id)];
|
||||
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 (
|
||||
<ScrollView style={{ flex: 1, backgroundColor: C.bg }} contentContainerStyle={{ padding: 16 }}>
|
||||
<Field label="Title" value={draft.title} onChangeText={(v) => set({ title: v })} />
|
||||
<PriorityChips value={draft.priority} onChange={(priority) => set({ priority })} />
|
||||
<Field
|
||||
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"
|
||||
/>
|
||||
<EstimateChips value={draft.estimate_min} onChange={(estimate_min) => set({ estimate_min })} />
|
||||
<Chips label="List" options={STATUSES} value={draft.status} onChange={(status) => set({ status })} />
|
||||
<Chips
|
||||
label="Project"
|
||||
@@ -69,6 +85,22 @@ export default function TaskDetail() {
|
||||
placeholder="@home, @computer, @errands…"
|
||||
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" && (
|
||||
<Field
|
||||
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 />
|
||||
<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 />
|
||||
{!!draft.defer_date && (
|
||||
<Text style={s.deferHint}>Hidden from lists until then — it resurfaces on the day.</Text>
|
||||
)}
|
||||
<Field
|
||||
label="Notes"
|
||||
value={draft.notes}
|
||||
@@ -98,3 +140,20 @@ export default function TaskDetail() {
|
||||
</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 },
|
||||
});
|
||||
|
||||
56
app/src/components/EstimateChips.tsx
Normal file
56
app/src/components/EstimateChips.tsx
Normal 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 },
|
||||
});
|
||||
@@ -1,13 +1,20 @@
|
||||
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 { 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 { useStore } from "../lib/useStore";
|
||||
import type { TaskStatus } from "../lib/types";
|
||||
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({
|
||||
status,
|
||||
emptyHint,
|
||||
@@ -17,7 +24,18 @@ export default function TaskListScreen({
|
||||
}) {
|
||||
const store = useStore();
|
||||
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 (
|
||||
<View style={s.screen}>
|
||||
@@ -27,7 +45,7 @@ export default function TaskListScreen({
|
||||
activationDistance={12}
|
||||
onDragBegin={() => haptic.pickup()}
|
||||
onDragEnd={({ data, from, to }) => {
|
||||
if (from !== to) {
|
||||
if (!archive && from !== to) {
|
||||
haptic.success();
|
||||
reorderTask(data[to].id, status, to);
|
||||
}
|
||||
@@ -35,7 +53,7 @@ export default function TaskListScreen({
|
||||
renderItem={({ item, drag, isActive }) => (
|
||||
<ScaleDecorator activeScale={1.03}>
|
||||
<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>
|
||||
</ScaleDecorator>
|
||||
)}
|
||||
@@ -51,6 +69,15 @@ export default function TaskListScreen({
|
||||
/>
|
||||
}
|
||||
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 }}
|
||||
/>
|
||||
</View>
|
||||
@@ -61,4 +88,5 @@ const s = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: C.bg },
|
||||
activeRow: { backgroundColor: C.surface2, borderRadius: 12 },
|
||||
empty: { color: C.muted, textAlign: "center", marginTop: 48, fontSize: 15 },
|
||||
deferredFooter: { color: C.muted, fontSize: 13, textAlign: "center", paddingVertical: 14 },
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import Animated, { FadeIn } from "react-native-reanimated";
|
||||
import { dueMeta, friendlyDate, isDeferred } from "../lib/dates";
|
||||
import { haptic } from "../lib/haptics";
|
||||
import { formatEstimate, PRIORITY_COLOR } from "../lib/priority";
|
||||
import { updateTask } from "../lib/store";
|
||||
@@ -21,15 +22,21 @@ export default function TaskRow({
|
||||
onLongPress?: () => void;
|
||||
}) {
|
||||
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,
|
||||
task.repeat && "↻",
|
||||
task.context,
|
||||
task.waiting_for && `→ ${task.waiting_for}`,
|
||||
formatEstimate(task.estimate_min),
|
||||
task.due_date && `due ${task.due_date}`,
|
||||
isDeferred(task.defer_date) && `⏸ until ${friendlyDate(task.defer_date!)}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
const meta = base || due;
|
||||
|
||||
const toggle = () => {
|
||||
const done = task.status !== "done";
|
||||
@@ -52,7 +59,13 @@ export default function TaskRow({
|
||||
<Text style={s.title} numberOfLines={2}>
|
||||
{task.title}
|
||||
</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>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
|
||||
51
app/src/lib/dates.ts
Normal file
51
app/src/lib/dates.ts
Normal 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" };
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export const PRIORITY_LABEL: Record<Task["priority"], string> = {
|
||||
4: "Low",
|
||||
};
|
||||
|
||||
export const PRIORITY_COLOR: Record<Task["priority"], string> = {
|
||||
export const PRIORITY_COLOR: Record<Task["priority"], `#${string}`> = {
|
||||
1: "#E05B5B",
|
||||
2: "#E0A93E",
|
||||
3: "#5B8DEF",
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
// 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,
|
||||
// project "Sydney" (matched by name prefix), estimate 30 min.
|
||||
import type { Project, Task } from "./types";
|
||||
// project "Sydney" (matched by name prefix), estimate 30 min, repeats monthly.
|
||||
//
|
||||
// The add form parses the title field live and mirrors every recognized token
|
||||
// into a visible form control, so a false positive (e.g. "fri" in a name) is
|
||||
// seen and fixable, never silent.
|
||||
import type { Project, Repeat, Task } from "./types";
|
||||
|
||||
export interface ParsedQuickAdd {
|
||||
title: string;
|
||||
@@ -11,9 +15,17 @@ export interface ParsedQuickAdd {
|
||||
context?: string;
|
||||
project_id?: string;
|
||||
estimate_min?: number;
|
||||
repeat?: Repeat;
|
||||
}
|
||||
|
||||
const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
|
||||
const REPEAT_WORDS: Record<string, Repeat> = {
|
||||
daily: "daily",
|
||||
weekdays: "weekdays",
|
||||
weekly: "weekly",
|
||||
monthly: "monthly",
|
||||
yearly: "yearly",
|
||||
};
|
||||
|
||||
function iso(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
@@ -26,7 +38,8 @@ function parseDateWord(word: string): string | undefined {
|
||||
const w = word.toLowerCase();
|
||||
const today = new Date();
|
||||
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);
|
||||
d.setDate(d.getDate() + 1);
|
||||
return iso(d);
|
||||
@@ -77,6 +90,11 @@ export function parseQuickAdd(input: string, projects: Project[]): ParsedQuickAd
|
||||
out.estimate_min = est;
|
||||
continue;
|
||||
}
|
||||
const rep = REPEAT_WORDS[token.toLowerCase()];
|
||||
if (rep && out.repeat === undefined) {
|
||||
out.repeat = rep;
|
||||
continue;
|
||||
}
|
||||
if (out.due_date === undefined) {
|
||||
const due = parseDateWord(token);
|
||||
if (due) {
|
||||
|
||||
65
app/src/lib/repeat.ts
Normal file
65
app/src/lib/repeat.ts
Normal 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
29
app/src/lib/review.ts
Normal 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];
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import * as Crypto from "expo-crypto";
|
||||
import { api } from "./api";
|
||||
import { getConfig } from "./config";
|
||||
import { daysFromToday, isDeferred, todayIso } from "./dates";
|
||||
import { nextOccurrence, shiftDays } from "./repeat";
|
||||
import type { Project, Task } from "./types";
|
||||
|
||||
export interface State {
|
||||
@@ -18,6 +20,8 @@ export interface State {
|
||||
hydrated: boolean;
|
||||
syncing: boolean;
|
||||
pending: number;
|
||||
/** True when the last sync failed for network reasons — expected offline. */
|
||||
offline: boolean;
|
||||
syncError: string | null;
|
||||
}
|
||||
|
||||
@@ -34,6 +38,7 @@ let state: State = {
|
||||
hydrated: false,
|
||||
syncing: false,
|
||||
pending: 0,
|
||||
offline: false,
|
||||
syncError: null,
|
||||
};
|
||||
let queue: Op[] = [];
|
||||
@@ -78,7 +83,7 @@ function now() {
|
||||
|
||||
// Fields the API accepts — everything else (timestamps, completed_at) is local
|
||||
// bookkeeping until the server's authoritative row arrives via pull().
|
||||
const TASK_FIELDS = ["title", "notes", "status", "project_id", "context", "waiting_for", "due_date", "defer_date", "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;
|
||||
|
||||
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 {
|
||||
// "next" — this app has no inbox; a task without an explicit box is actionable.
|
||||
const status = data.status ?? "next";
|
||||
const task: Task = {
|
||||
id: Crypto.randomUUID(),
|
||||
title: data.title,
|
||||
notes: data.notes ?? "",
|
||||
status: data.status ?? "inbox",
|
||||
status,
|
||||
project_id: data.project_id ?? null,
|
||||
context: data.context ?? null,
|
||||
waiting_for: data.waiting_for ?? null,
|
||||
@@ -113,11 +120,12 @@ export function createTask(data: Partial<Task> & { title: string }): Task {
|
||||
defer_date: data.defer_date ?? null,
|
||||
priority: data.priority ?? 4,
|
||||
estimate_min: data.estimate_min ?? null,
|
||||
repeat: data.repeat ?? null,
|
||||
completed_at: null,
|
||||
// New tasks land at the top of their list.
|
||||
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(),
|
||||
updated_at: now(),
|
||||
};
|
||||
@@ -128,6 +136,7 @@ export function createTask(data: Partial<Task> & { title: string }): Task {
|
||||
}
|
||||
|
||||
export function updateTask(id: string, patch: Partial<Task>) {
|
||||
const before = state.tasks.find((t) => t.id === id);
|
||||
setState({
|
||||
tasks: state.tasks.map((t) => {
|
||||
if (t.id !== id) return t;
|
||||
@@ -139,6 +148,39 @@ export function updateTask(id: string, patch: Partial<Task>) {
|
||||
});
|
||||
persistData();
|
||||
enqueue({ kind: "task.update", id, data: pickFields(patch, TASK_FIELDS) });
|
||||
// Completing a repeating task spawns the next occurrence. Un-checking the
|
||||
// done row later leaves the spawn in place — it's one tap to trash, versus
|
||||
// silently deleting a row the user may have edited.
|
||||
if (before && before.repeat && before.status !== "done" && patch.status === "done") {
|
||||
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) {
|
||||
@@ -193,9 +235,13 @@ export async function sync() {
|
||||
setState({});
|
||||
}
|
||||
await pull();
|
||||
setState({ syncError: null });
|
||||
setState({ syncError: null, offline: false });
|
||||
} catch (e) {
|
||||
setState({ syncError: e instanceof Error ? e.message : String(e) });
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
// fetch() network failures (airplane mode, no wifi, DNS) are the expected
|
||||
// offline case, not an error — the queue simply waits for connectivity.
|
||||
const isNetwork = e instanceof TypeError || /network request failed|unable to resolve|fetch failed|timeout/i.test(msg);
|
||||
setState(isNetwork ? { offline: true, syncError: null } : { syncError: msg });
|
||||
} finally {
|
||||
syncRunning = false;
|
||||
setState({ syncing: false });
|
||||
@@ -222,7 +268,14 @@ async function push(op: Op) {
|
||||
}
|
||||
|
||||
async function pull() {
|
||||
const [tasks, projects] = await Promise.all([api.tasks.list(), api.projects.list()]);
|
||||
// The default list excludes trashed; fetch trash separately so the Trash
|
||||
// screen (and restore) survive a sync.
|
||||
const [open, trashed, projects] = await Promise.all([
|
||||
api.tasks.list(),
|
||||
api.tasks.list({ status: "trashed" }),
|
||||
api.projects.list(),
|
||||
]);
|
||||
const tasks = [...open, ...trashed];
|
||||
// Rows with ops still queued keep their local version; the next sync pass
|
||||
// pushes those ops and then this merge converges to the server state.
|
||||
const dirty = new Set(queue.map((o) => o.id));
|
||||
@@ -261,6 +314,15 @@ export function tasksByStatus(s: State, status: Task["status"]): Task[] {
|
||||
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). */
|
||||
export function reorderTask(id: string, status: Task["status"], index: number) {
|
||||
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 {
|
||||
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)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@ export const C = {
|
||||
accent: "#5B8DEF",
|
||||
done: "#4CAF7D",
|
||||
danger: "#E05B5B",
|
||||
};
|
||||
warn: "#E0A93E",
|
||||
} as const;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type TaskStatus = "inbox" | "next" | "waiting" | "scheduled" | "someday" | "done" | "trashed";
|
||||
export type ProjectStatus = "active" | "someday" | "completed" | "dropped";
|
||||
export type Repeat = "daily" | "weekdays" | "weekly" | "monthly" | "yearly";
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
@@ -13,6 +14,7 @@ export interface Task {
|
||||
defer_date: string | null;
|
||||
priority: 1 | 2 | 3 | 4;
|
||||
estimate_min: number | null;
|
||||
repeat: Repeat | null;
|
||||
completed_at: string | null;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
|
||||
98
app/src/widget/NextUpWidget.tsx
Normal file
98
app/src/widget/NextUpWidget.tsx
Normal 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
18
app/src/widget/refresh.ts
Normal 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
14
app/src/widget/render.tsx
Normal 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} />;
|
||||
}
|
||||
19
app/src/widget/widget-task-handler.tsx
Normal file
19
app/src/widget/widget-task-handler.tsx
Normal 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());
|
||||
}
|
||||
@@ -5,12 +5,17 @@ const sql = new SQL(process.env.DATABASE_URL!);
|
||||
const API_TOKEN = process.env.API_TOKEN!;
|
||||
if (!API_TOKEN) throw new Error("API_TOKEN not set");
|
||||
|
||||
// schema.sql is idempotent (create/alter-if-not-exists) — apply it at boot so
|
||||
// a deployed server is never newer than the database it queries.
|
||||
await sql.unsafe(await Bun.file(new URL("schema.sql", import.meta.url)).text());
|
||||
|
||||
const TASK_COLS = `id, title, notes, status, project_id, context, waiting_for,
|
||||
due_date::text as due_date, defer_date::text as defer_date,
|
||||
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 TASK_STATUSES = ["inbox", "next", "waiting", "scheduled", "someday", "done", "trashed"];
|
||||
const REPEATS = ["daily", "weekdays", "weekly", "monthly", "yearly"];
|
||||
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}$/;
|
||||
|
||||
@@ -28,12 +33,15 @@ function err(message: string, status: number) {
|
||||
}
|
||||
|
||||
// Fields a client may set. completed_at is derived from status server-side.
|
||||
const TASK_FIELDS = ["title", "notes", "status", "project_id", "context", "waiting_for", "due_date", "defer_date", "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"];
|
||||
|
||||
// Only these may be cleared to NULL; notes/title are NOT NULL and keep "".
|
||||
const NULLABLE = new Set(["project_id", "context", "waiting_for", "due_date", "defer_date", "estimate_min", "repeat"]);
|
||||
|
||||
function pick(body: Record<string, unknown>, fields: string[]) {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const f of fields) if (f in body) out[f] = body[f] === "" ? null : body[f];
|
||||
for (const f of fields) if (f in body) out[f] = body[f] === "" && NULLABLE.has(f) ? null : body[f];
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -70,16 +78,17 @@ async function handle(req: Request): Promise<Response> {
|
||||
const t = pick(body, TASK_FIELDS);
|
||||
if (typeof t.title !== "string" || !t.title.trim()) return err("title required", 400);
|
||||
if (t.status && !TASK_STATUSES.includes(t.status as string)) return err("bad status", 400);
|
||||
if (t.repeat && !REPEATS.includes(t.repeat as string)) return err("bad repeat", 400);
|
||||
const id = typeof body.id === "string" && UUID_RE.test(body.id) ? body.id : null;
|
||||
// Client-supplied id + on-conflict no-op make offline-queue retries idempotent.
|
||||
const rows = await sql.unsafe(
|
||||
`insert into tasks (id, title, notes, status, project_id, context, waiting_for, due_date, defer_date, priority, estimate_min, sort_order)
|
||||
values (coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
`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, $13)
|
||||
on conflict (id) do nothing
|
||||
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.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);
|
||||
const existing = await sql.unsafe(`select ${TASK_COLS} from tasks where id = $1`, [id]);
|
||||
return json(existing[0], 200);
|
||||
@@ -94,17 +103,18 @@ async function handle(req: Request): Promise<Response> {
|
||||
const id = m[1];
|
||||
const patch = method === "DELETE" ? { status: "trashed" } : pick(body, TASK_FIELDS);
|
||||
if (patch.status && !TASK_STATUSES.includes(patch.status as string)) return err("bad status", 400);
|
||||
if (patch.repeat && !REPEATS.includes(patch.repeat as string)) return err("bad repeat", 400);
|
||||
const existing = await sql.unsafe(`select ${TASK_COLS} from tasks where id = $1`, [id]);
|
||||
if (!existing.length) return err("not found", 404);
|
||||
const cur = { ...existing[0], ...patch };
|
||||
const completedAt = cur.status === "done" ? (existing[0].completed_at ?? new Date()) : null;
|
||||
const rows = await sql.unsafe(
|
||||
`update tasks set title=$2, notes=$3, status=$4, project_id=$5, context=$6, waiting_for=$7,
|
||||
due_date=$8, defer_date=$9, priority=$10, estimate_min=$11, completed_at=$12, sort_order=$13,
|
||||
updated_at=now()
|
||||
due_date=$8, defer_date=$9, priority=$10, estimate_min=$11, repeat=$12, completed_at=$13,
|
||||
sort_order=$14, updated_at=now()
|
||||
where id=$1 returning ${TASK_COLS}`,
|
||||
[id, cur.title, cur.notes, cur.status, cur.project_id, cur.context, cur.waiting_for,
|
||||
cur.due_date, cur.defer_date, 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]);
|
||||
}
|
||||
|
||||
@@ -166,6 +176,10 @@ Bun.serve({
|
||||
fetch: (req) =>
|
||||
handle(req).catch((e) => {
|
||||
console.error(e);
|
||||
// Constraint violations are bad input, not server faults — 400 so
|
||||
// offline clients drop the op instead of retrying it forever.
|
||||
const errno = (e as { errno?: string }).errno ?? "";
|
||||
if (errno.startsWith("23")) return err(`constraint violation: ${(e as Error).message}`, 400);
|
||||
return err("internal error", 500);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ create table if not exists tasks (
|
||||
defer_date date, -- hide from lists until this date
|
||||
priority int not null default 4 check (priority between 1 and 4), -- 1=urgent … 4=low
|
||||
estimate_min int check (estimate_min > 0),
|
||||
repeat text check (repeat in ('daily', 'weekdays', 'weekly', 'monthly', 'yearly')),
|
||||
completed_at timestamptz,
|
||||
sort_order double precision not null default 0,
|
||||
created_at timestamptz not null default now(),
|
||||
@@ -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_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'));
|
||||
|
||||
Reference in New Issue
Block a user