Fixed due date timezone parse

X-Lovable-Edit-ID: edt-a7933617-ca5a-40dc-8011-c0e64c3d4c69
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-24 13:59:58 +00:00
co-authored by renee-png
7 changed files with 51 additions and 14 deletions
+6 -2
View File
@@ -12,6 +12,7 @@ import { NewTaskDialog } from "@/components/tasks/new-task-dialog";
import { ApplyWorkflowDialog } from "@/components/tasks/apply-workflow-dialog";
import { cn } from "@/lib/utils";
import { spawnNextWorkflowTask } from "@/lib/workflow-chain";
import { parseDateOnly } from "@/lib/format";
interface TaskRow {
id: string;
@@ -130,7 +131,10 @@ export function CaseTasksTab({ caseId }: { caseId: string }) {
)}
{tasks.map((t) => {
const ids = assigneesByTask[t.id] || [];
const overdue = t.due_date && t.status === "incomplete" && isBefore(startOfDay(new Date(t.due_date)), today);
const overdue =
t.due_date &&
t.status === "incomplete" &&
isBefore(startOfDay(parseDateOnly(t.due_date)!), today);
return (
<div
key={t.id}
@@ -151,7 +155,7 @@ export function CaseTasksTab({ caseId }: { caseId: string }) {
{t.due_date && (
<Badge variant={overdue ? "destructive" : "outline"} className="gap-1">
<CalIcon className="h-3 w-3" />
{format(new Date(t.due_date), "MMM d")}
{format(parseDateOnly(t.due_date)!, "MMM d")}
</Badge>
)}
<div className="flex -space-x-1.5">
@@ -25,7 +25,7 @@ import {
} from "@/components/ui/select";
import { CalendarClock, Loader2, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { formatCurrency, formatDate } from "@/lib/format";
import { formatCurrency, formatDate, parseDateOnly } from "@/lib/format";
type Frequency = "weekly" | "biweekly" | "monthly";
type PlanStatus = "active" | "completed" | "defaulted" | "cancelled";
@@ -200,7 +200,8 @@ export function PaymentPlansPanel({ collectionId }: { collectionId: string }) {
{ins.length > 0 && (
<div className="divide-y">
{ins.map((i) => {
const overdue = !i.paid && new Date(i.due_date) < new Date(new Date().toDateString());
const overdue =
!i.paid && parseDateOnly(i.due_date)! < new Date(new Date().toDateString());
return (
<div key={i.id} className="flex items-center gap-3 p-2.5 px-3">
<Checkbox
+3 -2
View File
@@ -38,6 +38,7 @@ import {
DialogFooter,
} from "@/components/ui/dialog";
import { format } from "date-fns";
import { parseDateOnly } from "@/lib/format";
import type { Database } from "@/integrations/supabase/types";
import { cn } from "@/lib/utils";
import { createNotifications } from "@/lib/notifications";
@@ -453,13 +454,13 @@ export function TaskDetailPanel({
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 px-2 text-sm font-normal">
<CalendarIcon className="h-3.5 w-3.5 mr-1.5" />
{task.due_date ? format(new Date(task.due_date), "MM/dd/yyyy") : "Set date"}
{task.due_date ? format(parseDateOnly(task.due_date)!, "MM/dd/yyyy") : "Set date"}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="p-0">
<Calendar
mode="single"
selected={task.due_date ? new Date(task.due_date) : undefined}
selected={task.due_date ? parseDateOnly(task.due_date)! : undefined}
onSelect={setDueDate}
/>
{task.due_date && (
+18
View File
@@ -3,6 +3,24 @@ export function formatCurrency(amount: number | string | null | undefined) {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n || 0);
}
/**
* Parse a date-only string ("YYYY-MM-DD") as a LOCAL date (midnight in the
* user's timezone). Using `new Date("2026-04-24")` parses as UTC midnight,
* which renders as the previous day in negative-offset timezones — that's
* the source of the "due date is one day earlier" bug.
*
* Returns null for falsy/invalid input. If a non date-only string or Date is
* passed, returns a Date built from it as-is.
*/
export function parseDateOnly(value: string | Date | null | undefined): Date | null {
if (!value) return null;
if (value instanceof Date) return value;
const m = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (m) return new Date(+m[1], +m[2] - 1, +m[3]);
const d = new Date(value);
return isNaN(d.getTime()) ? null : d;
}
export function formatDate(value: string | Date | null | undefined) {
if (!value) return "—";
// Date-only strings ("YYYY-MM-DD") must be rendered in UTC, otherwise
+15 -3
View File
@@ -1,5 +1,17 @@
import { supabase } from "@/integrations/supabase/client";
import { addDays, format } from "date-fns";
import { addDays } from "date-fns";
/**
* Format a Date as "YYYY-MM-DD" using its UTC components. We do this so that
* a `completed_at` UTC timestamp like 2026-04-24T02:00:00Z (which is still
* Apr 23 in US local time) doesn't shift the computed due_date back a day.
*/
function toUTCDateString(d: Date): string {
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
/**
* After a task is marked complete, spawn the next task from its workflow
@@ -48,7 +60,7 @@ export async function spawnNextWorkflowTask(taskId: string): Promise<string | nu
if (existing) return existing.id;
const baseDate = task.completed_at ? new Date(task.completed_at) : new Date();
const dueDate = format(addDays(baseDate, nextTpl.days_from_start ?? 0), "yyyy-MM-dd");
const dueDate = toUTCDateString(addDays(baseDate, nextTpl.days_from_start ?? 0));
const { data: created, error } = await supabase
.from("tasks")
@@ -129,7 +141,7 @@ export async function spawnNextCollectionWorkflowTask(
if (existing) return existing.id;
const baseDate = ct.done_at ? new Date(ct.done_at) : new Date();
const dueDate = format(addDays(baseDate, nextTpl.days_from_start ?? 0), "yyyy-MM-dd");
const dueDate = toUTCDateString(addDays(baseDate, nextTpl.days_from_start ?? 0));
const { data: created, error } = await supabase
.from("collection_tasks")
+2 -2
View File
@@ -38,7 +38,7 @@ import { CaseInvoicesTab } from "@/components/cases/invoices-tab";
import { CaseLitigationTab } from "@/components/cases/litigation-tab";
import { CaseCustomFieldsTab } from "@/components/cases/custom-fields-tab";
import { formatDate } from "@/lib/format";
import { formatDate, parseDateOnly } from "@/lib/format";
import {
ArrowLeft,
ArrowRight,
@@ -526,7 +526,7 @@ function CollectionDetailRoute() {
const overdue =
!t.done &&
t.due_date &&
new Date(t.due_date) < new Date(new Date().toDateString());
parseDateOnly(t.due_date)! < new Date(new Date().toDateString());
return (
<div
key={t.id}
+4 -3
View File
@@ -24,6 +24,7 @@ import { NewTaskDialog } from "@/components/tasks/new-task-dialog";
import { ApplyWorkflowPickerDialog } from "@/components/tasks/apply-workflow-picker-dialog";
import { cn } from "@/lib/utils";
import { spawnNextWorkflowTask } from "@/lib/workflow-chain";
import { parseDateOnly } from "@/lib/format";
export const Route = createFileRoute("/tasks/")({
component: TasksPage,
@@ -166,7 +167,7 @@ function TasksPage() {
groups["No due date"].push(t);
continue;
}
const d = startOfDay(new Date(t.due_date));
const d = startOfDay(parseDateOnly(t.due_date)!);
if (isBefore(d, today)) groups.Overdue.push(t);
else if (isSameDay(d, today)) groups.Today.push(t);
else if (isSameDay(d, tomorrow)) groups.Tomorrow.push(t);
@@ -297,7 +298,7 @@ function TasksPage() {
.map((p) => p.full_name || p.email);
let countdown: { label: string; tone: string } | null = null;
if (t.due_date && t.status !== "complete") {
const days = differenceInCalendarDays(startOfDay(new Date(t.due_date)), startOfDay(new Date()));
const days = differenceInCalendarDays(startOfDay(parseDateOnly(t.due_date)!), startOfDay(new Date()));
if (days < 0)
countdown = {
label: `${Math.abs(days)}d overdue`,
@@ -342,7 +343,7 @@ function TasksPage() {
<div className="text-xs text-right shrink-0">
<div className="text-muted-foreground flex items-center gap-1 justify-end">
<CalIcon className="h-3 w-3" />
{format(new Date(t.due_date), "MMM d, yyyy")}
{format(parseDateOnly(t.due_date)!, "MMM d, yyyy")}
</div>
{countdown && (
<div className={cn("text-[11px] font-medium", countdown.tone)}>