diff --git a/CHANGELOG.md b/CHANGELOG.md index c8a0be1..ce21c4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 0.67.0 (2026-07-31) + +- **Recurring tasks** (T-0110): work that comes back every day, week or month + no longer has to be typed onto the board by hand. The **Recurring** button on + the Tasks tab 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 + rules sit next to the board rather than in Settings because they are task + content, not an app preference. 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..756583f 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 with the **Recurring** button on the **Tasks** tab (they are task content, so they live next to the board rather than in Settings). + +- **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** saves the rules and then creates whatever is due right away.`; + 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,65 @@ 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 with the{" "} + Recurring button on the{" "} + Tasks tab — they are task + content, so they live next to the board rather than in Settings. +

+
    +
  • + 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{" "} + saves the rules and then creates whatever is due right away. +
  • +
+
diff --git a/src/components/recurring-dialog.tsx b/src/components/recurring-dialog.tsx new file mode 100644 index 0000000..bd2cdf0 --- /dev/null +++ b/src/components/recurring-dialog.tsx @@ -0,0 +1,146 @@ +import { useEffect, useState } from "react"; +import { Loader2, Play } from "lucide-react"; +import { api } from "@/lib/api"; +import { generateDueTasks } from "@/lib/use-recurring-tasks"; +import { RecurringSettings } from "@/components/recurring-settings"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import type { RecurringRule } from "@/types"; + +interface Props { + open: boolean; + onClose: () => void; + /** Called after the rules were persisted, so the caller can refresh its copy. */ + onSaved?: (rules: RecurringRule[]) => void; +} + +/** + * Recurring rules editor (T-0110), opened from the Tasks toolbar. + * + * The rules are task content rather than an app preference, so they live next + * to the board they fill instead of in the settings dialog. This component owns + * the load/save round trip: it reads the rules from the config when it opens and + * writes only `settings.recurring` back, leaving every other setting alone even + * if the settings dialog saved something while this was open. + */ +export function RecurringDialog({ open, onClose, onSaved }: Props) { + const [rules, setRules] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [running, setRunning] = useState(false); + const [message, setMessage] = useState(""); + + useEffect(() => { + if (!open) return; + setMessage(""); + setLoading(true); + void (async () => { + try { + const cfg = await api.getConfig(); + setRules(cfg.settings.recurring ?? []); + } catch (e) { + setMessage(`Could not load the rules — ${e}`); + } finally { + setLoading(false); + } + })(); + }, [open]); + + /** Writes the draft rules, leaving the rest of the config as found on disk. */ + const persist = async (next: RecurringRule[]) => { + const cfg = await api.getConfig(); + await api.saveConfig({ ...cfg, settings: { ...cfg.settings, recurring: next } }); + onSaved?.(next); + }; + + const save = async () => { + setSaving(true); + setMessage(""); + try { + await persist(rules); + onClose(); + } catch (e) { + setMessage(`Save failed — ${e}`); + } finally { + setSaving(false); + } + }; + + /** + * Saves first, then generates: a run reads the rules from disk, so running + * with unsaved edits on screen would silently use the old ones. Re-reading + * afterwards picks up the `last_generated` slots the run just consumed, so + * a later Save cannot reinstate them and fire the same occurrence twice. + */ + const runNow = async () => { + setRunning(true); + setMessage(""); + try { + await persist(rules); + const result = await generateDueTasks(); + const cfg = await api.getConfig(); + setRules(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(`Run failed — ${e}`); + } finally { + setRunning(false); + } + }; + + return ( + !o && onClose()}> + + + Recurring tasks + + Rules that put a task on the board on their own schedule. + + +
+ {loading ? ( +

Loading…

+ ) : ( + + )} +
+ {message &&

{message}

} + + +
+ + +
+
+
+
+ ); +} diff --git a/src/components/recurring-settings.tsx b/src/components/recurring-settings.tsx new file mode 100644 index 0000000..c1c233b --- /dev/null +++ b/src/components/recurring-settings.tsx @@ -0,0 +1,420 @@ +import { useState } from "react"; +import { ChevronDown, ChevronRight, Plus, Trash2 } from "lucide-react"; +import { describeSchedule, newRule, nextOccurrence } from "@/lib/recurring"; +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 hosting dialog is open (drives the model combobox). */ + open: boolean; +} + +/** + * The recurring-rule list editor (T-0110): a controlled component that edits a + * draft array of rules. Persisting it — and running the rules — belongs to the + * hosting dialog (`recurring-dialog.tsx`). + */ +export function RecurringSettings({ rules, onChange, open }: Props) { + const [expanded, setExpanded] = useState(null); + + 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)), + ); + + 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. +

+
+ +
+ +