TaskDecay v1.0.0 MVP — smart task review & archive Chrome extension
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import { fetchTodoistTasks, closeTodoistTask, deleteTodoistTask } from './todoist.js';
|
||||
import { fetchTickTickTasks, closeTickTickTask, deleteTickTickTask } from './ticktick.js';
|
||||
import { fetchNotionTasks, archiveNotionTask, deleteNotionTask } from './notion.js';
|
||||
export async function fetchAllTasks(providers) {
|
||||
const all = [];
|
||||
const errors = [];
|
||||
if (providers.todoist.enabled && providers.todoist.token) {
|
||||
try {
|
||||
const tasks = await fetchTodoistTasks(providers.todoist);
|
||||
all.push(...tasks);
|
||||
}
|
||||
catch (e) {
|
||||
errors.push(`Todoist: ${e.message}`);
|
||||
}
|
||||
}
|
||||
if (providers.ticktick.enabled && providers.ticktick.token) {
|
||||
try {
|
||||
const tasks = await fetchTickTickTasks(providers.ticktick);
|
||||
all.push(...tasks);
|
||||
}
|
||||
catch (e) {
|
||||
errors.push(`TickTick: ${e.message}`);
|
||||
}
|
||||
}
|
||||
if (providers.notion.enabled && providers.notion.token) {
|
||||
try {
|
||||
const tasks = await fetchNotionTasks(providers.notion);
|
||||
all.push(...tasks);
|
||||
}
|
||||
catch (e) {
|
||||
errors.push(`Notion: ${e.message}`);
|
||||
}
|
||||
}
|
||||
if (errors.length > 0 && all.length === 0) {
|
||||
throw new Error(errors.join('; '));
|
||||
}
|
||||
return all;
|
||||
}
|
||||
export async function completeTask(task, token) {
|
||||
if (task.source === 'todoist')
|
||||
await closeTodoistTask(task.id, token);
|
||||
else if (task.source === 'ticktick')
|
||||
await closeTickTickTask(task.id, token);
|
||||
else if (task.source === 'notion')
|
||||
await archiveNotionTask(task.id, token);
|
||||
}
|
||||
export async function deleteTask(task, token) {
|
||||
if (task.source === 'todoist')
|
||||
await deleteTodoistTask(task.id, token);
|
||||
else if (task.source === 'ticktick')
|
||||
await deleteTickTickTask(task.id, token);
|
||||
else if (task.source === 'notion')
|
||||
await deleteNotionTask(task.id, token);
|
||||
}
|
||||
export function getTokenForSource(source, providers) {
|
||||
if (source === 'todoist')
|
||||
return providers.todoist.token;
|
||||
if (source === 'ticktick')
|
||||
return providers.ticktick.token;
|
||||
if (source === 'notion')
|
||||
return providers.notion.token;
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Notion API — queries a database for tasks
|
||||
export async function fetchNotionTasks(config) {
|
||||
if (!config.token)
|
||||
throw new Error('Notion token not configured');
|
||||
if (!config.projectIds || config.projectIds.length === 0) {
|
||||
throw new Error('Notion database ID not configured. Set a database ID in options.');
|
||||
}
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${config.token}`,
|
||||
'Notion-Version': '2022-06-28',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const allTasks = [];
|
||||
for (const dbId of config.projectIds) {
|
||||
const res = await fetch(`https://api.notion.com/v1/databases/${dbId}/query`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ page_size: 100 }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(`Notion DB query failed for ${dbId}: ${res.status}`);
|
||||
continue;
|
||||
}
|
||||
const data = await res.json();
|
||||
const results = data.results || [];
|
||||
for (const page of results) {
|
||||
const props = page.properties || {};
|
||||
const titleProp = Object.values(props).find((p) => p.type === 'title');
|
||||
const title = titleProp?.title?.[0]?.plain_text || 'Untitled';
|
||||
const statusProp = Object.values(props).find((p) => p.type === 'status' || p.type === 'select' || p.type === 'checkbox');
|
||||
let done = false;
|
||||
if (statusProp?.type === 'checkbox')
|
||||
done = statusProp.checkbox;
|
||||
else if (statusProp?.type === 'status')
|
||||
done = statusProp.status?.name === 'Done';
|
||||
else if (statusProp?.type === 'select')
|
||||
done = statusProp.select?.name === 'Done';
|
||||
if (done)
|
||||
continue;
|
||||
const dateProp = Object.values(props).find((p) => p.type === 'date');
|
||||
const dueDate = dateProp?.date?.start ? new Date(dateProp.date.start) : null;
|
||||
const multiSelect = Object.values(props).filter((p) => p.type === 'multi_select');
|
||||
const labels = multiSelect.flatMap((p) => p.multi_select?.map((s) => s.name) || []);
|
||||
const created = page.created_time ? new Date(page.created_time) : new Date();
|
||||
const updated = page.last_edited_time ? new Date(page.last_edited_time) : created;
|
||||
allTasks.push({
|
||||
id: `notion-${page.id}`,
|
||||
title,
|
||||
priority: 1,
|
||||
createdAt: created,
|
||||
updatedAt: updated,
|
||||
completedAt: null,
|
||||
dueDate,
|
||||
projectId: `notion-${dbId}`,
|
||||
projectName: dbId.slice(0, 8),
|
||||
labels,
|
||||
url: page.url,
|
||||
source: 'notion',
|
||||
raw: page,
|
||||
});
|
||||
}
|
||||
}
|
||||
return allTasks;
|
||||
}
|
||||
export async function archiveNotionTask(taskId, token) {
|
||||
const realId = taskId.replace('notion-', '');
|
||||
const res = await fetch(`https://api.notion.com/v1/pages/${realId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Notion-Version': '2022-06-28',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ archived: true }),
|
||||
});
|
||||
if (!res.ok)
|
||||
throw new Error(`Failed to archive Notion task: ${res.status}`);
|
||||
}
|
||||
export async function deleteNotionTask(taskId, token) {
|
||||
const realId = taskId.replace('notion-', '');
|
||||
const res = await fetch(`https://api.notion.com/v1/blocks/${realId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Notion-Version': '2022-06-28',
|
||||
},
|
||||
});
|
||||
if (!res.ok)
|
||||
throw new Error(`Failed to delete Notion task: ${res.status}`);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// TickTick API v2 — uses login token or OAuth access token
|
||||
export async function fetchTickTickTasks(config) {
|
||||
if (!config.token)
|
||||
throw new Error('TickTick token not configured');
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${config.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
// Get all projects first
|
||||
const projectsRes = await fetch('https://api.ticktick.com/open/v1/project', { headers });
|
||||
const projects = projectsRes.ok ? await projectsRes.json() : [];
|
||||
const projectList = Array.isArray(projects) ? projects : (projects || []);
|
||||
const allTasks = [];
|
||||
for (const proj of projectList) {
|
||||
const pid = proj.id;
|
||||
const tasksRes = await fetch(`https://api.ticktick.com/open/v1/project/${pid}/task`, { headers });
|
||||
if (!tasksRes.ok)
|
||||
continue;
|
||||
const tasks = await tasksRes.json();
|
||||
const taskList = Array.isArray(tasks) ? tasks : (tasks || []);
|
||||
for (const t of taskList) {
|
||||
if (t.status === 2)
|
||||
continue; // completed
|
||||
allTasks.push({
|
||||
id: `ticktick-${t.id}`,
|
||||
title: t.title,
|
||||
priority: (t.priority || 0) + 1,
|
||||
createdAt: new Date(t.createdTime || Date.now()),
|
||||
updatedAt: new Date(t.modifiedTime || t.createdTime || Date.now()),
|
||||
completedAt: null,
|
||||
dueDate: t.dueDate ? new Date(t.dueDate) : null,
|
||||
projectId: `ticktick-${pid}`,
|
||||
projectName: proj.name || null,
|
||||
labels: t.tags || [],
|
||||
url: `https://ticktick.com/webapp/#p/${pid}/task/${t.id}`,
|
||||
source: 'ticktick',
|
||||
raw: t,
|
||||
});
|
||||
}
|
||||
}
|
||||
return allTasks;
|
||||
}
|
||||
export async function closeTickTickTask(taskId, token) {
|
||||
const realId = taskId.replace('ticktick-', '');
|
||||
const res = await fetch(`https://api.ticktick.com/open/v1/task/${realId}/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok)
|
||||
throw new Error(`Failed to close TickTick task: ${res.status}`);
|
||||
}
|
||||
export async function deleteTickTickTask(taskId, token) {
|
||||
const realId = taskId.replace('ticktick-', '');
|
||||
const res = await fetch(`https://api.ticktick.com/open/v1/task/${realId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok)
|
||||
throw new Error(`Failed to delete TickTick task: ${res.status}`);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export async function fetchTodoistTasks(config) {
|
||||
if (!config.token)
|
||||
throw new Error('Todoist token not configured');
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${config.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
// Fetch all active tasks
|
||||
const tasksRes = await fetch('https://api.todoist.com/rest/v2/tasks', { headers });
|
||||
if (!tasksRes.ok) {
|
||||
throw new Error(`Todoist API error: ${tasksRes.status} ${tasksRes.statusText}`);
|
||||
}
|
||||
const tasks = await tasksRes.json();
|
||||
// Fetch projects for names
|
||||
const projectsRes = await fetch('https://api.todoist.com/rest/v2/projects', { headers });
|
||||
const projects = projectsRes.ok ? await projectsRes.json() : [];
|
||||
const projectMap = new Map(projects.map((p) => [p.id, p.name]));
|
||||
return tasks.map((t) => ({
|
||||
id: `todoist-${t.id}`,
|
||||
title: t.content,
|
||||
priority: t.priority || 1,
|
||||
createdAt: new Date(t.created_at || Date.now()),
|
||||
updatedAt: new Date(t.updated_at || t.created_at || Date.now()),
|
||||
completedAt: null,
|
||||
dueDate: t.due?.date ? new Date(t.due.date) : null,
|
||||
projectId: t.project_id ? `todoist-${t.project_id}` : null,
|
||||
projectName: projectMap.get(t.project_id) || null,
|
||||
labels: t.labels || [],
|
||||
url: `https://todoist.com/app/task/${t.id}`,
|
||||
source: 'todoist',
|
||||
raw: t,
|
||||
}));
|
||||
}
|
||||
export async function closeTodoistTask(taskId, token) {
|
||||
const realId = taskId.replace('todoist-', '');
|
||||
const res = await fetch(`https://api.todoist.com/rest/v2/tasks/${realId}/close`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok)
|
||||
throw new Error(`Failed to close Todoist task: ${res.status}`);
|
||||
}
|
||||
export async function deleteTodoistTask(taskId, token) {
|
||||
const realId = taskId.replace('todoist-', '');
|
||||
const res = await fetch(`https://api.todoist.com/rest/v2/tasks/${realId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok)
|
||||
throw new Error(`Failed to delete Todoist task: ${res.status}`);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { fetchAllTasks, getTokenForSource, completeTask, deleteTask } from './apis/index.js';
|
||||
import { getSettings, getStoredTasks, setStoredTasks, setLastSync, addDismissedTask, addArchivedTask } from './storage.js';
|
||||
import { getReviewTasks, getAutoArchiveCandidates } from './scoring.js';
|
||||
// Background service worker for TaskDecay
|
||||
// Handles periodic sync, badge count, and alarm-based review reminders
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
chrome.alarms.create('taskdecay-sync', { periodInMinutes: 60 });
|
||||
chrome.alarms.create('taskdecay-review-reminder', { periodInMinutes: 60 * 24 });
|
||||
updateBadge();
|
||||
});
|
||||
chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
if (alarm.name === 'taskdecay-sync') {
|
||||
await syncTasks();
|
||||
}
|
||||
else if (alarm.name === 'taskdecay-review-reminder') {
|
||||
await checkReviewReminder();
|
||||
}
|
||||
});
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
const msg = message;
|
||||
if (msg.type === 'sync') {
|
||||
syncTasks().then(sendResponse).catch(e => sendResponse({ error: e.message }));
|
||||
return true;
|
||||
}
|
||||
if (msg.type === 'getReviewTasks') {
|
||||
getReviewData().then(sendResponse).catch(e => sendResponse({ error: e.message }));
|
||||
return true;
|
||||
}
|
||||
if (msg.type === 'dismiss') {
|
||||
addDismissedTask(msg.taskId).then(() => sendResponse({ ok: true })).catch(e => sendResponse({ error: e.message }));
|
||||
return true;
|
||||
}
|
||||
if (msg.type === 'archive') {
|
||||
archiveTask(msg.taskId).then(() => sendResponse({ ok: true })).catch(e => sendResponse({ error: e.message }));
|
||||
return true;
|
||||
}
|
||||
if (msg.type === 'complete') {
|
||||
completeTaskById(msg.taskId).then(() => sendResponse({ ok: true })).catch(e => sendResponse({ error: e.message }));
|
||||
return true;
|
||||
}
|
||||
if (msg.type === 'delete') {
|
||||
deleteTaskById(msg.taskId).then(() => sendResponse({ ok: true })).catch(e => sendResponse({ error: e.message }));
|
||||
return true;
|
||||
}
|
||||
if (msg.type === 'openReview') {
|
||||
chrome.tabs.create({ url: chrome.runtime.getURL('src/review.html') });
|
||||
sendResponse({ ok: true });
|
||||
return true;
|
||||
}
|
||||
});
|
||||
async function syncTasks() {
|
||||
const settings = await getSettings();
|
||||
const tasks = await fetchAllTasks(settings.providers);
|
||||
await setStoredTasks(tasks);
|
||||
await setLastSync(new Date().toISOString());
|
||||
await updateBadge();
|
||||
return { count: tasks.length, syncedAt: new Date().toISOString() };
|
||||
}
|
||||
async function getReviewData() {
|
||||
const settings = await getSettings();
|
||||
const tasks = await getStoredTasks();
|
||||
const review = getReviewTasks(tasks, settings);
|
||||
const autoArchive = getAutoArchiveCandidates(tasks, settings);
|
||||
return { review, autoArchiveCount: autoArchive.length, total: tasks.length };
|
||||
}
|
||||
async function archiveTask(taskId) {
|
||||
const settings = await getSettings();
|
||||
const tasks = await getStoredTasks();
|
||||
const task = tasks.find(t => t.id === taskId);
|
||||
if (!task)
|
||||
throw new Error('Task not found');
|
||||
const token = getTokenForSource(task.source, settings.providers);
|
||||
if (!token)
|
||||
throw new Error('No token for source');
|
||||
await completeTask(task, token);
|
||||
await addArchivedTask(taskId);
|
||||
await updateBadge();
|
||||
}
|
||||
async function completeTaskById(taskId) {
|
||||
const settings = await getSettings();
|
||||
const tasks = await getStoredTasks();
|
||||
const task = tasks.find(t => t.id === taskId);
|
||||
if (!task)
|
||||
throw new Error('Task not found');
|
||||
const token = getTokenForSource(task.source, settings.providers);
|
||||
if (!token)
|
||||
throw new Error('No token for source');
|
||||
await completeTask(task, token);
|
||||
await addArchivedTask(taskId);
|
||||
await updateBadge();
|
||||
}
|
||||
async function deleteTaskById(taskId) {
|
||||
const settings = await getSettings();
|
||||
const tasks = await getStoredTasks();
|
||||
const task = tasks.find(t => t.id === taskId);
|
||||
if (!task)
|
||||
throw new Error('Task not found');
|
||||
const token = getTokenForSource(task.source, settings.providers);
|
||||
if (!token)
|
||||
throw new Error('No token for source');
|
||||
await deleteTask(task, token);
|
||||
await addArchivedTask(taskId);
|
||||
await updateBadge();
|
||||
}
|
||||
async function checkReviewReminder() {
|
||||
const settings = await getSettings();
|
||||
if (!settings.lastReviewDate)
|
||||
return;
|
||||
const last = new Date(settings.lastReviewDate);
|
||||
const now = new Date();
|
||||
const daysSince = (now.getTime() - last.getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (daysSince >= settings.reviewIntervalDays) {
|
||||
const tasks = await getStoredTasks();
|
||||
const review = getReviewTasks(tasks, settings);
|
||||
if (review.length > 0) {
|
||||
chrome.notifications.create('review-reminder', {
|
||||
type: 'basic',
|
||||
iconUrl: 'icon128.png',
|
||||
title: 'TaskDecay Review Ready',
|
||||
message: `${review.length} tasks need your attention. Open the review session to clean your backlog.`,
|
||||
buttons: [{ title: 'Start Review' }],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
async function updateBadge() {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
const tasks = await getStoredTasks();
|
||||
const review = getReviewTasks(tasks, settings);
|
||||
const count = review.length;
|
||||
chrome.action.setBadgeText({ text: count > 0 ? String(count) : '' });
|
||||
chrome.action.setBadgeBackgroundColor({ color: '#e74c3c' });
|
||||
}
|
||||
catch {
|
||||
chrome.action.setBadgeText({ text: '' });
|
||||
}
|
||||
}
|
||||
chrome.notifications.onButtonClicked.addListener((notificationId) => {
|
||||
if (notificationId === 'review-reminder') {
|
||||
chrome.tabs.create({ url: chrome.runtime.getURL('src/review.html') });
|
||||
}
|
||||
});
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
const BADGE_CLASS = 'taskdecay-injected-badge';
|
||||
function detectTaskManager() {
|
||||
const host = window.location.host;
|
||||
if (host.includes('todoist.com'))
|
||||
return 'todoist';
|
||||
if (host.includes('ticktick.com'))
|
||||
return 'ticktick';
|
||||
if (host.includes('notion.so'))
|
||||
return 'notion';
|
||||
return null;
|
||||
}
|
||||
function extractTaskIdFromUrl() {
|
||||
const url = window.location.href;
|
||||
const platform = detectTaskManager();
|
||||
if (platform === 'todoist') {
|
||||
const m = url.match(/task\/(\d+)/);
|
||||
if (m)
|
||||
return `todoist-${m[1]}`;
|
||||
}
|
||||
if (platform === 'ticktick') {
|
||||
const m = url.match(/task\/(\w+)/);
|
||||
if (m)
|
||||
return `ticktick-${m[1]}`;
|
||||
}
|
||||
if (platform === 'notion') {
|
||||
const m = url.match(/([a-f0-9]{32})/);
|
||||
if (m)
|
||||
return `notion-${m[1]}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function injectStyles() {
|
||||
if (document.getElementById('taskdecay-styles'))
|
||||
return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'taskdecay-styles';
|
||||
style.textContent = `
|
||||
.taskdecay-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
margin-left: 8px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.taskdecay-badge:hover { opacity: 0.8; }
|
||||
.taskdecay-badge-warm { background: #f39c12; color: #fff; }
|
||||
.taskdecay-badge-hot { background: #e74c3c; color: #fff; }
|
||||
.taskdecay-badge-cold { background: #3498db; color: #fff; }
|
||||
.taskdecay-toast {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: 13px;
|
||||
z-index: 999999;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
max-width: 320px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.taskdecay-toast a {
|
||||
color: #58a6ff;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.taskdecay-toast button {
|
||||
background: #e74c3c;
|
||||
border: none;
|
||||
color: #fff;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
function showToast(message, action) {
|
||||
const existing = document.querySelector('.taskdecay-toast');
|
||||
if (existing)
|
||||
existing.remove();
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'taskdecay-toast';
|
||||
toast.textContent = message;
|
||||
if (action) {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = action.label;
|
||||
btn.onclick = () => { action.onClick(); toast.remove(); };
|
||||
toast.appendChild(btn);
|
||||
}
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 8000);
|
||||
}
|
||||
async function getTaskDecayInfo(taskId) {
|
||||
return new Promise((resolve) => {
|
||||
chrome.runtime.sendMessage({ type: 'getReviewTasks' }, (response) => {
|
||||
if (!response || response.error) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const match = response.review?.find((r) => r.task.id === taskId);
|
||||
if (match) {
|
||||
resolve({
|
||||
taskId: match.task.id,
|
||||
score: match.score.score,
|
||||
ageDays: match.score.ageDays,
|
||||
explanation: match.score.explanation,
|
||||
});
|
||||
}
|
||||
else {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function addBadgeToTodoist(info) {
|
||||
const selectors = [
|
||||
'[data-testid="task-details-modal"] [data-testid="task-title"]',
|
||||
'[data-testid="task-details-modal"] h1',
|
||||
'.task_content',
|
||||
'[data-item-id] .task_content',
|
||||
];
|
||||
for (const sel of selectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el && !el.parentElement?.querySelector('.taskdecay-badge')) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = `taskdecay-badge ${info.score >= 70 ? 'taskdecay-badge-hot' : info.score >= 40 ? 'taskdecay-badge-warm' : 'taskdecay-badge-cold'}`;
|
||||
badge.textContent = `${info.score} decay${info.ageDays > 1 ? ` · ${Math.round(info.ageDays)}d` : ''}`;
|
||||
badge.title = info.explanation;
|
||||
badge.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
chrome.runtime.sendMessage({ type: 'openReview' });
|
||||
};
|
||||
el.parentElement?.insertBefore(badge, el.nextSibling);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
function addBadgeToTickTick(info) {
|
||||
const selectors = [
|
||||
'.task-title',
|
||||
'.task-title-text',
|
||||
'[class*="taskTitle"]',
|
||||
'h1',
|
||||
];
|
||||
for (const sel of selectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el && !el.parentElement?.querySelector('.taskdecay-badge')) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = `taskdecay-badge ${info.score >= 70 ? 'taskdecay-badge-hot' : info.score >= 40 ? 'taskdecay-badge-warm' : 'taskdecay-badge-cold'}`;
|
||||
badge.textContent = `${info.score} decay${info.ageDays > 1 ? ` · ${Math.round(info.ageDays)}d` : ''}`;
|
||||
badge.title = info.explanation;
|
||||
badge.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
chrome.runtime.sendMessage({ type: 'openReview' });
|
||||
};
|
||||
el.parentElement?.insertBefore(badge, el.nextSibling);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
function addBadgeToNotion(info) {
|
||||
const selectors = [
|
||||
'[data-testid="page-title"]',
|
||||
'.notion-page-block',
|
||||
'h1',
|
||||
'[class*="title" i]',
|
||||
];
|
||||
for (const sel of selectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el && !el.parentElement?.querySelector('.taskdecay-badge')) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = `taskdecay-badge ${info.score >= 70 ? 'taskdecay-badge-hot' : info.score >= 40 ? 'taskdecay-badge-warm' : 'taskdecay-badge-cold'}`;
|
||||
badge.textContent = `${info.score} decay${info.ageDays > 1 ? ` · ${Math.round(info.ageDays)}d` : ''}`;
|
||||
badge.title = info.explanation;
|
||||
badge.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
chrome.runtime.sendMessage({ type: 'openReview' });
|
||||
};
|
||||
el.parentElement?.insertBefore(badge, el.nextSibling);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
async function injectBadge() {
|
||||
const taskId = extractTaskIdFromUrl();
|
||||
if (!taskId)
|
||||
return;
|
||||
const info = await getTaskDecayInfo(taskId);
|
||||
if (!info)
|
||||
return;
|
||||
const platform = detectTaskManager();
|
||||
if (platform === 'todoist')
|
||||
addBadgeToTodoist(info);
|
||||
else if (platform === 'ticktick')
|
||||
addBadgeToTickTick(info);
|
||||
else if (platform === 'notion')
|
||||
addBadgeToNotion(info);
|
||||
}
|
||||
function init() {
|
||||
injectStyles();
|
||||
injectBadge();
|
||||
// Re-inject on URL changes (SPA navigation)
|
||||
let lastUrl = window.location.href;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (window.location.href !== lastUrl) {
|
||||
lastUrl = window.location.href;
|
||||
setTimeout(injectBadge, 800);
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
}
|
||||
else {
|
||||
init();
|
||||
}
|
||||
chrome.runtime.onMessage.addListener((message) => {
|
||||
const msg = message;
|
||||
if (msg.type === 'showToast') {
|
||||
showToast(msg.text, msg.action ? { label: msg.action.label, onClick: () => {
|
||||
chrome.runtime.sendMessage({ type: msg.action.type });
|
||||
} } : undefined);
|
||||
}
|
||||
});
|
||||
export {};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { getSettings, setSettings, clearAllTasks } from './storage.js';
|
||||
import { fetchAllTasks } from './apis/index.js';
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const el = (id) => document.getElementById(id);
|
||||
const settings = await getSettings();
|
||||
el('todoistEnabled').checked = settings.providers.todoist.enabled;
|
||||
el('todoistToken').value = settings.providers.todoist.token;
|
||||
el('ticktickEnabled').checked = settings.providers.ticktick.enabled;
|
||||
el('ticktickToken').value = settings.providers.ticktick.token;
|
||||
el('notionEnabled').checked = settings.providers.notion.enabled;
|
||||
el('notionToken').value = settings.providers.notion.token;
|
||||
el('notionDbs').value = (settings.providers.notion.projectIds || []).join(', ');
|
||||
el('decayThreshold').value = String(settings.decayThresholdDays);
|
||||
el('reviewInterval').value = String(settings.reviewIntervalDays);
|
||||
el('maxTasks').value = String(settings.maxTasksPerReview);
|
||||
el('autoArchive').value = String(settings.autoArchiveDays);
|
||||
document.getElementById('saveBtn')?.addEventListener('click', async () => {
|
||||
const status = document.getElementById('status');
|
||||
status.textContent = '';
|
||||
status.className = 'status';
|
||||
const newSettings = {
|
||||
...settings,
|
||||
providers: {
|
||||
todoist: {
|
||||
enabled: el('todoistEnabled').checked,
|
||||
token: el('todoistToken').value.trim(),
|
||||
lastSync: settings.providers.todoist.lastSync,
|
||||
projectIds: null,
|
||||
},
|
||||
ticktick: {
|
||||
enabled: el('ticktickEnabled').checked,
|
||||
token: el('ticktickToken').value.trim(),
|
||||
lastSync: settings.providers.ticktick.lastSync,
|
||||
projectIds: null,
|
||||
},
|
||||
notion: {
|
||||
enabled: el('notionEnabled').checked,
|
||||
token: el('notionToken').value.trim(),
|
||||
lastSync: settings.providers.notion.lastSync,
|
||||
projectIds: el('notionDbs').value.split(',').map(s => s.trim()).filter(Boolean),
|
||||
},
|
||||
},
|
||||
decayThresholdDays: parseInt(el('decayThreshold').value) || 14,
|
||||
reviewIntervalDays: parseInt(el('reviewInterval').value) || 7,
|
||||
maxTasksPerReview: parseInt(el('maxTasks').value) || 20,
|
||||
autoArchiveDays: parseInt(el('autoArchive').value) || 90,
|
||||
};
|
||||
await setSettings(newSettings);
|
||||
status.textContent = 'Settings saved.';
|
||||
status.classList.add('success');
|
||||
});
|
||||
document.getElementById('testBtn')?.addEventListener('click', async () => {
|
||||
const status = document.getElementById('status');
|
||||
status.textContent = 'Testing...';
|
||||
status.className = 'status';
|
||||
try {
|
||||
const testSettings = {
|
||||
...settings,
|
||||
providers: {
|
||||
todoist: { enabled: el('todoistEnabled').checked, token: el('todoistToken').value.trim(), lastSync: null, projectIds: null },
|
||||
ticktick: { enabled: el('ticktickEnabled').checked, token: el('ticktickToken').value.trim(), lastSync: null, projectIds: null },
|
||||
notion: { enabled: el('notionEnabled').checked, token: el('notionToken').value.trim(), lastSync: null, projectIds: el('notionDbs').value.split(',').map(s => s.trim()).filter(Boolean) },
|
||||
},
|
||||
};
|
||||
const tasks = await fetchAllTasks(testSettings.providers);
|
||||
status.textContent = `Connection OK! Found ${tasks.length} tasks across enabled providers.`;
|
||||
status.classList.add('success');
|
||||
}
|
||||
catch (e) {
|
||||
status.textContent = `Error: ${e.message}`;
|
||||
status.classList.add('error');
|
||||
}
|
||||
});
|
||||
document.getElementById('clearBtn')?.addEventListener('click', async () => {
|
||||
if (!confirm('Clear all stored tasks and sync data?'))
|
||||
return;
|
||||
await clearAllTasks();
|
||||
const status = document.getElementById('status');
|
||||
status.textContent = 'All data cleared.';
|
||||
status.className = 'status success';
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const main = document.getElementById('main');
|
||||
const syncStatus = document.getElementById('syncStatus');
|
||||
const countBadge = document.getElementById('countBadge');
|
||||
async function load() {
|
||||
try {
|
||||
const res = await chrome.runtime.sendMessage({ type: 'getReviewTasks' });
|
||||
if (res.error) {
|
||||
syncStatus.textContent = '';
|
||||
renderError(res.error);
|
||||
return;
|
||||
}
|
||||
const review = res.review || [];
|
||||
const total = res.total || 0;
|
||||
const autoArchiveCount = res.autoArchiveCount || 0;
|
||||
syncStatus.textContent = `${total} total tasks synced`;
|
||||
if (review.length === 0) {
|
||||
countBadge.style.display = 'none';
|
||||
renderEmpty();
|
||||
}
|
||||
else {
|
||||
countBadge.textContent = String(review.length);
|
||||
countBadge.style.display = 'inline-block';
|
||||
renderStats(review, autoArchiveCount);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
syncStatus.textContent = '';
|
||||
renderError(e.message);
|
||||
}
|
||||
}
|
||||
function renderEmpty() {
|
||||
main.innerHTML = `
|
||||
<div class="empty">No decayed tasks found. Great job keeping up!</div>
|
||||
<button class="btn btn-secondary" id="syncBtn">Sync Now</button>
|
||||
<a href="options.html" target="_blank" class="settings">Open Settings</a>
|
||||
`;
|
||||
document.getElementById('syncBtn')?.addEventListener('click', doSync);
|
||||
}
|
||||
function renderError(msg) {
|
||||
main.innerHTML = `
|
||||
<div class="error">${escapeHtml(msg)}</div>
|
||||
<button class="btn btn-secondary" id="syncBtn">Retry Sync</button>
|
||||
<a href="options.html" target="_blank" class="settings">Open Settings</a>
|
||||
`;
|
||||
document.getElementById('syncBtn')?.addEventListener('click', doSync);
|
||||
}
|
||||
function renderStats(review, autoArchiveCount) {
|
||||
const hot = review.filter((r) => r.score.score >= 70).length;
|
||||
const warm = review.filter((r) => r.score.score >= 40 && r.score.score < 70).length;
|
||||
const cold = review.filter((r) => r.score.score < 40).length;
|
||||
const oldest = review.reduce((a, b) => a.score.ageDays > b.score.ageDays ? a : b);
|
||||
main.innerHTML = `
|
||||
<div class="stat-row"><span class="stat-label">Hot (≥70)</span><span class="stat-value hot">${hot}</span></div>
|
||||
<div class="stat-row"><span class="stat-label">Warm (40-69)</span><span class="stat-value warm">${warm}</span></div>
|
||||
<div class="stat-row"><span class="stat-label">Cold (<40)</span><span class="stat-value">${cold}</span></div>
|
||||
<div class="stat-row"><span class="stat-label">Oldest task</span><span class="stat-value">${Math.round(oldest.score.ageDays)} days</span></div>
|
||||
${autoArchiveCount > 0 ? `<div class="stat-row"><span class="stat-label">Auto-archive ready</span><span class="stat-value hot">${autoArchiveCount}</span></div>` : ''}
|
||||
<button class="btn btn-primary" id="reviewBtn">Start Review Session</button>
|
||||
<button class="btn btn-secondary" id="syncBtn">Sync Now</button>
|
||||
<a href="options.html" target="_blank" class="settings">Open Settings</a>
|
||||
`;
|
||||
document.getElementById('reviewBtn')?.addEventListener('click', () => {
|
||||
chrome.runtime.sendMessage({ type: 'openReview' });
|
||||
window.close();
|
||||
});
|
||||
document.getElementById('syncBtn')?.addEventListener('click', doSync);
|
||||
}
|
||||
async function doSync() {
|
||||
const btn = document.getElementById('syncBtn');
|
||||
if (btn)
|
||||
btn.textContent = 'Syncing...';
|
||||
try {
|
||||
const res = await chrome.runtime.sendMessage({ type: 'sync' });
|
||||
if (res.error) {
|
||||
renderError(res.error);
|
||||
return;
|
||||
}
|
||||
await load();
|
||||
}
|
||||
catch (e) {
|
||||
renderError(e.message);
|
||||
}
|
||||
}
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
load();
|
||||
});
|
||||
export {};
|
||||
@@ -0,0 +1,96 @@
|
||||
let items = [];
|
||||
let doneIds = new Set();
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const container = document.getElementById('container');
|
||||
const progress = document.getElementById('progress');
|
||||
try {
|
||||
const res = await chrome.runtime.sendMessage({ type: 'getReviewTasks' });
|
||||
if (res.error) {
|
||||
container.innerHTML = `<div class="error">${escapeHtml(res.error)}</div>`;
|
||||
return;
|
||||
}
|
||||
items = res.review || [];
|
||||
render();
|
||||
}
|
||||
catch (e) {
|
||||
container.innerHTML = `<div class="error">${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
function render() {
|
||||
const remaining = items.filter(i => !doneIds.has(i.task.id));
|
||||
progress.textContent = `${items.length - remaining.length} / ${items.length} reviewed`;
|
||||
if (remaining.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<h2>All caught up!</h2>
|
||||
<p>No more decayed tasks to review. Come back next week.</p>
|
||||
</div>
|
||||
`;
|
||||
chrome.runtime.sendMessage({ type: 'setLastReviewDate', date: new Date().toISOString() }).catch(() => { });
|
||||
return;
|
||||
}
|
||||
container.innerHTML = '';
|
||||
for (const item of remaining) {
|
||||
container.appendChild(renderCard(item));
|
||||
}
|
||||
}
|
||||
function renderCard(item) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
card.id = `card-${item.task.id}`;
|
||||
const pillClass = item.score.score >= 70 ? 'score-hot' : item.score.score >= 40 ? 'score-warm' : 'score-cold';
|
||||
card.innerHTML = `
|
||||
<div class="card-header">
|
||||
<div class="title">${escapeHtml(item.task.title)}</div>
|
||||
<div class="score-pill ${pillClass}">${item.score.score} decay</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span>${escapeHtml(item.task.source)}</span>
|
||||
${item.task.projectName ? `<span>Project: ${escapeHtml(item.task.projectName)}</span>` : ''}
|
||||
${item.task.labels.length > 0 ? `<span>Labels: ${escapeHtml(item.task.labels.join(', '))}</span>` : ''}
|
||||
<span>Age: ${Math.round(item.score.ageDays)} days</span>
|
||||
</div>
|
||||
<div class="meta">${escapeHtml(item.score.explanation)}</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-done" data-action="complete" data-id="${item.task.id}">Mark Done</button>
|
||||
<button class="btn btn-archive" data-action="delete" data-id="${item.task.id}">Delete</button>
|
||||
<button class="btn btn-dismiss" data-action="dismiss" data-id="${item.task.id}">Dismiss</button>
|
||||
<a href="${escapeHtml(item.task.url)}" target="_blank" class="btn btn-open">Open</a>
|
||||
</div>
|
||||
`;
|
||||
card.querySelectorAll('button[data-action]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
const target = e.currentTarget;
|
||||
const action = target.getAttribute('data-action');
|
||||
const id = target.getAttribute('data-id');
|
||||
target.textContent = '...';
|
||||
try {
|
||||
if (action === 'complete') {
|
||||
await chrome.runtime.sendMessage({ type: 'complete', taskId: id });
|
||||
}
|
||||
else if (action === 'delete') {
|
||||
await chrome.runtime.sendMessage({ type: 'delete', taskId: id });
|
||||
}
|
||||
else if (action === 'dismiss') {
|
||||
await chrome.runtime.sendMessage({ type: 'dismiss', taskId: id });
|
||||
}
|
||||
doneIds.add(id);
|
||||
const c = document.getElementById(`card-${id}`);
|
||||
if (c) {
|
||||
c.classList.add('done');
|
||||
}
|
||||
setTimeout(render, 300);
|
||||
}
|
||||
catch (err) {
|
||||
target.textContent = 'Error';
|
||||
}
|
||||
});
|
||||
});
|
||||
return card;
|
||||
}
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
});
|
||||
export {};
|
||||
@@ -0,0 +1,72 @@
|
||||
export function scoreDecay(task, settings, allTasks) {
|
||||
const now = Date.now();
|
||||
const ageMs = now - task.createdAt.getTime();
|
||||
const ageDays = ageMs / (1000 * 60 * 60 * 24);
|
||||
// Priority weight: 1 = lowest, 4 = highest; invert so low-priority tasks decay faster
|
||||
const priorityWeight = (5 - task.priority) * 15; // 15-60 range
|
||||
// Project activity: if many tasks in same project are completed recently, project is active
|
||||
const projectTasks = allTasks.filter(t => t.projectId === task.projectId && t.projectId !== null);
|
||||
const recentProjectActivity = projectTasks.filter(t => t.completedAt && (now - t.completedAt.getTime()) < 30 * 24 * 60 * 60 * 1000).length;
|
||||
const projectActivity = Math.min(30, recentProjectActivity * 5); // 0-30
|
||||
// Label density: more labels = more mental overhead = higher decay
|
||||
const labelDensity = task.labels.length * 8;
|
||||
// Due urgency: if overdue, add urgency; if far future, reduce decay
|
||||
let dueUrgency = 0;
|
||||
if (task.dueDate) {
|
||||
const dueMs = task.dueDate.getTime() - now;
|
||||
if (dueMs < 0) {
|
||||
dueUrgency = 20; // overdue
|
||||
}
|
||||
else if (dueMs < 7 * 24 * 60 * 60 * 1000) {
|
||||
dueUrgency = -10; // due soon, reduce decay
|
||||
}
|
||||
}
|
||||
// Auto-archive: if older than autoArchiveDays, cap at 100
|
||||
let score = Math.round((Math.min(ageDays, settings.autoArchiveDays) / settings.autoArchiveDays) * 50 +
|
||||
priorityWeight +
|
||||
projectActivity +
|
||||
labelDensity +
|
||||
dueUrgency);
|
||||
score = Math.max(0, Math.min(100, score));
|
||||
let explanation = `Age: ${Math.round(ageDays)} days`;
|
||||
if (priorityWeight > 30)
|
||||
explanation += `; Low priority (${task.priority})`;
|
||||
if (projectActivity > 10)
|
||||
explanation += `; Active project`;
|
||||
if (labelDensity > 0)
|
||||
explanation += `; ${task.labels.length} labels`;
|
||||
if (dueUrgency > 0)
|
||||
explanation += `; Overdue`;
|
||||
if (dueUrgency < 0)
|
||||
explanation += `; Due soon`;
|
||||
return {
|
||||
score,
|
||||
ageDays: Math.round(ageDays * 10) / 10,
|
||||
priorityWeight,
|
||||
projectActivity,
|
||||
labelDensity,
|
||||
dueUrgency,
|
||||
explanation,
|
||||
};
|
||||
}
|
||||
export function getReviewTasks(tasks, settings) {
|
||||
const scored = tasks
|
||||
.filter(t => !t.completedAt)
|
||||
.filter(t => {
|
||||
const ageDays = (Date.now() - t.createdAt.getTime()) / (1000 * 60 * 60 * 24);
|
||||
return ageDays >= settings.decayThresholdDays;
|
||||
})
|
||||
.filter(t => !settings.dismissedTasks.includes(t.id))
|
||||
.filter(t => !settings.archivedTasks.includes(t.id))
|
||||
.map(t => ({ task: t, score: scoreDecay(t, settings, tasks) }))
|
||||
.sort((a, b) => b.score.score - a.score.score);
|
||||
return scored.slice(0, settings.maxTasksPerReview);
|
||||
}
|
||||
export function getAutoArchiveCandidates(tasks, settings) {
|
||||
return tasks.filter(t => {
|
||||
if (t.completedAt)
|
||||
return false;
|
||||
const ageDays = (Date.now() - t.createdAt.getTime()) / (1000 * 60 * 60 * 24);
|
||||
return ageDays >= settings.autoArchiveDays && !settings.archivedTasks.includes(t.id);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { DEFAULT_SETTINGS } from './types.js';
|
||||
const SETTINGS_KEY = 'taskdecay_settings_v1';
|
||||
const TASKS_KEY = 'taskdecay_tasks_v1';
|
||||
const SYNC_KEY = 'taskdecay_last_sync_v1';
|
||||
export async function getSettings() {
|
||||
const result = await chrome.storage.local.get([SETTINGS_KEY]);
|
||||
const stored = result[SETTINGS_KEY];
|
||||
if (!stored)
|
||||
return JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
||||
const parsed = typeof stored === 'string' ? JSON.parse(stored) : stored;
|
||||
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||
}
|
||||
export async function setSettings(settings) {
|
||||
await chrome.storage.local.set({ [SETTINGS_KEY]: JSON.stringify(settings) });
|
||||
}
|
||||
export async function getStoredTasks() {
|
||||
const result = await chrome.storage.local.get([TASKS_KEY]);
|
||||
const stored = result[TASKS_KEY];
|
||||
if (!stored)
|
||||
return [];
|
||||
const parsed = typeof stored === 'string' ? JSON.parse(stored) : stored;
|
||||
return parsed.map((t) => ({
|
||||
...t,
|
||||
createdAt: new Date(t.createdAt),
|
||||
updatedAt: new Date(t.updatedAt),
|
||||
completedAt: t.completedAt ? new Date(t.completedAt) : null,
|
||||
dueDate: t.dueDate ? new Date(t.dueDate) : null,
|
||||
}));
|
||||
}
|
||||
export async function setStoredTasks(tasks) {
|
||||
await chrome.storage.local.set({ [TASKS_KEY]: JSON.stringify(tasks) });
|
||||
}
|
||||
export async function getLastSync() {
|
||||
const result = await chrome.storage.local.get([SYNC_KEY]);
|
||||
return result[SYNC_KEY] || null;
|
||||
}
|
||||
export async function setLastSync(date) {
|
||||
await chrome.storage.local.set({ [SYNC_KEY]: date });
|
||||
}
|
||||
export async function addDismissedTask(taskId) {
|
||||
const settings = await getSettings();
|
||||
if (!settings.dismissedTasks.includes(taskId)) {
|
||||
settings.dismissedTasks.push(taskId);
|
||||
await setSettings(settings);
|
||||
}
|
||||
}
|
||||
export async function addArchivedTask(taskId) {
|
||||
const settings = await getSettings();
|
||||
if (!settings.archivedTasks.includes(taskId)) {
|
||||
settings.archivedTasks.push(taskId);
|
||||
await setSettings(settings);
|
||||
}
|
||||
}
|
||||
export async function clearAllTasks() {
|
||||
await chrome.storage.local.remove([TASKS_KEY, SYNC_KEY]);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export const DEFAULT_SETTINGS = {
|
||||
providers: {
|
||||
todoist: { enabled: false, token: '', lastSync: null, projectIds: null },
|
||||
ticktick: { enabled: false, token: '', lastSync: null, projectIds: null },
|
||||
notion: { enabled: false, token: '', lastSync: null, projectIds: null },
|
||||
},
|
||||
decayThresholdDays: 14,
|
||||
reviewIntervalDays: 7,
|
||||
maxTasksPerReview: 20,
|
||||
autoArchiveDays: 90,
|
||||
lastReviewDate: null,
|
||||
dismissedTasks: [],
|
||||
archivedTasks: [],
|
||||
};
|
||||
Reference in New Issue
Block a user