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

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;
// Trackers keep their existing /projects/... URLs (no moves, so no redirects needed).
export const trackers = [
// Trackers, in display order. Names are short single nouns. Most keep their existing
// /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',
subtitle: 'every pokemon in every game, and call of duty camo mastery',
href: '/projects/game-tracker',
external: false
name: 'tasks',
subtitle: 'private: to-dos, goals and my TickTick history',
href: '/trackers/tasks',
external: false,
private: true
},
{
name: 'money',
subtitle: 'a manual ledger for accounts, bills and utilities',
href: '/trackers/money',
external: 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
external: false,
private: false
},
{
name: 'books',
subtitle: 'my library: what I am reading, have read and want to read',
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',
subtitle: 'books, games, places and other things worth keeping',
href: '/lists',
external: 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
external: false,
private: false
}
] as const;
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[] {
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),
source: text('source'), // e.g. 'daisy-import', 'manual', 'ticktick'
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'),
completedAt: timestamptz('completed_at'),
...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;
});
}