feat: schedule Todo native reminders
This commit is contained in:
parent
7e34a73835
commit
ed5e1ca9cd
|
|
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "Низкий",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue