diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..f27968a --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,77 @@ +# Quick Start Guide + +Get the Todo app running in 5 minutes! + +## Prerequisites + +- Docker Desktop (running) **← Make sure this is started!** +- Node.js (v18+) +- pnpm (`npm install -g pnpm`) + +## Setup Commands + +```bash +# 1. Install dependencies +pnpm install + +# 2. Start database (Docker must be running!) +pnpm db:up + +# 3. Wait 10 seconds for database to initialize, then create tables +pnpm db:push + +# 4. Start dev servers +pnpm dev +``` + +Open http://localhost:5173 🎉 + +## Troubleshooting + +### "Cannot connect to database" +- Make sure Docker Desktop is running +- Check if database container is up: `docker ps` +- If not listed, run: `pnpm db:up` + +### "pnpm db:push" fails +- Wait 10-15 seconds after starting database +- The database needs time to initialize +- Try running `pnpm db:push` again + +## What You'll See + +1. **Timeline** - Shows your tasks across 45 days (15 past, 30 future) +2. **Today's Tasks** - Essential tasks always visible, "When I have time" tasks collapsible +3. **Create Task Button** - Add new tasks with: + - Name (60 char max) + - Repeats (yes/no) + - Frequency (days/weeks/months/years) + - To do today (yes/no) + - Priority (Essential / When I have time) + +## Task Actions + +- **✓ Complete** - Marks task done (updates `last_completed_on`) +- **↻ Renew** - Resets task to do again today + +## Sample Tasks to Try + +1. **Daily workout** - Repeats every 1 day, Essential +2. **Weekly review** - Repeats every 1 week, Essential +3. **Clean garage** - No repeat, When I have time +4. **Monthly backup** - Repeats every 1 month, Essential + +## Stopping + +```bash +# Stop database +pnpm db:down + +# Stop dev servers +Ctrl+C in both terminal windows +``` + +## Next Steps + +- See [SETUP.md](./SETUP.md) for detailed documentation +- Check [README.md](./README.md) for deployment instructions diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..e7f1a76 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,140 @@ +# Todo App - Setup Guide + +## Overview + +This is a task management application that helps you track tasks with repeating schedules and priorities. + +## Features + +- **Timeline View**: Visual timeline showing past (15 days) and future (30 days) tasks +- **Today's Tasks**: Organized by priority (Essential / When I have time) +- **Task Management**: Create, complete, and renew tasks +- **Recurring Tasks**: Support for daily, weekly, monthly, and yearly repetition +- **Priority Levels**: Essential (always visible) and When I have time (collapsible) + +## Tech Stack + +- **Backend**: Node.js + Fastify + Drizzle ORM +- **Frontend**: React + TypeScript + Vite +- **Database**: PostgreSQL +- **Build**: Turborepo monorepo + +## Local Development Setup + +### Prerequisites + +- Node.js (v18 or higher) +- pnpm (`npm install -g pnpm`) +- Docker Desktop (for local database) + +### Steps + +1. **Clone the repository** + ```bash + git clone + cd ludops-todo + ``` + +2. **Install dependencies** + ```bash + pnpm install + ``` + +3. **Set up environment** + ```bash + cp .env.example .env + ``` + The .env file should contain: + ``` + DATABASE_URL=postgres://postgres:postgres@localhost:5432/todo_db + ``` + +4. **Start local database** + ```bash + docker compose -f docker-compose.dev.yml up -d + ``` + +5. **Push database schema** + ```bash + cd apps/api + pnpm db:push push + ``` + +6. **Start development servers** + + In one terminal (backend): + ```bash + cd apps/api + pnpm dev + ``` + + In another terminal (frontend): + ```bash + cd apps/web + pnpm dev + ``` + +7. **Open the app** + ``` + Frontend: http://localhost:5173 + Backend API: http://localhost:3000 + ``` + +## Database Schema + +### Tasks Table + +| Column | Type | Description | +|--------|------|-------------| +| `id` | serial | Primary key | +| `name` | varchar(60) | Task name (required) | +| `created_on` | timestamp | Creation timestamp | +| `updated_on` | timestamp | Last update timestamp | +| `last_completed_on` | timestamp | Last completion date (nullable) | +| `does_repeat` | boolean | Whether task repeats | +| `repeats_every` | varchar(20) | Format: `X_UNIT` (e.g., `1_WEEKS`, `2_MONTHS`) | +| `priority` | enum | `ESSENTIAL` or `WHEN_I_HAVE_TIME` | + +## API Endpoints + +- `GET /api/tasks` - Get all tasks +- `POST /api/tasks` - Create a new task +- `PATCH /api/tasks/:id/complete` - Mark task as completed +- `PATCH /api/tasks/:id/renew` - Renew task (reset completion date) + +## Task Logic + +### Due Date Calculation + +- **Non-repeating tasks**: Due immediately if `last_completed_on` is null +- **Repeating tasks**: Due date = `last_completed_on + repeats_every` +- **Today's tasks**: Any task with due date <= today + +### Timeline Display + +- Tasks are positioned on a 45-day timeline (15 past + 30 future) +- TODAY marker is at 33.33% from the left +- Tasks on the same day are stacked horizontally by priority +- Only shows tasks within the timeline range + +## Production Deployment + +See the main README.md for CI/CD deployment instructions using Gitea and Docker. + +## Troubleshooting + +### Database connection issues +- Make sure Docker is running: `docker ps` +- Check database is accessible: `docker exec -it ludops-todo-db-dev psql -U postgres -d todo_db` + +### Port conflicts +- Frontend (5173): Change in `apps/web/vite.config.ts` +- Backend (3000): Change in `apps/api/src/index.ts` +- Database (5432): Change in `docker-compose.dev.yml` + +### Schema changes +After modifying schema files in `apps/api/src/db/schemas/`, run: +```bash +cd apps/api +pnpm db:push push +``` diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts index fddc249..f6bf898 100644 --- a/apps/api/drizzle.config.ts +++ b/apps/api/drizzle.config.ts @@ -4,6 +4,6 @@ export default { out: './drizzle', dialect: 'postgresql', dbCredentials: { - url: process.env.DATABASE_URL || '', + url: 'postgres://postgres:postgres@localhost:5432/todo_db', }, }; \ No newline at end of file diff --git a/apps/api/src/db/schemas.ts b/apps/api/src/db/schemas.ts index 83d89dd..5ea0c2e 100644 --- a/apps/api/src/db/schemas.ts +++ b/apps/api/src/db/schemas.ts @@ -1 +1,2 @@ -export * from './schemas/visit-logs.schema.js'; \ No newline at end of file +export * from './schemas/visit-logs.schema.js'; +export * from './schemas/tasks.schema.js'; \ No newline at end of file diff --git a/apps/api/src/db/schemas/tasks.schema.ts b/apps/api/src/db/schemas/tasks.schema.ts new file mode 100644 index 0000000..19ce051 --- /dev/null +++ b/apps/api/src/db/schemas/tasks.schema.ts @@ -0,0 +1,17 @@ +import { pgTable, serial, varchar, timestamp, boolean, pgEnum } from 'drizzle-orm/pg-core'; + +export const priorityEnum = pgEnum('priority', ['ESSENTIAL', 'WHEN_I_HAVE_TIME']); + +export const tasks = pgTable('tasks', { + id: serial('id').primaryKey(), + name: varchar('name', { length: 60 }).notNull(), + createdOn: timestamp('created_on').defaultNow().notNull(), + updatedOn: timestamp('updated_on').defaultNow().notNull(), + lastCompletedOn: timestamp('last_completed_on'), + doesRepeat: boolean('does_repeat').notNull().default(false), + repeatsEvery: varchar('repeats_every', { length: 20 }), // Format: "XX_DAYS", "XX_WEEKS", "XX_MONTHS", "XX_YEARS" + priority: priorityEnum('priority').notNull().default('ESSENTIAL'), +}); + +export type Task = typeof tasks.$inferSelect; +export type NewTask = typeof tasks.$inferInsert; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index f764016..da79352 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -4,8 +4,8 @@ import { fileURLToPath } from 'url'; import fastifyStatic from '@fastify/static'; import { AppStatus } from '@ludops/shared'; import { db } from './db/index.js'; -import { visits } from './db/schemas.js'; -import { sql } from 'drizzle-orm'; +import { visits, tasks } from './db/schemas.js'; +import { sql, eq } from 'drizzle-orm'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -19,6 +19,87 @@ fastify.get('/api/status', async (): Promise => { return { status: 'OK', version: '1.0.0', database: true, totalVisits: Number(result.count) }; }); +// Tasks API Routes +fastify.get('/api/tasks', async () => { + const allTasks = await db.select().from(tasks); + return allTasks; +}); + +fastify.post<{ Body: { name: string; doesRepeat: boolean; repeatsEvery?: string; priority: 'ESSENTIAL' | 'WHEN_I_HAVE_TIME'; toDoToday: boolean } }>('/api/tasks', async (request, reply) => { + const { name, doesRepeat, repeatsEvery, priority, toDoToday } = request.body; + + // Check if task already exists and is active + const existingTasks = await db.select().from(tasks).where(eq(tasks.name, name)); + const activeTask = existingTasks.find(task => + task.doesRepeat || (task.lastCompletedOn === null) + ); + + if (activeTask) { + reply.code(400).send({ error: 'Task with this name already exists and is active' }); + return; + } + + // Calculate lastCompletedOn based on toDoToday + let lastCompletedOn: Date | null = null; + if (!toDoToday && doesRepeat && repeatsEvery) { + // Set it in the future so it doesn't appear as due today + const [amount, unit] = repeatsEvery.split('_'); + const days = unit === 'DAYS' ? parseInt(amount) : + unit === 'WEEKS' ? parseInt(amount) * 7 : + unit === 'MONTHS' ? parseInt(amount) * 30 : + parseInt(amount) * 365; + lastCompletedOn = new Date(Date.now() - days * 24 * 60 * 60 * 1000 + 24 * 60 * 60 * 1000); + } + + const [newTask] = await db.insert(tasks).values({ + name, + doesRepeat, + repeatsEvery: doesRepeat ? repeatsEvery : null, + priority, + lastCompletedOn, + }).returning(); + + reply.code(201).send(newTask); +}); + +fastify.patch<{ Params: { id: string } }>('/api/tasks/:id/complete', async (request, reply) => { + const id = parseInt(request.params.id); + + const [updatedTask] = await db.update(tasks) + .set({ + lastCompletedOn: new Date(), + updatedOn: new Date() + }) + .where(eq(tasks.id, id)) + .returning(); + + if (!updatedTask) { + reply.code(404).send({ error: 'Task not found' }); + return; + } + + reply.send(updatedTask); +}); + +fastify.patch<{ Params: { id: string } }>('/api/tasks/:id/renew', async (request, reply) => { + const id = parseInt(request.params.id); + + const [updatedTask] = await db.update(tasks) + .set({ + lastCompletedOn: null, + updatedOn: new Date() + }) + .where(eq(tasks.id, id)) + .returning(); + + if (!updatedTask) { + reply.code(404).send({ error: 'Task not found' }); + return; + } + + reply.send(updatedTask); +}); + // Serve Frontend (Vite Dist) const webDistPath = process.env.NODE_ENV === 'production' ? path.join(__dirname, '../web-dist') // Adjusted for the flattened Docker structure diff --git a/apps/web/src/App.css b/apps/web/src/App.css index 05561b6..ad29ecd 100644 --- a/apps/web/src/App.css +++ b/apps/web/src/App.css @@ -1,11 +1,27 @@ main { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - min-height: 100svh; - gap: 16px; - padding: 32px 20px; + max-width: 1400px; + margin: 0 auto; + padding: 40px 20px; + min-height: 100vh; +} + +@media (max-width: 768px) { + main { + padding: 20px 12px; + } +} + +h1 { + font-size: 42px; + font-weight: 700; + margin-bottom: 16px; + color: #2c3e50; +} + +@media (max-width: 768px) { + h1 { + font-size: 32px; + } } .subtitle { @@ -14,31 +30,82 @@ main { } .status { - margin-top: 8px; - padding: 6px 14px; + display: inline-block; + margin-bottom: 40px; + padding: 8px 16px; border-radius: 6px; - font-size: 14px; - font-family: var(--mono); + font-size: 13px; + font-weight: 500; } .status--loading { - background: var(--code-bg); - color: var(--text); + background: #f5f5f5; + color: #666; } .status--ok { - background: rgba(34, 197, 94, 0.12); - color: #16a34a; + background: #f5f5f5; + color: #666; } .status--error { - background: rgba(239, 68, 68, 0.12); - color: #dc2626; + background: #ffebee; + color: #c62828; } -@media (prefers-color-scheme: dark) { - .status--ok { color: #4ade80; } - .status--error { color: #f87171; } +.error-message { + position: fixed; + top: 20px; + right: 20px; + background: #ffebee; + color: #c62828; + padding: 12px 16px; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1000; + display: flex; + align-items: center; + gap: 12px; + max-width: 400px; + animation: slideIn 0.3s ease; +} + +@media (max-width: 768px) { + .error-message { + left: 12px; + right: 12px; + max-width: none; + } +} + +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +.error-dismiss { + background: none; + border: none; + color: #c62828; + cursor: pointer; + font-size: 18px; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.error-dismiss:hover { + opacity: 0.7; } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b16728c..7b00a63 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,5 +1,10 @@ import { useEffect, useState } from 'react' import './App.css' +import Timeline from './components/Timeline' +import TodaysTasks from './components/TodaysTasks' +import CreateTaskForm from './components/CreateTaskForm' +import type { Task, NewTaskInput } from './types' +import { isTaskDueToday } from './utils/taskUtils' const APP_NAME = 'TODO' @@ -7,24 +12,125 @@ type ApiStatus = 'loading' | 'ok' | 'error' function App() { const [apiStatus, setApiStatus] = useState('loading') + const [tasks, setTasks] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // Fetch tasks + const fetchTasks = async () => { + try { + const response = await fetch('/api/tasks') + if (!response.ok) throw new Error('Failed to fetch tasks') + const data = await response.json() + setTasks(data) + } catch (error) { + console.error('Error fetching tasks:', error) + } finally { + setLoading(false) + } + } useEffect(() => { + // Check API status fetch('/api/status') .then((r) => (r.ok ? r.json() : Promise.reject())) .then(() => setApiStatus('ok')) .catch(() => setApiStatus('error')) + + // Fetch tasks + fetchTasks() }, []) + const handleCreateTask = async (taskInput: NewTaskInput) => { + setError(null) + try { + const response = await fetch('/api/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(taskInput), + }) + + if (!response.ok) { + const errorData = await response.json() + setError(errorData.error || 'Failed to create task') + return + } + + await fetchTasks() + } catch (error) { + console.error('Error creating task:', error) + setError('Failed to create task') + } + } + + const handleCompleteTask = async (taskId: number) => { + setError(null) + try { + const response = await fetch(`/api/tasks/${taskId}/complete`, { + method: 'PATCH', + }) + + if (!response.ok) { + setError('Failed to complete task') + return + } + await fetchTasks() + } catch (error) { + console.error('Error completing task:', error) + setError('Failed to complete task') + } + } + + const handleRenewTask = async (taskId: number) => { + setError(null) + try { + const response = await fetch(`/api/tasks/${taskId}/renew`, { + method: 'PATCH', + }) + + if (!response.ok) { + setError('Failed to renew task') + return + } + await fetchTasks() + } catch (error) { + console.error('Error renewing task:', error) + setError('Failed to renew task') + } + } + + const todaysTasks = tasks.filter(isTaskDueToday) + return (

