TaskDecay v1.0.0 MVP — smart task review & archive Chrome extension
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.verdict
|
||||
*.log
|
||||
.venv/
|
||||
.DS_Store
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 202 B |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 202 B |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 202 B |
@@ -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: [],
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "TaskDecay",
|
||||
"version": "1.0.0",
|
||||
"description": "Smart Task Review & Archive — surface buried tasks, score by age + priority, and clean your backlog with one click.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
"activeTab",
|
||||
"alarms"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://api.todoist.com/*",
|
||||
"https://api.ticktick.com/*",
|
||||
"https://api.notion.com/*",
|
||||
"https://todoist.com/*",
|
||||
"https://ticktick.com/*",
|
||||
"https://notion.so/*"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "src/background.ts"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "src/popup.html",
|
||||
"default_icon": {
|
||||
"16": "icon16.png",
|
||||
"48": "icon48.png",
|
||||
"128": "icon128.png"
|
||||
}
|
||||
},
|
||||
"options_page": "src/options.html",
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["https://todoist.com/*", "https://ticktick.com/*", "https://www.notion.so/*", "https://notion.so/*"],
|
||||
"js": ["src/content.ts"],
|
||||
"css": ["src/content.css"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"icons": {
|
||||
"16": "icon16.png",
|
||||
"48": "icon48.png",
|
||||
"128": "icon128.png"
|
||||
}
|
||||
}
|
||||
Generated
+2003
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "taskdecay",
|
||||
"version": "1.0.0",
|
||||
"description": "Smart Task Review & Archive for Buried Tasks — Chrome Extension",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "tsx --test src/scoring.test.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@crxjs/vite-plugin": "^2.0.0-beta.28",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"tsx": "^4.22.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Task, ProviderConfig } from '../types.js';
|
||||
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: {
|
||||
todoist: ProviderConfig;
|
||||
ticktick: ProviderConfig;
|
||||
notion: ProviderConfig;
|
||||
}): Promise<Task[]> {
|
||||
const all: Task[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
if (providers.todoist.enabled && providers.todoist.token) {
|
||||
try {
|
||||
const tasks = await fetchTodoistTasks(providers.todoist);
|
||||
all.push(...tasks);
|
||||
} catch (e) {
|
||||
errors.push(`Todoist: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (providers.ticktick.enabled && providers.ticktick.token) {
|
||||
try {
|
||||
const tasks = await fetchTickTickTasks(providers.ticktick);
|
||||
all.push(...tasks);
|
||||
} catch (e) {
|
||||
errors.push(`TickTick: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (providers.notion.enabled && providers.notion.token) {
|
||||
try {
|
||||
const tasks = await fetchNotionTasks(providers.notion);
|
||||
all.push(...tasks);
|
||||
} catch (e) {
|
||||
errors.push(`Notion: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0 && all.length === 0) {
|
||||
throw new Error(errors.join('; '));
|
||||
}
|
||||
|
||||
return all;
|
||||
}
|
||||
|
||||
export async function completeTask(task: Task, token: string): Promise<void> {
|
||||
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: Task, token: string): Promise<void> {
|
||||
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: string, providers: {
|
||||
todoist: ProviderConfig;
|
||||
ticktick: ProviderConfig;
|
||||
notion: ProviderConfig;
|
||||
}): string {
|
||||
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,99 @@
|
||||
import { Task, ProviderConfig } from '../types.js';
|
||||
|
||||
// Notion API — queries a database for tasks
|
||||
export async function fetchNotionTasks(config: ProviderConfig): Promise<Task[]> {
|
||||
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: Task[] = [];
|
||||
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: any) => p.type === 'title') as any;
|
||||
const title = titleProp?.title?.[0]?.plain_text || 'Untitled';
|
||||
|
||||
const statusProp = Object.values(props).find((p: any) =>
|
||||
p.type === 'status' || p.type === 'select' || p.type === 'checkbox'
|
||||
) as any;
|
||||
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: any) => p.type === 'date') as any;
|
||||
const dueDate = dateProp?.date?.start ? new Date(dateProp.date.start) : null;
|
||||
|
||||
const multiSelect = Object.values(props).filter((p: any) => p.type === 'multi_select') as any[];
|
||||
const labels = multiSelect.flatMap((p: any) => p.multi_select?.map((s: any) => 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: string, token: string): Promise<void> {
|
||||
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: string, token: string): Promise<void> {
|
||||
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,63 @@
|
||||
import { Task, ProviderConfig } from '../types.js';
|
||||
|
||||
// TickTick API v2 — uses login token or OAuth access token
|
||||
export async function fetchTickTickTasks(config: ProviderConfig): Promise<Task[]> {
|
||||
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: Task[] = [];
|
||||
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: string, token: string): Promise<void> {
|
||||
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: string, token: string): Promise<void> {
|
||||
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,56 @@
|
||||
import { Task, ProviderConfig } from './types.js';
|
||||
|
||||
export async function fetchTodoistTasks(config: ProviderConfig): Promise<Task[]> {
|
||||
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: any) => [p.id, p.name]));
|
||||
|
||||
return tasks.map((t: any): Task => ({
|
||||
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: string, token: string): Promise<void> {
|
||||
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: string, token: string): Promise<void> {
|
||||
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,146 @@
|
||||
import { fetchAllTasks, getTokenForSource, completeTask, deleteTask } from './apis/index.js';
|
||||
import { getSettings, setSettings, 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 as any;
|
||||
if (msg.type === 'sync') {
|
||||
syncTasks().then(sendResponse).catch(e => sendResponse({ error: (e as Error).message }));
|
||||
return true;
|
||||
}
|
||||
if (msg.type === 'getReviewTasks') {
|
||||
getReviewData().then(sendResponse).catch(e => sendResponse({ error: (e as Error).message }));
|
||||
return true;
|
||||
}
|
||||
if (msg.type === 'dismiss') {
|
||||
addDismissedTask(msg.taskId).then(() => sendResponse({ ok: true })).catch(e => sendResponse({ error: (e as Error).message }));
|
||||
return true;
|
||||
}
|
||||
if (msg.type === 'archive') {
|
||||
archiveTask(msg.taskId).then(() => sendResponse({ ok: true })).catch(e => sendResponse({ error: (e as Error).message }));
|
||||
return true;
|
||||
}
|
||||
if (msg.type === 'complete') {
|
||||
completeTaskById(msg.taskId).then(() => sendResponse({ ok: true })).catch(e => sendResponse({ error: (e as Error).message }));
|
||||
return true;
|
||||
}
|
||||
if (msg.type === 'delete') {
|
||||
deleteTaskById(msg.taskId).then(() => sendResponse({ ok: true })).catch(e => sendResponse({ error: (e as Error).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: string) {
|
||||
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: string) {
|
||||
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: string) {
|
||||
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') });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
.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; }
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
const BADGE_CLASS = 'taskdecay-injected-badge';
|
||||
|
||||
interface DecayedTaskInfo {
|
||||
taskId: string;
|
||||
score: number;
|
||||
ageDays: number;
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
function detectTaskManager(): 'todoist' | 'ticktick' | 'notion' | null {
|
||||
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(): string | null {
|
||||
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: string, action?: { label: string; onClick: () => void }) {
|
||||
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: string): Promise<DecayedTaskInfo | null> {
|
||||
return new Promise((resolve) => {
|
||||
chrome.runtime.sendMessage({ type: 'getReviewTasks' }, (response) => {
|
||||
if (!response || response.error) { resolve(null); return; }
|
||||
const match = response.review?.find((r: any) => 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: DecayedTaskInfo) {
|
||||
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: DecayedTaskInfo) {
|
||||
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: DecayedTaskInfo) {
|
||||
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 as any;
|
||||
if (msg.type === 'showToast') {
|
||||
showToast(msg.text, msg.action ? { label: msg.action.label, onClick: () => {
|
||||
chrome.runtime.sendMessage({ type: msg.action.type });
|
||||
}} : undefined);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TaskDecay Settings</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
max-width: 640px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #0f0f0f;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
h1 { font-size: 22px; margin: 0 0 20px; }
|
||||
h2 { font-size: 16px; margin: 24px 0 12px; border-bottom: 1px solid #222; padding-bottom: 8px; }
|
||||
.section { background: #161616; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
|
||||
label { display: block; font-size: 13px; color: #888; margin-bottom: 4px; }
|
||||
input[type="text"], input[type="number"], input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #333;
|
||||
background: #0f0f0f;
|
||||
color: #e0e0e0;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
input:focus { outline: none; border-color: #2ecc71; }
|
||||
.row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
|
||||
input[type="checkbox"] { width: 18px; height: 18px; accent-color: #2ecc71; }
|
||||
.btn {
|
||||
padding: 10px 16px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary { background: #2ecc71; color: #0f0f0f; }
|
||||
.btn-secondary { background: #1a1a1a; color: #ccc; border: 1px solid #333; }
|
||||
.status { font-size: 13px; margin-top: 10px; }
|
||||
.success { color: #2ecc71; }
|
||||
.error { color: #e74c3c; }
|
||||
.hint { font-size: 12px; color: #666; margin-top: -8px; margin-bottom: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>TaskDecay Settings</h1>
|
||||
<div class="section">
|
||||
<h2>Providers</h2>
|
||||
<div class="row">
|
||||
<input type="checkbox" id="todoistEnabled" />
|
||||
<label for="todoistEnabled" style="margin:0">Enable Todoist</label>
|
||||
</div>
|
||||
<label for="todoistToken">Todoist API Token</label>
|
||||
<input type="password" id="todoistToken" placeholder="Paste your Todoist API token" />
|
||||
<div class="hint">Get token at <a href="https://todoist.com/app/settings/integrations/developer" target="_blank" style="color:#58a6ff">Settings → Integrations</a></div>
|
||||
|
||||
<div class="row">
|
||||
<input type="checkbox" id="ticktickEnabled" />
|
||||
<label for="ticktickEnabled" style="margin:0">Enable TickTick</label>
|
||||
</div>
|
||||
<label for="ticktickToken">TickTick Access Token</label>
|
||||
<input type="password" id="ticktickToken" placeholder="Paste your TickTick token" />
|
||||
<div class="hint">Get token from <a href="https://developer.ticktick.com" target="_blank" style="color:#58a6ff">TickTick Developer Portal</a></div>
|
||||
|
||||
<div class="row">
|
||||
<input type="checkbox" id="notionEnabled" />
|
||||
<label for="notionEnabled" style="margin:0">Enable Notion</label>
|
||||
</div>
|
||||
<label for="notionToken">Notion Integration Token</label>
|
||||
<input type="password" id="notionToken" placeholder="Paste your Notion integration token" />
|
||||
<div class="hint">Create at <a href="https://www.notion.so/my-integrations" target="_blank" style="color:#58a6ff">Notion Integrations</a></div>
|
||||
<label for="notionDbs">Notion Database IDs (comma-separated)</label>
|
||||
<input type="text" id="notionDbs" placeholder="db-id-1, db-id-2" />
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Behavior</h2>
|
||||
<label for="decayThreshold">Decay threshold (days)</label>
|
||||
<input type="number" id="decayThreshold" min="1" max="365" />
|
||||
<label for="reviewInterval">Review interval (days)</label>
|
||||
<input type="number" id="reviewInterval" min="1" max="90" />
|
||||
<label for="maxTasks">Max tasks per review</label>
|
||||
<input type="number" id="maxTasks" min="5" max="100" />
|
||||
<label for="autoArchive">Auto-archive after (days)</label>
|
||||
<input type="number" id="autoArchive" min="7" max="365" />
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" id="saveBtn">Save Settings</button>
|
||||
<button class="btn btn-secondary" id="testBtn" style="margin-left:8px">Test Connection</button>
|
||||
<button class="btn btn-secondary" id="clearBtn" style="margin-left:8px">Clear Data</button>
|
||||
<div id="status" class="status"></div>
|
||||
|
||||
<script type="module" src="options.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,87 @@
|
||||
import { getSettings, setSettings, clearAllTasks } from './storage.js';
|
||||
import { fetchAllTasks } from './apis/index.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const el = (id: string) => document.getElementById(id) as HTMLInputElement;
|
||||
|
||||
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 as Error).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,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TaskDecay</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
width: 360px;
|
||||
min-height: 200px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #0f0f0f;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
.header {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #222;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.header h1 { margin: 0; font-size: 16px; font-weight: 700; color: #fff; }
|
||||
.header .badge {
|
||||
background: #e74c3c;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.body { padding: 12px 16px; }
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
font-size: 13px;
|
||||
}
|
||||
.stat-row:last-child { border-bottom: none; }
|
||||
.stat-label { color: #888; }
|
||||
.stat-value { font-weight: 600; color: #fff; }
|
||||
.hot { color: #e74c3c; }
|
||||
.warm { color: #f39c12; }
|
||||
.btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-primary { background: #2ecc71; color: #0f0f0f; }
|
||||
.btn-secondary { background: #1a1a1a; color: #ccc; border: 1px solid #333; }
|
||||
.btn:hover { opacity: 0.9; }
|
||||
.error { color: #e74c3c; font-size: 12px; padding: 8px 0; }
|
||||
.sync-status { font-size: 11px; color: #666; padding: 4px 0; }
|
||||
.empty { color: #666; font-size: 13px; text-align: center; padding: 16px 0; }
|
||||
a.settings { color: #888; font-size: 12px; display: block; text-align: center; margin-top: 8px; text-decoration: none; }
|
||||
a.settings:hover { color: #ccc; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>TaskDecay</h1>
|
||||
<span id="countBadge" class="badge" style="display:none">0</span>
|
||||
</div>
|
||||
<div class="body" id="main">
|
||||
<div id="syncStatus" class="sync-status">Loading...</div>
|
||||
</div>
|
||||
<script type="module" src="popup.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,98 @@
|
||||
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 as Error).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: string) {
|
||||
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: any[], autoArchiveCount: number) {
|
||||
const hot = review.filter((r: any) => r.score.score >= 70).length;
|
||||
const warm = review.filter((r: any) => r.score.score >= 40 && r.score.score < 70).length;
|
||||
const cold = review.filter((r: any) => r.score.score < 40).length;
|
||||
const oldest = review.reduce((a: any, b: any) => 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') as HTMLButtonElement;
|
||||
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 as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
load();
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TaskDecay Review Session</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #0f0f0f;
|
||||
color: #e0e0e0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #222;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.header h1 { margin: 0; font-size: 20px; }
|
||||
.progress { font-size: 14px; color: #888; }
|
||||
.container { max-width: 720px; margin: 0 auto; padding: 24px; }
|
||||
.card {
|
||||
background: #161616;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #222;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.title { font-size: 16px; font-weight: 600; color: #fff; line-height: 1.4; }
|
||||
.score-pill {
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.score-hot { background: #e74c3c; color: #fff; }
|
||||
.score-warm { background: #f39c12; color: #fff; }
|
||||
.score-cold { background: #3498db; color: #fff; }
|
||||
.meta { font-size: 12px; color: #888; margin-bottom: 12px; }
|
||||
.meta span { margin-right: 12px; }
|
||||
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.btn {
|
||||
padding: 8px 14px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-done { background: #2ecc71; color: #0f0f0f; }
|
||||
.btn-archive { background: #e74c3c; color: #fff; }
|
||||
.btn-dismiss { background: #1a1a1a; color: #888; border: 1px solid #333; }
|
||||
.btn-open { background: #1a1a1a; color: #58a6ff; border: 1px solid #333; text-decoration: none; }
|
||||
.btn:hover { opacity: 0.9; }
|
||||
.done { opacity: 0.4; }
|
||||
.done .title { text-decoration: line-through; }
|
||||
.empty-state { text-align: center; padding: 60px 20px; }
|
||||
.empty-state h2 { font-size: 18px; margin-bottom: 8px; }
|
||||
.empty-state p { color: #888; font-size: 14px; }
|
||||
.error { color: #e74c3c; padding: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>TaskDecay Review Session</h1>
|
||||
<div class="progress" id="progress">Loading...</div>
|
||||
</div>
|
||||
<div class="container" id="container">
|
||||
<div class="empty-state">
|
||||
<h2>Loading your backlog...</h2>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="review.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
interface ReviewItem {
|
||||
task: {
|
||||
id: string;
|
||||
title: string;
|
||||
source: string;
|
||||
projectName: string | null;
|
||||
labels: string[];
|
||||
url: string;
|
||||
};
|
||||
score: {
|
||||
score: number;
|
||||
ageDays: number;
|
||||
explanation: string;
|
||||
};
|
||||
}
|
||||
|
||||
let items: ReviewItem[] = [];
|
||||
let doneIds = new Set<string>();
|
||||
|
||||
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 as Error).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: ReviewItem): HTMLElement {
|
||||
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 as HTMLElement;
|
||||
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: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { scoreDecay, getReviewTasks, getAutoArchiveCandidates } from './scoring.js';
|
||||
import assert from 'node:assert';
|
||||
import test from 'node:test';
|
||||
|
||||
function makeTask(overrides = {}) {
|
||||
return {
|
||||
id: 't-1',
|
||||
title: 'Test task',
|
||||
priority: 1,
|
||||
createdAt: new Date(Date.now() - 20 * 86400000),
|
||||
updatedAt: new Date(),
|
||||
completedAt: null,
|
||||
dueDate: null,
|
||||
projectId: 'p-1',
|
||||
projectName: 'Project A',
|
||||
labels: [],
|
||||
url: 'https://example.com',
|
||||
source: 'todoist',
|
||||
raw: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const settings = {
|
||||
providers: {
|
||||
todoist: { enabled: true, 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: [],
|
||||
};
|
||||
|
||||
test('scoreDecay gives higher score for older tasks', () => {
|
||||
const old = makeTask({ createdAt: new Date(Date.now() - 60 * 86400000) });
|
||||
const young = makeTask({ createdAt: new Date(Date.now() - 5 * 86400000) });
|
||||
const all = [old, young];
|
||||
const sOld = scoreDecay(old, settings, all);
|
||||
const sYoung = scoreDecay(young, settings, all);
|
||||
assert(sOld.score > sYoung.score, 'older task should have higher decay score');
|
||||
});
|
||||
|
||||
test('scoreDecay caps at 100 and floors at 0', () => {
|
||||
const t = makeTask({ createdAt: new Date(Date.now() - 500 * 86400000) });
|
||||
const s = scoreDecay(t, settings, [t]);
|
||||
assert(s.score >= 0 && s.score <= 100, 'score should be within [0,100]');
|
||||
});
|
||||
|
||||
test('getReviewTasks filters by age threshold and sorts by score', () => {
|
||||
const a = makeTask({ id: 'a', createdAt: new Date(Date.now() - 30 * 86400000), priority: 1 });
|
||||
const b = makeTask({ id: 'b', createdAt: new Date(Date.now() - 10 * 86400000), priority: 4 });
|
||||
const c = makeTask({ id: 'c', createdAt: new Date(Date.now() - 100 * 86400000), priority: 1 });
|
||||
const review = getReviewTasks([a, b, c], settings);
|
||||
assert.strictEqual(review.length, 2, 'should exclude task under threshold');
|
||||
assert.strictEqual(review[0].task.id, 'c', 'highest decay should be first');
|
||||
});
|
||||
|
||||
test('getReviewTasks excludes completed and dismissed', () => {
|
||||
const t = makeTask({ id: 'x', completedAt: new Date() });
|
||||
const review = getReviewTasks([t], settings);
|
||||
assert.strictEqual(review.length, 0, 'completed should be excluded');
|
||||
});
|
||||
|
||||
test('getAutoArchiveCandidates selects old unarchived tasks', () => {
|
||||
const old = makeTask({ id: 'old', createdAt: new Date(Date.now() - 100 * 86400000) });
|
||||
const young = makeTask({ id: 'young', createdAt: new Date(Date.now() - 10 * 86400000) });
|
||||
const cand = getAutoArchiveCandidates([old, young], settings);
|
||||
assert.strictEqual(cand.length, 1);
|
||||
assert.strictEqual(cand[0].id, 'old');
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Task, DecayScore, Settings } from './types.js';
|
||||
|
||||
export function scoreDecay(task: Task, settings: Settings, allTasks: Task[]): DecayScore {
|
||||
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: Task[],
|
||||
settings: Settings
|
||||
): { task: Task; score: DecayScore }[] {
|
||||
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: Task[],
|
||||
settings: Settings
|
||||
): Task[] {
|
||||
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,64 @@
|
||||
import { Settings, DEFAULT_SETTINGS, Task } 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(): Promise<Settings> {
|
||||
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: Settings): Promise<void> {
|
||||
await chrome.storage.local.set({ [SETTINGS_KEY]: JSON.stringify(settings) });
|
||||
}
|
||||
|
||||
export async function getStoredTasks(): Promise<Task[]> {
|
||||
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: Task) => ({
|
||||
...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: Task[]): Promise<void> {
|
||||
await chrome.storage.local.set({ [TASKS_KEY]: JSON.stringify(tasks) });
|
||||
}
|
||||
|
||||
export async function getLastSync(): Promise<string | null> {
|
||||
const result = await chrome.storage.local.get([SYNC_KEY]);
|
||||
return result[SYNC_KEY] || null;
|
||||
}
|
||||
|
||||
export async function setLastSync(date: string): Promise<void> {
|
||||
await chrome.storage.local.set({ [SYNC_KEY]: date });
|
||||
}
|
||||
|
||||
export async function addDismissedTask(taskId: string): Promise<void> {
|
||||
const settings = await getSettings();
|
||||
if (!settings.dismissedTasks.includes(taskId)) {
|
||||
settings.dismissedTasks.push(taskId);
|
||||
await setSettings(settings);
|
||||
}
|
||||
}
|
||||
|
||||
export async function addArchivedTask(taskId: string): Promise<void> {
|
||||
const settings = await getSettings();
|
||||
if (!settings.archivedTasks.includes(taskId)) {
|
||||
settings.archivedTasks.push(taskId);
|
||||
await setSettings(settings);
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearAllTasks(): Promise<void> {
|
||||
await chrome.storage.local.remove([TASKS_KEY, SYNC_KEY]);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
priority: number; // 1-4 normalized
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
completedAt: Date | null;
|
||||
dueDate: Date | null;
|
||||
projectId: string | null;
|
||||
projectName: string | null;
|
||||
labels: string[];
|
||||
url: string;
|
||||
source: 'todoist' | 'ticktick' | 'notion';
|
||||
raw: unknown;
|
||||
}
|
||||
|
||||
export interface DecayScore {
|
||||
score: number; // 0-100, higher = more decayed/needs attention
|
||||
ageDays: number;
|
||||
priorityWeight: number;
|
||||
projectActivity: number;
|
||||
labelDensity: number;
|
||||
dueUrgency: number;
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
export interface ProviderConfig {
|
||||
enabled: boolean;
|
||||
token: string;
|
||||
lastSync: string | null;
|
||||
projectIds: string[] | null;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
providers: {
|
||||
todoist: ProviderConfig;
|
||||
ticktick: ProviderConfig;
|
||||
notion: ProviderConfig;
|
||||
};
|
||||
decayThresholdDays: number;
|
||||
reviewIntervalDays: number;
|
||||
maxTasksPerReview: number;
|
||||
autoArchiveDays: number;
|
||||
lastReviewDate: string | null;
|
||||
dismissedTasks: string[];
|
||||
archivedTasks: string[];
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: 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: [],
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "lib",
|
||||
"rootDir": "src",
|
||||
"strict": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": false,
|
||||
"sourceMap": false
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { crx } from '@crxjs/vite-plugin'
|
||||
import manifest from './manifest.json' assert { type: 'json' }
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
popup: 'src/popup.html',
|
||||
options: 'src/options.html',
|
||||
review: 'src/review.html'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [crx({ manifest })]
|
||||
})
|
||||
Reference in New Issue
Block a user