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
This commit is contained in:
72
AGENTS.md
72
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
|
||||
|
||||
<!-- Add project-specific rules below this line -->
|
||||
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -44,6 +44,7 @@ model Task {
|
||||
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])
|
||||
}
|
||||
|
||||
90
scripts/send-reminders.ts
Normal file
90
scripts/send-reminders.ts
Normal file
@@ -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<boolean> {
|
||||
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);
|
||||
});
|
||||
62
src/app/api/reminders/route.ts
Normal file
62
src/app/api/reminders/route.ts
Normal file
@@ -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<string, unknown> = {};
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const [subtasks, setSubtasks] = useState<Task[]>([]);
|
||||
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,13 +241,51 @@ export default function EditTaskModal() {
|
||||
<option value="urgent">🔴 Urgent</option>
|
||||
</select>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-text-secondary mb-1">Due Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
className="bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
className="w-full bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reminders section */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs text-text-secondary">🔔 Reminders</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addReminder}
|
||||
className="text-xs text-accent hover:text-accent-hover transition-colors"
|
||||
>
|
||||
+ Add Reminder
|
||||
</button>
|
||||
</div>
|
||||
{reminders.length === 0 && (
|
||||
<p className="text-xs text-text-secondary/60 italic">No reminders set</p>
|
||||
)}
|
||||
{reminders.map((reminder, index) => (
|
||||
<div key={index} className="flex gap-2 mb-1.5">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={reminder}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeReminder(index)}
|
||||
className="px-2 text-text-secondary hover:text-red-400 transition-colors text-sm"
|
||||
title="Remove reminder"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Recurrence */}
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const titleInputRef = useRef<HTMLInputElement>(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() {
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="bg-card rounded-xl w-full max-w-md mx-4 shadow-xl animate-slide-in"
|
||||
className="bg-card rounded-xl w-full max-w-md mx-4 shadow-xl animate-slide-in max-h-[90vh] overflow-y-auto"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<div className="flex items-center justify-between p-4 border-b border-border sticky top-0 bg-card z-10">
|
||||
<h2 className="text-lg font-bold">New Task</h2>
|
||||
<button
|
||||
onClick={() => setShowNewTaskModal(false)}
|
||||
@@ -147,13 +164,16 @@ export default function NewTaskModal() {
|
||||
<option value="urgent">🔴 Urgent</option>
|
||||
</select>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-text-secondary mb-1">Due Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
className="bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
className="w-full bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<select
|
||||
@@ -165,6 +185,41 @@ export default function NewTaskModal() {
|
||||
<option value="in_progress">In Progress</option>
|
||||
</select>
|
||||
|
||||
{/* Reminders section */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs text-text-secondary">🔔 Reminders</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addReminder}
|
||||
className="text-xs text-accent hover:text-accent-hover transition-colors"
|
||||
>
|
||||
+ Add Reminder
|
||||
</button>
|
||||
</div>
|
||||
{reminders.length === 0 && (
|
||||
<p className="text-xs text-text-secondary/60 italic">No reminders set</p>
|
||||
)}
|
||||
{reminders.map((reminder, index) => (
|
||||
<div key={index} className="flex gap-2 mb-1.5">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={reminder}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeReminder(index)}
|
||||
className="px-2 text-text-secondary hover:text-red-400 transition-colors text-sm"
|
||||
title="Remove reminder"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Recurrence toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
@@ -213,7 +268,7 @@ export default function NewTaskModal() {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 p-4 border-t border-border">
|
||||
<div className="flex items-center justify-end gap-2 p-4 border-t border-border sticky bottom-0 bg-card">
|
||||
<button
|
||||
onClick={() => setShowNewTaskModal(false)}
|
||||
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||
|
||||
Reference in New Issue
Block a user