Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/USER-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/USER-GUIDE.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@

## 已知限制

提醒选择逻辑仍需跨宿主收敛(#62)。桌面发行包尚未附带独立 CLI;源码 CLI 用法见 README。
三端提醒均由公共核心一致判定,但 Taskly 必须保持运行,且系统需允许通知投递。桌面发行包尚未附带独立 CLI;源码 CLI 用法见 README。

## 更新与卸载

Expand Down
47 changes: 33 additions & 14 deletions linux/GeneratedBackend.hpp

Large diffs are not rendered by default.

140 changes: 62 additions & 78 deletions linux/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -440,11 +440,8 @@ struct AppState {
std::atomic<bool> 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<std::int64_t> 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};
};

Expand Down Expand Up @@ -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<bool> 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<bool>) {});
});
});
}

void check_reminders(bool startup) {
Expand All @@ -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<rivet_app::Snapshot> 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<std::vector<rivet_app::ReminderDecision>> 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<rivet_app::Task const*> 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;
}
});
});
Expand Down Expand Up @@ -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);
});
});
Expand Down
13 changes: 8 additions & 5 deletions macos-host/Sources/RivetHost/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading