Tasks: TickTick history sync (export + idempotent import, start_at/time_zone); trackers ordered tasks, money, books with single-noun names
All checks were successful
Deploy production / deploy (push) Successful in 2m33s
All checks were successful
Deploy production / deploy (push) Successful in 2m33s
This commit is contained in:
7
data/tasks/ticktick-merges.json
Normal file
7
data/tasks/ticktick-merges.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[
|
||||||
|
{ "ticktickId": "6ad52191ebcd7400000000ce", "title": "Jan Loman printer visit" },
|
||||||
|
{ "ticktickId": "690d2fd98c824268f297a676", "title": "Router and networking setup" },
|
||||||
|
{ "ticktickId": "6844769df471d103461fa9f1", "title": "Home server rebuild with ZFS" },
|
||||||
|
{ "ticktickId": "6ab6f32759b6090811e91a00", "title": "Dentist checkup" },
|
||||||
|
{ "ticktickId": "68225a8dbfe81104eac49a7a", "title": "Merge banks into Revolut" }
|
||||||
|
]
|
||||||
2
drizzle/20261024030132_tasks_ticktick/migration.sql
Normal file
2
drizzle/20261024030132_tasks_ticktick/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "tasks"."tasks" ADD COLUMN "start_at" timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tasks"."tasks" ADD COLUMN "time_zone" text;
|
||||||
15158
drizzle/20261024030132_tasks_ticktick/snapshot.json
Normal file
15158
drizzle/20261024030132_tasks_ticktick/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
79
scripts/ticktick-export.ts
Normal file
79
scripts/ticktick-export.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Export every TickTick task reachable through the official CLI (Open API v1):
|
||||||
|
* open, won't-do and completed tasks of the inbox and every list, plus completed
|
||||||
|
* history month by month (windows are split further when they hit the API's
|
||||||
|
* 200-result cap). Needs `ticktick auth login` done once; never reads the token.
|
||||||
|
*
|
||||||
|
* bun scripts/ticktick-export.ts ~/.cache/ticktick-export/all.json
|
||||||
|
*
|
||||||
|
* Then: bun scripts/ticktick-import.ts ~/.cache/ticktick-export/all.json
|
||||||
|
*/
|
||||||
|
import { $ } from 'bun';
|
||||||
|
|
||||||
|
const out = process.argv[2];
|
||||||
|
if (!out) {
|
||||||
|
console.error('usage: bun scripts/ticktick-export.ts <out.json> [--since YYYY]');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const sinceArg = process.argv.indexOf('--since');
|
||||||
|
const since = sinceArg > 0 ? Number(process.argv[sinceArg + 1]) : 2015;
|
||||||
|
const CAP = 200;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type Json = any;
|
||||||
|
async function tt(...args: string[]): Promise<Json> {
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
const r = await $`ticktick ${args} --json`.quiet().nothrow();
|
||||||
|
if (r.exitCode === 0) return JSON.parse(r.stdout.toString() || '[]');
|
||||||
|
if (attempt >= 3)
|
||||||
|
throw new Error(
|
||||||
|
`ticktick ${args[0]} ${args[1]} failed: ${r.stderr.toString().slice(0, 300)}`
|
||||||
|
);
|
||||||
|
await Bun.sleep(2000 * (attempt + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fmt = (d: Date) => d.toISOString().replace(/\.\d{3}Z$/, '+0000');
|
||||||
|
const byId = new Map<string, Json>();
|
||||||
|
const add = (list: Json[]) => list.filter((t) => !byId.has(t.id) && byId.set(t.id, t)).length;
|
||||||
|
|
||||||
|
const projects: Json[] = await tt('project', 'list');
|
||||||
|
// The inbox is not a project; its id shows up on tasks (inbox<userId>).
|
||||||
|
const probe: Json[] = await tt('task', 'filter', '--status=0,2,-1');
|
||||||
|
const inboxId = probe.map((t) => t.projectId).find((p: string) => p?.startsWith('inbox'));
|
||||||
|
add(probe);
|
||||||
|
const ids = [...(inboxId ? [inboxId] : []), ...projects.map((p) => p.id)];
|
||||||
|
let capped = 0;
|
||||||
|
for (const status of ['0', '-1', '2'])
|
||||||
|
for (const p of ids) {
|
||||||
|
const r = await tt('task', 'filter', '--projects', p, `--status=${status}`);
|
||||||
|
if (r.length >= CAP) capped++;
|
||||||
|
add(r);
|
||||||
|
}
|
||||||
|
for (const p of projects) add((await tt('project', 'data', p.id)).tasks ?? []);
|
||||||
|
|
||||||
|
async function completed(start: Date, end: Date): Promise<void> {
|
||||||
|
const r = await tt('task', 'completed', '--start-date', fmt(start), '--end-date', fmt(end));
|
||||||
|
if (r.length >= CAP && end.getTime() - start.getTime() > 3_600_000) {
|
||||||
|
const mid = new Date(Math.floor((start.getTime() + end.getTime()) / 2000) * 1000);
|
||||||
|
await completed(start, mid);
|
||||||
|
await completed(new Date(mid.getTime() + 1000), end);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
add(r);
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
for (let y = since; y <= now.getUTCFullYear(); y++)
|
||||||
|
for (let m = 0; m < 12; m++) {
|
||||||
|
const start = new Date(Date.UTC(y, m, 1));
|
||||||
|
if (start > now) break;
|
||||||
|
await completed(start, new Date(Date.UTC(y, m + 1, 1) - 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = [...byId.values()];
|
||||||
|
await Bun.write(out, JSON.stringify({ exportedAt: now.toISOString(), inboxId, projects, tasks }));
|
||||||
|
const count = (s: number) => tasks.filter((t) => t.status === s).length;
|
||||||
|
console.log(
|
||||||
|
`${tasks.length} tasks (${count(0)} open, ${count(2)} completed, ${count(-1)} won't do) from ` +
|
||||||
|
`${projects.length} lists + inbox` +
|
||||||
|
(capped ? `; ${capped} status/list queries hit the ${CAP} cap (older ones may be missing)` : '')
|
||||||
|
);
|
||||||
49
scripts/ticktick-import.ts
Normal file
49
scripts/ticktick-import.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Sync a TickTick export (scripts/ticktick-export.ts) into the private `tasks`
|
||||||
|
* schema. Idempotent; rerun after a new export to pull in changes.
|
||||||
|
*
|
||||||
|
* bun scripts/ticktick-import.ts [--dry-run] ~/.cache/ticktick-export/all.json
|
||||||
|
*
|
||||||
|
* Links listed in data/tasks/ticktick-merges.json (plus exact title matches, see
|
||||||
|
* syncTickTick) attach a TickTick task to an existing task instead of duplicating it.
|
||||||
|
* Uses DB_URL (the app role on prod, personal_dev on dev).
|
||||||
|
*/
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import { syncTickTick, type TtExport, type TtMerge } from '../src/lib/server/ticktick';
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const dryRun = args.includes('--dry-run');
|
||||||
|
const files = args.filter((a) => !a.startsWith('--'));
|
||||||
|
if (files.length !== 1) {
|
||||||
|
console.error('usage: bun scripts/ticktick-import.ts [--dry-run] <export.json>');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const data = JSON.parse(await readFile(files[0], 'utf8')) as TtExport;
|
||||||
|
const merges = JSON.parse(
|
||||||
|
await readFile(new URL('../data/tasks/ticktick-merges.json', import.meta.url), 'utf8')
|
||||||
|
) as TtMerge[];
|
||||||
|
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
||||||
|
const client = postgres(process.env.DB_URL, { max: 1, onnotice: () => {} });
|
||||||
|
class DryRun extends Error {}
|
||||||
|
try {
|
||||||
|
const db = drizzle({ client });
|
||||||
|
let report;
|
||||||
|
try {
|
||||||
|
report = await db.transaction(async (tx) => {
|
||||||
|
const r = await syncTickTick(tx, data, { merges });
|
||||||
|
if (dryRun) {
|
||||||
|
console.log(JSON.stringify(r, null, 2));
|
||||||
|
throw new DryRun();
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(report, null, 2));
|
||||||
|
} catch (e) {
|
||||||
|
if (!(e instanceof DryRun)) throw e;
|
||||||
|
console.log('(dry run, rolled back)');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
21
src/lib/projects.test.ts
Normal file
21
src/lib/projects.test.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { visibleTrackers } from './projects';
|
||||||
|
|
||||||
|
describe('trackers', () => {
|
||||||
|
it('lists tasks, money, books first for the owner', () => {
|
||||||
|
expect(
|
||||||
|
visibleTrackers(true)
|
||||||
|
.map((t) => t.name)
|
||||||
|
.slice(0, 4)
|
||||||
|
).toEqual(['tasks', 'money', 'books', 'health']);
|
||||||
|
});
|
||||||
|
it('hides private trackers when logged out', () => {
|
||||||
|
const names = visibleTrackers(false).map((t) => t.name);
|
||||||
|
expect(names).not.toContain('tasks');
|
||||||
|
expect(names).not.toContain('health');
|
||||||
|
expect(names.slice(0, 2)).toEqual(['money', 'books']);
|
||||||
|
});
|
||||||
|
it('uses short single-word names', () => {
|
||||||
|
for (const t of visibleTrackers(true)) expect(t.name).toMatch(/^[a-z]+$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,76 +32,96 @@ export const projects = [
|
|||||||
}
|
}
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Trackers keep their existing /projects/... URLs (no moves, so no redirects needed).
|
// Trackers, in display order. Names are short single nouns. Most keep their existing
|
||||||
export const trackers = [
|
// /projects/... URLs (no moves, so no redirects needed). `private` trackers are shown in the
|
||||||
|
// header, home page and /trackers only to the logged-in owner (layout data `isOwner`), never
|
||||||
|
// in the sitemap; their routes 404 for everyone else.
|
||||||
|
const allTrackers = [
|
||||||
{
|
{
|
||||||
name: 'game tracker',
|
name: 'tasks',
|
||||||
subtitle: 'every pokemon in every game, and call of duty camo mastery',
|
subtitle: 'private: to-dos, goals and my TickTick history',
|
||||||
href: '/projects/game-tracker',
|
href: '/trackers/tasks',
|
||||||
external: false
|
external: false,
|
||||||
|
private: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'money',
|
name: 'money',
|
||||||
subtitle: 'a manual ledger for accounts, bills and utilities',
|
subtitle: 'a manual ledger for accounts, bills and utilities',
|
||||||
href: '/trackers/money',
|
href: '/trackers/money',
|
||||||
external: false
|
external: false,
|
||||||
},
|
private: false
|
||||||
{
|
|
||||||
name: 'house tracker',
|
|
||||||
subtitle: 'Dutch rental and sale listings, with alerts',
|
|
||||||
href: '/projects/house-tracker',
|
|
||||||
external: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'cs prices',
|
|
||||||
subtitle: 'Counter-Strike market prices across time',
|
|
||||||
href: '/projects/cs-prices',
|
|
||||||
external: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'energy',
|
|
||||||
subtitle: 'Dutch electricity mix, utilization and gas storage',
|
|
||||||
href: '/projects/energy',
|
|
||||||
external: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'animaldex',
|
|
||||||
subtitle: 'add neighborhood pets to the world map',
|
|
||||||
href: '/projects/animaldex',
|
|
||||||
external: false
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'books',
|
name: 'books',
|
||||||
subtitle: 'my library: what I am reading, have read and want to read',
|
subtitle: 'my library: what I am reading, have read and want to read',
|
||||||
href: '/trackers/books',
|
href: '/trackers/books',
|
||||||
external: false
|
external: false,
|
||||||
|
private: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'health',
|
||||||
|
subtitle: 'private: body, workouts, sleep, meds and vaccinations',
|
||||||
|
href: '/trackers/health',
|
||||||
|
external: false,
|
||||||
|
private: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'energy',
|
||||||
|
subtitle: 'Dutch electricity mix, utilization and gas storage',
|
||||||
|
href: '/projects/energy',
|
||||||
|
external: false,
|
||||||
|
private: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'games',
|
||||||
|
subtitle: 'every pokemon in every game, and call of duty camo mastery',
|
||||||
|
href: '/projects/game-tracker',
|
||||||
|
external: false,
|
||||||
|
private: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'houses',
|
||||||
|
subtitle: 'Dutch rental and sale listings, with alerts',
|
||||||
|
href: '/projects/house-tracker',
|
||||||
|
external: false,
|
||||||
|
private: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'skins',
|
||||||
|
subtitle: 'Counter-Strike skin market prices across time',
|
||||||
|
href: '/projects/cs-prices',
|
||||||
|
external: false,
|
||||||
|
private: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'pets',
|
||||||
|
subtitle: 'animaldex: add neighborhood pets to the world map',
|
||||||
|
href: '/projects/animaldex',
|
||||||
|
external: false,
|
||||||
|
private: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'lists',
|
name: 'lists',
|
||||||
subtitle: 'books, games, places and other things worth keeping',
|
subtitle: 'books, games, places and other things worth keeping',
|
||||||
href: '/lists',
|
href: '/lists',
|
||||||
external: false
|
external: false,
|
||||||
}
|
private: false
|
||||||
] as const;
|
|
||||||
|
|
||||||
// Owner-only trackers: shown in the header, home page and /trackers only to the logged-in
|
|
||||||
// owner (layout data `isOwner`), never in the sitemap. Their routes 404 for everyone else.
|
|
||||||
export const privateTrackers = [
|
|
||||||
{
|
|
||||||
name: 'health',
|
|
||||||
subtitle: 'private: body, workouts, sleep, meds and vaccinations',
|
|
||||||
href: '/trackers/health',
|
|
||||||
external: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'tasks',
|
|
||||||
subtitle: 'private: to-dos and long-term goals',
|
|
||||||
href: '/trackers/tasks',
|
|
||||||
external: false
|
|
||||||
}
|
}
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type Tracker = { name: string; subtitle: string; href: string; external: boolean };
|
type Tracker = { name: string; subtitle: string; href: string; external: boolean };
|
||||||
|
const strip = ({ name, subtitle, href, external }: Tracker): Tracker => ({
|
||||||
|
name,
|
||||||
|
subtitle,
|
||||||
|
href,
|
||||||
|
external
|
||||||
|
});
|
||||||
|
/** Public trackers (sitemap, logged-out navigation). */
|
||||||
|
export const trackers: readonly Tracker[] = allTrackers.filter((t) => !t.private).map(strip);
|
||||||
|
/** Owner-only trackers. */
|
||||||
|
export const privateTrackers: readonly Tracker[] = allTrackers.filter((t) => t.private).map(strip);
|
||||||
|
const ownerTrackers: readonly Tracker[] = allTrackers.map(strip);
|
||||||
|
|
||||||
export function visibleTrackers(isOwner: boolean): readonly Tracker[] {
|
export function visibleTrackers(isOwner: boolean): readonly Tracker[] {
|
||||||
return isOwner ? [...trackers, ...privateTrackers] : trackers;
|
return isOwner ? ownerTrackers : trackers;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ export const todoTasks = tasksSchema.table(
|
|||||||
sortOrder: integer('sort_order').notNull().default(0),
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
source: text('source'), // e.g. 'daisy-import', 'manual', 'ticktick'
|
source: text('source'), // e.g. 'daisy-import', 'manual', 'ticktick'
|
||||||
externalId: text('external_id'), // id in the source system, for future syncs
|
externalId: text('external_id'), // id in the source system, for future syncs
|
||||||
|
startAt: timestamptz('start_at'), // planned start (TickTick startDate)
|
||||||
|
timeZone: text('time_zone'), // zone the task was planned in (TickTick); due_time stays Amsterdam
|
||||||
startedAt: timestamptz('started_at'),
|
startedAt: timestamptz('started_at'),
|
||||||
completedAt: timestamptz('completed_at'),
|
completedAt: timestamptz('completed_at'),
|
||||||
...timestamps
|
...timestamps
|
||||||
|
|||||||
80
src/lib/server/ticktick.test.ts
Normal file
80
src/lib/server/ticktick.test.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
mapTask,
|
||||||
|
moreComplete,
|
||||||
|
normTitle,
|
||||||
|
slugify,
|
||||||
|
ttDate,
|
||||||
|
ttDue,
|
||||||
|
ttPriority,
|
||||||
|
ttRecurrence,
|
||||||
|
ttReminder,
|
||||||
|
ttStatus
|
||||||
|
} from './ticktick';
|
||||||
|
|
||||||
|
describe('ticktick mapping', () => {
|
||||||
|
it('parses TickTick timestamps', () => {
|
||||||
|
expect(ttDate('2024-06-11T17:30:36.000+0000')?.toISOString()).toBe('2024-06-11T17:30:36.000Z');
|
||||||
|
expect(ttDate('')).toBeNull();
|
||||||
|
});
|
||||||
|
it('keeps the calendar date of all-day dues and converts timed dues to Amsterdam', () => {
|
||||||
|
expect(ttDue('2025-03-11T23:00:00.000+0000', true, 'Europe/Amsterdam')).toEqual({
|
||||||
|
dueDate: '2025-03-12',
|
||||||
|
dueTime: null
|
||||||
|
});
|
||||||
|
expect(ttDue('2026-05-04T04:00:00.000+0000', false, 'Europe/Berlin')).toEqual({
|
||||||
|
dueDate: '2026-05-04',
|
||||||
|
dueTime: '06:00'
|
||||||
|
});
|
||||||
|
expect(ttDue(undefined, true, undefined)).toEqual({ dueDate: null, dueTime: null });
|
||||||
|
});
|
||||||
|
it('maps priority, status, recurrence and reminders', () => {
|
||||||
|
expect([5, 3, 1, 0, undefined].map(ttPriority)).toEqual([3, 2, 1, 0, 0]);
|
||||||
|
expect([0, 2, -1].map(ttStatus)).toEqual(['todo', 'done', 'dropped']);
|
||||||
|
expect(ttRecurrence('RRULE:FREQ=WEEKLY;INTERVAL=1')).toBe('FREQ=WEEKLY;INTERVAL=1');
|
||||||
|
const due = new Date('2026-01-01T10:00:00Z');
|
||||||
|
expect(ttReminder(['TRIGGER:-PT60M', 'TRIGGER:PT0S'], due)?.toISOString()).toBe(
|
||||||
|
'2026-01-01T09:00:00.000Z'
|
||||||
|
);
|
||||||
|
expect(ttReminder(['TRIGGER:P0DT9H0M0S'], due)?.toISOString()).toBe('2026-01-01T19:00:00.000Z');
|
||||||
|
});
|
||||||
|
it('prefers the more complete status', () => {
|
||||||
|
expect(moreComplete('done', 'todo')).toBe('done');
|
||||||
|
expect(moreComplete('todo', 'done')).toBe('done');
|
||||||
|
expect(moreComplete('in_progress', 'todo')).toBe('in_progress');
|
||||||
|
});
|
||||||
|
it('turns checklist items into subtasks', () => {
|
||||||
|
const rows = mapTask({
|
||||||
|
id: 't1',
|
||||||
|
projectId: 'inbox1',
|
||||||
|
title: ' Groceries ',
|
||||||
|
kind: 'CHECKLIST',
|
||||||
|
desc: 'weekly',
|
||||||
|
status: 2,
|
||||||
|
priority: 5,
|
||||||
|
createdTime: '2025-01-01T10:00:00.000+0000',
|
||||||
|
modifiedTime: '2025-01-02T10:00:00.000+0000',
|
||||||
|
completedTime: '2025-01-02T09:00:00.000+0000',
|
||||||
|
items: [
|
||||||
|
{ id: 'i1', title: 'milk', status: 1, completedTime: '2025-01-02T08:00:00.000+0000' },
|
||||||
|
{ id: 'i2', title: 'eggs', status: 0 }
|
||||||
|
]
|
||||||
|
} as Parameters<typeof mapTask>[0]);
|
||||||
|
expect(rows).toHaveLength(3);
|
||||||
|
expect(rows[0]).toMatchObject({
|
||||||
|
title: 'Groceries',
|
||||||
|
notes: 'weekly',
|
||||||
|
status: 'done',
|
||||||
|
priority: 3
|
||||||
|
});
|
||||||
|
expect(rows[0].completedAt?.toISOString()).toBe('2025-01-02T09:00:00.000Z');
|
||||||
|
expect(rows[1]).toMatchObject({ externalId: 't1:i1', parentExternalId: 't1', status: 'done' });
|
||||||
|
// Unchecked item of a completed task follows the task.
|
||||||
|
expect(rows[2]).toMatchObject({ externalId: 't1:i2', status: 'done' });
|
||||||
|
expect(rows[2].completedAt?.toISOString()).toBe('2025-01-02T09:00:00.000Z');
|
||||||
|
});
|
||||||
|
it('slugs and normalises titles', () => {
|
||||||
|
expect(slugify('Stuhee')).toBe('stuhee');
|
||||||
|
expect(normTitle('Book haircut!')).toBe('book haircut');
|
||||||
|
});
|
||||||
|
});
|
||||||
489
src/lib/server/ticktick.ts
Normal file
489
src/lib/server/ticktick.ts
Normal file
@@ -0,0 +1,489 @@
|
|||||||
|
// TickTick → tasks schema sync. Pure mapping + an idempotent sync, shared by
|
||||||
|
// scripts/ticktick-import.ts and its tests; must not import anything from SvelteKit.
|
||||||
|
// Input is the JSON written by scripts/ticktick-export.ts (Open API v1 task objects).
|
||||||
|
import { and, eq, isNotNull, isNull, sql } from 'drizzle-orm';
|
||||||
|
import type { PgAsyncDatabase } from 'drizzle-orm/pg-core';
|
||||||
|
import { todoActivity, todoAreas, todoTasks, type TaskStatus } from './db/schema/tasks';
|
||||||
|
import { TIME_ZONE } from './tasks';
|
||||||
|
|
||||||
|
export const TICKTICK_SOURCE = 'ticktick';
|
||||||
|
|
||||||
|
export type TtItem = {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
status?: number;
|
||||||
|
completedTime?: string | number;
|
||||||
|
sortOrder?: number;
|
||||||
|
startDate?: string;
|
||||||
|
isAllDay?: boolean;
|
||||||
|
timeZone?: string;
|
||||||
|
};
|
||||||
|
export type TtTask = {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
desc?: string;
|
||||||
|
status?: number;
|
||||||
|
priority?: number;
|
||||||
|
tags?: string[];
|
||||||
|
dueDate?: string;
|
||||||
|
startDate?: string;
|
||||||
|
isAllDay?: boolean;
|
||||||
|
timeZone?: string;
|
||||||
|
repeatFlag?: string;
|
||||||
|
reminders?: string[];
|
||||||
|
items?: TtItem[];
|
||||||
|
parentId?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
createdTime?: string;
|
||||||
|
modifiedTime?: string;
|
||||||
|
completedTime?: string;
|
||||||
|
};
|
||||||
|
export type TtProject = { id: string; name: string; closed?: boolean };
|
||||||
|
export type TtExport = { projects: TtProject[]; tasks: TtTask[]; inboxId?: string };
|
||||||
|
/** Hand-picked links: a TickTick task that is clearly the same as an existing task. */
|
||||||
|
export type TtMerge = { ticktickId: string; title: string };
|
||||||
|
|
||||||
|
/** TickTick timestamps look like 2024-06-11T17:30:36.000+0000. */
|
||||||
|
export function ttDate(v: string | number | null | undefined): Date | null {
|
||||||
|
if (v === null || v === undefined || v === '') return null;
|
||||||
|
const d =
|
||||||
|
typeof v === 'number' ? new Date(v) : new Date(v.replace(/([+-]\d{2})(\d{2})$/, '$1:$2'));
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = (d: Date, timeZone: string) => {
|
||||||
|
const f = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hourCycle: 'h23'
|
||||||
|
}).formatToParts(d);
|
||||||
|
const g = (t: string) => f.find((p) => p.type === t)?.value ?? '';
|
||||||
|
return { date: `${g('year')}-${g('month')}-${g('day')}`, time: `${g('hour')}:${g('minute')}` };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All-day dues are midnight in the task's own zone (keep that calendar date);
|
||||||
|
* timed dues become an Amsterdam wall-clock date + time (due_time is Amsterdam).
|
||||||
|
*/
|
||||||
|
export function ttDue(
|
||||||
|
dueDate: string | undefined,
|
||||||
|
isAllDay: boolean | undefined,
|
||||||
|
timeZone: string | undefined
|
||||||
|
): { dueDate: string | null; dueTime: string | null } {
|
||||||
|
const d = ttDate(dueDate);
|
||||||
|
if (!d) return { dueDate: null, dueTime: null };
|
||||||
|
if (isAllDay) return { dueDate: parts(d, validZone(timeZone)).date, dueTime: null };
|
||||||
|
const p = parts(d, TIME_ZONE);
|
||||||
|
return { dueDate: p.date, dueTime: p.time };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validZone(z: string | undefined) {
|
||||||
|
if (!z) return TIME_ZONE;
|
||||||
|
try {
|
||||||
|
new Intl.DateTimeFormat('en', { timeZone: z });
|
||||||
|
return z;
|
||||||
|
} catch {
|
||||||
|
return TIME_ZONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First reminder: TRIGGER:PT0S / -PT60M / P0DT9H0M0S (relative to the due moment). */
|
||||||
|
export function ttReminder(reminders: string[] | undefined, due: Date | null): Date | null {
|
||||||
|
if (!due || !reminders?.length) return null;
|
||||||
|
let best: Date | null = null;
|
||||||
|
for (const r of reminders) {
|
||||||
|
const m = r.match(
|
||||||
|
/^TRIGGER:(-)?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/
|
||||||
|
);
|
||||||
|
if (!m) continue;
|
||||||
|
const [, neg, w, d, h, min, s] = m;
|
||||||
|
const ms =
|
||||||
|
((Number(w ?? 0) * 7 + Number(d ?? 0)) * 86_400 +
|
||||||
|
Number(h ?? 0) * 3600 +
|
||||||
|
Number(min ?? 0) * 60 +
|
||||||
|
Number(s ?? 0)) *
|
||||||
|
1000;
|
||||||
|
const at = new Date(due.getTime() + (neg ? -ms : ms));
|
||||||
|
if (!best || at < best) best = at;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ttPriority = (p: number | undefined) => (p === 5 ? 3 : p === 3 ? 2 : p === 1 ? 1 : 0);
|
||||||
|
export const ttStatus = (s: number | undefined): TaskStatus =>
|
||||||
|
s === 2 ? 'done' : s === -1 ? 'dropped' : 'todo';
|
||||||
|
/** TickTick stores "RRULE:FREQ=…"; the tracker keeps the bare RFC 5545 rule. */
|
||||||
|
export const ttRecurrence = (r: string | undefined) => r?.replace(/^RRULE:/, '').trim() || null;
|
||||||
|
|
||||||
|
const RANK: Record<TaskStatus, number> = { todo: 0, in_progress: 1, dropped: 2, done: 3 };
|
||||||
|
/** The more complete of two statuses (done > dropped > in progress > to do). */
|
||||||
|
export const moreComplete = (a: TaskStatus, b: TaskStatus) => (RANK[b] > RANK[a] ? b : a);
|
||||||
|
|
||||||
|
export const slugify = (s: string) =>
|
||||||
|
s
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[^\p{L}\p{N}]+/gu, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.toLowerCase() || 'list';
|
||||||
|
export const normTitle = (s: string) =>
|
||||||
|
s
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
export type MappedRow = {
|
||||||
|
externalId: string;
|
||||||
|
parentExternalId: string | null;
|
||||||
|
projectId: string;
|
||||||
|
title: string;
|
||||||
|
notes: string | null;
|
||||||
|
status: TaskStatus;
|
||||||
|
priority: number;
|
||||||
|
tags: string[];
|
||||||
|
dueDate: string | null;
|
||||||
|
dueTime: string | null;
|
||||||
|
startAt: Date | null;
|
||||||
|
timeZone: string | null;
|
||||||
|
reminderAt: Date | null;
|
||||||
|
recurrence: string | null;
|
||||||
|
completedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
ttSortOrder: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One TickTick task → its row plus one row per checklist item (as subtasks). */
|
||||||
|
export function mapTask(t: TtTask): MappedRow[] {
|
||||||
|
const status = ttStatus(t.status);
|
||||||
|
const created =
|
||||||
|
ttDate(t.createdTime) ?? ttDate(t.modifiedTime) ?? ttDate(t.completedTime) ?? new Date(0);
|
||||||
|
const modified = ttDate(t.modifiedTime) ?? created;
|
||||||
|
const due = ttDue(t.dueDate, t.isAllDay, t.timeZone);
|
||||||
|
const notes = (t.content?.trim() || t.desc?.trim() || '').slice(0, 50_000) || null;
|
||||||
|
const done = status === 'done' || status === 'dropped';
|
||||||
|
const row: MappedRow = {
|
||||||
|
externalId: t.id,
|
||||||
|
parentExternalId: t.parentId || null,
|
||||||
|
projectId: t.projectId,
|
||||||
|
title: t.title?.trim() || '(untitled)',
|
||||||
|
notes,
|
||||||
|
status,
|
||||||
|
priority: ttPriority(t.priority),
|
||||||
|
tags: [...new Set((t.tags ?? []).map((x) => x.toLowerCase()))],
|
||||||
|
...due,
|
||||||
|
startAt: ttDate(t.startDate),
|
||||||
|
timeZone: t.timeZone || null,
|
||||||
|
reminderAt: ttReminder(t.reminders, ttDate(t.dueDate)),
|
||||||
|
recurrence: ttRecurrence(t.repeatFlag),
|
||||||
|
completedAt: done ? (ttDate(t.completedTime) ?? modified) : null,
|
||||||
|
createdAt: created,
|
||||||
|
updatedAt: modified,
|
||||||
|
ttSortOrder: t.sortOrder ?? 0
|
||||||
|
};
|
||||||
|
const items = (t.items ?? []).map((i): MappedRow => {
|
||||||
|
// Checklist status: 0 open, 1 (or 2) checked. Unchecked items of a finished task
|
||||||
|
// follow the task (done → done, won't do → dropped) so they don't resurface as open.
|
||||||
|
const checked = (i.status ?? 0) !== 0 || status === 'done';
|
||||||
|
return {
|
||||||
|
externalId: `${t.id}:${i.id}`,
|
||||||
|
parentExternalId: t.id,
|
||||||
|
projectId: t.projectId,
|
||||||
|
title: i.title?.trim() || '(untitled)',
|
||||||
|
notes: null,
|
||||||
|
status: checked ? 'done' : status === 'dropped' ? 'dropped' : 'todo',
|
||||||
|
priority: 0,
|
||||||
|
tags: [],
|
||||||
|
...ttDue(i.startDate, i.isAllDay ?? true, i.timeZone ?? t.timeZone),
|
||||||
|
startAt: null,
|
||||||
|
timeZone: i.timeZone || t.timeZone || null,
|
||||||
|
reminderAt: null,
|
||||||
|
recurrence: null,
|
||||||
|
completedAt: checked
|
||||||
|
? (ttDate(i.completedTime) ?? row.completedAt ?? modified)
|
||||||
|
: status === 'dropped'
|
||||||
|
? row.completedAt
|
||||||
|
: null,
|
||||||
|
createdAt: created,
|
||||||
|
updatedAt: modified,
|
||||||
|
ttSortOrder: i.sortOrder ?? 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return [row, ...items];
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type Db = PgAsyncDatabase<any, any>;
|
||||||
|
|
||||||
|
export type SyncReport = {
|
||||||
|
areas: { inserted: string[]; existing: string[] };
|
||||||
|
tasks: { inserted: number; updated: number; unchanged: number; subtasks: number };
|
||||||
|
merged: { ticktick: string; task: string; status: TaskStatus }[];
|
||||||
|
byStatus: Record<TaskStatus, number>;
|
||||||
|
byArea: Record<string, number>;
|
||||||
|
range: { created: [string, string] | null; completed: [string, string] | null };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert a TickTick export (idempotent). Rows are keyed on external_id = TickTick id
|
||||||
|
* (checklist items: "<taskId>:<itemId>"). A TickTick row is only overwritten when
|
||||||
|
* TickTick modified it after our last update, so edits made on the site win until
|
||||||
|
* the task changes in TickTick again. Links to existing (e.g. daisy-import) tasks:
|
||||||
|
* the explicit `merges` list, plus an exact title match when the TickTick task is
|
||||||
|
* still open or was finished after that task was created. A linked task keeps its
|
||||||
|
* own fields; only its status moves to the more complete of the two.
|
||||||
|
*/
|
||||||
|
export async function syncTickTick(
|
||||||
|
db: Db,
|
||||||
|
data: TtExport,
|
||||||
|
opts: { merges?: TtMerge[] } = {}
|
||||||
|
): Promise<SyncReport> {
|
||||||
|
const report: SyncReport = {
|
||||||
|
areas: { inserted: [], existing: [] },
|
||||||
|
tasks: { inserted: 0, updated: 0, unchanged: 0, subtasks: 0 },
|
||||||
|
merged: [],
|
||||||
|
byStatus: { todo: 0, in_progress: 0, done: 0, dropped: 0 },
|
||||||
|
byArea: {},
|
||||||
|
range: { created: null, completed: null }
|
||||||
|
};
|
||||||
|
const rows = data.tasks.flatMap(mapTask);
|
||||||
|
report.tasks.subtasks = rows.filter((r) => r.parentExternalId).length;
|
||||||
|
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
// Areas: one per TickTick list. The inbox and any list no longer returned
|
||||||
|
// (closed/deleted) get their own area.
|
||||||
|
const projectName = new Map(data.projects.map((p) => [p.id, p.name]));
|
||||||
|
const areaFor = new Map<string, number>();
|
||||||
|
const [{ maxSort }] = await tx
|
||||||
|
.select({ maxSort: sql<number>`coalesce(max(${todoAreas.sortOrder}), 0)` })
|
||||||
|
.from(todoAreas);
|
||||||
|
let sort = Number(maxSort);
|
||||||
|
for (const pid of new Set(rows.map((r) => r.projectId))) {
|
||||||
|
const isInbox = pid.startsWith('inbox');
|
||||||
|
const raw = isInbox ? 'Inbox' : (projectName.get(pid) ?? `TickTick list ${pid.slice(-6)}`);
|
||||||
|
const name = raw.replace(/^[^\p{L}\p{N}]+/u, '').trim() || raw;
|
||||||
|
const slug = slugify(name);
|
||||||
|
const [found] = await tx.select().from(todoAreas).where(eq(todoAreas.slug, slug));
|
||||||
|
if (found) {
|
||||||
|
areaFor.set(pid, found.id);
|
||||||
|
report.areas.existing.push(slug);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [ins] = await tx
|
||||||
|
.insert(todoAreas)
|
||||||
|
.values({
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
description: isInbox ? 'TickTick inbox' : `TickTick list "${raw}"`,
|
||||||
|
sortOrder: (sort += 10)
|
||||||
|
})
|
||||||
|
.returning({ id: todoAreas.id });
|
||||||
|
areaFor.set(pid, ins.id);
|
||||||
|
report.areas.inserted.push(slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Site sort order: rank by TickTick sortOrder within each list (TickTick values overflow int4).
|
||||||
|
const siteSort = new Map<string, number>();
|
||||||
|
const perProject = new Map<string, MappedRow[]>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const list = perProject.get(r.projectId) ?? [];
|
||||||
|
list.push(r);
|
||||||
|
perProject.set(r.projectId, list);
|
||||||
|
}
|
||||||
|
for (const list of perProject.values())
|
||||||
|
list
|
||||||
|
.sort(
|
||||||
|
(a, b) => a.ttSortOrder - b.ttSortOrder || a.createdAt.getTime() - b.createdAt.getTime()
|
||||||
|
)
|
||||||
|
.forEach((r, i) => siteSort.set(r.externalId, (i + 1) * 10));
|
||||||
|
|
||||||
|
const existing = new Map(
|
||||||
|
(
|
||||||
|
await tx
|
||||||
|
.select({
|
||||||
|
id: todoTasks.id,
|
||||||
|
source: todoTasks.source,
|
||||||
|
externalId: todoTasks.externalId,
|
||||||
|
status: todoTasks.status,
|
||||||
|
updatedAt: todoTasks.updatedAt
|
||||||
|
})
|
||||||
|
.from(todoTasks)
|
||||||
|
.where(isNotNull(todoTasks.externalId))
|
||||||
|
).map((r) => [r.externalId!, r])
|
||||||
|
);
|
||||||
|
// Unlinked non-TickTick tasks are merge candidates.
|
||||||
|
const candidates = await tx
|
||||||
|
.select({
|
||||||
|
id: todoTasks.id,
|
||||||
|
title: todoTasks.title,
|
||||||
|
status: todoTasks.status,
|
||||||
|
createdAt: todoTasks.createdAt
|
||||||
|
})
|
||||||
|
.from(todoTasks)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
isNull(todoTasks.externalId),
|
||||||
|
sql`coalesce(${todoTasks.source}, '') <> ${TICKTICK_SOURCE}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const byTitle = new Map<string, typeof candidates>();
|
||||||
|
for (const c of candidates) {
|
||||||
|
const k = normTitle(c.title);
|
||||||
|
byTitle.set(k, [...(byTitle.get(k) ?? []), c]);
|
||||||
|
}
|
||||||
|
const explicit = new Map((opts.merges ?? []).map((m) => [m.ticktickId, normTitle(m.title)]));
|
||||||
|
const linked = new Set<number>();
|
||||||
|
|
||||||
|
const idFor = new Map<string, number>();
|
||||||
|
const activity: (typeof todoActivity.$inferInsert)[] = [];
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
const prev = existing.get(r.externalId);
|
||||||
|
const fields = {
|
||||||
|
title: r.title,
|
||||||
|
notes: r.notes,
|
||||||
|
status: r.status,
|
||||||
|
priority: r.priority,
|
||||||
|
tags: r.tags,
|
||||||
|
dueDate: r.dueDate,
|
||||||
|
dueTime: r.dueTime,
|
||||||
|
startAt: r.startAt,
|
||||||
|
timeZone: r.timeZone,
|
||||||
|
reminderAt: r.reminderAt,
|
||||||
|
recurrence: r.recurrence,
|
||||||
|
completedAt: r.completedAt,
|
||||||
|
areaId: areaFor.get(r.projectId) ?? null
|
||||||
|
};
|
||||||
|
if (prev && prev.source === TICKTICK_SOURCE) {
|
||||||
|
idFor.set(r.externalId, prev.id);
|
||||||
|
if (r.updatedAt > prev.updatedAt) {
|
||||||
|
await tx.update(todoTasks).set(fields).where(eq(todoTasks.id, prev.id));
|
||||||
|
if (prev.status !== r.status)
|
||||||
|
activity.push({
|
||||||
|
taskId: prev.id,
|
||||||
|
kind: 'status',
|
||||||
|
fromStatus: prev.status,
|
||||||
|
toStatus: r.status,
|
||||||
|
note: 'TickTick sync',
|
||||||
|
createdAt: r.updatedAt
|
||||||
|
});
|
||||||
|
report.tasks.updated++;
|
||||||
|
} else report.tasks.unchanged++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (prev) {
|
||||||
|
// Linked earlier to a task from another source: only upgrade its status.
|
||||||
|
idFor.set(r.externalId, prev.id);
|
||||||
|
const next = moreComplete(prev.status, r.status);
|
||||||
|
if (next !== prev.status) {
|
||||||
|
await tx
|
||||||
|
.update(todoTasks)
|
||||||
|
.set({ status: next, completedAt: r.completedAt })
|
||||||
|
.where(eq(todoTasks.id, prev.id));
|
||||||
|
report.tasks.updated++;
|
||||||
|
} else report.tasks.unchanged++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!r.parentExternalId) {
|
||||||
|
const want = explicit.get(r.externalId);
|
||||||
|
const pool = (want ? byTitle.get(want) : byTitle.get(normTitle(r.title))) ?? [];
|
||||||
|
const match = pool.filter(
|
||||||
|
(c) =>
|
||||||
|
!linked.has(c.id) &&
|
||||||
|
(want || r.status === 'todo' || (r.completedAt && r.completedAt >= c.createdAt))
|
||||||
|
);
|
||||||
|
if (match.length === 1) {
|
||||||
|
const c = match[0];
|
||||||
|
linked.add(c.id);
|
||||||
|
const next = moreComplete(c.status, r.status);
|
||||||
|
await tx
|
||||||
|
.update(todoTasks)
|
||||||
|
.set({
|
||||||
|
externalId: r.externalId,
|
||||||
|
status: next,
|
||||||
|
...(next !== c.status ? { completedAt: r.completedAt } : {})
|
||||||
|
})
|
||||||
|
.where(eq(todoTasks.id, c.id));
|
||||||
|
activity.push({
|
||||||
|
taskId: c.id,
|
||||||
|
kind: 'note',
|
||||||
|
note: `Linked to TickTick task "${r.title}" (${r.status})`
|
||||||
|
});
|
||||||
|
idFor.set(r.externalId, c.id);
|
||||||
|
report.merged.push({ ticktick: r.title, task: c.title, status: next });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [ins] = await tx
|
||||||
|
.insert(todoTasks)
|
||||||
|
.values({
|
||||||
|
...fields,
|
||||||
|
sortOrder: siteSort.get(r.externalId) ?? 0,
|
||||||
|
source: TICKTICK_SOURCE,
|
||||||
|
externalId: r.externalId,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
updatedAt: r.updatedAt
|
||||||
|
})
|
||||||
|
.returning({ id: todoTasks.id });
|
||||||
|
idFor.set(r.externalId, ins.id);
|
||||||
|
activity.push({
|
||||||
|
taskId: ins.id,
|
||||||
|
kind: 'created',
|
||||||
|
toStatus: r.status,
|
||||||
|
note: 'Imported (ticktick)',
|
||||||
|
createdAt: r.createdAt
|
||||||
|
});
|
||||||
|
report.tasks.inserted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parents (TickTick subtasks and checklist items), set after every row exists.
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!r.parentExternalId) continue;
|
||||||
|
const me = idFor.get(r.externalId);
|
||||||
|
const parent = idFor.get(r.parentExternalId);
|
||||||
|
const mine = existing.get(r.externalId);
|
||||||
|
if (!me || !parent || me === parent) continue;
|
||||||
|
if (mine && mine.source !== TICKTICK_SOURCE) continue;
|
||||||
|
await tx
|
||||||
|
.update(todoTasks)
|
||||||
|
.set({ parentId: parent })
|
||||||
|
.where(and(eq(todoTasks.id, me), sql`${todoTasks.parentId} IS DISTINCT FROM ${parent}`));
|
||||||
|
}
|
||||||
|
for (let i = 0; i < activity.length; i += 500)
|
||||||
|
await tx.insert(todoActivity).values(activity.slice(i, i + 500));
|
||||||
|
|
||||||
|
// Report from the database: everything that came from (or is linked to) TickTick.
|
||||||
|
const stats = await tx
|
||||||
|
.select({
|
||||||
|
status: todoTasks.status,
|
||||||
|
area: todoAreas.name,
|
||||||
|
n: sql<number>`count(*)::int`
|
||||||
|
})
|
||||||
|
.from(todoTasks)
|
||||||
|
.leftJoin(todoAreas, eq(todoAreas.id, todoTasks.areaId))
|
||||||
|
.where(isNotNull(todoTasks.externalId))
|
||||||
|
.groupBy(todoTasks.status, todoAreas.name);
|
||||||
|
for (const s of stats) {
|
||||||
|
report.byStatus[s.status] += s.n;
|
||||||
|
const a = s.area ?? 'No area';
|
||||||
|
report.byArea[a] = (report.byArea[a] ?? 0) + s.n;
|
||||||
|
}
|
||||||
|
const iso = (d: Date) => d.toISOString().slice(0, 10);
|
||||||
|
const created = rows
|
||||||
|
.map((r) => r.createdAt)
|
||||||
|
.filter((d) => d.getTime() > 0)
|
||||||
|
.sort((a, b) => +a - +b);
|
||||||
|
const completed = rows
|
||||||
|
.map((r) => r.completedAt)
|
||||||
|
.filter((d): d is Date => !!d)
|
||||||
|
.sort((a, b) => +a - +b);
|
||||||
|
if (created.length) report.range.created = [iso(created[0]), iso(created.at(-1)!)];
|
||||||
|
if (completed.length) report.range.completed = [iso(completed[0]), iso(completed.at(-1)!)];
|
||||||
|
return report;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -59,6 +59,9 @@
|
|||||||
let areaFilter = $state('all');
|
let areaFilter = $state('all');
|
||||||
let priorityFilter = $state<'any' | 'flagged' | 'high'>('any');
|
let priorityFilter = $state<'any' | 'flagged' | 'high'>('any');
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
|
// Long areas (years of TickTick history) render the first PAGE rows until expanded.
|
||||||
|
const PAGE = 40;
|
||||||
|
let expanded = $state<Record<string, boolean>>({});
|
||||||
|
|
||||||
const isOpen = (t: Task) => t.status === 'todo' || t.status === 'in_progress';
|
const isOpen = (t: Task) => t.status === 'todo' || t.status === 'in_progress';
|
||||||
const matches = (t: Task) =>
|
const matches = (t: Task) =>
|
||||||
@@ -431,8 +434,20 @@
|
|||||||
<span style:width={`${(g.done / g.total) * 100}%`}></span>
|
<span style:width={`${(g.done / g.total) * 100}%`}></span>
|
||||||
</div>{/if}
|
</div>{/if}
|
||||||
<ul class="divide-y divide-[var(--site-border)]">
|
<ul class="divide-y divide-[var(--site-border)]">
|
||||||
{#each g.tasks as t (t.id)}{@render taskRow(t, 0)}{/each}
|
{#each expanded[g.key] ? g.tasks : g.tasks.slice(0, PAGE) as t (t.id)}{@render taskRow(
|
||||||
|
t,
|
||||||
|
0
|
||||||
|
)}{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
{#if g.tasks.length > PAGE && !expanded[g.key]}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="mt-2"
|
||||||
|
onclick={() => (expanded[g.key] = true)}>Show all {g.tasks.length}</Button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-[var(--site-muted)]">No tasks match these filters.</p>
|
<p class="text-[var(--site-muted)]">No tasks match these filters.</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user