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

This commit is contained in:
Bun Bun
2026-06-17 06:48:47 +00:00
commit cc9aee68ee
38 changed files with 4710 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
import { fetchTodoistTasks, closeTodoistTask, deleteTodoistTask } from './todoist.js';
import { fetchTickTickTasks, closeTickTickTask, deleteTickTickTask } from './ticktick.js';
import { fetchNotionTasks, archiveNotionTask, deleteNotionTask } from './notion.js';
export async function fetchAllTasks(providers) {
const all = [];
const errors = [];
if (providers.todoist.enabled && providers.todoist.token) {
try {
const tasks = await fetchTodoistTasks(providers.todoist);
all.push(...tasks);
}
catch (e) {
errors.push(`Todoist: ${e.message}`);
}
}
if (providers.ticktick.enabled && providers.ticktick.token) {
try {
const tasks = await fetchTickTickTasks(providers.ticktick);
all.push(...tasks);
}
catch (e) {
errors.push(`TickTick: ${e.message}`);
}
}
if (providers.notion.enabled && providers.notion.token) {
try {
const tasks = await fetchNotionTasks(providers.notion);
all.push(...tasks);
}
catch (e) {
errors.push(`Notion: ${e.message}`);
}
}
if (errors.length > 0 && all.length === 0) {
throw new Error(errors.join('; '));
}
return all;
}
export async function completeTask(task, token) {
if (task.source === 'todoist')
await closeTodoistTask(task.id, token);
else if (task.source === 'ticktick')
await closeTickTickTask(task.id, token);
else if (task.source === 'notion')
await archiveNotionTask(task.id, token);
}
export async function deleteTask(task, token) {
if (task.source === 'todoist')
await deleteTodoistTask(task.id, token);
else if (task.source === 'ticktick')
await deleteTickTickTask(task.id, token);
else if (task.source === 'notion')
await deleteNotionTask(task.id, token);
}
export function getTokenForSource(source, providers) {
if (source === 'todoist')
return providers.todoist.token;
if (source === 'ticktick')
return providers.ticktick.token;
if (source === 'notion')
return providers.notion.token;
return '';
}
+90
View File
@@ -0,0 +1,90 @@
// Notion API — queries a database for tasks
export async function fetchNotionTasks(config) {
if (!config.token)
throw new Error('Notion token not configured');
if (!config.projectIds || config.projectIds.length === 0) {
throw new Error('Notion database ID not configured. Set a database ID in options.');
}
const headers = {
'Authorization': `Bearer ${config.token}`,
'Notion-Version': '2022-06-28',
'Content-Type': 'application/json',
};
const allTasks = [];
for (const dbId of config.projectIds) {
const res = await fetch(`https://api.notion.com/v1/databases/${dbId}/query`, {
method: 'POST',
headers,
body: JSON.stringify({ page_size: 100 }),
});
if (!res.ok) {
console.warn(`Notion DB query failed for ${dbId}: ${res.status}`);
continue;
}
const data = await res.json();
const results = data.results || [];
for (const page of results) {
const props = page.properties || {};
const titleProp = Object.values(props).find((p) => p.type === 'title');
const title = titleProp?.title?.[0]?.plain_text || 'Untitled';
const statusProp = Object.values(props).find((p) => p.type === 'status' || p.type === 'select' || p.type === 'checkbox');
let done = false;
if (statusProp?.type === 'checkbox')
done = statusProp.checkbox;
else if (statusProp?.type === 'status')
done = statusProp.status?.name === 'Done';
else if (statusProp?.type === 'select')
done = statusProp.select?.name === 'Done';
if (done)
continue;
const dateProp = Object.values(props).find((p) => p.type === 'date');
const dueDate = dateProp?.date?.start ? new Date(dateProp.date.start) : null;
const multiSelect = Object.values(props).filter((p) => p.type === 'multi_select');
const labels = multiSelect.flatMap((p) => p.multi_select?.map((s) => s.name) || []);
const created = page.created_time ? new Date(page.created_time) : new Date();
const updated = page.last_edited_time ? new Date(page.last_edited_time) : created;
allTasks.push({
id: `notion-${page.id}`,
title,
priority: 1,
createdAt: created,
updatedAt: updated,
completedAt: null,
dueDate,
projectId: `notion-${dbId}`,
projectName: dbId.slice(0, 8),
labels,
url: page.url,
source: 'notion',
raw: page,
});
}
}
return allTasks;
}
export async function archiveNotionTask(taskId, token) {
const realId = taskId.replace('notion-', '');
const res = await fetch(`https://api.notion.com/v1/pages/${realId}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Notion-Version': '2022-06-28',
'Content-Type': 'application/json',
},
body: JSON.stringify({ archived: true }),
});
if (!res.ok)
throw new Error(`Failed to archive Notion task: ${res.status}`);
}
export async function deleteNotionTask(taskId, token) {
const realId = taskId.replace('notion-', '');
const res = await fetch(`https://api.notion.com/v1/blocks/${realId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
'Notion-Version': '2022-06-28',
},
});
if (!res.ok)
throw new Error(`Failed to delete Notion task: ${res.status}`);
}
+60
View File
@@ -0,0 +1,60 @@
// TickTick API v2 — uses login token or OAuth access token
export async function fetchTickTickTasks(config) {
if (!config.token)
throw new Error('TickTick token not configured');
const headers = {
'Authorization': `Bearer ${config.token}`,
'Content-Type': 'application/json',
};
// Get all projects first
const projectsRes = await fetch('https://api.ticktick.com/open/v1/project', { headers });
const projects = projectsRes.ok ? await projectsRes.json() : [];
const projectList = Array.isArray(projects) ? projects : (projects || []);
const allTasks = [];
for (const proj of projectList) {
const pid = proj.id;
const tasksRes = await fetch(`https://api.ticktick.com/open/v1/project/${pid}/task`, { headers });
if (!tasksRes.ok)
continue;
const tasks = await tasksRes.json();
const taskList = Array.isArray(tasks) ? tasks : (tasks || []);
for (const t of taskList) {
if (t.status === 2)
continue; // completed
allTasks.push({
id: `ticktick-${t.id}`,
title: t.title,
priority: (t.priority || 0) + 1,
createdAt: new Date(t.createdTime || Date.now()),
updatedAt: new Date(t.modifiedTime || t.createdTime || Date.now()),
completedAt: null,
dueDate: t.dueDate ? new Date(t.dueDate) : null,
projectId: `ticktick-${pid}`,
projectName: proj.name || null,
labels: t.tags || [],
url: `https://ticktick.com/webapp/#p/${pid}/task/${t.id}`,
source: 'ticktick',
raw: t,
});
}
}
return allTasks;
}
export async function closeTickTickTask(taskId, token) {
const realId = taskId.replace('ticktick-', '');
const res = await fetch(`https://api.ticktick.com/open/v1/task/${realId}/complete`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok)
throw new Error(`Failed to close TickTick task: ${res.status}`);
}
export async function deleteTickTickTask(taskId, token) {
const realId = taskId.replace('ticktick-', '');
const res = await fetch(`https://api.ticktick.com/open/v1/task/${realId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok)
throw new Error(`Failed to delete TickTick task: ${res.status}`);
}
+51
View File
@@ -0,0 +1,51 @@
export async function fetchTodoistTasks(config) {
if (!config.token)
throw new Error('Todoist token not configured');
const headers = {
'Authorization': `Bearer ${config.token}`,
'Content-Type': 'application/json',
};
// Fetch all active tasks
const tasksRes = await fetch('https://api.todoist.com/rest/v2/tasks', { headers });
if (!tasksRes.ok) {
throw new Error(`Todoist API error: ${tasksRes.status} ${tasksRes.statusText}`);
}
const tasks = await tasksRes.json();
// Fetch projects for names
const projectsRes = await fetch('https://api.todoist.com/rest/v2/projects', { headers });
const projects = projectsRes.ok ? await projectsRes.json() : [];
const projectMap = new Map(projects.map((p) => [p.id, p.name]));
return tasks.map((t) => ({
id: `todoist-${t.id}`,
title: t.content,
priority: t.priority || 1,
createdAt: new Date(t.created_at || Date.now()),
updatedAt: new Date(t.updated_at || t.created_at || Date.now()),
completedAt: null,
dueDate: t.due?.date ? new Date(t.due.date) : null,
projectId: t.project_id ? `todoist-${t.project_id}` : null,
projectName: projectMap.get(t.project_id) || null,
labels: t.labels || [],
url: `https://todoist.com/app/task/${t.id}`,
source: 'todoist',
raw: t,
}));
}
export async function closeTodoistTask(taskId, token) {
const realId = taskId.replace('todoist-', '');
const res = await fetch(`https://api.todoist.com/rest/v2/tasks/${realId}/close`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok)
throw new Error(`Failed to close Todoist task: ${res.status}`);
}
export async function deleteTodoistTask(taskId, token) {
const realId = taskId.replace('todoist-', '');
const res = await fetch(`https://api.todoist.com/rest/v2/tasks/${realId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok)
throw new Error(`Failed to delete Todoist task: ${res.status}`);
}