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"
>
Auto (Current Sprint)
- {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) => (
- {sprint.name} ({new Date(sprint.startDate).toLocaleDateString()})
+ {sprint.name} ({formatSprintDisplayDate(sprint.startDate, "start")})
))}
@@ -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"
>
No Sprint
- {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) => (
- {sprint.name} ({new Date(sprint.startDate).toLocaleDateString()})
+ {sprint.name} ({formatSprintDisplayDate(sprint.startDate, "start")})
))}
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"
>
No Sprint
- {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) => (
- {sprint.name} ({new Date(sprint.startDate).toLocaleDateString()})
+ {sprint.name} ({parseSprintStart(sprint.startDate).toLocaleDateString()})
))}
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}`
+}