{APP_NAME}

-

Start building your app here.

{apiStatus === 'loading' && 'Connecting to API…'} {apiStatus === 'ok' && '✓ API & database connected'} {apiStatus === 'error' && '⚠️ Could not reach API'}
+ + {error && ( +
+ {error} + +
+ )} + + {loading ? ( +

Loading tasks...

+ ) : ( + <> + + + + + )}
) } diff --git a/apps/web/src/components/CreateTaskForm.css b/apps/web/src/components/CreateTaskForm.css new file mode 100644 index 0000000..46988ad --- /dev/null +++ b/apps/web/src/components/CreateTaskForm.css @@ -0,0 +1,218 @@ +.create-task-form { + width: 100%; + max-width: 600px; + margin: 0 auto; +} + +.open-form-btn { + width: 100%; + padding: 14px; + background: #2c3e50; + color: white; + border: none; + border-radius: 6px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +@media (max-width: 768px) { + .open-form-btn { + padding: 12px; + font-size: 14px; + } +} + +.open-form-btn:hover { + background: #1a252f; +} + +.task-form { + background: white; + padding: 24px; + border-radius: 8px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); + border: 1px solid #e0e0e0; +} + +@media (max-width: 768px) { + .task-form { + padding: 16px; + } +} + +.task-form h3 { + margin: 0 0 20px 0; + color: #2c3e50; + font-size: 18px; +} + +@media (max-width: 768px) { + .task-form h3 { + font-size: 16px; + margin-bottom: 16px; + } +} + +.form-group { + margin-bottom: 18px; +} + +@media (max-width: 768px) { + .form-group { + margin-bottom: 14px; + } +} + +.form-group label { + display: block; + margin-bottom: 6px; + color: #555; + font-weight: 500; + font-size: 13px; +} + +.form-group input[type="text"] { + width: 100%; + padding: 10px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + transition: border-color 0.2s; + box-sizing: border-box; +} + +@media (max-width: 768px) { + .form-group input[type="text"] { + font-size: 16px; /* Prevents zoom on iOS */ + } +} + +.form-group input[type="text"]:focus { + outline: none; + border-color: #2c3e50; +} + +.char-count { + display: block; + text-align: right; + font-size: 11px; + color: #999; + margin-top: 4px; +} + +.radio-group { + display: flex; + gap: 16px; + flex-wrap: wrap; +} + +.radio-group label { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + margin-bottom: 0; + font-weight: normal; + font-size: 14px; +} + +.radio-group input[type="radio"] { + cursor: pointer; + width: 16px; + height: 16px; +} + +.repeat-input { + display: flex; + gap: 8px; +} + +.repeat-amount { + width: 80px; + padding: 10px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; +} + +@media (max-width: 768px) { + .repeat-amount { + width: 70px; + font-size: 16px; /* Prevents zoom on iOS */ + } +} + +.repeat-amount:focus { + outline: none; + border-color: #2c3e50; +} + +.repeat-unit { + flex: 1; + padding: 10px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + cursor: pointer; + background: white; +} + +@media (max-width: 768px) { + .repeat-unit { + font-size: 16px; /* Prevents zoom on iOS */ + } +} + +.repeat-unit:focus { + outline: none; + border-color: #2c3e50; +} + +.form-actions { + display: flex; + gap: 10px; + margin-top: 20px; +} + +@media (max-width: 768px) { + .form-actions { + flex-direction: column-reverse; + } +} + +.submit-btn { + flex: 1; + padding: 12px; + background: #2c3e50; + color: white; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.submit-btn:hover { + background: #1a252f; +} + +.cancel-btn { + flex: 1; + padding: 12px; + background: white; + color: #666; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.cancel-btn:hover { + background: #f5f5f5; + border-color: #bbb; +} diff --git a/apps/web/src/components/CreateTaskForm.tsx b/apps/web/src/components/CreateTaskForm.tsx new file mode 100644 index 0000000..744a1fd --- /dev/null +++ b/apps/web/src/components/CreateTaskForm.tsx @@ -0,0 +1,171 @@ +import { useState } from 'react'; +import type { NewTaskInput } from '../types'; +import './CreateTaskForm.css'; + +interface CreateTaskFormProps { + onCreateTask: (task: NewTaskInput) => void; +} + +export default function CreateTaskForm({ onCreateTask }: CreateTaskFormProps) { + const [isOpen, setIsOpen] = useState(false); + const [name, setName] = useState(''); + const [doesRepeat, setDoesRepeat] = useState(true); + const [repeatAmount, setRepeatAmount] = useState('1'); + const [repeatUnit, setRepeatUnit] = useState<'WEEKS' | 'MONTHS' | 'YEARS'>('WEEKS'); + const [toDoToday, setToDoToday] = useState(true); + const [priority, setPriority] = useState<'ESSENTIAL' | 'WHEN_I_HAVE_TIME'>('ESSENTIAL'); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!name.trim()) { + alert('Please enter a task name'); + return; + } + + const taskInput: NewTaskInput = { + name: name.trim(), + doesRepeat, + repeatsEvery: doesRepeat ? `${repeatAmount}_${repeatUnit}` : undefined, + priority, + toDoToday, + }; + + onCreateTask(taskInput); + + // Reset form + setName(''); + setDoesRepeat(true); + setRepeatAmount('1'); + setRepeatUnit('WEEKS'); + setToDoToday(true); + setPriority('ESSENTIAL'); + setIsOpen(false); + }; + + return ( +
+ {!isOpen ? ( + + ) : ( +
+

Create New Task

+ +
+ + setName(e.target.value.slice(0, 60))} + placeholder="task name" + maxLength={60} + autoFocus + /> + {name.length}/60 +
+ +
+ +
+ + +
+
+ + {doesRepeat && ( +
+ +
+ setRepeatAmount(e.target.value)} + min="1" + max="999" + className="repeat-amount" + /> + +
+
+ )} + +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ + +
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/Timeline.css b/apps/web/src/components/Timeline.css new file mode 100644 index 0000000..db60258 --- /dev/null +++ b/apps/web/src/components/Timeline.css @@ -0,0 +1,141 @@ +.timeline-container { + width: 100%; + padding: 40px 20px; + background: #fafafa; + border-radius: 12px; + margin-bottom: 30px; + overflow-x: auto; +} + +@media (max-width: 768px) { + .timeline-container { + padding: 24px 12px; + margin-bottom: 20px; + } +} + +.timeline { + position: relative; + height: 150px; + margin: 0 auto; + max-width: 1200px; + min-width: 600px; +} + +@media (max-width: 768px) { + .timeline { + height: 120px; + min-width: 500px; + } +} + +.timeline-line { + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 2px; + background: #e0e0e0; + border-radius: 2px; +} + +.today-marker { + position: absolute; + top: 50%; + transform: translateX(-50%); + z-index: 100; +} + +.today-arrow { + font-size: 28px; + color: #e74c3c; + text-align: center; + line-height: 0; + margin-bottom: -6px; +} + +@media (max-width: 768px) { + .today-arrow { + font-size: 24px; + } +} + +.today-label { + background: #e74c3c; + color: white; + padding: 4px 10px; + border-radius: 4px; + font-weight: 600; + font-size: 11px; + text-align: center; + white-space: nowrap; +} + +@media (max-width: 768px) { + .today-label { + font-size: 10px; + padding: 3px 8px; + } +} + +.timeline-day-group { + position: absolute; + top: 0; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +.timeline-date { + font-size: 10px; + color: #999; + font-weight: 500; +} + +@media (max-width: 768px) { + .timeline-date { + font-size: 9px; + } +} + +.timeline-tasks { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + margin-top: 40px; +} + +@media (max-width: 768px) { + .timeline-tasks { + margin-top: 32px; + } +} + +.timeline-task { + background: #555; + color: white; + padding: 4px 8px; + border-radius: 8px; + font-size: 10px; + white-space: nowrap; + max-width: 100px; + overflow: hidden; + text-overflow: ellipsis; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + position: relative; +} + +@media (max-width: 768px) { + .timeline-task { + font-size: 9px; + padding: 3px 6px; + max-width: 80px; + } +} + +.timeline-task.essential { + background: #2c3e50; +} diff --git a/apps/web/src/components/Timeline.tsx b/apps/web/src/components/Timeline.tsx new file mode 100644 index 0000000..1e8057a --- /dev/null +++ b/apps/web/src/components/Timeline.tsx @@ -0,0 +1,101 @@ +import type { Task } from '../types'; +import { calculateNextDueDate, formatDate } from '../utils/taskUtils'; +import './Timeline.css'; + +interface TimelineProps { + tasks: Task[]; +} + +export default function Timeline({ tasks }: TimelineProps) { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // Timeline range: 15 days past, 30 days future + const startDate = new Date(today); + startDate.setDate(startDate.getDate() - 15); + + const endDate = new Date(today); + endDate.setDate(endDate.getDate() + 30); + + // Calculate positions for tasks on timeline + const totalDays = 45; // 15 past + 30 future + const todayPosition = (15 / totalDays) * 100; // 33.33% from left + + const taskPositions = tasks.map(task => { + const dueDate = calculateNextDueDate(task); + if (!dueDate) return null; + + // Only show tasks within timeline range + if (dueDate < startDate || dueDate > endDate) return null; + + const daysDiff = Math.floor((dueDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)); + const position = (daysDiff / totalDays) * 100; + + return { + task, + position, + dueDate, + }; + }).filter(Boolean) as { task: Task; position: number; dueDate: Date }[]; + + // Group tasks by day + const tasksByDay = taskPositions.reduce((acc, item) => { + const dateKey = item.dueDate.toDateString(); + if (!acc[dateKey]) acc[dateKey] = []; + acc[dateKey].push(item); + return acc; + }, {} as Record); + + // Sort tasks within each day by priority + Object.values(tasksByDay).forEach(dayTasks => { + dayTasks.sort((a, b) => { + if (a.task.priority === 'ESSENTIAL' && b.task.priority !== 'ESSENTIAL') return -1; + if (a.task.priority !== 'ESSENTIAL' && b.task.priority === 'ESSENTIAL') return 1; + return 0; + }); + }); + + return ( +
+
+
+ + {/* TODAY marker */} +
+
+
TODAY
+
+ + {/* Tasks */} + {Object.entries(tasksByDay).map(([dateKey, dayTasks]) => { + const avgPosition = dayTasks.reduce((sum, item) => sum + item.position, 0) / dayTasks.length; + + return ( +
+
{formatDate(dayTasks[0].dueDate)}
+
+ {dayTasks.map(({ task }, index) => ( +
+ {task.name} +
+ ))} +
+
+ ); + })} +
+
+ ); +} diff --git a/apps/web/src/components/TodaysTasks.css b/apps/web/src/components/TodaysTasks.css new file mode 100644 index 0000000..be40ab2 --- /dev/null +++ b/apps/web/src/components/TodaysTasks.css @@ -0,0 +1,178 @@ +.todays-tasks { + width: 100%; + max-width: 800px; + margin: 0 auto 40px; +} + +.todays-tasks h2 { + font-size: 24px; + margin-bottom: 20px; + color: #2c3e50; +} + +@media (max-width: 768px) { + .todays-tasks h2 { + font-size: 20px; + margin-bottom: 16px; + } +} + +.task-section { + margin-bottom: 24px; +} + +@media (max-width: 768px) { + .task-section { + margin-bottom: 16px; + } +} + +.priority-header { + font-size: 16px; + font-weight: 600; + padding: 10px 14px; + border-radius: 6px; + margin-bottom: 10px; + display: flex; + justify-content: space-between; + align-items: center; + background: #f5f5f5; + color: #555; + border-left: 3px solid #ddd; +} + +@media (max-width: 768px) { + .priority-header { + font-size: 14px; + padding: 8px 12px; + } +} + +.priority-header.essential { + background: #fafafa; + color: #2c3e50; + border-left-color: #e74c3c; +} + +.priority-header.when-i-have-time { + background: #f5f5f5; + color: #666; + border-left-color: #bbb; +} + +.priority-header.clickable { + cursor: pointer; + user-select: none; + transition: background 0.2s; +} + +.priority-header.clickable:hover { + background: #efefef; +} + +.toggle-icon { + font-size: 12px; + margin-left: 8px; + color: #999; +} + +.task-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +@media (max-width: 768px) { + .task-list { + gap: 6px; + } +} + +.task-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: white; + border: 1px solid #e0e0e0; + border-radius: 6px; + transition: all 0.2s; +} + +@media (max-width: 768px) { + .task-item { + padding: 10px 12px; + } +} + +.task-item:hover { + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); + border-color: #ccc; +} + +.task-name { + font-size: 15px; + color: #2c3e50; + flex: 1; + margin-right: 12px; +} + +@media (max-width: 768px) { + .task-name { + font-size: 14px; + } +} + +.task-actions { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +.action-btn { + width: 32px; + height: 32px; + border: 1px solid #e0e0e0; + border-radius: 4px; + font-size: 16px; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + background: white; + color: #666; +} + +@media (max-width: 768px) { + .action-btn { + width: 36px; + height: 36px; + } +} + +.complete-btn:hover { + background: #f5f5f5; + border-color: #2c3e50; + color: #2c3e50; +} + +.renew-btn:hover { + background: #f5f5f5; + border-color: #666; + color: #2c3e50; +} + +.no-tasks { + color: #999; + font-style: italic; + padding: 12px 16px; + font-size: 14px; +} + +@media (max-width: 768px) { + .no-tasks { + font-size: 13px; + padding: 10px 12px; + } +} diff --git a/apps/web/src/components/TodaysTasks.tsx b/apps/web/src/components/TodaysTasks.tsx new file mode 100644 index 0000000..e6a06c1 --- /dev/null +++ b/apps/web/src/components/TodaysTasks.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react'; +import type { Task } from '../types'; +import './TodaysTasks.css'; + +interface TodaysTasksProps { + tasks: Task[]; + onComplete: (taskId: number) => void; + onRenew: (taskId: number) => void; +} + +export default function TodaysTasks({ tasks, onComplete, onRenew }: TodaysTasksProps) { + const essentialTasks = tasks.filter(t => t.priority === 'ESSENTIAL'); + const whenIHaveTimeTasks = tasks.filter(t => t.priority === 'WHEN_I_HAVE_TIME'); + const [showWhenIHaveTime, setShowWhenIHaveTime] = useState(false); + + return ( +
+

Today's Tasks

+ + {/* Essential Tasks */} +
+

Essential

+ {essentialTasks.length === 0 ? ( +

No essential tasks for today

+ ) : ( +
+ {essentialTasks.map(task => ( + + ))} +
+ )} +
+ + {/* When I Have Time Tasks */} +
+

setShowWhenIHaveTime(!showWhenIHaveTime)} + > + When I have time ({whenIHaveTimeTasks.length}) + {showWhenIHaveTime ? '▼' : '▶'} +

