diff --git a/src/components/collections/payment-plans-panel.tsx b/src/components/collections/payment-plans-panel.tsx
index ec2ff75..cef9093 100644
--- a/src/components/collections/payment-plans-panel.tsx
+++ b/src/components/collections/payment-plans-panel.tsx
@@ -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 && (
{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 (
{task.due_date && (
diff --git a/src/lib/format.ts b/src/lib/format.ts
index 943d822..c5392a9 100644
--- a/src/lib/format.ts
+++ b/src/lib/format.ts
@@ -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
diff --git a/src/lib/workflow-chain.ts b/src/lib/workflow-chain.ts
index 660dedd..ea015b8 100644
--- a/src/lib/workflow-chain.ts
+++ b/src/lib/workflow-chain.ts
@@ -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 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() {