From c11c0d9db52fa27ae649919931866be0a5fbe3ad Mon Sep 17 00:00:00 2001 From: atman-33 Date: Fri, 31 Jul 2026 08:45:42 +0900 Subject: [PATCH 1/2] feat: add recurring task rules that create tasks on a schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recurring work had to be typed onto the board by hand. Settings > Recurring now holds rules — a task template plus a daily/weekly/monthly schedule — that the app stamps out when they come due. The schedule is local wall-clock time, so evaluation lives in the frontend (src/lib/recurring.ts) rather than beside the vault-tidy scheduler in Rust, which has no time zone. The backend only persists the rules. Two invariants keep duplicates out: only the latest missed occurrence is ever created (a week offline yields one task, not seven), and an occurrence is consumed by recording it in last_generated whether the task was created or skipped. skip_if_open suppresses a new task while an earlier one from the same rule is still open; generated tasks are identified by a recurring/ tag, so the Task frontmatter schema is unchanged. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 16 + src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/models.rs | 104 ++++++ src/app.tsx | 4 + src/components/help-view.tsx | 71 ++++ src/components/recurring-settings.tsx | 455 ++++++++++++++++++++++++++ src/components/settings-dialog.tsx | 17 +- src/lib/recurring.test.ts | 233 +++++++++++++ src/lib/recurring.ts | 280 ++++++++++++++++ src/lib/use-recurring-tasks.ts | 104 ++++++ src/types.ts | 48 +++ 12 files changed, 1332 insertions(+), 4 deletions(-) create mode 100644 src/components/recurring-settings.tsx create mode 100644 src/lib/recurring.test.ts create mode 100644 src/lib/recurring.ts create mode 100644 src/lib/use-recurring-tasks.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c8a0be1..9e85c65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.67.0 (2026-07-30) + +- **Recurring tasks** (T-0110): work that comes back every day, week or month + no longer has to be typed onto the board by hand. **⚙ Settings → Recurring** + holds rules — a task template plus its schedule (daily every N days, weekly + on chosen weekdays, or monthly on a day clamped to short months, at a time of + day) — and the app creates the task when a rule comes due. The schedule is + local wall-clock time, and rules are re-checked at startup and every few + minutes, so a machine booted at 10:00 still gets its 09:00 task; only the + latest missed occurrence is created, so a week with the app closed produces + one task rather than seven. **Skip while the last one is still open** leaves + the occurrence alone while an earlier task from the same rule is unfinished, + which is what keeps a daily rule from stacking up copies of work you have not + got to yet. Generated tasks carry a `recurring/` tag, which is how + the app recognizes its own. + ## 0.66.1 (2026-07-29) - **Reordered the top tab bar** (T-0106): Tasks, Repos, Schedule, Inbox, diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 07a7a8f..fff3930 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5997,7 +5997,7 @@ dependencies = [ [[package]] name = "workhub" -version = "0.66.0" +version = "0.67.0" dependencies = [ "cpal", "dirs 5.0.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b0b4f30..913bb0a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "workhub" -version = "0.66.1" +version = "0.67.0" edition = "2021" description = "Developer Workspace Manager — a fast hub for AI-driven, multi-repo development" license = "MIT" diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 714bfe6..e0f4f7c 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -148,6 +148,11 @@ pub struct Settings { /// project it describes. #[serde(default)] pub schedule_export_dir: String, + /// Recurring task rules (T-0110): definitions the app turns into real tasks + /// on their own schedule. Evaluated by the frontend, which owns local-time + /// calendar arithmetic; the backend only persists them. + #[serde(default)] + pub recurring: Vec, /// Display language for the schedule calendar — weekday and month labels /// on screen *and* in the HTML export: "en" | "ja". Display only; a /// schedule note never stores localized text, so this can never change a @@ -318,10 +323,109 @@ impl Default for Settings { schedule_confirm: false, schedule_export_dir: String::new(), schedule_locale: default_schedule_locale(), + recurring: Vec::new(), } } } +/// One recurring-task rule (T-0110): a task template plus the calendar that +/// says when to stamp it out. +/// +/// The schedule is wall-clock ("every weekday at 09:00"), so all evaluation +/// happens in the frontend, which has the machine's local time zone; the +/// backend only stores what the UI hands it. `last_generated` is the fired-slot +/// bookkeeping that keeps one occurrence from being created twice. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecurringRule { + /// Stable id (`R-001`), also the suffix of the `recurring/` tag put on + /// every task the rule generates. Never reused. + pub id: String, + #[serde(default = "default_true")] + pub enabled: bool, + /// Title of the generated task. + #[serde(default)] + pub title: String, + /// Body of the generated task. Empty = the standard empty sections. + #[serde(default)] + pub body: String, + /// Frontmatter the generated task starts with. + #[serde(default = "default_recurring_status")] + pub status: String, + #[serde(default = "default_recurring_assignee")] + pub assignee: String, + #[serde(default)] + pub project: String, + #[serde(default = "default_recurring_priority")] + pub priority: String, + #[serde(default)] + pub model: String, + /// Extra tags on top of the mandatory `recurring/` marker tag. + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub confirm: bool, + #[serde(default)] + pub worktree: bool, + /// `due` = occurrence date + this many days; unset = no due date. + #[serde(default)] + pub due_offset_days: Option, + /// Skip the occurrence entirely while a task from this rule is still open + /// (status != done, not archived) — "don't add it if it's already there". + /// The slot is still consumed, so the rule doesn't fire again immediately. + #[serde(default = "default_true")] + pub skip_if_open: bool, + pub schedule: RecurringSchedule, + /// Unix seconds of the occurrence this rule was last evaluated for + /// (generated *or* deliberately skipped). Unset = never fired. + #[serde(default)] + pub last_generated: Option, +} + +/// When a `RecurringRule` fires. All times are the machine's local wall clock. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecurringSchedule { + /// "daily" | "weekly" | "monthly". + #[serde(default = "default_recurring_kind")] + pub kind: String, + /// daily: fire every N days counted from `start_date`. + #[serde(default = "default_interval_days")] + pub interval_days: u32, + /// weekly: weekdays to fire on, 0 = Sunday .. 6 = Saturday. + #[serde(default)] + pub weekdays: Vec, + /// monthly: day of month, clamped to the last day of shorter months. + #[serde(default = "default_day_of_month")] + pub day_of_month: u32, + /// Time of day, "HH:MM" (24h, local). + #[serde(default = "default_recurring_time")] + pub time: String, + /// `YYYY-MM-DD`; no occurrence before this date. Empty = no lower bound. + #[serde(default)] + pub start_date: String, +} + +fn default_recurring_status() -> String { + "todo".into() +} +fn default_recurring_assignee() -> String { + "me".into() +} +fn default_recurring_priority() -> String { + "medium".into() +} +fn default_recurring_kind() -> String { + "daily".into() +} +fn default_interval_days() -> u32 { + 1 +} +fn default_day_of_month() -> u32 { + 1 +} +fn default_recurring_time() -> String { + "09:00".into() +} + /// One schedule note as the picker sees it — enough to choose a file without /// reading every note's body. #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/app.tsx b/src/app.tsx index e37d657..4ab9285 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -26,6 +26,7 @@ import { VoiceView } from "@/components/voice-view"; import { Button } from "@/components/ui/button"; import { TooltipProvider } from "@/components/ui/tooltip"; import { api } from "@/lib/api"; +import { useRecurringTasks } from "@/lib/use-recurring-tasks"; import { useTidyNotifications } from "@/lib/use-tidy-notifications"; import { cn } from "@/lib/utils"; import type { Settings, TemplateDiff, UpdateInfo } from "@/types"; @@ -62,6 +63,9 @@ export default function App() { // Bumped after every settings save; views reload their config when it changes. const [configVersion, setConfigVersion] = useState(0); useTidyNotifications(); + // Recurring task rules (T-0110): checked on start and every few minutes, so a + // machine booted after a rule's time still gets that occurrence's task. + useRecurringTasks(configVersion); const checkTemplate = useCallback(async (vaultPath: string) => { try { diff --git a/src/components/help-view.tsx b/src/components/help-view.tsx index 48e486c..fecef2c 100644 --- a/src/components/help-view.tsx +++ b/src/components/help-view.tsx @@ -12,6 +12,7 @@ import { Mic, MonitorUp, PenLine, + Repeat, Rocket, Sparkles, } from "lucide-react"; @@ -160,6 +161,17 @@ Keeps the vault easy for AI to search: files stale notes out of \`inbox/\` and r - The routine runs with the same auto-approve permission mode a task-card agent launch uses, so it doesn't sit waiting on prompts. An operation it isn't allowed to do is skipped rather than asked about, which can leave a run half-finished. - **Resume session** picks up exactly where a run left off — after a failure, a stall, a killed process, or an app restart. The session id is shown next to the run status with a copy button, and is also written into the run log under \`_ai/logs/tidy/\`, so you can resume from a terminal yourself with \`claude --resume \`. (OpenCode mints its own session ids, so there Resume just reopens the agent in the vault.)`; +const RECURRING_MD = `## Recurring tasks + +Rules that create a task for you on a schedule — a daily standup note, a weekly review, a monthly report. Set them up in **⚙ Settings → Recurring**. + +- **Repeat** is daily (every N days from the start date), weekly (pick the weekdays), or monthly (pick the day; it is clamped to the last day of shorter months), plus a time of day. Times are this machine's local clock. +- The generated task uses the rule's title, status, assignee, project, priority, model and body, so it lands on the board ready to work. **Due offset** sets \`due\` to the occurrence date plus N days; leave it empty for no due date. +- Rules are checked when the app starts and every few minutes after that, so a machine booted at 10:00 still gets its 09:00 task. Only the **latest** missed occurrence is created — a week with the app closed produces one task, not seven. +- **Skip while the last one is still open** is the "don't add it if it's already there" switch: while an earlier task from the same rule is not done, the occurrence is skipped instead of putting a second copy on the board. +- Every generated task carries the tag \`recurring/\` (e.g. \`recurring/R-001\`). That tag is how the app recognizes its own tasks — keep it if you edit the task in Obsidian. +- **Run now** creates whatever is due right away, using the rules already saved on disk.`; + const MEMORY_MD = `## Long-term memory for AI agents Gives every agent session on the vault — Claude Code and OpenCode — a memory of past sessions, fully local, no cloud, no LLM. Each session's Q&A pairs are saved into \`/_ai/memory/memory.db\` (SQLite), and new sessions automatically receive a time summary ("last session was N days ago") plus past conversations relevant to the current prompt, found by hybrid keyword + vector search. @@ -266,6 +278,7 @@ const SECTIONS = [ { value: "voice", title: "Voice input", icon: Mic }, { value: "schedule", title: "Planning dates", icon: CalendarRange }, { value: "tidy", title: "Vault tidy", icon: Sparkles }, + { value: "recurring", title: "Recurring tasks", icon: Repeat }, ] as const; const ALL_SECTION_VALUES = SECTIONS.map((s) => s.value as string); @@ -1081,6 +1094,64 @@ export function HelpView() { + +
+

