diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c0d4203..5afab4f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -24,13 +24,14 @@ parts where platform fidelity is the feature. | SQLite schema/migrations, queries | Racket core | | config and DB path resolution | Racket core | | validation, date parsing, filtering | Racket core | +| reminder eligibility, startup summary, session de-duplication and handoff state | Racket core | | task/list state transitions | Racket core | | CLI semantics and exit classification | Racket core (migration target) | | cross-host RPC/state/events, typed DTO generation | Rivet | | Racket CS embedding/lifecycle | Rivet | | WinUI / SwiftUI / GTK composition | native host | | IME, keyboard, accessibility | native host | -| file dialogs, menus, notifications | native host | +| file dialogs, menus, notification permission and OS delivery | native host | | signing/package/update integration | native host + release tooling | ## Target data flow @@ -88,6 +89,7 @@ taskly/ │ │ ├── config.rkt │ │ ├── validation.rkt │ │ ├── date-parser.rkt +│ │ ├── reminder.rkt # reminder decisions + delivery acknowledgement state │ │ ├── db.rkt │ │ ├── service.rkt │ │ ├── rivet-schema.rkt diff --git a/CHANGELOG.md b/CHANGELOG.md index 202e8ea..8859ece 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Shared-core reminder decisions on macOS, Windows and Linux: the Racket + service now owns due-task eligibility, startup summaries, per-database + de-duplication and delivery acknowledgement, while each native host owns + permission and OS handoff. Windows gains the missing native reminder path, + and stale database-switch decisions cannot leak into the new database (#62). - An Automatically Check for Updates toggle in the Settings menu on every platform (config `auto-check-updates`, default on): silent launch and 4-hour checks honor it, the manual Check for Updates… item always runs diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index e59de9f..c027e06 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -30,7 +30,7 @@ Core task use is local. Update checks/downloads and links you open contact exter ## Known limits -Notification selection still needs cross-host consolidation (#62). The standalone packaged CLI is not yet part of desktop releases; source CLI instructions remain in the README. +Reminders are evaluated consistently by the shared core on all three desktop hosts, but Taskly must be running and the OS must allow notification delivery. The standalone packaged CLI is not yet part of desktop releases; source CLI instructions remain in the README. ## Updates and removal diff --git a/docs/USER-GUIDE.zh-CN.md b/docs/USER-GUIDE.zh-CN.md index c9d080e..dd81317 100644 --- a/docs/USER-GUIDE.zh-CN.md +++ b/docs/USER-GUIDE.zh-CN.md @@ -30,7 +30,7 @@ ## 已知限制 -提醒选择逻辑仍需跨宿主收敛(#62)。桌面发行包尚未附带独立 CLI;源码 CLI 用法见 README。 +三端提醒均由公共核心一致判定,但 Taskly 必须保持运行,且系统需允许通知投递。桌面发行包尚未附带独立 CLI;源码 CLI 用法见 README。 ## 更新与卸载 diff --git a/linux/GeneratedBackend.hpp b/linux/GeneratedBackend.hpp index de7ac9c..269fab5 100644 --- a/linux/GeneratedBackend.hpp +++ b/linux/GeneratedBackend.hpp @@ -30,6 +30,25 @@ struct QuickAddParse { std::optional due_time; }; +struct Task { + std::int64_t id; + std::int64_t list_id; + std::optional list_name; + std::string text; + bool completed; + std::optional due_date; + std::optional due_time; + std::optional notes; + std::string created_at; +}; + +struct ReminderDecision { + std::string token; + std::string kind; + std::int64_t count; + std::optional task; +}; + struct Settings { std::string language; std::string theme; @@ -53,18 +72,6 @@ struct TodoList { std::int64_t pending_count; }; -struct Task { - std::int64_t id; - std::int64_t list_id; - std::optional list_name; - std::string text; - bool completed; - std::optional due_date; - std::optional due_time; - std::optional notes; - std::string created_at; -}; - struct Snapshot { SmartCounts counts; std::vector lists; @@ -107,10 +114,10 @@ struct Result { namespace detail { inline rivet::Value encode_String(std::string const& v) { return rivet::Value(v); } +inline rivet::Value encode_Bool(bool v) { return rivet::Value(v); } inline rivet::Value encode_Int64(std::int64_t v) { return rivet::Value(v); } inline rivet::Value encode__Optional_Int64_(std::optional const& v) { return v ? encode_Int64(*v) : rivet::Value{}; } inline rivet::Value encode__Optional_String_(std::optional const& v) { return v ? encode_String(*v) : rivet::Value{}; } -inline rivet::Value encode_Bool(bool v) { return rivet::Value(v); } inline rivet::Value encode_Task(Task const& v) { rivet::Value::List r; r.reserve(9); r.push_back(encode_Int64(v.id)); r.push_back(encode_Int64(v.list_id)); r.push_back(encode__Optional_String_(v.list_name)); r.push_back(encode_String(v.text)); r.push_back(encode_Bool(v.completed)); r.push_back(encode__Optional_String_(v.due_date)); r.push_back(encode__Optional_String_(v.due_time)); r.push_back(encode__Optional_String_(v.notes)); r.push_back(encode_String(v.created_at)); return rivet::Value(std::move(r)); } inline rivet::Value encode_UpdateCheck(UpdateCheck const& v) { rivet::Value::List r; r.reserve(8); r.push_back(encode_String(v.status)); r.push_back(encode__Optional_String_(v.error)); r.push_back(encode_String(v.current_version)); r.push_back(encode__Optional_String_(v.available_version)); r.push_back(encode__Optional_Int64_(v.build)); r.push_back(encode__Optional_String_(v.published_at)); r.push_back(encode__Optional_String_(v.installer)); r.push_back(encode__Optional_Int64_(v.size_bytes)); return rivet::Value(std::move(r)); } inline rivet::Value encode_Void() { return rivet::Value{}; } @@ -121,14 +128,17 @@ inline rivet::Value encode__List_TodoList_(std::vector const& xs) { ri inline rivet::Value encode__List_Task_(std::vector const& xs) { rivet::Value::List r; r.reserve(xs.size()); for (auto const& x : xs) r.push_back(encode_Task(x)); return rivet::Value(std::move(r)); } inline rivet::Value encode_Snapshot(Snapshot const& v) { rivet::Value::List r; r.reserve(3); r.push_back(encode_SmartCounts(v.counts)); r.push_back(encode__List_TodoList_(v.lists)); r.push_back(encode__List_Task_(v.tasks)); return rivet::Value(std::move(r)); } inline rivet::Value encode_QuickAddParse(QuickAddParse const& v) { rivet::Value::List r; r.reserve(3); r.push_back(encode_String(v.text)); r.push_back(encode__Optional_String_(v.due_date)); r.push_back(encode__Optional_String_(v.due_time)); return rivet::Value(std::move(r)); } +inline rivet::Value encode__Optional_Task_(std::optional const& v) { return v ? encode_Task(*v) : rivet::Value{}; } +inline rivet::Value encode_ReminderDecision(ReminderDecision const& v) { rivet::Value::List r; r.reserve(4); r.push_back(encode_String(v.token)); r.push_back(encode_String(v.kind)); r.push_back(encode_Int64(v.count)); r.push_back(encode__Optional_Task_(v.task)); return rivet::Value(std::move(r)); } +inline rivet::Value encode__List_ReminderDecision_(std::vector const& xs) { rivet::Value::List r; r.reserve(xs.size()); for (auto const& x : xs) r.push_back(encode_ReminderDecision(x)); return rivet::Value(std::move(r)); } inline rivet::Value encode_UpdateState(UpdateState const& v) { rivet::Value::List r; r.reserve(5); r.push_back(encode_String(v.phase)); r.push_back(encode_Int64(v.percent)); r.push_back(encode__Optional_String_(v.message)); r.push_back(encode__Optional_String_(v.downloaded_path)); r.push_back(encode__Optional_String_(v.available_version)); return rivet::Value(std::move(r)); } inline rivet::Value encode_Any(rivet::Value v) { return v; } inline std::string decode_String(rivet::Value const& v) { if (auto p = std::get_if(&v.data)) return *p; throw std::runtime_error("Rivet result type mismatch: String"); } +inline bool decode_Bool(rivet::Value const& v) { if (auto p = std::get_if(&v.data)) return *p; throw std::runtime_error("Rivet result type mismatch: Bool"); } inline std::int64_t decode_Int64(rivet::Value const& v) { if (auto p = std::get_if(&v.data)) return *p; throw std::runtime_error("Rivet result type mismatch: Int64"); } inline std::optional decode__Optional_Int64_(rivet::Value const& v) { if (std::holds_alternative(v.data)) return std::nullopt; return decode_Int64(v); } inline std::optional decode__Optional_String_(rivet::Value const& v) { if (std::holds_alternative(v.data)) return std::nullopt; return decode_String(v); } -inline bool decode_Bool(rivet::Value const& v) { if (auto p = std::get_if(&v.data)) return *p; throw std::runtime_error("Rivet result type mismatch: Bool"); } inline Task decode_Task(rivet::Value const& v) { auto p = std::get_if(&v.data); if (!p || p->size() != 9) throw std::runtime_error("Rivet result type mismatch: Task"); return Task{decode_Int64((*p)[0]), decode_Int64((*p)[1]), decode__Optional_String_((*p)[2]), decode_String((*p)[3]), decode_Bool((*p)[4]), decode__Optional_String_((*p)[5]), decode__Optional_String_((*p)[6]), decode__Optional_String_((*p)[7]), decode_String((*p)[8])}; } inline UpdateCheck decode_UpdateCheck(rivet::Value const& v) { auto p = std::get_if(&v.data); if (!p || p->size() != 8) throw std::runtime_error("Rivet result type mismatch: UpdateCheck"); return UpdateCheck{decode_String((*p)[0]), decode__Optional_String_((*p)[1]), decode_String((*p)[2]), decode__Optional_String_((*p)[3]), decode__Optional_Int64_((*p)[4]), decode__Optional_String_((*p)[5]), decode__Optional_String_((*p)[6]), decode__Optional_Int64_((*p)[7])}; } inline void decode_Void(rivet::Value const& v) { if (!std::holds_alternative(v.data)) throw std::runtime_error("Rivet result type mismatch: Void"); } @@ -139,6 +149,9 @@ inline std::vector decode__List_TodoList_(rivet::Value const& v) { aut inline std::vector decode__List_Task_(rivet::Value const& v) { auto p = std::get_if(&v.data); if (!p) throw std::runtime_error("Rivet result type mismatch: (List Task)"); std::vector r; r.reserve(p->size()); for (auto const& x : *p) r.push_back(decode_Task(x)); return r; } inline Snapshot decode_Snapshot(rivet::Value const& v) { auto p = std::get_if(&v.data); if (!p || p->size() != 3) throw std::runtime_error("Rivet result type mismatch: Snapshot"); return Snapshot{decode_SmartCounts((*p)[0]), decode__List_TodoList_((*p)[1]), decode__List_Task_((*p)[2])}; } inline QuickAddParse decode_QuickAddParse(rivet::Value const& v) { auto p = std::get_if(&v.data); if (!p || p->size() != 3) throw std::runtime_error("Rivet result type mismatch: QuickAddParse"); return QuickAddParse{decode_String((*p)[0]), decode__Optional_String_((*p)[1]), decode__Optional_String_((*p)[2])}; } +inline std::optional decode__Optional_Task_(rivet::Value const& v) { if (std::holds_alternative(v.data)) return std::nullopt; return decode_Task(v); } +inline ReminderDecision decode_ReminderDecision(rivet::Value const& v) { auto p = std::get_if(&v.data); if (!p || p->size() != 4) throw std::runtime_error("Rivet result type mismatch: ReminderDecision"); return ReminderDecision{decode_String((*p)[0]), decode_String((*p)[1]), decode_Int64((*p)[2]), decode__Optional_Task_((*p)[3])}; } +inline std::vector decode__List_ReminderDecision_(rivet::Value const& v) { auto p = std::get_if(&v.data); if (!p) throw std::runtime_error("Rivet result type mismatch: (List ReminderDecision)"); std::vector r; r.reserve(p->size()); for (auto const& x : *p) r.push_back(decode_ReminderDecision(x)); return r; } inline UpdateState decode_UpdateState(rivet::Value const& v) { auto p = std::get_if(&v.data); if (!p || p->size() != 5) throw std::runtime_error("Rivet result type mismatch: UpdateState"); return UpdateState{decode_String((*p)[0]), decode_Int64((*p)[1]), decode__Optional_String_((*p)[2]), decode__Optional_String_((*p)[3]), decode__Optional_String_((*p)[4])}; } inline rivet::Value decode_Any(rivet::Value const& v) { return v; } } // namespace detail @@ -154,6 +167,9 @@ class API { public: explicit API(rivet::linux_runtime::Backend& backend) : backend_(backend) {} + std::future acknowledge_reminder(std::string token, std::string phase) { auto rivet_call = backend_.call("acknowledge_reminder", rivet::Value::List{detail::encode_String(token), detail::encode_String(phase)}); return std::async(std::launch::deferred, [rivet_call = std::move(rivet_call)]() mutable -> bool { return detail::decode_Bool(rivet_call.get()); }); } + [[nodiscard]] std::uint64_t acknowledge_reminder_async(std::string token, std::string phase, std::function)> completion) { if (!completion) throw std::invalid_argument("Rivet async completion handler is empty"); return backend_.request_async("acknowledge_reminder", rivet::Value::List{detail::encode_String(token), detail::encode_String(phase)}, [completion = std::move(completion)](rivet::linux_runtime::CallResult rivet_result) mutable { Result result; if (rivet_result.error) { result.error = rivet_result.error; } else { try { if (!rivet_result.value) throw std::runtime_error("Rivet async call completed without a value"); result.value = detail::decode_Bool(*rivet_result.value); } catch (...) { result.error = std::current_exception(); } } completion(std::move(result)); }); } + std::optional acknowledge_reminder_within(std::string token, std::string phase, std::chrono::milliseconds rivet_timeout) { auto rivet_done = std::make_shared>(); auto rivet_future = rivet_done->get_future(); auto rivet_id = backend_.request_async("acknowledge_reminder", rivet::Value::List{detail::encode_String(token), detail::encode_String(phase)}, [rivet_done](rivet::linux_runtime::CallResult rivet_result) mutable { if (rivet_result.error) { rivet_done->set_exception(rivet_result.error); } else { try { if (!rivet_result.value) throw std::runtime_error("Rivet async call completed without a value"); rivet_done->set_value(detail::decode_Bool(*rivet_result.value)); } catch (...) { rivet_done->set_exception(std::current_exception()); } } }); if (rivet_future.wait_for(rivet_timeout) != std::future_status::ready) { backend_.cancel(rivet_id); return std::nullopt; } return rivet_future.get(); } std::future add_task(std::string text, std::optional list_id, std::optional due_date, std::optional due_time, std::optional notes) { auto rivet_call = backend_.call("add_task", rivet::Value::List{detail::encode_String(text), detail::encode__Optional_Int64_(list_id), detail::encode__Optional_String_(due_date), detail::encode__Optional_String_(due_time), detail::encode__Optional_String_(notes)}); return std::async(std::launch::deferred, [rivet_call = std::move(rivet_call)]() mutable -> Task { return detail::decode_Task(rivet_call.get()); }); } [[nodiscard]] std::uint64_t add_task_async(std::string text, std::optional list_id, std::optional due_date, std::optional due_time, std::optional notes, std::function)> completion) { if (!completion) throw std::invalid_argument("Rivet async completion handler is empty"); return backend_.request_async("add_task", rivet::Value::List{detail::encode_String(text), detail::encode__Optional_Int64_(list_id), detail::encode__Optional_String_(due_date), detail::encode__Optional_String_(due_time), detail::encode__Optional_String_(notes)}, [completion = std::move(completion)](rivet::linux_runtime::CallResult rivet_result) mutable { Result result; if (rivet_result.error) { result.error = rivet_result.error; } else { try { if (!rivet_result.value) throw std::runtime_error("Rivet async call completed without a value"); result.value = detail::decode_Task(*rivet_result.value); } catch (...) { result.error = std::current_exception(); } } completion(std::move(result)); }); } std::optional add_task_within(std::string text, std::optional list_id, std::optional due_date, std::optional due_time, std::optional notes, std::chrono::milliseconds rivet_timeout) { auto rivet_done = std::make_shared>(); auto rivet_future = rivet_done->get_future(); auto rivet_id = backend_.request_async("add_task", rivet::Value::List{detail::encode_String(text), detail::encode__Optional_Int64_(list_id), detail::encode__Optional_String_(due_date), detail::encode__Optional_String_(due_time), detail::encode__Optional_String_(notes)}, [rivet_done](rivet::linux_runtime::CallResult rivet_result) mutable { if (rivet_result.error) { rivet_done->set_exception(rivet_result.error); } else { try { if (!rivet_result.value) throw std::runtime_error("Rivet async call completed without a value"); rivet_done->set_value(detail::decode_Task(*rivet_result.value)); } catch (...) { rivet_done->set_exception(std::current_exception()); } } }); if (rivet_future.wait_for(rivet_timeout) != std::future_status::ready) { backend_.cancel(rivet_id); return std::nullopt; } return rivet_future.get(); } @@ -193,6 +209,9 @@ class API { std::future parse_quick_add(std::string text) { auto rivet_call = backend_.call("parse_quick_add", rivet::Value::List{detail::encode_String(text)}); return std::async(std::launch::deferred, [rivet_call = std::move(rivet_call)]() mutable -> QuickAddParse { return detail::decode_QuickAddParse(rivet_call.get()); }); } [[nodiscard]] std::uint64_t parse_quick_add_async(std::string text, std::function)> completion) { if (!completion) throw std::invalid_argument("Rivet async completion handler is empty"); return backend_.request_async("parse_quick_add", rivet::Value::List{detail::encode_String(text)}, [completion = std::move(completion)](rivet::linux_runtime::CallResult rivet_result) mutable { Result result; if (rivet_result.error) { result.error = rivet_result.error; } else { try { if (!rivet_result.value) throw std::runtime_error("Rivet async call completed without a value"); result.value = detail::decode_QuickAddParse(*rivet_result.value); } catch (...) { result.error = std::current_exception(); } } completion(std::move(result)); }); } std::optional parse_quick_add_within(std::string text, std::chrono::milliseconds rivet_timeout) { auto rivet_done = std::make_shared>(); auto rivet_future = rivet_done->get_future(); auto rivet_id = backend_.request_async("parse_quick_add", rivet::Value::List{detail::encode_String(text)}, [rivet_done](rivet::linux_runtime::CallResult rivet_result) mutable { if (rivet_result.error) { rivet_done->set_exception(rivet_result.error); } else { try { if (!rivet_result.value) throw std::runtime_error("Rivet async call completed without a value"); rivet_done->set_value(detail::decode_QuickAddParse(*rivet_result.value)); } catch (...) { rivet_done->set_exception(std::current_exception()); } } }); if (rivet_future.wait_for(rivet_timeout) != std::future_status::ready) { backend_.cancel(rivet_id); return std::nullopt; } return rivet_future.get(); } + std::future> reminder_decisions(bool startup) { auto rivet_call = backend_.call("reminder_decisions", rivet::Value::List{detail::encode_Bool(startup)}); return std::async(std::launch::deferred, [rivet_call = std::move(rivet_call)]() mutable -> std::vector { return detail::decode__List_ReminderDecision_(rivet_call.get()); }); } + [[nodiscard]] std::uint64_t reminder_decisions_async(bool startup, std::function>)> completion) { if (!completion) throw std::invalid_argument("Rivet async completion handler is empty"); return backend_.request_async("reminder_decisions", rivet::Value::List{detail::encode_Bool(startup)}, [completion = std::move(completion)](rivet::linux_runtime::CallResult rivet_result) mutable { Result> result; if (rivet_result.error) { result.error = rivet_result.error; } else { try { if (!rivet_result.value) throw std::runtime_error("Rivet async call completed without a value"); result.value = detail::decode__List_ReminderDecision_(*rivet_result.value); } catch (...) { result.error = std::current_exception(); } } completion(std::move(result)); }); } + std::optional> reminder_decisions_within(bool startup, std::chrono::milliseconds rivet_timeout) { auto rivet_done = std::make_shared>>(); auto rivet_future = rivet_done->get_future(); auto rivet_id = backend_.request_async("reminder_decisions", rivet::Value::List{detail::encode_Bool(startup)}, [rivet_done](rivet::linux_runtime::CallResult rivet_result) mutable { if (rivet_result.error) { rivet_done->set_exception(rivet_result.error); } else { try { if (!rivet_result.value) throw std::runtime_error("Rivet async call completed without a value"); rivet_done->set_value(detail::decode__List_ReminderDecision_(*rivet_result.value)); } catch (...) { rivet_done->set_exception(std::current_exception()); } } }); if (rivet_future.wait_for(rivet_timeout) != std::future_status::ready) { backend_.cancel(rivet_id); return std::nullopt; } return rivet_future.get(); } std::future> search_tasks(std::string keyword) { auto rivet_call = backend_.call("search_tasks", rivet::Value::List{detail::encode_String(keyword)}); return std::async(std::launch::deferred, [rivet_call = std::move(rivet_call)]() mutable -> std::vector { return detail::decode__List_Task_(rivet_call.get()); }); } [[nodiscard]] std::uint64_t search_tasks_async(std::string keyword, std::function>)> completion) { if (!completion) throw std::invalid_argument("Rivet async completion handler is empty"); return backend_.request_async("search_tasks", rivet::Value::List{detail::encode_String(keyword)}, [completion = std::move(completion)](rivet::linux_runtime::CallResult rivet_result) mutable { Result> result; if (rivet_result.error) { result.error = rivet_result.error; } else { try { if (!rivet_result.value) throw std::runtime_error("Rivet async call completed without a value"); result.value = detail::decode__List_Task_(*rivet_result.value); } catch (...) { result.error = std::current_exception(); } } completion(std::move(result)); }); } std::optional> search_tasks_within(std::string keyword, std::chrono::milliseconds rivet_timeout) { auto rivet_done = std::make_shared>>(); auto rivet_future = rivet_done->get_future(); auto rivet_id = backend_.request_async("search_tasks", rivet::Value::List{detail::encode_String(keyword)}, [rivet_done](rivet::linux_runtime::CallResult rivet_result) mutable { if (rivet_result.error) { rivet_done->set_exception(rivet_result.error); } else { try { if (!rivet_result.value) throw std::runtime_error("Rivet async call completed without a value"); rivet_done->set_value(detail::decode__List_Task_(*rivet_result.value)); } catch (...) { rivet_done->set_exception(std::current_exception()); } } }); if (rivet_future.wait_for(rivet_timeout) != std::future_status::ready) { backend_.cancel(rivet_id); return std::nullopt; } return rivet_future.get(); } diff --git a/linux/src/main.cpp b/linux/src/main.cpp index 4107f59..cb0cddb 100644 --- a/linux/src/main.cpp +++ b/linux/src/main.cpp @@ -440,11 +440,8 @@ struct AppState { std::atomic reload_in_flight{false}; std::int64_t open_editor_id{-1}; - // Reminders (PRODUCT-SPEC §9): one notification per task per session, a - // dedupe set that resets when the database changes, and a disable-on-failure - // rule — a missing notification service must never crash the host. - std::unordered_set notified_tasks; - std::string notified_db_path; + // Native reminder transport only. Eligibility, startup summarization, + // per-database de-duplication and handoff acknowledgement live in the core. bool notifications_disabled{false}; }; @@ -1114,45 +1111,59 @@ void reload_snapshot() { } // --------------------------------------------------------------------------- -// Reminders (PRODUCT-SPEC §9) — check every 60 s while a DB is connected, -// plus one check at startup. Due = incomplete ∧ has due_date ∧ -// combine(due_date, due_time|00:00) ≤ now. Each task notifies at most once -// per session (dedupe set, reset when the DB changes). Startup: ≤3 overdue → -// one notification each; >3 → one summary, and everything seen is marked -// notified. The check runs against a full-database snapshot (not the current -// view). Never crash: capability probe + swallow-everything disables -// reminders for the session. +// Reminders (PRODUCT-SPEC §9) — the host schedules the 60 s wakeup and owns +// native OS delivery. The shared core returns already de-duplicated task or +// startup-summary decisions and records emitted -> attempted -> delivered or +// failed. Stale tokens after a database switch are rejected before delivery. // --------------------------------------------------------------------------- -// Local "now" as YYYY-MM-DD HH:MM:SS for lexicographic due compares. -std::string now_stamp() { - auto const now = std::time(nullptr); - std::tm local{}; - localtime_r(&now, &local); - char buffer[20]; - std::snprintf(buffer, sizeof(buffer), "%04d-%02d-%02d %02d:%02d:%02d", - local.tm_year + 1900, local.tm_mon + 1, local.tm_mday, - local.tm_hour, local.tm_min, local.tm_sec); - return buffer; -} - -bool is_task_due(rivet_app::Task const& task, std::string const& now) { - if (task.completed || !task.due_date.has_value()) return false; - auto combined = *task.due_date + " " + - task.due_time.value_or("00:00") + ":00"; - if (combined.size() == 16) combined += ":00"; // "yyyy-MM-dd HH:mm" - return combined <= now; -} +void deliver_reminder(rivet_app::ReminderDecision decision) { + if (g_state.api == nullptr) return; + g_state.api->acknowledge_reminder_async( + decision.token, "attempted", + [decision = std::move(decision)](rivet_app::Result result) mutable { + dispatch_ui([decision = std::move(decision), + result = std::move(result)]() mutable { + bool accepted = false; + try { + accepted = result.get(); + } catch (...) { + return; + } + // A DB switch invalidates the old decision before OS delivery. + if (!accepted || g_state.api == nullptr) return; -void notify_task_due(rivet_app::Task const& task) { - auto body = task.text + "\n" + tr("reminderDueAt") + ": " + - *task.due_date; - if (task.due_time.has_value()) { - body += " " + *task.due_time; - } - rivet::system::Notifications::Notify( - "Taskly", "taskly-reminder-" + std::to_string(task.id), - tr("reminderTitle"), body); + std::string outcome{"failed"}; + if (!g_state.notifications_disabled) { + try { + std::string body; + std::string tag; + if (decision.kind == "summary") { + tag = "taskly-reminder-summary"; + body = format_positional(tr("reminderStartupSummary"), + std::to_string(decision.count)); + } else if (decision.task.has_value() && + decision.task->due_date.has_value()) { + auto const& task = *decision.task; + tag = "taskly-reminder-" + std::to_string(task.id); + body = task.text + "\n" + tr("reminderDueAt") + ": " + + *task.due_date; + if (task.due_time.has_value()) body += " " + *task.due_time; + } else { + throw std::runtime_error("malformed reminder decision"); + } + rivet::system::Notifications::Notify( + "Taskly", tag, tr("reminderTitle"), body); + outcome = "delivered"; + } catch (...) { + // Permission or transport failure disables this app session. + g_state.notifications_disabled = true; + } + } + g_state.api->acknowledge_reminder_async( + decision.token, outcome, [](rivet_app::Result) {}); + }); + }); } void check_reminders(bool startup) { @@ -1166,42 +1177,20 @@ void check_reminders(bool startup) { g_state.notifications_disabled = true; return; } - // The due set must cover the whole database, not the visible view. Errors - // are swallowed: a failed reminder snapshot must not touch the UI status. - g_state.api->load_snapshot_async( - "all", std::nullopt, false, - [startup](rivet_app::Result result) { - dispatch_ui([result = std::move(result), startup]() mutable { + // Errors are swallowed: a failed reminder decision must not touch UI status. + g_state.api->reminder_decisions_async( + startup, + [](rivet_app::Result> result) { + dispatch_ui([result = std::move(result)]() mutable { if (g_state.notifications_disabled) return; try { - auto const snapshot = result.get(); - auto const now = now_stamp(); - std::vector due; - for (auto const& task : snapshot.tasks) { - if (g_state.notified_tasks.count(task.id) > 0) continue; - if (is_task_due(task, now)) { - due.push_back(&task); - } - } - if (due.empty()) return; - if (startup && due.size() > 3) { - rivet::system::Notifications::Notify( - "Taskly", "taskly-reminder-summary", - tr("reminderTitle"), - format_positional(tr("reminderStartupSummary"), - std::to_string(due.size()))); - } else { - for (auto const* task : due) { - notify_task_due(*task); - } - } - for (auto const* task : due) { - g_state.notified_tasks.insert(task->id); + auto decisions = result.get(); + for (auto& decision : decisions) { + deliver_reminder(std::move(decision)); } } catch (std::exception const&) { - g_state.notifications_disabled = true; + // Backend/transient policy errors do not disable OS transport. } catch (...) { - g_state.notifications_disabled = true; } }); }); @@ -3389,12 +3378,7 @@ void open_database_at(std::string const& path) { rebuild_task_list(); } refresh_persistent_status(); - // Reminders: a different database resets the per-session - // dedupe set; every open runs the startup check. - if (g_state.notified_db_path != path) { - g_state.notified_tasks.clear(); - g_state.notified_db_path = path; - } + // open_database created a fresh shared reminder session. check_reminders(true); }); }); diff --git a/macos-host/Sources/RivetHost/AppModel.swift b/macos-host/Sources/RivetHost/AppModel.swift index a482ea4..bcbe4bf 100644 --- a/macos-host/Sources/RivetHost/AppModel.swift +++ b/macos-host/Sources/RivetHost/AppModel.swift @@ -418,14 +418,17 @@ final class AppModel { // MARK: - Database lifecycle (File menu) - /// Full snapshot of the planned view — every dated incomplete task, - /// due ones first; the reminder poll filters by time (spec §9). - func currentSnapshotForReminders() async throws -> RivetTypes.Snapshot { + /// Shared-core reminder decisions; native hosts only schedule and deliver. + func reminderDecisions(startup: Bool) async throws -> [RivetTypes.ReminderDecision] { guard let api else { throw HostError.missingRuntimeLayout("backend not started") } - return try await api.load_snapshot( - view: "planned", list_id: nil, show_completed: false) + return try await api.reminder_decisions(startup: startup) + } + + func acknowledgeReminder(token: String, phase: String) async -> Bool { + guard let api else { return false } + return (try? await api.acknowledge_reminder(token: token, phase: phase)) ?? false } /// Open (creating when absent) a database file in place. diff --git a/macos-host/Sources/RivetHost/GeneratedBackend.swift b/macos-host/Sources/RivetHost/GeneratedBackend.swift index 60a1cee..d150b48 100644 --- a/macos-host/Sources/RivetHost/GeneratedBackend.swift +++ b/macos-host/Sources/RivetHost/GeneratedBackend.swift @@ -25,6 +25,42 @@ public enum RivetTypes { } } + public struct Task: Sendable { + public let id: Int64 + public let list_id: Int64 + public let list_name: String? + public let text: String + public let completed: Bool + public let due_date: String? + public let due_time: String? + public let notes: String? + public let created_at: String + public init(id: Int64, list_id: Int64, list_name: String?, text: String, completed: Bool, due_date: String?, due_time: String?, notes: String?, created_at: String) { + self.id = id + self.list_id = list_id + self.list_name = list_name + self.text = text + self.completed = completed + self.due_date = due_date + self.due_time = due_time + self.notes = notes + self.created_at = created_at + } + } + + public struct ReminderDecision: Sendable { + public let token: String + public let kind: String + public let count: Int64 + public let task: RivetTypes.Task? + public init(token: String, kind: String, count: Int64, task: RivetTypes.Task?) { + self.token = token + self.kind = kind + self.count = count + self.task = task + } + } + public struct Settings: Sendable { public let language: String public let theme: String @@ -68,29 +104,6 @@ public enum RivetTypes { } } - public struct Task: Sendable { - public let id: Int64 - public let list_id: Int64 - public let list_name: String? - public let text: String - public let completed: Bool - public let due_date: String? - public let due_time: String? - public let notes: String? - public let created_at: String - public init(id: Int64, list_id: Int64, list_name: String?, text: String, completed: Bool, due_date: String?, due_time: String?, notes: String?, created_at: String) { - self.id = id - self.list_id = list_id - self.list_name = list_name - self.text = text - self.completed = completed - self.due_date = due_date - self.due_time = due_time - self.notes = notes - self.created_at = created_at - } - } - public struct Snapshot: Sendable { public let counts: RivetTypes.SmartCounts public let lists: [RivetTypes.TodoList] @@ -141,10 +154,10 @@ public enum RivetTypes { } private func encode_String(_ v: String) -> RivetValue { .string(v) } +private func encode_Bool(_ v: Bool) -> RivetValue { .bool(v) } private func encode_Int64(_ v: Int64) -> RivetValue { .int64(v) } private func encode__Optional_Int64_(_ v: Int64?) -> RivetValue { v.map(encode_Int64) ?? .null } private func encode__Optional_String_(_ v: String?) -> RivetValue { v.map(encode_String) ?? .null } -private func encode_Bool(_ v: Bool) -> RivetValue { .bool(v) } private func encode_Task(_ v: RivetTypes.Task) -> RivetValue { .list([encode_Int64(v.id), encode_Int64(v.list_id), encode__Optional_String_(v.list_name), encode_String(v.text), encode_Bool(v.completed), encode__Optional_String_(v.due_date), encode__Optional_String_(v.due_time), encode__Optional_String_(v.notes), encode_String(v.created_at)]) } private func encode_UpdateCheck(_ v: RivetTypes.UpdateCheck) -> RivetValue { .list([encode_String(v.status), encode__Optional_String_(v.error), encode_String(v.current_version), encode__Optional_String_(v.available_version), encode__Optional_Int64_(v.build), encode__Optional_String_(v.published_at), encode__Optional_String_(v.installer), encode__Optional_Int64_(v.size_bytes)]) } private func encode_Void(_ v: Void) -> RivetValue { .null } @@ -155,14 +168,17 @@ private func encode__List_TodoList_(_ v: [RivetTypes.TodoList]) -> RivetValue { private func encode__List_Task_(_ v: [RivetTypes.Task]) -> RivetValue { .list(v.map(encode_Task)) } private func encode_Snapshot(_ v: RivetTypes.Snapshot) -> RivetValue { .list([encode_SmartCounts(v.counts), encode__List_TodoList_(v.lists), encode__List_Task_(v.tasks)]) } private func encode_QuickAddParse(_ v: RivetTypes.QuickAddParse) -> RivetValue { .list([encode_String(v.text), encode__Optional_String_(v.due_date), encode__Optional_String_(v.due_time)]) } +private func encode__Optional_Task_(_ v: RivetTypes.Task?) -> RivetValue { v.map(encode_Task) ?? .null } +private func encode_ReminderDecision(_ v: RivetTypes.ReminderDecision) -> RivetValue { .list([encode_String(v.token), encode_String(v.kind), encode_Int64(v.count), encode__Optional_Task_(v.task)]) } +private func encode__List_ReminderDecision_(_ v: [RivetTypes.ReminderDecision]) -> RivetValue { .list(v.map(encode_ReminderDecision)) } private func encode_UpdateState(_ v: RivetTypes.UpdateState) -> RivetValue { .list([encode_String(v.phase), encode_Int64(v.percent), encode__Optional_String_(v.message), encode__Optional_String_(v.downloaded_path), encode__Optional_String_(v.available_version)]) } private func encode_Any(_ v: RivetValue) -> RivetValue { v } private func decode_String(_ v: RivetValue) throws -> String { guard case .string(let x) = v else { throw RivetGeneratedError.typeMismatch("String") }; return x } +private func decode_Bool(_ v: RivetValue) throws -> Bool { guard case .bool(let x) = v else { throw RivetGeneratedError.typeMismatch("Bool") }; return x } private func decode_Int64(_ v: RivetValue) throws -> Int64 { guard case .int64(let x) = v else { throw RivetGeneratedError.typeMismatch("Int64") }; return x } private func decode__Optional_Int64_(_ v: RivetValue) throws -> Int64? { if case .null = v { return nil }; return try decode_Int64(v) } private func decode__Optional_String_(_ v: RivetValue) throws -> String? { if case .null = v { return nil }; return try decode_String(v) } -private func decode_Bool(_ v: RivetValue) throws -> Bool { guard case .bool(let x) = v else { throw RivetGeneratedError.typeMismatch("Bool") }; return x } private func decode_Task(_ v: RivetValue) throws -> RivetTypes.Task { guard case .list(let xs) = v, xs.count == 9 else { throw RivetGeneratedError.typeMismatch("Task") }; return RivetTypes.Task(id: try decode_Int64(xs[0]), list_id: try decode_Int64(xs[1]), list_name: try decode__Optional_String_(xs[2]), text: try decode_String(xs[3]), completed: try decode_Bool(xs[4]), due_date: try decode__Optional_String_(xs[5]), due_time: try decode__Optional_String_(xs[6]), notes: try decode__Optional_String_(xs[7]), created_at: try decode_String(xs[8])) } private func decode_UpdateCheck(_ v: RivetValue) throws -> RivetTypes.UpdateCheck { guard case .list(let xs) = v, xs.count == 8 else { throw RivetGeneratedError.typeMismatch("UpdateCheck") }; return RivetTypes.UpdateCheck(status: try decode_String(xs[0]), error: try decode__Optional_String_(xs[1]), current_version: try decode_String(xs[2]), available_version: try decode__Optional_String_(xs[3]), build: try decode__Optional_Int64_(xs[4]), published_at: try decode__Optional_String_(xs[5]), installer: try decode__Optional_String_(xs[6]), size_bytes: try decode__Optional_Int64_(xs[7])) } private func decode_Void(_ v: RivetValue) throws -> Void { guard case .null = v else { throw RivetGeneratedError.typeMismatch("Void") } } @@ -173,6 +189,9 @@ private func decode__List_TodoList_(_ v: RivetValue) throws -> [RivetTypes.TodoL private func decode__List_Task_(_ v: RivetValue) throws -> [RivetTypes.Task] { guard case .list(let xs) = v else { throw RivetGeneratedError.typeMismatch("(List Task)") }; return try xs.map(decode_Task) } private func decode_Snapshot(_ v: RivetValue) throws -> RivetTypes.Snapshot { guard case .list(let xs) = v, xs.count == 3 else { throw RivetGeneratedError.typeMismatch("Snapshot") }; return RivetTypes.Snapshot(counts: try decode_SmartCounts(xs[0]), lists: try decode__List_TodoList_(xs[1]), tasks: try decode__List_Task_(xs[2])) } private func decode_QuickAddParse(_ v: RivetValue) throws -> RivetTypes.QuickAddParse { guard case .list(let xs) = v, xs.count == 3 else { throw RivetGeneratedError.typeMismatch("QuickAddParse") }; return RivetTypes.QuickAddParse(text: try decode_String(xs[0]), due_date: try decode__Optional_String_(xs[1]), due_time: try decode__Optional_String_(xs[2])) } +private func decode__Optional_Task_(_ v: RivetValue) throws -> RivetTypes.Task? { if case .null = v { return nil }; return try decode_Task(v) } +private func decode_ReminderDecision(_ v: RivetValue) throws -> RivetTypes.ReminderDecision { guard case .list(let xs) = v, xs.count == 4 else { throw RivetGeneratedError.typeMismatch("ReminderDecision") }; return RivetTypes.ReminderDecision(token: try decode_String(xs[0]), kind: try decode_String(xs[1]), count: try decode_Int64(xs[2]), task: try decode__Optional_Task_(xs[3])) } +private func decode__List_ReminderDecision_(_ v: RivetValue) throws -> [RivetTypes.ReminderDecision] { guard case .list(let xs) = v else { throw RivetGeneratedError.typeMismatch("(List ReminderDecision)") }; return try xs.map(decode_ReminderDecision) } private func decode_UpdateState(_ v: RivetValue) throws -> RivetTypes.UpdateState { guard case .list(let xs) = v, xs.count == 5 else { throw RivetGeneratedError.typeMismatch("UpdateState") }; return RivetTypes.UpdateState(phase: try decode_String(xs[0]), percent: try decode_Int64(xs[1]), message: try decode__Optional_String_(xs[2]), downloaded_path: try decode__Optional_String_(xs[3]), available_version: try decode__Optional_String_(xs[4])) } private func decode_Any(_ v: RivetValue) throws -> RivetValue { v } @@ -221,6 +240,10 @@ public struct RivetAPI: Sendable { self.client = RivetGeneratedClosureClient(invoke: call) } + public func acknowledge_reminder(token: String, phase: String) async throws -> Bool { + let rivetResult = try await client.call("acknowledge_reminder", arguments: [encode_String(token), encode_String(phase)]) + return try decode_Bool(rivetResult) + } public func add_task(text: String, list_id: Int64?, due_date: String?, due_time: String?, notes: String?) async throws -> RivetTypes.Task { let rivetResult = try await client.call("add_task", arguments: [encode_String(text), encode__Optional_Int64_(list_id), encode__Optional_String_(due_date), encode__Optional_String_(due_time), encode__Optional_String_(notes)]) return try decode_Task(rivetResult) @@ -273,6 +296,10 @@ public struct RivetAPI: Sendable { let rivetResult = try await client.call("parse_quick_add", arguments: [encode_String(text)]) return try decode_QuickAddParse(rivetResult) } + public func reminder_decisions(startup: Bool) async throws -> [RivetTypes.ReminderDecision] { + let rivetResult = try await client.call("reminder_decisions", arguments: [encode_Bool(startup)]) + return try decode__List_ReminderDecision_(rivetResult) + } public func search_tasks(keyword: String) async throws -> [RivetTypes.Task] { let rivetResult = try await client.call("search_tasks", arguments: [encode_String(keyword)]) return try decode__List_Task_(rivetResult) diff --git a/macos-host/Sources/RivetHost/ReminderService.swift b/macos-host/Sources/RivetHost/ReminderService.swift index a45f114..88df7c5 100644 --- a/macos-host/Sources/RivetHost/ReminderService.swift +++ b/macos-host/Sources/RivetHost/ReminderService.swift @@ -1,19 +1,16 @@ import Foundation import RivetSystem -/// Due-task reminders (PRODUCT-SPEC §9). Checks every 60 s while a database -/// is connected plus once at startup. Every task notifies at most once per -/// session; the startup check summarizes when more than three tasks are due. -/// Notification transport failures are swallowed for the session — this -/// crashed on macOS once (0.6.1 regression), never again. +/// Native scheduling and OS delivery for shared-core reminder decisions. +/// Eligibility, startup summarization, per-database de-duplication and +/// acknowledgement state live in the Racket application service. @MainActor final class ReminderService { private weak var model: AppModel? private var timer: Timer? - private var notifiedIDs: Set = [] private var authorized = false private var started = false - /// The first check after launch summarizes instead of spamming. + /// The first check after launch asks the core for summary semantics. private var startupCheckPending = true init(model: AppModel) { @@ -30,16 +27,13 @@ final class ReminderService { // denied both mean the same thing: stay silent all session. self.authorized = (try? await RivetNotifications.requestAuthorization()) ?? false guard self.authorized else { return } - await MainActor.run { - self.scheduleTimer() - self.checkNow() - } + self.scheduleTimer() + self.checkNow() } } - /// Database changed underneath us (File ▸ Open): forget what we showed. + /// `open_database` created a fresh shared-core reminder session. func resetSession() { - notifiedIDs.removeAll() startupCheckPending = true if authorized { checkNow() @@ -68,31 +62,45 @@ final class ReminderService { startupCheckPending = false _Concurrency.Task { [weak self] in guard let self else { return } - guard let snapshot = try? await model.currentSnapshotForReminders() else { return } - await MainActor.run { - self.process(snapshot.tasks, startup: startup, model: model) + guard let decisions = try? await model.reminderDecisions(startup: startup) else { + return } + await self.deliver(decisions, model: model) } } - private func process(_ tasks: [RivetTypes.Task], startup: Bool, model: AppModel) { - let due = tasks.filter { isDue($0) && !notifiedIDs.contains($0.id) } - // Everything seen now is marked notified, even the summarized ones. - for task in due { - notifiedIDs.insert(task.id) - } - guard !due.isEmpty else { return } + private func deliver(_ decisions: [RivetTypes.ReminderDecision], model: AppModel) async { + for decision in decisions { + guard authorized else { + _ = await model.acknowledgeReminder(token: decision.token, phase: "failed") + continue + } + guard await model.acknowledgeReminder( + token: decision.token, phase: "attempted") else { + continue // stale decision after a database switch + } - if startup && due.count > 3 { - show( - title: model.t("reminderTitle"), - body: model.t("reminderStartupSummary", due.count)) - return - } - for task in due { - show( - title: model.t("reminderTitle"), - body: Self.body(task, dueAtLabel: model.t("reminderDueAt"))) + let body: String + if decision.kind == "summary" { + body = model.t("reminderStartupSummary", Int(decision.count)) + } else if let task = decision.task { + body = Self.body(task, dueAtLabel: model.t("reminderDueAt")) + } else { + _ = await model.acknowledgeReminder(token: decision.token, phase: "failed") + continue + } + + do { + try await RivetNotifications.show( + title: model.t("reminderTitle"), body: body) + _ = await model.acknowledgeReminder( + token: decision.token, phase: "delivered") + } catch { + // A rejected/unavailable transport disables this app session. + authorized = false + _ = await model.acknowledgeReminder( + token: decision.token, phase: "failed") + } } } @@ -104,29 +112,4 @@ final class ReminderService { } return "\(task.text)\n\(dueAtLabel): \(when)" } - - /// Due = incomplete ∧ has due_date ∧ combine(due_date, due_time|00:00) ≤ now - /// (overdue included, however old). - private func isDue(_ task: RivetTypes.Task) -> Bool { - guard !task.completed, let dateOnly = task.due_date, !dateOnly.isEmpty else { - return false - } - guard var date = DateParser.strictDate(from: dateOnly) else { return false } - if let time = task.due_time, !time.isEmpty { - let parts = time.split(separator: ":") - if parts.count == 2, let hour = Int(parts[0]), let minute = Int(parts[1]) { - date = Calendar.current.date( - bySettingHour: hour, minute: minute, second: 0, of: date) ?? date - } - } - return date <= Date() - } - - /// A failing transport must never take the app down: swallow for the - /// session (PRODUCT-SPEC §9). - private func show(title: String, body: String) { - _Concurrency.Task { - try? await RivetNotifications.show(title: title, body: body) - } - } } diff --git a/racket/taskly/backend.rkt b/racket/taskly/backend.rkt index 283ef29..acf19b0 100644 --- a/racket/taskly/backend.rkt +++ b/racket/taskly/backend.rkt @@ -7,6 +7,7 @@ racket/format racket/path racket/string + "clock.rkt" "config.rkt" "date-parser.rkt" "errors.rkt" @@ -198,6 +199,18 @@ (let-values ([(d t) (parse-due-expression command)]) (QuickAddParse clean (nullable d) (nullable t)))))))) +;; Reminder policy lives in the shared service. Hosts only schedule polls, +;; localize the returned decision and perform the native OS handoff. +(define-rpc (reminder_decisions [startup : Bool] : (List ReminderDecision)) + (map reminder-decision->dto + (service-reminder-decisions! + (require-service) startup + #:now-date (local-date-string) + #:now-time (local-time-string)))) + +(define-rpc (acknowledge_reminder [token : String] [phase : String] : Bool) + (service-acknowledge-reminder! (require-service) token phase)) + (define-rpc (get_settings : Settings) (settings->dto)) diff --git a/racket/taskly/reminder.rkt b/racket/taskly/reminder.rkt new file mode 100644 index 0000000..56e176e --- /dev/null +++ b/racket/taskly/reminder.rkt @@ -0,0 +1,91 @@ +#lang racket/base + +(require racket/list + racket/string + "model.rkt") + +(provide (struct-out reminder-decision) + make-reminder-session + reminder-session-decide! + reminder-session-acknowledge! + reminder-session-decision-phase) + +;; Reminder selection is product policy, not notification-transport policy. +;; A session belongs to one opened database. Native hosts ask for decisions, +;; report the OS handoff lifecycle, and never reimplement eligibility, +;; startup summarization, or de-duplication. +(struct reminder-decision (token kind count task) #:transparent) +(struct reminder-session (notified phases next-token lock) #:mutable + #:transparent) + +(define (make-reminder-session) + (reminder-session (make-hasheqv) (make-hash) 0 (make-semaphore 1))) + +(define (task-due? task now-date now-time) + (and (not (task-item-completed task)) + (let ([due-date (task-item-due-date task)]) + (and due-date + (not (string=? due-date "")) + (string<=? (string-append due-date " " + (or (task-item-due-time task) "00:00")) + (string-append now-date " " now-time)))))) + +(define (next-token! session) + (define next (add1 (reminder-session-next-token session))) + (set-reminder-session-next-token! session next) + (format "reminder-~a" next)) + +(define (register-decision! session kind count task) + (define token (next-token! session)) + (hash-set! (reminder-session-phases session) token "emitted") + (reminder-decision token kind count task)) + +(define (reminder-session-decide! session tasks startup? + #:now-date now-date + #:now-time now-time) + (call-with-semaphore + (reminder-session-lock session) + (lambda () + (define due + (for/list ([task (in-list tasks)] + #:when (and (task-due? task now-date now-time) + (not (hash-has-key? + (reminder-session-notified session) + (task-item-id task))))) + task)) + ;; Emission is the de-duplication boundary. A crashed or rejected native + ;; handoff may be retried after a fresh application/database session, but + ;; never by a repeated wakeup in this session. + (for ([task (in-list due)]) + (hash-set! (reminder-session-notified session) (task-item-id task) #t)) + (cond + [(null? due) '()] + [(and startup? (> (length due) 3)) + (list (register-decision! session "summary" (length due) #f))] + [else + (for/list ([task (in-list due)]) + (register-decision! session "task" 1 task))])))) + +(define (valid-transition? current next) + (or (string=? current next) + (and (string=? current "emitted") + (member next '("attempted" "failed"))) + (and (string=? current "attempted") + (member next '("delivered" "failed"))))) + +(define (reminder-session-acknowledge! session token phase) + (call-with-semaphore + (reminder-session-lock session) + (lambda () + (define current (hash-ref (reminder-session-phases session) token #f)) + (cond + [(and current (valid-transition? current phase)) + (hash-set! (reminder-session-phases session) token phase) + #t] + [else #f])))) + +(define (reminder-session-decision-phase session token) + (call-with-semaphore + (reminder-session-lock session) + (lambda () + (hash-ref (reminder-session-phases session) token #f)))) diff --git a/racket/taskly/rivet-schema.rkt b/racket/taskly/rivet-schema.rkt index b769396..5787328 100644 --- a/racket/taskly/rivet-schema.rkt +++ b/racket/taskly/rivet-schema.rkt @@ -4,6 +4,7 @@ racket/path "config.rkt" "model.rkt" + "reminder.rkt" "service.rkt") (provide (all-defined-out)) @@ -57,6 +58,15 @@ [due-date : (Optional String)] [due-time : (Optional String)])) +;; A shared-core reminder decision. `kind` is "task" or "summary"; task is +;; present only for the former. The opaque token is acknowledged by hosts as +;; emitted -> attempted -> delivered|failed. +(define-record ReminderDecision + ([token : String] + [kind : String] + [count : Int64] + [task : (Optional Task)])) + ;; Result of check_updates. status: "available" | "up-to-date" | "error"; ;; the descriptive fields are only filled for "available". (define-record UpdateCheck @@ -135,6 +145,14 @@ (config-last-selected-list-id config) (path->string (resolve-database-path #f config)))) +(define (reminder-decision->dto decision) + (ReminderDecision + (reminder-decision-token decision) + (reminder-decision-kind decision) + (reminder-decision-count decision) + (nullable (and (reminder-decision-task decision) + (task->dto (reminder-decision-task decision)))))) + (define (snapshot->dto service #:view [view 'all] #:list-id [list-id #f] #:show-completed [show-completed #f]) (Snapshot diff --git a/racket/taskly/service.rkt b/racket/taskly/service.rkt index 30111b7..9d72faa 100644 --- a/racket/taskly/service.rkt +++ b/racket/taskly/service.rkt @@ -7,6 +7,7 @@ "db.rkt" "errors.rkt" "model.rkt" + "reminder.rkt" "validation.rkt") (provide (struct-out taskly-service) @@ -24,12 +25,16 @@ service-add-list! service-update-list! service-delete-list! - service-counts) + service-counts + service-reminder-decisions! + service-acknowledge-reminder! + service-reminder-decision-phase) -(struct taskly-service (db) #:transparent) +(struct taskly-service (db reminders) #:transparent) (define (open-taskly-service [path #f]) - (taskly-service (open-taskly-db (resolve-database-path path)))) + (taskly-service (open-taskly-db (resolve-database-path path)) + (make-reminder-session))) (define (close-taskly-service service) (close-taskly-db (taskly-service-db service))) @@ -154,3 +159,23 @@ (db-count-planned db) (db-count-incomplete db) (db-count-completed db))) + +(define (service-reminder-decisions! service startup? + #:now-date now-date + #:now-time now-time) + (reminder-session-decide! + (taskly-service-reminders service) + (service-tasks service #:view 'planned #:show-completed #f) + startup? + #:now-date now-date + #:now-time now-time)) + +(define (service-acknowledge-reminder! service token phase) + (unless (member phase '("attempted" "delivered" "failed")) + (raise-taskly 'validation 2 (format "Unknown reminder phase: ~a" phase))) + (reminder-session-acknowledge! + (taskly-service-reminders service) token phase)) + +(define (service-reminder-decision-phase service token) + (reminder-session-decision-phase + (taskly-service-reminders service) token)) diff --git a/racket/tests/backend-wire-test.rkt b/racket/tests/backend-wire-test.rkt index c02559a..456098d 100644 --- a/racket/tests/backend-wire-test.rkt +++ b/racket/tests/backend-wire-test.rkt @@ -45,10 +45,10 @@ (check-equal? (sort (map (lambda (rpc) (hash-ref rpc 'name)) (rpc-schema)) stringstring - '(add_task check_updates close_database create_list + '(acknowledge_reminder add_task check_updates close_database create_list default_database delete_list delete_task get_setting get_settings load_snapshot open_database parse_due - parse_quick_add search_tasks set_completed set_setting + parse_quick_add reminder_decisions search_tasks set_completed set_setting start_download update_list update_state update_task update_task_text)) stringdto (car decisions))) + (check-equal? (record-ref reminder-dto 'kind) "task") + (check-equal? (record-ref reminder-dto 'count) 1) + (check-equal? (record-ref (record-ref reminder-dto 'task) 'text) + "typed task") + (close-taskly-service service))) (lambda () (delete-directory/files temp-root))) diff --git a/shared/spec/PRODUCT-SPEC.md b/shared/spec/PRODUCT-SPEC.md index 73db8dc..b9aef66 100644 --- a/shared/spec/PRODUCT-SPEC.md +++ b/shared/spec/PRODUCT-SPEC.md @@ -191,11 +191,21 @@ pickers. Save (blank text silently ignored), Delete (confirm first), Cancel DB changes). Startup: ≤3 overdue → one notification each; >3 → single summary `" task(s) overdue"`. Everything seen at startup is marked notified. +- The shared Racket application service owns eligibility, summarization and + the logical database session. It emits a typed `task` or `summary` decision + and records `emitted → attempted → delivered|failed`; repeated wakeups never + retry an emitted decision. A database switch invalidates outstanding tokens. + Native hosts own permission, timers, OS delivery and localized presentation. + Clock/calendar inputs are injectable in core fixtures, including local + midnight and DST-boundary cases. - Notification: title `⏰ 任务到期` (reminderTitle), body `\n: `. - Never crash: missing notification permission or any transport failure is - swallowed for the session (this crashed on macOS once — 0.6.1 regression — - never again). No click-through action required. + swallowed and disables native delivery for the app session (this crashed on + macOS once — 0.6.1 regression — never again). The failed handoff is + acknowledged without creating a duplicate retry. No click-through action + required. Reminders are process-bound: no host promises background delivery + after Taskly exits or while the computer sleeps. ## 10. Validation diff --git a/windows/GeneratedBackend.hpp b/windows/GeneratedBackend.hpp index 0e946fa..474a076 100644 --- a/windows/GeneratedBackend.hpp +++ b/windows/GeneratedBackend.hpp @@ -30,6 +30,25 @@ struct QuickAddParse { std::optional due_time; }; +struct Task { + std::int64_t id; + std::int64_t list_id; + std::optional list_name; + std::string text; + bool completed; + std::optional due_date; + std::optional due_time; + std::optional notes; + std::string created_at; +}; + +struct ReminderDecision { + std::string token; + std::string kind; + std::int64_t count; + std::optional task; +}; + struct Settings { std::string language; std::string theme; @@ -53,18 +72,6 @@ struct TodoList { std::int64_t pending_count; }; -struct Task { - std::int64_t id; - std::int64_t list_id; - std::optional list_name; - std::string text; - bool completed; - std::optional due_date; - std::optional due_time; - std::optional notes; - std::string created_at; -}; - struct Snapshot { SmartCounts counts; std::vector lists; @@ -107,10 +114,10 @@ struct Result { namespace detail { inline rivet::Value encode_String(std::string const& v) { return rivet::Value(v); } +inline rivet::Value encode_Bool(bool v) { return rivet::Value(v); } inline rivet::Value encode_Int64(std::int64_t v) { return rivet::Value(v); } inline rivet::Value encode__Optional_Int64_(std::optional const& v) { return v ? encode_Int64(*v) : rivet::Value{}; } inline rivet::Value encode__Optional_String_(std::optional const& v) { return v ? encode_String(*v) : rivet::Value{}; } -inline rivet::Value encode_Bool(bool v) { return rivet::Value(v); } inline rivet::Value encode_Task(Task const& v) { rivet::Value::List r; r.reserve(9); r.push_back(encode_Int64(v.id)); r.push_back(encode_Int64(v.list_id)); r.push_back(encode__Optional_String_(v.list_name)); r.push_back(encode_String(v.text)); r.push_back(encode_Bool(v.completed)); r.push_back(encode__Optional_String_(v.due_date)); r.push_back(encode__Optional_String_(v.due_time)); r.push_back(encode__Optional_String_(v.notes)); r.push_back(encode_String(v.created_at)); return rivet::Value(std::move(r)); } inline rivet::Value encode_UpdateCheck(UpdateCheck const& v) { rivet::Value::List r; r.reserve(8); r.push_back(encode_String(v.status)); r.push_back(encode__Optional_String_(v.error)); r.push_back(encode_String(v.current_version)); r.push_back(encode__Optional_String_(v.available_version)); r.push_back(encode__Optional_Int64_(v.build)); r.push_back(encode__Optional_String_(v.published_at)); r.push_back(encode__Optional_String_(v.installer)); r.push_back(encode__Optional_Int64_(v.size_bytes)); return rivet::Value(std::move(r)); } inline rivet::Value encode_Void() { return rivet::Value{}; } @@ -121,14 +128,17 @@ inline rivet::Value encode__List_TodoList_(std::vector const& xs) { ri inline rivet::Value encode__List_Task_(std::vector const& xs) { rivet::Value::List r; r.reserve(xs.size()); for (auto const& x : xs) r.push_back(encode_Task(x)); return rivet::Value(std::move(r)); } inline rivet::Value encode_Snapshot(Snapshot const& v) { rivet::Value::List r; r.reserve(3); r.push_back(encode_SmartCounts(v.counts)); r.push_back(encode__List_TodoList_(v.lists)); r.push_back(encode__List_Task_(v.tasks)); return rivet::Value(std::move(r)); } inline rivet::Value encode_QuickAddParse(QuickAddParse const& v) { rivet::Value::List r; r.reserve(3); r.push_back(encode_String(v.text)); r.push_back(encode__Optional_String_(v.due_date)); r.push_back(encode__Optional_String_(v.due_time)); return rivet::Value(std::move(r)); } +inline rivet::Value encode__Optional_Task_(std::optional const& v) { return v ? encode_Task(*v) : rivet::Value{}; } +inline rivet::Value encode_ReminderDecision(ReminderDecision const& v) { rivet::Value::List r; r.reserve(4); r.push_back(encode_String(v.token)); r.push_back(encode_String(v.kind)); r.push_back(encode_Int64(v.count)); r.push_back(encode__Optional_Task_(v.task)); return rivet::Value(std::move(r)); } +inline rivet::Value encode__List_ReminderDecision_(std::vector const& xs) { rivet::Value::List r; r.reserve(xs.size()); for (auto const& x : xs) r.push_back(encode_ReminderDecision(x)); return rivet::Value(std::move(r)); } inline rivet::Value encode_UpdateState(UpdateState const& v) { rivet::Value::List r; r.reserve(5); r.push_back(encode_String(v.phase)); r.push_back(encode_Int64(v.percent)); r.push_back(encode__Optional_String_(v.message)); r.push_back(encode__Optional_String_(v.downloaded_path)); r.push_back(encode__Optional_String_(v.available_version)); return rivet::Value(std::move(r)); } inline rivet::Value encode_Any(rivet::Value v) { return v; } inline std::string decode_String(rivet::Value const& v) { if (auto p = std::get_if(&v.data)) return *p; throw std::runtime_error("Rivet result type mismatch: String"); } +inline bool decode_Bool(rivet::Value const& v) { if (auto p = std::get_if(&v.data)) return *p; throw std::runtime_error("Rivet result type mismatch: Bool"); } inline std::int64_t decode_Int64(rivet::Value const& v) { if (auto p = std::get_if(&v.data)) return *p; throw std::runtime_error("Rivet result type mismatch: Int64"); } inline std::optional decode__Optional_Int64_(rivet::Value const& v) { if (std::holds_alternative(v.data)) return std::nullopt; return decode_Int64(v); } inline std::optional decode__Optional_String_(rivet::Value const& v) { if (std::holds_alternative(v.data)) return std::nullopt; return decode_String(v); } -inline bool decode_Bool(rivet::Value const& v) { if (auto p = std::get_if(&v.data)) return *p; throw std::runtime_error("Rivet result type mismatch: Bool"); } inline Task decode_Task(rivet::Value const& v) { auto p = std::get_if(&v.data); if (!p || p->size() != 9) throw std::runtime_error("Rivet result type mismatch: Task"); return Task{decode_Int64((*p)[0]), decode_Int64((*p)[1]), decode__Optional_String_((*p)[2]), decode_String((*p)[3]), decode_Bool((*p)[4]), decode__Optional_String_((*p)[5]), decode__Optional_String_((*p)[6]), decode__Optional_String_((*p)[7]), decode_String((*p)[8])}; } inline UpdateCheck decode_UpdateCheck(rivet::Value const& v) { auto p = std::get_if(&v.data); if (!p || p->size() != 8) throw std::runtime_error("Rivet result type mismatch: UpdateCheck"); return UpdateCheck{decode_String((*p)[0]), decode__Optional_String_((*p)[1]), decode_String((*p)[2]), decode__Optional_String_((*p)[3]), decode__Optional_Int64_((*p)[4]), decode__Optional_String_((*p)[5]), decode__Optional_String_((*p)[6]), decode__Optional_Int64_((*p)[7])}; } inline void decode_Void(rivet::Value const& v) { if (!std::holds_alternative(v.data)) throw std::runtime_error("Rivet result type mismatch: Void"); } @@ -139,6 +149,9 @@ inline std::vector decode__List_TodoList_(rivet::Value const& v) { aut inline std::vector decode__List_Task_(rivet::Value const& v) { auto p = std::get_if(&v.data); if (!p) throw std::runtime_error("Rivet result type mismatch: (List Task)"); std::vector r; r.reserve(p->size()); for (auto const& x : *p) r.push_back(decode_Task(x)); return r; } inline Snapshot decode_Snapshot(rivet::Value const& v) { auto p = std::get_if(&v.data); if (!p || p->size() != 3) throw std::runtime_error("Rivet result type mismatch: Snapshot"); return Snapshot{decode_SmartCounts((*p)[0]), decode__List_TodoList_((*p)[1]), decode__List_Task_((*p)[2])}; } inline QuickAddParse decode_QuickAddParse(rivet::Value const& v) { auto p = std::get_if(&v.data); if (!p || p->size() != 3) throw std::runtime_error("Rivet result type mismatch: QuickAddParse"); return QuickAddParse{decode_String((*p)[0]), decode__Optional_String_((*p)[1]), decode__Optional_String_((*p)[2])}; } +inline std::optional decode__Optional_Task_(rivet::Value const& v) { if (std::holds_alternative(v.data)) return std::nullopt; return decode_Task(v); } +inline ReminderDecision decode_ReminderDecision(rivet::Value const& v) { auto p = std::get_if(&v.data); if (!p || p->size() != 4) throw std::runtime_error("Rivet result type mismatch: ReminderDecision"); return ReminderDecision{decode_String((*p)[0]), decode_String((*p)[1]), decode_Int64((*p)[2]), decode__Optional_Task_((*p)[3])}; } +inline std::vector decode__List_ReminderDecision_(rivet::Value const& v) { auto p = std::get_if(&v.data); if (!p) throw std::runtime_error("Rivet result type mismatch: (List ReminderDecision)"); std::vector r; r.reserve(p->size()); for (auto const& x : *p) r.push_back(decode_ReminderDecision(x)); return r; } inline UpdateState decode_UpdateState(rivet::Value const& v) { auto p = std::get_if(&v.data); if (!p || p->size() != 5) throw std::runtime_error("Rivet result type mismatch: UpdateState"); return UpdateState{decode_String((*p)[0]), decode_Int64((*p)[1]), decode__Optional_String_((*p)[2]), decode__Optional_String_((*p)[3]), decode__Optional_String_((*p)[4])}; } inline rivet::Value decode_Any(rivet::Value const& v) { return v; } } // namespace detail @@ -154,6 +167,9 @@ class API { public: explicit API(rivet::windows::Backend& backend) : backend_(backend) {} + std::future acknowledge_reminder(std::string token, std::string phase) { auto rivet_call = backend_.call("acknowledge_reminder", rivet::Value::List{detail::encode_String(token), detail::encode_String(phase)}); return std::async(std::launch::deferred, [rivet_call = std::move(rivet_call)]() mutable -> bool { return detail::decode_Bool(rivet_call.get()); }); } + [[nodiscard]] std::uint64_t acknowledge_reminder_async(std::string token, std::string phase, std::function)> completion) { if (!completion) throw std::invalid_argument("Rivet async completion handler is empty"); return backend_.request_async("acknowledge_reminder", rivet::Value::List{detail::encode_String(token), detail::encode_String(phase)}, [completion = std::move(completion)](rivet::windows::CallResult rivet_result) mutable { Result result; if (rivet_result.error) { result.error = rivet_result.error; } else { try { if (!rivet_result.value) throw std::runtime_error("Rivet async call completed without a value"); result.value = detail::decode_Bool(*rivet_result.value); } catch (...) { result.error = std::current_exception(); } } completion(std::move(result)); }); } + std::optional acknowledge_reminder_within(std::string token, std::string phase, std::chrono::milliseconds rivet_timeout) { auto rivet_done = std::make_shared>(); auto rivet_future = rivet_done->get_future(); auto rivet_id = backend_.request_async("acknowledge_reminder", rivet::Value::List{detail::encode_String(token), detail::encode_String(phase)}, [rivet_done](rivet::windows::CallResult rivet_result) mutable { if (rivet_result.error) { rivet_done->set_exception(rivet_result.error); } else { try { if (!rivet_result.value) throw std::runtime_error("Rivet async call completed without a value"); rivet_done->set_value(detail::decode_Bool(*rivet_result.value)); } catch (...) { rivet_done->set_exception(std::current_exception()); } } }); if (rivet_future.wait_for(rivet_timeout) != std::future_status::ready) { backend_.cancel(rivet_id); return std::nullopt; } return rivet_future.get(); } std::future add_task(std::string text, std::optional list_id, std::optional due_date, std::optional due_time, std::optional notes) { auto rivet_call = backend_.call("add_task", rivet::Value::List{detail::encode_String(text), detail::encode__Optional_Int64_(list_id), detail::encode__Optional_String_(due_date), detail::encode__Optional_String_(due_time), detail::encode__Optional_String_(notes)}); return std::async(std::launch::deferred, [rivet_call = std::move(rivet_call)]() mutable -> Task { return detail::decode_Task(rivet_call.get()); }); } [[nodiscard]] std::uint64_t add_task_async(std::string text, std::optional list_id, std::optional due_date, std::optional due_time, std::optional notes, std::function)> completion) { if (!completion) throw std::invalid_argument("Rivet async completion handler is empty"); return backend_.request_async("add_task", rivet::Value::List{detail::encode_String(text), detail::encode__Optional_Int64_(list_id), detail::encode__Optional_String_(due_date), detail::encode__Optional_String_(due_time), detail::encode__Optional_String_(notes)}, [completion = std::move(completion)](rivet::windows::CallResult rivet_result) mutable { Result result; if (rivet_result.error) { result.error = rivet_result.error; } else { try { if (!rivet_result.value) throw std::runtime_error("Rivet async call completed without a value"); result.value = detail::decode_Task(*rivet_result.value); } catch (...) { result.error = std::current_exception(); } } completion(std::move(result)); }); } std::optional add_task_within(std::string text, std::optional list_id, std::optional due_date, std::optional due_time, std::optional notes, std::chrono::milliseconds rivet_timeout) { auto rivet_done = std::make_shared>(); auto rivet_future = rivet_done->get_future(); auto rivet_id = backend_.request_async("add_task", rivet::Value::List{detail::encode_String(text), detail::encode__Optional_Int64_(list_id), detail::encode__Optional_String_(due_date), detail::encode__Optional_String_(due_time), detail::encode__Optional_String_(notes)}, [rivet_done](rivet::windows::CallResult rivet_result) mutable { if (rivet_result.error) { rivet_done->set_exception(rivet_result.error); } else { try { if (!rivet_result.value) throw std::runtime_error("Rivet async call completed without a value"); rivet_done->set_value(detail::decode_Task(*rivet_result.value)); } catch (...) { rivet_done->set_exception(std::current_exception()); } } }); if (rivet_future.wait_for(rivet_timeout) != std::future_status::ready) { backend_.cancel(rivet_id); return std::nullopt; } return rivet_future.get(); } @@ -193,6 +209,9 @@ class API { std::future parse_quick_add(std::string text) { auto rivet_call = backend_.call("parse_quick_add", rivet::Value::List{detail::encode_String(text)}); return std::async(std::launch::deferred, [rivet_call = std::move(rivet_call)]() mutable -> QuickAddParse { return detail::decode_QuickAddParse(rivet_call.get()); }); } [[nodiscard]] std::uint64_t parse_quick_add_async(std::string text, std::function)> completion) { if (!completion) throw std::invalid_argument("Rivet async completion handler is empty"); return backend_.request_async("parse_quick_add", rivet::Value::List{detail::encode_String(text)}, [completion = std::move(completion)](rivet::windows::CallResult rivet_result) mutable { Result result; if (rivet_result.error) { result.error = rivet_result.error; } else { try { if (!rivet_result.value) throw std::runtime_error("Rivet async call completed without a value"); result.value = detail::decode_QuickAddParse(*rivet_result.value); } catch (...) { result.error = std::current_exception(); } } completion(std::move(result)); }); } std::optional parse_quick_add_within(std::string text, std::chrono::milliseconds rivet_timeout) { auto rivet_done = std::make_shared>(); auto rivet_future = rivet_done->get_future(); auto rivet_id = backend_.request_async("parse_quick_add", rivet::Value::List{detail::encode_String(text)}, [rivet_done](rivet::windows::CallResult rivet_result) mutable { if (rivet_result.error) { rivet_done->set_exception(rivet_result.error); } else { try { if (!rivet_result.value) throw std::runtime_error("Rivet async call completed without a value"); rivet_done->set_value(detail::decode_QuickAddParse(*rivet_result.value)); } catch (...) { rivet_done->set_exception(std::current_exception()); } } }); if (rivet_future.wait_for(rivet_timeout) != std::future_status::ready) { backend_.cancel(rivet_id); return std::nullopt; } return rivet_future.get(); } + std::future> reminder_decisions(bool startup) { auto rivet_call = backend_.call("reminder_decisions", rivet::Value::List{detail::encode_Bool(startup)}); return std::async(std::launch::deferred, [rivet_call = std::move(rivet_call)]() mutable -> std::vector { return detail::decode__List_ReminderDecision_(rivet_call.get()); }); } + [[nodiscard]] std::uint64_t reminder_decisions_async(bool startup, std::function>)> completion) { if (!completion) throw std::invalid_argument("Rivet async completion handler is empty"); return backend_.request_async("reminder_decisions", rivet::Value::List{detail::encode_Bool(startup)}, [completion = std::move(completion)](rivet::windows::CallResult rivet_result) mutable { Result> result; if (rivet_result.error) { result.error = rivet_result.error; } else { try { if (!rivet_result.value) throw std::runtime_error("Rivet async call completed without a value"); result.value = detail::decode__List_ReminderDecision_(*rivet_result.value); } catch (...) { result.error = std::current_exception(); } } completion(std::move(result)); }); } + std::optional> reminder_decisions_within(bool startup, std::chrono::milliseconds rivet_timeout) { auto rivet_done = std::make_shared>>(); auto rivet_future = rivet_done->get_future(); auto rivet_id = backend_.request_async("reminder_decisions", rivet::Value::List{detail::encode_Bool(startup)}, [rivet_done](rivet::windows::CallResult rivet_result) mutable { if (rivet_result.error) { rivet_done->set_exception(rivet_result.error); } else { try { if (!rivet_result.value) throw std::runtime_error("Rivet async call completed without a value"); rivet_done->set_value(detail::decode__List_ReminderDecision_(*rivet_result.value)); } catch (...) { rivet_done->set_exception(std::current_exception()); } } }); if (rivet_future.wait_for(rivet_timeout) != std::future_status::ready) { backend_.cancel(rivet_id); return std::nullopt; } return rivet_future.get(); } std::future> search_tasks(std::string keyword) { auto rivet_call = backend_.call("search_tasks", rivet::Value::List{detail::encode_String(keyword)}); return std::async(std::launch::deferred, [rivet_call = std::move(rivet_call)]() mutable -> std::vector { return detail::decode__List_Task_(rivet_call.get()); }); } [[nodiscard]] std::uint64_t search_tasks_async(std::string keyword, std::function>)> completion) { if (!completion) throw std::invalid_argument("Rivet async completion handler is empty"); return backend_.request_async("search_tasks", rivet::Value::List{detail::encode_String(keyword)}, [completion = std::move(completion)](rivet::windows::CallResult rivet_result) mutable { Result> result; if (rivet_result.error) { result.error = rivet_result.error; } else { try { if (!rivet_result.value) throw std::runtime_error("Rivet async call completed without a value"); result.value = detail::decode__List_Task_(*rivet_result.value); } catch (...) { result.error = std::current_exception(); } } completion(std::move(result)); }); } std::optional> search_tasks_within(std::string keyword, std::chrono::milliseconds rivet_timeout) { auto rivet_done = std::make_shared>>(); auto rivet_future = rivet_done->get_future(); auto rivet_id = backend_.request_async("search_tasks", rivet::Value::List{detail::encode_String(keyword)}, [rivet_done](rivet::windows::CallResult rivet_result) mutable { if (rivet_result.error) { rivet_done->set_exception(rivet_result.error); } else { try { if (!rivet_result.value) throw std::runtime_error("Rivet async call completed without a value"); rivet_done->set_value(detail::decode__List_Task_(*rivet_result.value)); } catch (...) { rivet_done->set_exception(std::current_exception()); } } }); if (rivet_future.wait_for(rivet_timeout) != std::future_status::ready) { backend_.cancel(rivet_id); return std::nullopt; } return rivet_future.get(); } diff --git a/windows/MainWindow.xaml.cpp b/windows/MainWindow.xaml.cpp index 7b9cc8c..bd49b69 100644 --- a/windows/MainWindow.xaml.cpp +++ b/windows/MainWindow.xaml.cpp @@ -616,6 +616,14 @@ MainWindow::MainWindow() { window->OnEffectiveThemeChanged(); } }); + try { + // Shell_NotifyIcon is Rivet's first-party notification surface for this + // unpackaged WinUI host. Failure/denial disables reminders, never startup. + notification_icon_ = std::make_unique( + hwnd, 2, WM_APP + 43, L"Taskly", ::LoadIconW(nullptr, IDI_APPLICATION)); + } catch (...) { + notifications_disabled_ = true; + } InstallAppAccelerators(); InitializeBackendAsync(); } @@ -668,6 +676,16 @@ winrt::fire_and_forget MainWindow::InitializeBackendAsync() { }); window->poll_timer_.Start(); + window->reminder_timer_ = mx::DispatcherTimer(); + window->reminder_timer_.Interval(std::chrono::seconds{60}); + window->reminder_timer_.Tick( + [window_weak = window->get_weak()](auto&&, auto&&) { + if (auto current = window_weak.get()) { + current->CheckReminders(false); + } + }); + window->reminder_timer_.Start(); + rivet_app::API api(backend_ref); auto const settings_dispatcher = window->DispatcherQueue(); auto const settings_weak = window->get_weak(); @@ -726,6 +744,96 @@ winrt::fire_and_forget MainWindow::InitializeBackendAsync() { } } +// --------------------------------------------------------------------------- +// Reminders — shared-core decisions, native Windows delivery +// --------------------------------------------------------------------------- + +void MainWindow::CheckReminders(bool startup) { + if (!database_connected_ || notifications_disabled_ || + notification_icon_ == nullptr || backend_ == nullptr || + !backend_->running()) { + return; + } + auto const dispatcher = DispatcherQueue(); + auto const weak = get_weak(); + rivet_app::API api(*backend_); + (void)api.reminder_decisions_async( + startup, + [dispatcher, weak]( + rivet_app::Result> result) { + try { + auto decisions = result.get(); + dispatcher.TryEnqueue( + [weak, decisions = std::move(decisions)]() mutable { + if (auto window = weak.get()) { + for (auto& decision : decisions) { + window->DeliverReminder(std::move(decision)); + } + } + }); + } catch (...) { + // A transient backend decision failure does not disable transport. + } + }); +} + +void MainWindow::DeliverReminder(rivet_app::ReminderDecision decision) { + if (backend_ == nullptr || !backend_->running()) return; + auto const dispatcher = DispatcherQueue(); + auto const weak = get_weak(); + rivet_app::API api(*backend_); + (void)api.acknowledge_reminder_async( + decision.token, "attempted", + [dispatcher, weak, decision = std::move(decision)]( + rivet_app::Result result) mutable { + bool accepted = false; + try { + accepted = result.get(); + } catch (...) { + return; + } + dispatcher.TryEnqueue( + [weak, accepted, decision = std::move(decision)]() mutable { + auto window = weak.get(); + if (!window || !accepted || window->backend_ == nullptr) { + return; // stale decision after a database switch + } + + std::string outcome{"failed"}; + if (!window->notifications_disabled_ && + window->notification_icon_ != nullptr) { + try { + std::wstring body; + if (decision.kind == "summary") { + body = tf("reminderStartupSummary", + std::to_wstring(decision.count)); + } else if (decision.task.has_value() && + decision.task->due_date.has_value()) { + auto const& task = *decision.task; + auto when = wide(*task.due_date); + if (task.due_time.has_value()) { + when += L" " + wide(*task.due_time); + } + body = wide(task.text) + L"\n" + t("reminderDueAt") + + L": " + when; + } else { + throw std::runtime_error("malformed reminder decision"); + } + window->notification_icon_->Notify(t("reminderTitle"), body); + outcome = "delivered"; + } catch (...) { + window->notifications_disabled_ = true; + } + } + + rivet_app::API current_api(*window->backend_); + (void)current_api.acknowledge_reminder_async( + decision.token, outcome, + [](rivet_app::Result) {}); + }); + }); +} + // --------------------------------------------------------------------------- // Rendering // --------------------------------------------------------------------------- @@ -1859,6 +1967,8 @@ winrt::fire_and_forget MainWindow::OpenDatabaseAsync(std::wstring const& path) { // Opening another DB invalidates the previous fingerprint. window->ApplySnapshot(snapshot, /*force=*/true); } + window->database_connected_ = true; + window->CheckReminders(true); // An in-flight download owns the status line. if (!window->update_downloading_) { window->SetStatus(t("statusDatabaseConnected")); @@ -2378,6 +2488,7 @@ void MainWindow::OnCloseDatabase( // The next snapshot must render even if it matches the one the // closed database ended with. window->has_applied_snapshot_ = false; + window->database_connected_ = false; window->RenderSidebar(); window->RenderTasks(); window->SetStatus(L""); diff --git a/windows/MainWindow.xaml.h b/windows/MainWindow.xaml.h index 90987e2..7c6dd34 100644 --- a/windows/MainWindow.xaml.h +++ b/windows/MainWindow.xaml.h @@ -5,6 +5,8 @@ #include "pch.h" #include "GeneratedBackend.hpp" #include "MainWindow.g.h" +#include "system_services.hpp" +#include "winui_window.hpp" namespace winrt::RivetHost::implementation { @@ -111,6 +113,8 @@ struct MainWindow : MainWindowT { void InstallAppAccelerators(); void SwitchSmartView(ViewKind kind); void ToggleShowCompleted(); + void CheckReminders(bool startup); + void DeliverReminder(rivet_app::ReminderDecision decision); static std::string ViewStringFor(ViewKind kind); @@ -224,6 +228,7 @@ struct MainWindow : MainWindowT { std::wstring const& copy_button = L""); std::shared_ptr backend_; + std::unique_ptr notification_icon_; rivet_app::Settings settings_{}; // Host-owned (like last-update-check): UPDATE.md auto-check toggle. bool auto_check_updates_{true}; @@ -258,12 +263,15 @@ struct MainWindow : MainWindowT { std::string applied_snapshot_; std::atomic reload_in_flight_{false}; winrt::Microsoft::UI::Xaml::DispatcherTimer poll_timer_{nullptr}; + winrt::Microsoft::UI::Xaml::DispatcherTimer reminder_timer_{nullptr}; winrt::Microsoft::UI::Xaml::DispatcherTimer update_timer_{nullptr}; winrt::Microsoft::UI::Dispatching::DispatcherQueueTimer preview_timer_{nullptr}; bool update_downloading_{false}; // Wall-clock start of the active download; a poll past the total limit // fails the download even if the backend state machine went quiet. std::int64_t update_download_start_{0}; + bool database_connected_{false}; + bool notifications_disabled_{false}; }; } // namespace winrt::RivetHost::implementation diff --git a/windows/RivetHost.vcxproj b/windows/RivetHost.vcxproj index 6f59054..e569c07 100644 --- a/windows/RivetHost.vcxproj +++ b/windows/RivetHost.vcxproj @@ -70,7 +70,7 @@ Level4 %(AdditionalOptions) /bigobj /permissive- /utf-8 stdcpp17 - $(RIVET_RACKET_INCLUDE);$(RIVET_ROOT)\runtime\include;$(RIVET_ROOT)\platform\windows\runtime;%(AdditionalIncludeDirectories) + $(RIVET_RACKET_INCLUDE);$(RIVET_ROOT)\runtime\include;$(RIVET_ROOT)\platform\windows\runtime;$(RIVET_ROOT)\platform\windows\system;%(AdditionalIncludeDirectories) $(RIVET_RACKET_IMPORT_LIB);shell32.lib;%(AdditionalDependencies) @@ -98,6 +98,8 @@ + +