Compare commits

...

4 Commits

Author SHA1 Message Date
Max
027496baf0 Signed-off-by: Max <ai-agent@topdoglabs.com> 2026-02-22 18:07:15 -06:00
Max
6cdd584b82 Signed-off-by: Max <ai-agent@topdoglabs.com> 2026-02-22 18:02:50 -06:00
Max
ab8cc0a6a1 fixed
Signed-off-by: Max <ai-agent@topdoglabs.com>
2026-02-22 17:56:39 -06:00
Max
b2b2beef2d removed defaults and legacy
Signed-off-by: Max <ai-agent@topdoglabs.com>
2026-02-22 17:48:49 -06:00
17 changed files with 723 additions and 1503 deletions

View File

@ -1,91 +0,0 @@
# Auth and Settings Migration Notes (Feb 21, 2026)
This document captures the auth/settings stabilization work completed during the SQLite -> Supabase migration cleanup.
## Why this was needed
The app had overlapping auth models:
- Supabase Auth tables (`auth.users`)
- App tables (`public.users`, `public.sessions`, `public.password_reset_tokens`)
The app was still expecting `public.users.password_hash`, but that column was not present in the migrated schema, which caused:
- login failures
- reset password failures (`Could not find the 'password_hash' column...`)
- settings profile/session issues
## Environment key mapping that must be correct
Use different values for these:
- `NEXT_PUBLIC_SUPABASE_ANON_KEY` -> anon/publishable key
- `SUPABASE_SERVICE_ROLE_KEY` -> service_role/secret key
If these are the same value, auth/session/profile flows break.
## Database work completed (Phase 1)
Phase 1 schema alignment was completed in Supabase:
- created `public.profiles` with `id` referencing `auth.users(id)`
- enabled RLS policies for own profile access
- backfilled profile rows from existing data
- optional trigger created to auto-create profile rows for new auth users
`public.users` was retained for compatibility during transition.
## Code changes made
### 1) `src/lib/server/auth.ts`
- `registerUser` now creates credentials in Supabase Auth (`auth.users`) via admin API.
- `authenticateUser` now verifies credentials using `signInWithPassword` instead of `public.users.password_hash`.
- `updateUserAccount` now updates password/email in Supabase Auth admin API.
- Profile fields are mirrored to:
- `public.users`
- `public.profiles`
- Existing custom `gantt_session` flow remains in place for compatibility.
### 2) `src/app/api/auth/reset-password/route.ts`
- Removed dependency on `public.users.password_hash`.
- Password reset now updates Supabase Auth user password through admin API.
- Added fallback for legacy records: if auth user is missing, create it with the same UUID, then set password.
### 3) `src/app/settings/page.tsx`
- Hardened response parsing for session/profile/password requests to avoid client runtime errors on malformed responses.
- Fixed avatar preset rendering key collisions:
- deduped generated preset URLs
- switched to stable numeric keys (not data URL strings)
## Current state (important)
Auth is currently hybrid but stable:
- credentials/passwords: Supabase Auth (`auth.users`)
- app profile fields: `public.profiles` (and mirrored `public.users`)
- app session guard cookie: custom `gantt_session` is still used by this codebase
This is intentional as an intermediate compatibility stage.
## Known behavior
Forgot password route currently generates reset links for dev/testing flow and does not send production email by itself unless an email provider flow is added.
## Validation checklist used
- login works after env fix
- reset password no longer fails on missing `password_hash`
- settings screen loads name/email/avatar
- avatar save no longer returns auth/session-related errors
- duplicate React key warning in avatar presets resolved
## Next cleanup (recommended)
To fully finish migration and remove duplication:
1. Move API auth/session checks to Supabase JWT/session directly.
2. Remove custom session table/cookie flow (`public.sessions`, `gantt_session`) after cutover.
3. Keep `public.profiles` as the only app profile table and retire compatibility mirrors.

View File

