TaskDecay v1.0.0 MVP — smart task review & archive Chrome extension

This commit is contained in:
Bun Bun
2026-06-17 06:48:47 +00:00
commit cc9aee68ee
38 changed files with 4710 additions and 0 deletions
+63
View File
@@ -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 '';
}
+90
View File
@@ -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}`);
}
+60
View File
@@ -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}`);
}
+51
View File
@@ -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}`);
}
+143
View File
@@ -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
View File
@@ -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 {};
+82
View File
@@ -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';
});
});
+92
View File
@@ -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 {};
+96
View File
@@ -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 {};
+72
View File
@@ -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);
});
}
+56
View File
@@ -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]);
}
+14
View File
@@ -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: [],
};