5 Commits
v1.22 ... v1.24

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

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmf3K9nCoEsXvvwkmMnuiM
2026-09-05 17:44:25 +10:00
28 changed files with 913 additions and 643 deletions

View File

@@ -8,17 +8,17 @@ jobs:
check: check:
# Fast fail: lockfile sync, types, lint — catches in ~2 min what would # Fast fail: lockfile sync, types, lint — catches in ~2 min what would
# otherwise kill the APK build twenty minutes in. # otherwise kill the APK build twenty minutes in.
runs-on: desktop runs-on: macbook
container:
image: node:22
steps: steps:
- name: Checkout - name: Checkout
run: | run: |
git init -q . git init -q .
git fetch -q --depth 1 "https://git.rehbock.xyz/${{ github.repository }}.git" "${{ github.sha }}" git fetch -q --depth 1 "https://git.rehbock.xyz/${{ github.repository }}.git" "${{ github.sha }}"
git checkout -q FETCH_HEAD git checkout -q -f FETCH_HEAD
git clean -qfd
- name: Lockfile + typecheck + lint - name: Lockfile + typecheck + lint
run: | run: |
export PATH="$HOME/.hermes/node/bin:$PATH"
cd app cd app
npm ci --no-audit --no-fund npm ci --no-audit --no-fund
npx tsc --noEmit npx tsc --noEmit
@@ -35,49 +35,38 @@ jobs:
build: build:
needs: check needs: check
# RN/NDK builds starve the 4-core VPS — run on the desktop runner # Runs natively on the MacBook Air (gitea-runner in host mode, label
# (marcusDesktop). Jobs queue while the desktop is off and start when it # "macbook"): Java 21 from Homebrew, the Android SDK from Android Studio,
# comes online; switch back to ubuntu-latest to build on the VPS. # node 22 from ~/.hermes. No container, no SDK download.
runs-on: desktop runs-on: macbook
container: env:
image: eclipse-temurin:21-jdk ANDROID_HOME: /Users/marcus/Library/Android/sdk
JAVA_HOME: /opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
steps: steps:
# actions/checkout needs node, which this container lacks — clone directly
- name: Checkout - name: Checkout
run: | run: |
apt-get update -qq && apt-get install -y -qq git unzip curl >/dev/null
git init -q . git init -q .
git fetch -q --depth 1 "https://git.rehbock.xyz/${{ github.repository }}.git" "${{ github.sha }}" git fetch -q --depth 1 "https://git.rehbock.xyz/${{ github.repository }}.git" "${{ github.sha }}"
git checkout -q FETCH_HEAD git checkout -q -f FETCH_HEAD
git clean -qfd
- name: Install Node.js - name: Toolchain
run: | run: |
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - >/dev/null 2>&1 export PATH="$HOME/.hermes/node/bin:$PATH"
apt-get install -y -qq nodejs >/dev/null
node --version node --version
"$JAVA_HOME/bin/java" -version
- name: Install Android SDK test -d "$ANDROID_HOME/platforms/android-36"
# $HOME/android-sdk is a persistent runner volume — skip when warm. test -d "$ANDROID_HOME/build-tools/36.0.0"
run: |
if [ ! -d "$HOME/android-sdk/platforms/android-36" ]; then
mkdir -p "$HOME/android-sdk/cmdline-tools"
curl -sLo /tmp/ct.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip -q /tmp/ct.zip -d /tmp
mv /tmp/cmdline-tools "$HOME/android-sdk/cmdline-tools/latest"
yes | "$HOME/android-sdk/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$HOME/android-sdk" --licenses >/dev/null 2>&1 || true
"$HOME/android-sdk/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$HOME/android-sdk" \
"platform-tools" "platforms;android-36" "build-tools;36.0.0" >/dev/null
else
echo "SDK cache warm — skipping install"
fi
- name: Install app dependencies - name: Install app dependencies
run: cd app && npm ci --no-audit --no-fund run: |
export PATH="$HOME/.hermes/node/bin:$PATH"
cd app && npm ci --no-audit --no-fund
- name: Decode signing keystore - name: Decode signing keystore
env: env:
KEYSTORE_B64: ${{ secrets.KEYSTORE_B64 }} KEYSTORE_B64: ${{ secrets.KEYSTORE_B64 }}
run: echo "$KEYSTORE_B64" | base64 -d > /tmp/release.jks run: echo "$KEYSTORE_B64" | base64 --decode > /tmp/release.jks
- name: Build signed release APK - name: Build signed release APK
env: env:
@@ -90,7 +79,7 @@ jobs:
VERSION_CODE: ${{ github.run_number }} VERSION_CODE: ${{ github.run_number }}
VERSION_NAME: 1.${{ github.run_number }} VERSION_NAME: 1.${{ github.run_number }}
run: | run: |
export ANDROID_HOME="$HOME/android-sdk" export PATH="$HOME/.hermes/node/bin:$PATH"
cd app/android cd app/android
echo "sdk.dir=$ANDROID_HOME" > local.properties echo "sdk.dir=$ANDROID_HOME" > local.properties
./gradlew assembleRelease --no-daemon --console=plain ./gradlew assembleRelease --no-daemon --console=plain

View File

@@ -9,11 +9,11 @@ Minimal Getting Things Done app. One Expo/React Native codebase for iPhone, Andr
## Layout ## Layout
- `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). - `app/` — Expo app (expo-router, TypeScript). Home shows the top 3 next actions plus a launcher menu: Today / Next / Waiting / Someday / Projects / Review.
- **Personal / Work**: the toggle in the home header is a global lens — every list, Today, Projects and Review show only the active area, and new tasks/projects are born into it. Tasks and projects carry an `area` column (`personal` default); the task form's Area chip re-files a task across areas. The choice persists locally (`area_v1`). There is no inbox — every task is born fully scoped (title, priority, due date). Tap a task to clarify (list, project, context, waiting-for, due/defer dates, repeat).
- **Quick add**: the title field parses tokens live — `Pay rent tmr p1 @home #Sydney ~30m monthly` — and mirrors them into the form. - **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). - **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. - **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

1
app/.gitignore vendored
View File

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

View File