@ -1,156 +0,0 @@
# Supabase Migration Summary
## ✅ What Was Created
### 1. Documentation
- **`SUPABASE_SETUP.md`** - Complete setup guide with step-by-step instructions
- **`supabase/schema.sql`** - Full database schema with tables, indexes, RLS policies, and functions
### 2. Migration Script
- **`scripts/migrate-to-supabase.ts`** - TypeScript script to migrate all data from SQLite to Supabase
### 3. New Supabase Client Code
- **`src/lib/supabase/client.ts`** - Supabase client configuration
- **`src/lib/supabase/database.types.ts`** - TypeScript types for database tables
### 4. Updated Server Modules
- **`src/lib/server/auth.ts`** - Completely rewritten to use Supabase instead of SQLite
- **`src/lib/server/taskDb.ts`** - Completely rewritten to use Supabase instead of SQLite
### 5. Environment Template
- **`.env.local.example`** - Template for required environment variables
## 📊 Your Current Data
- **2 users** → Will be migrated
- **19 tasks** → Will be migrated
- **3 projects** → Will be migrated
- **3 sprints** → Will be migrated
## 🚀 Next Steps (In Order)
### Step 1: Create Supabase Project
1. Go to https://supabase.com/dashboard
2. Click "New Project"
3. Fill in details:
- Name: `gantt-board` (or your choice)
- Database Password: Generate a strong password
- Region: Choose closest to you (e.g., `us-east-1`)
4. Wait for creation (~2 minutes)
### Step 2: Get Credentials
1. Go to **Project Settings** → **API**
2. Copy:
- Project URL
- `anon` public key
- `service_role` secret key
### Step 3: Set Up Environment Variables
```bash
cd /Users/mattbruce/Documents/Projects/OpenClaw/Web/gantt-board
cp .env.local.example .env.local
```
Edit `.env.local` and fill in your actual Supabase credentials:
```bash
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here
```
### Step 4: Run the Database Schema
1. Go to Supabase Dashboard → **SQL Editor**
2. Click "New Query"
3. Copy contents of `supabase/schema.sql`
4. Click "Run"
### Step 5: Migrate Your Data
```bash
cd /Users/mattbruce/Documents/Projects/OpenClaw/Web/gantt-board
npx tsx scripts/migrate-to-supabase.ts
```
You should see output like:
```
🚀 Starting SQLite → Supabase migration
✅ Connected to Supabase
📦 Migrating users...
✓ user@example.com
✅ Migrated 2 users
📦 Migrating sessions...
✅ Migrated X sessions
...
✅ Migration Complete!
```
### Step 6: Test Locally
```bash
npm run dev
```
Test all functionality:
- Login/logout
- Create/edit tasks
- Create/edit projects
- Create/edit sprints
### Step 7: Deploy to Vercel
1. Push code to git (the Supabase code is already in place)
2. Add environment variables in Vercel dashboard:
- `NEXT_PUBLIC_SUPABASE_URL`
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
- `SUPABASE_SERVICE_ROLE_KEY`
3. Deploy!
## 🔐 Security Notes
1. **Never commit `.env.local`** - It's already in `.gitignore`
2. **Service Role Key** - Only used server-side, never expose to browser
3. **Row Level Security** - Enabled on all tables with appropriate policies
4. **Password Hashing** - Uses same scrypt algorithm as before
## 📁 Files Modified/Created
### New Files:
- `SUPABASE_SETUP.md`
- `supabase/schema.sql`
- `scripts/migrate-to-supabase.ts`
- `src/lib/supabase/client.ts`
- `src/lib/supabase/database.types.ts`
- `.env.local.example`
### Modified Files:
- `package.json` - Added `@supabase/supabase-js` and `dotenv`
- `src/lib/server/auth.ts` - Rewritten for Supabase
- `src/lib/server/taskDb.ts` - Rewritten for Supabase
## 🔄 Rollback Plan
If something goes wrong:
1. Keep your `data/tasks.db` file - it's untouched
2. You can revert the code changes with git:
```bash
git checkout src/lib/server/auth.ts src/lib/server/taskDb.ts
```
3. Remove Supabase env vars to fall back to SQLite
## ❓ Troubleshooting
### Migration fails with connection error
- Check that your Supabase URL and keys are correct
- Ensure your Supabase project is active (not paused)
### Data doesn't appear after migration
- Check the migration script output for errors
- Verify tables were created by checking Supabase Table Editor
### Auth issues after migration
- Users will need to log in again (sessions aren't migrated by default)
- Passwords are preserved - same login credentials work
## 🎉 You're All Set!
Once you complete the steps above, your Gantt Board will be running on Supabase with:
- ✅ Persistent data that survives server restarts
- ✅ Works on Vercel (no file system dependencies)
- ✅ Can scale to multiple servers
- ✅ Real-time capabilities (future enhancement possible)

View File

@ -16,12 +16,11 @@ Task and sprint board built with Next.js + Zustand and Supabase-backed API persi
### Feb 21, 2026 updates
- Stabilized auth and settings migration after SQLite -> Supabase transition.
- Stabilized auth and settings flows with Supabase Auth credentials.
- Login/password validation now uses Supabase Auth credentials.
- Reset password flow no longer depends on `public.users.password_hash`.
- Settings API response handling was hardened to avoid client script errors.
- Avatar preset key collision warning in settings was fixed.
- Detailed notes: `AUTH_SETTINGS_MIGRATION_NOTES_2026-02-21.md`
### Feb 22, 2026 updates
@ -29,6 +28,13 @@ Task and sprint board built with Next.js + Zustand and Supabase-backed API persi
- Standardized sprint date parsing across Kanban, Backlog, Sprint Board, Archive, and task detail sprint pickers.
- `POST /api/sprints` and `PATCH /api/sprints` now normalize `startDate`/`endDate` to `YYYY-MM-DD` before writing to Supabase.
### Feb 23, 2026 updates
- Removed non-live fallback data paths from the task store; board data now comes from Supabase-backed APIs only.
- Removed legacy schema expectations from API code and docs (`legacy_id`, `meta` dependency, non-existent task columns).
- Task detail now includes explicit Project + Sprint selection; selecting a sprint auto-aligns `projectId`.
- API validation now accepts UUID-shaped IDs used by existing data and returns clearer error payloads for failed writes.
### Sprint date semantics (important)
- Sprint dates are treated as local calendar-day boundaries:
@ -54,8 +60,8 @@ Task and sprint board built with Next.js + Zustand and Supabase-backed API persi
- Tasks use labels (`tags: string[]`) and can have multiple labels.
- Tasks support attachments (`attachments: TaskAttachment[]`).
- Tasks now track `createdById`, `createdByName`, `createdByAvatarUrl`, `updatedById`, `updatedByName`, and `updatedByAvatarUrl`.
- Tasks now track assignment via `assigneeId`, `assigneeName`, `assigneeEmail`, and `assigneeAvatarUrl`.
- Tasks persist identity references via `createdById`, `updatedById`, and `assigneeId`.
- Display fields (`createdByName`, `updatedByName`, `assigneeName`, avatar/email) are resolved from `users` data in API/UI, not stored as separate task table columns.
- There is no active `backlog` status in workflow logic.
- A task is considered in Backlog when `sprintId` is empty.
- Current status values:
@ -119,7 +125,7 @@ Task and sprint board built with Next.js + Zustand and Supabase-backed API persi
### Labels
- Project selection UI for tasks was removed in favor of labels.
- Labels are independent of project/sprint selection.
- You can add/remove labels inline in:
- New Task modal
- Task Detail page
@ -135,6 +141,7 @@ Task and sprint board built with Next.js + Zustand and Supabase-backed API persi
- `/tasks/{taskId}`
- Task detail is no longer popup-only behavior.
- Each task now has a shareable deep link.
- Task detail includes explicit Project and Sprint dropdowns.
### Attachments
@ -201,8 +208,12 @@ During drag, the active target column shows expanded status drop zones for clari
## Persistence
- Client state is managed with Zustand.
- Persistence is done via `/api/tasks`.
- API reads/writes Supabase tables.
- Persistence is handled through Supabase-backed API routes:
- `/api/tasks`
- `/api/projects`
- `/api/sprints`
- No local task/project/sprint fallback dataset is used.
- API reads/writes Supabase tables only.
## Run locally

View File

@ -149,7 +149,7 @@ Or push to git and let Vercel auto-deploy.
### After (Supabase):
- PostgreSQL database hosted by Supabase
- JWT-based session tokens
- Supabase Auth credentials + app-managed `gantt_session` cookie sessions
- Works on multiple servers/Vercel edge functions
- Real-time subscriptions possible (future enhancement)
@ -164,6 +164,7 @@ Or push to git and let Vercel auto-deploy.
- Users can only see/modify their own data
- Admin operations use service role key
3. **Password hashing remains the same**
- We use scrypt hashing (same as SQLite version)
- Passwords are never stored in plain text
3. **Password handling**
- Passwords are managed by Supabase Auth.
- Do not add `public.users.password_hash`; keep password data out of public tables.
- App login state uses secure, HTTP-only session cookies.

View File

@ -41,17 +41,17 @@ A unified CLI that covers all API operations.
# Create task
./scripts/gantt.sh task create <title> [status] [priority] [project-id]
./scripts/gantt.sh task create "Fix bug" open high 1
./scripts/gantt.sh task create "Fix bug" open high <project-uuid>
# Create from natural language
./scripts/gantt.sh task natural "Fix login bug by Friday, assign to Matt, high priority"
# Update any field
./scripts/gantt.sh task update <task-id> <field> <value>
./scripts/gantt.sh task update abc-123 status done
./scripts/gantt.sh task update abc-123 priority urgent
./scripts/gantt.sh task update abc-123 title "New title"
./scripts/gantt.sh task update abc-123 assigneeId <user-id>
./scripts/gantt.sh task update <task-uuid> status done
./scripts/gantt.sh task update <task-uuid> priority urgent
./scripts/gantt.sh task update <task-uuid> title "New title"
./scripts/gantt.sh task update <task-uuid> assigneeId <user-uuid>
# Delete task
./scripts/gantt.sh task delete <task-id>
@ -61,7 +61,7 @@ A unified CLI that covers all API operations.
# Attach file
./scripts/gantt.sh task attach <task-id> <file-path>
./scripts/gantt.sh task attach abc-123 ./research.pdf
./scripts/gantt.sh task attach <task-uuid> ./research.pdf
```
### Project Commands
@ -78,9 +78,9 @@ A unified CLI that covers all API operations.
# Update project field
./scripts/gantt.sh project update <project-id> <field> <value>
./scripts/gantt.sh project update abc-123 name "New Name"
./scripts/gantt.sh project update abc-123 description "New desc"
./scripts/gantt.sh project update abc-123 color "#ff0000"
./scripts/gantt.sh project update <project-uuid> name "New Name"
./scripts/gantt.sh project update <project-uuid> description "New desc"
./scripts/gantt.sh project update <project-uuid> color "#ff0000"
# Delete project
./scripts/gantt.sh project delete <project-id>
@ -100,9 +100,9 @@ A unified CLI that covers all API operations.
# Update sprint field
./scripts/gantt.sh sprint update <sprint-id> <field> <value>
./scripts/gantt.sh sprint update abc-123 name "New Sprint Name"
./scripts/gantt.sh sprint update abc-123 status active
./scripts/gantt.sh sprint update abc-123 startDate "2026-02-25"
./scripts/gantt.sh sprint update <sprint-uuid> name "New Sprint Name"
./scripts/gantt.sh sprint update <sprint-uuid> status active
./scripts/gantt.sh sprint update <sprint-uuid> startDate "2026-02-25"
# Delete sprint
./scripts/gantt.sh sprint delete <sprint-id>
@ -242,13 +242,13 @@ export API_URL=http://localhost:3001/api
```bash
# Add completion comment
./scripts/gantt.sh task comment abc-123 "Completed. See attached notes."
./scripts/gantt.sh task comment <task-uuid> "Completed. See attached notes."
# Attach notes
./scripts/gantt.sh task attach abc-123 ./completion-notes.md
./scripts/gantt.sh task attach <task-uuid> ./completion-notes.md
# Mark done
./scripts/gantt.sh task update abc-123 status done
./scripts/gantt.sh task update <task-uuid> status done
```
### Find Tasks
@ -294,6 +294,7 @@ Task IDs are UUIDs like `33ebc71e-7d40-456c-8f98-bb3578d2bb2b`. Find them:
- In the URL when viewing a task
- From `task list` output
- From `task create` output
- `projectId`, `sprintId`, and `assigneeId` fields are UUID values as well.
## Tips

View File

@ -74,37 +74,16 @@ export async function POST(request: Request) {
const userEmail = Array.isArray(resetToken.users)
? resetToken.users[0]?.email
: resetToken.users?.email;
const userName = Array.isArray(resetToken.users)
? resetToken.users[0]?.name
: resetToken.users?.name;
if (userEmail?.toLowerCase() !== email) {
return NextResponse.json({ error: "Invalid reset token" }, { status: 400 });
}
// Update Supabase Auth password. If auth user doesn't exist yet (legacy migration),
// create it with the same UUID so app foreign keys remain valid.
// Strict mode: reset requires an existing Supabase Auth user.
const { error: updateError } = await supabase.auth.admin.updateUserById(resetToken.user_id, {
password,
});
if (updateError) {
const updateMessage = updateError.message || "";
if (updateMessage.toLowerCase().includes("not found")) {
const { error: createError } = await supabase.auth.admin.createUser({
id: resetToken.user_id,
email,
password,
email_confirm: true,
user_metadata: {
name: typeof userName === "string" && userName.trim().length > 0 ? userName : undefined,
},
});
if (createError) throw createError;
} else {
throw updateError;
}
}
if (updateError) throw updateError;
// Mark token as used
await supabase

View File

@ -74,21 +74,34 @@ export async function PATCH(request: Request) {
}
const body = await request.json();
const { id, ...updates } = body;
const { id, ...rawUpdates } = body;
if (!id) {
return NextResponse.json({ error: "Missing project id" }, { status: 400 });
}
const updates: Record<string, unknown> = {};
if (typeof rawUpdates.name === "string" && rawUpdates.name.trim().length > 0) {
updates.name = rawUpdates.name.trim();
}
if (Object.prototype.hasOwnProperty.call(rawUpdates, "description")) {
updates.description =
typeof rawUpdates.description === "string" && rawUpdates.description.trim().length > 0
? rawUpdates.description
: null;
}
if (typeof rawUpdates.color === "string" && rawUpdates.color.trim().length > 0) {
updates.color = rawUpdates.color;
}
if (Object.keys(updates).length === 0) {
return NextResponse.json({ error: "No valid project updates provided" }, { status: 400 });
}
const supabase = getServiceSupabase();
const now = new Date().toISOString();
const { data, error } = await supabase
.from("projects")
.update({
...updates,
updated_at: now,
})
.update(updates)
.eq("id", id)
.select()
.single();
@ -117,9 +130,15 @@ export async function DELETE(request: Request) {
}
const supabase = getServiceSupabase();
const { error } = await supabase.from("projects").delete().eq("id", id);
const { error, count } = await supabase
.from("projects")
.delete({ count: "exact" })
.eq("id", id);
if (error) throw error;
if ((count ?? 0) === 0) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
return NextResponse.json({ success: true });
} catch (error) {

View File

@ -7,6 +7,20 @@ export const runtime = "nodejs";
// Sprint dates are stored as SQL DATE values (YYYY-MM-DD). We accept either
// date-only or ISO datetime inputs and normalize to the date prefix.
const DATE_PREFIX_PATTERN = /^(\d{4}-\d{2}-\d{2})/;
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const SPRINT_STATUSES = ["planning", "active", "completed"] as const;
type SprintStatus = (typeof SPRINT_STATUSES)[number];
class HttpError extends Error {
readonly status: number;
readonly details?: Record<string, unknown>;
constructor(status: number, message: string, details?: Record<string, unknown>) {
super(message);
this.status = status;
this.details = details;
}
}
function toDateOnlyInput(value: unknown): string | null {
if (typeof value !== "string") return null;
@ -16,8 +30,38 @@ function toDateOnlyInput(value: unknown): string | null {
return match?.[1] ?? null;
}
function currentDateOnly(): string {
return new Date().toISOString().split("T")[0];
function requireNonEmptyString(value: unknown, field: string): string {
if (typeof value !== "string" || value.trim().length === 0) {
throw new HttpError(400, `${field} is required`, { field, value });
}
return value.trim();
}
function requireUuid(value: unknown, field: string): string {
const normalized = requireNonEmptyString(value, field);
if (!UUID_PATTERN.test(normalized)) {
throw new HttpError(400, `${field} must be a UUID`, { field, value });
}
return normalized;
}
function requireSprintStatus(value: unknown, field: string): SprintStatus {
if (typeof value !== "string" || !SPRINT_STATUSES.includes(value as SprintStatus)) {
throw new HttpError(400, `${field} must be one of: ${SPRINT_STATUSES.join(", ")}`, { field, value });
}
return value as SprintStatus;
}
async function requireExistingProjectId(
supabase: ReturnType<typeof getServiceSupabase>,
projectId: string
): Promise<string> {
const { data, error } = await supabase.from("projects").select("id").eq("id", projectId).maybeSingle();
if (error) throw error;
if (!data?.id) {
throw new HttpError(400, "projectId does not exist", { projectId });
}
return data.id;
}
// GET - fetch all sprints (optionally filtered by status)
@ -31,6 +75,9 @@ export async function GET(request: Request) {
// Parse query params
const { searchParams } = new URL(request.url);
const status = searchParams.get("status");
if (status && !SPRINT_STATUSES.includes(status as SprintStatus)) {
throw new HttpError(400, `status must be one of: ${SPRINT_STATUSES.join(", ")}`, { status });
}
const supabase = getServiceSupabase();
let query = supabase
@ -39,7 +86,7 @@ export async function GET(request: Request) {
.order("start_date", { ascending: true });
// Filter by status if provided
if (status && ["planning", "active", "completed"].includes(status)) {
if (status) {
query = query.eq("status", status);
}
@ -50,6 +97,9 @@ export async function GET(request: Request) {
return NextResponse.json({ sprints: sprints || [] });
} catch (error) {
console.error(">>> API GET /sprints error:", error);
if (error instanceof HttpError) {
return NextResponse.json({ error: error.message, details: error.details }, { status: error.status });
}
return NextResponse.json({ error: "Failed to fetch sprints" }, { status: 500 });
}
}
@ -65,25 +115,32 @@ export async function POST(request: Request) {
const body = await request.json();
const { name, goal, startDate, endDate, status, projectId } = body;
if (!name || typeof name !== "string") {
return NextResponse.json({ error: "Missing sprint name" }, { status: 400 });
}
const supabase = getServiceSupabase();
const resolvedName = requireNonEmptyString(name, "name");
const normalizedStartDate = toDateOnlyInput(startDate);
if (!normalizedStartDate) {
throw new HttpError(400, "startDate must be YYYY-MM-DD or ISO date-time", { startDate });
}
const normalizedEndDate = toDateOnlyInput(endDate);
if (!normalizedEndDate) {
throw new HttpError(400, "endDate must be YYYY-MM-DD or ISO date-time", { endDate });
}
const resolvedStatus = requireSprintStatus(status, "status");
const resolvedProjectId = await requireExistingProjectId(
supabase,
requireUuid(projectId, "projectId")
);
const now = new Date().toISOString();
const fallbackDate = currentDateOnly();
const normalizedStartDate = toDateOnlyInput(startDate) ?? fallbackDate;
const normalizedEndDate = toDateOnlyInput(endDate) ?? normalizedStartDate;
const { data, error } = await supabase
.from("sprints")
.insert({
name,
name: resolvedName,
goal: goal || null,
start_date: normalizedStartDate,
end_date: normalizedEndDate,
status: status || "planning",
project_id: projectId || null,
status: resolvedStatus,
project_id: resolvedProjectId,
created_at: now,
})
.select()
@ -94,6 +151,9 @@ export async function POST(request: Request) {
return NextResponse.json({ success: true, sprint: data });
} catch (error) {
console.error(">>> API POST /sprints error:", error);
if (error instanceof HttpError) {
return NextResponse.json({ error: error.message, details: error.details }, { status: error.status });
}
return NextResponse.json({ error: "Failed to create sprint" }, { status: 500 });
}
}
@ -109,39 +169,37 @@ export async function PATCH(request: Request) {
const body = await request.json();
const { id, ...updates } = body;
if (!id) {
return NextResponse.json({ error: "Missing sprint id" }, { status: 400 });
}
const supabase = getServiceSupabase();
const now = new Date().toISOString();
const sprintId = requireUuid(id, "id");
// Map camelCase to snake_case for database and keep date-only semantics.
const dbUpdates: Record<string, unknown> = {};
if (updates.name !== undefined) dbUpdates.name = updates.name;
if (updates.name !== undefined) dbUpdates.name = requireNonEmptyString(updates.name, "name");
if (updates.goal !== undefined) dbUpdates.goal = updates.goal;
if (updates.startDate !== undefined) {
const normalizedStartDate = toDateOnlyInput(updates.startDate);
if (!normalizedStartDate) {
return NextResponse.json({ error: "Invalid startDate format" }, { status: 400 });
throw new HttpError(400, "startDate must be YYYY-MM-DD or ISO date-time", { startDate: updates.startDate });
}
dbUpdates.start_date = normalizedStartDate;
}
if (updates.endDate !== undefined) {
const normalizedEndDate = toDateOnlyInput(updates.endDate);
if (!normalizedEndDate) {
return NextResponse.json({ error: "Invalid endDate format" }, { status: 400 });
throw new HttpError(400, "endDate must be YYYY-MM-DD or ISO date-time", { endDate: updates.endDate });
}
dbUpdates.end_date = normalizedEndDate;
}
if (updates.status !== undefined) dbUpdates.status = updates.status;
if (updates.projectId !== undefined) dbUpdates.project_id = updates.projectId;
dbUpdates.updated_at = now;
if (updates.status !== undefined) dbUpdates.status = requireSprintStatus(updates.status, "status");
if (updates.projectId !== undefined) {
const projectId = requireUuid(updates.projectId, "projectId");
dbUpdates.project_id = await requireExistingProjectId(supabase, projectId);
}
const { data, error } = await supabase
.from("sprints")
.update(dbUpdates)
.eq("id", id)
.eq("id", sprintId)
.select()
.single();
@ -150,6 +208,9 @@ export async function PATCH(request: Request) {
return NextResponse.json({ success: true, sprint: data });
} catch (error) {
console.error(">>> API PATCH /sprints error:", error);
if (error instanceof HttpError) {
return NextResponse.json({ error: error.message, details: error.details }, { status: error.status });
}
return NextResponse.json({ error: "Failed to update sprint" }, { status: 500 });
}
}
@ -163,19 +224,25 @@ export async function DELETE(request: Request) {
}
const { id } = await request.json();
if (!id) {
return NextResponse.json({ error: "Missing sprint id" }, { status: 400 });
}
const sprintId = requireUuid(id, "id");
const supabase = getServiceSupabase();
const { error } = await supabase.from("sprints").delete().eq("id", id);
const { error, count } = await supabase
.from("sprints")
.delete({ count: "exact" })
.eq("id", sprintId);
if (error) throw error;
if ((count ?? 0) === 0) {
throw new HttpError(404, "Sprint not found", { id: sprintId });
}
return NextResponse.json({ success: true });
} catch (error) {
console.error(">>> API DELETE /sprints error:", error);
if (error instanceof HttpError) {
return NextResponse.json({ error: error.message, details: error.details }, { status: error.status });
}
return NextResponse.json({ error: "Failed to delete sprint" }, { status: 500 });
}
}

View File

@ -1,5 +1,4 @@
import { NextResponse } from "next/server";
import { randomUUID } from "crypto";
import { getServiceSupabase } from "@/lib/supabase/client";
import { getAuthenticatedUser } from "@/lib/server/auth";
@ -62,7 +61,7 @@ const TASK_TYPES: Task["type"][] = ["idea", "task", "bug", "research", "plan"];
const TASK_STATUSES: Task["status"][] = ["open", "todo", "blocked", "in-progress", "review", "validate", "archived", "canceled", "done"];
const TASK_PRIORITIES: Task["priority"][] = ["low", "medium", "high", "urgent"];
const SPRINT_STATUSES: Sprint["status"][] = ["planning", "active", "completed"];
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// Optimized field selection - only fetch fields needed for board display
// Full task details (description, comments, attachments) fetched lazily
@ -77,41 +76,39 @@ const TASK_FIELDS_LIGHT = [
"created_at",
"updated_at",
"created_by_id",
"created_by_name",
"created_by_avatar_url",
"updated_by_id",
"updated_by_name",
"updated_by_avatar_url",
"assignee_id",
"assignee_name",
"assignee_email",
"assignee_avatar_url",
"due_date",
"tags",
];
// Fields for full task detail (when opening a task)
const TASK_FIELDS_FULL = [
...TASK_FIELDS_LIGHT,
"description",
"comments",
"attachments",
];
class HttpError extends Error {
readonly status: number;
readonly details?: Record<string, unknown>;
function isTaskType(value: unknown): value is Task["type"] {
return typeof value === "string" && TASK_TYPES.includes(value as Task["type"]);
constructor(status: number, message: string, details?: Record<string, unknown>) {
super(message);
this.status = status;
this.details = details;
}
}
function isTaskStatus(value: unknown): value is Task["status"] {
return typeof value === "string" && TASK_STATUSES.includes(value as Task["status"]);
function toErrorDetails(error: unknown): Record<string, unknown> | undefined {
if (!error || typeof error !== "object") return undefined;
const candidate = error as { code?: unknown; details?: unknown; hint?: unknown; message?: unknown };
return {
code: candidate.code,
details: candidate.details,
hint: candidate.hint,
message: candidate.message,
};
}
function isTaskPriority(value: unknown): value is Task["priority"] {
return typeof value === "string" && TASK_PRIORITIES.includes(value as Task["priority"]);
}
function isSprintStatus(value: unknown): value is Sprint["status"] {
return typeof value === "string" && SPRINT_STATUSES.includes(value as Sprint["status"]);
function throwQueryError(scope: string, error: unknown): never {
throw new HttpError(500, `${scope} query failed`, {
scope,
...toErrorDetails(error),
});
}
function toNonEmptyString(value: unknown): string | undefined {
@ -126,124 +123,92 @@ function stripUndefined<T extends Record<string, unknown>>(value: T): Record<str
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
}
function getMissingColumnFromError(error: unknown): string | null {
if (!error || typeof error !== "object") return null;
const candidate = error as { code?: string; message?: string };
if (typeof candidate.message !== "string") return null;
if (candidate.code === "PGRST204") {
const postgrestMatch = candidate.message.match(/Could not find the '([^']+)' column/);
if (postgrestMatch?.[1]) return postgrestMatch[1];
function requireNonEmptyString(value: unknown, field: string, status = 400): string {
const normalized = toNonEmptyString(value);
if (!normalized) {
throw new HttpError(status, `${field} is required`, { field, value });
}
return normalized;
}
if (candidate.code === "42703") {
const postgresMatch = candidate.message.match(/column\s+(?:\w+\.)?\"?([a-zA-Z0-9_]+)\"?\s+does not exist/i);
if (postgresMatch?.[1]) return postgresMatch[1];
function requireUuid(value: unknown, field: string, status = 400): string {
const normalized = requireNonEmptyString(value, field, status);
if (!isUuid(normalized)) {
throw new HttpError(status, `${field} must be a UUID`, { field, value });
}
return normalized;
}
return null;
function requireEnum<T extends string>(value: unknown, allowed: readonly T[], field: string, status = 400): T {
if (typeof value !== "string" || !allowed.includes(value as T)) {
throw new HttpError(status, `Invalid ${field} value`, { field, value });
}
function getForeignKeyColumnFromError(error: unknown): string | null {
if (!error || typeof error !== "object") return null;
const candidate = error as { code?: string; details?: string };
if (candidate.code !== "23503" || typeof candidate.details !== "string") return null;
const match = candidate.details.match(/\(([^)]+)\)=/);
return match?.[1] ?? null;
}
async function fetchTasksWithColumnFallback(
supabase: ReturnType<typeof getServiceSupabase>
): Promise<{ rows: Record<string, unknown>[]; droppedColumns: string[] }> {
let selectedFields = [...TASK_FIELDS_LIGHT];
const droppedColumns: string[] = [];
while (selectedFields.length > 0) {
const { data, error } = await supabase
.from("tasks")
.select(selectedFields.join(", "))
.order("updated_at", { ascending: false });
if (!error) {
return {
rows: (data as unknown as Record<string, unknown>[] | null) ?? [],
droppedColumns,
};
}
const missingColumn = getMissingColumnFromError(error);
if (missingColumn && selectedFields.includes(missingColumn)) {
droppedColumns.push(missingColumn);
selectedFields = selectedFields.filter((field) => field !== missingColumn);
continue;
}
throw error;
}
return { rows: [], droppedColumns };
return value as T;
}
async function resolveRequiredProjectId(
supabase: ReturnType<typeof getServiceSupabase>,
requestedProjectId?: string
): Promise<string> {
const requested = toNonEmptyString(requestedProjectId);
if (requested) {
const { data } = await supabase.from("projects").select("id").eq("id", requested).maybeSingle();
if (data?.id) return data.id as string;
const projectId = requireUuid(requestedProjectId, "task.projectId");
const { data, error } = await supabase.from("projects").select("id").eq("id", projectId).maybeSingle();
if (error) throw error;
if (!data?.id) {
throw new HttpError(400, "task.projectId does not exist", { projectId });
}
const { data: firstProject } = await supabase
.from("projects")
.select("id")
.order("created_at", { ascending: true })
.limit(1)
.maybeSingle();
if (firstProject?.id) return firstProject.id as string;
throw new Error("No projects available to assign task");
return data.id as string;
}
async function resolveOptionalForeignId(
supabase: ReturnType<typeof getServiceSupabase>,
table: "sprints" | "users",
id?: string
id: string | undefined,
field: string
): Promise<string | null> {
const requested = toNonEmptyString(id);
if (!requested) return null;
const { data } = await supabase.from(table).select("id").eq("id", requested).maybeSingle();
return data?.id ? (data.id as string) : null;
if (!isUuid(requested)) {
throw new HttpError(400, `${field} must be a UUID when provided`, { field, value: id });
}
const { data, error } = await supabase
.from(table)
.select("id")
.eq("id", requested)
.maybeSingle();
if (error) throw error;
if (!data?.id) {
throw new HttpError(400, `${field} does not exist`, { field, value: requested });
}
return data.id as string;
}
function mapProjectRow(row: Record<string, unknown>): Project {
return {
id: String(row.id ?? ""),
name: toNonEmptyString(row.name) ?? "Untitled Project",
id: requireNonEmptyString(row.id, "projects.id", 500),
name: requireNonEmptyString(row.name, "projects.name", 500),
description: toNonEmptyString(row.description),
color: toNonEmptyString(row.color) ?? "#3b82f6",
createdAt: toNonEmptyString(row.created_at) ?? new Date().toISOString(),
color: requireNonEmptyString(row.color, "projects.color", 500),
createdAt: requireNonEmptyString(row.created_at, "projects.created_at", 500),
};
}
function mapSprintRow(row: Record<string, unknown>): Sprint {
const fallbackDate = new Date().toISOString();
return {
id: String(row.id ?? ""),
name: toNonEmptyString(row.name) ?? "Untitled Sprint",
id: requireNonEmptyString(row.id, "sprints.id", 500),
name: requireNonEmptyString(row.name, "sprints.name", 500),
goal: toNonEmptyString(row.goal),
startDate: toNonEmptyString(row.start_date) ?? fallbackDate,
endDate: toNonEmptyString(row.end_date) ?? fallbackDate,
status: isSprintStatus(row.status) ? row.status : "planning",
projectId: String(row.project_id ?? ""),
createdAt: toNonEmptyString(row.created_at) ?? fallbackDate,
startDate: requireNonEmptyString(row.start_date, "sprints.start_date", 500),
endDate: requireNonEmptyString(row.end_date, "sprints.end_date", 500),
status: requireEnum(row.status, SPRINT_STATUSES, "sprints.status", 500),
projectId: requireNonEmptyString(row.project_id, "sprints.project_id", 500),
createdAt: requireNonEmptyString(row.created_at, "sprints.created_at", 500),
};
}
function mapUserRow(row: Record<string, unknown>): UserProfile | null {
const id = toNonEmptyString(row.id);
const name = toNonEmptyString(row.name);
if (!id || !name) return null;
function mapUserRow(row: Record<string, unknown>): UserProfile {
const id = requireNonEmptyString(row.id, "users.id", 500);
const name = requireNonEmptyString(row.name, "users.name", 500);
return {
id,
name,
@ -253,7 +218,6 @@ function mapUserRow(row: Record<string, unknown>): UserProfile | null {
}
function mapTaskRow(row: Record<string, unknown>, usersById: Map<string, UserProfile>, includeFullData = false): Task {
const fallbackDate = new Date().toISOString();
const createdById = toNonEmptyString(row.created_by_id);
const updatedById = toNonEmptyString(row.updated_by_id);
const assigneeId = toNonEmptyString(row.assignee_id);
@ -261,31 +225,43 @@ function mapTaskRow(row: Record<string, unknown>, usersById: Map<string, UserPro
const updatedByUser = updatedById ? usersById.get(updatedById) : undefined;
const assigneeUser = assigneeId ? usersById.get(assigneeId) : undefined;
if (!Array.isArray(row.tags)) {
throw new HttpError(500, "Invalid tasks.tags value in database", { taskId: row.id, value: row.tags });
}
if (includeFullData && row.comments !== undefined && !Array.isArray(row.comments)) {
throw new HttpError(500, "Invalid tasks.comments value in database", { taskId: row.id, value: row.comments });
}
if (includeFullData && row.attachments !== undefined && !Array.isArray(row.attachments)) {
throw new HttpError(500, "Invalid tasks.attachments value in database", { taskId: row.id, value: row.attachments });
}
const task: Task = {
id: String(row.id ?? ""),
title: toNonEmptyString(row.title) ?? "",
id: requireNonEmptyString(row.id, "tasks.id", 500),
title: requireNonEmptyString(row.title, "tasks.title", 500),
description: includeFullData ? toNonEmptyString(row.description) : undefined,
type: isTaskType(row.type) ? row.type : "task",
status: isTaskStatus(row.status) ? row.status : "todo",
priority: isTaskPriority(row.priority) ? row.priority : "medium",
projectId: String(row.project_id ?? ""),
type: requireEnum(row.type, TASK_TYPES, "tasks.type", 500),
status: requireEnum(row.status, TASK_STATUSES, "tasks.status", 500),
priority: requireEnum(row.priority, TASK_PRIORITIES, "tasks.priority", 500),
projectId: requireNonEmptyString(row.project_id, "tasks.project_id", 500),
sprintId: toNonEmptyString(row.sprint_id),
createdAt: toNonEmptyString(row.created_at) ?? fallbackDate,
updatedAt: toNonEmptyString(row.updated_at) ?? fallbackDate,
createdAt: requireNonEmptyString(row.created_at, "tasks.created_at", 500),
updatedAt: requireNonEmptyString(row.updated_at, "tasks.updated_at", 500),
createdById,
createdByName: toNonEmptyString(row.created_by_name) ?? createdByUser?.name,
createdByAvatarUrl: toNonEmptyString(row.created_by_avatar_url) ?? createdByUser?.avatarUrl,
createdByName: createdByUser?.name,
createdByAvatarUrl: createdByUser?.avatarUrl,
updatedById,
updatedByName: toNonEmptyString(row.updated_by_name) ?? updatedByUser?.name,
updatedByAvatarUrl: toNonEmptyString(row.updated_by_avatar_url) ?? updatedByUser?.avatarUrl,
updatedByName: updatedByUser?.name,
updatedByAvatarUrl: updatedByUser?.avatarUrl,
assigneeId,
assigneeName: toNonEmptyString(row.assignee_name) ?? assigneeUser?.name,
assigneeEmail: toNonEmptyString(row.assignee_email) ?? assigneeUser?.email,
assigneeAvatarUrl: toNonEmptyString(row.assignee_avatar_url) ?? assigneeUser?.avatarUrl,
assigneeName: assigneeUser?.name,
assigneeEmail: assigneeUser?.email,
assigneeAvatarUrl: assigneeUser?.avatarUrl,
dueDate: toNonEmptyString(row.due_date),
comments: includeFullData && Array.isArray(row.comments) ? row.comments : [],
tags: Array.isArray(row.tags) ? row.tags.filter((tag): tag is string => typeof tag === "string") : [],
attachments: includeFullData && Array.isArray(row.attachments) ? row.attachments : [],
comments: includeFullData ? (row.comments as unknown[] | undefined) ?? [] : [],
tags: (row.tags as unknown[]).filter((tag): tag is string => typeof tag === "string"),
attachments: includeFullData ? (row.attachments as unknown[] | undefined) ?? [] : [],
};
return task;
@ -295,11 +271,10 @@ function mapTaskRow(row: Record<string, unknown>, usersById: Map<string, UserPro
// Uses lightweight fields for faster initial load
export async function GET() {
try {
// TODO: Re-enable auth after fixing cookie issue on Vercel
// const user = await getAuthenticatedUser();
// if (!user) {
// return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
// }
const user = await getAuthenticatedUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const supabase = getServiceSupabase();
@ -307,61 +282,61 @@ export async function GET() {
const [
{ data: projects, error: projectsError },
{ data: sprints, error: sprintsError },
{ data: users, error: usersError },
{ data: meta, error: metaError }
{ data: taskRows, error: tasksError },
{ data: users, error: usersError }
] = await Promise.all([
supabase.from("projects").select("id, name, description, color, created_at").order("created_at", { ascending: true }),
supabase.from("sprints").select("id, name, goal, start_date, end_date, status, project_id, created_at").order("start_date", { ascending: true }),
supabase.from("tasks").select(TASK_FIELDS_LIGHT.join(", ")).order("updated_at", { ascending: false }),
supabase.from("users").select("id, name, email, avatar_url"),
supabase.from("meta").select("value").eq("key", "lastUpdated").maybeSingle(),
]);
if (projectsError) throw projectsError;
if (sprintsError) throw sprintsError;
if (metaError) throw metaError;
if (usersError) {
console.warn(">>> API GET /tasks users query failed, continuing without user lookup:", usersError);
}
const { rows: taskRows, droppedColumns } = await fetchTasksWithColumnFallback(supabase);
if (droppedColumns.length > 0) {
console.warn(">>> API GET /tasks fallback: omitted missing task columns:", droppedColumns.join(", "));
}
if (projectsError) throwQueryError("projects", projectsError);
if (sprintsError) throwQueryError("sprints", sprintsError);
if (tasksError) throwQueryError("tasks", tasksError);
if (usersError) throwQueryError("users", usersError);
const usersById = new Map<string, UserProfile>();
for (const row of users || []) {
const mapped = mapUserRow(row as Record<string, unknown>);
if (mapped) usersById.set(mapped.id, mapped);
usersById.set(mapped.id, mapped);
}
return NextResponse.json({
projects: (projects || []).map((row) => mapProjectRow(row as Record<string, unknown>)),
sprints: (sprints || []).map((row) => mapSprintRow(row as Record<string, unknown>)),
tasks: taskRows.map((row) => mapTaskRow(row, usersById, false)),
lastUpdated: Number(meta?.value ?? Date.now()),
tasks: ((taskRows as unknown as Record<string, unknown>[] | null) || []).map((row) => mapTaskRow(row, usersById, false)),
currentUser: {
id: user.id,
name: user.name,
email: user.email,
avatarUrl: user.avatarUrl,
},
lastUpdated: Date.now(),
}, {
headers: {
// Enable caching for 30 seconds to reduce repeated requests
'Cache-Control': 'private, max-age=30, stale-while-revalidate=60',
'Cache-Control': 'no-store',
}
});
} catch (error) {
console.error(">>> API GET error:", error);
return NextResponse.json({ error: "Failed to fetch data" }, { status: 500 });
if (error instanceof HttpError) {
return NextResponse.json({ error: error.message, details: error.details }, { status: error.status });
}
const message = error instanceof Error ? error.message : "Failed to fetch data";
return NextResponse.json({ error: message, details: toErrorDetails(error) }, { status: 500 });
}
}
// POST - create or update a single task
export async function POST(request: Request) {
try {
// TODO: Re-enable auth after fixing cookie issue on Vercel
// const user = await getAuthenticatedUser();
// if (!user) {
// console.error(">>> API POST: No authenticated user");
// return NextResponse.json({ error: "Unauthorized - please log in again" }, { status: 401 });
// }
// console.log(">>> API POST: Authenticated as", user.email);
const user = { id: 'temp-user', email: 'temp@example.com', name: 'Temp User', createdAt: new Date().toISOString() };
const user = await getAuthenticatedUser();
if (!user) {
console.error(">>> API POST: No authenticated user");
return NextResponse.json({ error: "Unauthorized - please log in again" }, { status: 401 });
}
console.log(">>> API POST: Authenticated as", user.email);
const body = await request.json();
const { task } = body as { task?: Task };
@ -372,30 +347,31 @@ export async function POST(request: Request) {
const supabase = getServiceSupabase();
const now = new Date().toISOString();
const clientTaskId = toNonEmptyString(task.id);
const canonicalTaskId = isUuid(clientTaskId) ? clientTaskId : randomUUID();
const taskId = requireUuid(task.id, "task.id");
const resolvedProjectId = await resolveRequiredProjectId(supabase, task.projectId);
const resolvedSprintId = await resolveOptionalForeignId(supabase, "sprints", task.sprintId);
const resolvedAssigneeId = await resolveOptionalForeignId(supabase, "users", task.assigneeId);
const resolvedSprintId = await resolveOptionalForeignId(supabase, "sprints", task.sprintId, "task.sprintId");
const resolvedAssigneeId = await resolveOptionalForeignId(supabase, "users", task.assigneeId, "task.assigneeId");
// Check if task exists
const { data: existing } = await supabase
let existing: { id: string } | null = null;
const { data: byId, error: byIdError } = await supabase
.from("tasks")
.select("id")
.eq("id", canonicalTaskId)
.eq("id", taskId)
.maybeSingle();
if (byIdError) throw byIdError;
existing = (byId as { id: string } | null) ?? null;
let taskData = stripUndefined({
id: canonicalTaskId,
legacy_id: clientTaskId && !isUuid(clientTaskId) ? clientTaskId : null,
title: task.title,
const taskData = stripUndefined({
id: taskId,
title: requireNonEmptyString(task.title, "task.title"),
description: task.description || null,
type: task.type || "task",
status: task.status || "todo",
priority: task.priority || "medium",
type: task.type ? requireEnum(task.type, TASK_TYPES, "task.type") : "task",
status: task.status ? requireEnum(task.status, TASK_STATUSES, "task.status") : "todo",
priority: task.priority ? requireEnum(task.priority, TASK_PRIORITIES, "task.priority") : "medium",
project_id: resolvedProjectId,
sprint_id: resolvedSprintId,
created_at: existing ? undefined : (task.createdAt || now),
created_at: existing ? undefined : toNonEmptyString(task.createdAt),
updated_at: now,
created_by_id: existing ? undefined : user.id,
updated_by_id: user.id,
@ -406,50 +382,19 @@ export async function POST(request: Request) {
attachments: task.attachments || [],
});
let result;
for (let attempt = 0; attempt < 8; attempt += 1) {
const query = existing
? supabase.from("tasks").update(taskData).eq("id", canonicalTaskId).select().single()
? supabase.from("tasks").update(taskData).eq("id", taskId).select().single()
: supabase.from("tasks").insert(taskData).select().single();
const { data, error } = await query;
if (!error) {
result = data;
break;
}
const missingColumn = getMissingColumnFromError(error);
if (missingColumn && missingColumn in taskData) {
const nextPayload = { ...taskData };
delete nextPayload[missingColumn];
taskData = nextPayload;
console.warn(`>>> API POST: removing unsupported tasks column '${missingColumn}' and retrying`);
continue;
}
const foreignKeyColumn = getForeignKeyColumnFromError(error);
if (foreignKeyColumn && foreignKeyColumn in taskData) {
const nextPayload = { ...taskData, [foreignKeyColumn]: null };
taskData = nextPayload;
console.warn(`>>> API POST: clearing invalid foreign key '${foreignKeyColumn}' and retrying`);
continue;
}
throw error;
}
if (!result) throw new Error("Failed to save task after schema fallback attempts");
// Update lastUpdated
await supabase.from("meta").upsert({
key: "lastUpdated",
value: String(Date.now()),
updated_at: now,
});
const { data: result, error: saveError } = await query;
if (saveError) throw saveError;
if (!result) throw new HttpError(500, "Task save succeeded without returning a row");
return NextResponse.json({ success: true, task: result });
} catch (error: unknown) {
console.error(">>> API POST error:", error);
if (error instanceof HttpError) {
return NextResponse.json({ error: error.message, details: error.details }, { status: error.status });
}
const message = error instanceof Error ? error.message : "Failed to save";
const details =
error && typeof error === "object"
@ -466,29 +411,31 @@ export async function POST(request: Request) {
// DELETE - remove a task
export async function DELETE(request: Request) {
try {
// TODO: Re-enable auth after fixing cookie issue on Vercel
// const user = await getAuthenticatedUser();
// if (!user) {
// return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
// }
const user = await getAuthenticatedUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = (await request.json()) as { id: string };
const taskId = requireUuid(id, "id");
const supabase = getServiceSupabase();
const { error } = await supabase.from("tasks").delete().eq("id", id);
const { error, count } = await supabase
.from("tasks")
.delete({ count: "exact" })
.eq("id", taskId);
if (error) throw error;
// Update lastUpdated
await supabase.from("meta").upsert({
key: "lastUpdated",
value: String(Date.now()),
updated_at: new Date().toISOString(),
});
if ((count ?? 0) === 0) {
throw new HttpError(404, "Task not found", { id: taskId });
}
return NextResponse.json({ success: true });
} catch (error: unknown) {
console.error(">>> API DELETE error:", error);
if (error instanceof HttpError) {
return NextResponse.json({ error: error.message, details: error.details }, { status: error.status });
}
const message = error instanceof Error ? error.message : "Failed to delete";
return NextResponse.json({ error: message }, { status: 500 });
}

View File

@ -1,6 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useState, type FormEvent } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
@ -70,6 +70,11 @@ export default function LoginPage() {
}
}
const handleAuthSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
void submit()
}
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center p-4">
<div className="w-full max-w-md border border-slate-800 rounded-xl bg-slate-900/70 p-6">
@ -93,7 +98,7 @@ export default function LoginPage() {
</button>
</div>
<div className="space-y-4">
<form className="space-y-4" onSubmit={handleAuthSubmit}>
{mode === "register" && (
<div>
<label className="block text-sm text-slate-300 mb-1">Name</label>
@ -152,10 +157,10 @@ export default function LoginPage() {
{error && <p className="text-sm text-red-400">{error}</p>}
<Button onClick={submit} className="w-full" disabled={isSubmitting}>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? "Please wait..." : mode === "login" ? "Login" : "Create Account"}
</Button>
</div>
</form>
{/* Forgot Password Modal */}
{showForgot && (
@ -225,6 +230,11 @@ function ForgotPasswordModal({
}
}
const handleResetSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
void handleSubmit()
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="w-full max-w-md border border-slate-800 rounded-xl bg-slate-900 p-6">
@ -233,7 +243,7 @@ function ForgotPasswordModal({
Enter your email and we&apos;ll send you a link to reset your password.
</p>
<div className="space-y-4">
<form className="space-y-4" onSubmit={handleResetSubmit}>
<div>
<label className="block text-sm text-slate-300 mb-1">Email</label>
<input
@ -251,11 +261,11 @@ function ForgotPasswordModal({
<Button onClick={onClose} variant="outline" className="flex-1">
Cancel
</Button>
<Button onClick={handleSubmit} className="flex-1" disabled={isSubmitting}>
<Button type="submit" className="flex-1" disabled={isSubmitting}>
{isSubmitting ? "Sending..." : "Send Link"}
</Button>
</div>
</div>
</form>
</div>
</div>
)

View File

@ -38,6 +38,7 @@ import {
import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment, type CommentAuthor } from "@/stores/useTaskStore"
import { BacklogSkeleton, SearchSkeleton } from "@/components/LoadingSkeletons"
import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download, Search, Archive } from "lucide-react"
import { toast } from "sonner"
// Dynamic imports for heavy view components - only load when needed
const BacklogView = dynamic(() => import("@/components/BacklogView").then(mod => mod.BacklogView), {
@ -830,7 +831,14 @@ export default function Home() {
if (newTask.title?.trim()) {
// If a specific sprint is selected, use that sprint's project
const selectedSprint = newTask.sprintId ? sprints.find(s => s.id === newTask.sprintId) : null
const targetProjectId = selectedSprint?.projectId || selectedProjectId || projects[0]?.id || '2'
const targetProjectId = selectedSprint?.projectId || selectedProjectId || projects[0]?.id
if (!targetProjectId) {
toast.error("Cannot create task", {
description: "No project is available. Create or select a project first.",
duration: 5000,
})
return
}
const taskToCreate: Omit<Task, 'id' | 'createdAt' | 'updatedAt' | 'comments'> = {
title: newTask.title.trim(),

View File

@ -227,6 +227,7 @@ export default function TaskDetailPage() {
const {
tasks,
projects,
sprints,
currentUser,
updateTask,
@ -352,6 +353,14 @@ export default function TaskDetailPage() {
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name))
}, [users, currentUser])
const sortedSprints = useMemo(
() =>
sprints
.slice()
.sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime()),
[sprints]
)
const handleAttachmentUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || [])
if (files.length === 0) return
@ -451,8 +460,46 @@ export default function TaskDetailPage() {
})
}
const setEditedTaskProject = (projectId: string) => {
if (!editedTask) return
const sprintStillMatchesProject =
!editedTask.sprintId || sprints.some((sprint) => sprint.id === editedTask.sprintId && sprint.projectId === projectId)
setEditedTask({
...editedTask,
projectId,
sprintId: sprintStillMatchesProject ? editedTask.sprintId : undefined,
})
}
const setEditedTaskSprint = (sprintId: string) => {
if (!editedTask) return
if (!sprintId) {
setEditedTask({
...editedTask,
sprintId: undefined,
})
return
}
const sprint = sprints.find((entry) => entry.id === sprintId)
setEditedTask({
...editedTask,
sprintId,
projectId: sprint?.projectId || editedTask.projectId,
})
}
const handleSave = async () => {
if (!editedTask) return
if (!editedTask.projectId) {
toast.error("Project is required", {
description: "Select a project before saving this task.",
duration: 5000,
})
return
}
setIsSaving(true)
setSaveSuccess(false)
@ -769,15 +816,31 @@ export default function TaskDetailPage() {
</select>
</div>
<div>
<Label className="text-slate-400">Project</Label>
<select
value={editedTask.projectId}
onChange={(event) => setEditedTaskProject(event.target.value)}
className="w-full mt-2 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white focus:outline-none focus:border-blue-500"
>
<option value="" disabled>Select project</option>
{projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
</div>
<div>
<Label className="text-slate-400">Sprint</Label>
<select
value={editedTask.sprintId || ""}
onChange={(event) => setEditedTask({ ...editedTask, sprintId: event.target.value || undefined })}
onChange={(event) => setEditedTaskSprint(event.target.value)}
className="w-full mt-2 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white focus:outline-none focus:border-blue-500"
>
<option value="">No Sprint</option>
{sprints.sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime()).map((sprint) => (
{sortedSprints.map((sprint) => (
<option key={sprint.id} value={sprint.id}>
{sprint.name} ({parseSprintStart(sprint.startDate).toLocaleDateString()})
</option>

View File

@ -309,7 +309,14 @@ export function BacklogView({ searchQuery = "" }: BacklogViewProps) {
// Get current active sprint
// Treat end date as end-of-day (23:59:59) to handle timezone issues
const now = new Date()
const currentSprint = sprints.find((s) => {
const projectSprints = selectedProjectId
? sprints.filter((s) => s.projectId === selectedProjectId)
: sprints
const projectTasks = selectedProjectId
? tasks.filter((t) => t.projectId === selectedProjectId)
: tasks
const currentSprint = projectSprints.find((s) => {
if (s.status !== "active") return false
const sprintStart = parseSprintStart(s.startDate)
const sprintEnd = parseSprintEnd(s.endDate)
@ -317,20 +324,20 @@ export function BacklogView({ searchQuery = "" }: BacklogViewProps) {
})
// Get other sprints (not current)
const otherSprints = sprints.filter((s) => s.id !== currentSprint?.id)
const otherSprints = projectSprints.filter((s) => s.id !== currentSprint?.id)
// Sort tasks by updatedAt (descending) - latest first
const sortByUpdated = (a: Task, b: Task) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
// Get tasks by section (sorted by last updated)
const currentSprintTasks = currentSprint
? tasks.filter((t) => t.sprintId === currentSprint.id && matchesSearch(t)).sort(sortByUpdated)
? projectTasks.filter((t) => t.sprintId === currentSprint.id && matchesSearch(t)).sort(sortByUpdated)
: []
const backlogTasks = tasks.filter((t) => !t.sprintId && matchesSearch(t)).sort(sortByUpdated)
const backlogTasks = projectTasks.filter((t) => !t.sprintId && matchesSearch(t)).sort(sortByUpdated)
// Get active task for drag overlay
const activeTask = activeId ? tasks.find((t) => t.id === activeId) : null
const activeTask = activeId ? projectTasks.find((t) => t.id === activeId) : null
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string)
@ -344,7 +351,7 @@ export function BacklogView({ searchQuery = "" }: BacklogViewProps) {
const taskId = active.id as string
const overId = over.id as string
const overTask = tasks.find((t) => t.id === overId)
const overTask = projectTasks.find((t) => t.id === overId)
const destinationId = overTask
? overTask.sprintId
@ -370,7 +377,7 @@ export function BacklogView({ searchQuery = "" }: BacklogViewProps) {
}
const handleCreateSprint = () => {
if (!newSprint.name) return
if (!newSprint.name || !selectedProjectId) return
addSprint({
name: newSprint.name,
@ -378,7 +385,7 @@ export function BacklogView({ searchQuery = "" }: BacklogViewProps) {
startDate: newSprint.startDate || toLocalDateInputValue(),
endDate: newSprint.endDate || toLocalDateInputValue(),
status: "planning",
projectId: selectedProjectId || "2",
projectId: selectedProjectId,
})
setIsCreatingSprint(false)
@ -418,8 +425,7 @@ export function BacklogView({ searchQuery = "" }: BacklogViewProps) {
{otherSprints
.sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime())
.map((sprint) => {
const sprintTasks = tasks.filter((t) => t.sprintId === sprint.id && matchesSearch(t)).sort(sortByUpdated)
console.log(`Sprint ${sprint.name}: ${sprintTasks.length} tasks`, sprintTasks.map(t => t.title))
const sprintTasks = projectTasks.filter((t) => t.sprintId === sprint.id && matchesSearch(t)).sort(sortByUpdated)
return (
<SectionDropZone key={sprint.id} id={`sprint-${sprint.id}`}>
<TaskSection

View File

@ -1,414 +0,0 @@
import { getServiceSupabase } from "@/lib/supabase/client";
export interface TaskAttachment {
id: string;
name: string;
type: string;
size: number;
dataUrl: string;
uploadedAt: string;
}
export interface TaskCommentAuthor {
id: string;
name: string;
email?: string;
avatarUrl?: string;
type: "human" | "assistant";
}
export interface TaskComment {
id: string;
text: string;
createdAt: string;
author: TaskCommentAuthor | "user" | "assistant";
replies?: TaskComment[];
}
export interface Task {
id: string;
title: string;
description?: string;
type: "idea" | "task" | "bug" | "research" | "plan";
status: "open" | "todo" | "blocked" | "in-progress" | "review" | "validate" | "archived" | "canceled" | "done";
priority: "low" | "medium" | "high" | "urgent";
projectId: string;
sprintId?: string;
createdAt: string;
updatedAt: string;
createdById?: string;
createdByName?: string;
createdByAvatarUrl?: string;
updatedById?: string;
updatedByName?: string;
updatedByAvatarUrl?: string;
assigneeId?: string;
assigneeName?: string;
assigneeEmail?: string;
assigneeAvatarUrl?: string;
dueDate?: string;
comments: TaskComment[];
tags: string[];
attachments: TaskAttachment[];
}
export interface Project {
id: string;
name: string;
description?: string;
color: string;
createdAt: string;
}
export interface Sprint {
id: string;
name: string;
goal?: string;
startDate: string;
endDate: string;
status: "planning" | "active" | "completed";
projectId: string;
createdAt: string;
}
export interface DataStore {
projects: Project[];
tasks: Task[];
sprints: Sprint[];
lastUpdated: number;
}
const defaultData: DataStore = {
projects: [
{ id: "1", name: "OpenClaw iOS", description: "Main iOS app development", color: "#8b5cf6", createdAt: new Date().toISOString() },
{ id: "2", name: "Web Projects", description: "Web tools and dashboards", color: "#3b82f6", createdAt: new Date().toISOString() },
{ id: "3", name: "Research", description: "Experiments and learning", color: "#10b981", createdAt: new Date().toISOString() },
],
tasks: [],
sprints: [],
lastUpdated: Date.now(),
};
// Helper to safely parse JSON arrays
function safeParseArray<T>(value: unknown, fallback: T[]): T[] {
if (!value) return fallback;
if (Array.isArray(value)) return value as T[];
try {
const parsed = JSON.parse(String(value));
return Array.isArray(parsed) ? (parsed as T[]) : fallback;
} catch {
return fallback;
}
}
// Normalize attachments
function normalizeAttachments(attachments: unknown): TaskAttachment[] {
if (!Array.isArray(attachments)) return [];
return attachments
.map((attachment: unknown) => {
if (!attachment || typeof attachment !== "object") return null;
const value = attachment as Partial<TaskAttachment>;
const name = typeof value.name === "string" ? value.name.trim() : "";
const dataUrl = typeof value.dataUrl === "string" ? value.dataUrl : "";
if (!name || !dataUrl) return null;
return {
id: typeof value.id === "string" && value.id ? value.id : `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name,
type: typeof value.type === "string" ? value.type : "application/octet-stream",
size: typeof value.size === "number" && Number.isFinite(value.size) ? value.size : 0,
dataUrl,
uploadedAt: typeof value.uploadedAt === "string" && value.uploadedAt ? value.uploadedAt : new Date().toISOString(),
};
})
.filter((attachment): attachment is TaskAttachment => attachment !== null);
}
// Normalize comment author
function normalizeCommentAuthor(author: unknown): TaskCommentAuthor {
if (author === "assistant") {
return { id: "assistant", name: "Assistant", type: "assistant" };
}
if (author === "user") {
return { id: "legacy-user", name: "User", type: "human" };
}
if (!author || typeof author !== "object") {
return { id: "legacy-user", name: "User", type: "human" };
}
const value = author as Partial<TaskCommentAuthor>;
const type: TaskCommentAuthor["type"] =
value.type === "assistant" || value.id === "assistant" ? "assistant" : "human";
const id = typeof value.id === "string" && value.id.trim().length > 0
? value.id
: type === "assistant" ? "assistant" : "legacy-user";
const name = typeof value.name === "string" && value.name.trim().length > 0
? value.name.trim()
: type === "assistant" ? "Assistant" : "User";
const email = typeof value.email === "string" && value.email.trim().length > 0 ? value.email.trim() : undefined;
const avatarUrl = typeof value.avatarUrl === "string" && value.avatarUrl.trim().length > 0 ? value.avatarUrl : undefined;
return { id, name, email, avatarUrl, type };
}
// Normalize comments
function normalizeComments(comments: unknown): TaskComment[] {
if (!Array.isArray(comments)) return [];
const normalized: TaskComment[] = [];
for (const entry of comments) {
if (!entry || typeof entry !== "object") continue;
const value = entry as Partial<TaskComment>;
if (typeof value.id !== "string" || typeof value.text !== "string") continue;
normalized.push({
id: value.id,
text: value.text,
createdAt: typeof value.createdAt === "string" ? value.createdAt : new Date().toISOString(),
author: normalizeCommentAuthor(value.author),
replies: normalizeComments(value.replies),
});
}
return normalized;
}
// Normalize a task from database row
function normalizeTask(task: Record<string, unknown>): Task {
const comments = safeParseArray<unknown>(task.comments, []);
const tags = safeParseArray<unknown>(task.tags, []);
const attachments = safeParseArray<unknown>(task.attachments, []);
return {
id: String(task.id ?? ""),
title: String(task.title ?? ""),
description: task.description ? String(task.description) : undefined,
type: (task.type as Task["type"]) ?? "task",
status: (task.status as Task["status"]) ?? "open",
priority: (task.priority as Task["priority"]) ?? "medium",
projectId: String(task.project_id ?? ""),
sprintId: task.sprint_id ? String(task.sprint_id) : undefined,
createdAt: String(task.created_at ?? new Date().toISOString()),
updatedAt: String(task.updated_at ?? new Date().toISOString()),
createdById: task.created_by_id ? String(task.created_by_id) : undefined,
createdByName: task.created_by_name ? String(task.created_by_name) : undefined,
createdByAvatarUrl: task.created_by_avatar_url ? String(task.created_by_avatar_url) : undefined,
updatedById: task.updated_by_id ? String(task.updated_by_id) : undefined,
updatedByName: task.updated_by_name ? String(task.updated_by_name) : undefined,
updatedByAvatarUrl: task.updated_by_avatar_url ? String(task.updated_by_avatar_url) : undefined,
assigneeId: task.assignee_id ? String(task.assignee_id) : undefined,
assigneeName: task.assignee_name ? String(task.assignee_name) : undefined,
assigneeEmail: task.assignee_email ? String(task.assignee_email) : undefined,
assigneeAvatarUrl: task.assignee_avatar_url ? String(task.assignee_avatar_url) : undefined,
dueDate: task.due_date ? String(task.due_date) : undefined,
comments: normalizeComments(comments),
tags: tags.filter((tag): tag is string => typeof tag === "string"),
attachments: normalizeAttachments(attachments),
};
}
// Fetch user lookup map
async function getUserLookup(): Promise<Map<string, { id: string; name: string; email?: string; avatarUrl?: string }>> {
const supabase = getServiceSupabase();
const { data: users } = await supabase
.from("users")
.select("id, name, email, avatar_url");
const lookup = new Map<string, { id: string; name: string; email?: string; avatarUrl?: string }>();
for (const user of users || []) {
lookup.set(user.id, {
id: user.id,
name: user.name,
email: user.email ?? undefined,
avatarUrl: user.avatar_url ?? undefined,
});
}
return lookup;
}
export async function getData(): Promise<DataStore> {
const supabase = getServiceSupabase();
const usersById = await getUserLookup();
// Fetch all data in parallel
const [{ data: projects }, { data: sprints }, { data: tasks }, { data: meta }] = await Promise.all([
supabase.from("projects").select("*").order("created_at", { ascending: true }),
supabase.from("sprints").select("*").order("start_date", { ascending: true }),
supabase.from("tasks").select("*").order("created_at", { ascending: true }),
supabase.from("meta").select("value").eq("key", "lastUpdated").maybeSingle(),
]);
// If no data exists, seed with defaults
if ((projects?.length ?? 0) === 0 && (tasks?.length ?? 0) === 0 && (sprints?.length ?? 0) === 0) {
await seedDefaultData();
return getData();
}
return {
projects: (projects || []).map((p) => ({
id: p.id,
name: p.name,
description: p.description ?? undefined,
color: p.color,
createdAt: p.created_at,
})),
sprints: (sprints || []).map((s) => ({
id: s.id,
name: s.name,
goal: s.goal ?? undefined,
startDate: s.start_date,
endDate: s.end_date,
status: s.status,
projectId: s.project_id,
createdAt: s.created_at,
})),
tasks: (tasks || []).map((t) => {
const createdByUser = t.created_by_id ? usersById.get(t.created_by_id) : undefined;
const updatedByUser = t.updated_by_id ? usersById.get(t.updated_by_id) : undefined;
const assigneeUser = t.assignee_id ? usersById.get(t.assignee_id) : undefined;
return {
id: t.id,
title: t.title,
description: t.description ?? undefined,
type: t.type,
status: t.status,
priority: t.priority,
projectId: t.project_id,
sprintId: t.sprint_id ?? undefined,
createdAt: t.created_at,
updatedAt: t.updated_at,
createdById: t.created_by_id ?? undefined,
createdByName: t.created_by_name ?? createdByUser?.name ?? undefined,
createdByAvatarUrl: createdByUser?.avatarUrl ?? t.created_by_avatar_url ?? undefined,
updatedById: t.updated_by_id ?? undefined,
updatedByName: t.updated_by_name ?? updatedByUser?.name ?? undefined,
updatedByAvatarUrl: updatedByUser?.avatarUrl ?? t.updated_by_avatar_url ?? undefined,
assigneeId: t.assignee_id ?? undefined,
assigneeName: assigneeUser?.name ?? t.assignee_name ?? undefined,
assigneeEmail: assigneeUser?.email ?? t.assignee_email ?? undefined,
assigneeAvatarUrl: assigneeUser?.avatarUrl ?? undefined,
dueDate: t.due_date ?? undefined,
comments: normalizeComments(t.comments),
tags: safeParseArray<unknown>(t.tags, []).filter((tag): tag is string => typeof tag === "string"),
attachments: normalizeAttachments(t.attachments),
};
}),
lastUpdated: Number(meta?.value ?? Date.now()),
};
}
async function seedDefaultData(): Promise<void> {
const supabase = getServiceSupabase();
const now = new Date().toISOString();
// Insert default projects
for (const project of defaultData.projects) {
await supabase.from("projects").insert({
id: project.id,
name: project.name,
description: project.description,
color: project.color,
created_at: project.createdAt,
});
}
// Update lastUpdated
await supabase.from("meta").upsert({
key: "lastUpdated",
value: String(Date.now()),
updated_at: now,
});
}
export async function saveData(data: DataStore): Promise<DataStore> {
const supabase = getServiceSupabase();
const now = new Date().toISOString();
const lastUpdated = Date.now();
// Delete existing data (in correct order due to FK constraints)
const { error: deleteTasksError } = await supabase.from("tasks").delete().neq("id", "");
const { error: deleteSprintsError } = await supabase.from("sprints").delete().neq("id", "");
const { error: deleteProjectsError } = await supabase.from("projects").delete().neq("id", "");
if (deleteTasksError) console.error("Failed to delete tasks:", deleteTasksError);
if (deleteSprintsError) console.error("Failed to delete sprints:", deleteSprintsError);
if (deleteProjectsError) console.error("Failed to delete projects:", deleteProjectsError);
// Insert projects
if (data.projects.length > 0) {
const { error: projectError } = await supabase.from("projects").insert(
data.projects.map((p) => ({
id: p.id,
name: p.name,
description: p.description,
color: p.color,
created_at: p.createdAt,
}))
);
if (projectError) {
console.error("Failed to insert projects:", projectError);
throw new Error(`Failed to insert projects: ${projectError.message}`);
}
}
// Insert sprints
if (data.sprints.length > 0) {
const { error: sprintError } = await supabase.from("sprints").insert(
data.sprints.map((s) => ({
id: s.id,
name: s.name,
goal: s.goal,
start_date: s.startDate,
end_date: s.endDate,
status: s.status,
project_id: s.projectId,
created_at: s.createdAt,
}))
);
if (sprintError) {
console.error("Failed to insert sprints:", sprintError);
throw new Error(`Failed to insert sprints: ${sprintError.message}`);
}
}
// Insert tasks
if (data.tasks.length > 0) {
const { error: taskError } = await supabase.from("tasks").insert(
data.tasks.map((t) => ({
id: t.id,
title: t.title,
description: t.description,
type: t.type,
status: t.status,
priority: t.priority,
project_id: t.projectId,
sprint_id: t.sprintId,
created_at: t.createdAt,
updated_at: now,
created_by_id: t.createdById,
updated_by_id: t.updatedById,
assignee_id: t.assigneeId,
due_date: t.dueDate,
comments: t.comments,
tags: t.tags,
attachments: t.attachments,
}))
);
if (taskError) {
console.error("Failed to insert tasks:", taskError);
throw new Error(`Failed to insert tasks: ${taskError.message}`);
}
}
// Update lastUpdated
const { error: metaError } = await supabase.from("meta").upsert({
key: "lastUpdated",
value: String(lastUpdated),
updated_at: now,
});
return getData();
}

