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

This commit is contained in:
2026-10-24 03:05:05 +00:00
parent 9d08e6af23
commit 6a127de950
11 changed files with 15974 additions and 52 deletions

View 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" }
]

View 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;

File diff suppressed because it is too large Load Diff

View 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)` : '')
);

View 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
View 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]+$/);
});
});

View File

@@ -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;
} }

View File

@@ -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

View 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
View 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;
});
}

View File

@@ -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>