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.
285 lines
9.4 KiB
Svelte
285 lines
9.4 KiB
Svelte
<script lang="ts">
|
|
// @ts-nocheck
|
|
// Phase 5 morning triage. Surfaces a calm "pick up to three for today"
|
|
// modal on the first launch-of-day once the user-set trigger time has
|
|
// passed. Evidence-based "rule of 3" per Life Skills Advocate / Aspire
|
|
// Therapy ADHD routine literature: externalise the daily choice and
|
|
// cap it at 3 to protect working memory (Sweller cognitive-load theory,
|
|
// Barkley's point-of-performance principle).
|
|
//
|
|
// Copy audit: no "overdue", no "failed", no subtractive framing (RSD).
|
|
//
|
|
// Triggering: runs a lightweight check on mount and when the page
|
|
// regains focus. Only one modal per calendar day regardless of how
|
|
// many times the app is restarted.
|
|
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { hasTauriRuntime } from '$lib/utils/runtime.js';
|
|
import { settings } from '$lib/stores/page.svelte.js';
|
|
import { toasts } from '$lib/stores/toasts.svelte.js';
|
|
|
|
interface TriageTask {
|
|
id: string;
|
|
text: string;
|
|
bucket: string;
|
|
done: boolean;
|
|
createdAt: string;
|
|
}
|
|
|
|
let open = $state(false);
|
|
let loading = $state(false);
|
|
let tasks = $state<TriageTask[]>([]);
|
|
let selected = $state<Set<string>>(new Set());
|
|
let tooManyFlash = $state(false);
|
|
let applying = $state(false);
|
|
let focusHandler: (() => void) | null = null;
|
|
|
|
function todayKey(): string {
|
|
const d = new Date();
|
|
const y = d.getFullYear();
|
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
const day = String(d.getDate()).padStart(2, '0');
|
|
return `${y}-${m}-${day}`;
|
|
}
|
|
|
|
// Parse a HH:MM string into minutes-since-midnight. Falls back to
|
|
// 08:00 on malformed input rather than throwing — rituals must never
|
|
// break the app shell.
|
|
function parseTriggerMinutes(hhmm: string | undefined): number {
|
|
if (typeof hhmm !== 'string') return 8 * 60;
|
|
const match = /^(\d{1,2}):(\d{2})$/.exec(hhmm.trim());
|
|
if (!match) return 8 * 60;
|
|
const h = Math.max(0, Math.min(23, parseInt(match[1], 10)));
|
|
const m = Math.max(0, Math.min(59, parseInt(match[2], 10)));
|
|
return h * 60 + m;
|
|
}
|
|
|
|
function currentMinutes(): number {
|
|
const d = new Date();
|
|
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
|
|
// as "yesterday" regardless of the UTC offset.
|
|
const d = new Date(createdAt);
|
|
if (Number.isNaN(d.getTime())) return false;
|
|
const local = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
return local < todayKey();
|
|
}
|
|
|
|
async function maybeShow() {
|
|
if (open || applying) return;
|
|
if (!settings.ritualsMorning) return;
|
|
if (!hasTauriRuntime()) return;
|
|
if (currentMinutes() < parseTriggerMinutes(settings.ritualsMorningTime)) return;
|
|
|
|
let lastShown: string | null = null;
|
|
try {
|
|
lastShown = await invoke<string | null>('get_last_morning_triage');
|
|
} catch {
|
|
// Non-fatal: if we can't read the sentinel, treat as never-shown.
|
|
}
|
|
if (lastShown === todayKey()) return;
|
|
|
|
loading = true;
|
|
try {
|
|
const all = await invoke<TriageTask[]>('list_tasks_cmd');
|
|
tasks = all.filter(
|
|
(t) => !t.done && t.bucket !== 'today' && isBeforeToday(t.createdAt),
|
|
);
|
|
} catch {
|
|
tasks = [];
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
|
|
if (tasks.length === 0) {
|
|
// 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;
|
|
}
|
|
|
|
selected = new Set();
|
|
open = true;
|
|
}
|
|
|
|
function toggle(taskId: string) {
|
|
if (selected.has(taskId)) {
|
|
const next = new Set(selected);
|
|
next.delete(taskId);
|
|
selected = next;
|
|
return;
|
|
}
|
|
if (selected.size >= 3) {
|
|
tooManyFlash = true;
|
|
setTimeout(() => { tooManyFlash = false; }, 1800);
|
|
return;
|
|
}
|
|
const next = new Set(selected);
|
|
next.add(taskId);
|
|
selected = next;
|
|
}
|
|
|
|
async function skipForToday() {
|
|
applying = true;
|
|
try {
|
|
await invoke('mark_morning_triage_shown', { date: todayKey() });
|
|
} catch (err) {
|
|
toasts.warn('Could not save triage state', String(err));
|
|
} finally {
|
|
applying = false;
|
|
open = false;
|
|
dispatchTriageFinished('skipped');
|
|
}
|
|
}
|
|
|
|
async function startTheDay() {
|
|
if (selected.size === 0) return;
|
|
applying = true;
|
|
try {
|
|
for (const id of selected) {
|
|
try {
|
|
await invoke('update_task_cmd', {
|
|
id,
|
|
patch: { bucket: 'today' },
|
|
});
|
|
} catch (err) {
|
|
// Continue the loop — surface a single toast at the end rather
|
|
// than one per failure, so the user isn't drowned in errors.
|
|
console.warn('Triage: failed to move task', id, err);
|
|
}
|
|
}
|
|
await invoke('mark_morning_triage_shown', { date: todayKey() });
|
|
} catch (err) {
|
|
toasts.warn('Could not save triage state', String(err));
|
|
} finally {
|
|
applying = false;
|
|
open = false;
|
|
dispatchTriageFinished('picked');
|
|
}
|
|
}
|
|
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
if (!open) return;
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
skipForToday();
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
maybeShow();
|
|
focusHandler = () => maybeShow();
|
|
window.addEventListener('focus', focusHandler);
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (focusHandler) window.removeEventListener('focus', focusHandler);
|
|
});
|
|
</script>
|
|
|
|
<svelte:window onkeydown={handleKeydown} />
|
|
|
|
{#if open}
|
|
<div
|
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="triage-title"
|
|
>
|
|
<div class="bg-bg-elevated border border-border rounded-2xl shadow-2xl max-w-[480px] w-[90vw] max-h-[80vh] flex flex-col">
|
|
<div class="px-6 pt-6 pb-3">
|
|
<h2 id="triage-title" class="font-display text-[22px] italic text-text">Pick up to three for today</h2>
|
|
<p class="text-[12px] text-text-secondary mt-1">
|
|
Yesterday's open items. The rest can wait.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="flex-1 overflow-y-auto px-6 pb-3 min-h-0">
|
|
{#if loading}
|
|
<p class="text-[12px] text-text-tertiary py-6 text-center">Loading your list…</p>
|
|
{:else}
|
|
<ul class="flex flex-col gap-1.5">
|
|
{#each tasks as task (task.id)}
|
|
{@const picked = selected.has(task.id)}
|
|
<li>
|
|
<button
|
|
type="button"
|
|
class="w-full text-left flex items-start gap-3 px-3 py-2 rounded-lg border transition-colors
|
|
{picked
|
|
? 'bg-accent/10 border-accent text-text'
|
|
: 'bg-bg-input border-border-subtle text-text-secondary hover:border-border'}"
|
|
onclick={() => toggle(task.id)}
|
|
aria-pressed={picked}
|
|
>
|
|
<span
|
|
class="mt-0.5 w-4 h-4 rounded-sm border flex items-center justify-center flex-shrink-0
|
|
{picked ? 'bg-accent border-accent text-white' : 'border-border'}"
|
|
aria-hidden="true"
|
|
>
|
|
{#if picked}
|
|
<svg viewBox="0 0 24 24" class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="3">
|
|
<path d="M5 12l5 5L20 7" stroke-linecap="round" stroke-linejoin="round" />
|
|
</svg>
|
|
{/if}
|
|
</span>
|
|
<span class="text-[13px] leading-snug">{task.text}</span>
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="px-6 pb-5 pt-2">
|
|
<p
|
|
class="text-[11px] mb-3 min-h-[16px] transition-colors
|
|
{tooManyFlash ? 'text-warning' : 'text-text-tertiary'}"
|
|
aria-live="polite"
|
|
>
|
|
{#if tooManyFlash}
|
|
Just three for today. Unpick one to swap.
|
|
{:else if selected.size > 0}
|
|
{selected.size} picked · room for {3 - selected.size} more
|
|
{:else}
|
|
Pick 1, 2, or 3.
|
|
{/if}
|
|
</p>
|
|
<div class="flex items-center justify-between gap-3">
|
|
<button
|
|
type="button"
|
|
class="px-3 py-2 text-[12px] text-text-tertiary hover:text-text"
|
|
onclick={skipForToday}
|
|
disabled={applying}
|
|
>
|
|
Skip for today
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="px-4 py-2 rounded-lg text-[12px] font-medium transition-colors
|
|
{selected.size >= 1 && !applying
|
|
? 'bg-accent text-white hover:bg-accent-hover'
|
|
: 'bg-bg-input text-text-tertiary cursor-not-allowed'}"
|
|
onclick={startTheDay}
|
|
disabled={selected.size === 0 || applying}
|
|
>
|
|
{applying ? 'Saving…' : 'Start the day'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|