From 4b8d75617b6f6e97bad977d25d39587f466f0c37 Mon Sep 17 00:00:00 2001 From: Victor Date: Sat, 9 May 2026 01:57:49 +0000 Subject: [PATCH] feat: add reminder system with cron scheduler and fix timezone formatting - Add reminders to Prisma schema (Reminder model, TaskReminder relation) - Add /api/reminders endpoint and cron send-reminders.ts script - Add reminder fields to NewTaskModal and EditTaskModal components - Fix reminder datetime serialization: use toISOString().slice(0,16) for UTC-safe YYYY-MM-DDTHH:mm format compatible with datetime-local inputs - Update Dockerfile to install tsx for cron container - Add AGENTS.md with project conventions - Update docker-compose.yml with cron service and build context --- AGENTS.md | 72 +++++++++++++++++++++++++ Dockerfile | 6 +++ docker-compose.yml | 20 ++++++- prisma/schema.prisma | 60 +++++++++++++++------ scripts/send-reminders.ts | 90 ++++++++++++++++++++++++++++++++ src/app/api/reminders/route.ts | 62 ++++++++++++++++++++++ src/app/api/tasks/[id]/route.ts | 28 +++++++++- src/app/api/tasks/route.ts | 39 ++++++++++++-- src/components/AppProvider.tsx | 7 +++ src/components/EditTaskModal.tsx | 71 ++++++++++++++++++++++--- src/components/NewTaskModal.tsx | 73 ++++++++++++++++++++++---- 11 files changed, 490 insertions(+), 38 deletions(-) create mode 100644 scripts/send-reminders.ts create mode 100644 src/app/api/reminders/route.ts diff --git a/AGENTS.md b/AGENTS.md index 8bd0e39..773129c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,75 @@ +# CLAUDE.md + +## Behavioral Guidelines + +Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed. + +**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. + +### 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +### 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +### 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +### 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +--- + +**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes. + +## Project-Specific + + + # This is NOT the Next.js you know diff --git a/Dockerfile b/Dockerfile index 6c8c79f..0889236 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,6 +43,12 @@ COPY --from=builder /app/prisma ./prisma # Copy generated Prisma client (includes native query engine binary) COPY --from=builder /app/src/generated ./src/generated +# Copy cron script for reminder notifications +COPY --from=builder /app/scripts ./scripts + +# Install tsx for cron worker +RUN npm install -g tsx + # Copy startup script COPY start.sh /app/start.sh RUN chmod +x /app/start.sh diff --git a/docker-compose.yml b/docker-compose.yml index 5c4b7e0..f3730b3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,11 +27,29 @@ services: postgres: condition: service_healthy ports: - - "3070:3000" + - "3000:3000" environment: DATABASE_URL: postgresql://vixtix:vixtix_secret@postgres:5432/vixtix DB_HOST: postgres DB_PORT: 5432 + vixtix-cron: + image: vixtix:latest + container_name: vixtix-cron + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + DATABASE_URL: postgresql://vixtix:vixtix_secret@postgres:5432/vixtix + MATTERMOST_WEBHOOK_URL: ${MATTERMOST_WEBHOOK_URL} + entrypoint: ["sh", "-c"] + command: + - | + while true; do + npx tsx /app/scripts/send-reminders.ts + sleep 60 + done + volumes: vixtix-data: diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 45276e1..e4aa2a9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -25,25 +25,26 @@ model Project { } model Task { - id String @id @default(uuid()) @db.Uuid + id String @id @default(uuid()) @db.Uuid title String - description String @default("") - completed Boolean @default(false) - priority Priority @default(medium) - dueDate DateTime? @map("due_date") - status Status @default(todo) - parentTaskId String? @map("parent_task_id") @db.Uuid - recurrenceRule Recurrence @default(none) @map("recurrence_rule") - recurrenceInterval Int @default(1) @map("recurrence_interval") - nextOccurrence DateTime? @map("next_occurrence") - sortOrder Int @default(0) @map("sort_order") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + description String @default("") + completed Boolean @default(false) + priority Priority @default(medium) + dueDate DateTime? @map("due_date") + status Status @default(todo) + parentTaskId String? @map("parent_task_id") @db.Uuid + recurrenceRule Recurrence @default(none) @map("recurrence_rule") + recurrenceInterval Int @default(1) @map("recurrence_interval") + nextOccurrence DateTime? @map("next_occurrence") + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") - parentTask Task? @relation("TaskChildren", fields: [parentTaskId], references: [id], onDelete: Cascade) - children Task[] @relation("TaskChildren") - project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) - projectId String? @map("project_id") @db.Uuid + parentTask Task? @relation("TaskChildren", fields: [parentTaskId], references: [id], onDelete: Cascade) + children Task[] @relation("TaskChildren") + project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) + projectId String? @map("project_id") @db.Uuid + reminders TaskReminder[] @relation("TaskReminders") @@index([projectId]) @@index([parentTaskId]) @@ -74,3 +75,28 @@ enum Recurrence { monthly yearly } + +model TaskReminder { + id String @id @default(uuid()) @db.Uuid + taskId String @map("task_id") @db.Uuid + reminder DateTime @map("reminder") + sent Boolean @default(false) @map("sent") + createdAt DateTime @default(now()) @map("created_at") + + task Task @relation("TaskReminders", fields: [taskId], references: [id], onDelete: Cascade) + + @@index([taskId]) + @@index([sent]) +} + +model Reminder { + id String @id @default(uuid()) @db.Uuid + message String + dueDate DateTime @map("due_date") + sent Boolean @default(false) @map("sent") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([dueDate]) + @@index([sent]) +} diff --git a/scripts/send-reminders.ts b/scripts/send-reminders.ts new file mode 100644 index 0000000..f05d800 --- /dev/null +++ b/scripts/send-reminders.ts @@ -0,0 +1,90 @@ +// @ts-nocheck +import { PrismaClient } from '../src/generated/prisma/client'; + +const prisma = new PrismaClient(); + +const MATTERMOST_WEBHOOK_URL = process.env.MATTERMOST_WEBHOOK_URL; + +if (!MATTERMOST_WEBHOOK_URL) { + console.error('MATTERMOST_WEBHOOK_URL is not set'); + process.exit(1); +} + +async function sendToMattermost(message: string): Promise { + try { + const payload = { + text: `⏰ Reminder: ${message}`, + }; + + const response = await fetch(MATTERMOST_WEBHOOK_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + console.error(`Mattermost webhook failed: ${response.status} ${response.statusText}`); + return false; + } + + console.log(`Sent reminder to Mattermost: ${message}`); + return true; + } catch (error) { + console.error('Error sending to Mattermost:', error); + return false; + } +} + +async function main() { + const now = new Date(); + console.log(`Checking for due reminders at ${now.toISOString()}...`); + + // 1. Find unsent standalone reminders that are due + const dueReminders = await prisma.reminder.findMany({ + where: { + sent: false, + dueDate: { lte: now }, + }, + orderBy: { dueDate: 'asc' }, + }); + + for (const reminder of dueReminders) { + const sent = await sendToMattermost(reminder.message); + if (sent) { + await prisma.reminder.update({ + where: { id: reminder.id }, + data: { sent: true }, + }); + } + } + + // 2. Find unsent task reminders that are due + const dueTaskReminders = await prisma.taskReminder.findMany({ + where: { + sent: false, + reminder: { lte: now }, + }, + orderBy: { reminder: 'asc' }, + include: { task: true }, + }); + + for (const tr of dueTaskReminders) { + const message = `[${tr.task.title}]${tr.task.description ? ` - ${tr.task.description}` : ''}`; + const sent = await sendToMattermost(message); + if (sent) { + await prisma.taskReminder.update({ + where: { id: tr.id }, + data: { sent: true }, + }); + } + } + + await prisma.$disconnect(); + console.log('Done.'); +} + +main().catch((error) => { + console.error('Cron worker error:', error); + prisma.$disconnect(); + process.exit(1); +}); diff --git a/src/app/api/reminders/route.ts b/src/app/api/reminders/route.ts new file mode 100644 index 0000000..cb3c99b --- /dev/null +++ b/src/app/api/reminders/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server'; +import prisma from '@/lib/db'; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const sent = searchParams.get('sent'); + + try { + const where: Record = {}; + if (sent !== null) where.sent = sent === 'true'; + + const reminders = await prisma.reminder.findMany({ + where, + orderBy: [{ dueDate: 'asc' }], + }); + + return NextResponse.json( + reminders.map((r) => ({ + ...r, + dueDate: r.dueDate.toISOString(), + })) + ); + } catch (error) { + console.error('Error fetching reminders:', error); + return NextResponse.json({ error: 'Failed to fetch reminders' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + let body; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const { message, dueDate } = body; + + if (!message?.trim()) { + return NextResponse.json({ error: 'Message is required' }, { status: 400 }); + } + if (!dueDate) { + return NextResponse.json({ error: 'dueDate is required' }, { status: 400 }); + } + + try { + const reminder = await prisma.reminder.create({ + data: { + message: message.trim(), + dueDate: new Date(dueDate), + }, + }); + + return NextResponse.json({ + ...reminder, + dueDate: reminder.dueDate.toISOString(), + }); + } catch (error) { + console.error('Error creating reminder:', error); + return NextResponse.json({ error: 'Failed to create reminder' }, { status: 500 }); + } +} diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts index 4ecac38..fb01c95 100644 --- a/src/app/api/tasks/[id]/route.ts +++ b/src/app/api/tasks/[id]/route.ts @@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server'; import prisma from '@/lib/db'; import { addDays, addWeeks, addMonths, addYears } from 'date-fns'; + + export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> } @@ -13,6 +15,7 @@ export async function GET( where: { id }, include: { children: { orderBy: { sortOrder: 'asc' } }, + reminders: { orderBy: { reminder: 'asc' } }, }, }); @@ -24,6 +27,10 @@ export async function GET( ...task, dueDate: task.dueDate ? task.dueDate.toISOString().split('T')[0] : null, nextOccurrence: task.nextOccurrence ? task.nextOccurrence.toISOString().split('T')[0] : null, + reminders: task.reminders.map((r) => ({ + ...r, + reminder: r.reminder.toISOString().slice(0, 16), + })), subtasks: task.children.map((c) => ({ ...c, dueDate: c.dueDate ? c.dueDate.toISOString().split('T')[0] : null, @@ -49,7 +56,7 @@ export async function PUT( return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); } - const { title, description, projectId, priority, dueDate, status, recurrenceRule, recurrenceInterval, nextOccurrence } = body; + const { title, description, projectId, priority, dueDate, status, recurrenceRule, recurrenceInterval, nextOccurrence, reminders } = body; if (!title?.trim()) { return NextResponse.json({ error: 'Title is required' }, { status: 400 }); @@ -68,6 +75,21 @@ export async function PUT( recurrenceRule: recurrenceRule, recurrenceInterval: recurrenceInterval, nextOccurrence: nextOccurrence ? new Date(nextOccurrence) : undefined, + reminders: { + deleteMany: {}, + create: (reminders || []).map((r: string) => { + // Parse local datetime string and construct Date to preserve local time + const [datePart, timePart] = r.split('T'); + const [year, month, day] = datePart.split('-').map(Number); + const [hours, minutes] = timePart.split(':').map(Number); + return { + reminder: new Date(Date.UTC(year, month - 1, day, hours, minutes)), + }; + }), + }, + }, + include: { + reminders: { orderBy: { reminder: 'asc' } }, }, }); @@ -75,6 +97,10 @@ export async function PUT( ...task, dueDate: task.dueDate ? task.dueDate.toISOString().split('T')[0] : null, nextOccurrence: task.nextOccurrence ? task.nextOccurrence.toISOString().split('T')[0] : null, + reminders: task.reminders.map((r) => ({ + ...r, + reminder: r.reminder.toISOString().slice(0, 16), + })), }); } catch (error) { console.error('Error updating task:', error); diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index 1d8dc67..398ceaf 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import prisma from '@/lib/db'; + + export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); @@ -29,13 +31,23 @@ export async function GET(request: NextRequest) { ? [{ priority: 'asc' }, { title: 'asc' }] : [{ dueDate: { sort: 'asc', nulls: 'last' } }, { title: 'asc' }]; - const tasks = await prisma.task.findMany({ where, orderBy }); + const tasks = await prisma.task.findMany({ + where, + orderBy, + include: { + reminders: { orderBy: { reminder: 'asc' } }, + }, + }); return NextResponse.json( tasks.map((t) => ({ ...t, dueDate: t.dueDate ? t.dueDate.toISOString().split('T')[0] : null, nextOccurrence: t.nextOccurrence ? t.nextOccurrence.toISOString().split('T')[0] : null, + reminders: t.reminders.map((r) => ({ + ...r, + reminder: r.reminder.toISOString().slice(0, 16), + })), })) ); } catch (error) { @@ -52,14 +64,17 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); } - const { title, description, projectId, priority, dueDate, status, recurrenceRule, recurrenceInterval, parentTaskId } = body; + const { + title, description, projectId, priority, dueDate, + status, recurrenceRule, recurrenceInterval, parentTaskId, + reminders, + } = body; if (!title?.trim()) { return NextResponse.json({ error: 'Title is required' }, { status: 400 }); } try { - // Get max sort_order for the project (or overall if no project) const maxSort = await prisma.task.aggregate({ where: projectId ? { projectId } : undefined, _max: { sortOrder: true }, @@ -77,6 +92,20 @@ export async function POST(request: NextRequest) { recurrenceInterval: recurrenceInterval || 1, parentTaskId: parentTaskId || undefined, sortOrder: (maxSort._max.sortOrder ?? 0) + 1, + reminders: { + create: (reminders || []).map((r: string) => { + // Parse local datetime string and construct Date to preserve local time + const [datePart, timePart] = r.split('T'); + const [year, month, day] = datePart.split('-').map(Number); + const [hours, minutes] = timePart.split(':').map(Number); + return { + reminder: new Date(Date.UTC(year, month - 1, day, hours, minutes)), + }; + }), + }, + }, + include: { + reminders: { orderBy: { reminder: 'asc' } }, }, }); @@ -84,6 +113,10 @@ export async function POST(request: NextRequest) { ...task, dueDate: task.dueDate ? task.dueDate.toISOString().split('T')[0] : null, nextOccurrence: task.nextOccurrence ? task.nextOccurrence.toISOString().split('T')[0] : null, + reminders: task.reminders.map((r) => ({ + ...r, + reminder: r.reminder.toISOString().slice(0, 16), + })), }); } catch (error) { console.error('Error creating task:', error); diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx index 8e33874..f67fdbd 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -20,6 +20,13 @@ export interface Task { sortOrder: number; createdAt: string; updatedAt: string; + reminders?: Array<{ + id: string; + taskId: string; + reminder: string; + sent: boolean; + createdAt: string; + }>; } export interface Project { diff --git a/src/components/EditTaskModal.tsx b/src/components/EditTaskModal.tsx index 5ae9644..dedbb17 100644 --- a/src/components/EditTaskModal.tsx +++ b/src/components/EditTaskModal.tsx @@ -19,6 +19,7 @@ export default function EditTaskModal() { const [status, setStatus] = useState<'todo' | 'in_progress' | 'done'>('todo'); const [recurrenceRule, setRecurrenceRule] = useState<'none' | 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'yearly'>('none'); const [recurrenceInterval, setRecurrenceInterval] = useState(1); + const [reminders, setReminders] = useState([]); const [subtasks, setSubtasks] = useState([]); const [newSubtaskTitle, setNewSubtaskTitle] = useState(''); @@ -35,18 +36,35 @@ export default function EditTaskModal() { } }, [editingTask]); - // Fetch subtasks when modal opens + // Fetch subtasks and reminders when modal opens useEffect(() => { if (showEditTaskModal && editingTask) { fetch(`/api/tasks/${editingTask.id}`) .then(res => res.json()) .then(data => { setSubtasks(data.subtasks || []); + if (data.reminders && data.reminders.length > 0) { + setReminders(data.reminders.map((r: any) => r.reminder)); + } }) .catch(console.error); } }, [showEditTaskModal, editingTask]); + const addReminder = () => { + setReminders([...reminders, '']); + }; + + const removeReminder = (index: number) => { + setReminders(reminders.filter((_, i) => i !== index)); + }; + + const updateReminder = (index: number, value: string) => { + const updated = [...reminders]; + updated[index] = value; + setReminders(updated); + }; + const handleSave = async () => { if (!editingTask || !title.trim()) return; @@ -63,6 +81,7 @@ export default function EditTaskModal() { status, recurrenceRule, recurrenceInterval, + reminders: reminders.filter((r) => r !== ''), }), }); @@ -222,12 +241,50 @@ export default function EditTaskModal() { - setDueDate(e.target.value)} - className="bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent" - /> +
+ + setDueDate(e.target.value)} + className="w-full bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent" + /> +
+ + + {/* Reminders section */} +
+
+ + +
+ {reminders.length === 0 && ( +

No reminders set

+ )} + {reminders.map((reminder, index) => ( +
+ updateReminder(index, e.target.value)} + className="flex-1 bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent" + /> + +
+ ))}
{/* Recurrence */} diff --git a/src/components/NewTaskModal.tsx b/src/components/NewTaskModal.tsx index 51aedc8..71cca36 100644 --- a/src/components/NewTaskModal.tsx +++ b/src/components/NewTaskModal.tsx @@ -19,6 +19,7 @@ export default function NewTaskModal() { const [showRecurrence, setShowRecurrence] = useState(false); const [recurrenceRule, setRecurrenceRule] = useState<'none' | 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'yearly'>('none'); const [recurrenceInterval, setRecurrenceInterval] = useState(1); + const [reminders, setReminders] = useState([]); const modalRef = useRef(null); const titleInputRef = useRef(null); @@ -35,6 +36,20 @@ export default function NewTaskModal() { } }, [showNewTaskModal, selectedProject]); + const addReminder = () => { + setReminders([...reminders, '']); + }; + + const removeReminder = (index: number) => { + setReminders(reminders.filter((_, i) => i !== index)); + }; + + const updateReminder = (index: number, value: string) => { + const updated = [...reminders]; + updated[index] = value; + setReminders(updated); + }; + const handleCreate = async () => { if (!title.trim()) return; @@ -51,6 +66,7 @@ export default function NewTaskModal() { status, recurrenceRule, recurrenceInterval, + reminders: reminders.filter((r) => r !== ''), }), }); @@ -59,6 +75,7 @@ export default function NewTaskModal() { setPriority('medium'); setDueDate(''); setStatus('todo'); + setReminders([]); setShowRecurrence(false); setRecurrenceRule('none'); setRecurrenceInterval(1); @@ -87,11 +104,11 @@ export default function NewTaskModal() { >
{/* Header */} -
+

New Task

{/* Status */} @@ -165,6 +185,41 @@ export default function NewTaskModal() { + {/* Reminders section */} +
+
+ + +
+ {reminders.length === 0 && ( +

No reminders set

+ )} + {reminders.map((reminder, index) => ( +
+ updateReminder(index, e.target.value)} + className="flex-1 bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent" + /> + +
+ ))} +
+ {/* Recurrence toggle */}