Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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/<rule-id>` 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,
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
104 changes: 104 additions & 0 deletions src-tauri/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RecurringRule>,
/// 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
Expand Down Expand Up @@ -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/<id>` 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/<id>` marker tag.
#[serde(default)]
pub tags: Vec<String>,
#[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<i32>,
/// 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<u64>,
}

/// 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<u32>,
/// 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)]
Expand Down
4 changes: 4 additions & 0 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down
72 changes: 72 additions & 0 deletions src/components/help-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Mic,
MonitorUp,
PenLine,
Repeat,
Rocket,
Sparkles,
} from "lucide-react";
Expand Down Expand Up @@ -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 <id>\`. (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/<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.`;

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 \`<vault>/_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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1081,6 +1094,65 @@ export function HelpView() {
</li>
</ul>
</Section>

<Section
icon={Repeat}
title="Recurring tasks"
value="recurring"
markdown={RECURRING_MD}
copiedId={copiedId}
onCopy={handleCopy}
>
<p>
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{" "}
<span className="font-medium">Recurring</span> button on the{" "}
<span className="font-medium">Tasks</span> tab — they are task
content, so they live next to the board rather than in Settings.
</p>
<ul className="ml-4 list-disc space-y-1.5">
<li>
<span className="font-medium text-foreground">Repeat</span> 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.
</li>
<li>
The generated task uses the rule's title, status, assignee,
project, priority, model and body, so it lands on the board ready
to work. <span className="font-medium">Due offset</span> sets{" "}
<span className="font-mono text-xs">due</span> to the occurrence
date plus N days; leave it empty for no due date.
</li>
<li>
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 <span className="font-medium">latest</span> missed
occurrence is created — a week with the app closed produces one
task, not seven.
</li>
<li>
<span className="font-medium text-foreground">
Skip while the last one is still open
</span>{" "}
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.
</li>
<li>
Every generated task carries the tag{" "}
<span className="font-mono text-xs">recurring/&lt;rule-id&gt;</span>{" "}
(e.g. <span className="font-mono text-xs">recurring/R-001</span>).
That tag is how the app recognizes its own tasks — keep it if you
edit the task in Obsidian.
</li>
<li>
<span className="font-medium text-foreground">Run now</span>{" "}
saves the rules and then creates whatever is due right away.
</li>
</ul>
</Section>
</Accordion>
</div>
</div>
Expand Down
Loading
Loading