feat: migrate schema to DATE type, add test infrastructure

- Migrate due_date/next_occurrence columns from TIMESTAMPTZ to DATE
- Update serializeRow() to distinguish DATE vs TIMESTAMPTZ serialization
- Simplify frontend date parsing (no more timezone workarounds)
- Add Vitest + Testing Library test infrastructure
- Add initial date parsing/formatting unit tests
- Update package.json with dev dependencies (vitest, testing-library, jsdom)
This commit is contained in:
Victor
2026-05-03 03:16:54 +00:00
commit 6daa8f7f59
43 changed files with 9786 additions and 0 deletions

124
src/server/db/migrate.ts Normal file
View File

@@ -0,0 +1,124 @@
import { Pool } from 'pg';
const connectionString = process.env.DATABASE_URL || 'postgresql://vixtix:vixtix_secret@localhost:5433/vixtix';
const pool = new Pool({ connectionString });
async function migrate() {
console.log('Running migrations...');
const client = await pool.connect();
try {
await client.query('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"');
// Create enums - use DO block to handle duplicates
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'priority') THEN
CREATE TYPE priority AS ENUM ('low', 'medium', 'high', 'urgent');
END IF;
END $$;
`);
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'status') THEN
CREATE TYPE status AS ENUM ('todo', 'in_progress', 'done');
END IF;
END $$;
`);
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'recurrence') THEN
CREATE TYPE recurrence AS ENUM ('daily', 'weekly', 'biweekly', 'monthly', 'yearly', 'none');
END IF;
END $$;
`);
await client.query(`
CREATE TABLE IF NOT EXISTS projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL, description TEXT DEFAULT '', color TEXT DEFAULT '#3b82f6',
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
);
`);
await client.query(`
CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID REFERENCES projects(id),
title TEXT NOT NULL, description TEXT DEFAULT '',
completed BOOLEAN DEFAULT FALSE NOT NULL,
priority priority DEFAULT 'medium' NOT NULL,
due_date DATE,
status status DEFAULT 'todo' NOT NULL,
parent_task_id UUID REFERENCES tasks(id),
recurrence_rule recurrence DEFAULT 'none' NOT NULL,
recurrence_interval INTEGER DEFAULT 1 NOT NULL,
next_occurrence DATE,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
);
`);
await client.query(`
CREATE INDEX IF NOT EXISTS idx_tasks_project_id ON tasks(project_id);
CREATE INDEX IF NOT EXISTS idx_tasks_parent_task_id ON tasks(parent_task_id);
CREATE INDEX IF NOT EXISTS idx_tasks_completed ON tasks(completed);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date);
CREATE INDEX IF NOT EXISTS idx_tasks_recurrence ON tasks(recurrence_rule);
`);
const count = await client.query('SELECT COUNT(*) FROM projects');
if (parseInt(count.rows[0].count) === 0) {
console.log('Seeding initial data...');
await client.query(`
INSERT INTO projects (name, description, color, sort_order) VALUES
('Personal', 'Personal tasks and goals', '#3b82f6', 1),
('Work', 'Work-related tasks', '#10b981', 2),
('Health', 'Health and fitness', '#f59e0b', 3),
('Finance', 'Financial tasks and tracking', '#8b5cf6', 4);
`);
// Use DATE arithmetic instead of INTERVAL
await client.query(`
INSERT INTO tasks (project_id, title, description, priority, status, due_date, sort_order)
SELECT p.id, t.title, t.description, t.priority::priority, t.status::status, t.due_date, t.sort_order
FROM (
VALUES
('Personal', 'Set up daily routine', 'Morning meditation, exercise, and planning', 'high', 'todo', CURRENT_DATE + 1, 1),
('Personal', 'Read 30 minutes', 'Read a book or articles', 'medium', 'todo', CURRENT_DATE + 2, 2),
('Personal', 'Clean apartment', 'Deep clean kitchen and bathrooms', 'medium', 'in_progress', CURRENT_DATE + 3, 3),
('Work', 'Review sprint backlog', 'Prioritize tasks for next sprint', 'high', 'todo', CURRENT_DATE + 1, 1),
('Work', 'Update documentation', 'Add API docs for new endpoints', 'medium', 'in_progress', CURRENT_DATE + 5, 2),
('Work', 'Code review', 'Review pull requests from team', 'low', 'done', CURRENT_DATE - 1, 3),
('Health', 'Gym workout', 'Upper body strength training', 'high', 'todo', CURRENT_DATE + 1, 1),
('Health', 'Meal prep', 'Prepare healthy meals for the week', 'medium', 'todo', CURRENT_DATE + 2, 2),
('Health', 'Track water intake', 'Drink at least 8 glasses of water', 'low', 'done', CURRENT_DATE - 1, 3),
('Finance', 'Review monthly budget', 'Check spending and adjust categories', 'high', 'todo', CURRENT_DATE + 3, 1),
('Finance', 'Pay bills', 'Electricity, internet, phone', 'urgent', 'todo', CURRENT_DATE + 1, 2),
('Finance', 'Investment review', 'Check portfolio performance', 'medium', 'todo', CURRENT_DATE + 7, 3)
) AS t(name, title, description, priority, status, due_date, sort_order)
JOIN projects p ON p.name = t.name;
`);
console.log('Seed data inserted successfully!');
} else {
console.log('Database already seeded.');
}
console.log('Migrations completed successfully!');
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
} finally {
client.release();
await pool.end();
}
}
migrate();