diff --git a/README.md b/README.md index 5b78ccd..fe7c51c 100644 --- a/README.md +++ b/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. - 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 - Tasks use labels (`tags: string[]`) and can have multiple labels. diff --git a/SUPABASE_SETUP.md b/SUPABASE_SETUP.md index 2c9c2e4..c57f881 100644 --- a/SUPABASE_SETUP.md +++ b/SUPABASE_SETUP.md @@ -82,6 +82,11 @@ Test all functionality: - Create/edit sprints - 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 ### 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 - 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 ### Before (SQLite): diff --git a/app/_app.js b/app/_app.js deleted file mode 100644 index 9639c99..0000000 --- a/app/_app.js +++ /dev/null @@ -1,14 +0,0 @@ -import dynamic from 'next/dynamic'; - -export default function App({ Component, pageProps }) { - const DynamicComponent = dynamic(() => import('@/components/MyComponent'), { - loading: () =>

Loading...

, - }); - - return ( -
- - -
- ); -} \ No newline at end of file diff --git a/src/app/api/sprints/route.ts b/src/app/api/sprints/route.ts index 4ba1a75..dd200c0 100644 --- a/src/app/api/sprints/route.ts +++ b/src/app/api/sprints/route.ts @@ -4,6 +4,20 @@ import { getAuthenticatedUser } from "@/lib/server/auth"; 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) export async function GET(request: Request) { try { @@ -55,14 +69,17 @@ export async function POST(request: Request) { const supabase = getServiceSupabase(); const now = new Date().toISOString(); + const fallbackDate = currentDateOnly(); + const normalizedStartDate = toDateOnlyInput(startDate) ?? fallbackDate; + const normalizedEndDate = toDateOnlyInput(endDate) ?? normalizedStartDate; const { data, error } = await supabase .from("sprints") .insert({ name, goal: goal || null, - start_date: startDate || now, - end_date: endDate || now, + start_date: normalizedStartDate, + end_date: normalizedEndDate, status: status || "planning", project_id: projectId || null, created_at: now, @@ -101,8 +118,20 @@ export async function PATCH(request: Request) { const dbUpdates: Record = {}; if (updates.name !== undefined) dbUpdates.name = updates.name; if (updates.goal !== undefined) dbUpdates.goal = updates.goal; - if (updates.startDate !== undefined) dbUpdates.start_date = updates.startDate; - if (updates.endDate !== undefined) dbUpdates.end_date = updates.endDate; + if (updates.startDate !== undefined) { + 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.projectId !== undefined) dbUpdates.project_id = updates.projectId; dbUpdates.updated_at = now; diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index 986b42a..5977359 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -129,9 +129,19 @@ function stripUndefined>(value: T): Record +): Promise<{ rows: Record[]; 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[] | 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( supabase: ReturnType, requestedProjectId?: string @@ -263,19 +305,29 @@ export async function GET() { // Use Promise.all for parallel queries with optimized field selection const [ - { data: projects }, - { data: sprints }, - { data: tasks }, - { data: users }, - { data: meta } + { data: projects, error: projectsError }, + { data: sprints, error: sprintsError }, + { 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("tasks").select(TASK_FIELDS_LIGHT.join(", ")).order("updated_at", { ascending: false }).limit(200), supabase.from("users").select("id, name, email, avatar_url"), supabase.from("meta").select("value").eq("key", "lastUpdated").maybeSingle(), ]); + if (projectsError) throw projectsError; + if (sprintsError) throw sprintsError; + if (metaError) throw metaError; + if (usersError) { + console.warn(">>> API GET /tasks users query failed, continuing without user lookup:", usersError); + } + + const { rows: taskRows, droppedColumns } = await fetchTasksWithColumnFallback(supabase); + if (droppedColumns.length > 0) { + console.warn(">>> API GET /tasks fallback: omitted missing task columns:", droppedColumns.join(", ")); + } + const usersById = new Map(); for (const row of users || []) { const mapped = mapUserRow(row as Record); @@ -285,7 +337,7 @@ export async function GET() { return NextResponse.json({ projects: (projects || []).map((row) => mapProjectRow(row as Record)), sprints: (sprints || []).map((row) => mapSprintRow(row as Record)), - tasks: (tasks || []).map((row) => mapTaskRow(row as unknown as Record, usersById, false)), + tasks: taskRows.map((row) => mapTaskRow(row, usersById, false)), lastUpdated: Number(meta?.value ?? Date.now()), }, { headers: { diff --git a/src/app/page.tsx b/src/app/page.tsx index fc74fd4..1aa10da 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -140,6 +140,11 @@ const formatStatusLabel = (status: TaskStatus) => .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .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({ status, count, @@ -647,7 +652,7 @@ export default function Home() { // Find next sprint (earliest start date that's in the future or active) const nextSprint = sprints .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 @@ -1139,7 +1144,7 @@ export default function Home() {

{currentSprint?.name || "No Active Sprint"}

{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"}

@@ -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" > - {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) => ( ))} @@ -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" > - {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) => ( ))} diff --git a/src/app/sprints/archive/page.tsx b/src/app/sprints/archive/page.tsx index d87e6b6..83ae80c 100644 --- a/src/app/sprints/archive/page.tsx +++ b/src/app/sprints/archive/page.tsx @@ -2,12 +2,13 @@ import { useEffect, useState } from "react" 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 { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { parseSprintEnd, parseSprintStart } from "@/lib/utils" interface Sprint { id: string @@ -46,8 +47,8 @@ interface SprintDetail { } function formatDateRange(startDate: string, endDate: string): string { - const start = parseISO(startDate) - const end = parseISO(endDate) + const start = parseSprintStart(startDate) + const end = parseSprintEnd(endDate) if (!isValid(start) || !isValid(end)) return "Invalid dates" 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" ) - const start = new Date(sprint.startDate) - const end = new Date(sprint.endDate) + const start = parseSprintStart(sprint.startDate) + const end = parseSprintEnd(sprint.endDate) const durationMs = end.getTime() - start.getTime() 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) - 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) } catch (error) { console.error("Failed to fetch sprint data:", error) diff --git a/src/app/tasks/[taskId]/page.tsx b/src/app/tasks/[taskId]/page.tsx index a1bdb81..37f14a9 100644 --- a/src/app/tasks/[taskId]/page.tsx +++ b/src/app/tasks/[taskId]/page.tsx @@ -17,6 +17,7 @@ import { markdownPreviewObjectUrl, textPreviewObjectUrl, } from "@/lib/attachments" +import { parseSprintStart } from "@/lib/utils" import { useTaskStore, 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" > - {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) => ( ))} diff --git a/src/components/BacklogView.tsx b/src/components/BacklogView.tsx index 4a8da52..d9511fe 100644 --- a/src/components/BacklogView.tsx +++ b/src/components/BacklogView.tsx @@ -24,8 +24,8 @@ import { Card, CardContent } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Plus, GripVertical, ChevronDown, ChevronRight, Calendar } from "lucide-react" -import { format, isValid, parseISO } from "date-fns" -import { parseSprintEnd, parseSprintStart } from "@/lib/utils" +import { format, isValid } from "date-fns" +import { parseSprintEnd, parseSprintStart, toLocalDateInputValue } from "@/lib/utils" import { generateAvatarDataUrl } from "@/lib/avatar" const priorityColors: Record = { @@ -45,8 +45,8 @@ const typeLabels: Record = { function formatSprintDateRange(startDate?: string, endDate?: string): string { if (!startDate || !endDate) return "No dates" - const start = parseISO(startDate) - const end = parseISO(endDate) + const start = parseSprintStart(startDate) + const end = parseSprintEnd(endDate) if (!isValid(start) || !isValid(end)) return "Invalid dates" return `${format(start, "MMM d")} - ${format(end, "MMM d")}` } @@ -375,8 +375,8 @@ export function BacklogView({ searchQuery = "" }: BacklogViewProps) { addSprint({ name: newSprint.name, goal: newSprint.goal, - startDate: newSprint.startDate || new Date().toISOString(), - endDate: newSprint.endDate || new Date().toISOString(), + startDate: newSprint.startDate || toLocalDateInputValue(), + endDate: newSprint.endDate || toLocalDateInputValue(), status: "planning", projectId: selectedProjectId || "2", }) @@ -416,7 +416,7 @@ export function BacklogView({ searchQuery = "" }: BacklogViewProps) { {/* Other Sprints Sections - ordered by start date */} {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) => { 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)) diff --git a/src/components/SprintBoard.tsx b/src/components/SprintBoard.tsx index 3f7d599..5a3e9d4 100644 --- a/src/components/SprintBoard.tsx +++ b/src/components/SprintBoard.tsx @@ -22,7 +22,8 @@ import { Card, CardContent } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" 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 type SprintColumnStatus = typeof statusColumns[number] @@ -50,8 +51,8 @@ const priorityColors: Record = { function formatSprintDateRange(startDate?: string, endDate?: string): string { if (!startDate || !endDate) return "No dates" - const start = parseISO(startDate) - const end = parseISO(endDate) + const start = parseSprintStart(startDate) + const end = parseSprintEnd(endDate) if (!isValid(start) || !isValid(end)) return "Invalid dates" return `${format(start, "MMM d")} - ${format(end, "MMM d, yyyy")}` } @@ -209,8 +210,8 @@ export function SprintBoard() { const sprint: Omit = { name: newSprint.name, goal: newSprint.goal, - startDate: newSprint.startDate || new Date().toISOString(), - endDate: newSprint.endDate || new Date().toISOString(), + startDate: newSprint.startDate || toLocalDateInputValue(), + endDate: newSprint.endDate || toLocalDateInputValue(), status: "planning", projectId: selectedProjectId, } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 635a16b..cf8093e 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -5,29 +5,40 @@ export function cn(...inputs: ClassValue[]) { 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] { - const [year, month, day] = value.split("-").map(Number) - return [year, month, day] +function parseDateParts(value: string): [number, number, number] | null { + const match = value.match(DATE_PREFIX_PATTERN) + 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 { - if (DATE_ONLY_PATTERN.test(startDate)) { - const [year, month, day] = parseDateParts(startDate) - return new Date(year, month - 1, day, 0, 0, 0, 0) - } - + const localStart = asLocalDayDate(startDate, false) + if (localStart) return localStart return new Date(startDate) } export function parseSprintEnd(endDate: string): Date { - if (DATE_ONLY_PATTERN.test(endDate)) { - const [year, month, day] = parseDateParts(endDate) - return new Date(year, month - 1, day, 23, 59, 59, 999) - } - + const localEnd = asLocalDayDate(endDate, true) + if (localEnd) return localEnd const parsed = new Date(endDate) parsed.setHours(23, 59, 59, 999) 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}` +}