feat(intentions): Phase 7 — if-then rules for task / time / triage triggers
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled

Small if-then automation layer. Rules persist in SQLite; the runner
lives on the frontend and binds to the Phase 6 event bus so the rule
pipeline reuses the same delivery primitives (timer events, TTS,
Tasks navigation).

Storage:
- Migration v12 adds implementation_rules (id, enabled, trigger_kind,
  trigger_value, actions_json, last_fired_key, created_at,
  updated_at) with enabled+trigger_kind index for the runner's hot
  path.
- CRUD helpers: insert / list / get / set-enabled / mark-fired /
  delete, plus a round-trip test.

Commands (all main-window-guarded via ensure_main_window):
- list_implementation_rules
- create_implementation_rule — validates HH:MM, checks the target
  task exists at save time for surface-task actions, caps
  speak-line at 240 chars, pins v1 timers to 5 minutes.
- set_implementation_rule_enabled
- mark_implementation_rule_fired — main-thread idempotency shim so
  the runner can atomically claim a fire.
- delete_implementation_rule

Runner (implementationIntentions.svelte.ts):
- Subscribes to kon:task-completed and kon:morning-triage-finished
  (MorningTriageModal now emits on all three exit paths — empty,
  skipped, picked — so skip counts as finishing).
- 30 s poll for time-of-day rules, plus an immediate check on startup
  so a rule whose time has already passed today catches up once.
- Idempotency via last_fired_key composed as YYYY-MM-DD@HH:MM for
  time rules; new time rules whose HH:MM has already passed today
  are pre-seeded so they don't fire retroactively on save.
- Rules are paused when Nudges "Mute for now" is on — a hard mute
  stops all rule delivery in addition to OS notifications.
- Stale-task safety: if a surface-task action's target has been
  deleted, the runner opens Tasks and warns clearly rather than
  pretending to surface something that's gone.

Editor (ImplementationRulesEditor.svelte):
- Lives in Settings under a new "If-then rules" accordion section.
- `If` picker: time of day (with time input), a task completes,
  morning triage finishes.
- `Then` composer: optional surface (inbox / today / all tasks /
  specific task), optional 5-min timer, optional speak-aloud line.
- Saved rules list with enable toggle + delete.

Rules table integration for Phase 10b rename sweep: add
implementation_rules to the kon.db → corbie.db migration shim when
that phase lands.

Gates: fmt, clippy -D warnings, cargo test 265/0, svelte-check
0/0, npm build green. Pre-existing Vite chunk warning on sounds.ts
is unrelated to Phase 7.
This commit is contained in:
2026-04-24 19:27:06 +01:00
parent eebea8cb9a
commit 6cd1c22c0f
12 changed files with 1180 additions and 10 deletions

View File