+ Rules that create a task for you on a schedule — a daily standup + note, a weekly review, a monthly report. Set them up in{" "} + ⚙ Settings → Recurring. +

+
    +
  • + Repeat is + daily (every N days from the start date), weekly (pick the + weekdays), or monthly (pick the day; it is clamped to the last + day of shorter months), plus a time of day. Times are this + machine's local clock. +
  • +
  • + The generated task uses the rule's title, status, assignee, + project, priority, model and body, so it lands on the board ready + to work. Due offset sets{" "} + due to the occurrence + date plus N days; leave it empty for no due date. +
  • +
  • + Rules are checked when the app starts and every few minutes after + that, so a machine booted at 10:00 still gets its 09:00 task. + Only the latest missed + occurrence is created — a week with the app closed produces one + task, not seven. +
  • +
  • + + Skip while the last one is still open + {" "} + is the "don't add it if it's already there" switch: while an + earlier task from the same rule is not done, the occurrence is + skipped instead of putting a second copy on the board. +
  • +
  • + Every generated task carries the tag{" "} + recurring/<rule-id>{" "} + (e.g. recurring/R-001). + That tag is how the app recognizes its own tasks — keep it if you + edit the task in Obsidian. +
  • +
  • + Run now{" "} + creates whatever is due right away, using the rules already saved + on disk. +
  • +
