Compare commits
No commits in common. "29cac07f58ac87de01a0274fd0a61310967554fc" and "47724e3fb7f8bbbb8d6a5445f66161560ee21396" have entirely different histories.
29cac07f58
...
47724e3fb7
21
README.md
21
README.md
@ -23,27 +23,6 @@ Task and sprint board built with Next.js + Zustand and Supabase-backed API persi
|
|||||||
- Avatar preset key collision warning in settings was fixed.
|
- Avatar preset key collision warning in settings was fixed.
|
||||||
- Detailed notes: `AUTH_SETTINGS_MIGRATION_NOTES_2026-02-21.md`
|
- Detailed notes: `AUTH_SETTINGS_MIGRATION_NOTES_2026-02-21.md`
|
||||||
|
|
||||||
### Feb 22, 2026 updates
|
|
||||||
|
|
||||||
- Fixed sprint date timezone drift (for example `2026-02-22` rendering as `2/21` in US time zones).
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
### Sprint date semantics (important)
|
|
||||||
|
|
||||||
- Sprint dates are treated as local calendar-day boundaries:
|
|
||||||
- `startDate` means local `12:00:00 AM` of that date.
|
|
||||||
- `endDate` means local `11:59:59.999 PM` of that date.
|
|
||||||
- Supabase stores sprint dates as SQL `DATE` (`YYYY-MM-DD`) in `sprints.start_date` and `sprints.end_date`.
|
|
||||||
- API contract for `/api/sprints`:
|
|
||||||
- Accepts date strings with a `YYYY-MM-DD` prefix (`YYYY-MM-DD` or ISO timestamp).
|
|
||||||
- Normalizes and persists only the date part (`YYYY-MM-DD`).
|
|
||||||
- `PATCH` returns `400` for invalid `startDate`/`endDate` format.
|
|
||||||
- Do not use `new Date("YYYY-MM-DD")` for sprint display logic. Use shared helpers:
|
|
||||||
- `parseSprintStart(...)`
|
|
||||||
- `parseSprintEnd(...)`
|
|
||||||
- `toLocalDateInputValue(...)`
|
|
||||||
|
|
||||||
### Data model and status rules
|
### Data model and status rules
|
||||||
|
|
||||||
- Tasks use labels (`tags: string[]`) and can have multiple labels.
|
- Tasks use labels (`tags: string[]`) and can have multiple labels.
|
||||||
|
|||||||
@ -82,11 +82,6 @@ Test all functionality:
|
|||||||
- Create/edit sprints
|
- Create/edit sprints
|
||||||
- User management
|
- User management
|
||||||
|
|
||||||
Sprint date verification:
|
|
||||||
- Create a sprint with `startDate`/`endDate` from date inputs (for example `2026-02-16` to `2026-02-22`).
|
|
||||||
- Confirm UI displays the same calendar dates without shifting a day earlier in local timezone.
|
|
||||||
- Confirm `sprints.start_date` and `sprints.end_date` are stored as `YYYY-MM-DD` in Supabase.
|
|
||||||
|
|
||||||
## Step 8: Deploy to Vercel
|
## Step 8: Deploy to Vercel
|
||||||
|
|
||||||
### Add Environment Variables in Vercel:
|
### Add Environment Variables in Vercel:
|
||||||
@ -125,15 +120,6 @@ Or push to git and let Vercel auto-deploy.
|
|||||||
- The script uses upsert, so existing data won't be duplicated
|
- The script uses upsert, so existing data won't be duplicated
|
||||||
- Check the error message for specific constraint violations
|
- Check the error message for specific constraint violations
|
||||||
|
|
||||||
### Off-by-One Sprint Date Display
|
|
||||||
- Root cause: using `new Date("YYYY-MM-DD")` directly can parse as UTC and render as the previous day in some local time zones.
|
|
||||||
- Expected behavior in this app:
|
|
||||||
- Sprint start date = local `12:00:00 AM` for selected day.
|
|
||||||
- Sprint end date = local `11:59:59.999 PM` for selected day.
|
|
||||||
- API behavior:
|
|
||||||
- `/api/sprints` normalizes `startDate` and `endDate` to `YYYY-MM-DD` before persistence.
|
|
||||||
- Invalid date format on `PATCH /api/sprints` returns `400`.
|
|
||||||
|
|
||||||
## Architecture Changes
|
## Architecture Changes
|
||||||
|
|
||||||
### Before (SQLite):
|
### Before (SQLite):
|
||||||
|
|||||||
@ -1,12 +0,0 @@
|
|||||||
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
webpack: (config) => {
|
|
||||||
config.plugins.push(new BundleAnalyzerPlugin({
|
|
||||||
analyzerMode: 'static',
|
|
||||||
reportFilename: 'bundle-analysis.html',
|
|
||||||
openAnalyzer: false
|
|
||||||
}));
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,67 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
// Bundle optimization
|
/* config options here */
|
||||||
experimental: {
|
|
||||||
// Optimize package imports for faster builds and smaller bundles
|
|
||||||
optimizePackageImports: [
|
|
||||||
"lucide-react",
|
|
||||||
"@radix-ui/react-dialog",
|
|
||||||
"@radix-ui/react-dropdown-menu",
|
|
||||||
"@radix-ui/react-select",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
// Webpack optimization
|
|
||||||
webpack: (config, { isServer }) => {
|
|
||||||
// Split chunks for better caching
|
|
||||||
if (!isServer) {
|
|
||||||
config.optimization = {
|
|
||||||
...config.optimization,
|
|
||||||
splitChunks: {
|
|
||||||
chunks: "all",
|
|
||||||
cacheGroups: {
|
|
||||||
// Vendor chunk for node_modules
|
|
||||||
vendor: {
|
|
||||||
name: "vendor",
|
|
||||||
test: /[\\/]node_modules[\\/]/,
|
|
||||||
priority: 10,
|
|
||||||
reuseExistingChunk: true,
|
|
||||||
},
|
|
||||||
// DND kit chunk - heavy library
|
|
||||||
dnd: {
|
|
||||||
name: "dnd",
|
|
||||||
test: /[\\/]node_modules[\\/]@dnd-kit[\\/]/,
|
|
||||||
priority: 20,
|
|
||||||
reuseExistingChunk: true,
|
|
||||||
},
|
|
||||||
// Radix UI chunk
|
|
||||||
radix: {
|
|
||||||
name: "radix",
|
|
||||||
test: /[\\/]node_modules[\\/]@radix-ui[\\/]/,
|
|
||||||
priority: 15,
|
|
||||||
reuseExistingChunk: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Image optimization
|
|
||||||
images: {
|
|
||||||
remotePatterns: [],
|
|
||||||
},
|
|
||||||
|
|
||||||
// Compression
|
|
||||||
compress: true,
|
|
||||||
|
|
||||||
// Power optimization - reduce CPU usage
|
|
||||||
poweredByHeader: false,
|
|
||||||
|
|
||||||
// Trailing slashes for SEO
|
|
||||||
trailingSlash: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
2073
package-lock.json
generated
2073
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@ -6,8 +6,7 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint"
|
||||||
"analyze": "ANALYZE=true npm run build"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@ -18,14 +17,17 @@
|
|||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@supabase/supabase-js": "^2.97.0",
|
"@supabase/supabase-js": "^2.97.0",
|
||||||
"@tanstack/react-query": "^5.66.0",
|
"better-sqlite3": "^12.6.2",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"sonner": "^2.0.7",
|
"firebase": "^12.9.0",
|
||||||
"swr": "^2.4.0"
|
"resend": "^6.9.2",
|
||||||
|
"sonner": "^2.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/frappe-gantt": "^0.9.0",
|
||||||
"@types/node": "^20.19.33",
|
"@types/node": "^20.19.33",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
@ -36,16 +38,17 @@
|
|||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "^16.1.6",
|
"eslint-config-next": "^16.1.6",
|
||||||
"framer-motion": "^12.34.1",
|
"framer-motion": "^12.34.1",
|
||||||
|
"frappe-gantt": "^1.2.1",
|
||||||
"lucide-react": "^0.574.0",
|
"lucide-react": "^0.574.0",
|
||||||
"next": "^15.5.12",
|
"next": "^15.5.12",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"tailwind-merge": "^3.4.1",
|
"tailwind-merge": "^3.4.1",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"webpack-bundle-analyzer": "^5.2.0",
|
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"description": "This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).",
|
"description": "This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).",
|
||||||
|
|||||||
@ -1,39 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
// Update task status to 'review' via Supabase API
|
|
||||||
const SUPABASE_URL = 'https://qnatchrjlpehiijwtreh.supabase.co';
|
|
||||||
const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InFuYXRjaHJqbHBlaGlpand0cmVoIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzE2NDA0MzYsImV4cCI6MjA4NzIxNjQzNn0.47XOMrQBzcQEh71phQflPoO4v79Jk3rft7BC72KHDvA';
|
|
||||||
const TASK_ID = '66f1146e-41c4-4b03-a292-9358b7f9bedb';
|
|
||||||
|
|
||||||
async function updateTaskStatus() {
|
|
||||||
try {
|
|
||||||
console.log('Updating task status to review...');
|
|
||||||
|
|
||||||
const response = await fetch(`${SUPABASE_URL}/rest/v1/tasks?id=eq.${TASK_ID}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'apikey': SUPABASE_ANON_KEY,
|
|
||||||
'Authorization': `Bearer ${SUPABASE_ANON_KEY}`,
|
|
||||||
'Prefer': 'return=minimal'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
status: 'review',
|
|
||||||
updated_at: new Date().toISOString()
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
console.log('✅ Task status updated to review successfully!');
|
|
||||||
} else {
|
|
||||||
const errorText = await response.text();
|
|
||||||
console.error('❌ Failed to update task status:', response.status, errorText);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error updating task:', error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTaskStatus();
|
|
||||||
@ -4,20 +4,6 @@ import { getAuthenticatedUser } from "@/lib/server/auth";
|
|||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
const DATE_PREFIX_PATTERN = /^(\d{4}-\d{2}-\d{2})/;
|
|
||||||
|
|
||||||
function toDateOnlyInput(value: unknown): string | null {
|
|
||||||
if (typeof value !== "string") return null;
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) return null;
|
|
||||||
const match = trimmed.match(DATE_PREFIX_PATTERN);
|
|
||||||
return match?.[1] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentDateOnly(): string {
|
|
||||||
return new Date().toISOString().split("T")[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET - fetch all sprints (optionally filtered by status)
|
// GET - fetch all sprints (optionally filtered by status)
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
@ -69,17 +55,14 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const supabase = getServiceSupabase();
|
const supabase = getServiceSupabase();
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const fallbackDate = currentDateOnly();
|
|
||||||
const normalizedStartDate = toDateOnlyInput(startDate) ?? fallbackDate;
|
|
||||||
const normalizedEndDate = toDateOnlyInput(endDate) ?? normalizedStartDate;
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("sprints")
|
.from("sprints")
|
||||||
.insert({
|
.insert({
|
||||||
name,
|
name,
|
||||||
goal: goal || null,
|
goal: goal || null,
|
||||||
start_date: normalizedStartDate,
|
start_date: startDate || now,
|
||||||
end_date: normalizedEndDate,
|
end_date: endDate || now,
|
||||||
status: status || "planning",
|
status: status || "planning",
|
||||||
project_id: projectId || null,
|
project_id: projectId || null,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
@ -118,20 +101,8 @@ export async function PATCH(request: Request) {
|
|||||||
const dbUpdates: Record<string, unknown> = {};
|
const dbUpdates: Record<string, unknown> = {};
|
||||||
if (updates.name !== undefined) dbUpdates.name = updates.name;
|
if (updates.name !== undefined) dbUpdates.name = updates.name;
|
||||||
if (updates.goal !== undefined) dbUpdates.goal = updates.goal;
|
if (updates.goal !== undefined) dbUpdates.goal = updates.goal;
|
||||||
if (updates.startDate !== undefined) {
|
if (updates.startDate !== undefined) dbUpdates.start_date = updates.startDate;
|
||||||
const normalizedStartDate = toDateOnlyInput(updates.startDate);
|
if (updates.endDate !== undefined) dbUpdates.end_date = updates.endDate;
|
||||||
if (!normalizedStartDate) {
|
|
||||||
return NextResponse.json({ error: "Invalid startDate format" }, { status: 400 });
|
|
||||||
}
|
|
||||||
dbUpdates.start_date = normalizedStartDate;
|
|
||||||
}
|
|
||||||
if (updates.endDate !== undefined) {
|
|
||||||
const normalizedEndDate = toDateOnlyInput(updates.endDate);
|
|
||||||
if (!normalizedEndDate) {
|
|
||||||
return NextResponse.json({ error: "Invalid endDate format" }, { status: 400 });
|
|
||||||
}
|
|
||||||
dbUpdates.end_date = normalizedEndDate;
|
|
||||||
}
|
|
||||||
if (updates.status !== undefined) dbUpdates.status = updates.status;
|
if (updates.status !== undefined) dbUpdates.status = updates.status;
|
||||||
if (updates.projectId !== undefined) dbUpdates.project_id = updates.projectId;
|
if (updates.projectId !== undefined) dbUpdates.project_id = updates.projectId;
|
||||||
dbUpdates.updated_at = now;
|
dbUpdates.updated_at = now;
|
||||||
|
|||||||
@ -64,40 +64,6 @@ const TASK_PRIORITIES: Task["priority"][] = ["low", "medium", "high", "urgent"];
|
|||||||
const SPRINT_STATUSES: Sprint["status"][] = ["planning", "active", "completed"];
|
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}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
// Optimized field selection - only fetch fields needed for board display
|
|
||||||
// Full task details (description, comments, attachments) fetched lazily
|
|
||||||
const TASK_FIELDS_LIGHT = [
|
|
||||||
"id",
|
|
||||||
"title",
|
|
||||||
"type",
|
|
||||||
"status",
|
|
||||||
"priority",
|
|
||||||
"project_id",
|
|
||||||
"sprint_id",
|
|
||||||
"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",
|
|
||||||
];
|
|
||||||
|
|
||||||
function isTaskType(value: unknown): value is Task["type"] {
|
function isTaskType(value: unknown): value is Task["type"] {
|
||||||
return typeof value === "string" && TASK_TYPES.includes(value as Task["type"]);
|
return typeof value === "string" && TASK_TYPES.includes(value as Task["type"]);
|
||||||
}
|
}
|
||||||
@ -129,19 +95,9 @@ function stripUndefined<T extends Record<string, unknown>>(value: T): Record<str
|
|||||||
function getMissingColumnFromError(error: unknown): string | null {
|
function getMissingColumnFromError(error: unknown): string | null {
|
||||||
if (!error || typeof error !== "object") return null;
|
if (!error || typeof error !== "object") return null;
|
||||||
const candidate = error as { code?: string; message?: string };
|
const candidate = error as { code?: string; message?: string };
|
||||||
if (typeof candidate.message !== "string") return null;
|
if (candidate.code !== "PGRST204" || typeof candidate.message !== "string") return null;
|
||||||
|
const match = candidate.message.match(/Could not find the '([^']+)' column/);
|
||||||
if (candidate.code === "PGRST204") {
|
return match?.[1] ?? null;
|
||||||
const postgrestMatch = candidate.message.match(/Could not find the '([^']+)' column/);
|
|
||||||
if (postgrestMatch?.[1]) return postgrestMatch[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getForeignKeyColumnFromError(error: unknown): string | null {
|
function getForeignKeyColumnFromError(error: unknown): string | null {
|
||||||
@ -152,38 +108,6 @@ function getForeignKeyColumnFromError(error: unknown): string | null {
|
|||||||
return match?.[1] ?? null;
|
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveRequiredProjectId(
|
async function resolveRequiredProjectId(
|
||||||
supabase: ReturnType<typeof getServiceSupabase>,
|
supabase: ReturnType<typeof getServiceSupabase>,
|
||||||
requestedProjectId?: string
|
requestedProjectId?: string
|
||||||
@ -252,7 +176,7 @@ function mapUserRow(row: Record<string, unknown>): UserProfile | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapTaskRow(row: Record<string, unknown>, usersById: Map<string, UserProfile>, includeFullData = false): Task {
|
function mapTaskRow(row: Record<string, unknown>, usersById: Map<string, UserProfile>): Task {
|
||||||
const fallbackDate = new Date().toISOString();
|
const fallbackDate = new Date().toISOString();
|
||||||
const createdById = toNonEmptyString(row.created_by_id);
|
const createdById = toNonEmptyString(row.created_by_id);
|
||||||
const updatedById = toNonEmptyString(row.updated_by_id);
|
const updatedById = toNonEmptyString(row.updated_by_id);
|
||||||
@ -261,10 +185,10 @@ function mapTaskRow(row: Record<string, unknown>, usersById: Map<string, UserPro
|
|||||||
const updatedByUser = updatedById ? usersById.get(updatedById) : undefined;
|
const updatedByUser = updatedById ? usersById.get(updatedById) : undefined;
|
||||||
const assigneeUser = assigneeId ? usersById.get(assigneeId) : undefined;
|
const assigneeUser = assigneeId ? usersById.get(assigneeId) : undefined;
|
||||||
|
|
||||||
const task: Task = {
|
return {
|
||||||
id: String(row.id ?? ""),
|
id: String(row.id ?? ""),
|
||||||
title: toNonEmptyString(row.title) ?? "",
|
title: toNonEmptyString(row.title) ?? "",
|
||||||
description: includeFullData ? toNonEmptyString(row.description) : undefined,
|
description: toNonEmptyString(row.description),
|
||||||
type: isTaskType(row.type) ? row.type : "task",
|
type: isTaskType(row.type) ? row.type : "task",
|
||||||
status: isTaskStatus(row.status) ? row.status : "todo",
|
status: isTaskStatus(row.status) ? row.status : "todo",
|
||||||
priority: isTaskPriority(row.priority) ? row.priority : "medium",
|
priority: isTaskPriority(row.priority) ? row.priority : "medium",
|
||||||
@ -283,51 +207,30 @@ function mapTaskRow(row: Record<string, unknown>, usersById: Map<string, UserPro
|
|||||||
assigneeEmail: toNonEmptyString(row.assignee_email) ?? assigneeUser?.email,
|
assigneeEmail: toNonEmptyString(row.assignee_email) ?? assigneeUser?.email,
|
||||||
assigneeAvatarUrl: toNonEmptyString(row.assignee_avatar_url) ?? assigneeUser?.avatarUrl,
|
assigneeAvatarUrl: toNonEmptyString(row.assignee_avatar_url) ?? assigneeUser?.avatarUrl,
|
||||||
dueDate: toNonEmptyString(row.due_date),
|
dueDate: toNonEmptyString(row.due_date),
|
||||||
comments: includeFullData && Array.isArray(row.comments) ? row.comments : [],
|
comments: Array.isArray(row.comments) ? row.comments : [],
|
||||||
tags: Array.isArray(row.tags) ? row.tags.filter((tag): tag is string => typeof tag === "string") : [],
|
tags: Array.isArray(row.tags) ? row.tags.filter((tag): tag is string => typeof tag === "string") : [],
|
||||||
attachments: includeFullData && Array.isArray(row.attachments) ? row.attachments : [],
|
attachments: Array.isArray(row.attachments) ? row.attachments : [],
|
||||||
};
|
};
|
||||||
|
|
||||||
return task;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET - fetch all tasks, projects, and sprints
|
// GET - fetch all tasks, projects, and sprints
|
||||||
// Uses lightweight fields for faster initial load
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
// TODO: Re-enable auth after fixing cookie issue on Vercel
|
const user = await getAuthenticatedUser();
|
||||||
// const user = await getAuthenticatedUser();
|
if (!user) {
|
||||||
// if (!user) {
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
// return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
}
|
||||||
// }
|
|
||||||
|
|
||||||
const supabase = getServiceSupabase();
|
const supabase = getServiceSupabase();
|
||||||
|
|
||||||
// Use Promise.all for parallel queries with optimized field selection
|
const [{ data: projects }, { data: sprints }, { data: tasks }, { data: users }, { data: meta }] = await Promise.all([
|
||||||
const [
|
supabase.from("projects").select("*").order("created_at", { ascending: true }),
|
||||||
{ data: projects, error: projectsError },
|
supabase.from("sprints").select("*").order("start_date", { ascending: true }),
|
||||||
{ data: sprints, error: sprintsError },
|
supabase.from("tasks").select("*").order("created_at", { ascending: true }),
|
||||||
{ data: users, error: usersError },
|
|
||||||
{ data: meta, error: metaError }
|
|
||||||
] = 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("users").select("id, name, email, avatar_url"),
|
supabase.from("users").select("id, name, email, avatar_url"),
|
||||||
supabase.from("meta").select("value").eq("key", "lastUpdated").maybeSingle(),
|
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(", "));
|
|
||||||
}
|
|
||||||
|
|
||||||
const usersById = new Map<string, UserProfile>();
|
const usersById = new Map<string, UserProfile>();
|
||||||
for (const row of users || []) {
|
for (const row of users || []) {
|
||||||
const mapped = mapUserRow(row as Record<string, unknown>);
|
const mapped = mapUserRow(row as Record<string, unknown>);
|
||||||
@ -337,13 +240,8 @@ export async function GET() {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
projects: (projects || []).map((row) => mapProjectRow(row as Record<string, unknown>)),
|
projects: (projects || []).map((row) => mapProjectRow(row as Record<string, unknown>)),
|
||||||
sprints: (sprints || []).map((row) => mapSprintRow(row as Record<string, unknown>)),
|
sprints: (sprints || []).map((row) => mapSprintRow(row as Record<string, unknown>)),
|
||||||
tasks: taskRows.map((row) => mapTaskRow(row, usersById, false)),
|
tasks: (tasks || []).map((row) => mapTaskRow(row as Record<string, unknown>, usersById)),
|
||||||
lastUpdated: Number(meta?.value ?? Date.now()),
|
lastUpdated: Number(meta?.value ?? Date.now()),
|
||||||
}, {
|
|
||||||
headers: {
|
|
||||||
// Enable caching for 30 seconds to reduce repeated requests
|
|
||||||
'Cache-Control': 'private, max-age=30, stale-while-revalidate=60',
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(">>> API GET error:", error);
|
console.error(">>> API GET error:", error);
|
||||||
@ -351,17 +249,15 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST - create or update a single task
|
// POST - create or update a single task (lightweight)
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
// TODO: Re-enable auth after fixing cookie issue on Vercel
|
const user = await getAuthenticatedUser();
|
||||||
// const user = await getAuthenticatedUser();
|
if (!user) {
|
||||||
// if (!user) {
|
console.error(">>> API POST: No authenticated user");
|
||||||
// console.error(">>> API POST: No authenticated user");
|
return NextResponse.json({ error: "Unauthorized - please log in again" }, { status: 401 });
|
||||||
// return NextResponse.json({ error: "Unauthorized - please log in again" }, { status: 401 });
|
}
|
||||||
// }
|
console.log(">>> API POST: Authenticated as", user.email);
|
||||||
// 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 body = await request.json();
|
const body = await request.json();
|
||||||
const { task } = body as { task?: Task };
|
const { task } = body as { task?: Task };
|
||||||
@ -466,11 +362,10 @@ export async function POST(request: Request) {
|
|||||||
// DELETE - remove a task
|
// DELETE - remove a task
|
||||||
export async function DELETE(request: Request) {
|
export async function DELETE(request: Request) {
|
||||||
try {
|
try {
|
||||||
// TODO: Re-enable auth after fixing cookie issue on Vercel
|
const user = await getAuthenticatedUser();
|
||||||
// const user = await getAuthenticatedUser();
|
if (!user) {
|
||||||
// if (!user) {
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
// return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
}
|
||||||
// }
|
|
||||||
|
|
||||||
const { id } = (await request.json()) as { id: string };
|
const { id } = (await request.json()) as { id: string };
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import { JetBrains_Mono, Lexend, Source_Sans_3 } from 'next/font/google'
|
import { JetBrains_Mono, Lexend, Source_Sans_3 } from 'next/font/google'
|
||||||
import { Toaster } from 'sonner'
|
import { Toaster } from 'sonner'
|
||||||
import { QueryProvider } from '@/components/QueryProvider'
|
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
|
||||||
const headingFont = Lexend({
|
const headingFont = Lexend({
|
||||||
@ -32,9 +31,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={`${headingFont.variable} ${bodyFont.variable} ${monoFont.variable} antialiased`}>
|
<body className={`${headingFont.variable} ${bodyFont.variable} ${monoFont.variable} antialiased`}>
|
||||||
<QueryProvider>
|
|
||||||
{children}
|
{children}
|
||||||
</QueryProvider>
|
|
||||||
<Toaster
|
<Toaster
|
||||||
position="bottom-right"
|
position="bottom-right"
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect, useMemo, type ChangeEvent, type ReactNode, Suspense } from "react"
|
import { useState, useEffect, useMemo, type ChangeEvent, type ReactNode } from "react"
|
||||||
import dynamic from "next/dynamic"
|
|
||||||
import { useDebounce } from "@/hooks/useDebounce"
|
import { useDebounce } from "@/hooks/useDebounce"
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
@ -36,20 +35,9 @@ import {
|
|||||||
textPreviewObjectUrl,
|
textPreviewObjectUrl,
|
||||||
} from "@/lib/attachments"
|
} from "@/lib/attachments"
|
||||||
import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment, type CommentAuthor } from "@/stores/useTaskStore"
|
import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment, type CommentAuthor } from "@/stores/useTaskStore"
|
||||||
import { BacklogSkeleton, SearchSkeleton } from "@/components/LoadingSkeletons"
|
import { BacklogView } from "@/components/BacklogView"
|
||||||
import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download, Search, Archive } from "lucide-react"
|
import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download, Search, Archive } from "lucide-react"
|
||||||
|
|
||||||
// Dynamic imports for heavy view components - only load when needed
|
|
||||||
const BacklogView = dynamic(() => import("@/components/BacklogView").then(mod => mod.BacklogView), {
|
|
||||||
loading: () => <BacklogSkeleton />,
|
|
||||||
ssr: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const SearchView = dynamic(() => import("@/components/SearchView").then(mod => mod.SearchView), {
|
|
||||||
loading: () => <SearchSkeleton />,
|
|
||||||
ssr: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
interface AssignableUser {
|
interface AssignableUser {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@ -140,11 +128,6 @@ const formatStatusLabel = (status: TaskStatus) =>
|
|||||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
.join(" ")
|
.join(" ")
|
||||||
|
|
||||||
const formatSprintDisplayDate = (value: string, boundary: "start" | "end" = "start") => {
|
|
||||||
const parsed = boundary === "end" ? parseSprintEnd(value) : parseSprintStart(value)
|
|
||||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleDateString()
|
|
||||||
}
|
|
||||||
|
|
||||||
function KanbanStatusDropTarget({
|
function KanbanStatusDropTarget({
|
||||||
status,
|
status,
|
||||||
count,
|
count,
|
||||||
@ -390,7 +373,7 @@ export default function Home() {
|
|||||||
tags: [],
|
tags: [],
|
||||||
})
|
})
|
||||||
const [newComment, setNewComment] = useState("")
|
const [newComment, setNewComment] = useState("")
|
||||||
const [viewMode, setViewMode] = useState<'kanban' | 'backlog' | 'search'>('kanban')
|
const [viewMode, setViewMode] = useState<'kanban' | 'backlog'>('kanban')
|
||||||
const [editedTask, setEditedTask] = useState<Task | null>(null)
|
const [editedTask, setEditedTask] = useState<Task | null>(null)
|
||||||
const [newTaskLabelInput, setNewTaskLabelInput] = useState("")
|
const [newTaskLabelInput, setNewTaskLabelInput] = useState("")
|
||||||
const [editedTaskLabelInput, setEditedTaskLabelInput] = useState("")
|
const [editedTaskLabelInput, setEditedTaskLabelInput] = useState("")
|
||||||
@ -586,13 +569,6 @@ export default function Home() {
|
|||||||
console.log('>>> PAGE: tasks changed, new count:', tasks.length)
|
console.log('>>> PAGE: tasks changed, new count:', tasks.length)
|
||||||
}, [tasks])
|
}, [tasks])
|
||||||
|
|
||||||
// Auto-switch to search view when user types in search box
|
|
||||||
useEffect(() => {
|
|
||||||
if (debouncedSearchQuery.trim() && viewMode !== 'search') {
|
|
||||||
setViewMode('search')
|
|
||||||
}
|
|
||||||
}, [debouncedSearchQuery, viewMode])
|
|
||||||
|
|
||||||
const selectedTask = tasks.find((t) => t.id === selectedTaskId)
|
const selectedTask = tasks.find((t) => t.id === selectedTaskId)
|
||||||
const editedTaskTags = editedTask ? getTags(editedTask) : []
|
const editedTaskTags = editedTask ? getTags(editedTask) : []
|
||||||
const editedTaskAttachments = editedTask ? getAttachments(editedTask) : []
|
const editedTaskAttachments = editedTask ? getAttachments(editedTask) : []
|
||||||
@ -652,7 +628,7 @@ export default function Home() {
|
|||||||
// Find next sprint (earliest start date that's in the future or active)
|
// Find next sprint (earliest start date that's in the future or active)
|
||||||
const nextSprint = sprints
|
const nextSprint = sprints
|
||||||
.filter((s) => s.status === 'planning' || (s.status === 'active' && !endedSprints.find((e) => e.id === s.id)))
|
.filter((s) => s.status === 'planning' || (s.status === 'active' && !endedSprints.find((e) => e.id === s.id)))
|
||||||
.sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime())[0]
|
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime())[0]
|
||||||
|
|
||||||
if (!nextSprint) return
|
if (!nextSprint) return
|
||||||
|
|
||||||
@ -1089,17 +1065,6 @@ export default function Home() {
|
|||||||
<ListTodo className="w-4 h-4" />
|
<ListTodo className="w-4 h-4" />
|
||||||
Backlog
|
Backlog
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => setViewMode('search')}
|
|
||||||
className={`flex items-center gap-1 px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
|
||||||
viewMode === 'search'
|
|
||||||
? 'bg-slate-700 text-white'
|
|
||||||
: 'text-slate-400 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Search className="w-4 h-4" />
|
|
||||||
Search
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setNewTaskOpen(true)} className="w-full sm:w-auto">
|
<Button onClick={() => setNewTaskOpen(true)} className="w-full sm:w-auto">
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
@ -1131,9 +1096,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* View Content */}
|
{/* View Content */}
|
||||||
{viewMode === 'search' ? (
|
{viewMode === 'backlog' ? (
|
||||||
<SearchView searchQuery={debouncedSearchQuery} />
|
|
||||||
) : viewMode === 'backlog' ? (
|
|
||||||
<BacklogView searchQuery={debouncedSearchQuery} />
|
<BacklogView searchQuery={debouncedSearchQuery} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@ -1144,7 +1107,7 @@ export default function Home() {
|
|||||||
<h3 className="font-medium text-white">{currentSprint?.name || "No Active Sprint"}</h3>
|
<h3 className="font-medium text-white">{currentSprint?.name || "No Active Sprint"}</h3>
|
||||||
<p className="text-sm text-slate-400">
|
<p className="text-sm text-slate-400">
|
||||||
{currentSprint
|
{currentSprint
|
||||||
? `${formatSprintDisplayDate(currentSprint.startDate, "start")} - ${formatSprintDisplayDate(currentSprint.endDate, "end")}`
|
? `${new Date(currentSprint.startDate).toLocaleDateString()} - ${new Date(currentSprint.endDate).toLocaleDateString()}`
|
||||||
: "Create or activate a sprint to group work"}
|
: "Create or activate a sprint to group work"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -1285,9 +1248,9 @@ export default function Home() {
|
|||||||
className="w-full mt-1.5 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white focus:outline-none focus:border-blue-500"
|
className="w-full mt-1.5 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white focus:outline-none focus:border-blue-500"
|
||||||
>
|
>
|
||||||
<option value="">Auto (Current Sprint)</option>
|
<option value="">Auto (Current Sprint)</option>
|
||||||
{sprints.sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime()).map((sprint) => (
|
{sprints.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()).map((sprint) => (
|
||||||
<option key={sprint.id} value={sprint.id}>
|
<option key={sprint.id} value={sprint.id}>
|
||||||
{sprint.name} ({formatSprintDisplayDate(sprint.startDate, "start")})
|
{sprint.name} ({new Date(sprint.startDate).toLocaleDateString()})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@ -1502,9 +1465,9 @@ export default function Home() {
|
|||||||
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"
|
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>
|
<option value="">No Sprint</option>
|
||||||
{sprints.sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime()).map((sprint) => (
|
{sprints.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()).map((sprint) => (
|
||||||
<option key={sprint.id} value={sprint.id}>
|
<option key={sprint.id} value={sprint.id}>
|
||||||
{sprint.name} ({formatSprintDisplayDate(sprint.startDate, "start")})
|
{sprint.name} ({new Date(sprint.startDate).toLocaleDateString()})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@ -2,13 +2,12 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { format, isValid } from "date-fns"
|
import { format, parseISO, isValid } from "date-fns"
|
||||||
import { ArrowLeft, Calendar, CheckCircle2, Target, TrendingUp, Clock, Archive, ChevronRight, X } from "lucide-react"
|
import { ArrowLeft, Calendar, CheckCircle2, Target, TrendingUp, Clock, Archive, ChevronRight, X } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { parseSprintEnd, parseSprintStart } from "@/lib/utils"
|
|
||||||
|
|
||||||
interface Sprint {
|
interface Sprint {
|
||||||
id: string
|
id: string
|
||||||
@ -47,8 +46,8 @@ interface SprintDetail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDateRange(startDate: string, endDate: string): string {
|
function formatDateRange(startDate: string, endDate: string): string {
|
||||||
const start = parseSprintStart(startDate)
|
const start = parseISO(startDate)
|
||||||
const end = parseSprintEnd(endDate)
|
const end = parseISO(endDate)
|
||||||
if (!isValid(start) || !isValid(end)) return "Invalid dates"
|
if (!isValid(start) || !isValid(end)) return "Invalid dates"
|
||||||
return `${format(start, "MMM d")} - ${format(end, "MMM d, yyyy")}`
|
return `${format(start, "MMM d")} - ${format(end, "MMM d, yyyy")}`
|
||||||
}
|
}
|
||||||
@ -112,8 +111,8 @@ export default function SprintArchivePage() {
|
|||||||
(t) => t.status === "done" || t.status === "archived"
|
(t) => t.status === "done" || t.status === "archived"
|
||||||
)
|
)
|
||||||
|
|
||||||
const start = parseSprintStart(sprint.startDate)
|
const start = new Date(sprint.startDate)
|
||||||
const end = parseSprintEnd(sprint.endDate)
|
const end = new Date(sprint.endDate)
|
||||||
const durationMs = end.getTime() - start.getTime()
|
const durationMs = end.getTime() - start.getTime()
|
||||||
const durationDays = Math.max(1, Math.ceil(durationMs / (1000 * 60 * 60 * 24)))
|
const durationDays = Math.max(1, Math.ceil(durationMs / (1000 * 60 * 60 * 24)))
|
||||||
|
|
||||||
@ -130,7 +129,7 @@ export default function SprintArchivePage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Sort by end date (most recent first)
|
// Sort by end date (most recent first)
|
||||||
stats.sort((a, b) => parseSprintEnd(b.sprint.endDate).getTime() - parseSprintEnd(a.sprint.endDate).getTime())
|
stats.sort((a, b) => new Date(b.sprint.endDate).getTime() - new Date(a.sprint.endDate).getTime())
|
||||||
setSprintStats(stats)
|
setSprintStats(stats)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch sprint data:", error)
|
console.error("Failed to fetch sprint data:", error)
|
||||||
|
|||||||
@ -17,7 +17,6 @@ import {
|
|||||||
markdownPreviewObjectUrl,
|
markdownPreviewObjectUrl,
|
||||||
textPreviewObjectUrl,
|
textPreviewObjectUrl,
|
||||||
} from "@/lib/attachments"
|
} from "@/lib/attachments"
|
||||||
import { parseSprintStart } from "@/lib/utils"
|
|
||||||
import {
|
import {
|
||||||
useTaskStore,
|
useTaskStore,
|
||||||
type Comment as TaskComment,
|
type Comment as TaskComment,
|
||||||
@ -777,9 +776,9 @@ export default function TaskDetailPage() {
|
|||||||
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"
|
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>
|
<option value="">No Sprint</option>
|
||||||
{sprints.sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime()).map((sprint) => (
|
{sprints.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()).map((sprint) => (
|
||||||
<option key={sprint.id} value={sprint.id}>
|
<option key={sprint.id} value={sprint.id}>
|
||||||
{sprint.name} ({parseSprintStart(sprint.startDate).toLocaleDateString()})
|
{sprint.name} ({new Date(sprint.startDate).toLocaleDateString()})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@ -24,8 +24,8 @@ import { Card, CardContent } from "@/components/ui/card"
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Plus, GripVertical, ChevronDown, ChevronRight, Calendar } from "lucide-react"
|
import { Plus, GripVertical, ChevronDown, ChevronRight, Calendar } from "lucide-react"
|
||||||
import { format, isValid } from "date-fns"
|
import { format, isValid, parseISO } from "date-fns"
|
||||||
import { parseSprintEnd, parseSprintStart, toLocalDateInputValue } from "@/lib/utils"
|
import { parseSprintEnd, parseSprintStart } from "@/lib/utils"
|
||||||
import { generateAvatarDataUrl } from "@/lib/avatar"
|
import { generateAvatarDataUrl } from "@/lib/avatar"
|
||||||
|
|
||||||
const priorityColors: Record<string, string> = {
|
const priorityColors: Record<string, string> = {
|
||||||
@ -45,8 +45,8 @@ const typeLabels: Record<string, string> = {
|
|||||||
|
|
||||||
function formatSprintDateRange(startDate?: string, endDate?: string): string {
|
function formatSprintDateRange(startDate?: string, endDate?: string): string {
|
||||||
if (!startDate || !endDate) return "No dates"
|
if (!startDate || !endDate) return "No dates"
|
||||||
const start = parseSprintStart(startDate)
|
const start = parseISO(startDate)
|
||||||
const end = parseSprintEnd(endDate)
|
const end = parseISO(endDate)
|
||||||
if (!isValid(start) || !isValid(end)) return "Invalid dates"
|
if (!isValid(start) || !isValid(end)) return "Invalid dates"
|
||||||
return `${format(start, "MMM d")} - ${format(end, "MMM d")}`
|
return `${format(start, "MMM d")} - ${format(end, "MMM d")}`
|
||||||
}
|
}
|
||||||
@ -375,8 +375,8 @@ export function BacklogView({ searchQuery = "" }: BacklogViewProps) {
|
|||||||
addSprint({
|
addSprint({
|
||||||
name: newSprint.name,
|
name: newSprint.name,
|
||||||
goal: newSprint.goal,
|
goal: newSprint.goal,
|
||||||
startDate: newSprint.startDate || toLocalDateInputValue(),
|
startDate: newSprint.startDate || new Date().toISOString(),
|
||||||
endDate: newSprint.endDate || toLocalDateInputValue(),
|
endDate: newSprint.endDate || new Date().toISOString(),
|
||||||
status: "planning",
|
status: "planning",
|
||||||
projectId: selectedProjectId || "2",
|
projectId: selectedProjectId || "2",
|
||||||
})
|
})
|
||||||
@ -416,7 +416,7 @@ export function BacklogView({ searchQuery = "" }: BacklogViewProps) {
|
|||||||
|
|
||||||
{/* Other Sprints Sections - ordered by start date */}
|
{/* Other Sprints Sections - ordered by start date */}
|
||||||
{otherSprints
|
{otherSprints
|
||||||
.sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime())
|
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime())
|
||||||
.map((sprint) => {
|
.map((sprint) => {
|
||||||
const sprintTasks = tasks.filter((t) => t.sprintId === sprint.id && matchesSearch(t)).sort(sortByUpdated)
|
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))
|
console.log(`Sprint ${sprint.name}: ${sprintTasks.length} tasks`, sprintTasks.map(t => t.title))
|
||||||
|
|||||||
@ -1,173 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
|
|
||||||
function SkeletonPulse({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<div className={`animate-pulse bg-slate-800 rounded ${className}`} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KanbanColumnSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<SkeletonPulse className="h-5 w-24" />
|
|
||||||
<SkeletonPulse className="h-5 w-10" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2 mb-2">
|
|
||||||
<SkeletonPulse className="h-6 w-20" />
|
|
||||||
<SkeletonPulse className="h-6 w-24" />
|
|
||||||
<SkeletonPulse className="h-6 w-16" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3 min-h-32 rounded-lg p-2">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<TaskCardSkeleton key={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TaskCardSkeleton() {
|
|
||||||
return (
|
|
||||||
<Card className="bg-slate-900 border-slate-800">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-start justify-between mb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<SkeletonPulse className="h-7 w-6 rounded" />
|
|
||||||
<SkeletonPulse className="h-5 w-16" />
|
|
||||||
</div>
|
|
||||||
<SkeletonPulse className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<SkeletonPulse className="h-5 w-3/4 mb-1" />
|
|
||||||
<SkeletonPulse className="h-4 w-full mb-2" />
|
|
||||||
<SkeletonPulse className="h-4 w-2/3 mb-3" />
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<SkeletonPulse className="h-5 w-14" />
|
|
||||||
<SkeletonPulse className="h-4 w-12" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<SkeletonPulse className="h-6 w-6 rounded-full" />
|
|
||||||
<SkeletonPulse className="h-4 w-20" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HeaderSkeleton() {
|
|
||||||
return (
|
|
||||||
<header className="border-b border-slate-800 bg-slate-900/50 backdrop-blur">
|
|
||||||
<div className="max-w-[1800px] mx-auto px-4 py-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<SkeletonPulse className="h-8 w-48 mb-1" />
|
|
||||||
<SkeletonPulse className="h-4 w-64" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<SkeletonPulse className="h-8 w-32 hidden sm:block" />
|
|
||||||
<SkeletonPulse className="h-8 w-20" />
|
|
||||||
<SkeletonPulse className="h-8 w-24" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SprintHeaderSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="mb-4 p-3 bg-slate-800/50 border border-slate-700 rounded-lg">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="w-full">
|
|
||||||
<SkeletonPulse className="h-5 w-48 mb-2" />
|
|
||||||
<SkeletonPulse className="h-4 w-64" />
|
|
||||||
</div>
|
|
||||||
<SkeletonPulse className="h-6 w-16" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BoardSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-slate-950 text-slate-100">
|
|
||||||
<HeaderSkeleton />
|
|
||||||
<div className="max-w-[1800px] mx-auto px-4 py-4 md:py-6">
|
|
||||||
<main className="min-w-0">
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 md:mb-6">
|
|
||||||
<div>
|
|
||||||
<SkeletonPulse className="h-7 w-32 mb-1" />
|
|
||||||
<SkeletonPulse className="h-4 w-40" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<SkeletonPulse className="h-10 w-48" />
|
|
||||||
<SkeletonPulse className="h-10 w-28" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<SprintHeaderSkeleton />
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<KanbanColumnSkeleton />
|
|
||||||
<KanbanColumnSkeleton />
|
|
||||||
<KanbanColumnSkeleton />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TableRowSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-4 p-3 border-b border-slate-800">
|
|
||||||
<SkeletonPulse className="h-4 w-4" />
|
|
||||||
<SkeletonPulse className="h-5 w-8" />
|
|
||||||
<SkeletonPulse className="h-5 w-20" />
|
|
||||||
<SkeletonPulse className="h-5 w-48 flex-1" />
|
|
||||||
<SkeletonPulse className="h-5 w-16" />
|
|
||||||
<SkeletonPulse className="h-5 w-20" />
|
|
||||||
<SkeletonPulse className="h-6 w-6 rounded-full" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BacklogSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<SkeletonPulse className="h-6 w-32" />
|
|
||||||
<SkeletonPulse className="h-10 w-28" />
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-900 border border-slate-800 rounded-lg overflow-hidden">
|
|
||||||
<div className="flex items-center gap-4 p-3 border-b border-slate-800 bg-slate-800/50">
|
|
||||||
<SkeletonPulse className="h-4 w-4" />
|
|
||||||
<SkeletonPulse className="h-4 w-8" />
|
|
||||||
<SkeletonPulse className="h-4 w-20" />
|
|
||||||
<SkeletonPulse className="h-4 w-32 flex-1" />
|
|
||||||
<SkeletonPulse className="h-4 w-16" />
|
|
||||||
<SkeletonPulse className="h-4 w-20" />
|
|
||||||
<SkeletonPulse className="h-4 w-8" />
|
|
||||||
</div>
|
|
||||||
{[1, 2, 3, 4, 5].map((i) => (
|
|
||||||
<TableRowSkeleton key={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SearchSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<SkeletonPulse className="h-10 w-full max-w-md mb-4" />
|
|
||||||
<div className="grid grid-cols-1 gap-3">
|
|
||||||
{[1, 2, 3, 4].map((i) => (
|
|
||||||
<TaskCardSkeleton key={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import { ReactNode, useState } from "react";
|
|
||||||
|
|
||||||
export function QueryProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [queryClient] = useState(
|
|
||||||
() =>
|
|
||||||
new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
// Data is considered fresh for 30 seconds
|
|
||||||
staleTime: 30 * 1000,
|
|
||||||
// Cache data for 5 minutes
|
|
||||||
gcTime: 5 * 60 * 1000,
|
|
||||||
// Retry failed requests 2 times
|
|
||||||
retry: 2,
|
|
||||||
// Don't refetch on window focus to reduce API calls
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
// Refetch when reconnecting
|
|
||||||
refetchOnReconnect: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
{children}
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,344 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
import { useTaskStore, Task, TaskStatus } from "@/stores/useTaskStore"
|
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { generateAvatarDataUrl } from "@/lib/avatar"
|
|
||||||
import { MessageSquare, Calendar, Paperclip } from "lucide-react"
|
|
||||||
import { format, isValid, parseISO } from "date-fns"
|
|
||||||
|
|
||||||
const typeColors: Record<string, string> = {
|
|
||||||
idea: "bg-purple-500",
|
|
||||||
task: "bg-blue-500",
|
|
||||||
bug: "bg-red-500",
|
|
||||||
research: "bg-green-500",
|
|
||||||
plan: "bg-amber-500",
|
|
||||||
}
|
|
||||||
|
|
||||||
const typeLabels: Record<string, string> = {
|
|
||||||
idea: "💡 Idea",
|
|
||||||
task: "📋 Task",
|
|
||||||
bug: "🐛 Bug",
|
|
||||||
research: "🔬 Research",
|
|
||||||
plan: "📐 Plan",
|
|
||||||
}
|
|
||||||
|
|
||||||
const priorityColors: Record<string, string> = {
|
|
||||||
low: "text-slate-400",
|
|
||||||
medium: "text-blue-400",
|
|
||||||
high: "text-orange-400",
|
|
||||||
urgent: "text-red-400",
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusColors: Record<TaskStatus, string> = {
|
|
||||||
open: "bg-slate-600",
|
|
||||||
todo: "bg-slate-600",
|
|
||||||
blocked: "bg-red-600",
|
|
||||||
"in-progress": "bg-blue-600",
|
|
||||||
review: "bg-yellow-600",
|
|
||||||
validate: "bg-purple-600",
|
|
||||||
archived: "bg-gray-600",
|
|
||||||
canceled: "bg-red-800",
|
|
||||||
done: "bg-green-600",
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatStatusLabel = (status: TaskStatus) =>
|
|
||||||
status === "todo"
|
|
||||||
? "To Do"
|
|
||||||
: status
|
|
||||||
.split("-")
|
|
||||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
||||||
.join(" ")
|
|
||||||
|
|
||||||
function AvatarCircle({
|
|
||||||
name,
|
|
||||||
avatarUrl,
|
|
||||||
seed,
|
|
||||||
sizeClass = "h-6 w-6",
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
name?: string
|
|
||||||
avatarUrl?: string
|
|
||||||
seed?: string
|
|
||||||
sizeClass?: string
|
|
||||||
title?: string
|
|
||||||
}) {
|
|
||||||
const displayUrl = avatarUrl || generateAvatarDataUrl(seed || name || "default", name || "User")
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={displayUrl}
|
|
||||||
alt={name || "User avatar"}
|
|
||||||
className={`${sizeClass} rounded-full border border-slate-700 object-cover bg-slate-800`}
|
|
||||||
title={title || name || "User"}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AssignableUser {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
email?: string
|
|
||||||
avatarUrl?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SearchViewProps {
|
|
||||||
searchQuery: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SearchView({ searchQuery }: SearchViewProps) {
|
|
||||||
const router = useRouter()
|
|
||||||
const { tasks, sprints } = useTaskStore()
|
|
||||||
const [assignableUsers, setAssignableUsers] = useState<AssignableUser[]>([])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true
|
|
||||||
const loadUsers = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/auth/users", { cache: "no-store" })
|
|
||||||
if (!response.ok) return
|
|
||||||
const data = await response.json()
|
|
||||||
if (!active || !Array.isArray(data?.users)) return
|
|
||||||
setAssignableUsers(
|
|
||||||
data.users.map((entry: { id: string; name: string; email?: string; avatarUrl?: string }) => ({
|
|
||||||
id: entry.id,
|
|
||||||
name: entry.name,
|
|
||||||
email: entry.email,
|
|
||||||
avatarUrl: entry.avatarUrl,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
// Keep view usable if users lookup fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void loadUsers()
|
|
||||||
return () => {
|
|
||||||
active = false
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const resolveAssignee = (assigneeId: string | undefined) => {
|
|
||||||
if (!assigneeId) return null
|
|
||||||
return assignableUsers.find((user) => user.id === assigneeId) || null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter tasks by search query across ALL tasks
|
|
||||||
const matchingTasks = tasks.filter((task) => {
|
|
||||||
if (!searchQuery.trim()) return true
|
|
||||||
const query = searchQuery.toLowerCase()
|
|
||||||
const matchesTitle = task.title.toLowerCase().includes(query)
|
|
||||||
const matchesDescription = task.description?.toLowerCase().includes(query) ?? false
|
|
||||||
const matchesTags = task.tags?.some((tag) => tag.toLowerCase().includes(query)) ?? false
|
|
||||||
const matchesAssignee = task.assigneeName?.toLowerCase().includes(query) ?? false
|
|
||||||
const matchesStatus = formatStatusLabel(task.status).toLowerCase().includes(query)
|
|
||||||
const matchesType = typeLabels[task.type]?.toLowerCase().includes(query) ?? false
|
|
||||||
return matchesTitle || matchesDescription || matchesTags || matchesAssignee || matchesStatus || matchesType
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sort by updatedAt descending (most recent first)
|
|
||||||
const sortedTasks = matchingTasks.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
|
||||||
|
|
||||||
// Get sprint info for a task
|
|
||||||
const getSprintInfo = (task: Task) => {
|
|
||||||
if (!task.sprintId) return null
|
|
||||||
const sprint = sprints.find((s) => s.id === task.sprintId)
|
|
||||||
if (!sprint) return null
|
|
||||||
return sprint
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format date
|
|
||||||
const formatDate = (dateStr: string) => {
|
|
||||||
try {
|
|
||||||
const date = parseISO(dateStr)
|
|
||||||
if (!isValid(date)) return "Invalid date"
|
|
||||||
return format(date, "MMM d, yyyy")
|
|
||||||
} catch {
|
|
||||||
return "Invalid date"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!searchQuery.trim()) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-slate-400">
|
|
||||||
<div className="w-16 h-16 mb-4 rounded-full bg-slate-800 flex items-center justify-center">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="h-8 w-8 text-slate-500"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-medium text-slate-300 mb-2">Search Tasks</h3>
|
|
||||||
<p className="text-sm text-slate-500 max-w-md text-center">
|
|
||||||
Enter a search term above to find tasks across all projects, sprints, and statuses.
|
|
||||||
<br />
|
|
||||||
Search by title, description, tags, assignee, status, or type.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Search Results Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-sm text-slate-400">
|
|
||||||
Found <span className="text-white font-medium">{sortedTasks.length}</span> tasks matching "
|
|
||||||
<span className="text-blue-400">{searchQuery}</span>"
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search Results List */}
|
|
||||||
{sortedTasks.length === 0 ? (
|
|
||||||
<div className="text-center py-12 text-slate-500">
|
|
||||||
<p>No tasks found matching "{searchQuery}"</p>
|
|
||||||
<p className="text-sm mt-2">Try a different search term</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{sortedTasks.map((task) => {
|
|
||||||
const sprint = getSprintInfo(task)
|
|
||||||
const assignee = resolveAssignee(task.assigneeId)
|
|
||||||
const attachmentCount = task.attachments?.length || 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={task.id}
|
|
||||||
className="bg-slate-900 border-slate-800 hover:border-slate-700 cursor-pointer transition-colors group"
|
|
||||||
onClick={() => router.push(`/tasks/${encodeURIComponent(task.id)}`)}
|
|
||||||
>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
{/* Type Badge */}
|
|
||||||
<div className="flex flex-col items-center gap-1 pt-1">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={`text-xs ${typeColors[task.type]} text-white border-0 shrink-0`}
|
|
||||||
>
|
|
||||||
{typeLabels[task.type]}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
{/* Title and Priority */}
|
|
||||||
<div className="flex items-start justify-between gap-2 mb-1">
|
|
||||||
<h4 className="font-medium text-white truncate">{task.title}</h4>
|
|
||||||
<span className={`text-xs font-medium uppercase ${priorityColors[task.priority]} shrink-0`}>
|
|
||||||
{task.priority}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
{task.description && (
|
|
||||||
<p className="text-sm text-slate-400 line-clamp-2 mb-3">
|
|
||||||
{task.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Meta Row */}
|
|
||||||
<div className="flex flex-wrap items-center gap-3 text-xs">
|
|
||||||
{/* Status */}
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className={`${statusColors[task.status]} text-white text-[10px] uppercase tracking-wide`}
|
|
||||||
>
|
|
||||||
{formatStatusLabel(task.status)}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
{/* Sprint */}
|
|
||||||
{sprint && (
|
|
||||||
<span className="text-slate-500 flex items-center gap-1">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="h-3 w-3"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{sprint.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Comments */}
|
|
||||||
{task.comments && task.comments.length > 0 && (
|
|
||||||
<span className="flex items-center gap-1 text-slate-500">
|
|
||||||
<MessageSquare className="w-3 h-3" />
|
|
||||||
{task.comments.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Attachments */}
|
|
||||||
{attachmentCount > 0 && (
|
|
||||||
<span className="flex items-center gap-1 text-slate-500">
|
|
||||||
<Paperclip className="w-3 h-3" />
|
|
||||||
{attachmentCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Due Date */}
|
|
||||||
{task.dueDate && (
|
|
||||||
<span className="text-slate-500 flex items-center gap-1">
|
|
||||||
<Calendar className="w-3 h-3" />
|
|
||||||
{formatDate(task.dueDate)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
{task.tags && task.tags.length > 0 && (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{task.tags.slice(0, 3).map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className="text-[10px] px-1.5 py-0.5 bg-slate-800 text-slate-400 rounded"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{task.tags.length > 3 && (
|
|
||||||
<span className="text-[10px] text-slate-500">+{task.tags.length - 3}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Assignee */}
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
|
||||||
<AvatarCircle
|
|
||||||
name={task.assigneeName || "Unassigned"}
|
|
||||||
avatarUrl={assignee?.avatarUrl || task.assigneeAvatarUrl}
|
|
||||||
seed={task.assigneeId}
|
|
||||||
sizeClass="h-5 w-5"
|
|
||||||
title={task.assigneeName ? `Assigned to ${task.assigneeName}` : "Unassigned"}
|
|
||||||
/>
|
|
||||||
<span className="text-slate-500 hidden sm:inline">
|
|
||||||
{task.assigneeName || "Unassigned"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -22,8 +22,7 @@ import { Card, CardContent } from "@/components/ui/card"
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Plus, Calendar, Flag, GripVertical } from "lucide-react"
|
import { Plus, Calendar, Flag, GripVertical } from "lucide-react"
|
||||||
import { format, isValid } from "date-fns"
|
import { format, isValid, parseISO } from "date-fns"
|
||||||
import { parseSprintEnd, parseSprintStart, toLocalDateInputValue } from "@/lib/utils"
|
|
||||||
|
|
||||||
const statusColumns = ["backlog", "in-progress", "review", "done"] as const
|
const statusColumns = ["backlog", "in-progress", "review", "done"] as const
|
||||||
type SprintColumnStatus = typeof statusColumns[number]
|
type SprintColumnStatus = typeof statusColumns[number]
|
||||||
@ -51,8 +50,8 @@ const priorityColors: Record<string, string> = {
|
|||||||
|
|
||||||
function formatSprintDateRange(startDate?: string, endDate?: string): string {
|
function formatSprintDateRange(startDate?: string, endDate?: string): string {
|
||||||
if (!startDate || !endDate) return "No dates"
|
if (!startDate || !endDate) return "No dates"
|
||||||
const start = parseSprintStart(startDate)
|
const start = parseISO(startDate)
|
||||||
const end = parseSprintEnd(endDate)
|
const end = parseISO(endDate)
|
||||||
if (!isValid(start) || !isValid(end)) return "Invalid dates"
|
if (!isValid(start) || !isValid(end)) return "Invalid dates"
|
||||||
return `${format(start, "MMM d")} - ${format(end, "MMM d, yyyy")}`
|
return `${format(start, "MMM d")} - ${format(end, "MMM d, yyyy")}`
|
||||||
}
|
}
|
||||||
@ -210,8 +209,8 @@ export function SprintBoard() {
|
|||||||
const sprint: Omit<Sprint, "id" | "createdAt"> = {
|
const sprint: Omit<Sprint, "id" | "createdAt"> = {
|
||||||
name: newSprint.name,
|
name: newSprint.name,
|
||||||
goal: newSprint.goal,
|
goal: newSprint.goal,
|
||||||
startDate: newSprint.startDate || toLocalDateInputValue(),
|
startDate: newSprint.startDate || new Date().toISOString(),
|
||||||
endDate: newSprint.endDate || toLocalDateInputValue(),
|
endDate: newSprint.endDate || new Date().toISOString(),
|
||||||
status: "planning",
|
status: "planning",
|
||||||
projectId: selectedProjectId,
|
projectId: selectedProjectId,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,40 +5,29 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
const DATE_PREFIX_PATTERN = /^(\d{4})-(\d{2})-(\d{2})/
|
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/
|
||||||
|
|
||||||
function parseDateParts(value: string): [number, number, number] | null {
|
function parseDateParts(value: string): [number, number, number] {
|
||||||
const match = value.match(DATE_PREFIX_PATTERN)
|
const [year, month, day] = value.split("-").map(Number)
|
||||||
if (!match) return null
|
return [year, month, day]
|
||||||
return [Number(match[1]), Number(match[2]), Number(match[3])]
|
|
||||||
}
|
|
||||||
|
|
||||||
function asLocalDayDate(value: string, endOfDay: boolean): Date | null {
|
|
||||||
const parts = parseDateParts(value)
|
|
||||||
if (!parts) return null
|
|
||||||
const [year, month, day] = parts
|
|
||||||
return endOfDay
|
|
||||||
? new Date(year, month - 1, day, 23, 59, 59, 999)
|
|
||||||
: new Date(year, month - 1, day, 0, 0, 0, 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseSprintStart(startDate: string): Date {
|
export function parseSprintStart(startDate: string): Date {
|
||||||
const localStart = asLocalDayDate(startDate, false)
|
if (DATE_ONLY_PATTERN.test(startDate)) {
|
||||||
if (localStart) return localStart
|
const [year, month, day] = parseDateParts(startDate)
|
||||||
|
return new Date(year, month - 1, day, 0, 0, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
return new Date(startDate)
|
return new Date(startDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseSprintEnd(endDate: string): Date {
|
export function parseSprintEnd(endDate: string): Date {
|
||||||
const localEnd = asLocalDayDate(endDate, true)
|
if (DATE_ONLY_PATTERN.test(endDate)) {
|
||||||
if (localEnd) return localEnd
|
const [year, month, day] = parseDateParts(endDate)
|
||||||
|
return new Date(year, month - 1, day, 23, 59, 59, 999)
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = new Date(endDate)
|
const parsed = new Date(endDate)
|
||||||
parsed.setHours(23, 59, 59, 999)
|
parsed.setHours(23, 59, 59, 999)
|
||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toLocalDateInputValue(date: Date = new Date()): string {
|
|
||||||
const year = date.getFullYear()
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, "0")
|
|
||||||
const day = String(date.getDate()).padStart(2, "0")
|
|
||||||
return `${year}-${month}-${day}`
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user