Task + goal tracker: private tasks schema, /trackers/tasks page, idempotent seed import (daisy-import)
All checks were successful
Deploy production / deploy (push) Successful in 2m38s
All checks were successful
Deploy production / deploy (push) Successful in 2m38s
This commit is contained in:
@@ -92,6 +92,12 @@ export const privateTrackers = [
|
||||
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;
|
||||
|
||||
|
||||
@@ -14,3 +14,4 @@ export * from './cs-prices';
|
||||
export * from './house-tracker';
|
||||
export * from './health';
|
||||
export * from './books';
|
||||
export * from './tasks';
|
||||
|
||||
@@ -14,3 +14,4 @@ export const siteSchema = pgSchema('site');
|
||||
export const metaSchema = pgSchema('meta');
|
||||
export const healthSchema = pgSchema('health');
|
||||
export const booksSchema = pgSchema('books');
|
||||
export const tasksSchema = pgSchema('tasks');
|
||||
|
||||
143
src/lib/server/db/schema/tasks.ts
Normal file
143
src/lib/server/db/schema/tasks.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
type AnyPgColumn,
|
||||
boolean,
|
||||
check,
|
||||
date,
|
||||
index,
|
||||
integer,
|
||||
numeric,
|
||||
smallint,
|
||||
text,
|
||||
time,
|
||||
uniqueIndex
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { tasksSchema } from './schemas';
|
||||
import { id, timestamps, timestamptz } from './helpers';
|
||||
|
||||
// Private task + goal tracker (owner only, see requireOwner); meant to replace TickTick.
|
||||
// No user_id: there is exactly one owner, and the schema is granted to personal_app only.
|
||||
// Statuses are text + CHECK (not enums) so new ones are a one-line migration.
|
||||
// Due dates are Europe/Amsterdam wall-clock (date + optional time), like TickTick's
|
||||
// all-day vs timed tasks; reminders are real moments (timestamptz).
|
||||
|
||||
export const TASK_STATUSES = ['todo', 'in_progress', 'done', 'dropped'] as const;
|
||||
export type TaskStatus = (typeof TASK_STATUSES)[number];
|
||||
export const GOAL_STATUSES = ['active', 'achieved', 'dropped'] as const;
|
||||
export type GoalStatus = (typeof GOAL_STATUSES)[number];
|
||||
|
||||
/** Areas group tasks (TickTick "lists"); `slug` is the stable key for imports. */
|
||||
export const todoAreas = tasksSchema.table(
|
||||
'areas',
|
||||
{
|
||||
id: id(),
|
||||
slug: text('slug').notNull(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
color: text('color'), // CSS colour, optional
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
archivedAt: timestamptz('archived_at'),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('areas_slug_key').on(t.slug),
|
||||
check('areas_slug_check', sql`${t.slug} ~ '^[a-z0-9][a-z0-9-]{0,62}$'`),
|
||||
check('areas_name_check', sql`length(btrim(${t.name})) > 0`)
|
||||
]
|
||||
);
|
||||
|
||||
/** Long-term goals, optionally with a target date and a numeric metric. */
|
||||
export const todoGoals = tasksSchema.table(
|
||||
'goals',
|
||||
{
|
||||
id: id(),
|
||||
title: text('title').notNull(),
|
||||
notes: text('notes'),
|
||||
status: text('status').$type<GoalStatus>().notNull().default('active'),
|
||||
areaId: integer('area_id').references(() => todoAreas.id, { onDelete: 'set null' }),
|
||||
targetDate: date('target_date', { mode: 'string' }),
|
||||
metric: text('metric'), // what is measured, e.g. "net income per month"
|
||||
metricUnit: text('metric_unit'), // e.g. "EUR"
|
||||
metricTarget: numeric('metric_target', { precision: 14, scale: 2, mode: 'number' }),
|
||||
metricCurrent: numeric('metric_current', { precision: 14, scale: 2, mode: 'number' }),
|
||||
flagged: boolean('flagged').notNull().default(false),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
source: text('source'), // e.g. 'daisy-import', 'manual', 'ticktick'
|
||||
achievedAt: timestamptz('achieved_at'),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('goals_title_key').on(sql`lower(${t.title})`),
|
||||
check('goals_title_check', sql`length(btrim(${t.title})) > 0`),
|
||||
check('goals_status_check', sql`${t.status} IN ('active','achieved','dropped')`)
|
||||
]
|
||||
);
|
||||
|
||||
export const todoTasks = tasksSchema.table(
|
||||
'tasks',
|
||||
{
|
||||
id: id(),
|
||||
title: text('title').notNull(),
|
||||
notes: text('notes'),
|
||||
status: text('status').$type<TaskStatus>().notNull().default('todo'),
|
||||
priority: smallint('priority').notNull().default(0), // 0 none, 1 low, 2 medium, 3 high
|
||||
flagged: boolean('flagged').notNull().default(false), // Stan's "(!)"
|
||||
dueDate: date('due_date', { mode: 'string' }),
|
||||
dueTime: time('due_time'), // optional; Europe/Amsterdam wall clock
|
||||
reminderAt: timestamptz('reminder_at'),
|
||||
recurrence: text('recurrence'), // RFC 5545 RRULE, e.g. FREQ=WEEKLY;BYDAY=MO (not acted on yet)
|
||||
areaId: integer('area_id').references(() => todoAreas.id, { onDelete: 'set null' }),
|
||||
goalId: integer('goal_id').references(() => todoGoals.id, { onDelete: 'set null' }),
|
||||
parentId: integer('parent_id').references((): AnyPgColumn => todoTasks.id, {
|
||||
onDelete: 'cascade'
|
||||
}),
|
||||
tags: text('tags').array().notNull().default([]),
|
||||
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
|
||||
startedAt: timestamptz('started_at'),
|
||||
completedAt: timestamptz('completed_at'),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
index('tasks_area_idx').on(t.areaId),
|
||||
index('tasks_goal_idx').on(t.goalId),
|
||||
index('tasks_parent_idx').on(t.parentId),
|
||||
index('tasks_status_due_idx').on(t.status, t.dueDate),
|
||||
index('tasks_tags_idx').using('gin', t.tags),
|
||||
uniqueIndex('tasks_source_external_key')
|
||||
.on(t.source, t.externalId)
|
||||
.where(sql`${t.externalId} IS NOT NULL`),
|
||||
check('tasks_title_check', sql`length(btrim(${t.title})) > 0`),
|
||||
check('tasks_status_check', sql`${t.status} IN ('todo','in_progress','done','dropped')`),
|
||||
check('tasks_priority_check', sql`${t.priority} BETWEEN 0 AND 3`),
|
||||
check('tasks_parent_check', sql`${t.parentId} IS NULL OR ${t.parentId} <> ${t.id}`),
|
||||
check('tasks_time_check', sql`${t.dueTime} IS NULL OR ${t.dueDate} IS NOT NULL`)
|
||||
]
|
||||
);
|
||||
|
||||
/** Append-only history per task (status changes, notes, progress updates). */
|
||||
export const todoActivity = tasksSchema.table(
|
||||
'activity',
|
||||
{
|
||||
id: id(),
|
||||
taskId: integer('task_id').references(() => todoTasks.id, { onDelete: 'cascade' }),
|
||||
goalId: integer('goal_id').references(() => todoGoals.id, { onDelete: 'cascade' }),
|
||||
kind: text('kind').notNull(), // created | status | edit | note | progress
|
||||
fromStatus: text('from_status'),
|
||||
toStatus: text('to_status'),
|
||||
note: text('note'),
|
||||
value: numeric('value', { precision: 14, scale: 2, mode: 'number' }), // goal metric snapshot
|
||||
createdAt: timestamptz('created_at').notNull().defaultNow()
|
||||
},
|
||||
(t) => [
|
||||
index('activity_task_idx').on(t.taskId, t.createdAt),
|
||||
index('activity_goal_idx').on(t.goalId, t.createdAt),
|
||||
check('activity_target_check', sql`${t.taskId} IS NOT NULL OR ${t.goalId} IS NOT NULL`),
|
||||
check('activity_kind_check', sql`${t.kind} IN ('created','status','edit','note','progress')`)
|
||||
]
|
||||
);
|
||||
|
||||
export type TodoArea = typeof todoAreas.$inferSelect;
|
||||
export type TodoGoal = typeof todoGoals.$inferSelect;
|
||||
export type TodoTask = typeof todoTasks.$inferSelect;
|
||||
@@ -16,6 +16,9 @@ describe('owner gate', () => {
|
||||
expect(isOwnerOnlyPath('/trackers/Health/import')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/healthy')).toBe(false);
|
||||
expect(isOwnerOnlyPath('/trackers')).toBe(false);
|
||||
expect(isOwnerOnlyPath('/trackers/tasks')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/Tasks/')).toBe(true);
|
||||
expect(isOwnerWritePath('/trackers/tasks')).toBe(true);
|
||||
});
|
||||
|
||||
it('gates the book reader, files and APIs but not the catalogue', () => {
|
||||
|
||||
@@ -19,7 +19,11 @@ export function requireOwner(event: OwnerEvent) {
|
||||
}
|
||||
|
||||
/** Paths that only the owner may see (checked in hooks.server.ts before any route runs). */
|
||||
export const ownerOnlyPrefixes = ['/trackers/health', '/trackers/books/files'] as const;
|
||||
export const ownerOnlyPrefixes = [
|
||||
'/trackers/health',
|
||||
'/trackers/tasks',
|
||||
'/trackers/books/files'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Owner-only paths inside otherwise public areas: the book reader and its progress/notes
|
||||
@@ -31,7 +35,11 @@ export const ownerOnlyPatterns = [/^\/trackers\/books\/\d+\/(read|progress|notes
|
||||
* Areas where only the owner may write: any non-GET request from anyone else is refused
|
||||
* (404) in hooks.server.ts, which also covers form actions on the public book pages.
|
||||
*/
|
||||
export const ownerWritePrefixes = ['/trackers/health', '/trackers/books'] as const;
|
||||
export const ownerWritePrefixes = [
|
||||
'/trackers/health',
|
||||
'/trackers/tasks',
|
||||
'/trackers/books'
|
||||
] as const;
|
||||
|
||||
const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, '');
|
||||
const under = (path: string, prefix: string) => path === prefix || path.startsWith(`${prefix}/`);
|
||||
|
||||
62
src/lib/server/tasks.test.ts
Normal file
62
src/lib/server/tasks.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import seed from '../../../data/tasks/daisy-import.json';
|
||||
import { parseQuickTitle, seedFile, statusTimestamps, taskInput } from './tasks';
|
||||
|
||||
describe('task tracker', () => {
|
||||
it('parses the seed file', () => {
|
||||
const parsed = seedFile.parse(seed);
|
||||
expect(parsed.tasks.length).toBeGreaterThan(50);
|
||||
expect(parsed.goals.length).toBeGreaterThan(3);
|
||||
const slugs = new Set(parsed.areas.map((a) => a.slug));
|
||||
for (const t of parsed.tasks) expect(slugs.has(t.area!)).toBe(true);
|
||||
const goals = new Set(parsed.goals.map((g) => g.title.toLowerCase()));
|
||||
for (const t of parsed.tasks) if (t.goal) expect(goals.has(t.goal.toLowerCase())).toBe(true);
|
||||
const keys = parsed.tasks.map((t) => `${t.area}|${t.title.toLowerCase()}`);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
it('validates form input', () => {
|
||||
expect(taskInput.safeParse({ title: ' ' }).success).toBe(false);
|
||||
expect(taskInput.safeParse({ title: 'x', dueTime: '10:00' }).success).toBe(false);
|
||||
const ok = taskInput.parse({ title: 'x', due: '2026-10-26', dueTime: '10:00', tags: '#a, b' });
|
||||
expect(ok.tags).toEqual(['a', 'b']);
|
||||
expect(ok.status).toBe('todo');
|
||||
});
|
||||
it('parses quick-add flags', () => {
|
||||
expect(parseQuickTitle('Call vet !')).toEqual({
|
||||
title: 'Call vet',
|
||||
flagged: true,
|
||||
priority: 0
|
||||
});
|
||||
expect(parseQuickTitle('Pay rent !!!')).toEqual({
|
||||
title: 'Pay rent',
|
||||
flagged: true,
|
||||
priority: 3
|
||||
});
|
||||
expect(parseQuickTitle('Sign up (!)')).toEqual({
|
||||
title: 'Sign up',
|
||||
flagged: true,
|
||||
priority: 0
|
||||
});
|
||||
expect(parseQuickTitle('Hello!')).toEqual({ title: 'Hello!', flagged: false, priority: 0 });
|
||||
});
|
||||
it('sets completion timestamps from the status', () => {
|
||||
const now = new Date('2026-10-24T10:00:00Z');
|
||||
expect(statusTimestamps('done', null, now).completedAt).toEqual(now);
|
||||
expect(statusTimestamps('todo', { startedAt: null, completedAt: now }, now).completedAt).toBe(
|
||||
null
|
||||
);
|
||||
expect(statusTimestamps('in_progress', null, now).startedAt).toEqual(now);
|
||||
});
|
||||
});
|
||||
|
||||
describe('local times', () => {
|
||||
it('reads datetime-local values as Amsterdam time', async () => {
|
||||
const { parseLocalMoment } = await import('./tasks');
|
||||
expect(parseLocalMoment('2026-10-24T11:50')?.toISOString()).toBe('2026-10-24T09:50:00.000Z');
|
||||
expect(parseLocalMoment('2026-12-07T09:00')?.toISOString()).toBe('2026-12-07T08:00:00.000Z');
|
||||
expect(parseLocalMoment('2026-10-24T11:50:00+02:00')?.toISOString()).toBe(
|
||||
'2026-10-24T09:50:00.000Z'
|
||||
);
|
||||
expect(parseLocalMoment('nope')).toBe(null);
|
||||
});
|
||||
});
|
||||
338
src/lib/server/tasks.ts
Normal file
338
src/lib/server/tasks.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
// Task + goal tracker: validation and the idempotent seed import. Shared by
|
||||
// /trackers/tasks (owner only) and scripts/tasks-import.ts, so it must not import
|
||||
// anything from SvelteKit.
|
||||
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||
import type { PgAsyncDatabase } from 'drizzle-orm/pg-core';
|
||||
import * as z from 'zod';
|
||||
import {
|
||||
GOAL_STATUSES,
|
||||
TASK_STATUSES,
|
||||
todoActivity,
|
||||
todoAreas,
|
||||
todoGoals,
|
||||
todoTasks
|
||||
} from './db/schema/tasks';
|
||||
|
||||
export const TIME_ZONE = 'Europe/Amsterdam';
|
||||
|
||||
const blank = (v: unknown) => (typeof v === 'string' && v.trim() === '' ? null : v);
|
||||
const optText = z.preprocess(blank, z.string().trim().max(10_000).nullish());
|
||||
const day = z.preprocess(
|
||||
blank,
|
||||
z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Use YYYY-MM-DD')
|
||||
.refine((s) => !Number.isNaN(Date.parse(`${s}T00:00:00Z`)), 'Invalid date')
|
||||
.nullish()
|
||||
);
|
||||
const clock = z.preprocess(
|
||||
blank,
|
||||
z
|
||||
.string()
|
||||
.regex(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/, 'Use HH:MM')
|
||||
.nullish()
|
||||
);
|
||||
const moment = z.preprocess(
|
||||
blank,
|
||||
z
|
||||
.string()
|
||||
.refine((s) => !Number.isNaN(Date.parse(s)), 'Invalid date/time')
|
||||
.nullish()
|
||||
);
|
||||
const tags = z.preprocess(
|
||||
(v) =>
|
||||
typeof v === 'string'
|
||||
? v
|
||||
.split(/[,\s]+/)
|
||||
.map((s) => s.replace(/^#/, ''))
|
||||
.filter(Boolean)
|
||||
: v,
|
||||
z.array(z.string().trim().toLowerCase().min(1).max(40)).max(30).default([])
|
||||
);
|
||||
const num = z.preprocess(
|
||||
(v) => (blank(v) === null ? null : typeof v === 'string' ? Number(v.replace(',', '.')) : v),
|
||||
z.number().finite().nullish()
|
||||
);
|
||||
|
||||
export const areaSeed = z.object({
|
||||
slug: z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/),
|
||||
name: z.string().trim().min(1),
|
||||
description: optText,
|
||||
color: optText,
|
||||
sortOrder: z.number().int().default(0)
|
||||
});
|
||||
|
||||
export const goalInput = z.object({
|
||||
title: z.string().trim().min(1).max(300),
|
||||
notes: optText,
|
||||
status: z.enum(GOAL_STATUSES).default('active'),
|
||||
area: z.preprocess(blank, z.string().nullish()), // area slug
|
||||
targetDate: day,
|
||||
metric: optText,
|
||||
metricUnit: optText,
|
||||
metricTarget: num,
|
||||
metricCurrent: num,
|
||||
flagged: z.coerce.boolean().default(false)
|
||||
});
|
||||
|
||||
export const taskInput = z
|
||||
.object({
|
||||
title: z.string().trim().min(1).max(500),
|
||||
notes: optText,
|
||||
status: z.enum(TASK_STATUSES).default('todo'),
|
||||
priority: z.coerce.number().int().min(0).max(3).default(0),
|
||||
flagged: z.coerce.boolean().default(false),
|
||||
due: day,
|
||||
dueTime: clock,
|
||||
reminderAt: moment,
|
||||
recurrence: optText,
|
||||
area: z.preprocess(blank, z.string().nullish()), // area slug
|
||||
goal: z.preprocess(blank, z.string().nullish()), // goal title
|
||||
parent: z.preprocess(blank, z.string().nullish()), // parent task title (same area)
|
||||
tags
|
||||
})
|
||||
.refine((t) => !t.dueTime || t.due, {
|
||||
message: 'A due time needs a due date',
|
||||
path: ['dueTime']
|
||||
});
|
||||
|
||||
export const seedFile = z.object({
|
||||
source: z.string().min(1).default('daisy-import'),
|
||||
areas: z.array(areaSeed).default([]),
|
||||
goals: z.array(goalInput).default([]),
|
||||
tasks: z.array(taskInput).default([])
|
||||
});
|
||||
export type SeedFile = z.infer<typeof seedFile>;
|
||||
export type TaskInput = z.infer<typeof taskInput>;
|
||||
|
||||
/**
|
||||
* "Buy milk !" or "! buy milk" → flagged; "!!!" anywhere → priority 3 (TickTick-style).
|
||||
* Used by quick-add.
|
||||
*/
|
||||
export function parseQuickTitle(raw: string): {
|
||||
title: string;
|
||||
flagged: boolean;
|
||||
priority: number;
|
||||
} {
|
||||
let title = raw.trim();
|
||||
let priority = 0;
|
||||
const bangs = title.match(/(^|\s)(!{1,3})(?=\s|$)/);
|
||||
if (bangs) {
|
||||
priority = bangs[2].length === 1 ? 0 : bangs[2].length;
|
||||
title = title.replace(bangs[0], ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
const flagged = !!bangs || /\(!\)/.test(title);
|
||||
title = title.replace(/\s*\(!\)\s*/g, ' ').trim();
|
||||
return { title, flagged, priority };
|
||||
}
|
||||
|
||||
/** Status side effects: completedAt/startedAt follow the status. */
|
||||
export function statusTimestamps(
|
||||
status: (typeof TASK_STATUSES)[number],
|
||||
prev: { startedAt: Date | null; completedAt: Date | null } | null,
|
||||
now = new Date()
|
||||
) {
|
||||
return {
|
||||
startedAt:
|
||||
status === 'in_progress' || status === 'done'
|
||||
? (prev?.startedAt ?? (status === 'in_progress' ? now : null))
|
||||
: (prev?.startedAt ?? null),
|
||||
completedAt: status === 'done' || status === 'dropped' ? (prev?.completedAt ?? now) : null
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- any drizzle pg driver
|
||||
type Db = PgAsyncDatabase<any, any>;
|
||||
export type ImportResult = {
|
||||
areas: { inserted: number; existing: number };
|
||||
goals: { inserted: number; existing: number; updated: number };
|
||||
tasks: { inserted: number; existing: number; updated: number };
|
||||
};
|
||||
|
||||
/**
|
||||
* Idempotent import. Areas dedupe on slug, goals on lower(title), tasks on area + title
|
||||
* (case-insensitive). Existing rows are left alone unless `update` is set, so re-running
|
||||
* never undoes edits made on the site.
|
||||
*/
|
||||
export async function importSeed(
|
||||
db: Db,
|
||||
seed: SeedFile,
|
||||
{ update = false }: { update?: boolean } = {}
|
||||
): Promise<ImportResult> {
|
||||
const result: ImportResult = {
|
||||
areas: { inserted: 0, existing: 0 },
|
||||
goals: { inserted: 0, existing: 0, updated: 0 },
|
||||
tasks: { inserted: 0, existing: 0, updated: 0 }
|
||||
};
|
||||
await db.transaction(async (tx) => {
|
||||
const areaIds = new Map<string, number>();
|
||||
for (const a of seed.areas) {
|
||||
const [row] = await tx
|
||||
.insert(todoAreas)
|
||||
.values({ ...a, description: a.description ?? null, color: a.color ?? null })
|
||||
.onConflictDoNothing({ target: todoAreas.slug })
|
||||
.returning({ id: todoAreas.id });
|
||||
if (row) result.areas.inserted++;
|
||||
else result.areas.existing++;
|
||||
}
|
||||
for (const a of await tx.select({ id: todoAreas.id, slug: todoAreas.slug }).from(todoAreas))
|
||||
areaIds.set(a.slug, a.id);
|
||||
const areaId = (slug: string | null | undefined) => {
|
||||
if (!slug) return null;
|
||||
const id = areaIds.get(slug);
|
||||
if (id === undefined) throw new Error(`Unknown area "${slug}"`);
|
||||
return id;
|
||||
};
|
||||
|
||||
const goalIds = new Map<string, number>();
|
||||
for (const g of seed.goals) {
|
||||
const values = {
|
||||
title: g.title,
|
||||
notes: g.notes ?? null,
|
||||
status: g.status,
|
||||
areaId: areaId(g.area),
|
||||
targetDate: g.targetDate ?? null,
|
||||
metric: g.metric ?? null,
|
||||
metricUnit: g.metricUnit ?? null,
|
||||
metricTarget: g.metricTarget ?? null,
|
||||
metricCurrent: g.metricCurrent ?? null,
|
||||
flagged: g.flagged,
|
||||
source: seed.source
|
||||
};
|
||||
const [existing] = await tx
|
||||
.select({ id: todoGoals.id })
|
||||
.from(todoGoals)
|
||||
.where(sql`lower(${todoGoals.title}) = lower(${g.title})`);
|
||||
if (existing) {
|
||||
result.goals.existing++;
|
||||
if (update) {
|
||||
await tx.update(todoGoals).set(values).where(eq(todoGoals.id, existing.id));
|
||||
result.goals.updated++;
|
||||
}
|
||||
goalIds.set(g.title.toLowerCase(), existing.id);
|
||||
} else {
|
||||
const [row] = await tx.insert(todoGoals).values(values).returning({ id: todoGoals.id });
|
||||
result.goals.inserted++;
|
||||
goalIds.set(g.title.toLowerCase(), row.id);
|
||||
}
|
||||
}
|
||||
const goalId = async (title: string | null | undefined) => {
|
||||
if (!title) return null;
|
||||
const known = goalIds.get(title.toLowerCase());
|
||||
if (known) return known;
|
||||
const [row] = await tx
|
||||
.select({ id: todoGoals.id })
|
||||
.from(todoGoals)
|
||||
.where(sql`lower(${todoGoals.title}) = lower(${title})`);
|
||||
if (!row) throw new Error(`Unknown goal "${title}"`);
|
||||
return row.id;
|
||||
};
|
||||
|
||||
const findTask = async (area: number | null, title: string) => {
|
||||
const [row] = await tx
|
||||
.select({ id: todoTasks.id })
|
||||
.from(todoTasks)
|
||||
.where(
|
||||
and(
|
||||
area === null ? isNull(todoTasks.areaId) : eq(todoTasks.areaId, area),
|
||||
sql`lower(${todoTasks.title}) = lower(${title})`
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
return row?.id ?? null;
|
||||
};
|
||||
|
||||
// Parents first, so subtasks can point at them.
|
||||
const ordered = [...seed.tasks].sort((a, b) => Number(!!a.parent) - Number(!!b.parent));
|
||||
for (const [i, t] of ordered.entries()) {
|
||||
const area = areaId(t.area);
|
||||
const parentId = t.parent ? await findTask(area, t.parent) : null;
|
||||
if (t.parent && parentId === null) throw new Error(`Unknown parent task "${t.parent}"`);
|
||||
const values = {
|
||||
title: t.title,
|
||||
notes: t.notes ?? null,
|
||||
status: t.status,
|
||||
priority: t.priority,
|
||||
flagged: t.flagged,
|
||||
dueDate: t.due ?? null,
|
||||
dueTime: t.dueTime ?? null,
|
||||
reminderAt: t.reminderAt ? parseLocalMoment(t.reminderAt) : null,
|
||||
recurrence: t.recurrence ?? null,
|
||||
areaId: area,
|
||||
goalId: await goalId(t.goal),
|
||||
parentId,
|
||||
tags: t.tags,
|
||||
sortOrder: (i + 1) * 10,
|
||||
source: seed.source,
|
||||
...statusTimestamps(t.status, null)
|
||||
};
|
||||
const existing = await findTask(area, t.title);
|
||||
if (existing !== null) {
|
||||
result.tasks.existing++;
|
||||
if (update) {
|
||||
const rest: Partial<typeof values> = { ...values };
|
||||
delete rest.startedAt;
|
||||
delete rest.completedAt;
|
||||
await tx.update(todoTasks).set(rest).where(eq(todoTasks.id, existing));
|
||||
result.tasks.updated++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const [row] = await tx.insert(todoTasks).values(values).returning({ id: todoTasks.id });
|
||||
await tx.insert(todoActivity).values({
|
||||
taskId: row.id,
|
||||
kind: 'created',
|
||||
toStatus: t.status,
|
||||
note: `Imported (${seed.source})`
|
||||
});
|
||||
result.tasks.inserted++;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function offsetMinutes(at: Date, timeZone: string): number {
|
||||
const parts = Object.fromEntries(
|
||||
new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
hourCycle: 'h23',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
.formatToParts(at)
|
||||
.map((p) => [p.type, p.value])
|
||||
);
|
||||
const wall = Date.UTC(
|
||||
+parts.year,
|
||||
+parts.month - 1,
|
||||
+parts.day,
|
||||
+parts.hour,
|
||||
+parts.minute,
|
||||
+parts.second
|
||||
);
|
||||
return Math.round((wall - at.getTime()) / 60_000);
|
||||
}
|
||||
|
||||
/** "2026-12-07T09:00" (a datetime-local value, Amsterdam wall clock) → Date. ISO with offset passes through. */
|
||||
export function parseLocalMoment(value: string, timeZone = TIME_ZONE): Date | null {
|
||||
const local = value.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(:\d{2})?$/);
|
||||
if (!local) {
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
const guess = new Date(`${local[1]}T${local[2]}${local[3] ?? ':00'}Z`);
|
||||
if (Number.isNaN(guess.getTime())) return null;
|
||||
let result = new Date(guess.getTime() - offsetMinutes(guess, timeZone) * 60_000);
|
||||
const corrected = offsetMinutes(result, timeZone);
|
||||
result = new Date(guess.getTime() - corrected * 60_000);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Today's date in Amsterdam, YYYY-MM-DD. */
|
||||
export function todayIn(timeZone = TIME_ZONE, now = new Date()): string {
|
||||
return new Intl.DateTimeFormat('en-CA', { timeZone }).format(now);
|
||||
}
|
||||
Reference in New Issue
Block a user