+
diff --git a/src/components/recurring-settings.tsx b/src/components/recurring-settings.tsx new file mode 100644 index 0000000..ed0aae1 --- /dev/null +++ b/src/components/recurring-settings.tsx @@ -0,0 +1,455 @@ +import { useState } from "react"; +import { ChevronDown, ChevronRight, Loader2, Play, Plus, Trash2 } from "lucide-react"; +import { api } from "@/lib/api"; +import { describeSchedule, newRule, nextOccurrence } from "@/lib/recurring"; +import { generateDueTasks } from "@/lib/use-recurring-tasks"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { ModelCombobox } from "@/components/model-combobox"; +import { cn } from "@/lib/utils"; +import type { RecurringRule, TaskAssignee, TaskPriority, TaskStatus } from "@/types"; + +const STATUSES: TaskStatus[] = ["inbox", "todo", "doing"]; +const ASSIGNEES: TaskAssignee[] = ["me", "claude-code", "opencode"]; +const PRIORITIES: TaskPriority[] = ["low", "medium", "high"]; +const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +/** Same English-first formatting as the rest of the settings dialog. */ +const TIMESTAMP = new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", +}); + +interface Props { + rules: RecurringRule[]; + onChange: (rules: RecurringRule[]) => void; + /** True while the settings dialog is open (drives the model combobox). */ + open: boolean; +} + +/** + * Settings › Recurring (T-0110): the rule list that the app stamps tasks out + * of. Editing here only changes the draft; the dialog's Save persists it. + */ +export function RecurringSettings({ rules, onChange, open }: Props) { + const [expanded, setExpanded] = useState(null); + const [running, setRunning] = useState(false); + const [message, setMessage] = useState(""); + + const patch = (id: string, changes: Partial) => + onChange(rules.map((r) => (r.id === id ? { ...r, ...changes } : r))); + + const patchSchedule = (id: string, changes: Partial) => + onChange( + rules.map((r) => (r.id === id ? { ...r, schedule: { ...r.schedule, ...changes } } : r)), + ); + + const runNow = async () => { + setRunning(true); + setMessage(""); + try { + const result = await generateDueTasks(); + // The run wrote `last_generated` straight to disk; pull the rules back so + // saving this dialog can't reinstate the stale slots and double-fire. + const cfg = await api.getConfig(); + onChange(cfg.settings.recurring ?? []); + const parts: string[] = []; + if (result.created.length) parts.push(`created ${result.created.length}`); + if (result.skipped.length) parts.push(`skipped ${result.skipped.length} (still open)`); + setMessage(parts.length ? parts.join(", ") : "Nothing due right now."); + } catch (e) { + setMessage(String(e)); + } finally { + setRunning(false); + } + }; + + return ( +
+
+

