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:
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
Reference in New Issue
Block a user