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:
@@ -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;
|
||||
Reference in New Issue
Block a user