+ {showWhenIHaveTime && ( +
+ {whenIHaveTimeTasks.length === 0 ? ( +

No tasks in this category

+ ) : ( + whenIHaveTimeTasks.map(task => ( + + )) + )} +
+ )} +
+
+ ); +} + +interface TaskItemProps { + task: Task; + onComplete: (taskId: number) => void; + onRenew: (taskId: number) => void; +} + +function TaskItem({ task, onComplete, onRenew }: TaskItemProps) { + return ( +
+ {task.name} +
+ + +
+
+ ); +} diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts new file mode 100644 index 0000000..191634a --- /dev/null +++ b/apps/web/src/types.ts @@ -0,0 +1,18 @@ +export interface Task { + id: number; + name: string; + createdOn: string; + updatedOn: string; + lastCompletedOn: string | null; + doesRepeat: boolean; + repeatsEvery: string | null; + priority: 'ESSENTIAL' | 'WHEN_I_HAVE_TIME'; +} + +export interface NewTaskInput { + name: string; + doesRepeat: boolean; + repeatsEvery?: string; + priority: 'ESSENTIAL' | 'WHEN_I_HAVE_TIME'; + toDoToday: boolean; +} diff --git a/apps/web/src/utils/taskUtils.ts b/apps/web/src/utils/taskUtils.ts new file mode 100644 index 0000000..3ba8da1 --- /dev/null +++ b/apps/web/src/utils/taskUtils.ts @@ -0,0 +1,50 @@ +import type { Task } from '../types'; + +export const calculateNextDueDate = (task: Task): Date | null => { + if (!task.doesRepeat || !task.repeatsEvery) { + // Non-repeating tasks are due if not completed + return task.lastCompletedOn ? null : new Date(); + } + + const lastCompleted = task.lastCompletedOn ? new Date(task.lastCompletedOn) : new Date(task.createdOn); + const [amount, unit] = task.repeatsEvery.split('_'); + const amountNum = parseInt(amount); + + const nextDue = new Date(lastCompleted); + + switch (unit) { + case 'DAYS': + nextDue.setDate(nextDue.getDate() + amountNum); + break; + case 'WEEKS': + nextDue.setDate(nextDue.getDate() + amountNum * 7); + break; + case 'MONTHS': + nextDue.setMonth(nextDue.getMonth() + amountNum); + break; + case 'YEARS': + nextDue.setFullYear(nextDue.getFullYear() + amountNum); + break; + } + + return nextDue; +}; + +export const isTaskDueToday = (task: Task): boolean => { + const nextDue = calculateNextDueDate(task); + if (!nextDue) return false; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const dueDate = new Date(nextDue); + dueDate.setHours(0, 0, 0, 0); + + return dueDate <= today; +}; + +export const formatDate = (date: Date): string => { + const month = date.getMonth() + 1; + const day = date.getDate(); + return `${month}/${day}`; +}; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..d105e84 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,16 @@ +services: + postgres: + image: postgres:16 + container_name: ludops-todo-db-dev + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: todo_db + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + +volumes: + postgres-data: diff --git a/package.json b/package.json index 174de4e..5a86a37 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,10 @@ "main": "index.js", "scripts": { "build": "turbo run build", - "dev": "turbo run dev" + "dev": "turbo run dev", + "db:up": "docker compose -f docker-compose.dev.yml up -d", + "db:down": "docker compose -f docker-compose.dev.yml down", + "db:push": "cd apps/api && pnpm db:push push" }, "devDependencies": { "turbo": "latest"