feat: schedule Todo native reminders

This commit is contained in:
mirivlad 2026-07-14 02:47:05 +08:00
parent 7e34a73835
commit 7687f410c4
5 changed files with 63 additions and 4 deletions

View File

@ -340,18 +340,45 @@
}); });
} }
function notificationRequests() {
return sortTodos(todos).filter(function (todo) {
return todo.status === 'open' && todo.reminderAt;
}).map(function (todo) {
var dueAt = new Date(todo.reminderAt);
if (isNaN(dueAt.getTime())) return null;
var title = todo.title || tr('ui.untitled', null, 'Untitled todo');
return {
id: todo.id,
dueAt: dueAt.toISOString(),
title: tr('ui.notificationTitle', null, 'Todo reminder'),
body: tr('ui.notificationBody', { title: title }, title)
};
}).filter(function (item) { return item !== null; });
}
function syncNotifications() {
if (!api || !api.notifications || typeof api.notifications.replace !== 'function') return Promise.resolve();
return api.notifications.replace(notificationRequests()).catch(function (err) {
statusText = tr('ui.notificationError', { error: err && err.message ? err.message : String(err) }, 'Could not schedule reminders: ' + (err && err.message ? err.message : String(err)));
statusClass = 'error';
});
}
function persist() { function persist() {
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve(); if (!api || !api.settings || typeof api.settings.write !== 'function') return syncNotifications();
return api.settings.write(GLOBAL_KEY, storageTodos(sortTodos(todos))).catch(function (err) { return api.settings.write(GLOBAL_KEY, storageTodos(sortTodos(todos))).then(function () {
return syncNotifications();
}).catch(function (err) {
statusText = tr('ui.saveError', { error: err && err.message ? err.message : String(err) }, 'Could not save todos: ' + (err && err.message ? err.message : String(err))); statusText = tr('ui.saveError', { error: err && err.message ? err.message : String(err) }, 'Could not save todos: ' + (err && err.message ? err.message : String(err)));
statusClass = 'error'; statusClass = 'error';
}); });
} }
function loadStored() { function loadStored() {
if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve(); if (!api || !api.settings || typeof api.settings.read !== 'function') return syncNotifications();
return api.settings.read().then(function (settings) { return api.settings.read().then(function (settings) {
todos = sortTodos(normalizeTodos((settings || {})[GLOBAL_KEY])); todos = sortTodos(normalizeTodos((settings || {})[GLOBAL_KEY]));
return syncNotifications();
}).catch(function (err) { }).catch(function (err) {
statusText = tr('ui.loadError', { error: err && err.message ? err.message : String(err) }, 'Could not load todos: ' + (err && err.message ? err.message : String(err))); statusText = tr('ui.loadError', { error: err && err.message ? err.message : String(err) }, 'Could not load todos: ' + (err && err.message ? err.message : String(err)));
statusClass = 'error'; statusClass = 'error';
@ -589,6 +616,7 @@
searchInput.setAttribute('placeholder', tr('ui.search', null, 'Search todos')); searchInput.setAttribute('placeholder', tr('ui.search', null, 'Search todos'));
addBtn.textContent = tr('ui.add', null, 'Add Todo'); addBtn.textContent = tr('ui.add', null, 'Add Todo');
render(); render();
syncNotifications().then(render);
}); });
} }
}; };

View File

@ -19,6 +19,9 @@
"ui.unassigned": "Unassigned", "ui.unassigned": "Unassigned",
"ui.saveError": "Could not save todos: {error}", "ui.saveError": "Could not save todos: {error}",
"ui.loadError": "Could not load todos: {error}", "ui.loadError": "Could not load todos: {error}",
"ui.notificationError": "Could not schedule reminders: {error}",
"ui.notificationTitle": "Todo reminder",
"ui.notificationBody": "{title}",
"ui.titlePlaceholder": "Todo title", "ui.titlePlaceholder": "Todo title",
"ui.descriptionPlaceholder": "Optional description", "ui.descriptionPlaceholder": "Optional description",
"ui.priority.low": "Low", "ui.priority.low": "Low",

View File

@ -19,6 +19,9 @@
"ui.unassigned": "Не назначено", "ui.unassigned": "Не назначено",
"ui.saveError": "Не удалось сохранить задачи: {error}", "ui.saveError": "Не удалось сохранить задачи: {error}",
"ui.loadError": "Не удалось загрузить задачи: {error}", "ui.loadError": "Не удалось загрузить задачи: {error}",
"ui.notificationError": "Не удалось запланировать напоминания: {error}",
"ui.notificationTitle": "Напоминание о задаче",
"ui.notificationBody": "{title}",
"ui.titlePlaceholder": "Название задачи", "ui.titlePlaceholder": "Название задачи",
"ui.descriptionPlaceholder": "Необязательное описание", "ui.descriptionPlaceholder": "Необязательное описание",
"ui.priority.low": "Низкий", "ui.priority.low": "Низкий",

View File

