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:
@@ -524,6 +524,125 @@ pub async fn delete_task(pool: &SqlitePool, id: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Implementation intentions ---
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn insert_implementation_rule(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
id: &str,
|
||||||
|
enabled: bool,
|
||||||
|
trigger_kind: &str,
|
||||||
|
trigger_value: &str,
|
||||||
|
actions_json: &str,
|
||||||
|
last_fired_key: Option<&str>,
|
||||||
|
) -> Result<ImplementationRuleRow> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO implementation_rules (
|
||||||
|
id, enabled, trigger_kind, trigger_value, actions_json, last_fired_key
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(enabled as i64)
|
||||||
|
.bind(trigger_kind)
|
||||||
|
.bind(trigger_value)
|
||||||
|
.bind(actions_json)
|
||||||
|
.bind(last_fired_key)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| KonError::StorageError(format!("Insert implementation rule failed: {e}")))?;
|
||||||
|
|
||||||
|
get_implementation_rule(pool, id).await?.ok_or_else(|| {
|
||||||
|
KonError::StorageError(format!(
|
||||||
|
"insert_implementation_rule: rule {id} not found after insert"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_implementation_rules(pool: &SqlitePool) -> Result<Vec<ImplementationRuleRow>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT id, enabled, trigger_kind, trigger_value, actions_json, last_fired_key, \
|
||||||
|
created_at, updated_at \
|
||||||
|
FROM implementation_rules ORDER BY created_at DESC",
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| KonError::StorageError(format!("List implementation rules failed: {e}")))?;
|
||||||
|
|
||||||
|
Ok(rows.into_iter().map(implementation_rule_row_from).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_implementation_rule(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
id: &str,
|
||||||
|
) -> Result<Option<ImplementationRuleRow>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT id, enabled, trigger_kind, trigger_value, actions_json, last_fired_key, \
|
||||||
|
created_at, updated_at \
|
||||||
|
FROM implementation_rules WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| KonError::StorageError(format!("Get implementation rule failed: {e}")))?;
|
||||||
|
|
||||||
|
Ok(row.map(implementation_rule_row_from))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_implementation_rule_enabled(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
id: &str,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<ImplementationRuleRow> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE implementation_rules
|
||||||
|
SET enabled = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(enabled as i64)
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| KonError::StorageError(format!("Set implementation rule enabled failed: {e}")))?;
|
||||||
|
|
||||||
|
get_implementation_rule(pool, id).await?.ok_or_else(|| {
|
||||||
|
KonError::StorageError(format!(
|
||||||
|
"set_implementation_rule_enabled: rule {id} not found after update"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mark_implementation_rule_fired(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
id: &str,
|
||||||
|
last_fired_key: &str,
|
||||||
|
) -> Result<ImplementationRuleRow> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE implementation_rules
|
||||||
|
SET last_fired_key = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(last_fired_key)
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| KonError::StorageError(format!("Mark implementation rule fired failed: {e}")))?;
|
||||||
|
|
||||||
|
get_implementation_rule(pool, id).await?.ok_or_else(|| {
|
||||||
|
KonError::StorageError(format!(
|
||||||
|
"mark_implementation_rule_fired: rule {id} not found after update"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_implementation_rule(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM implementation_rules WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| KonError::StorageError(format!("Delete implementation rule failed: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// --- Settings CRUD ---
|
// --- Settings CRUD ---
|
||||||
|
|
||||||
pub async fn set_setting(pool: &SqlitePool, key: &str, value: &str) -> Result<()> {
|
pub async fn set_setting(pool: &SqlitePool, key: &str, value: &str) -> Result<()> {
|
||||||
@@ -612,6 +731,18 @@ pub struct TaskRow {
|
|||||||
pub energy: Option<String>,
|
pub energy: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ImplementationRuleRow {
|
||||||
|
pub id: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub trigger_kind: String,
|
||||||
|
pub trigger_value: String,
|
||||||
|
pub actions_json: String,
|
||||||
|
pub last_fired_key: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow {
|
fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow {
|
||||||
TranscriptRow {
|
TranscriptRow {
|
||||||
id: r.get("id"),
|
id: r.get("id"),
|
||||||
@@ -676,6 +807,19 @@ fn task_row_from(r: sqlx::sqlite::SqliteRow) -> TaskRow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn implementation_rule_row_from(r: sqlx::sqlite::SqliteRow) -> ImplementationRuleRow {
|
||||||
|
ImplementationRuleRow {
|
||||||
|
id: r.get("id"),
|
||||||
|
enabled: r.get::<i64, _>("enabled") != 0,
|
||||||
|
trigger_kind: r.get("trigger_kind"),
|
||||||
|
trigger_value: r.get("trigger_value"),
|
||||||
|
actions_json: r.get("actions_json"),
|
||||||
|
last_fired_key: r.get("last_fired_key"),
|
||||||
|
created_at: r.get("created_at"),
|
||||||
|
updated_at: r.get("updated_at"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Profile CRUD (Phase 2 Task 11) ---
|
// --- Profile CRUD (Phase 2 Task 11) ---
|
||||||
//
|
//
|
||||||
// Profiles partition the per-user transcription dictionary so that a single
|
// Profiles partition the per-user transcription dictionary so that a single
|
||||||
@@ -1318,6 +1462,46 @@ mod tests {
|
|||||||
assert!(b.done, "sibling must be untouched");
|
assert!(b.done, "sibling must be untouched");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn implementation_rule_crud_roundtrip() {
|
||||||
|
let pool = test_pool().await;
|
||||||
|
|
||||||
|
let rule = insert_implementation_rule(
|
||||||
|
&pool,
|
||||||
|
"rule-1",
|
||||||
|
true,
|
||||||
|
"time_of_day",
|
||||||
|
"09:00",
|
||||||
|
r#"[{"kind":"speak_line","text":"time to plan the day"}]"#,
|
||||||
|
Some("2026-04-24"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(rule.id, "rule-1");
|
||||||
|
assert!(rule.enabled);
|
||||||
|
assert_eq!(rule.trigger_kind, "time_of_day");
|
||||||
|
assert_eq!(rule.trigger_value, "09:00");
|
||||||
|
assert_eq!(rule.last_fired_key.as_deref(), Some("2026-04-24"));
|
||||||
|
|
||||||
|
let rules = list_implementation_rules(&pool).await.unwrap();
|
||||||
|
assert_eq!(rules.len(), 1);
|
||||||
|
assert_eq!(rules[0].actions_json, rule.actions_json);
|
||||||
|
|
||||||
|
let disabled = set_implementation_rule_enabled(&pool, "rule-1", false)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!disabled.enabled);
|
||||||
|
|
||||||
|
let fired = mark_implementation_rule_fired(&pool, "rule-1", "2026-04-25")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(fired.last_fired_key.as_deref(), Some("2026-04-25"));
|
||||||
|
|
||||||
|
delete_implementation_rule(&pool, "rule-1").await.unwrap();
|
||||||
|
assert!(list_implementation_rules(&pool).await.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn update_transcript_meta_happy_path() {
|
async fn update_transcript_meta_happy_path() {
|
||||||
// Task 2.5 — insert a transcript, update starred=true, read it back.
|
// Task 2.5 — insert a transcript, update starred=true, read it back.
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ pub const DEFAULT_PROFILE_ID: &str = "00000000-0000-0000-0000-000000000001";
|
|||||||
|
|
||||||
pub use database::{
|
pub use database::{
|
||||||
add_profile_term, complete_subtask_and_check_parent, complete_task, count_transcripts,
|
add_profile_term, complete_subtask_and_check_parent, complete_task, count_transcripts,
|
||||||
create_profile, delete_profile, delete_profile_term, delete_task, delete_transcript,
|
create_profile, delete_implementation_rule, delete_profile, delete_profile_term, delete_task,
|
||||||
get_profile, get_setting, get_task_by_id, get_transcript, init, insert_subtask, insert_task,
|
delete_transcript, get_implementation_rule, get_profile, get_setting, get_task_by_id,
|
||||||
insert_transcript, list_feedback_examples, list_profile_terms, list_profiles,
|
get_transcript, init, insert_implementation_rule, insert_subtask, insert_task,
|
||||||
list_recent_errors, list_subtasks, list_tasks, list_transcripts, list_transcripts_paged,
|
insert_transcript, list_feedback_examples, list_implementation_rules, list_profile_terms,
|
||||||
log_error, record_feedback, search_transcripts, set_setting, set_task_energy, uncomplete_task,
|
list_profiles, list_recent_errors, list_subtasks, list_tasks, list_transcripts,
|
||||||
update_profile, update_task, update_transcript, update_transcript_meta, ErrorLogRow,
|
list_transcripts_paged, log_error, mark_implementation_rule_fired, record_feedback,
|
||||||
FeedbackRow, FeedbackTargetType, InsertTranscriptParams, ProfileRow, ProfileTermRow,
|
search_transcripts, set_implementation_rule_enabled, set_setting, set_task_energy,
|
||||||
RecordFeedbackParams, TaskRow, TranscriptRow,
|
uncomplete_task, update_profile, update_task, update_transcript, update_transcript_meta,
|
||||||
|
ErrorLogRow, FeedbackRow, FeedbackTargetType, ImplementationRuleRow, InsertTranscriptParams,
|
||||||
|
ProfileRow, ProfileTermRow, RecordFeedbackParams, TaskRow, TranscriptRow,
|
||||||
};
|
};
|
||||||
pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir};
|
pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir};
|
||||||
|
|||||||
@@ -400,6 +400,36 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
|
|||||||
ON tasks(energy, created_at DESC);
|
ON tasks(energy, created_at DESC);
|
||||||
"#,
|
"#,
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
12,
|
||||||
|
"implementation intentions: if-then automation rules",
|
||||||
|
r#"
|
||||||
|
-- Phase 7 of the feature-complete roadmap. Rules are local-only,
|
||||||
|
-- user-authored implementation intentions: "if this happens, then
|
||||||
|
-- do this small thing". Execution stays in the frontend event bus;
|
||||||
|
-- SQLite owns the durable definition and the once-per-day marker
|
||||||
|
-- for time-of-day rules.
|
||||||
|
CREATE TABLE implementation_rules (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1
|
||||||
|
CHECK (enabled IN (0, 1)),
|
||||||
|
trigger_kind TEXT NOT NULL
|
||||||
|
CHECK (trigger_kind IN (
|
||||||
|
'time_of_day',
|
||||||
|
'task_completed',
|
||||||
|
'morning_triage_finished'
|
||||||
|
)),
|
||||||
|
trigger_value TEXT NOT NULL DEFAULT '',
|
||||||
|
actions_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
last_fired_key TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_implementation_rules_enabled_trigger
|
||||||
|
ON implementation_rules(enabled, trigger_kind);
|
||||||
|
"#,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
|
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
|
||||||
@@ -549,7 +579,7 @@ mod tests {
|
|||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 11);
|
assert_eq!(count, 12);
|
||||||
|
|
||||||
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
@@ -568,7 +598,7 @@ mod tests {
|
|||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 11);
|
assert_eq!(count, 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -596,6 +626,44 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn migration_implementation_rules_adds_rule_table() {
|
||||||
|
let pool = fk_test_pool().await;
|
||||||
|
run_migrations(&pool).await.expect("migrate");
|
||||||
|
|
||||||
|
let info = sqlx::query("PRAGMA table_info(implementation_rules)")
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let names: Vec<String> = info.iter().map(|r| r.get::<String, _>("name")).collect();
|
||||||
|
for col in [
|
||||||
|
"id",
|
||||||
|
"enabled",
|
||||||
|
"trigger_kind",
|
||||||
|
"trigger_value",
|
||||||
|
"actions_json",
|
||||||
|
"last_fired_key",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
names.contains(&col.to_string()),
|
||||||
|
"implementation_rules must have {col}; got {names:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rejected = sqlx::query(
|
||||||
|
"INSERT INTO implementation_rules (id, trigger_kind, actions_json)
|
||||||
|
VALUES ('bad', 'calendar_event', '[]')",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
rejected.is_err(),
|
||||||
|
"trigger_kind CHECK constraint must reject unknown triggers"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn migration_transcripts_meta_adds_columns() {
|
async fn migration_transcripts_meta_adds_columns() {
|
||||||
// Task 2.5 — verify starred / manual_tags / template / language /
|
// Task 2.5 — verify starred / manual_tags / template / language /
|
||||||
|
|||||||
308
src-tauri/src/commands/intentions.rs
Normal file
308
src-tauri/src/commands/intentions.rs
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
//! Phase 7 implementation intentions: small if-then automation rules.
|
||||||
|
//!
|
||||||
|
//! The frontend owns scheduling and execution because the v1 triggers are
|
||||||
|
//! app-local events (timer/task/ritual) plus local clock checks. Rust owns
|
||||||
|
//! durable rule storage, validation, and main-window-only command access.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use kon_storage::{
|
||||||
|
delete_implementation_rule as db_delete_rule, get_task_by_id,
|
||||||
|
insert_implementation_rule as db_insert_rule, list_implementation_rules as db_list_rules,
|
||||||
|
mark_implementation_rule_fired as db_mark_rule_fired,
|
||||||
|
set_implementation_rule_enabled as db_set_rule_enabled, ImplementationRuleRow,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::commands::security::ensure_main_window;
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RuleTriggerKind {
|
||||||
|
TimeOfDay,
|
||||||
|
TaskCompleted,
|
||||||
|
MorningTriageFinished,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuleTriggerKind {
|
||||||
|
fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
RuleTriggerKind::TimeOfDay => "time_of_day",
|
||||||
|
RuleTriggerKind::TaskCompleted => "task_completed",
|
||||||
|
RuleTriggerKind::MorningTriageFinished => "morning_triage_finished",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SurfaceTarget {
|
||||||
|
Inbox,
|
||||||
|
Today,
|
||||||
|
Tasks,
|
||||||
|
Task,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum RuleAction {
|
||||||
|
Surface {
|
||||||
|
target: SurfaceTarget,
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde(rename = "taskId")]
|
||||||
|
task_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
label: Option<String>,
|
||||||
|
},
|
||||||
|
StartTimer {
|
||||||
|
seconds: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde(rename = "taskId")]
|
||||||
|
task_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
label: Option<String>,
|
||||||
|
},
|
||||||
|
SpeakLine {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CreateImplementationRuleRequest {
|
||||||
|
pub trigger_kind: RuleTriggerKind,
|
||||||
|
pub trigger_value: String,
|
||||||
|
pub actions: Vec<RuleAction>,
|
||||||
|
#[serde(default = "default_enabled")]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_fired_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ImplementationRuleDto {
|
||||||
|
pub id: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub trigger_kind: RuleTriggerKind,
|
||||||
|
pub trigger_value: String,
|
||||||
|
pub actions: Vec<RuleAction>,
|
||||||
|
pub last_fired_key: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<ImplementationRuleRow> for ImplementationRuleDto {
|
||||||
|
type Error = String;
|
||||||
|
|
||||||
|
fn try_from(row: ImplementationRuleRow) -> Result<Self, Self::Error> {
|
||||||
|
let trigger_kind = match row.trigger_kind.as_str() {
|
||||||
|
"time_of_day" => RuleTriggerKind::TimeOfDay,
|
||||||
|
"task_completed" => RuleTriggerKind::TaskCompleted,
|
||||||
|
"morning_triage_finished" => RuleTriggerKind::MorningTriageFinished,
|
||||||
|
other => return Err(format!("unknown rule trigger kind: {other}")),
|
||||||
|
};
|
||||||
|
let actions = serde_json::from_str::<Vec<RuleAction>>(&row.actions_json)
|
||||||
|
.map_err(|e| format!("invalid rule actions JSON for {}: {e}", row.id))?;
|
||||||
|
Ok(Self {
|
||||||
|
id: row.id,
|
||||||
|
enabled: row.enabled,
|
||||||
|
trigger_kind,
|
||||||
|
trigger_value: row.trigger_value,
|
||||||
|
actions,
|
||||||
|
last_fired_key: row.last_fired_key,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_hhmm(value: &str) -> Result<(), String> {
|
||||||
|
let (h, m) = value
|
||||||
|
.split_once(':')
|
||||||
|
.ok_or_else(|| "time rules need HH:MM".to_string())?;
|
||||||
|
if h.len() != 2 || m.len() != 2 {
|
||||||
|
return Err("time rules need HH:MM".into());
|
||||||
|
}
|
||||||
|
let hour: u8 = h
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| "time hour must be 00-23".to_string())?;
|
||||||
|
let minute: u8 = m
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| "time minute must be 00-59".to_string())?;
|
||||||
|
if hour > 23 || minute > 59 {
|
||||||
|
return Err("time must be between 00:00 and 23:59".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_actions(
|
||||||
|
state: &tauri::State<'_, AppState>,
|
||||||
|
actions: &[RuleAction],
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if actions.is_empty() {
|
||||||
|
return Err("add at least one rule action".into());
|
||||||
|
}
|
||||||
|
for action in actions {
|
||||||
|
match action {
|
||||||
|
RuleAction::Surface {
|
||||||
|
target,
|
||||||
|
task_id,
|
||||||
|
label: _,
|
||||||
|
} => {
|
||||||
|
if matches!(target, SurfaceTarget::Task) {
|
||||||
|
let task_id = task_id
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|id| !id.is_empty())
|
||||||
|
.ok_or_else(|| "surface-task actions need a task".to_string())?;
|
||||||
|
let exists = get_task_by_id(&state.db, task_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.is_some();
|
||||||
|
if !exists {
|
||||||
|
return Err(format!("task {task_id} does not exist"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RuleAction::StartTimer { seconds, .. } => {
|
||||||
|
if *seconds != 300 {
|
||||||
|
return Err("v1 rule timers are fixed at 5 minutes".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RuleAction::SpeakLine { text } => {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err("speak actions need a line to read".into());
|
||||||
|
}
|
||||||
|
if trimmed.chars().count() > 240 {
|
||||||
|
return Err("speak lines must be 240 characters or fewer".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_request(
|
||||||
|
state: &tauri::State<'_, AppState>,
|
||||||
|
request: &CreateImplementationRuleRequest,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
match request.trigger_kind {
|
||||||
|
RuleTriggerKind::TimeOfDay => validate_hhmm(request.trigger_value.trim())?,
|
||||||
|
RuleTriggerKind::TaskCompleted | RuleTriggerKind::MorningTriageFinished => {
|
||||||
|
if !request.trigger_value.trim().is_empty() {
|
||||||
|
return Err("this trigger does not take an extra value".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validate_actions(state, &request.actions).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_implementation_rules(
|
||||||
|
window: tauri::WebviewWindow,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<Vec<ImplementationRuleDto>, String> {
|
||||||
|
ensure_main_window(&window)?;
|
||||||
|
db_list_rules(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.into_iter()
|
||||||
|
.map(ImplementationRuleDto::try_from)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_implementation_rule(
|
||||||
|
window: tauri::WebviewWindow,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
request: CreateImplementationRuleRequest,
|
||||||
|
) -> Result<ImplementationRuleDto, String> {
|
||||||
|
ensure_main_window(&window)?;
|
||||||
|
validate_request(&state, &request).await?;
|
||||||
|
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
let actions_json = serde_json::to_string(&request.actions)
|
||||||
|
.map_err(|e| format!("serialise rule actions failed: {e}"))?;
|
||||||
|
let trigger_value = match request.trigger_kind {
|
||||||
|
RuleTriggerKind::TimeOfDay => request.trigger_value.trim().to_string(),
|
||||||
|
RuleTriggerKind::TaskCompleted | RuleTriggerKind::MorningTriageFinished => String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
db_insert_rule(
|
||||||
|
&state.db,
|
||||||
|
&id,
|
||||||
|
request.enabled,
|
||||||
|
request.trigger_kind.as_str(),
|
||||||
|
&trigger_value,
|
||||||
|
&actions_json,
|
||||||
|
request.last_fired_key.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
.and_then(ImplementationRuleDto::try_from)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_implementation_rule_enabled(
|
||||||
|
window: tauri::WebviewWindow,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
id: String,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<ImplementationRuleDto, String> {
|
||||||
|
ensure_main_window(&window)?;
|
||||||
|
db_set_rule_enabled(&state.db, &id, enabled)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
.and_then(ImplementationRuleDto::try_from)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn mark_implementation_rule_fired(
|
||||||
|
window: tauri::WebviewWindow,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
id: String,
|
||||||
|
fired_key: String,
|
||||||
|
) -> Result<ImplementationRuleDto, String> {
|
||||||
|
ensure_main_window(&window)?;
|
||||||
|
let key = fired_key.trim();
|
||||||
|
if key.is_empty() {
|
||||||
|
return Err("fired_key is required".into());
|
||||||
|
}
|
||||||
|
db_mark_rule_fired(&state.db, &id, key)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
.and_then(ImplementationRuleDto::try_from)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_implementation_rule(
|
||||||
|
window: tauri::WebviewWindow,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
ensure_main_window(&window)?;
|
||||||
|
db_delete_rule(&state.db, &id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::validate_hhmm;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hhmm_validation_accepts_and_rejects_expected_shapes() {
|
||||||
|
assert!(validate_hhmm("09:00").is_ok());
|
||||||
|
assert!(validate_hhmm("23:59").is_ok());
|
||||||
|
assert!(validate_hhmm("9:00").is_err());
|
||||||
|
assert!(validate_hhmm("24:00").is_err());
|
||||||
|
assert!(validate_hhmm("08:60").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ pub mod diagnostics;
|
|||||||
pub mod feedback;
|
pub mod feedback;
|
||||||
pub mod hardware;
|
pub mod hardware;
|
||||||
pub mod hotkey;
|
pub mod hotkey;
|
||||||
|
pub mod intentions;
|
||||||
pub mod live;
|
pub mod live;
|
||||||
pub mod llm;
|
pub mod llm;
|
||||||
pub mod meeting;
|
pub mod meeting;
|
||||||
|
|||||||
@@ -329,6 +329,12 @@ pub fn run() {
|
|||||||
commands::rituals::mark_morning_triage_shown,
|
commands::rituals::mark_morning_triage_shown,
|
||||||
// Nudges (Phase 6 roadmap)
|
// Nudges (Phase 6 roadmap)
|
||||||
commands::nudges::deliver_nudge,
|
commands::nudges::deliver_nudge,
|
||||||
|
// Implementation intentions (Phase 7 roadmap)
|
||||||
|
commands::intentions::list_implementation_rules,
|
||||||
|
commands::intentions::create_implementation_rule,
|
||||||
|
commands::intentions::set_implementation_rule_enabled,
|
||||||
|
commands::intentions::mark_implementation_rule_fired,
|
||||||
|
commands::intentions::delete_implementation_rule,
|
||||||
// Profiles + profile terms (canonical SQLite-backed profile CRUD) — Task 12
|
// Profiles + profile terms (canonical SQLite-backed profile CRUD) — Task 12
|
||||||
commands::profiles::list_profiles_cmd,
|
commands::profiles::list_profiles_cmd,
|
||||||
commands::profiles::get_profile_cmd,
|
commands::profiles::get_profile_cmd,
|
||||||
|
|||||||
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();
|
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 {
|
function isBeforeToday(createdAt: string): boolean {
|
||||||
// Task `createdAt` comes from SQLite as ISO-8601 UTC. Compare the
|
// Task `createdAt` comes from SQLite as ISO-8601 UTC. Compare the
|
||||||
// local-time date portion so a task made last night locally counts
|
// 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
|
// Nothing to triage — record the shown sentinel anyway so we
|
||||||
// don't re-check the DB every focus event today.
|
// don't re-check the DB every focus event today.
|
||||||
try { await invoke('mark_morning_triage_shown', { date: todayKey() }); } catch {}
|
try { await invoke('mark_morning_triage_shown', { date: todayKey() }); } catch {}
|
||||||
|
dispatchTriageFinished('empty');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +141,7 @@
|
|||||||
} finally {
|
} finally {
|
||||||
applying = false;
|
applying = false;
|
||||||
open = false;
|
open = false;
|
||||||
|
dispatchTriageFinished('skipped');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +167,7 @@
|
|||||||
} finally {
|
} finally {
|
||||||
applying = false;
|
applying = false;
|
||||||
open = false;
|
open = false;
|
||||||
|
dispatchTriageFinished('picked');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import Toggle from "$lib/components/Toggle.svelte";
|
import Toggle from "$lib/components/Toggle.svelte";
|
||||||
import SegmentedButton from "$lib/components/SegmentedButton.svelte";
|
import SegmentedButton from "$lib/components/SegmentedButton.svelte";
|
||||||
import HotkeyRecorder from "$lib/components/HotkeyRecorder.svelte";
|
import HotkeyRecorder from "$lib/components/HotkeyRecorder.svelte";
|
||||||
|
import ImplementationRulesEditor from "$lib/components/ImplementationRulesEditor.svelte";
|
||||||
import ZonePicker from "$lib/components/ZonePicker.svelte";
|
import ZonePicker from "$lib/components/ZonePicker.svelte";
|
||||||
import AccessibilityControls from "$lib/components/AccessibilityControls.svelte";
|
import AccessibilityControls from "$lib/components/AccessibilityControls.svelte";
|
||||||
import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js";
|
import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js";
|
||||||
@@ -1571,6 +1572,22 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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) -->
|
<!-- Read aloud (Phase 4 roadmap) -->
|
||||||
<div class="border-b border-border-subtle">
|
<div class="border-b border-border-subtle">
|
||||||
<button
|
<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 AiGpuConcurrency = "parallel" | "sequential";
|
||||||
export type TaskBucket = "inbox" | "today" | "soon" | "later";
|
export type TaskBucket = "inbox" | "today" | "soon" | "later";
|
||||||
export type ToastSeverity = "info" | "success" | "warn" | "error";
|
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 {
|
export interface PageState {
|
||||||
current: string;
|
current: string;
|
||||||
@@ -249,6 +254,43 @@ export interface TaskEntry {
|
|||||||
energy: EnergyLevel | null;
|
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 {
|
export interface TaskDraft {
|
||||||
text: string;
|
text: string;
|
||||||
bucket?: TaskBucket;
|
bucket?: TaskBucket;
|
||||||
|
|||||||
@@ -26,6 +26,10 @@
|
|||||||
import { initI18n } from "$lib/i18n";
|
import { initI18n } from "$lib/i18n";
|
||||||
import { refreshLlmStatus } from "$lib/stores/llmStatus.svelte.js";
|
import { refreshLlmStatus } from "$lib/stores/llmStatus.svelte.js";
|
||||||
import { startNudgeBus, stopNudgeBus } from "$lib/stores/nudgeBus.svelte.ts";
|
import { startNudgeBus, stopNudgeBus } from "$lib/stores/nudgeBus.svelte.ts";
|
||||||
|
import {
|
||||||
|
startImplementationIntentions,
|
||||||
|
stopImplementationIntentions,
|
||||||
|
} from "$lib/stores/implementationIntentions.svelte.ts";
|
||||||
|
|
||||||
import { page as sveltePage } from "$app/stores";
|
import { page as sveltePage } from "$app/stores";
|
||||||
|
|
||||||
@@ -283,6 +287,7 @@
|
|||||||
// (just event listeners + two intervals), and means users don't
|
// (just event listeners + two intervals), and means users don't
|
||||||
// need to restart after flipping the toggle.
|
// need to restart after flipping the toggle.
|
||||||
startNudgeBus();
|
startNudgeBus();
|
||||||
|
startImplementationIntentions();
|
||||||
|
|
||||||
// Diagnostics: capture every uncaught frontend error to error_log.
|
// Diagnostics: capture every uncaught frontend error to error_log.
|
||||||
installGlobalErrorCapture();
|
installGlobalErrorCapture();
|
||||||
@@ -392,6 +397,7 @@
|
|||||||
unlistenWindDown();
|
unlistenWindDown();
|
||||||
}
|
}
|
||||||
stopNudgeBus();
|
stopNudgeBus();
|
||||||
|
stopImplementationIntentions();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user