From 07e9300b171471871508b36687eb740a400ea173 Mon Sep 17 00:00:00 2001 From: TanUIUX Date: Mon, 4 May 2026 16:07:55 +0000 Subject: [PATCH] feat: add Sunsama Clone task management app - Add Drizzle schema for sunsama_users and sunsama_tasks tables - Create sunsama-api Express backend with task CRUD, scheduling, and recurring tasks - Create sunsama-web React frontend with Today/Week/All Tasks views - Features: drag-drop scheduling, Pomodoro timer, recurring tasks, timeline view - Uses @dnd-kit, @tanstack/react-query, Zustand, Tailwind CSS v4 --- artifacts/sunsama-api/build.mjs | 47 + artifacts/sunsama-api/package.json | 25 + artifacts/sunsama-api/src/index.ts | 24 + artifacts/sunsama-api/src/routes/tasks.ts | 265 +++ .../sunsama-api/src/utils/recurringTasks.ts | 82 + artifacts/sunsama-api/src/utils/seedUser.ts | 21 + artifacts/sunsama-api/tsconfig.json | 14 + artifacts/sunsama-web/index.html | 13 + artifacts/sunsama-web/package.json | 29 + artifacts/sunsama-web/public/favicon.svg | 4 + artifacts/sunsama-web/src/App.tsx | 133 ++ artifacts/sunsama-web/src/api/client.ts | 29 + artifacts/sunsama-web/src/api/tasks.ts | 63 + .../src/components/CreateTaskForm.tsx | 120 + .../src/components/CreateTaskModal.tsx | 246 ++ .../src/components/CurrentTimeIndicator.tsx | 52 + .../src/components/DailyTimeSummary.tsx | 114 + .../src/components/DraggableTaskItem.tsx | 122 + .../src/components/EditTaskModal.tsx | 253 +++ .../src/components/PomodoroTimer.tsx | 170 ++ .../src/components/SettingsModal.tsx | 106 + .../sunsama-web/src/components/TaskItem.tsx | 73 + .../sunsama-web/src/components/Timeline.tsx | 245 ++ .../src/components/TodayTaskItem.tsx | 73 + .../src/components/WeekTaskCard.tsx | 56 + .../src/hooks/useKeyboardShortcut.ts | 39 + artifacts/sunsama-web/src/index.css | 14 + artifacts/sunsama-web/src/main.tsx | 23 + artifacts/sunsama-web/src/pages/Tasks.tsx | 153 ++ artifacts/sunsama-web/src/pages/Today.tsx | 255 +++ artifacts/sunsama-web/src/pages/Week.tsx | 210 ++ .../sunsama-web/src/store/settingsStore.ts | 23 + artifacts/sunsama-web/src/store/timerStore.ts | 108 + artifacts/sunsama-web/src/types/task.ts | 32 + artifacts/sunsama-web/src/utils/dateUtils.ts | 29 + artifacts/sunsama-web/tsconfig.json | 13 + artifacts/sunsama-web/vite.config.ts | 32 + lib/db/src/schema/index.ts | 2 + lib/db/src/schema/sunsama_tasks.ts | 28 + lib/db/src/schema/sunsama_users.ts | 16 + pnpm-lock.yaml | 1993 ++++++++++++++--- 41 files changed, 5030 insertions(+), 319 deletions(-) create mode 100644 artifacts/sunsama-api/build.mjs create mode 100644 artifacts/sunsama-api/package.json create mode 100644 artifacts/sunsama-api/src/index.ts create mode 100644 artifacts/sunsama-api/src/routes/tasks.ts create mode 100644 artifacts/sunsama-api/src/utils/recurringTasks.ts create mode 100644 artifacts/sunsama-api/src/utils/seedUser.ts create mode 100644 artifacts/sunsama-api/tsconfig.json create mode 100644 artifacts/sunsama-web/index.html create mode 100644 artifacts/sunsama-web/package.json create mode 100644 artifacts/sunsama-web/public/favicon.svg create mode 100644 artifacts/sunsama-web/src/App.tsx create mode 100644 artifacts/sunsama-web/src/api/client.ts create mode 100644 artifacts/sunsama-web/src/api/tasks.ts create mode 100644 artifacts/sunsama-web/src/components/CreateTaskForm.tsx create mode 100644 artifacts/sunsama-web/src/components/CreateTaskModal.tsx create mode 100644 artifacts/sunsama-web/src/components/CurrentTimeIndicator.tsx create mode 100644 artifacts/sunsama-web/src/components/DailyTimeSummary.tsx create mode 100644 artifacts/sunsama-web/src/components/DraggableTaskItem.tsx create mode 100644 artifacts/sunsama-web/src/components/EditTaskModal.tsx create mode 100644 artifacts/sunsama-web/src/components/PomodoroTimer.tsx create mode 100644 artifacts/sunsama-web/src/components/SettingsModal.tsx create mode 100644 artifacts/sunsama-web/src/components/TaskItem.tsx create mode 100644 artifacts/sunsama-web/src/components/Timeline.tsx create mode 100644 artifacts/sunsama-web/src/components/TodayTaskItem.tsx create mode 100644 artifacts/sunsama-web/src/components/WeekTaskCard.tsx create mode 100644 artifacts/sunsama-web/src/hooks/useKeyboardShortcut.ts create mode 100644 artifacts/sunsama-web/src/index.css create mode 100644 artifacts/sunsama-web/src/main.tsx create mode 100644 artifacts/sunsama-web/src/pages/Tasks.tsx create mode 100644 artifacts/sunsama-web/src/pages/Today.tsx create mode 100644 artifacts/sunsama-web/src/pages/Week.tsx create mode 100644 artifacts/sunsama-web/src/store/settingsStore.ts create mode 100644 artifacts/sunsama-web/src/store/timerStore.ts create mode 100644 artifacts/sunsama-web/src/types/task.ts create mode 100644 artifacts/sunsama-web/src/utils/dateUtils.ts create mode 100644 artifacts/sunsama-web/tsconfig.json create mode 100644 artifacts/sunsama-web/vite.config.ts create mode 100644 lib/db/src/schema/sunsama_tasks.ts create mode 100644 lib/db/src/schema/sunsama_users.ts diff --git a/artifacts/sunsama-api/build.mjs b/artifacts/sunsama-api/build.mjs new file mode 100644 index 0000000..e8e2d7e --- /dev/null +++ b/artifacts/sunsama-api/build.mjs @@ -0,0 +1,47 @@ +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { build as esbuild } from "esbuild"; +import { rm } from "node:fs/promises"; + +globalThis.require = createRequire(import.meta.url); + +const artifactDir = path.dirname(fileURLToPath(import.meta.url)); + +async function buildAll() { + const distDir = path.resolve(artifactDir, "dist"); + await rm(distDir, { recursive: true, force: true }); + + await esbuild({ + entryPoints: [path.resolve(artifactDir, "src/index.ts")], + platform: "node", + bundle: true, + format: "esm", + outdir: distDir, + outExtension: { ".js": ".mjs" }, + logLevel: "info", + external: [ + "*.node", + "pg-native", + "better-sqlite3", + "sqlite3", + "bcrypt", + ], + sourcemap: "linked", + banner: { + js: `import { createRequire as __bannerCrReq } from 'node:module'; +import __bannerPath from 'node:path'; +import __bannerUrl from 'node:url'; + +globalThis.require = __bannerCrReq(import.meta.url); +globalThis.__filename = __bannerUrl.fileURLToPath(import.meta.url); +globalThis.__dirname = __bannerPath.dirname(globalThis.__filename); + `, + }, + }); +} + +buildAll().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/artifacts/sunsama-api/package.json b/artifacts/sunsama-api/package.json new file mode 100644 index 0000000..6711b18 --- /dev/null +++ b/artifacts/sunsama-api/package.json @@ -0,0 +1,25 @@ +{ + "name": "@workspace/sunsama-api", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "export NODE_ENV=development && pnpm run build && pnpm run start", + "build": "node ./build.mjs", + "start": "node --enable-source-maps ./dist/index.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@workspace/db": "workspace:*", + "cors": "^2", + "drizzle-orm": "catalog:", + "express": "^5", + "zod": "catalog:" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/node": "catalog:", + "esbuild": "^0.27.3" + } +} diff --git a/artifacts/sunsama-api/src/index.ts b/artifacts/sunsama-api/src/index.ts new file mode 100644 index 0000000..480d362 --- /dev/null +++ b/artifacts/sunsama-api/src/index.ts @@ -0,0 +1,24 @@ +import express from "express"; +import cors from "cors"; +import taskRoutes from "./routes/tasks"; +import { ensureTempUser } from "./utils/seedUser"; + +const app = express(); +const PORT = process.env.SUNSAMA_API_PORT || 3002; + +app.use(cors()); +app.use(express.json()); + +ensureTempUser().catch(() => { + /* seed may fail if DB not ready yet */ +}); + +app.get("/health", (_req, res) => { + res.json({ status: "ok", message: "Sunsama API is running" }); +}); + +app.use("/api/tasks", taskRoutes); + +app.listen(PORT, () => { + console.log(`Sunsama API running on http://localhost:${PORT}`); +}); diff --git a/artifacts/sunsama-api/src/routes/tasks.ts b/artifacts/sunsama-api/src/routes/tasks.ts new file mode 100644 index 0000000..6ccaa3d --- /dev/null +++ b/artifacts/sunsama-api/src/routes/tasks.ts @@ -0,0 +1,265 @@ +import { Router } from "express"; +import { db, sunsamaTasksTable } from "@workspace/db"; +import { eq, and, gte, lte } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import { generateInstances } from "../utils/recurringTasks"; + +const router = Router(); + +router.get("/", async (_req, res) => { + try { + const userId = "temp-user-id"; + const tasks = await db + .select() + .from(sunsamaTasksTable) + .where(eq(sunsamaTasksTable.userId, userId)) + .orderBy(sunsamaTasksTable.createdAt); + + res.json(tasks); + } catch { + res.status(500).json({ error: "Failed to fetch tasks" }); + } +}); + +router.post("/", async (req, res) => { + try { + const { + title, + description, + dueDate, + timeEstimate, + isRecurring, + recurrencePattern, + recurrenceDays, + recurrenceInterval, + } = req.body; + const userId = "temp-user-id"; + + const [task] = await db + .insert(sunsamaTasksTable) + .values({ + id: randomUUID(), + title, + description: description ?? null, + dueDate: dueDate ? new Date(dueDate) : null, + timeEstimate: timeEstimate ?? null, + userId, + isRecurring: isRecurring ?? false, + recurrencePattern: recurrencePattern ?? null, + recurrenceDays: recurrenceDays ?? null, + recurrenceInterval: recurrenceInterval ?? null, + }) + .returning(); + + res.status(201).json(task); + } catch { + res.status(500).json({ error: "Failed to create task" }); + } +}); + +router.patch("/:id/toggle", async (req, res) => { + try { + const { id } = req.params; + + const [task] = await db + .select() + .from(sunsamaTasksTable) + .where(eq(sunsamaTasksTable.id, id)) + .limit(1); + + if (!task) { + res.status(404).json({ error: "Task not found" }); + return; + } + + const [updatedTask] = await db + .update(sunsamaTasksTable) + .set({ completed: !task.completed }) + .where(eq(sunsamaTasksTable.id, id)) + .returning(); + + res.json(updatedTask); + } catch { + res.status(500).json({ error: "Failed to update task" }); + } +}); + +router.delete("/:id", async (req, res) => { + try { + const { id } = req.params; + await db.delete(sunsamaTasksTable).where(eq(sunsamaTasksTable.id, id)); + res.status(204).send(); + } catch { + res.status(500).json({ error: "Failed to delete task" }); + } +}); + +router.patch("/:id/plan", async (req, res) => { + try { + const { id } = req.params; + const { plannedDate } = req.body; + + const [task] = await db + .select() + .from(sunsamaTasksTable) + .where(eq(sunsamaTasksTable.id, id)) + .limit(1); + + if (!task) { + res.status(404).json({ error: "Task not found" }); + return; + } + + const [updatedTask] = await db + .update(sunsamaTasksTable) + .set({ plannedDate: plannedDate ? new Date(plannedDate) : null }) + .where(eq(sunsamaTasksTable.id, id)) + .returning(); + + res.json(updatedTask); + } catch { + res.status(500).json({ error: "Failed to update planned date for task" }); + } +}); + +router.patch("/:id", async (req, res) => { + try { + const { id } = req.params; + const { + title, + description, + dueDate, + plannedDate, + timeEstimate, + startTime, + actualTime, + isRecurring, + recurrencePattern, + recurrenceDays, + recurrenceInterval, + } = req.body; + + const [task] = await db + .select() + .from(sunsamaTasksTable) + .where(eq(sunsamaTasksTable.id, id)) + .limit(1); + + if (!task) { + res.status(404).json({ error: "Task not found" }); + return; + } + + const updateData: Record = {}; + if (title !== undefined) updateData.title = title; + if (description !== undefined) updateData.description = description; + if (dueDate !== undefined) updateData.dueDate = dueDate ? new Date(dueDate) : null; + if (plannedDate !== undefined) updateData.plannedDate = plannedDate ? new Date(plannedDate) : null; + if (timeEstimate !== undefined) updateData.timeEstimate = timeEstimate; + if (startTime !== undefined) updateData.startTime = startTime; + if (actualTime !== undefined) updateData.actualTime = actualTime; + if (isRecurring !== undefined) updateData.isRecurring = isRecurring; + if (recurrencePattern !== undefined) updateData.recurrencePattern = recurrencePattern; + if (recurrenceDays !== undefined) updateData.recurrenceDays = recurrenceDays; + if (recurrenceInterval !== undefined) updateData.recurrenceInterval = recurrenceInterval; + + const [updatedTask] = await db + .update(sunsamaTasksTable) + .set(updateData) + .where(eq(sunsamaTasksTable.id, id)) + .returning(); + + res.json(updatedTask); + } catch { + res.status(500).json({ error: "Failed to update task" }); + } +}); + +router.post("/:id/generate-instances", async (req, res) => { + try { + const { id } = req.params; + const { weeks = 2 } = req.body; + + const [template] = await db + .select() + .from(sunsamaTasksTable) + .where(eq(sunsamaTasksTable.id, id)) + .limit(1); + + if (!template) { + res.status(404).json({ error: "Task not found" }); + return; + } + + if (!template.isRecurring) { + res.status(400).json({ error: "Task not recurring" }); + return; + } + + const startDate = new Date(); + startDate.setHours(0, 0, 0, 0); + + const endDate = new Date(); + endDate.setDate(endDate.getDate() + weeks * 7); + endDate.setHours(0, 0, 0, 0); + + const instances = generateInstances( + { + id: template.id, + title: template.title, + description: template.description, + timeEstimate: template.timeEstimate, + recurrencePattern: template.recurrencePattern, + recurrenceDays: template.recurrenceDays, + recurrenceInterval: template.recurrenceInterval, + userId: template.userId, + }, + startDate, + endDate + ); + + const existingInstances = await db + .select() + .from(sunsamaTasksTable) + .where( + and( + eq(sunsamaTasksTable.parentTaskId, template.id), + gte(sunsamaTasksTable.plannedDate, startDate), + lte(sunsamaTasksTable.plannedDate, endDate) + ) + ); + + const existingDates = new Set( + existingInstances.map((t: { plannedDate: Date | null }) => + t.plannedDate?.toISOString().split("T")[0] + ) + ); + + const newInstances = instances.filter( + (inst) => !existingDates.has(inst.plannedDate.toISOString().split("T")[0]) + ); + + if (newInstances.length > 0) { + await db.insert(sunsamaTasksTable).values( + newInstances.map((inst) => ({ + id: randomUUID(), + title: inst.title, + description: inst.description, + timeEstimate: inst.timeEstimate, + plannedDate: inst.plannedDate, + userId: inst.userId, + parentTaskId: inst.parentTaskId, + })) + ); + } + + res.json({ + message: `Generated ${newInstances.length} new instances`, + total: newInstances.length, + }); + } catch { + res.status(500).json({ error: "Failed to generate instances" }); + } +}); + +export default router; diff --git a/artifacts/sunsama-api/src/utils/recurringTasks.ts b/artifacts/sunsama-api/src/utils/recurringTasks.ts new file mode 100644 index 0000000..b254dc9 --- /dev/null +++ b/artifacts/sunsama-api/src/utils/recurringTasks.ts @@ -0,0 +1,82 @@ +interface RecurringTemplate { + id: string; + title: string; + description: string | null; + timeEstimate: number | null; + recurrencePattern: string | null; + recurrenceDays: string | null; + recurrenceInterval: number | null; + userId: string; +} + +interface TaskInstance { + title: string; + description: string | null; + timeEstimate: number | null; + plannedDate: Date; + userId: string; + parentTaskId: string; +} + +export function generateInstances( + template: RecurringTemplate, + startDate: Date, + endDate: Date +): TaskInstance[] { + if (!template.recurrencePattern) { + return []; + } + + const instances: TaskInstance[] = []; + const current = new Date(startDate); + current.setHours(0, 0, 0, 0); + + while (current <= endDate) { + let shouldCreate = false; + + switch (template.recurrencePattern) { + case "daily": + shouldCreate = true; + break; + + case "weekdays": { + const day = current.getDay(); + shouldCreate = day >= 1 && day <= 5; + break; + } + + case "weekly": + if (template.recurrenceDays) { + const selectedDays = JSON.parse(template.recurrenceDays) as string[]; + const dayNames = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"]; + const currentDayName = dayNames[current.getDay()]; + shouldCreate = selectedDays.includes(currentDayName); + } + break; + + case "custom": + if (template.recurrenceInterval) { + const daysSinceStart = Math.floor( + (current.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24) + ); + shouldCreate = daysSinceStart % template.recurrenceInterval === 0; + } + break; + } + + if (shouldCreate) { + instances.push({ + title: template.title, + description: template.description, + timeEstimate: template.timeEstimate, + plannedDate: new Date(current), + userId: template.userId, + parentTaskId: template.id, + }); + } + + current.setDate(current.getDate() + 1); + } + + return instances; +} diff --git a/artifacts/sunsama-api/src/utils/seedUser.ts b/artifacts/sunsama-api/src/utils/seedUser.ts new file mode 100644 index 0000000..fcb6588 --- /dev/null +++ b/artifacts/sunsama-api/src/utils/seedUser.ts @@ -0,0 +1,21 @@ +import { db, sunsamaUsersTable } from "@workspace/db"; +import { eq } from "drizzle-orm"; + +export async function ensureTempUser() { + const userId = "temp-user-id"; + + const existing = await db + .select() + .from(sunsamaUsersTable) + .where(eq(sunsamaUsersTable.id, userId)) + .limit(1); + + if (existing.length === 0) { + await db.insert(sunsamaUsersTable).values({ + id: userId, + email: "temp@example.com", + name: "Temp User", + password: "temp", + }); + } +} diff --git a/artifacts/sunsama-api/tsconfig.json b/artifacts/sunsama-api/tsconfig.json new file mode 100644 index 0000000..cda42f0 --- /dev/null +++ b/artifacts/sunsama-api/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"], + "references": [ + { + "path": "../../lib/db" + } + ] +} diff --git a/artifacts/sunsama-web/index.html b/artifacts/sunsama-web/index.html new file mode 100644 index 0000000..7642ff8 --- /dev/null +++ b/artifacts/sunsama-web/index.html @@ -0,0 +1,13 @@ + + + + + + + Sunsama Clone - Task Management + + +
+ + + diff --git a/artifacts/sunsama-web/package.json b/artifacts/sunsama-web/package.json new file mode 100644 index 0000000..cda5048 --- /dev/null +++ b/artifacts/sunsama-web/package.json @@ -0,0 +1,29 @@ +{ + "name": "@workspace/sunsama-web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --config vite.config.ts --host 0.0.0.0", + "build": "vite build --config vite.config.ts", + "serve": "vite preview --config vite.config.ts --host 0.0.0.0", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@tailwindcss/vite": "catalog:", + "@tanstack/react-query": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "axios": "^1.12.2", + "react": "catalog:", + "react-dom": "catalog:", + "tailwindcss": "catalog:", + "vite": "catalog:", + "zustand": "^5.0.8" + } +} diff --git a/artifacts/sunsama-web/public/favicon.svg b/artifacts/sunsama-web/public/favicon.svg new file mode 100644 index 0000000..2ecc68a --- /dev/null +++ b/artifacts/sunsama-web/public/favicon.svg @@ -0,0 +1,4 @@ + + + S + diff --git a/artifacts/sunsama-web/src/App.tsx b/artifacts/sunsama-web/src/App.tsx new file mode 100644 index 0000000..4e1dbe4 --- /dev/null +++ b/artifacts/sunsama-web/src/App.tsx @@ -0,0 +1,133 @@ +import { useState, useCallback } from "react"; +import { Today } from "./pages/Today"; +import { Tasks } from "./pages/Tasks"; +import { Week } from "./pages/Week"; +import { CreateTaskModal } from "./components/CreateTaskModal"; +import { SettingsModal } from "./components/SettingsModal"; +import { useKeyboardShortcut } from "./hooks/useKeyboardShortcut"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { taskApi } from "./api/tasks"; +import type { CreateTaskInput } from "./types/task"; + +type View = "today" | "week" | "tasks"; + +function App() { + const [currentView, setCurrentView] = useState("today"); + const [showCreateModal, setShowCreateModal] = useState(false); + const [showSettings, setShowSettings] = useState(false); + + const queryClient = useQueryClient(); + + const createMutation = useMutation({ + mutationFn: taskApi.createTask, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tasks"] }); + }, + }); + + const handleCreateTask = (data: CreateTaskInput) => { + createMutation.mutate(data); + setShowCreateModal(false); + }; + + useKeyboardShortcut( + "n", + useCallback(() => setShowCreateModal(true), []) + ); + + useKeyboardShortcut( + "t", + useCallback(() => setCurrentView("today"), []) + ); + + useKeyboardShortcut( + "w", + useCallback(() => setCurrentView("week"), []) + ); + + useKeyboardShortcut( + "a", + useCallback(() => setCurrentView("tasks"), []) + ); + + useKeyboardShortcut( + "Escape", + useCallback(() => { + setShowCreateModal(false); + setShowSettings(false); + }, []) + ); + + return ( +
+ + +
+ {currentView === "today" && } + {currentView === "week" && } + {currentView === "tasks" && } +
+ + setShowCreateModal(false)} + onSubmit={handleCreateTask} + isLoading={createMutation.isPending} + /> + setShowSettings(false)} + /> +
+ ); +} + +export default App; diff --git a/artifacts/sunsama-web/src/api/client.ts b/artifacts/sunsama-web/src/api/client.ts new file mode 100644 index 0000000..06067cf --- /dev/null +++ b/artifacts/sunsama-web/src/api/client.ts @@ -0,0 +1,29 @@ +import axios from "axios"; + +const API_BASE_URL = + import.meta.env.VITE_API_URL || "http://localhost:3002"; + +export const apiClient = axios.create({ + baseURL: API_BASE_URL, + headers: { + "Content-Type": "application/json", + }, +}); + +apiClient.interceptors.request.use((config) => { + const token = localStorage.getItem("auth_token"); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +apiClient.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + localStorage.removeItem("auth_token"); + } + return Promise.reject(error); + } +); diff --git a/artifacts/sunsama-web/src/api/tasks.ts b/artifacts/sunsama-web/src/api/tasks.ts new file mode 100644 index 0000000..f15779f --- /dev/null +++ b/artifacts/sunsama-web/src/api/tasks.ts @@ -0,0 +1,63 @@ +import { apiClient } from "./client"; +import type { CreateTaskInput, Task } from "../types/task"; + +export interface UpdateTaskInput { + title?: string; + description?: string; + dueDate?: string; + plannedDate?: string; + timeEstimate?: number; + actualTime?: number; + startTime?: string; + isRecurring?: boolean; + recurrencePattern?: string; + recurrenceDays?: string; + recurrenceInterval?: number; +} + +export const taskApi = { + getTasks: async (): Promise => { + const response = await apiClient.get("/api/tasks"); + return response.data; + }, + + createTask: async (data: CreateTaskInput): Promise => { + const response = await apiClient.post("/api/tasks", data); + return response.data; + }, + + toggleTask: async (id: string): Promise => { + const response = await apiClient.patch(`/api/tasks/${id}/toggle`); + return response.data; + }, + + deleteTask: async (id: string): Promise => { + await apiClient.delete(`/api/tasks/${id}`); + }, + + planTask: async ( + id: string, + plannedDate: string | null + ): Promise => { + const response = await apiClient.patch(`/api/tasks/${id}/plan`, { + plannedDate, + }); + return response.data; + }, + + updateTask: async (id: string, data: UpdateTaskInput): Promise => { + const response = await apiClient.patch(`/api/tasks/${id}`, data); + return response.data; + }, + + generateInstances: async ( + id: string, + weeks: number = 2 + ): Promise<{ message: string; total: number }> => { + const response = await apiClient.post( + `/api/tasks/${id}/generate-instances`, + { weeks } + ); + return response.data; + }, +}; diff --git a/artifacts/sunsama-web/src/components/CreateTaskForm.tsx b/artifacts/sunsama-web/src/components/CreateTaskForm.tsx new file mode 100644 index 0000000..3af0716 --- /dev/null +++ b/artifacts/sunsama-web/src/components/CreateTaskForm.tsx @@ -0,0 +1,120 @@ +import { useState } from "react"; +import type { CreateTaskInput } from "../types/task"; + +interface CreateTaskFormProps { + onSubmit: (data: CreateTaskInput) => void; + isLoading?: boolean; +} + +export function CreateTaskForm({ onSubmit, isLoading }: CreateTaskFormProps) { + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [dueDate, setDueDate] = useState(""); + const [timeEstimate, setTimeEstimate] = useState(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!title.trim()) return; + + onSubmit({ + title: title.trim(), + description: description.trim() || undefined, + dueDate: dueDate || undefined, + timeEstimate: timeEstimate || undefined, + }); + + setTitle(""); + setDescription(""); + setDueDate(""); + setTimeEstimate(undefined); + }; + + return ( +
+

Add New Task

+ +
+
+ + setTitle(e.target.value)} + placeholder="What do you need to do?" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + required + /> +
+ +
+ +