View File

@ -9,32 +9,49 @@ export interface Database {
users: {
Row: {
id: string;
legacy_id: string | null;
name: string;
email: string;
avatar_url: string | null;
password_hash: string;
created_at: string;
};
Insert: {
id?: string;
legacy_id?: string | null;
name: string;
email: string;
avatar_url?: string | null;
password_hash: string;
created_at?: string;
};
Update: {
id?: string;
legacy_id?: string | null;
name?: string;
email?: string;
avatar_url?: string | null;
password_hash?: string;
created_at?: string;
};
};
profiles: {
Row: {
id: string;
name: string | null;
avatar_url: string | null;
created_at: string;
updated_at: string;
};
Insert: {
id: string;
name?: string | null;
avatar_url?: string | null;
created_at?: string;
updated_at?: string;
};
Update: {
id?: string;
name?: string | null;
avatar_url?: string | null;
created_at?: string;
updated_at?: string;
};
};
sessions: {
Row: {
id: string;
@ -87,7 +104,6 @@ export interface Database {
projects: {
Row: {
id: string;
legacy_id: string | null;
name: string;
description: string | null;
color: string;
@ -95,7 +111,6 @@ export interface Database {
};
Insert: {
id?: string;
legacy_id?: string | null;
name: string;
description?: string | null;
color: string;
@ -103,7 +118,6 @@ export interface Database {
};
Update: {
id?: string;
legacy_id?: string | null;
name?: string;
description?: string | null;
color?: string;
@ -113,7 +127,6 @@ export interface Database {
sprints: {
Row: {
id: string;
legacy_id: string | null;
name: string;
goal: string | null;
start_date: string;
@ -124,7 +137,6 @@ export interface Database {
};
Insert: {
id?: string;
legacy_id?: string | null;
name: string;
goal?: string | null;
start_date: string;
@ -135,7 +147,6 @@ export interface Database {
};
Update: {
id?: string;
legacy_id?: string | null;
name?: string;
goal?: string | null;
start_date?: string;
@ -148,7 +159,6 @@ export interface Database {
tasks: {
Row: {
id: string;
legacy_id: string | null;
title: string;
description: string | null;
type: 'idea' | 'task' | 'bug' | 'research' | 'plan';
@ -159,15 +169,8 @@ export interface Database {
created_at: string;
updated_at: string;
created_by_id: string | null;
created_by_name: string | null;
created_by_avatar_url: string | null;
updated_by_id: string | null;
updated_by_name: string | null;
updated_by_avatar_url: string | null;
assignee_id: string | null;
assignee_name: string | null;
assignee_email: string | null;
assignee_avatar_url: string | null;
due_date: string | null;
comments: Json;
tags: Json;
@ -175,7 +178,6 @@ export interface Database {
};
Insert: {
id?: string;
legacy_id?: string | null;
title: string;
description?: string | null;
type: 'idea' | 'task' | 'bug' | 'research' | 'plan';
@ -186,15 +188,8 @@ export interface Database {
created_at?: string;
updated_at?: string;
created_by_id?: string | null;
created_by_name?: string | null;
created_by_avatar_url?: string | null;
updated_by_id?: string | null;
updated_by_name?: string | null;
updated_by_avatar_url?: string | null;
assignee_id?: string | null;
assignee_name?: string | null;
assignee_email?: string | null;
assignee_avatar_url?: string | null;
due_date?: string | null;
comments?: Json;
tags?: Json;
@ -202,7 +197,6 @@ export interface Database {
};
Update: {
id?: string;
legacy_id?: string | null;
title?: string;
description?: string | null;
type?: 'idea' | 'task' | 'bug' | 'research' | 'plan';
@ -213,38 +207,14 @@ export interface Database {
created_at?: string;
updated_at?: string;
created_by_id?: string | null;
created_by_name?: string | null;
created_by_avatar_url?: string | null;
updated_by_id?: string | null;
updated_by_name?: string | null;
updated_by_avatar_url?: string | null;
assignee_id?: string | null;
assignee_name?: string | null;
assignee_email?: string | null;
assignee_avatar_url?: string | null;
due_date?: string | null;
comments?: Json;
tags?: Json;
attachments?: Json;
};
};
meta: {
Row: {
key: string;
value: string;
updated_at: string;
};
Insert: {
key: string;
value: string;
updated_at?: string;
};
Update: {
key?: string;
value?: string;
updated_at?: string;
};
};
};
};
}

View File

@ -5,6 +5,7 @@ export type TaskType = 'idea' | 'task' | 'bug' | 'research' | 'plan'
export type TaskStatus = 'open' | 'todo' | 'blocked' | 'in-progress' | 'review' | 'validate' | 'archived' | 'canceled' | 'done'
export type Priority = 'low' | 'medium' | 'high' | 'urgent'
export type SprintStatus = 'planning' | 'active' | 'completed'
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
export interface Sprint {
id: string
@ -94,10 +95,10 @@ interface TaskStore {
currentUser: UserProfile
isLoading: boolean
lastSynced: number | null
syncError: string | null
// Sync actions
syncFromServer: () => Promise<void>
syncToServer: () => Promise<void>
setCurrentUser: (user: Partial<UserProfile>) => void
// Project actions
@ -129,296 +130,10 @@ interface TaskStore {
getTaskById: (id: string) => Task | undefined
}
// Sprint 1: Mon Feb 16 - Sun Feb 22, 2026 (current week)
const sprint1Start = new Date('2026-02-16T00:00:00.000Z')
const sprint1End = new Date('2026-02-22T23:59:59.999Z')
const defaultSprints: Sprint[] = [
{
id: 'sprint-1',
name: 'Sprint 1',
goal: 'Foundation and core features',
startDate: sprint1Start.toISOString(),
endDate: sprint1End.toISOString(),
status: 'active',
projectId: '2',
createdAt: new Date().toISOString(),
},
]
const defaultProjects: Project[] = [
{ id: '1', name: 'OpenClaw iOS', description: 'Main iOS app development', color: '#8b5cf6', createdAt: new Date().toISOString() },
{ id: '2', name: 'Web Projects', description: 'Web tools and dashboards', color: '#3b82f6', createdAt: new Date().toISOString() },
{ id: '3', name: 'Research', description: 'Experiments and learning', color: '#10b981', createdAt: new Date().toISOString() },
]
const defaultTasks: Task[] = [
{
id: '1',
title: 'Redesign Gantt Board',
description: 'Make it actually work with proper notes system',
type: 'task',
status: 'in-progress',
priority: 'high',
projectId: '2',
sprintId: 'sprint-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
comments: [
{ id: 'c1', text: 'Need 1-to-many notes, not one big text field', createdAt: new Date().toISOString(), author: 'user' },
{ id: 'c2', text: 'Agreed - will rebuild with proper comment threads', createdAt: new Date().toISOString(), author: 'assistant' },
],
tags: ['ui', 'rewrite']
},
{
id: '2',
title: 'MoodWeave App Idea',
description: 'Social mood tracking with woven visualizations',
type: 'idea',
status: 'open',
priority: 'medium',
projectId: '1',
sprintId: 'sprint-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
comments: [],
tags: ['ios', 'social']
},
{
id: '3',
title: 'Set up Gitea integration for code pushes',
description: 'Create bot account on Gitea (192.168.1.128:3000) and configure git remotes for all OpenClaw projects. Decide on account name, permissions, and auth method (SSH vs token). User prefers dedicated bot account over using their personal account for audit trail.',
type: 'task',
status: 'done',
priority: 'medium',
projectId: '2',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
comments: [
{ id: 'c3', text: 'User has local Gitea at http://192.168.1.128:3000', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c4', text: 'Options: 1) Create dedicated bot account (recommended), 2) Use existing account', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c5', text: 'Account created: mbruce@topdoglabs.com / !7883Gitea (username: ai-agent)', createdAt: new Date().toISOString(), author: 'user' },
{ id: 'c6', text: 'Git configured for all 3 projects. Gitea remotes added.', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c7', text: '✅ All 3 repos created and pushed to Gitea: gantt-board, blog-backup, heartbeat-monitor', createdAt: new Date().toISOString(), author: 'assistant' }
],
tags: ['gitea', 'git', 'automation', 'infrastructure']
},
{
id: '4',
title: 'Redesign Heartbeat Monitor to match UptimeRobot',
description: 'Completely redesign the Heartbeat Monitor website to be a competitor to https://uptimerobot.com. Study their design, layout, color scheme, typography, and functionality. Match their look, feel, and style as closely as possible. Include: modern dashboard, status pages, uptime charts, incident history, public status pages.',
type: 'task',
status: 'done',
priority: 'high',
projectId: '2',
sprintId: 'sprint-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
comments: [
{ id: 'c8', text: 'Reference: https://uptimerobot.com - study their homepage, dashboard, and status page designs', createdAt: new Date().toISOString(), author: 'user' },
{ id: 'c9', text: 'Focus on: clean modern UI, blue/green color scheme, card-based layouts, uptime percentage displays, incident timelines', createdAt: new Date().toISOString(), author: 'user' },
{ id: 'c29', text: 'COMPLETED: Full rebuild with Next.js + shadcn/ui + Framer Motion. Dark OLED theme, glass-morphism cards, animated status indicators, sparkline visualizations, grid/list views, tooltips, progress bars. Production-grade at http://localhost:3005', createdAt: new Date().toISOString(), author: 'assistant' }
],
tags: ['ui', 'ux', 'redesign', 'dashboard', 'monitoring']
},
{
id: '8',
title: 'Fix Kanban board - dynamic sync without hard refresh',
description: 'Current board uses localStorage persistence which requires hard refresh (Cmd+Shift+R) to see task updates from code changes. Need to add: server-side storage (API + database/file), or sync mechanism that checks for updates on regular refresh, or real-time updates via WebSocket/polling. User should see updates on normal page refresh without clearing cache.',
type: 'task',
status: 'in-progress',
priority: 'medium',
projectId: '2',
sprintId: 'sprint-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
comments: [
{ id: 'c21', text: 'Issue: Task status changes require hard refresh to see', createdAt: new Date().toISOString(), author: 'user' },
{ id: 'c22', text: 'Current: localStorage persistence via Zustand', createdAt: new Date().toISOString(), author: 'user' },
{ id: 'c23', text: 'Need: Server-side storage or sync on regular refresh', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c44', text: 'COMPLETED: Added /api/tasks endpoint with file-based storage', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c45', text: 'COMPLETED: Store now syncs from server on load and auto-syncs changes', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c46', text: 'COMPLETED: Falls back to localStorage if server unavailable', createdAt: new Date().toISOString(), author: 'assistant' }
],
tags: ['ui', 'sync', 'localstorage', 'real-time']
},
{
id: '5',
title: 'Fix Blog Backup links to be clickable',
description: 'Make links in the Daily Digest clickable in the blog backup UI. Currently links are just text that require copy-paste. Need to render markdown links properly so users can click directly. Consider different formatting for Telegram vs Blog - Telegram gets plain text summary with "Read more at [link]", Blog gets full formatted content with clickable links.',
type: 'task',
status: 'done',
priority: 'medium',
projectId: '2',
sprintId: 'sprint-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
comments: [
{ id: 'c10', text: 'Blog should show: [headline](url) as clickable markdown links', createdAt: new Date().toISOString(), author: 'user' },
{ id: 'c11', text: 'Telegram gets summary + "Full digest at: http://localhost:3003"', createdAt: new Date().toISOString(), author: 'user' },
{ id: 'c41', text: 'COMPLETED: Fixed parseDigest to extract URLs from markdown links [Title](url) in title lines', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c42', text: 'COMPLETED: Title is now the clickable link with external link icon on hover', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c43', text: 'COMPLETED: Better hover states - title turns blue, external link icon appears', createdAt: new Date().toISOString(), author: 'assistant' }
],
tags: ['blog', 'ui', 'markdown', 'links']
},
{
id: '6',
title: 'Fix monitoring schedule - 2 of 3 sites are down',
description: 'The cron job running every 10 minutes to check heartbeat website is failing. Currently 2 of 3 websites are down and not being auto-restarted. Debug and fix the monitoring schedule to ensure all 3 sites (gantt-board, blog-backup, heartbeat-monitor) are checked and auto-restarted properly.',
type: 'bug',
status: 'done',
priority: 'urgent',
projectId: '2',
sprintId: 'sprint-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
comments: [
{ id: 'c12', text: 'Issue: Cron job exists but sites are still going down without restart', createdAt: new Date().toISOString(), author: 'user' },
{ id: 'c13', text: 'Need to verify: cron is running, checks all 3 ports, restart logic works, permissions correct', createdAt: new Date().toISOString(), author: 'user' },
{ id: 'c14', text: 'ALL SITES BACK UP - manually restarted at 14:19. Now investigating why auto-restart failed.', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c15', text: 'Problem: Port 3005 was still in use (EADDRINUSE), need better process cleanup in restart logic', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c16', text: 'FIXED: Updated cron job with pkill cleanup before restart + 2s delay. Created backup script: monitor-restart.sh', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c17', text: 'All 3 sites stable. Cron job now properly kills old processes before restarting to avoid port conflicts.', createdAt: new Date().toISOString(), author: 'assistant' }
],
tags: ['monitoring', 'cron', 'bug', 'infrastructure', 'urgent']
},
{
id: '7',
title: 'Investigate root cause - why are websites dying?',
description: 'Currently monitoring only treats the symptom (restart when down). Need to investigate what is actually killing the Next.js dev servers. Check: system logs, memory usage, file watcher limits, zombie processes, macOS power management, SSH timeout, OOM killer. Set up logging to capture what happens right before crashes.',
type: 'research',
status: 'done',
priority: 'high',
projectId: '2',
sprintId: 'sprint-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
comments: [
{ id: 'c18', text: 'Problem: Sites go down randomly - what is killing them?', createdAt: new Date().toISOString(), author: 'user' },
{ id: 'c19', text: 'Suspects: Memory leaks, file watcher hitting limits, SSH session timeout, macOS power nap, OOM killer', createdAt: new Date().toISOString(), author: 'user' },
{ id: 'c20', text: 'Need to add logging/capture to see what kills processes', createdAt: new Date().toISOString(), author: 'user' },
{ id: 'c27', text: 'COMPLETED: Root cause analysis done. Primary suspect: Next.js dev server memory leaks. Secondary: SSH timeout, OOM killer, power mgmt. Full report: root-cause-analysis.md. Monitoring script deployed.', createdAt: new Date().toISOString(), author: 'assistant' }
],
tags: ['debugging', 'research', 'infrastructure', 'root-cause']
},
{
id: '9',
title: 'Add ability to edit task priority in Kanban board',
description: 'Currently users cannot change task priority (Low/Medium/High/Urgent) from the UI. Need to add priority editing capability to the task detail view or task card. Should be a dropdown selector allowing users to re-prioritize tasks on the fly without editing code.',
type: 'task',
status: 'done',
priority: 'high',
projectId: '2',
sprintId: 'sprint-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
comments: [
{ id: 'c24', text: 'User cannot currently change priority from Medium to High in UI', createdAt: new Date().toISOString(), author: 'user' },
{ id: 'c25', text: 'Need priority dropdown/editor in task detail view', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c28', text: 'COMPLETED: Added priority buttons to task detail dialog. Click any task to see Low/Medium/High/Urgent buttons with color coding. Changes apply immediately.', createdAt: new Date().toISOString(), author: 'assistant' }
],
tags: ['ui', 'kanban', 'feature', 'priority']
},
{
id: '10',
title: 'RESEARCH: Find viable screenshot solution for OpenClaw on macOS',
description: 'INVESTIGATION NEEDED: Find a reliable, persistent way for OpenClaw AI to capture screenshots of local websites running on macOS. Current browser tool requires Chrome extension which is not connected. Puppeteer workaround is temporary. Need to research and document ALL possible options including: macOS native screenshot tools (screencapture, automator), alternative browser automation tools, canvas/headless options, or any other method that works on macOS without requiring Chrome extension.',
type: 'research',
status: 'done',
priority: 'high',
projectId: '2',
sprintId: 'sprint-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
comments: [
{ id: 'c29', text: 'PROBLEM: User needs to share screenshots of local websites with friends who cannot access home network. Browser tool unavailable (Chrome extension not connected).', createdAt: new Date().toISOString(), author: 'user' },
{ id: 'c30', text: 'INVESTIGATE: macOS native screenshot capabilities - screencapture CLI, Automator workflows, AppleScript', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c31', text: 'INVESTIGATE: Alternative browser automation - Playwright, Selenium, WebDriver without Chrome extension requirement', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c32', text: 'INVESTIGATE: OpenClaw Gateway configuration - browser profiles, node setup, gateway settings', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c33', text: 'INVESTIGATE: Third-party screenshot APIs or services that could work locally', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c34', text: 'DELIVERABLE: Document ALL options found with pros/cons, setup requirements, and recommendation for best solution', createdAt: new Date().toISOString(), author: 'user' },
{ id: 'c35', text: 'FINDING: /usr/sbin/screencapture exists but requires interactive mode or captures full screen - cannot target specific URL', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c36', text: 'FINDING: Google Chrome is installed at /Applications/Google Chrome.app - Playwright can use this for headless screenshots', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c37', text: 'FINDING: Playwright v1.58.2 is already available via npx - can automate Chrome without extension', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c38', text: 'FINDING: OpenClaw Gateway is running on port 18789 with browser profile "chrome" but extension not attached to any tab', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c39', text: 'SUCCESS: Playwright + Google Chrome works! Successfully captured screenshot of http://localhost:3005 using headless Chrome', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c40', text: 'RECOMMENDATION: Install Playwright globally or in workspace so screenshots work without temporary installs. Command: npm install -g playwright', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c47', text: 'COMPLETED: Playwright installed globally. Screenshots now work anytime without setup.', createdAt: new Date().toISOString(), author: 'assistant' }
],
tags: ['research', 'screenshot', 'macos', 'openclaw', 'investigation']
},
{
id: '11',
title: 'RESEARCH: Find iOS side projects with MRR potential',
description: 'Research and identify iOS app ideas that have strong Monthly Recurring Revenue (MRR) opportunities. Focus on apps that are well-designed, multi-screen experiences (not single-screen utilities), and have viral potential. Look at current App Store trends, successful indie apps, and underserved niches. Consider: subscription models, freemium tiers, in-app purchases. Target ideas that leverage iOS-specific features (widgets, Live Activities, Siri, CoreML, etc.) and could generate $1K-$10K+ MRR.',
type: 'research',
status: 'done',
priority: 'low',
projectId: '3',
sprintId: 'sprint-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
comments: [
{ id: 'c48', text: 'Focus: iOS apps with MRR potential (subscriptions, recurring revenue)', createdAt: new Date().toISOString(), author: 'user' },
{ id: 'c49', text: 'Requirements: Well-thought-out, multi-screen, viral potential', createdAt: new Date().toISOString(), author: 'user' },
{ id: 'c50', text: 'Target: $1K-$10K+ MRR opportunities', createdAt: new Date().toISOString(), author: 'user' },
{ id: 'c51', text: 'Research areas: App Store trends, indie success stories, underserved niches', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c52', text: 'COMPLETED: Full research report saved to memory/ios-mrr-opportunities.md', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c53', text: 'TOP 10 IDEAS: (1) AI Translator Keyboard - $15K/mo potential, (2) Finance Widget Suite, (3) Focus Timer with Live Activities - RECOMMENDED, (4) AI Photo Enhancer, (5) Habit Tracker with Social, (6) Local Business Review Widget, (7) Audio Journal with Voice-to-Text, (8) Plant Care Tracker, (9) Sleep Sounds with HomeKit, (10) Family Password Manager', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c54', text: 'RECOMMENDATION: Focus Timer with Live Activities (#3) - Best first project. Technically achievable, proven market, high viral potential through focus streak sharing, leverages iOS 16+ features.', createdAt: new Date().toISOString(), author: 'assistant' }
],
tags: ['ios', 'mrr', 'research', 'side-project', 'entrepreneurship', 'app-ideas']
},
{
id: '12',
title: 'Add markdown rendering to Blog Backup',
description: 'The blog backup page currently shows raw markdown text instead of rendered HTML. This means links appear as [text](url) instead of clickable links. Need to install a markdown renderer (like react-markdown) and update the page component to properly render markdown content as HTML with clickable links, formatted headers, lists, etc.',
type: 'task',
status: 'done',
priority: 'high',
projectId: '2',
sprintId: 'sprint-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
comments: [
{ id: 'c55', text: 'Issue: Blog shows raw markdown [text](url) instead of clickable links', createdAt: new Date().toISOString(), author: 'user' },
{ id: 'c56', text: 'Solution: Install react-markdown and render content as HTML', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c57', text: 'Expected: Properly formatted markdown with clickable links, headers, lists', createdAt: new Date().toISOString(), author: 'user' },
{ id: 'c58', text: 'COMPLETED: Installed react-markdown and remark-gfm', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c59', text: 'COMPLETED: Installed @tailwindcss/typography for prose styling', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c60', text: 'COMPLETED: Updated page.tsx to render markdown as HTML with clickable links', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c61', text: 'COMPLETED: Links now open in new tab with blue styling and hover effects', createdAt: new Date().toISOString(), author: 'assistant' }
],
tags: ['blog', 'ui', 'markdown', 'frontend']
},
{
id: '13',
title: 'Research TTS options for Daily Digest podcast',
description: 'Research free text-to-speech (TTS) tools to convert the daily digest blog posts into an audio podcast format. Matt wants to listen to the digest during his morning dog walks with Tully and Remy. Look into: free TTS APIs (ElevenLabs free tier, Google TTS, AWS Polly), open-source solutions (Piper, Coqui TTS), browser-based options, RSS feed generation for podcast apps, and file hosting options. The solution should be cost-effective or free since budget is a concern.',
type: 'research',
status: 'open',
priority: 'medium',
projectId: '2',
sprintId: 'sprint-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
comments: [
{ id: 'c62', text: 'Goal: Convert daily digest text to audio for dog walks', createdAt: new Date().toISOString(), author: 'user' },
{ id: 'c63', text: 'Requirement: Free or very low cost solution', createdAt: new Date().toISOString(), author: 'user' },
{ id: 'c64', text: 'Look into: ElevenLabs free tier, Google TTS, AWS Polly, Piper, Coqui TTS', createdAt: new Date().toISOString(), author: 'assistant' },
{ id: 'c65', text: 'Also research: RSS feed generation, podcast hosting options', createdAt: new Date().toISOString(), author: 'assistant' }
],
tags: ['research', 'tts', 'podcast', 'audio', 'digest', 'accessibility']
const defaultCurrentUser: UserProfile = {
id: '',
name: 'Unknown User',
}
]
const createLocalUserProfile = (): UserProfile => ({
id: `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: 'Local User',
})
const defaultCurrentUser = createLocalUserProfile()
const normalizeUserProfile = (value: unknown, fallback: UserProfile = defaultCurrentUser): UserProfile => {
if (!value || typeof value !== 'object') return fallback
@ -556,12 +271,46 @@ const generateTaskId = (): string => {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID()
}
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
throw new Error('Unable to generate task UUID in this browser')
}
const getErrorMessage = (payload: unknown, fallback: string): string => {
if (!payload || typeof payload !== 'object') return fallback
const candidate = payload as { error?: unknown }
return typeof candidate.error === 'string' && candidate.error.trim().length > 0 ? candidate.error : fallback
}
async function requestApi(path: string, init: RequestInit): Promise<unknown> {
const response = await fetch(path, init)
if (response.ok) {
return response.json().catch(() => ({}))
}
const payload = await response.json().catch(() => null)
const message = getErrorMessage(payload, `${path} failed with status ${response.status}`)
throw new Error(message)
}
// Helper to sync a single task to server (lightweight)
async function syncTaskToServer(task: Task) {
console.log('>>> syncTaskToServer: saving task', task.id, task.title)
const isValidUuid = (value: string | undefined) => typeof value === 'string' && UUID_PATTERN.test(value)
if (!isValidUuid(task.id)) {
console.error('>>> syncTaskToServer: invalid task.id (expected UUID)', task.id)
return false
}
if (!isValidUuid(task.projectId)) {
console.error('>>> syncTaskToServer: invalid task.projectId (expected UUID)', task.projectId)
return false
}
if (task.sprintId && !isValidUuid(task.sprintId)) {
console.error('>>> syncTaskToServer: invalid task.sprintId (expected UUID)', task.sprintId)
return false
}
if (task.assigneeId && !isValidUuid(task.assigneeId)) {
console.error('>>> syncTaskToServer: invalid task.assigneeId (expected UUID)', task.assigneeId)
return false
}
try {
const res = await fetch('/api/tasks', {
method: 'POST',
@ -572,7 +321,15 @@ async function syncTaskToServer(task: Task) {
console.log('>>> syncTaskToServer: saved successfully')
return true
} else {
const errorPayload = await res.json().catch(() => null)
const rawBody = await res.text().catch(() => '')
let errorPayload: unknown = null
if (rawBody) {
try {
errorPayload = JSON.parse(rawBody)
} catch {
errorPayload = { rawBody }
}
}
console.error('>>> syncTaskToServer: failed with status', res.status, errorPayload)
return false
}
@ -604,123 +361,142 @@ async function deleteTaskFromServer(taskId: string) {
}
}
// Legacy bulk sync (for projects/sprints only)
async function syncToServer(projects: Project[], tasks: Task[], sprints: Sprint[]) {
console.log('>>> syncToServer: legacy bulk sync - projects/sprints only')
// Only sync projects and sprints in bulk, tasks are handled individually
try {
const res = await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projects, sprints }),
})
if (res.ok) {
console.log('>>> syncToServer: saved projects/sprints successfully')
} else {
console.error('>>> syncToServer: failed with status', res.status)
}
} catch (error) {
console.error('>>> syncToServer: Failed to sync:', error)
}
}
export const useTaskStore = create<TaskStore>()(
persist(
(set, get) => ({
projects: defaultProjects,
tasks: defaultTasks,
sprints: defaultSprints,
selectedProjectId: '1',
projects: [],
tasks: [],
sprints: [],
selectedProjectId: null,
selectedTaskId: null,
selectedSprintId: null,
currentUser: defaultCurrentUser,
isLoading: false,
lastSynced: null,
syncError: null,
syncFromServer: async () => {
console.log('>>> syncFromServer START')
set({ isLoading: true })
set({ isLoading: true, syncError: null })
try {
const res = await fetch('/api/tasks')
const res = await fetch('/api/tasks', { cache: 'no-store' })
console.log('>>> syncFromServer: API response status:', res.status)
if (res.ok) {
if (!res.ok) {
const errorPayload = await res.json().catch(() => ({}))
console.error('>>> syncFromServer: API error payload:', errorPayload)
const message = typeof errorPayload?.error === 'string'
? errorPayload.error
: `syncFromServer failed with status ${res.status}`
throw new Error(message)
}
const data = await res.json()
const serverProjects: Project[] = data.projects || []
const serverCurrentUser = normalizeUserProfile(data.currentUser, get().currentUser)
const currentSelectedProjectId = get().selectedProjectId
const nextSelectedProjectId =
currentSelectedProjectId && serverProjects.some((project) => project.id === currentSelectedProjectId)
? currentSelectedProjectId
: (serverProjects[0]?.id ?? null)
console.log('>>> syncFromServer: fetched data:', {
projectsCount: data.projects?.length,
projectsCount: serverProjects.length,
tasksCount: data.tasks?.length,
sprintsCount: data.sprints?.length,
firstTaskTitle: data.tasks?.[0]?.title,
lastUpdated: data.lastUpdated,
})
console.log('>>> syncFromServer: current store tasks count BEFORE set:', get().tasks.length)
// ALWAYS use server data if API returns successfully
set({
projects: data.projects || [],
projects: serverProjects,
tasks: (data.tasks || []).map((task: Task) => normalizeTask(task)),
sprints: data.sprints || [],
currentUser: serverCurrentUser,
selectedProjectId: nextSelectedProjectId,
syncError: null,
lastSynced: Date.now(),
})
console.log('>>> syncFromServer: store tasks count AFTER set:', get().tasks.length)
} else {
console.error('>>> syncFromServer: API returned error status:', res.status)
}
} catch (error) {
console.error('>>> syncFromServer: Failed to sync from server:', error)
// Keep local data if server fails
const message = error instanceof Error ? error.message : 'Unknown sync error'
set({
projects: [],
tasks: [],
sprints: [],
selectedProjectId: null,
selectedTaskId: null,
selectedSprintId: null,
currentUser: defaultCurrentUser,
syncError: message,
})
} finally {
set({ isLoading: false })
console.log('>>> syncFromServer END')
}
},
syncToServer: async () => {
const { projects, tasks, sprints } = get()
await syncToServer(projects, tasks, sprints)
set({ lastSynced: Date.now() })
},
setCurrentUser: (user) => {
set((state) => ({ currentUser: normalizeUserProfile(user, state.currentUser) }))
},
addProject: (name, description) => {
const colors = ['#8b5cf6', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4']
const newProject: Project = {
id: Date.now().toString(),
name,
description,
color: colors[Math.floor(Math.random() * colors.length)],
createdAt: new Date().toISOString(),
}
set((state) => {
const newState = { projects: [...state.projects, newProject] }
// Sync to server
syncToServer(newState.projects, state.tasks, state.sprints)
return newState
const color = colors[Math.floor(Math.random() * colors.length)]
void (async () => {
try {
await requestApi('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, color }),
})
await get().syncFromServer()
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create project'
console.error('>>> addProject failed:', error)
set({ syncError: message })
}
})()
},
updateProject: (id, updates) => {
set((state) => {
const newProjects = state.projects.map((p) => (p.id === id ? { ...p, ...updates } : p))
syncToServer(newProjects, state.tasks, state.sprints)
return { projects: newProjects }
const payload: Record<string, unknown> = { id }
if (updates.name !== undefined) payload.name = updates.name
if (updates.description !== undefined) payload.description = updates.description
if (updates.color !== undefined) payload.color = updates.color
void (async () => {
try {
await requestApi('/api/projects', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
await get().syncFromServer()
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to update project'
console.error('>>> updateProject failed:', error)
set({ syncError: message })
}
})()
},
deleteProject: (id) => {
set((state) => {
const newProjects = state.projects.filter((p) => p.id !== id)
const newTasks = state.tasks.filter((t) => t.projectId !== id)
const newSprints = state.sprints.filter((s) => s.projectId !== id)
syncToServer(newProjects, newTasks, newSprints)
return {
projects: newProjects,
tasks: newTasks,
sprints: newSprints,
selectedProjectId: state.selectedProjectId === id ? null : state.selectedProjectId,
}
void (async () => {
try {
await requestApi('/api/projects', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
})
await get().syncFromServer()
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to delete project'
console.error('>>> deleteProject failed:', error)
set({ syncError: message })
}
})()
},
selectProject: (id) => set({ selectedProjectId: id, selectedTaskId: null, selectedSprintId: null }),
@ -738,17 +514,21 @@ export const useTaskStore = create<TaskStore>()(
updatedById: actor.id,
updatedByName: actor.name,
updatedByAvatarUrl: actor.avatarUrl,
assigneeId: task.assigneeId || actor.id,
assigneeName: task.assigneeName || actor.name,
assigneeEmail: task.assigneeEmail || actor.email,
assigneeAvatarUrl: task.assigneeAvatarUrl || actor.avatarUrl,
assigneeId: task.assigneeId,
assigneeName: task.assigneeName,
assigneeEmail: task.assigneeEmail,
assigneeAvatarUrl: task.assigneeAvatarUrl,
comments: normalizeComments([]),
attachments: normalizeAttachments(task.attachments),
}
set((state) => {
const newTasks = [...state.tasks, newTask]
// Sync individual task to server (lightweight)
syncTaskToServer(newTask)
void (async () => {
const success = await syncTaskToServer(newTask)
if (!success) {
await get().syncFromServer()
}
})()
return { tasks: newTasks }
})
},
@ -782,6 +562,9 @@ export const useTaskStore = create<TaskStore>()(
// Sync individual task to server (lightweight)
if (updatedTask) {
syncSuccess = await syncTaskToServer(updatedTask)
if (!syncSuccess) {
await get().syncFromServer()
}
}
return syncSuccess
@ -790,8 +573,12 @@ export const useTaskStore = create<TaskStore>()(
deleteTask: (id) => {
set((state) => {
const newTasks = state.tasks.filter((t) => t.id !== id)
// Delete individual task from server (lightweight)
deleteTaskFromServer(id)
void (async () => {
const success = await deleteTaskFromServer(id)
if (!success) {
await get().syncFromServer()
}
})()
return {
tasks: newTasks,
selectedTaskId: state.selectedTaskId === id ? null : state.selectedTaskId,
@ -803,41 +590,54 @@ export const useTaskStore = create<TaskStore>()(
// Sprint actions
addSprint: (sprint) => {
const newSprint: Sprint = {
...sprint,
id: Date.now().toString(),
createdAt: new Date().toISOString(),
}
set((state) => {
const newSprints = [...state.sprints, newSprint]
syncToServer(state.projects, state.tasks, newSprints)
return { sprints: newSprints }
void (async () => {
try {
await requestApi('/api/sprints', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sprint),
})
await get().syncFromServer()
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create sprint'
console.error('>>> addSprint failed:', error)
set({ syncError: message })
}
})()
},
updateSprint: (id, updates) => {
set((state) => {
const newSprints = state.sprints.map((s) =>
s.id === id ? { ...s, ...updates } : s
)
syncToServer(state.projects, state.tasks, newSprints)
return { sprints: newSprints }
void (async () => {
try {
await requestApi('/api/sprints', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, ...updates }),
})
await get().syncFromServer()
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to update sprint'
console.error('>>> updateSprint failed:', error)
set({ syncError: message })
}
})()
},
deleteSprint: (id) => {
set((state) => {
const newSprints = state.sprints.filter((s) => s.id !== id)
const newTasks = state.tasks.map((t) =>
t.sprintId === id ? { ...t, sprintId: undefined } : t
)
syncToServer(state.projects, newTasks, newSprints)
return {
sprints: newSprints,
tasks: newTasks,
selectedSprintId: state.selectedSprintId === id ? null : state.selectedSprintId,
}
void (async () => {
try {
await requestApi('/api/sprints', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
})
await get().syncFromServer()
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to delete sprint'
console.error('>>> deleteSprint failed:', error)
set({ syncError: message })
}
})()
},
selectSprint: (id) => set({ selectedSprintId: id }),
@ -871,7 +671,12 @@ export const useTaskStore = create<TaskStore>()(
)
const updatedTask = newTasks.find(t => t.id === taskId)
if (updatedTask) {
syncTaskToServer(updatedTask)
void (async () => {
const success = await syncTaskToServer(updatedTask)
if (!success) {
await get().syncFromServer()
}
})()
}
return { tasks: newTasks }
})
@ -894,7 +699,12 @@ export const useTaskStore = create<TaskStore>()(
)
const updatedTask = newTasks.find(t => t.id === taskId)
if (updatedTask) {
syncTaskToServer(updatedTask)
void (async () => {
const success = await syncTaskToServer(updatedTask)
if (!success) {
await get().syncFromServer()
}
})()
}
return { tasks: newTasks }
})
@ -914,9 +724,26 @@ export const useTaskStore = create<TaskStore>()(
}),
{
name: 'task-store',
version: 1,
migrate: (persistedState) => {
if (!persistedState || typeof persistedState !== 'object') {
return {}
}
const state = persistedState as {
selectedProjectId?: unknown
selectedTaskId?: unknown
selectedSprintId?: unknown
}
return {
selectedProjectId: typeof state.selectedProjectId === 'string' ? state.selectedProjectId : null,
selectedTaskId: typeof state.selectedTaskId === 'string' ? state.selectedTaskId : null,
selectedSprintId: typeof state.selectedSprintId === 'string' ? state.selectedSprintId : null,
}
},
partialize: (state) => ({
// Persist user identity and UI state, not task data
currentUser: state.currentUser,
// Persist UI selection state only. All business data comes from Supabase.
selectedProjectId: state.selectedProjectId,
selectedTaskId: state.selectedTaskId,
selectedSprintId: state.selectedSprintId,
@ -928,10 +755,9 @@ export const useTaskStore = create<TaskStore>()(
console.log('>>> PERSIST: Rehydration error:', error)
} else {
console.log('>>> PERSIST: Rehydrated state:', {
currentUser: state?.currentUser?.name,
selectedProjectId: state?.selectedProjectId,
selectedTaskId: state?.selectedTaskId,
tasksCount: state?.tasks?.length,
selectedSprintId: state?.selectedSprintId,
})
}
}

View File

@ -9,17 +9,14 @@ CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ============================================
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
legacy_id TEXT UNIQUE, -- For migration from SQLite
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
avatar_url TEXT,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Create index on email for faster lookups
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_legacy_id ON users(legacy_id);
-- Enable RLS
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
@ -32,6 +29,17 @@ CREATE POLICY "Users can read own data" ON users
CREATE POLICY "Users can update own data" ON users
FOR UPDATE USING (auth.uid() = id);
-- ============================================
-- PROFILES TABLE
-- ============================================
CREATE TABLE IF NOT EXISTS profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id),
name TEXT,
avatar_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================
-- SESSIONS TABLE
-- ============================================
@ -83,16 +91,12 @@ CREATE POLICY "Service role manages reset tokens" ON password_reset_tokens
-- ============================================
CREATE TABLE IF NOT EXISTS projects (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
legacy_id TEXT UNIQUE, -- For migration from SQLite
name TEXT NOT NULL,
description TEXT,
color TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Create index for legacy ID lookups
CREATE INDEX IF NOT EXISTS idx_projects_legacy_id ON projects(legacy_id);
-- Enable RLS
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
@ -117,7 +121,6 @@ CREATE POLICY "Authenticated users can delete projects" ON projects
-- ============================================
CREATE TABLE IF NOT EXISTS sprints (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
legacy_id TEXT UNIQUE, -- For migration from SQLite
name TEXT NOT NULL,
goal TEXT,
start_date DATE NOT NULL,
@ -129,7 +132,6 @@ CREATE TABLE IF NOT EXISTS sprints (
-- Create indexes
CREATE INDEX IF NOT EXISTS idx_sprints_project_id ON sprints(project_id);
CREATE INDEX IF NOT EXISTS idx_sprints_legacy_id ON sprints(legacy_id);
CREATE INDEX IF NOT EXISTS idx_sprints_dates ON sprints(start_date, end_date);
-- Enable RLS
@ -144,7 +146,6 @@ CREATE POLICY "Authenticated users can manage sprints" ON sprints
-- ============================================
CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
legacy_id TEXT UNIQUE, -- For migration from SQLite
title TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL CHECK (type IN ('idea', 'task', 'bug', 'research', 'plan')),
@ -155,15 +156,8 @@ CREATE TABLE IF NOT EXISTS tasks (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_by_name TEXT,
created_by_avatar_url TEXT,
updated_by_id UUID REFERENCES users(id) ON DELETE SET NULL,
updated_by_name TEXT,
updated_by_avatar_url TEXT,
assignee_id UUID REFERENCES users(id) ON DELETE SET NULL,
assignee_name TEXT,
assignee_email TEXT,
assignee_avatar_url TEXT,
due_date DATE,
comments JSONB NOT NULL DEFAULT '[]'::jsonb,
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
@ -177,7 +171,6 @@ CREATE INDEX IF NOT EXISTS idx_tasks_assignee_id ON tasks(assignee_id);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority);
CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date);
CREATE INDEX IF NOT EXISTS idx_tasks_legacy_id ON tasks(legacy_id);
CREATE INDEX IF NOT EXISTS idx_tasks_updated_at ON tasks(updated_at DESC);
-- Create trigger to auto-update updated_at
@ -202,26 +195,6 @@ ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Authenticated users can manage tasks" ON tasks
FOR ALL USING (auth.role() = 'authenticated');
-- ============================================
-- META TABLE (for app state like lastUpdated)
-- ============================================
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Enable RLS
ALTER TABLE meta ENABLE ROW LEVEL SECURITY;
-- Policy: All authenticated users can manage meta
CREATE POLICY "Authenticated users can manage meta" ON meta
FOR ALL USING (auth.role() = 'authenticated');
-- Insert initial lastUpdated value
INSERT INTO meta (key, value) VALUES ('lastUpdated', extract(epoch from now()) * 1000)
ON CONFLICT (key) DO UPDATE SET value = excluded.value;
-- ============================================
-- FUNCTIONS
-- ============================================