@@ -0,0 +1,266 @@
<script lang="ts">
// @ts-nocheck
import { onMount } from "svelte";
import { Trash2 } from "lucide-svelte";
import Toggle from "$lib/components/Toggle.svelte";
import { tasks } from "$lib/stores/page.svelte.js";
import { toasts } from "$lib/stores/toasts.svelte.js";
import {
createImplementationRule,
deleteImplementationRule,
implementationRules,
initialLastFiredKeyForTimeRule,
loadImplementationRules,
ruleSummary,
setImplementationRuleEnabled,
} from "$lib/stores/implementationIntentions.svelte.ts";
let loading = $state(false);
let saving = $state(false);
let error = $state("");
let triggerKind = $state("time_of_day");
let triggerTime = $state("09:00");
let surfaceEnabled = $state(true);
let surfaceTarget = $state("inbox");
let surfaceTaskId = $state("");
let startTimer = $state(false);
let speakLine = $state("");
let activeTasks = $derived(tasks.filter((task) => !task.done && !task.parentTaskId));
onMount(() => {
loading = true;
loadImplementationRules(true)
.catch((err) => { error = String(err); })
.finally(() => { loading = false; });
});
function triggerValue() {
return triggerKind === "time_of_day" ? triggerTime : "";
}
function builtActions() {
const actions = [];
if (surfaceEnabled) {
actions.push({
kind: "surface",
target: surfaceTarget,
taskId: surfaceTarget === "task" ? surfaceTaskId : null,
label: surfaceTarget === "task"
? activeTasks.find((task) => task.id === surfaceTaskId)?.text ?? "Task"
: null,
});
}
if (startTimer) {
const selectedTask = activeTasks.find((task) => task.id === surfaceTaskId);
actions.push({
kind: "start_timer",
seconds: 300,
taskId: surfaceTarget === "task" ? surfaceTaskId : null,
label: selectedTask?.text ?? "5-minute timer",
});
}
if (speakLine.trim()) {
actions.push({ kind: "speak_line", text: speakLine.trim() });
}
return actions;
}
function canSave() {
if (saving) return false;
if (triggerKind === "time_of_day" && !/^\d{2}:\d{2}$/.test(triggerTime)) return false;
if (surfaceEnabled && surfaceTarget === "task" && !surfaceTaskId) return false;
return builtActions().length > 0;
}
async function saveRule() {
if (!canSave()) return;
saving = true;
error = "";
try {
await createImplementationRule({
enabled: true,
triggerKind,
triggerValue: triggerValue(),
actions: builtActions(),
lastFiredKey: triggerKind === "time_of_day"
? initialLastFiredKeyForTimeRule(triggerTime)
: null,
});
toasts.success("Rule saved");
triggerKind = "time_of_day";
triggerTime = "09:00";
surfaceEnabled = true;
surfaceTarget = "inbox";
surfaceTaskId = "";
startTimer = false;
speakLine = "";
} catch (err) {
error = String(err);
} finally {
saving = false;
}
}
async function toggleRule(rule, enabled) {
try {
await setImplementationRuleEnabled(rule.id, enabled);
} catch (err) {
toasts.warn("Could not update rule", String(err));
}
}
async function removeRule(rule) {
try {
await deleteImplementationRule(rule.id);
} catch (err) {
toasts.warn("Could not delete rule", String(err));
}
}
</script>
<div class="space-y-5">
<div>
<p class="text-[11px] text-text-tertiary mb-4">
Build simple if-then rules. They respect Mute for now in Nudges, and everything stays local.
</p>
<div class="rounded-lg border border-border-subtle bg-bg-input p-4">
<div class="grid grid-cols-1 md:grid-cols-[120px_1fr] gap-3 items-start">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider pt-2">If</p>
<div class="flex flex-wrap gap-2">
<select
class="bg-bg border border-border rounded-lg px-3 py-2 text-[12px] text-text focus:border-accent focus:outline-none"
bind:value={triggerKind}
>
<option value="time_of_day">time of day</option>
<option value="task_completed">a task completes</option>
<option value="morning_triage_finished">morning triage finishes</option>
</select>
{#if triggerKind === "time_of_day"}
<input
type="time"
class="bg-bg border border-border rounded-lg px-3 py-2 text-[12px] text-text focus:border-accent focus:outline-none"
bind:value={triggerTime}
/>
{/if}
</div>
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider pt-2">Then</p>
<div class="space-y-3">
<div class="flex flex-col gap-2">
<Toggle
bind:checked={surfaceEnabled}
label="Surface a list or task"
description="Open Tasks and put the chosen item back in view."
/>
{#if surfaceEnabled}
<div class="flex flex-wrap gap-2 pl-1">
<select
class="bg-bg border border-border rounded-lg px-3 py-2 text-[12px] text-text focus:border-accent focus:outline-none"
bind:value={surfaceTarget}
>
<option value="inbox">Inbox</option>
<option value="today">Today</option>
<option value="tasks">All tasks</option>
<option value="task">Specific task</option>
</select>
{#if surfaceTarget === "task"}
<select
class="min-w-[220px] bg-bg border border-border rounded-lg px-3 py-2 text-[12px] text-text focus:border-accent focus:outline-none"
bind:value={surfaceTaskId}
>
<option value="">Choose task…</option>
{#each activeTasks as task (task.id)}
<option value={task.id}>{task.text}</option>
{/each}
</select>
{/if}
</div>
{/if}
</div>
<Toggle
bind:checked={startTimer}
label="Start a 5-minute timer"
description="Use the same focus timer as micro-steps."
/>
<div>
<label for="rule-speak-line" class="block text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">
Speak aloud
</label>
<input
id="rule-speak-line"
type="text"
maxlength="240"
class="w-full bg-bg border border-border rounded-lg px-3 py-2 text-[12px] text-text placeholder:text-text-tertiary focus:border-accent focus:outline-none"
placeholder="Optional line, e.g. time to plan the day"
bind:value={speakLine}
/>
</div>
</div>
</div>
{#if error}
<p class="text-[11px] text-danger mt-3">{error}</p>
{/if}
<div class="flex justify-end mt-4">
<button
type="button"
class="px-4 py-2 rounded-lg text-[12px] font-medium
{canSave()
? 'bg-accent text-bg hover:bg-accent-hover'
: 'bg-bg-elevated text-text-tertiary cursor-not-allowed'}"
disabled={!canSave()}
onclick={saveRule}
>
{saving ? "Saving…" : "Save rule"}
</button>
</div>
</div>
</div>
<div>
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Saved rules</p>
{#if loading}
<p class="text-[11px] text-text-tertiary">Loading…</p>
{:else if implementationRules.length === 0}
<p class="text-[11px] text-text-tertiary italic">No rules yet.</p>
{:else}
<div class="space-y-2">
{#each implementationRules as rule (rule.id)}
<div class="flex items-center gap-3 rounded-lg border border-border-subtle bg-bg-input px-3 py-2">
<button
class="relative w-[34px] min-w-[34px] h-[20px] rounded-full flex-shrink-0
{rule.enabled ? 'bg-accent' : 'bg-bg-elevated'}"
role="switch"
aria-checked={rule.enabled}
aria-label={rule.enabled ? 'Disable rule' : 'Enable rule'}
onclick={() => toggleRule(rule, !rule.enabled)}
>
<span
class="absolute top-[3px] left-[3px] w-3.5 h-3.5 rounded-full bg-white shadow-sm
{rule.enabled ? 'translate-x-[14px]' : 'translate-x-0'}"
style="transition: transform var(--duration-ui)"
></span>
</button>
<p class="flex-1 min-w-0 text-[12px] text-text-secondary leading-snug">
{ruleSummary(rule)}
</p>
<button
class="text-text-tertiary hover:text-danger"
aria-label="Delete rule"
title="Delete rule"
onclick={() => removeRule(rule)}
>
<Trash2 size={14} aria-hidden="true" />
</button>
</div>
{/each}
</div>
{/if}
</div>
</div>