feat(intentions): Phase 7 — if-then rules for task / time / triage triggers
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:
266
src/lib/components/ImplementationRulesEditor.svelte
Normal file
266
src/lib/components/ImplementationRulesEditor.svelte
Normal 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>
|
||||
@@ -60,6 +60,13 @@
|
||||
return d.getHours() * 60 + d.getMinutes();
|
||||
}
|
||||
|
||||
function dispatchTriageFinished(mode: 'empty' | 'skipped' | 'picked') {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(new CustomEvent('kon:morning-triage-finished', {
|
||||
detail: { date: todayKey(), mode },
|
||||
}));
|
||||
}
|
||||
|
||||
function isBeforeToday(createdAt: string): boolean {
|
||||
// Task `createdAt` comes from SQLite as ISO-8601 UTC. Compare the
|
||||
// local-time date portion so a task made last night locally counts
|
||||
@@ -100,6 +107,7 @@
|
||||
// Nothing to triage — record the shown sentinel anyway so we
|
||||
// don't re-check the DB every focus event today.
|
||||
try { await invoke('mark_morning_triage_shown', { date: todayKey() }); } catch {}
|
||||
dispatchTriageFinished('empty');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -133,6 +141,7 @@
|
||||
} finally {
|
||||
applying = false;
|
||||
open = false;
|
||||
dispatchTriageFinished('skipped');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +167,7 @@
|
||||
} finally {
|
||||
applying = false;
|
||||
open = false;
|
||||
dispatchTriageFinished('picked');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import Toggle from "$lib/components/Toggle.svelte";
|
||||
import SegmentedButton from "$lib/components/SegmentedButton.svelte";
|
||||
import HotkeyRecorder from "$lib/components/HotkeyRecorder.svelte";
|
||||
import ImplementationRulesEditor from "$lib/components/ImplementationRulesEditor.svelte";
|
||||
import ZonePicker from "$lib/components/ZonePicker.svelte";
|
||||
import AccessibilityControls from "$lib/components/AccessibilityControls.svelte";
|
||||
import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js";
|
||||
@@ -1571,6 +1572,22 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- If-then rules (Phase 7 roadmap) -->
|
||||
<div class="border-b border-border-subtle">
|
||||
<button
|
||||
class="flex items-center justify-between w-full py-4 px-5 text-left"
|
||||
onclick={() => openSection = openSection === 'rules' ? null : 'rules'}
|
||||
>
|
||||
<h3 class="font-display text-[18px] italic text-text">If-then rules</h3>
|
||||
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'rules' ? '−' : '+'}</span>
|
||||
</button>
|
||||
{#if openSection === 'rules'}
|
||||
<div class="px-5 pb-5 animate-fade-in">
|
||||
<ImplementationRulesEditor />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Read aloud (Phase 4 roadmap) -->
|
||||
<div class="border-b border-border-subtle">
|
||||
<button
|
||||
|
||||
260
src/lib/stores/implementationIntentions.svelte.ts
Normal file
260
src/lib/stores/implementationIntentions.svelte.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
// Phase 7 implementation intentions. Rules are persisted in SQLite via
|
||||
// Tauri commands; this frontend runner binds them to the Phase 6 event
|
||||
// bus and executes their small local actions.
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
ImplementationRule,
|
||||
ImplementationRuleAction,
|
||||
ImplementationRuleDraft,
|
||||
} from "$lib/types/app";
|
||||
import { hasTauriRuntime } from "$lib/utils/runtime.js";
|
||||
import { page, settings, tasks } from "$lib/stores/page.svelte.js";
|
||||
import { toasts } from "$lib/stores/toasts.svelte.js";
|
||||
|
||||
const TIME_RULE_POLL_MS = 30_000;
|
||||
const RULES_CHANGED_EVENT = "kon:implementation-rules-changed";
|
||||
|
||||
export const implementationRules = $state<ImplementationRule[]>([]);
|
||||
|
||||
let loaded = false;
|
||||
let started = false;
|
||||
let timePollHandle: ReturnType<typeof setInterval> | null = null;
|
||||
const firingRules = new Set<string>();
|
||||
|
||||
function todayLocalKey(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function currentHHMM(): string {
|
||||
const d = new Date();
|
||||
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function hasTimeAlreadyPassed(hhmm: string): boolean {
|
||||
return currentHHMM() >= hhmm;
|
||||
}
|
||||
|
||||
export function initialLastFiredKeyForTimeRule(hhmm: string): string | null {
|
||||
return hasTimeAlreadyPassed(hhmm) ? `${todayLocalKey()}@${hhmm}` : null;
|
||||
}
|
||||
|
||||
function replaceRule(next: ImplementationRule): void {
|
||||
const idx = implementationRules.findIndex((rule) => rule.id === next.id);
|
||||
if (idx >= 0) implementationRules[idx] = next;
|
||||
else implementationRules.unshift(next);
|
||||
}
|
||||
|
||||
function broadcastRulesChanged(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(new CustomEvent(RULES_CHANGED_EVENT));
|
||||
}
|
||||
|
||||
export async function loadImplementationRules(force = false): Promise<void> {
|
||||
if (!hasTauriRuntime()) {
|
||||
implementationRules.length = 0;
|
||||
loaded = true;
|
||||
return;
|
||||
}
|
||||
if (loaded && !force) return;
|
||||
const rows = await invoke<ImplementationRule[]>("list_implementation_rules");
|
||||
implementationRules.splice(0, implementationRules.length, ...rows);
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
export async function createImplementationRule(
|
||||
draft: ImplementationRuleDraft,
|
||||
): Promise<ImplementationRule> {
|
||||
const row = await invoke<ImplementationRule>("create_implementation_rule", { request: draft });
|
||||
replaceRule(row);
|
||||
broadcastRulesChanged();
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function setImplementationRuleEnabled(id: string, enabled: boolean): Promise<void> {
|
||||
const row = await invoke<ImplementationRule>("set_implementation_rule_enabled", { id, enabled });
|
||||
replaceRule(row);
|
||||
broadcastRulesChanged();
|
||||
}
|
||||
|
||||
export async function deleteImplementationRule(id: string): Promise<void> {
|
||||
await invoke("delete_implementation_rule", { id });
|
||||
const idx = implementationRules.findIndex((rule) => rule.id === id);
|
||||
if (idx >= 0) implementationRules.splice(idx, 1);
|
||||
broadcastRulesChanged();
|
||||
}
|
||||
|
||||
async function markRuleFired(rule: ImplementationRule, firedKey: string): Promise<void> {
|
||||
const row = await invoke<ImplementationRule>("mark_implementation_rule_fired", {
|
||||
id: rule.id,
|
||||
firedKey,
|
||||
});
|
||||
replaceRule(row);
|
||||
}
|
||||
|
||||
function actionLabel(action: ImplementationRuleAction): string {
|
||||
if (action.kind === "speak_line") return `Speak "${action.text}"`;
|
||||
if (action.kind === "start_timer") return "Start a 5-minute timer";
|
||||
if (action.kind === "surface") {
|
||||
if (action.target === "task") return `Surface ${action.label || "task"}`;
|
||||
if (action.target === "inbox") return "Surface inbox";
|
||||
if (action.target === "today") return "Surface today";
|
||||
return "Surface tasks";
|
||||
}
|
||||
return "Action";
|
||||
}
|
||||
|
||||
export function ruleSummary(rule: ImplementationRule): string {
|
||||
const when = rule.triggerKind === "time_of_day"
|
||||
? `At ${rule.triggerValue}`
|
||||
: rule.triggerKind === "task_completed"
|
||||
? "After a task completes"
|
||||
: "After morning triage";
|
||||
return `${when} -> ${rule.actions.map(actionLabel).join(" + ")}`;
|
||||
}
|
||||
|
||||
function surfaceAction(action: Extract<ImplementationRuleAction, { kind: "surface" }>): void {
|
||||
if (action.target === "task" && action.taskId) {
|
||||
const task = tasks.find((candidate) => candidate.id === action.taskId);
|
||||
if (!task) {
|
||||
// The rule outlived its target. Don't fake a success toast —
|
||||
// surface the full list so the user can still act, and tell
|
||||
// them why the specific task isn't there. Cached label used
|
||||
// only for identification, not as a claim of surfacing.
|
||||
page.current = "tasks";
|
||||
page.taskSidebarOpen = true;
|
||||
const cachedLabel = action.label?.trim();
|
||||
toasts.warn(
|
||||
"Rule target missing",
|
||||
cachedLabel
|
||||
? `The task "${cachedLabel}" is gone — edit the rule to point at something else.`
|
||||
: "That rule's task is gone — edit the rule to point at something else.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
page.current = "tasks";
|
||||
page.taskSidebarOpen = true;
|
||||
toasts.info("Task ready", task.text);
|
||||
return;
|
||||
}
|
||||
|
||||
page.current = "tasks";
|
||||
page.taskSidebarOpen = true;
|
||||
|
||||
if (action.target === "inbox") {
|
||||
toasts.info("Inbox is here", "Your task list is open.");
|
||||
} else if (action.target === "today") {
|
||||
toasts.info("Today's list is here", "Your task list is open.");
|
||||
} else {
|
||||
toasts.info("Tasks are here", "Your list is open.");
|
||||
}
|
||||
}
|
||||
|
||||
async function executeAction(action: ImplementationRuleAction): Promise<void> {
|
||||
if (action.kind === "surface") {
|
||||
surfaceAction(action);
|
||||
return;
|
||||
}
|
||||
if (action.kind === "start_timer") {
|
||||
window.dispatchEvent(new CustomEvent("kon:start-timer", {
|
||||
detail: {
|
||||
taskId: action.taskId ?? null,
|
||||
seconds: 300,
|
||||
label: action.label ?? "5-minute timer",
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (action.kind === "speak_line") {
|
||||
await invoke("tts_speak", {
|
||||
text: action.text,
|
||||
rate: settings.ttsRate,
|
||||
voice: settings.ttsVoice ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function fireRule(rule: ImplementationRule, firedKey?: string): Promise<void> {
|
||||
if (!rule.enabled) return;
|
||||
if (settings.nudgesMuted) return;
|
||||
if (firedKey && rule.lastFiredKey === firedKey) return;
|
||||
if (firingRules.has(rule.id)) return;
|
||||
|
||||
firingRules.add(rule.id);
|
||||
try {
|
||||
for (const action of rule.actions) {
|
||||
try {
|
||||
await executeAction(action);
|
||||
} catch {
|
||||
// Rule actions are assistance, not critical workflow. Keep going
|
||||
// so a missing TTS voice does not block surfacing a task.
|
||||
}
|
||||
}
|
||||
if (firedKey) {
|
||||
await markRuleFired(rule, firedKey);
|
||||
}
|
||||
} finally {
|
||||
firingRules.delete(rule.id);
|
||||
}
|
||||
}
|
||||
|
||||
function timeRuleFiredKey(rule: ImplementationRule): string {
|
||||
return `${todayLocalKey()}@${rule.triggerValue}`;
|
||||
}
|
||||
|
||||
async function checkTimeRules(): Promise<void> {
|
||||
if (!loaded) await loadImplementationRules();
|
||||
for (const rule of implementationRules) {
|
||||
if (rule.triggerKind !== "time_of_day") continue;
|
||||
if (!rule.enabled) continue;
|
||||
if (currentHHMM() < rule.triggerValue) continue;
|
||||
const firedKey = timeRuleFiredKey(rule);
|
||||
if (rule.lastFiredKey === firedKey) continue;
|
||||
await fireRule(rule, firedKey);
|
||||
}
|
||||
}
|
||||
|
||||
function onTaskCompleted(): void {
|
||||
for (const rule of implementationRules) {
|
||||
if (rule.triggerKind === "task_completed") void fireRule(rule);
|
||||
}
|
||||
}
|
||||
|
||||
function onMorningTriageFinished(): void {
|
||||
for (const rule of implementationRules) {
|
||||
if (rule.triggerKind === "morning_triage_finished") void fireRule(rule);
|
||||
}
|
||||
}
|
||||
|
||||
function onRulesChanged(): void {
|
||||
void checkTimeRules();
|
||||
}
|
||||
|
||||
export function startImplementationIntentions(): void {
|
||||
if (started) return;
|
||||
if (typeof window === "undefined") return;
|
||||
started = true;
|
||||
|
||||
void loadImplementationRules(true)
|
||||
.then(() => checkTimeRules())
|
||||
.catch(() => {});
|
||||
window.addEventListener("kon:task-completed", onTaskCompleted);
|
||||
window.addEventListener("kon:morning-triage-finished", onMorningTriageFinished);
|
||||
window.addEventListener(RULES_CHANGED_EVENT, onRulesChanged);
|
||||
timePollHandle = setInterval(() => {
|
||||
void checkTimeRules().catch(() => {});
|
||||
}, TIME_RULE_POLL_MS);
|
||||
}
|
||||
|
||||
export function stopImplementationIntentions(): void {
|
||||
if (!started) return;
|
||||
started = false;
|
||||
window.removeEventListener("kon:task-completed", onTaskCompleted);
|
||||
window.removeEventListener("kon:morning-triage-finished", onMorningTriageFinished);
|
||||
window.removeEventListener(RULES_CHANGED_EVENT, onRulesChanged);
|
||||
if (timePollHandle !== null) {
|
||||
clearInterval(timePollHandle);
|
||||
timePollHandle = null;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,11 @@ export type LlmPromptPreset = "default" | "email" | "notes" | "code";
|
||||
export type AiGpuConcurrency = "parallel" | "sequential";
|
||||
export type TaskBucket = "inbox" | "today" | "soon" | "later";
|
||||
export type ToastSeverity = "info" | "success" | "warn" | "error";
|
||||
export type ImplementationRuleTriggerKind =
|
||||
| "time_of_day"
|
||||
| "task_completed"
|
||||
| "morning_triage_finished";
|
||||
export type ImplementationRuleSurfaceTarget = "inbox" | "today" | "tasks" | "task";
|
||||
|
||||
export interface PageState {
|
||||
current: string;
|
||||
@@ -249,6 +254,43 @@ export interface TaskEntry {
|
||||
energy: EnergyLevel | null;
|
||||
}
|
||||
|
||||
export type ImplementationRuleAction =
|
||||
| {
|
||||
kind: "surface";
|
||||
target: ImplementationRuleSurfaceTarget;
|
||||
taskId?: string | null;
|
||||
label?: string | null;
|
||||
}
|
||||
| {
|
||||
kind: "start_timer";
|
||||
seconds: number;
|
||||
taskId?: string | null;
|
||||
label?: string | null;
|
||||
}
|
||||
| {
|
||||
kind: "speak_line";
|
||||
text: string;
|
||||
};
|
||||
|
||||
export interface ImplementationRule {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
triggerKind: ImplementationRuleTriggerKind;
|
||||
triggerValue: string;
|
||||
actions: ImplementationRuleAction[];
|
||||
lastFiredKey: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ImplementationRuleDraft {
|
||||
enabled?: boolean;
|
||||
triggerKind: ImplementationRuleTriggerKind;
|
||||
triggerValue: string;
|
||||
actions: ImplementationRuleAction[];
|
||||
lastFiredKey?: string | null;
|
||||
}
|
||||
|
||||
export interface TaskDraft {
|
||||
text: string;
|
||||
bucket?: TaskBucket;
|
||||
|
||||
Reference in New Issue
Block a user