@ -12,10 +12,14 @@
"todo.list", "todo.list",
"todo.workspace" "todo.workspace"
], ],
"requires": [
"verstak/core/notifications/v1"
],
"permissions": [ "permissions": [
"files.read", "files.read",
"storage.namespace", "storage.namespace",
"ui.register" "ui.register",
"notifications.schedule"
], ],
"frontend": { "frontend": {
"entry": "frontend/src/index.js" "entry": "frontend/src/index.js"

View File

@ -146,10 +146,12 @@ function loadComponent(document, emittedEvents) {
function makeApi(initialSettings = {}, initialLocale = 'en') { function makeApi(initialSettings = {}, initialLocale = 'en') {
const settings = { ...initialSettings }; const settings = { ...initialSettings };
const notificationCalls = [];
let locale = initialLocale; let locale = initialLocale;
const localeListeners = []; const localeListeners = [];
return { return {
settings, settings,
notificationCalls,
settingsApi: { settingsApi: {
read: async (key) => (key ? settings[key] : { ...settings }), read: async (key) => (key ? settings[key] : { ...settings }),
write: async (key, value) => { write: async (key, value) => {
@ -163,6 +165,11 @@ function makeApi(initialSettings = {}, initialLocale = 'en') {
{ name: 'Project', relativePath: 'Project', type: 'folder' }, { name: 'Project', relativePath: 'Project', type: 'folder' },
], ],
}, },
notifications: {
replace: async (items) => {
notificationCalls.push((Array.isArray(items) ? items : []).map((item) => ({ ...item })));
},
},
setLocale(nextLocale) { setLocale(nextLocale) {
locale = nextLocale; locale = nextLocale;
localeListeners.slice().forEach((listener) => listener(locale)); localeListeners.slice().forEach((listener) => listener(locale));
@ -171,6 +178,7 @@ function makeApi(initialSettings = {}, initialLocale = 'en') {
return { return {
settings: this.settingsApi, settings: this.settingsApi,
files: this.files, files: this.files,
notifications: this.notifications,
i18n: { i18n: {
getLocale: () => locale, getLocale: () => locale,
t: (key, params, fallback) => { t: (key, params, fallback) => {
@ -208,6 +216,8 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make
if (manifest.id !== 'verstak.todo') throw new Error('todo manifest id mismatch'); if (manifest.id !== 'verstak.todo') throw new Error('todo manifest id mismatch');
if (!manifest.permissions.includes('storage.namespace')) throw new Error('todo manifest must request storage.namespace'); if (!manifest.permissions.includes('storage.namespace')) throw new Error('todo manifest must request storage.namespace');
if (!manifest.permissions.includes('files.read')) throw new Error('todo manifest must request files.read'); if (!manifest.permissions.includes('files.read')) throw new Error('todo manifest must request files.read');
if (!manifest.permissions.includes('notifications.schedule')) throw new Error('todo manifest must request notifications.schedule');
if (!manifest.requires.includes('verstak/core/notifications/v1')) throw new Error('todo manifest must require native notifications');
if (!(manifest.contributes.views || []).length) throw new Error('todo manifest must contribute a global view'); if (!(manifest.contributes.views || []).length) throw new Error('todo manifest must contribute a global view');
if (!(manifest.contributes.workspaceItems || []).length) throw new Error('todo manifest must contribute a workspace item'); if (!(manifest.contributes.workspaceItems || []).length) throw new Error('todo manifest must contribute a workspace item');
@ -233,6 +243,14 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make
if (createdTodo.status !== 'open' || createdTodo.priority !== 'high') throw new Error('Todo status or priority was not stored'); if (createdTodo.status !== 'open' || createdTodo.priority !== 'high') throw new Error('Todo status or priority was not stored');
if (createdTodo.dueAt !== '2000-01-01' || createdTodo.reminderAt !== '2000-01-01T09:00') throw new Error('Todo due/reminder metadata was not stored'); if (createdTodo.dueAt !== '2000-01-01' || createdTodo.reminderAt !== '2000-01-01T09:00') throw new Error('Todo due/reminder metadata was not stored');
if (!container.textContent.includes('Overdue') || !container.textContent.includes('Reminder due')) throw new Error('due/reminder indicators were not rendered'); if (!container.textContent.includes('Overdue') || !container.textContent.includes('Reminder due')) throw new Error('due/reminder indicators were not rendered');
const scheduledAfterCreate = apiState.notificationCalls.at(-1) || [];
if (scheduledAfterCreate.length !== 1
|| scheduledAfterCreate[0].id !== createdTodo.id
|| scheduledAfterCreate[0].dueAt !== new Date(createdTodo.reminderAt).toISOString()
|| scheduledAfterCreate[0].title !== 'Todo reminder'
|| scheduledAfterCreate[0].body !== 'Prepare project review') {
throw new Error('open Todo reminder was not scheduled as a native notification');
}
apiState.setLocale('ru'); apiState.setLocale('ru');
if (!container.textContent.includes('Задачи · Project') || !container.textContent.includes('Просрочено')) { if (!container.textContent.includes('Задачи · Project') || !container.textContent.includes('Просрочено')) {
@ -252,6 +270,9 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make
if (apiState.settings['todos:global'][0].status !== 'done' || !apiState.settings['todos:global'][0].completedAt) { if (apiState.settings['todos:global'][0].status !== 'done' || !apiState.settings['todos:global'][0].completedAt) {
throw new Error('mark done did not persist completed state'); throw new Error('mark done did not persist completed state');
} }
if ((apiState.notificationCalls.at(-1) || []).length !== 0) {
throw new Error('completed Todo reminder was not removed from native notification schedules');
}
if (!byData(container, 'data-todo-action', 'reopen')) throw new Error('done Todo did not expose reopen action'); if (!byData(container, 'data-todo-action', 'reopen')) throw new Error('done Todo did not expose reopen action');
if (!byData(container, 'data-todo-action', 'create-journal-entry')) throw new Error('done workspace Todo did not expose Journal conversion'); if (!byData(container, 'data-todo-action', 'create-journal-entry')) throw new Error('done workspace Todo did not expose Journal conversion');