+ Rules the app turns into real tasks on their own schedule. Times are this machine's local + clock; a missed occurrence (app closed) is created once at the next start, never + backfilled. +

+ +
+ + {rules.length === 0 && ( +

+ No recurring rules yet. +

+ )} + + {rules.map((rule) => { + const isOpen = expanded === rule.id; + const next = nextOccurrence(rule, new Date()); + return ( +
+
+ + patch(rule.id, { enabled: v })} + /> + +
+ + {isOpen && ( +
+
+ + patch(rule.id, { title: e.target.value })} + placeholder="Weekly review" + className="h-8 text-xs" + /> +
+ + {/* Schedule */} +
+
+ + +
+
+ + patchSchedule(rule.id, { time: e.target.value })} + className="h-8 text-xs" + /> +
+
+ + {rule.schedule.kind === "daily" && ( +
+ + + patchSchedule(rule.id, { + interval_days: Math.max(1, Number(e.target.value) || 1), + }) + } + className="h-8 w-24 text-xs" + /> +
+ )} + + {rule.schedule.kind === "weekly" && ( +
+ +
+ {WEEKDAYS.map((label, day) => { + const on = rule.schedule.weekdays.includes(day); + return ( + + ); + })} +
+
+ )} + + {rule.schedule.kind === "monthly" && ( +
+ + + patchSchedule(rule.id, { + day_of_month: Math.min(31, Math.max(1, Number(e.target.value) || 1)), + }) + } + className="h-8 w-24 text-xs" + /> +
+ )} + +
+
+ + patchSchedule(rule.id, { start_date: e.target.value })} + className="h-8 text-xs" + /> +
+
+ + + patch(rule.id, { + due_offset_days: + e.target.value === "" ? null : Number(e.target.value) || 0, + }) + } + className="h-8 text-xs" + /> +
+
+ + {/* Generated task's frontmatter */} +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + patch(rule.id, { project: e.target.value })} + placeholder="repo / project slug" + className="h-8 font-mono text-xs" + /> +
+
+ + patch(rule.id, { model })} + active={open} + disabled={rule.assignee === "me"} + placeholder={rule.assignee === "me" ? "n/a for me" : "agent default"} + modal + /> +
+
+ +
+ + + patch(rule.id, { + tags: e.target.value + .split(",") + .map((t) => t.trim()) + .filter(Boolean), + }) + } + placeholder="routine" + className="h-8 text-xs" + /> +

+ Every generated task also carries recurring/{rule.id}, which is how + the app recognizes its own tasks. +

+
+ +
+ +