@@ -2,13 +2,19 @@
"expo": { "expo": {
"name": "GTD", "name": "GTD",
"slug": "gtd", "slug": "gtd",
"version": "1.0.0", "version": "1.24",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "gtd", "scheme": "gtd",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"ios": { "ios": {
"icon": "./assets/expo.icon" "icon": "./assets/expo.icon",
"bundleIdentifier": "xyz.rehbock.gtd",
"buildNumber": "24",
"supportsTablet": false,
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {
@@ -33,7 +39,7 @@
"widgets": [ "widgets": [
{ {
"name": "NextUp", "name": "NextUp",
"label": "GTD — Next up", "label": "GTD \u2014 Next up",
"description": "Your top three next actions", "description": "Your top three next actions",
"minWidth": "250dp", "minWidth": "250dp",
"minHeight": "110dp", "minHeight": "110dp",

View File

@@ -9,10 +9,18 @@
"buildType": "apk" "buildType": "apk"
}, },
"env": { "env": {
"VERSION_CODE": "18", "VERSION_CODE": "24",
"VERSION_NAME": "1.18" "VERSION_NAME": "1.24"
}, },
"node": "22.20.0" "node": "22.20.0",
"ios": {
"autoIncrement": false
}
}
},
"submit": {
"production": {
"ios": {}
} }
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,12 @@
import NetInfo from "@react-native-community/netinfo"; import NetInfo from "@react-native-community/netinfo";
import { Link, Stack } from "expo-router"; import { 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, Pressable } from "react-native"; import { AppState } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import { getState, hydrate, subscribe, sync } from "../lib/store"; import { getState, hydrate, subscribe, sync } from "../lib/store";
import Icon from "../components/Icon";
import { ToastHost } from "../components/Toast"; import { ToastHost } from "../components/Toast";
import { C, space } from "../lib/theme"; import { C } from "../lib/theme";
import { refreshNextUpWidget } from "../widget/refresh"; import { refreshNextUpWidget } from "../widget/refresh";
export default function RootLayout() { export default function RootLayout() {
@@ -50,29 +49,12 @@ export default function RootLayout() {
contentStyle: { backgroundColor: C.bg }, contentStyle: { backgroundColor: C.bg },
}} }}
> >
<Stack.Screen <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
name="index"
options={{
title: "GTD",
headerRight: () => (
<Link href="/settings" asChild>
<Pressable
hitSlop={12}
style={({ pressed }) => [{ paddingHorizontal: space.sm }, pressed && { opacity: 0.5 }]}
>
<Icon name="settings" size={22} color={C.textSecondary} />
</Pressable>
</Link>
),
}}
/>
<Stack.Screen name="list/[status]" /> <Stack.Screen name="list/[status]" />
<Stack.Screen name="today" options={{ title: "Today" }} />
<Stack.Screen name="add" options={{ title: "New task", presentation: "modal" }} /> <Stack.Screen name="add" options={{ title: "New task", presentation: "modal" }} />
<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>
<ToastHost /> <ToastHost />

View File

@@ -1,227 +0,0 @@
import { useFocusEffect, useRouter } from "expo-router";
import { Platform, Pressable, StyleSheet, Text, View } from "react-native";
import { NestableDraggableFlatList, NestableScrollContainer, ScaleDecorator } from "react-native-draggable-flatlist";
import WebSortableList from "../components/WebSortableList";
import Icon, { type IconName } from "../components/Icon";
import TaskRow, { TaskSeparator } from "../components/TaskRow";
import { Card, cardEdges, Empty, Screen, SectionHeader, Title } from "../components/ui";
import { haptic } from "../lib/haptics";
import { daysSince, useLastReview } from "../lib/review";
import { reorderTask, todayTasks, visibleTasksByStatus } from "../lib/store";
import { C, radius, shadow, space, type } from "../lib/theme";
import { useStore } from "../lib/useStore";
const MENU: { icon: IconName; label: string; href: string }[] = [
{ icon: "today", label: "Today", href: "/today" },
{ icon: "next", label: "Next", href: "/list/next" },
{ icon: "waiting", label: "Waiting", href: "/list/waiting" },
{ icon: "someday", label: "Someday", href: "/list/someday" },
{ icon: "projects", label: "Projects", href: "/projects" },
{ icon: "review", label: "Review", href: "/review" },
];
const PREVIEW = 3;
/**
* One tappable line of status — overdue, review due, sync trouble. Rendered as
* a real row with a chevron rather than coloured micro-text, so it reads as
* something you can act on.
*/
function Alert({ tone, text, onPress }: { tone: string; text: string; onPress?: () => void }) {
const body = (
<View style={s.alert}>
<View style={[s.dot, { backgroundColor: tone }]} />
<Text style={[s.alertText, { color: tone }]}>{text}</Text>
{!!onPress && <Icon name="chevron" size={16} color={tone} />}
</View>
);
if (!onPress) return body;
return (
<Pressable onPress={onPress} style={({ pressed }) => pressed && { opacity: 0.6 }}>
{body}
</Pressable>
);
}
export default function Home() {
const router = useRouter();
const store = useStore();
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);
const preview = next.slice(0, PREVIEW);
return (
<Screen>
<NestableScrollContainer contentContainerStyle={s.content} showsVerticalScrollIndicator={false}>
<Title count={next.length}>Next up</Title>
{preview.length === 0 ? (
<Empty>Nothing queued. Add the next thing on your mind.</Empty>
) : Platform.OS === "web" ? (
<WebSortableList
data={preview}
onReorder={(id, to) => reorderTask(id, "next", to)}
separator={<TaskSeparator />}
renderItem={(t, i) => (
<View style={cardEdges(i, preview.length)}>
<TaskRow task={t} emphasized={i === 0} />
</View>
)}
/>
) : (
// Draggable so the "do this now" slot is a hand-picked choice, not
// just whatever sorted first. Preview indices are next[0..N], so a
// drop reorders the real Next list.
<NestableDraggableFlatList
data={preview}
keyExtractor={(t) => t.id}
activationDistance={12}
onDragBegin={() => haptic.pickup()}
onDragEnd={({ data, from, to }) => {
if (from !== to) {
haptic.success();
reorderTask(data[to].id, "next", to);
}
}}
renderItem={({ item, drag, isActive, getIndex }) => {
const i = getIndex() ?? 0;
return (
<ScaleDecorator activeScale={1.02}>
<View style={[...cardEdges(i, preview.length), isActive && s.lifted]}>
<TaskRow task={item} emphasized={i === 0} onLongPress={drag} />
</View>
</ScaleDecorator>
);
}}
ItemSeparatorComponent={TaskSeparator}
/>
)}
{next.length > PREVIEW && (
<Pressable
onPress={() => router.push("/list/next")}
style={({ pressed }) => [s.viewAll, pressed && { opacity: 0.6 }]}
>
<Text style={s.viewAllText}>All {next.length} next actions</Text>
<Icon name="chevron" size={16} color={C.accent} />
</Pressable>
)}
{(overdue.length > 0 || reviewDue || store.offline || !!store.syncError) && (
<>
<SectionHeader>Needs attention</SectionHeader>
<Card>
{overdue.length > 0 && (
<Alert
tone={C.danger}
text={`${overdue.length} overdue`}
onPress={() => router.push("/today")}
/>
)}
{reviewDue && (
<Alert
tone={C.warn}
text={
lastReview === null
? "No weekly review yet"
: `Weekly review is ${reviewDays} days overdue`
}
onPress={() => router.push("/review")}
/>
)}
{store.offline && (
<Alert
tone={C.textSecondary}
text={
store.pending > 0
? `Offline — ${store.pending} change${store.pending === 1 ? "" : "s"} queued`
: "Offline"
}
/>
)}
{!!store.syncError && <Alert tone={C.danger} text={store.syncError} />}
</Card>
</>
)}
</NestableScrollContainer>
<View style={s.tabBar}>
{MENU.map((m) => (
<Pressable
key={m.label}
style={({ pressed }) => [s.tab, pressed && { opacity: 0.5 }]}
onPress={() => {
haptic.tap();
router.push(m.href);
}}
>
<Icon name={m.icon} size={21} color={C.textSecondary} bg={C.surface} />
<Text style={s.tabLabel}>{m.label}</Text>
</Pressable>
))}
</View>
<Pressable
style={({ pressed }) => [s.fab, pressed && { transform: [{ scale: 0.94 }] }]}
onPress={() => {
haptic.tap();
router.push("/add");
}}
>
<Icon name="plus" size={26} color={C.onAccent} />
</Pressable>
</Screen>
);
}
const s = StyleSheet.create({
content: { paddingHorizontal: space.lg, paddingBottom: 120 },
lifted: { borderRadius: radius.lg, ...shadow.fab },
viewAll: {
flexDirection: "row",
alignItems: "center",
gap: space.xs,
paddingVertical: space.md,
paddingHorizontal: space.xs,
},
viewAllText: { ...type.body, color: C.accent, fontWeight: "500" },
alert: {
flexDirection: "row",
alignItems: "center",
gap: space.md,
paddingVertical: space.md + 1,
paddingHorizontal: space.lg,
},
dot: { width: 7, height: 7, borderRadius: 999 },
alertText: { ...type.body, flex: 1, fontWeight: "500" },
tabBar: {
flexDirection: "row",
backgroundColor: C.surface,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: C.border,
paddingTop: space.md,
paddingBottom: space.xxl,
},
tab: { flex: 1, alignItems: "center", gap: 5 },
tabLabel: { fontSize: 11, color: C.textSecondary, letterSpacing: 0.1 },
fab: {
position: "absolute",
right: space.xl,
bottom: 104,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: C.accent,
alignItems: "center",
justifyContent: "center",
...shadow.fab,
},
});

View File

@@ -30,7 +30,7 @@ export default function ProjectDetail() {
if (!title) return; if (!title) return;
setDraft(""); setDraft("");
haptic.success(); haptic.success();
createTask({ title, status: "next", project_id: id }); createTask({ title, status: "next", project_id: id, area: project.area ?? "personal" });
}; };
return ( return (

View File

@@ -1,208 +0,0 @@
// 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 Icon from "../components/Icon";
import { Button, Card, Empty, SectionHeader } 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 { completeWithUndo } from "../lib/undo";
import { C, radius, space, type } from "../lib/theme";
import { useStore } from "../lib/useStore";
import type { Task } from "../lib/types";
function ReviewRow({
task,
showDefer,
last,
}: {
task: Task;
showDefer?: boolean;
/** The card already draws the bottom edge — don't double it up. */
last?: boolean;
}) {
const router = useRouter();
const act = (fn: () => void) => () => {
haptic.select();
fn();
};
return (
<View style={[s.row, last && s.rowLast]}>
<Pressable
style={s.check}
hitSlop={8}
onPress={() => completeWithUndo(task)}
>
<View style={[s.checkbox, { 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={({ pressed }) => [s.action, pressed && s.actionPressed]}
hitSlop={6}
onPress={act(() => updateTask(task.id, { status: "next" }))}
>
<Icon name="next" size={14} color={C.accent} />
</Pressable>
)}
{task.status !== "someday" && (
<Pressable
style={({ pressed }) => [s.action, pressed && s.actionPressed]}
hitSlop={6}
onPress={act(() => updateTask(task.id, { status: "someday" }))}
>
<Icon name="someday" size={14} color={C.textSecondary} bg={C.surface} />
</Pressable>
)}
<Pressable
style={({ pressed }) => [s.action, pressed && s.actionPressed]}
hitSlop={6}
onPress={act(() => trashTask(task.id))}
>
<Text style={s.dropText}>Drop</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>
<SectionHeader>
{title} ({tasks.length})
</SectionHeader>
<Text style={s.sectionHint}>{hint}</Text>
<Card>
{tasks.map((t, i) => (
<ReviewRow key={t.id} task={t} showDefer={showDefer} last={i === tasks.length - 1} />
))}
</Card>
</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={s.content}>
<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>
<SectionHeader>Stuck projects ({buckets.stuckProjects.length})</SectionHeader>
<Text style={s.sectionHint}>Active, but no next action — decide the next step or park them.</Text>
<Card>
{buckets.stuckProjects.map((p, i) => (
<Pressable
key={p.id}
style={({ pressed }) => [
s.row,
i === buckets.stuckProjects.length - 1 && s.rowLast,
pressed && { backgroundColor: C.surface2 },
]}
onPress={() => router.push(`/project/${p.id}`)}
>
<Text style={[s.title, { flex: 1 }]}>{p.name}</Text>
<Icon name="chevron" size={16} color={C.muted} />
</Pressable>
))}
</Card>
</View>
)}
{empty && <Empty>All clear. Nothing needs a decision.</Empty>}
<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({
content: { padding: space.lg, paddingBottom: 48 },
lastReview: { ...type.meta, color: C.textSecondary, marginBottom: space.xs },
sectionHint: { ...type.meta, color: C.muted, paddingHorizontal: space.xs, marginBottom: space.sm },
row: {
flexDirection: "row",
alignItems: "center",
gap: space.md,
paddingVertical: space.md,
paddingHorizontal: space.lg,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: C.separator,
backgroundColor: C.surface,
},
rowLast: { borderBottomWidth: 0 },
check: { paddingRight: 2 },
checkbox: { width: 21, height: 21, borderRadius: 7, borderWidth: 1.5 },
title: { ...type.body, color: C.text },
meta: { ...type.meta, fontSize: 12, color: C.muted, marginTop: 2 },
action: {
minWidth: 30,
height: 28,
paddingHorizontal: space.sm,
borderRadius: radius.sm,
alignItems: "center",
justifyContent: "center",
},
actionPressed: { backgroundColor: C.surface2 },
dropText: { ...type.meta, color: C.danger, fontWeight: "500" },
archiveRow: { flexDirection: "row", justifyContent: "center", gap: space.sm, marginTop: space.xs },
archiveLink: { ...type.meta, color: C.textSecondary, padding: space.sm },
archiveDot: { color: C.muted, alignSelf: "center" },
});

View File

@@ -1,8 +1,9 @@
import * as Application from "expo-application"; 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, StyleSheet, Text } from "react-native"; import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import { Button, Field } from "../components/ui"; import Icon from "../components/Icon";
import { Button, Card, Field, SectionHeader, Separator } from "../components/ui";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { DEFAULT_URL, getConfig, setConfig } from "../lib/config"; import { DEFAULT_URL, getConfig, setConfig } from "../lib/config";
import { sync } from "../lib/store"; import { sync } from "../lib/store";
@@ -45,6 +46,24 @@ export default function Settings() {
/> />
<Button title="Save & test" onPress={save} /> <Button title="Save & test" onPress={save} />
{status && <Text style={s.error}>{status}</Text>} {status && <Text style={s.error}>{status}</Text>}
{/* Done and Trash are archives, not lists you work from — they live
behind the gear rather than on the bar. */}
<SectionHeader>Archive</SectionHeader>
<Card>
{(["done", "trashed"] as const).map((st, i) => (
<View key={st}>
{i > 0 && <Separator inset={space.lg} />}
<Pressable
onPress={() => router.push(`/list/${st}`)}
style={({ pressed }) => [s.archiveRow, pressed && { backgroundColor: C.surface2 }]}
>
<Text style={s.archiveText}>{st === "done" ? "Done" : "Trash"}</Text>
<Icon name="chevron" size={16} color={C.muted} />
</Pressable>
</View>
))}
</Card>
{/* CI stamps versionName from the release tag — confirms which build Obtainium installed. */} {/* CI stamps versionName from the release tag — confirms which build Obtainium installed. */}
<Text style={s.version}>GTD v{Application.nativeApplicationVersion ?? "dev"}</Text> <Text style={s.version}>GTD v{Application.nativeApplicationVersion ?? "dev"}</Text>
</ScrollView> </ScrollView>
@@ -54,5 +73,13 @@ export default function Settings() {
const s = StyleSheet.create({ const s = StyleSheet.create({
content: { padding: space.lg }, content: { padding: space.lg },
error: { ...type.meta, color: C.danger, marginTop: space.xs }, error: { ...type.meta, color: C.danger, marginTop: space.xs },
archiveRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: space.lg,
paddingVertical: space.md + 2,
},
archiveText: { ...type.body, color: C.text },
version: { ...type.meta, color: C.muted, textAlign: "center", marginTop: space.xxl }, version: { ...type.meta, color: C.muted, textAlign: "center", marginTop: space.xxl },
}); });

View File

@@ -25,6 +25,7 @@ const toDraft = (t: Task): TaskDraft => ({
estimate_min: t.estimate_min, estimate_min: t.estimate_min,
repeat: t.repeat, repeat: t.repeat,
priority: t.priority, priority: t.priority,
area: t.area ?? "personal",
}); });
export default function TaskDetail() { export default function TaskDetail() {

View File

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

View File

@@ -13,11 +13,12 @@ export type IconName =
| "waiting" | "waiting"
| "someday" | "someday"
| "projects" | "projects"
| "review" | "grid"
| "settings" | "settings"
| "flag" | "flag"
| "repeat" | "repeat"
| "tag" | "tag"
| "area"
| "plus" | "plus"
| "close" | "close"
| "chevron"; | "chevron";
@@ -169,37 +170,18 @@ export default function Icon({ name, size = 22, color = C.textSecondary, bg = C.
</View> </View>
); );
case "review": case "grid":
// Four squares — the Lists board's own shape.
return ( return (
<View style={[box, s.center]}> <View style={[box, s.center]}>
{/* an open ring plus an arrowhead reads as "cycle" without an SVG path */} <View style={{ width: size * 0.8, height: size * 0.8, flexDirection: "row", flexWrap: "wrap", gap: size * 0.1 }}>
<View {[0, 1, 2, 3].map((i) => (
style={{ <View
width: size * 0.82, key={i}
height: size * 0.82, style={{ width: size * 0.35, height: size * 0.35, borderWidth: w, borderColor: color, borderRadius: 3 }}
borderRadius: 999, />
borderWidth: w, ))}
borderColor: color, </View>
borderTopColor: "transparent",
transform: [{ rotate: "45deg" }],
}}
/>
<View
style={{
position: "absolute",
top: size * 0.06,
right: size * 0.12,
width: 0,
height: 0,
borderLeftWidth: size * 0.16,
borderRightWidth: size * 0.16,
borderBottomWidth: size * 0.2,
borderLeftColor: "transparent",
borderRightColor: "transparent",
borderBottomColor: color,
transform: [{ rotate: "115deg" }],
}}
/>
</View> </View>
); );
@@ -281,6 +263,38 @@ export default function Icon({ name, size = 22, color = C.textSecondary, bg = C.
</View> </View>
); );
case "area":
// Briefcase: a body with a short handle on top.
return (
<View style={[box, s.center]}>
<View
style={{
position: "absolute",
top: size * 0.12,
width: size * 0.36,
height: size * 0.26,
borderWidth: w,
borderBottomWidth: 0,
borderColor: color,
borderTopLeftRadius: 3,
borderTopRightRadius: 3,
}}
/>
<View
style={{
position: "absolute",
top: size * 0.32,
width: size * 0.84,
height: size * 0.56,
borderWidth: w,
borderColor: color,
borderRadius: 4,
}}
/>
<View style={{ position: "absolute", top: size * 0.32 + size * 0.2, width: size * 0.84, height: w, backgroundColor: color }} />
</View>
);
case "close": case "close":
return ( return (
<View style={[box, s.center]}> <View style={[box, s.center]}>

View File

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

View File

@@ -15,15 +15,16 @@ import { friendlyDate } from "../lib/dates";
import { haptic } from "../lib/haptics"; import { haptic } from "../lib/haptics";
import { formatEstimate, PRIORITY_COLOR, PRIORITY_LABEL } from "../lib/priority"; import { formatEstimate, PRIORITY_COLOR, PRIORITY_LABEL } from "../lib/priority";
import { REPEAT_LABEL, REPEATS } from "../lib/repeat"; import { REPEAT_LABEL, REPEATS } from "../lib/repeat";
import { activeProjects, knownContexts } from "../lib/store"; import { getState, knownContexts } from "../lib/store";
import { C, radius, space, type } from "../lib/theme"; import { C, radius, space, type } from "../lib/theme";
import { useStore } from "../lib/useStore"; import { useStore } from "../lib/useStore";
import type { Task, TaskStatus } from "../lib/types"; import { AREA_LABEL, AREAS, type Task, type TaskStatus } from "../lib/types";
import { AREA_TONE } from "./AreaToggle";
/** Everything a task screen edits. Priority is nullable while undecided. */ /** Everything a task screen edits. Priority is nullable while undecided. */
export type TaskDraft = Pick< export type TaskDraft = Pick<
Task, Task,
"title" | "notes" | "status" | "project_id" | "context" | "waiting_for" | "due_date" | "defer_date" | "estimate_min" | "repeat" "title" | "notes" | "status" | "project_id" | "context" | "waiting_for" | "due_date" | "defer_date" | "estimate_min" | "repeat" | "area"
> & { priority: Task["priority"] | null }; > & { priority: Task["priority"] | null };
export const BOX_LABEL: Record<string, string> = { export const BOX_LABEL: Record<string, string> = {
@@ -48,6 +49,8 @@ export const emptyDraft = (): TaskDraft => ({
estimate_min: null, estimate_min: null,
repeat: null, repeat: null,
priority: null, priority: null,
// Born into whichever lens is on — that's what "add" means in work mode.
area: getState().area,
}); });
export default function TaskForm({ export default function TaskForm({
@@ -71,7 +74,12 @@ export default function TaskForm({
const store = useStore(); const store = useStore();
// Keep a project that has since been archived visible, or editing an old task // Keep a project that has since been archived visible, or editing an old task
// would silently drop it. // would silently drop it.
const projects = activeProjects(store).filter((p) => p.status === "active" || p.id === d.project_id); // Projects follow the draft's area, not the global lens, so re-filing a task
// to Work offers Work projects. An archived project stays visible while the
// task still points at it.
const projects = store.projects
.filter((p) => (p.status === "active" && (p.area ?? "personal") === d.area) || p.id === d.project_id)
.sort((a, b) => a.sort_order - b.sort_order || b.created_at.localeCompare(a.created_at));
const contexts = knownContexts(store); const contexts = knownContexts(store);
const projectName = projects.find((p) => p.id === d.project_id)?.name ?? null; const projectName = projects.find((p) => p.id === d.project_id)?.name ?? null;
@@ -185,6 +193,32 @@ export default function TaskForm({
}, },
]; ];
// Area reads as a placeholder while it matches the lens you're in — only a
// deliberate cross-filing (a work task captured from personal) earns colour.
attrs.push({
key: "area",
icon: "area",
placeholder: AREA_LABEL[store.area],
value: d.area === store.area ? null : AREA_LABEL[d.area],
tone: AREA_TONE[d.area],
panel: (
<View>
<Chips
options={AREAS}
labels={AREA_LABEL}
value={d.area}
onChange={(area) => {
if (area === d.area) return;
haptic.select();
// A project belongs to one area; drop it when the task moves.
onChange({ area, project_id: null });
}}
/>
<Text style={s.panelEmpty}>Only shows up when that lens is on.</Text>
</View>
),
});
if (showDefer) { if (showDefer) {
attrs.push({ attrs.push({
key: "defer", key: "defer",

View File

@@ -5,7 +5,7 @@
// move/up listeners, and on release the target slot is computed from measured // move/up listeners, and on release the target slot is computed from measured
// row heights and handed to onReorder (the same store mutation native uses). // row heights and handed to onReorder (the same store mutation native uses).
import { useState, type ReactNode } from "react"; import { useState, type ReactNode } from "react";
import { StyleSheet, Text, View, type GestureResponderEvent } from "react-native"; import { StyleSheet, Text, View, type GestureResponderEvent, type ViewStyle } from "react-native";
import { C, space } from "../lib/theme"; import { C, space } from "../lib/theme";
export default function WebSortableList<T extends { id: string }>({ export default function WebSortableList<T extends { id: string }>({
@@ -77,7 +77,7 @@ export default function WebSortableList<T extends { id: string }>({
> >
{renderItem(item, i)} {renderItem(item, i)}
{!disabled && ( {!disabled && (
<View onPointerDown={(e) => startDrag(i, e as unknown as GestureResponderEvent)} style={s.handle}> <View onPointerDown={(e) => startDrag(i, e as unknown as GestureResponderEvent)} style={handleStyle}>
<Text style={s.handleGlyph}>⠿</Text> <Text style={s.handleGlyph}>⠿</Text>
</View> </View>
)} )}
@@ -106,10 +106,10 @@ const s = StyleSheet.create({
width: 44, width: 44,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
// @ts-expect-error web-only style
cursor: "grab",
touchAction: "none",
userSelect: "none",
}, },
handleGlyph: { color: C.muted, fontSize: 15, paddingHorizontal: space.sm }, handleGlyph: { color: C.muted, fontSize: 15, paddingHorizontal: space.sm },
}); });
// Pointer affordances react-native-web honours but RN's ViewStyle doesn't
// type ("grab" is not a CursorValue) — cast once, here, instead of per use.
const handleStyle = { ...s.handle, cursor: "grab", touchAction: "none", userSelect: "none" } as unknown as ViewStyle;

View File

@@ -5,7 +5,7 @@ import { C, radius, shadow, space, type } from "../lib/theme";
* Screen background + consistent gutter. Every screen sits on the grouped * Screen background + consistent gutter. Every screen sits on the grouped
* background so white cards read as raised paper. * background so white cards read as raised paper.
*/ */
export function Screen({ children, style }: { children: React.ReactNode; style?: ViewStyle }) { export function Screen({ children, style }: { children: React.ReactNode; style?: StyleProp<ViewStyle> }) {
return <View style={[u.screen, style]}>{children}</View>; return <View style={[u.screen, style]}>{children}</View>;
} }
@@ -32,7 +32,7 @@ export function SectionHeader({ children, tone }: { children: React.ReactNode; t
* Rounded white group. Rows inside are separated by inset hairlines rather * Rounded white group. Rows inside are separated by inset hairlines rather
* than full-bleed dividers, so the card keeps a clean silhouette. * than full-bleed dividers, so the card keeps a clean silhouette.
*/ */
export function Card({ children, style }: { children: React.ReactNode; style?: ViewStyle }) { export function Card({ children, style }: { children: React.ReactNode; style?: StyleProp<ViewStyle> }) {
return <View style={[u.card, style]}>{children}</View>; return <View style={[u.card, style]}>{children}</View>;
} }

View File

@@ -1,29 +0,0 @@
// 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

@@ -21,6 +21,7 @@ const store = await import("./store");
beforeEach(async () => { beforeEach(async () => {
storage.clear(); storage.clear();
await store.hydrate(); await store.hydrate();
store.setArea("personal");
}); });
const find = (id: string) => store.getState().tasks.find((t) => t.id === id)!; const find = (id: string) => store.getState().tasks.find((t) => t.id === id)!;
@@ -61,3 +62,65 @@ describe("completeTask", () => {
expect(store.completeTask("nope")).toBeNull(); expect(store.completeTask("nope")).toBeNull();
}); });
}); });
describe("area lens", () => {
test("new rows are born into the active area and selectors filter by it", () => {
const home = store.createTask({ title: "home" });
store.setArea("work");
const office = store.createTask({ title: "office" });
const proj = store.createProject({ name: "Q4" });
expect(home.area).toBe("personal");
expect(office.area).toBe("work");
expect(proj.area).toBe("work");
// The store is module state shared across tests, so assert on membership
// and on every visible row's area rather than on exact lists.
const ids = (s: ReturnType<typeof store.getState>) => store.tasksByStatus(s, "next").map((t) => t.id);
let s = store.getState();
expect(ids(s)).toContain(office.id);
expect(ids(s)).not.toContain(home.id);
expect(store.tasksByStatus(s, "next").every((t) => t.area === "work")).toBe(true);
expect(store.activeProjects(s).map((p) => p.id)).toEqual([proj.id]);
store.setArea("personal");
s = store.getState();
expect(ids(s)).toContain(home.id);
expect(ids(s)).not.toContain(office.id);
expect(store.activeProjects(s).some((p) => p.id === proj.id)).toBe(false);
});
test("area persists across hydrate", async () => {
store.setArea("work");
expect(storage.get("area_v1")).toBe("work");
});
test("rows cached without an area count as personal", () => {
// Simulate a pre-migration cache row.
const legacy = store.createTask({ title: "old" });
delete (legacy as { area?: string }).area;
store.setArea("work");
expect(store.todayTasks(store.getState()).overdue).toEqual([]);
store.setArea("personal");
expect(store.tasksByStatus(store.getState(), "next").some((t) => t.id === legacy.id)).toBe(true);
});
test("today and contexts respect the lens", () => {
store.createTask({ title: "p", due_date: "2000-01-01", context: "@home" });
store.setArea("work");
store.createTask({ title: "w", due_date: "2000-01-01", context: "@office" });
const s = store.getState();
expect(store.todayTasks(s).overdue.every((t) => t.area === "work")).toBe(true);
expect(store.todayTasks(s).overdue.map((t) => t.title)).toContain("w");
expect(store.todayTasks(s).overdue.map((t) => t.title)).not.toContain("p");
expect(store.knownContexts(s)).toEqual(["@office"]);
});
test("completing a repeating work task spawns into work", () => {
store.setArea("work");
const t = store.createTask({ title: "standup", repeat: "daily", due_date: "2000-01-01" });
store.setArea("personal");
store.completeTask(t.id);
const spawned = store.getState().tasks.find((x) => x.title === "standup" && x.status === "next")!;
expect(spawned.area).toBe("work");
});
});

View File

@@ -12,11 +12,17 @@ import { api } from "./api";
import { getConfig } from "./config"; import { getConfig } from "./config";
import { daysFromToday, isDeferred, todayIso } from "./dates"; import { daysFromToday, isDeferred, todayIso } from "./dates";
import { nextOccurrence, shiftDays } from "./repeat"; import { nextOccurrence, shiftDays } from "./repeat";
import type { Project, Task } from "./types"; import type { Area, Project, Task } from "./types";
export interface State { export interface State {
tasks: Task[]; tasks: Task[];
projects: Project[]; projects: Project[];
/**
* Which half of life is on screen. Every selector filters by it, so the
* toggle acts as a global lens: work mode shows no personal rows and vice
* versa. New tasks and projects are born into the active area.
*/
area: Area;
hydrated: boolean; hydrated: boolean;
syncing: boolean; syncing: boolean;
pending: number; pending: number;
@@ -31,10 +37,12 @@ type Op =
const DATA_KEY = "store_v1"; const DATA_KEY = "store_v1";
const QUEUE_KEY = "queue_v1"; const QUEUE_KEY = "queue_v1";
const AREA_KEY = "area_v1";
let state: State = { let state: State = {
tasks: [], tasks: [],
projects: [], projects: [],
area: "personal",
hydrated: false, hydrated: false,
syncing: false, syncing: false,
pending: 0, pending: 0,
@@ -68,13 +76,39 @@ function persistQueue() {
export async function hydrate() { export async function hydrate() {
if (state.hydrated) return; if (state.hydrated) return;
const [data, q] = await Promise.all([AsyncStorage.getItem(DATA_KEY), AsyncStorage.getItem(QUEUE_KEY)]); const [data, q, area] = await Promise.all([
AsyncStorage.getItem(DATA_KEY),
AsyncStorage.getItem(QUEUE_KEY),
AsyncStorage.getItem(AREA_KEY),
]);
if (q) queue = JSON.parse(q); if (q) queue = JSON.parse(q);
const parsed = data ? JSON.parse(data) : { tasks: [], projects: [] }; const parsed = data ? JSON.parse(data) : { tasks: [], projects: [] };
setState({ tasks: parsed.tasks, projects: parsed.projects, hydrated: true }); setState({
tasks: parsed.tasks,
projects: parsed.projects,
area: area === "work" ? "work" : "personal",
hydrated: true,
});
void sync(); void sync();
} }
/** Flip the personal/work lens. Persists so the app reopens where you left it. */
export function setArea(area: Area) {
if (area === state.area) return;
setState({ area });
void AsyncStorage.setItem(AREA_KEY, area);
}
// Rows cached before the area column existed have no `area`; they are
// personal, matching the server's column default.
function areaOf(row: { area?: Area }): Area {
return row.area ?? "personal";
}
function inArea<T extends { area?: Area }>(s: State, rows: T[]): T[] {
return rows.filter((r) => areaOf(r) === s.area);
}
// ---- mutations (instant; network happens later) ---- // ---- mutations (instant; network happens later) ----
function now() { function now() {
@@ -83,8 +117,8 @@ 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", "repeat", "sort_order"] as const; const TASK_FIELDS = ["title", "notes", "status", "project_id", "context", "waiting_for", "due_date", "defer_date", "priority", "estimate_min", "repeat", "sort_order", "area"] as const;
const PROJECT_FIELDS = ["name", "status", "notes", "sort_order"] as const; const PROJECT_FIELDS = ["name", "status", "notes", "sort_order", "area"] as const;
function pickFields<T>(data: Partial<T>, fields: readonly (keyof T & string)[]): Partial<T> { function pickFields<T>(data: Partial<T>, fields: readonly (keyof T & string)[]): Partial<T> {
const out: Partial<T> = {}; const out: Partial<T> = {};
@@ -122,6 +156,7 @@ export function createTask(data: Partial<Task> & { title: string }): Task {
estimate_min: data.estimate_min ?? null, estimate_min: data.estimate_min ?? null,
repeat: data.repeat ?? null, repeat: data.repeat ?? null,
completed_at: null, completed_at: null,
area: data.area ?? state.area,
// 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 ??
@@ -202,6 +237,7 @@ function spawnNextOccurrence(t: Task): Task {
priority: t.priority, priority: t.priority,
estimate_min: t.estimate_min, estimate_min: t.estimate_min,
repeat: t.repeat, repeat: t.repeat,
area: areaOf(t),
}); });
} }
@@ -216,6 +252,7 @@ export function createProject(data: Partial<Project> & { name: string }): Projec
status: data.status ?? "active", status: data.status ?? "active",
notes: data.notes ?? "", notes: data.notes ?? "",
sort_order: data.sort_order ?? 0, sort_order: data.sort_order ?? 0,
area: data.area ?? state.area,
created_at: now(), created_at: now(),
updated_at: now(), updated_at: now(),
}; };
@@ -333,7 +370,7 @@ function byManualOrder(a: Task, b: Task): number {
} }
export function tasksByStatus(s: State, status: Task["status"]): Task[] { export function tasksByStatus(s: State, status: Task["status"]): Task[] {
return s.tasks.filter((t) => t.status === status).sort(byManualOrder); return inArea(s, s.tasks).filter((t) => t.status === status).sort(byManualOrder);
} }
/** /**
@@ -362,7 +399,7 @@ export function reorderTask(id: string, status: Task["status"], index: number) {
export function todayTasks(s: State): { overdue: Task[]; today: Task[] } { export function todayTasks(s: State): { overdue: Task[]; today: Task[] } {
const now = new Date(); const now = new Date();
const todayIso = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; const todayIso = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
const open = s.tasks.filter((t) => t.status !== "done" && t.status !== "trashed"); const open = inArea(s, s.tasks).filter((t) => t.status !== "done" && t.status !== "trashed");
return { return {
overdue: open.filter((t) => t.due_date && t.due_date < todayIso).sort(byPriorityDue), overdue: open.filter((t) => t.due_date && t.due_date < todayIso).sort(byPriorityDue),
today: open today: open
@@ -372,7 +409,7 @@ export function todayTasks(s: State): { overdue: Task[]; today: Task[] } {
} }
export function activeProjects(s: State): Project[] { export function activeProjects(s: State): Project[] {
return s.projects return inArea(s, s.projects)
.filter((p) => p.status !== "dropped") .filter((p) => p.status !== "dropped")
.sort((a, b) => a.sort_order - b.sort_order || b.created_at.localeCompare(a.created_at)); .sort((a, b) => a.sort_order - b.sort_order || b.created_at.localeCompare(a.created_at));
} }
@@ -384,48 +421,8 @@ export function openTaskCount(s: State, projectId: string): number {
/** Distinct contexts on open tasks — suggestion chips beat retyping "@homr". */ /** Distinct contexts on open tasks — suggestion chips beat retyping "@homr". */
export function knownContexts(s: State): string[] { export function knownContexts(s: State): string[] {
const set = new Set<string>(); const set = new Set<string>();
for (const t of s.tasks) { for (const t of inArea(s, s.tasks)) {
if (t.context && t.status !== "done" && t.status !== "trashed") set.add(t.context); if (t.context && t.status !== "done" && t.status !== "trashed") set.add(t.context);
} }
return [...set].sort(); 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

@@ -27,6 +27,8 @@ const light = {
accent: "#2F6FED", accent: "#2F6FED",
/** Tinted accent wash for selected chips and active states. */ /** Tinted accent wash for selected chips and active states. */
accentSoft: "#E8F0FE", accentSoft: "#E8F0FE",
/** Accent for the work lens — the one place the app changes colour. */
work: "#0F766E",
done: "#2FA36B", done: "#2FA36B",
danger: "#E5484D", danger: "#E5484D",
warn: "#D98324", warn: "#D98324",
@@ -45,6 +47,7 @@ const dark = {
muted: "#7A7A85", muted: "#7A7A85",
accent: "#4C86F0", accent: "#4C86F0",
accentSoft: "#1B2740", accentSoft: "#1B2740",
work: "#2DD4BF",
done: "#3FBF84", done: "#3FBF84",
danger: "#F0605F", danger: "#F0605F",
warn: "#E0A93E", warn: "#E0A93E",

View File

@@ -1,6 +1,11 @@
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 type Repeat = "daily" | "weekdays" | "weekly" | "monthly" | "yearly";
/** Personal vs work — the app-wide toggle shows one area at a time. */
export type Area = "personal" | "work";
export const AREAS: Area[] = ["personal", "work"];
export const AREA_LABEL: Record<Area, string> = { personal: "Personal", work: "Work" };
export interface Task { export interface Task {
id: string; id: string;
@@ -17,6 +22,7 @@ export interface Task {
repeat: Repeat | null; repeat: Repeat | null;
completed_at: string | null; completed_at: string | null;
sort_order: number; sort_order: number;
area: Area;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -27,6 +33,7 @@ export interface Project {
status: ProjectStatus; status: ProjectStatus;
notes: string; notes: string;
sort_order: number; sort_order: number;
area: Area;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
open_tasks?: number; open_tasks?: number;

View File

@@ -11,12 +11,13 @@ 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, repeat, completed_at, sort_order, created_at, updated_at`; priority, estimate_min, repeat, completed_at, sort_order, area, created_at, updated_at`;
const PROJECT_COLS = `id, name, status, notes, sort_order, created_at, updated_at`; const PROJECT_COLS = `id, name, status, notes, sort_order, area, created_at, updated_at`;
const TASK_STATUSES = ["inbox", "next", "waiting", "scheduled", "someday", "done", "trashed"]; const TASK_STATUSES = ["inbox", "next", "waiting", "scheduled", "someday", "done", "trashed"];
const REPEATS = ["daily", "weekdays", "weekly", "monthly", "yearly"]; const REPEATS = ["daily", "weekdays", "weekly", "monthly", "yearly"];
const PROJECT_STATUSES = ["active", "someday", "completed", "dropped"]; const PROJECT_STATUSES = ["active", "someday", "completed", "dropped"];
const AREAS = ["personal", "work"];
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const CORS = { const CORS = {
@@ -33,8 +34,8 @@ 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", "repeat", "sort_order"]; const TASK_FIELDS = ["title", "notes", "status", "project_id", "context", "waiting_for", "due_date", "defer_date", "priority", "estimate_min", "repeat", "sort_order", "area"];
const PROJECT_FIELDS = ["name", "status", "notes", "sort_order"]; const PROJECT_FIELDS = ["name", "status", "notes", "sort_order", "area"];
// Only these may be cleared to NULL; notes/title are NOT NULL and keep "". // 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"]); const NULLABLE = new Set(["project_id", "context", "waiting_for", "due_date", "defer_date", "estimate_min", "repeat"]);
@@ -62,12 +63,15 @@ async function handle(req: Request): Promise<Response> {
if (path === "/v1/tasks" && method === "GET") { if (path === "/v1/tasks" && method === "GET") {
const status = url.searchParams.get("status"); const status = url.searchParams.get("status");
const projectId = url.searchParams.get("project_id"); const projectId = url.searchParams.get("project_id");
const area = url.searchParams.get("area");
if (status && !TASK_STATUSES.includes(status)) return err("bad status", 400); if (status && !TASK_STATUSES.includes(status)) return err("bad status", 400);
if (area && !AREAS.includes(area)) return err("bad area", 400);
const where: string[] = []; const where: string[] = [];
const params: unknown[] = []; const params: unknown[] = [];
if (status) { params.push(status); where.push(`status = $${params.length}`); } if (status) { params.push(status); where.push(`status = $${params.length}`); }
else where.push(`status != 'trashed'`); else where.push(`status != 'trashed'`);
if (projectId) { params.push(projectId); where.push(`project_id = $${params.length}`); } if (projectId) { params.push(projectId); where.push(`project_id = $${params.length}`); }
if (area) { params.push(area); where.push(`area = $${params.length}`); }
const rows = await sql.unsafe( const rows = await sql.unsafe(
`select ${TASK_COLS} from tasks where ${where.join(" and ")} `select ${TASK_COLS} from tasks where ${where.join(" and ")}
order by sort_order, created_at desc`, params); order by sort_order, created_at desc`, params);
@@ -79,16 +83,17 @@ async function handle(req: Request): Promise<Response> {
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); if (t.repeat && !REPEATS.includes(t.repeat as string)) return err("bad repeat", 400);
if (t.area && !AREAS.includes(t.area as string)) return err("bad area", 400);
const id = typeof body.id === "string" && UUID_RE.test(body.id) ? body.id : null; 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, repeat, sort_order) `insert into tasks (id, title, notes, status, project_id, context, waiting_for, due_date, defer_date, priority, estimate_min, repeat, sort_order, area)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) values (coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
on conflict (id) do nothing on conflict (id) do nothing
returning ${TASK_COLS}`, returning ${TASK_COLS}`,
[id, t.title, t.notes ?? "", t.status ?? "next", 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.repeat ?? null, t.sort_order ?? 0]); t.estimate_min ?? null, t.repeat ?? null, t.sort_order ?? 0, t.area ?? "personal"]);
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);
@@ -104,6 +109,7 @@ async function handle(req: Request): Promise<Response> {
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); if (patch.repeat && !REPEATS.includes(patch.repeat as string)) return err("bad repeat", 400);
if (patch.area && !AREAS.includes(patch.area as string)) return err("bad area", 400);
const existing = await sql.unsafe(`select ${TASK_COLS} from tasks where id = $1`, [id]); 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 };
@@ -111,10 +117,10 @@ async function handle(req: Request): Promise<Response> {
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, repeat=$12, completed_at=$13, due_date=$8, defer_date=$9, priority=$10, estimate_min=$11, repeat=$12, completed_at=$13,
sort_order=$14, updated_at=now() sort_order=$14, area=$15, 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, cur.repeat, completedAt, cur.sort_order]); cur.due_date, cur.defer_date, cur.priority, cur.estimate_min, cur.repeat, completedAt, cur.sort_order, cur.area]);
return json(rows[0]); return json(rows[0]);
} }
@@ -133,13 +139,14 @@ async function handle(req: Request): Promise<Response> {
const p = pick(body, PROJECT_FIELDS); const p = pick(body, PROJECT_FIELDS);
if (typeof p.name !== "string" || !p.name.trim()) return err("name required", 400); if (typeof p.name !== "string" || !p.name.trim()) return err("name required", 400);
if (p.status && !PROJECT_STATUSES.includes(p.status as string)) return err("bad status", 400); if (p.status && !PROJECT_STATUSES.includes(p.status as string)) return err("bad status", 400);
if (p.area && !AREAS.includes(p.area as string)) return err("bad area", 400);
const id = typeof body.id === "string" && UUID_RE.test(body.id) ? body.id : null; const id = typeof body.id === "string" && UUID_RE.test(body.id) ? body.id : null;
const rows = await sql.unsafe( const rows = await sql.unsafe(
`insert into projects (id, name, status, notes, sort_order) `insert into projects (id, name, status, notes, sort_order, area)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5) values (coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6)
on conflict (id) do nothing on conflict (id) do nothing
returning ${PROJECT_COLS}`, returning ${PROJECT_COLS}`,
[id, p.name, p.status ?? "active", p.notes ?? "", p.sort_order ?? 0]); [id, p.name, p.status ?? "active", p.notes ?? "", p.sort_order ?? 0, p.area ?? "personal"]);
if (rows.length) return json(rows[0], 201); if (rows.length) return json(rows[0], 201);
const existing = await sql.unsafe(`select ${PROJECT_COLS} from projects where id = $1`, [id]); const existing = await sql.unsafe(`select ${PROJECT_COLS} from projects where id = $1`, [id]);
return json(existing[0], 200); return json(existing[0], 200);
@@ -150,13 +157,14 @@ async function handle(req: Request): Promise<Response> {
const id = m[1]; const id = m[1];
const patch = method === "DELETE" ? { status: "dropped" } : pick(body, PROJECT_FIELDS); const patch = method === "DELETE" ? { status: "dropped" } : pick(body, PROJECT_FIELDS);
if (patch.status && !PROJECT_STATUSES.includes(patch.status as string)) return err("bad status", 400); if (patch.status && !PROJECT_STATUSES.includes(patch.status as string)) return err("bad status", 400);
if (patch.area && !AREAS.includes(patch.area as string)) return err("bad area", 400);
const existing = await sql.unsafe(`select ${PROJECT_COLS} from projects where id = $1`, [id]); const existing = await sql.unsafe(`select ${PROJECT_COLS} from projects 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 rows = await sql.unsafe( const rows = await sql.unsafe(
`update projects set name=$2, status=$3, notes=$4, sort_order=$5, updated_at=now() `update projects set name=$2, status=$3, notes=$4, sort_order=$5, area=$6, updated_at=now()
where id=$1 returning ${PROJECT_COLS}`, where id=$1 returning ${PROJECT_COLS}`,
[id, cur.name, cur.status, cur.notes, cur.sort_order]); [id, cur.name, cur.status, cur.notes, cur.sort_order, cur.area]);
return json(rows[0]); return json(rows[0]);
} }

View File

@@ -38,3 +38,10 @@ 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). -- Columns added after the initial deploy (create-if-not-exists won't alter).
alter table tasks add column if not exists repeat text alter table tasks add column if not exists repeat text
check (repeat in ('daily', 'weekdays', 'weekly', 'monthly', 'yearly')); check (repeat in ('daily', 'weekdays', 'weekly', 'monthly', 'yearly'));
-- Personal vs work: the app's top-level toggle filters every list by area.
alter table tasks add column if not exists area text not null default 'personal'
check (area in ('personal', 'work'));
alter table projects add column if not exists area text not null default 'personal'
check (area in ('personal', 'work'));
create index if not exists tasks_area_idx on tasks (area);