GTD Task Inbox

A capture-first inbox, daily focus view, and upcoming timeline — the three-view core of a GTD workflow. Used in OrgFlo.

Overview

Three cooperating views follow David Allen's capture-clarify-organise workflow:

Inbox

Zero-friction capture. Tasks with no project land here for later triage.

Today

Tasks due today or overdue. Drives daily focus without manual priority tags.

Upcoming

Future tasks grouped by date. Surfaces commitments before they become urgent.

Data is modelled as Area → Project → Task. The inbox is defined by projectID == nil — no separate flag required.

Inbox Preview

Inbox4 items
Research competitor pricing
Inbox
Draft Q3 retrospective agenda
Inbox
Follow up with contractor
Today
Review pull requests
Inbox
Capture…

Quick-capture bar

A persistent bottom toolbar hosts the capture field. Pressing Return inserts an OFTask with no project. The field clears instantly — the task appears at the top of the list.

Triage flow

  • Tap a task to open the detail panel (NavigationSplitView on iPad/macOS)
  • Assign an Area, Project, and due date to move it out of the inbox
  • Inbox count decrements in real time — zero shows an "All clear" empty state

Code

SwiftUI tabs show InboxView/TodayView (iOS) and the OFTask SwiftData model (macOS tab). React and HTML tabs show equivalent web implementations.

TaskInbox.tsx
tsx
"use client";

import { useState, useId } from "react";

interface Task {
  id: string;
  title: string;
  dueDate?: string;
  projectId?: string;
  completed: boolean;
}

// React GTD inbox — local state version; swap useState for a server action / Zustand store.
export function TaskInbox() {
  const [tasks, setTasks] = useState<Task[]>([
    { id: "1", title: "Research competitor pricing", completed: false },
    { id: "2", title: "Draft Q3 retro agenda", completed: false },
    { id: "3", title: "Follow up with contractor", dueDate: new Date().toISOString(), completed: false },
  ]);
  const [draft, setDraft] = useState("");

  // Inbox = tasks with no project assignment
  const inbox = tasks.filter((t) => !t.projectId && !t.completed);

  const capture = () => {
    if (!draft.trim()) return;
    setTasks((prev) => [...prev, { id: crypto.randomUUID(), title: draft.trim(), completed: false }]);
    setDraft("");
  };

  const toggle = (id: string) =>
    setTasks((prev) => prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t)));

  return (
    <div style={{ maxWidth: 400, fontFamily: "system-ui, sans-serif" }}>
      {/* Header */}
      <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "14px 0 10px" }}>
        <h2 style={{ fontSize: 20, fontWeight: 700, margin: 0 }}>Inbox</h2>
        <span style={{ fontSize: 12, fontWeight: 600, padding: "2px 10px", borderRadius: 99, background: "rgba(52,168,101,0.15)", color: "#34a865" }}>
          {inbox.length} items
        </span>
      </div>

      {/* Task list */}
      <ul style={{ listStyle: "none", padding: 0, margin: 0, borderRadius: 12, overflow: "hidden", border: "1px solid var(--border, #333)" }}>
        {inbox.length === 0 && (
          <li style={{ padding: "24px 16px", textAlign: "center", color: "#888", fontSize: 14 }}>All clear ✓</li>
        )}
        {inbox.map((task, i) => (
          <li
            key={task.id}
            style={{
              display: "flex", alignItems: "flex-start", gap: 12, padding: "12px 16px",
              borderBottom: i < inbox.length - 1 ? "1px solid var(--border-subtle, #222)" : undefined,
            }}
          >
            <button
              onClick={() => toggle(task.id)}
              aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
              style={{ width: 20, height: 20, borderRadius: "50%", border: "2px solid #555", background: "transparent", cursor: "pointer", marginTop: 2, flexShrink: 0 }}
            />
            <div style={{ flex: 1 }}>
              <div style={{ fontSize: 14, color: "var(--text-primary, #f2f2f6)" }}>{task.title}</div>
              {task.dueDate && (
                <div style={{ fontSize: 11, color: new Date(task.dueDate) < new Date() ? "#f87171" : "#888", marginTop: 2 }}>
                  Due {new Date(task.dueDate).toLocaleDateString()}
                </div>
              )}
            </div>
          </li>
        ))}
      </ul>

      {/* Quick-capture bar */}
      <div style={{ marginTop: 8, display: "flex", gap: 8 }}>
        <input
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && capture()}
          placeholder="Capture…"
          style={{ flex: 1, padding: "8px 12px", borderRadius: 8, border: "1px solid #333", background: "transparent", color: "inherit", fontSize: 14 }}
        />
        {draft && (
          <button onClick={capture} style={{ padding: "8px 14px", borderRadius: 8, background: "#34a865", color: "#fff", border: "none", cursor: "pointer", fontWeight: 600, fontSize: 14 }}>
            Add
          </button>
        )}
      </div>
    </div>
  );
}

Sync & Persistence

LayerResponsibility
SwiftData storeOn-device persistence; @Query predicates define inbox / today / upcoming filters
SyncServiceBatches changed object IDs, diffs to CloudKit private database, last-write-wins on modifiedAt
WCSessionPushes habit summary to watchOS companion; receives completion events

Accessibility

  • Completion toggle uses .contentTransition(.symbolEffect(.replace)); VoiceOver announces via .accessibilityValue
  • Overdue dates render red and also carry .accessibilityLabel("Overdue, date") so colour is not the only signal
  • Capture field uses .submitLabel(.done) — keyboard stays up for rapid multi-item entry