fix issues with timing on sprints
Signed-off-by: Max <ai-agent@topdoglabs.com>
This commit is contained in:
parent
a238cb6138
commit
29cac07f58
21
README.md
21
README.md
@ -23,6 +23,27 @@ 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,6 +82,11 @@ 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:
|
||||||
@ -120,6 +125,15 @@ 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):
|
||||||
|
|||||||
14
app/_app.js
14
app/_app.js
@ -1,14 +0,0 @@
|
|||||||
import dynamic from 'next/dynamic';
|
|
||||||
|
|
||||||
export default function App({ Component, pageProps }) {
|
|
||||||
const DynamicComponent = dynamic(() => import('@/components/MyComponent'), {
|
|
||||||
loading: () => <p>Loading...</p>,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<DynamicComponent />
|
|
||||||
<Component {...pageProps} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -4,6 +4,20 @@ 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 {
|
||||||
@ -55,14 +69,17 @@ 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: startDate || now,
|
start_date: normalizedStartDate,
|
||||||
end_date: endDate || now,
|
end_date: normalizedEndDate,
|
||||||
status: status || "planning",
|
status: status || "planning",
|
||||||
project_id: projectId || null,
|
project_id: projectId || null,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
@ -101,8 +118,20 @@ 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) dbUpdates.start_date = updates.startDate;
|
if (updates.startDate !== undefined) {
|
||||||
if (updates.endDate !== undefined) dbUpdates.end_date = updates.endDate;
|
const normalizedStartDate = toDateOnlyInput(updates.startDate);
|
||||||
|
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;
|
||||||
|
|||||||
@ -129,9 +129,19 @@ 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 (candidate.code !== "PGRST204" || typeof candidate.message !== "string") return null;
|
if (typeof candidate.message !== "string") return null;
|
||||||
const match = candidate.message.match(/Could not find the '([^']+)' column/);
|
|
||||||
return match?.[1] ?? null;
|
if (candidate.code === "PGRST204") {
|
||||||
|
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 {
|
||||||
@ -142,6 +152,38 @@ 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
|
||||||
@ -263,19 +305,29 @@ export async function GET() {
|
|||||||
|
|
||||||
// Use Promise.all for parallel queries with optimized field selection
|
// Use Promise.all for parallel queries with optimized field selection
|
||||||
const [
|
const [
|
||||||
{ data: projects },
|
{ data: projects, error: projectsError },
|
||||||
{ data: sprints },
|
{ data: sprints, error: sprintsError },
|
||||||
{ data: tasks },
|
{ data: users, error: usersError },
|
||||||
{ data: users },
|
{ data: meta, error: metaError }
|
||||||
{ data: meta }
|
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
supabase.from("projects").select("id, name, description, color, created_at").order("created_at", { ascending: true }),
|
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("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 }).limit(200),
|
|
||||||
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>);
|
||||||
@ -285,7 +337,7 @@ 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: (tasks || []).map((row) => mapTaskRow(row as unknown as Record<string, unknown>, usersById, false)),
|
tasks: taskRows.map((row) => mapTaskRow(row, usersById, false)),
|
||||||
lastUpdated: Number(meta?.value ?? Date.now()),
|
lastUpdated: Number(meta?.value ?? Date.now()),
|
||||||
}, {
|
}, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@ -140,6 +140,11 @@ 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,
|
||||||
@ -647,7 +652,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) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime())[0]
|
.sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime())[0]
|
||||||
|
|
||||||
if (!nextSprint) return
|
if (!nextSprint) return
|
||||||
|
|
||||||
@ -1139,7 +1144,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
|
||||||
? `${new Date(currentSprint.startDate).toLocaleDateString()} - ${new Date(currentSprint.endDate).toLocaleDateString()}`
|
? `${formatSprintDisplayDate(currentSprint.startDate, "start")} - ${formatSprintDisplayDate(currentSprint.endDate, "end")}`
|
||||||
: "Create or activate a sprint to group work"}
|
: "Create or activate a sprint to group work"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -1280,9 +1285,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) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()).map((sprint) => (
|
{sprints.sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime()).map((sprint) => (
|
||||||
<option key={sprint.id} value={sprint.id}>
|
<option key={sprint.id} value={sprint.id}>
|
||||||
{sprint.name} ({new Date(sprint.startDate).toLocaleDateString()})
|
{sprint.name} ({formatSprintDisplayDate(sprint.startDate, "start")})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@ -1497,9 +1502,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) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()).map((sprint) => (
|
{sprints.sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime()).map((sprint) => (
|
||||||
<option key={sprint.id} value={sprint.id}>
|
<option key={sprint.id} value={sprint.id}>
|
||||||
{sprint.name} ({new Date(sprint.startDate).toLocaleDateString()})
|
{sprint.name} ({formatSprintDisplayDate(sprint.startDate, "start")})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { format, parseISO, isValid } from "date-fns"
|
import { format, 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
|
||||||
@ -46,8 +47,8 @@ interface SprintDetail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDateRange(startDate: string, endDate: string): string {
|
function formatDateRange(startDate: string, endDate: string): string {
|
||||||
const start = parseISO(startDate)
|
const start = parseSprintStart(startDate)
|
||||||
const end = parseISO(endDate)
|
const end = parseSprintEnd(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")}`
|
||||||
}
|
}
|
||||||
@ -111,8 +112,8 @@ export default function SprintArchivePage() {
|
|||||||
(t) => t.status === "done" || t.status === "archived"
|
(t) => t.status === "done" || t.status === "archived"
|
||||||
)
|
)
|
||||||
|
|
||||||
const start = new Date(sprint.startDate)
|
const start = parseSprintStart(sprint.startDate)
|
||||||
const end = new Date(sprint.endDate)
|
const end = parseSprintEnd(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)))
|
||||||
|
|
||||||
@ -129,7 +130,7 @@ export default function SprintArchivePage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Sort by end date (most recent first)
|
// Sort by end date (most recent first)
|
||||||
stats.sort((a, b) => new Date(b.sprint.endDate).getTime() - new Date(a.sprint.endDate).getTime())
|
stats.sort((a, b) => parseSprintEnd(b.sprint.endDate).getTime() - parseSprintEnd(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,6 +17,7 @@ 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,
|
||||||
@ -776,9 +777,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) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()).map((sprint) => (
|
{sprints.sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime()).map((sprint) => (
|
||||||
<option key={sprint.id} value={sprint.id}>
|
<option key={sprint.id} value={sprint.id}>
|
||||||
{sprint.name} ({new Date(sprint.startDate).toLocaleDateString()})
|
{sprint.name} ({parseSprintStart(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, parseISO } from "date-fns"
|
import { format, isValid } from "date-fns"
|
||||||
import { parseSprintEnd, parseSprintStart } from "@/lib/utils"
|
import { parseSprintEnd, parseSprintStart, toLocalDateInputValue } 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 = parseISO(startDate)
|
const start = parseSprintStart(startDate)
|
||||||
const end = parseISO(endDate)
|
const end = parseSprintEnd(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 || new Date().toISOString(),
|
startDate: newSprint.startDate || toLocalDateInputValue(),
|
||||||
endDate: newSprint.endDate || new Date().toISOString(),
|
endDate: newSprint.endDate || toLocalDateInputValue(),
|
||||||
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) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime())
|
.sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(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))
|
||||||
|
|||||||
@ -22,7 +22,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, Calendar, Flag, GripVertical } from "lucide-react"
|
import { Plus, Calendar, Flag, GripVertical } from "lucide-react"
|
||||||
import { format, isValid, parseISO } from "date-fns"
|
import { format, isValid } 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]
|
||||||
@ -50,8 +51,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 = parseISO(startDate)
|
const start = parseSprintStart(startDate)
|
||||||
const end = parseISO(endDate)
|
const end = parseSprintEnd(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")}`
|
||||||
}
|
}
|
||||||
@ -209,8 +210,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 || new Date().toISOString(),
|
startDate: newSprint.startDate || toLocalDateInputValue(),
|
||||||
endDate: newSprint.endDate || new Date().toISOString(),
|
endDate: newSprint.endDate || toLocalDateInputValue(),
|
||||||
status: "planning",
|
status: "planning",
|
||||||
projectId: selectedProjectId,
|
projectId: selectedProjectId,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,29 +5,40 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/
|
const DATE_PREFIX_PATTERN = /^(\d{4})-(\d{2})-(\d{2})/
|
||||||
|
|
||||||
function parseDateParts(value: string): [number, number, number] {
|
function parseDateParts(value: string): [number, number, number] | null {
|
||||||
const [year, month, day] = value.split("-").map(Number)
|
const match = value.match(DATE_PREFIX_PATTERN)
|
||||||
return [year, month, day]
|
if (!match) return null
|
||||||
|
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 {
|
||||||
if (DATE_ONLY_PATTERN.test(startDate)) {
|
const localStart = asLocalDayDate(startDate, false)
|
||||||
const [year, month, day] = parseDateParts(startDate)
|
if (localStart) return localStart
|
||||||
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 {
|
||||||
if (DATE_ONLY_PATTERN.test(endDate)) {
|
const localEnd = asLocalDayDate(endDate, true)
|
||||||
const [year, month, day] = parseDateParts(endDate)
|
if (localEnd) return localEnd
|
||||||
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