diff --git a/admin/vite.config.ts b/admin/vite.config.ts
index 829f8c9e8..6639a7c28 100644
--- a/admin/vite.config.ts
+++ b/admin/vite.config.ts
@@ -1,9 +1,12 @@
-import { defineConfig } from "vite";
+if (!process.env.VITE_SENTRY_RELEASE && process.env.CF_PAGES_COMMIT_SHA) process.env.VITE_SENTRY_RELEASE = process.env.CF_PAGES_COMMIT_SHA;import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
import tailwindcss from "@tailwindcss/vite";
import { sentryVitePlugin } from "@sentry/vite-plugin";
+// A Cloudflare Pages build sets no release, so its events are tagged with the commit it built.
+if (!process.env.VITE_SENTRY_RELEASE && process.env.CF_PAGES_COMMIT_SHA) process.env.VITE_SENTRY_RELEASE = process.env.CF_PAGES_COMMIT_SHA;
+
// Source maps, and nothing else, is what this section decides.
//
// Uploading them is never a required build step: a fork, a self-host build or a
diff --git a/web/src/app/app/api-keys/_components/KeyDetailDrawer.tsx b/web/src/app/app/api-keys/_components/KeyDetailDrawer.tsx
index f72b8c86f..afae01c6c 100644
--- a/web/src/app/app/api-keys/_components/KeyDetailDrawer.tsx
+++ b/web/src/app/app/api-keys/_components/KeyDetailDrawer.tsx
@@ -545,7 +545,7 @@ function Legend({ color, label }: { color: string; label: string }) {
);
}
-function fmtRelative(iso: string): string {
+function fmtRelative(iso: string | Date): string {
try {
const d = new Date(iso);
const diff = Date.now() - d.getTime();
@@ -563,7 +563,7 @@ function fmtRelative(iso: string): string {
}
}
-function fmtFull(iso: string): string {
+function fmtFull(iso: string | Date): string {
try {
const d = new Date(iso);
return d.toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit" });
@@ -574,7 +574,7 @@ function fmtFull(iso: string): string {
// Compact HH:MM for the mobile activity log, where the full timestamp
// would crowd out the endpoint path.
-function fmtTime(iso: string): string {
+function fmtTime(iso: string | Date): string {
try {
const d = new Date(iso);
return d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
diff --git a/web/src/app/app/api-keys/_components/Sparkline.tsx b/web/src/app/app/api-keys/_components/Sparkline.tsx
index 78f162e86..53c46f2fe 100644
--- a/web/src/app/app/api-keys/_components/Sparkline.tsx
+++ b/web/src/app/app/api-keys/_components/Sparkline.tsx
@@ -59,7 +59,7 @@ export function Sparkline({
}
export interface BucketLike {
- bucket: string;
+ bucket: Date;
success: number;
client_errors: number;
server_errors: number;
@@ -90,7 +90,7 @@ export function StackedBars({
({
- key: b.bucket,
+ key: b.bucket.toISOString(),
parts: [b.success, b.client_errors, b.server_errors],
}))}
tones={["emerald", "amber", "rose"]}
@@ -107,7 +107,7 @@ export function StackedBars({
);
}
-function labelTick(iso: string | undefined): string {
+function labelTick(iso: Date | undefined): string {
if (!iso) return "";
try {
const d = new Date(iso);
diff --git a/web/src/app/app/api-keys/page.tsx b/web/src/app/app/api-keys/page.tsx
index b07fe13ca..d8a67484c 100644
--- a/web/src/app/app/api-keys/page.tsx
+++ b/web/src/app/app/api-keys/page.tsx
@@ -332,7 +332,7 @@ function Legend({ color, label }: { color: string; label: string }) {
);
}
-function fmtRelative(iso: string): string {
+function fmtRelative(iso: string | Date): string {
try {
const d = new Date(iso);
const diff = Date.now() - d.getTime();
@@ -350,7 +350,7 @@ function fmtRelative(iso: string): string {
}
}
-function fmtFull(iso: string): string {
+function fmtFull(iso: string | Date): string {
try {
return new Date(iso).toLocaleString("en-US", {
month: "short",
diff --git a/web/src/app/app/audit/page.tsx b/web/src/app/app/audit/page.tsx
index ef5bce7fe..c860ae53e 100644
--- a/web/src/app/app/audit/page.tsx
+++ b/web/src/app/app/audit/page.tsx
@@ -412,7 +412,7 @@ function actorLabel(log: AuditLog): string {
return "System";
}
-function fmt(d: string) {
+function fmt(d: string | Date) {
if (!d) return "—";
try {
const dt = new Date(d);
diff --git a/web/src/app/app/crm/deals/page.tsx b/web/src/app/app/crm/deals/page.tsx
index f5cfba8d9..757cc4e63 100644
--- a/web/src/app/app/crm/deals/page.tsx
+++ b/web/src/app/app/crm/deals/page.tsx
@@ -56,6 +56,7 @@ import { useLivePatch } from "@/hooks/useLivePatch";
import { cursorColor } from "@/hooks/useLiveCursors";
import ResourceViewers from "@/components/app/presence/ResourceViewers";
import type Deal from "@/lib/api/models/app/crm/Deal";
+import type { DealWrite } from "@/lib/api/models/app/crm/Deal";
import type { Stage } from "@/lib/api/models/app/crm/Pipeline";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
@@ -159,7 +160,7 @@ export default function DealsPage() {
async function moveDeal(dealId: string, newStageId: string) {
try {
await toast.promise(
- updateDeal.mutateAsync({ id: dealId, data: { stage_id: newStageId } as Partial }),
+ updateDeal.mutateAsync({ id: dealId, data: { stage_id: newStageId } as DealWrite }),
{ loading: "Moving…", success: "Moved", error: (e: AppError) => buildError(e) },
);
// Nudge teammates on the same board to update now (the audit refetch is
@@ -694,7 +695,7 @@ function DealDialog({
toast.error("Pick a stage");
return;
}
- const data: Partial = {
+ const data: DealWrite = {
pipeline_id: pipelineId,
stage_id: stageId,
name: name.trim(),
@@ -949,7 +950,7 @@ function formatMoney(n: number | undefined, currency = "USD") {
}
}
-function fmtDate(d: string | undefined) {
+function fmtDate(d: string | Date | undefined) {
if (!d) return "—";
try {
return new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric" });
diff --git a/web/src/app/app/crm/meetings/page.tsx b/web/src/app/app/crm/meetings/page.tsx
index cba0d48f1..7480352c2 100644
--- a/web/src/app/app/crm/meetings/page.tsx
+++ b/web/src/app/app/crm/meetings/page.tsx
@@ -71,7 +71,7 @@ const STATUS_STYLE: Record = {
no_show: { label: "No-show", cls: "bg-red-50 text-red-700 border-red-200" },
};
-function formatWhen(iso?: string): { date: string; time: string; rel: string } {
+function formatWhen(iso?: string | Date): { date: string; time: string; rel: string } {
if (!iso) return { date: "No time set", time: "", rel: "" };
const d = new Date(iso);
if (isNaN(d.getTime())) return { date: "No time set", time: "", rel: "" };
@@ -87,7 +87,7 @@ function formatWhen(iso?: string): { date: string; time: string; rel: string } {
// --- Add to calendar (no API: a Google template link + a downloadable .ics) ---
-function gcalStamp(iso: string): string {
+function gcalStamp(iso: string | Date): string {
return new Date(iso).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
}
diff --git a/web/src/app/app/crm/tasks/page.tsx b/web/src/app/app/crm/tasks/page.tsx
index 05e1480e0..84375f174 100644
--- a/web/src/app/app/crm/tasks/page.tsx
+++ b/web/src/app/app/crm/tasks/page.tsx
@@ -80,6 +80,7 @@ import { useSuppressGlobalCursors } from "@/components/app/presence/GlobalCursor
import { useLivePatch } from "@/hooks/useLivePatch";
import ResourceViewers from "@/components/app/presence/ResourceViewers";
import type CRMTask from "@/lib/api/models/app/crm/CRMTask";
+import type { CRMTaskWrite } from "@/lib/api/models/app/crm/CRMTask";
import type { CRMTaskPriority, CRMTaskStatus } from "@/lib/api/models/app/crm/CRMTask";
import type SearchTasks from "@/lib/api/models/app/crm/SearchTasks";
import type { TaskSortBy } from "@/lib/api/models/app/crm/SearchTasks";
@@ -146,7 +147,7 @@ const TONE = {
muted: { dot: "bg-slate-300", label: "text-slate-500" },
} as const;
-function bucketize(due: string | undefined): Bucket {
+function bucketize(due: string | Date | undefined): Bucket {
if (!due) return "no_due";
const d = new Date(due);
if (Number.isNaN(d.getTime())) return "no_due";
@@ -739,7 +740,7 @@ function FlatRow({
try {
await update.mutateAsync({
id: task.id,
- data: { status: done ? "completed" : "pending" } as Partial,
+ data: { status: done ? "completed" : "pending" } as CRMTaskWrite,
});
} catch (err) {
toast.error(buildError(err as AppError));
@@ -1012,7 +1013,7 @@ function GroupedRow({
e.stopPropagation();
const next: CRMTaskStatus = isDone ? "pending" : "completed";
try {
- await update.mutateAsync({ id: task.id, data: { status: next } as Partial });
+ await update.mutateAsync({ id: task.id, data: { status: next } as CRMTaskWrite });
} catch (err) {
toast.error(buildError(err as AppError));
}
@@ -1210,7 +1211,7 @@ function TaskTypeTag({
);
}
-function DueCell({ due, overdue }: { due: string | undefined; overdue: boolean }) {
+function DueCell({ due, overdue }: { due: string | Date | undefined; overdue: boolean }) {
if (!due) return —;
if (overdue) {
return (
@@ -1823,7 +1824,7 @@ function TaskDialog({
toast.error("Title required");
return;
}
- const data: Partial = {
+ const data: CRMTaskWrite = {
title: title.trim(),
priority,
type,
@@ -2210,7 +2211,7 @@ function hasAnyFilter(f: SearchTasks): boolean {
);
}
-function fmtDue(d: string) {
+function fmtDue(d: string | Date) {
try {
const dt = new Date(d);
const now = new Date();
diff --git a/web/src/app/app/settings/billing/CreditsCard.tsx b/web/src/app/app/settings/billing/CreditsCard.tsx
index e36c53830..45da41820 100644
--- a/web/src/app/app/settings/billing/CreditsCard.tsx
+++ b/web/src/app/app/settings/billing/CreditsCard.tsx
@@ -285,14 +285,14 @@ function describeReason(reason: string): string {
return map[reason] ?? reason.replace(/_/g, " ");
}
-function formatReset(value: string | null | undefined): string {
+function formatReset(value: string | Date | null | undefined): string {
if (!value) return "on renewal";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "on renewal";
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
-function formatWhen(value: string): string {
+function formatWhen(value: string | Date): string {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleDateString("en-US", {
diff --git a/web/src/app/app/settings/billing/page.tsx b/web/src/app/app/settings/billing/page.tsx
index 963ab075c..7a6f1d7f2 100644
--- a/web/src/app/app/settings/billing/page.tsx
+++ b/web/src/app/app/settings/billing/page.tsx
@@ -517,7 +517,7 @@ function describeRedemption(d: DiscountRedemption): string {
return d.type || "Discount";
}
-function fmtDate(value?: string | null): string {
+function fmtDate(value?: string | Date | null): string {
if (!value) return "—";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "—";
diff --git a/web/src/app/app/settings/referral/page.tsx b/web/src/app/app/settings/referral/page.tsx
index 72984889d..0dd6838a2 100644
--- a/web/src/app/app/settings/referral/page.tsx
+++ b/web/src/app/app/settings/referral/page.tsx
@@ -488,7 +488,7 @@ function formatMoney(cents: number, currency: string): string {
}
}
-function formatDate(value?: string): string {
+function formatDate(value?: string | Date): string {
if (!value) return "—";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "—";
diff --git a/web/src/app/app/settings/security/TwoFactorManager.tsx b/web/src/app/app/settings/security/TwoFactorManager.tsx
index a128ac3f9..b1ec16f4c 100644
--- a/web/src/app/app/settings/security/TwoFactorManager.tsx
+++ b/web/src/app/app/settings/security/TwoFactorManager.tsx
@@ -19,7 +19,7 @@ type Dialog = "enroll" | "regenerate" | "disable" | null;
// Below this many unused codes the row turns amber and asks for a new set.
const LOW_CODES = 3;
-const fmt = (d: string) =>
+const fmt = (d: string | Date) =>
new Date(d).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
export default function TwoFactorManager() {
diff --git a/web/src/components/app/agent/AgentPanel.tsx b/web/src/components/app/agent/AgentPanel.tsx
index e6cc37cf2..c01920de4 100644
--- a/web/src/components/app/agent/AgentPanel.tsx
+++ b/web/src/components/app/agent/AgentPanel.tsx
@@ -1166,7 +1166,7 @@ function DockBar({
// ── History rail ────────────────────────────────────────────────────
// historyBucket labels a session's recency group for the sidebar sections.
-function historyBucket(iso: string): string {
+function historyBucket(iso: string | Date): string {
const t = new Date(iso).getTime();
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
diff --git a/web/src/components/app/analytics/DirectMailSection.tsx b/web/src/components/app/analytics/DirectMailSection.tsx
index d550e378e..e361c32a6 100644
--- a/web/src/components/app/analytics/DirectMailSection.tsx
+++ b/web/src/components/app/analytics/DirectMailSection.tsx
@@ -16,6 +16,7 @@ import { EyeIcon, InboxIcon, MailIcon, ReplyIcon } from "lucide-react";
import { EmptyBlock, SectionBar, Stat, StatStrip } from "@/components/layout/Page";
import { MultiTrend, type TrendSeries } from "@/components/ui/charts";
+import { utcDay } from "@/lib/campaignPeriod";
import useDirectMail from "@/lib/api/hooks/app/analytics/useDirectMail";
import type DirectMailAnalytics from "@/lib/api/models/app/analytics/DirectMailAnalytics";
@@ -60,7 +61,7 @@ export default function DirectMailSection({ period }: { period: string }) {
{ key: "sent", label: "Sent", tone: "sky", values: (d?.daily_trend ?? []).map((x) => x.sent) },
{ key: "received", label: "Received", tone: "emerald", values: (d?.daily_trend ?? []).map((x) => x.received) },
];
- const labels = (d?.daily_trend ?? []).map((x) => x.date);
+ const labels = (d?.daily_trend ?? []).map((x) => utcDay(x.date));
if (q.isError) {
return (
diff --git a/web/src/components/app/campaigns/LaunchCampaignDialog.tsx b/web/src/components/app/campaigns/LaunchCampaignDialog.tsx
index 6960aef78..23d4e725f 100644
--- a/web/src/components/app/campaigns/LaunchCampaignDialog.tsx
+++ b/web/src/components/app/campaigns/LaunchCampaignDialog.tsx
@@ -133,7 +133,8 @@ export default function LaunchCampaignDialog({
setRiskBlocked(false);
setWaiting(false);
}
- }, [campaign]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the id: a launch changes the campaign itself
+ }, [campaign?.id]);
React.useEffect(() => {
return () => {
diff --git a/web/src/components/app/campaigns/SendPlanCard.tsx b/web/src/components/app/campaigns/SendPlanCard.tsx
index 8a0a4663e..0e3be9cfe 100644
--- a/web/src/components/app/campaigns/SendPlanCard.tsx
+++ b/web/src/components/app/campaigns/SendPlanCard.tsx
@@ -123,7 +123,7 @@ const LIMITED_BY: Record = {
workspace_risk: "workspace posture",
};
-function fmtTime(iso: string | undefined, tz: string): string {
+function fmtTime(iso: string | Date | undefined, tz: string): string {
if (!iso) return "";
try {
return new Date(iso).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", timeZone: tz });
@@ -132,7 +132,7 @@ function fmtTime(iso: string | undefined, tz: string): string {
}
}
-function fmtDay(iso: string | undefined, tz: string): string {
+function fmtDay(iso: string | Date | undefined, tz: string): string {
if (!iso) return "";
const d = new Date(iso);
const today = new Date();
diff --git a/web/src/components/app/contacts/ImportWizard.tsx b/web/src/components/app/contacts/ImportWizard.tsx
index de6b43ee7..e87c5af0b 100644
--- a/web/src/components/app/contacts/ImportWizard.tsx
+++ b/web/src/components/app/contacts/ImportWizard.tsx
@@ -1219,7 +1219,7 @@ function StatCard({
);
}
-function durationText(start: string, end: string): string {
+function durationText(start: string | Date, end: string | Date): string {
const s = new Date(start).getTime();
const e = new Date(end).getTime();
if (Number.isNaN(s) || Number.isNaN(e)) return "—";
diff --git a/web/src/components/app/contacts/VerificationBadge.test.ts b/web/src/components/app/contacts/VerificationBadge.test.ts
index b3fa54b04..035e5ea90 100644
--- a/web/src/components/app/contacts/VerificationBadge.test.ts
+++ b/web/src/components/app/contacts/VerificationBadge.test.ts
@@ -23,7 +23,7 @@ describe("verification provider attribution", () => {
verification_status: "invalid",
verification_source: "provider",
verification_provider: "millionverifier",
- verification_requested_at: "2026-09-22T10:00:00Z",
+ verification_requested_at: new Date("2026-09-22T10:00:00Z"),
})).toBe("Undeliverable · verified with MillionVerifier · re-check queued");
});
diff --git a/web/src/components/app/contacts/columns.tsx b/web/src/components/app/contacts/columns.tsx
index 620f02f93..7c6278d2d 100644
--- a/web/src/components/app/contacts/columns.tsx
+++ b/web/src/components/app/contacts/columns.tsx
@@ -42,9 +42,9 @@ export interface ContactRow {
verification_sub_status?: string;
verification_source?: VerificationSource;
verification_provider?: string;
- verification_checked_at?: string | null;
+ verification_checked_at?: Date | null;
verification_confidence?: number;
- verification_requested_at?: string | null;
+ verification_requested_at?: Date | null;
mail_host?: string;
created_at: Date;
updated_at?: Date;
diff --git a/web/src/components/app/contacts/contact-edit/OverviewTab.tsx b/web/src/components/app/contacts/contact-edit/OverviewTab.tsx
index c841b12d1..94dc25930 100644
--- a/web/src/components/app/contacts/contact-edit/OverviewTab.tsx
+++ b/web/src/components/app/contacts/contact-edit/OverviewTab.tsx
@@ -365,7 +365,7 @@ function LatestRow({
icon,
}: {
label: string;
- ts?: string | null;
+ ts?: Date | string | null;
icon: React.ReactNode;
}) {
return (
diff --git a/web/src/components/app/crm/DealsTable.tsx b/web/src/components/app/crm/DealsTable.tsx
index 61c8be4b7..e63f652d1 100644
--- a/web/src/components/app/crm/DealsTable.tsx
+++ b/web/src/components/app/crm/DealsTable.tsx
@@ -570,7 +570,7 @@ function money(n: number | undefined, currency = "USD") {
}
}
-function fmtDate(d: string | undefined) {
+function fmtDate(d: string | Date | undefined) {
if (!d) return "—";
try {
return new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric" });
diff --git a/web/src/components/app/emails/SyncStatusCard.tsx b/web/src/components/app/emails/SyncStatusCard.tsx
index a3bbd0b36..74575c482 100644
--- a/web/src/components/app/emails/SyncStatusCard.tsx
+++ b/web/src/components/app/emails/SyncStatusCard.tsx
@@ -25,7 +25,7 @@ const REASON_COPY: Record = {
priority_daily: "the daily limit for replies was reached",
};
-function relative(iso: string): string {
+function relative(iso: string | Date): string {
const diff = Date.now() - new Date(iso).getTime();
const m = Math.round(diff / 60_000);
if (m < 1) return "just now";
@@ -35,7 +35,7 @@ function relative(iso: string): string {
return new Date(iso).toLocaleDateString();
}
-function until(iso: string): string {
+function until(iso: string | Date): string {
const d = new Date(iso);
const sameDay = d.toDateString() === new Date().toDateString();
return sameDay
diff --git a/web/src/components/app/unibox/ContactContextPanel.tsx b/web/src/components/app/unibox/ContactContextPanel.tsx
index f2aeccd97..b8494d97d 100644
--- a/web/src/components/app/unibox/ContactContextPanel.tsx
+++ b/web/src/components/app/unibox/ContactContextPanel.tsx
@@ -61,7 +61,9 @@ import useUpdateDeal from "@/lib/api/hooks/app/crm/deals/useUpdateDeal";
import usePipelines from "@/lib/api/hooks/app/crm/pipelines/usePipelines";
import type { Stage } from "@/lib/api/models/app/crm/Pipeline";
import type Deal from "@/lib/api/models/app/crm/Deal";
+import type { DealWrite } from "@/lib/api/models/app/crm/Deal";
import type CRMTask from "@/lib/api/models/app/crm/CRMTask";
+import type { CRMTaskWrite } from "@/lib/api/models/app/crm/CRMTask";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
@@ -369,7 +371,7 @@ function campaignLine(s: ContactCampaignState): { text: string; title?: string }
return { text, title: text };
}
-function shortDate(iso: string): string {
+function shortDate(iso: string | Date): string {
return new Date(iso).toLocaleString(undefined, {
month: "short",
day: "numeric",
@@ -424,7 +426,7 @@ function DealsSection({
toast.error("Deal name required");
return;
}
- const data: Partial = {
+ const data: DealWrite = {
pipeline_id: pipelineId,
stage_id: stageId,
name: name.trim(),
@@ -453,7 +455,7 @@ function DealsSection({
async function moveDeal(dealId: string, newStageId: string) {
try {
await toast.promise(
- updateDeal.mutateAsync({ id: dealId, data: { stage_id: newStageId } as Partial }),
+ updateDeal.mutateAsync({ id: dealId, data: { stage_id: newStageId } as DealWrite }),
{ loading: "Moving…", success: "Moved", error: (e: AppError) => buildError(e) },
);
} catch {
@@ -639,7 +641,7 @@ function TasksSection({
toast.error("Task title required");
return;
}
- const data: Partial = {
+ const data: CRMTaskWrite = {
title: title.trim(),
contact_id: contactId,
priority,
diff --git a/web/src/components/app/unibox/ScheduledList.tsx b/web/src/components/app/unibox/ScheduledList.tsx
index 4a8fa6ce6..6d06c0729 100644
--- a/web/src/components/app/unibox/ScheduledList.tsx
+++ b/web/src/components/app/unibox/ScheduledList.tsx
@@ -26,7 +26,7 @@ import type UniboxScheduledItem from "@/lib/api/models/app/unibox/UniboxSchedule
import { cn } from "@/lib/utils";
import ComposeButton from "@/components/app/unibox/compose/ComposeButton";
-function formatWhen(iso: string): { absolute: string; relative: string } {
+function formatWhen(iso: string | Date): { absolute: string; relative: string } {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return { absolute: "—", relative: "" };
diff --git a/web/src/components/app/unibox/ThreadView.tsx b/web/src/components/app/unibox/ThreadView.tsx
index 1c724b25d..bdffafff9 100644
--- a/web/src/components/app/unibox/ThreadView.tsx
+++ b/web/src/components/app/unibox/ThreadView.tsx
@@ -777,9 +777,9 @@ function IconAction({
// Friendly relative-or-absolute time used for scheduled cards.
// Examples: "in 12 min", "in 3 h", "tomorrow, 09:00", "Mar 5, 17:00".
-function formatScheduled(iso: string): string {
+function formatScheduled(iso: string | Date): string {
const d = new Date(iso);
- if (!Number.isFinite(d.getTime())) return iso;
+ if (!Number.isFinite(d.getTime())) return String(iso);
const now = new Date();
const diffMs = d.getTime() - now.getTime();
const diffMin = Math.round(diffMs / 60_000);
diff --git a/web/src/components/app/unibox/compose/ComposeDraftsItem.tsx b/web/src/components/app/unibox/compose/ComposeDraftsItem.tsx
index f12dd2206..c778bdab9 100644
--- a/web/src/components/app/unibox/compose/ComposeDraftsItem.tsx
+++ b/web/src/components/app/unibox/compose/ComposeDraftsItem.tsx
@@ -15,7 +15,7 @@ function draftTitle(d: ComposeDraft): string {
return d.subject.trim() || d.to[0] || "(no subject)";
}
-function formatWhen(iso: string): string {
+function formatWhen(iso: string | Date): string {
const d = new Date(iso);
const now = new Date();
const sameDay =
diff --git a/web/src/components/app/unibox/compose/ComposeHistoryPanel.tsx b/web/src/components/app/unibox/compose/ComposeHistoryPanel.tsx
index a03617e36..2b9a40076 100644
--- a/web/src/components/app/unibox/compose/ComposeHistoryPanel.tsx
+++ b/web/src/components/app/unibox/compose/ComposeHistoryPanel.tsx
@@ -30,7 +30,7 @@ interface ComposeHistoryPanelProps {
affinityLine?: string;
}
-function formatWhen(iso: string): string {
+function formatWhen(iso: string | Date): string {
const d = new Date(iso);
const now = new Date();
const sameYear = d.getFullYear() === now.getFullYear();
diff --git a/web/src/components/app/unibox/compose/ComposeWindow.tsx b/web/src/components/app/unibox/compose/ComposeWindow.tsx
index 93b1426d2..8a350f899 100644
--- a/web/src/components/app/unibox/compose/ComposeWindow.tsx
+++ b/web/src/components/app/unibox/compose/ComposeWindow.tsx
@@ -367,7 +367,7 @@ function ComposeWindowInner({
// Undo window: the send is queued a few seconds out. No
// "sent" toast; the header pill counts down and can cancel,
// reopening the composer from this seed.
- const nowIso = new Date().toISOString();
+ const now = new Date();
addOutbox({
taskId: res.task_id,
scheduledAt: resolveSendAt(res.scheduled_at, user.undo_send_seconds || 30),
@@ -382,8 +382,8 @@ function ComposeWindowInner({
bcc,
subject: subject.trim(),
body: trimmedBody,
- updated_at: nowIso,
- created_at: nowIso,
+ updated_at: now,
+ created_at: now,
},
});
} else {
diff --git a/web/src/components/layout/CreditsMeter.tsx b/web/src/components/layout/CreditsMeter.tsx
index 12f2790a0..baf059190 100644
--- a/web/src/components/layout/CreditsMeter.tsx
+++ b/web/src/components/layout/CreditsMeter.tsx
@@ -321,7 +321,7 @@ function formatCredits(n: number): string {
}
// resetLabel turns next_reset_at into a short relative note for the panel.
-function resetLabel(iso: string | null): string | null {
+function resetLabel(iso: string | Date | null): string | null {
if (!iso) return null;
const days = Math.ceil((new Date(iso).getTime() - Date.now()) / 86_400_000);
if (days <= 0) return "Resets soon";
diff --git a/web/src/components/layout/ErrorBoundary.tsx b/web/src/components/layout/ErrorBoundary.tsx
index 045a91c41..10d023458 100644
--- a/web/src/components/layout/ErrorBoundary.tsx
+++ b/web/src/components/layout/ErrorBoundary.tsx
@@ -13,6 +13,7 @@ import React from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { AlertTriangleIcon, RefreshCcwIcon } from "lucide-react";
import { captureException } from "@/lib/observability";
+import { SENTRY_RELEASE } from "@/lib/information";
interface State {
error: Error | null;
@@ -108,6 +109,7 @@ function BoundaryFallback({ error, info, reset }: { error: Error; info: React.Er
Stack
+ {SENTRY_RELEASE ? `Build: ${SENTRY_RELEASE}\n\n` : ""}
{error.stack || ""}
{info?.componentStack ? `\n\nComponent stack:${info.componentStack}` : ""}
diff --git a/web/src/components/layout/NotificationBell.tsx b/web/src/components/layout/NotificationBell.tsx
index b9ed8d5c8..517817f71 100644
--- a/web/src/components/layout/NotificationBell.tsx
+++ b/web/src/components/layout/NotificationBell.tsx
@@ -73,7 +73,7 @@ function bucketFor(d: Date): Bucket {
}
// Compact relative timestamp: "now", "2m", "3h", "Yesterday", then a short date.
-function relTime(iso: string): string {
+function relTime(iso: string | Date): string {
const d = new Date(iso);
const s = Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000));
if (s < 60) return "now";
diff --git a/web/src/lib/api/client/app/campaigns/estimateCampaign.ts b/web/src/lib/api/client/app/campaigns/estimateCampaign.ts
index 0b4509ecb..0457f5b78 100644
--- a/web/src/lib/api/client/app/campaigns/estimateCampaign.ts
+++ b/web/src/lib/api/client/app/campaigns/estimateCampaign.ts
@@ -22,7 +22,7 @@ export interface CampaignEstimateResult {
remaining_today: number;
// Null when the pool has no capacity or the audience is empty.
sending_days: number | null;
- estimated_finish_at: string | null;
+ estimated_finish_at: Date | null;
}
export default async function estimateCampaign(input: CampaignEstimateInput): Promise {
diff --git a/web/src/lib/api/client/app/campaigns/getCampaignLogs.ts b/web/src/lib/api/client/app/campaigns/getCampaignLogs.ts
index 4115a40b1..baa631c55 100644
--- a/web/src/lib/api/client/app/campaigns/getCampaignLogs.ts
+++ b/web/src/lib/api/client/app/campaigns/getCampaignLogs.ts
@@ -6,7 +6,7 @@ interface CampaignLogRow {
event_type: string;
message: string;
metadata?: Record | null;
- created_at: string;
+ created_at: Date;
}
export interface CampaignLogItem {
diff --git a/web/src/lib/api/client/app/contacts/importContacts.ts b/web/src/lib/api/client/app/contacts/importContacts.ts
index ab8561471..72ec39fd2 100644
--- a/web/src/lib/api/client/app/contacts/importContacts.ts
+++ b/web/src/lib/api/client/app/contacts/importContacts.ts
@@ -66,8 +66,9 @@ export interface ImportResult {
updated: number;
skipped: number;
failed: number;
- started_at: string;
- ended_at: string;
+ // A Date when revived through Request (a lead-sync run); a string from the direct import upload.
+ started_at: Date | string;
+ ended_at: Date | string;
errors?: ImportRowError[];
// Set when more rows failed than the API reports back; `errors` then holds
// the first slice of them and `failed` is the true count.
diff --git a/web/src/lib/api/client/app/crm/deals/createDeal.ts b/web/src/lib/api/client/app/crm/deals/createDeal.ts
index 83a9b5d26..965fe71e3 100644
--- a/web/src/lib/api/client/app/crm/deals/createDeal.ts
+++ b/web/src/lib/api/client/app/crm/deals/createDeal.ts
@@ -1,7 +1,8 @@
import type Deal from "@/lib/api/models/app/crm/Deal";
+import type { DealWrite } from "@/lib/api/models/app/crm/Deal";
import Request from "../../../Request";
-export default async function createDeal(data: Partial): Promise {
+export default async function createDeal(data: DealWrite): Promise {
return await Request({
method: "POST",
url: `/crm/deals`,
diff --git a/web/src/lib/api/client/app/crm/deals/updateDeal.ts b/web/src/lib/api/client/app/crm/deals/updateDeal.ts
index 9163db0b5..a8876ad54 100644
--- a/web/src/lib/api/client/app/crm/deals/updateDeal.ts
+++ b/web/src/lib/api/client/app/crm/deals/updateDeal.ts
@@ -1,7 +1,8 @@
import type Deal from "@/lib/api/models/app/crm/Deal";
+import type { DealWrite } from "@/lib/api/models/app/crm/Deal";
import Request from "../../../Request";
-export default async function updateDeal(id: string, data: Partial): Promise {
+export default async function updateDeal(id: string, data: DealWrite): Promise {
return await Request({
method: "PATCH",
url: `/crm/deals/${id}`,
diff --git a/web/src/lib/api/client/app/crm/tasks/createCRMTask.ts b/web/src/lib/api/client/app/crm/tasks/createCRMTask.ts
index a58c143db..1cc10ede5 100644
--- a/web/src/lib/api/client/app/crm/tasks/createCRMTask.ts
+++ b/web/src/lib/api/client/app/crm/tasks/createCRMTask.ts
@@ -1,7 +1,8 @@
import type CRMTask from "@/lib/api/models/app/crm/CRMTask";
+import type { CRMTaskWrite } from "@/lib/api/models/app/crm/CRMTask";
import Request from "../../../Request";
-export default async function createCRMTask(data: Partial): Promise {
+export default async function createCRMTask(data: CRMTaskWrite): Promise {
return await Request({
method: "POST",
url: `/crm/tasks`,
diff --git a/web/src/lib/api/client/app/crm/tasks/updateCRMTask.ts b/web/src/lib/api/client/app/crm/tasks/updateCRMTask.ts
index 3ae407c64..108c04796 100644
--- a/web/src/lib/api/client/app/crm/tasks/updateCRMTask.ts
+++ b/web/src/lib/api/client/app/crm/tasks/updateCRMTask.ts
@@ -1,7 +1,8 @@
import type CRMTask from "@/lib/api/models/app/crm/CRMTask";
+import type { CRMTaskWrite } from "@/lib/api/models/app/crm/CRMTask";
import Request from "../../../Request";
-export default async function updateCRMTask(id: string, data: Partial): Promise {
+export default async function updateCRMTask(id: string, data: CRMTaskWrite): Promise {
return await Request({
method: "PATCH",
url: `/crm/tasks/${id}`,
diff --git a/web/src/lib/api/client/app/organizations/getOrganizations.ts b/web/src/lib/api/client/app/organizations/getOrganizations.ts
index d27bc5e21..3e7a7762e 100644
--- a/web/src/lib/api/client/app/organizations/getOrganizations.ts
+++ b/web/src/lib/api/client/app/organizations/getOrganizations.ts
@@ -15,7 +15,7 @@ interface RawMembership {
avatar?: string;
avatar_url?: string | null;
plan?: string;
- created_at: string;
+ created_at: Date;
};
}
diff --git a/web/src/lib/api/client/app/unibox/agentDrafts.ts b/web/src/lib/api/client/app/unibox/agentDrafts.ts
index e19b77ca2..b6624efec 100644
--- a/web/src/lib/api/client/app/unibox/agentDrafts.ts
+++ b/web/src/lib/api/client/app/unibox/agentDrafts.ts
@@ -19,8 +19,8 @@ export interface AIThreadDraft {
confidence: number
model: string
status: "pending" | "approved" | "discarded"
- created_at: string
- updated_at: string
+ created_at: Date
+ updated_at: Date
}
// listAgentDrafts returns the org's pending inbox-agent drafts, newest first.
diff --git a/web/src/lib/api/client/app/unibox/composeDrafts.ts b/web/src/lib/api/client/app/unibox/composeDrafts.ts
index 008bc99ea..d46d439c0 100644
--- a/web/src/lib/api/client/app/unibox/composeDrafts.ts
+++ b/web/src/lib/api/client/app/unibox/composeDrafts.ts
@@ -12,8 +12,8 @@ export interface ComposeDraft {
bcc: string[];
subject: string;
body: string;
- updated_at: string;
- created_at: string;
+ updated_at: Date;
+ created_at: Date;
}
export interface ComposeDraftSaveInput {
diff --git a/web/src/lib/api/client/app/unibox/searchIncoming.ts b/web/src/lib/api/client/app/unibox/searchIncoming.ts
index 3bf346c3b..713d8bb00 100644
--- a/web/src/lib/api/client/app/unibox/searchIncoming.ts
+++ b/web/src/lib/api/client/app/unibox/searchIncoming.ts
@@ -14,7 +14,7 @@ export interface UniboxListRow {
to_addr: string[];
subject: string;
snippet: string;
- internal_date: string;
+ internal_date: Date;
seen: boolean;
/** Messages in the conversation behind this row (1 = singleton). */
message_count: number;
diff --git a/web/src/lib/api/client/app/unibox/snoozeThread.ts b/web/src/lib/api/client/app/unibox/snoozeThread.ts
index e6142008f..6fadef253 100644
--- a/web/src/lib/api/client/app/unibox/snoozeThread.ts
+++ b/web/src/lib/api/client/app/unibox/snoozeThread.ts
@@ -10,7 +10,7 @@ export interface SnoozeRequest {
export interface SnoozeResponse {
id: string;
thread_id: string;
- snoozed_until: string;
+ snoozed_until: Date;
}
export async function snoozeThread(req: SnoozeRequest): Promise {
diff --git a/web/src/lib/api/client/auth/twoFactor.ts b/web/src/lib/api/client/auth/twoFactor.ts
index 918500469..08b3f6c4a 100644
--- a/web/src/lib/api/client/auth/twoFactor.ts
+++ b/web/src/lib/api/client/auth/twoFactor.ts
@@ -4,7 +4,7 @@ import Request from "../Request";
export interface TwoFactorStatus {
enabled: boolean;
/** When the authenticator was confirmed. Absent while 2FA is off. */
- confirmed_at?: string;
+ confirmed_at?: Date;
recovery_codes_remaining: number;
recovery_codes_total: number;
}
diff --git a/web/src/lib/api/hooks/app/crm/deals/useCreateDeal.ts b/web/src/lib/api/hooks/app/crm/deals/useCreateDeal.ts
index 231c60434..cf801702c 100644
--- a/web/src/lib/api/hooks/app/crm/deals/useCreateDeal.ts
+++ b/web/src/lib/api/hooks/app/crm/deals/useCreateDeal.ts
@@ -1,12 +1,12 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
-import type Deal from "@/lib/api/models/app/crm/Deal";
+import type { DealWrite } from "@/lib/api/models/app/crm/Deal";
import createDeal from "@/lib/api/client/app/crm/deals/createDeal";
export default function useCreateDeal() {
const queryClient = useQueryClient();
return useMutation({
- mutationFn: (data: Partial) => createDeal(data),
+ mutationFn: (data: DealWrite) => createDeal(data),
onSuccess: (_result, variables) => {
// Broad prefix so the board list, the cross-pipeline search table,
// and the summary aggregates all refresh.
diff --git a/web/src/lib/api/hooks/app/crm/deals/useUpdateDeal.ts b/web/src/lib/api/hooks/app/crm/deals/useUpdateDeal.ts
index 1ceccbcff..d82269109 100644
--- a/web/src/lib/api/hooks/app/crm/deals/useUpdateDeal.ts
+++ b/web/src/lib/api/hooks/app/crm/deals/useUpdateDeal.ts
@@ -1,12 +1,12 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
-import type Deal from "@/lib/api/models/app/crm/Deal";
+import type { DealWrite } from "@/lib/api/models/app/crm/Deal";
import updateDeal from "@/lib/api/client/app/crm/deals/updateDeal";
export default function useUpdateDeal() {
const queryClient = useQueryClient();
return useMutation({
- mutationFn: ({ id, data }: { id: string; data: Partial }) => updateDeal(id, data),
+ mutationFn: ({ id, data }: { id: string; data: DealWrite }) => updateDeal(id, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["crm", "deals"],
diff --git a/web/src/lib/api/hooks/app/crm/tasks/useCreateCRMTask.ts b/web/src/lib/api/hooks/app/crm/tasks/useCreateCRMTask.ts
index 2323dd46c..e5960f56f 100644
--- a/web/src/lib/api/hooks/app/crm/tasks/useCreateCRMTask.ts
+++ b/web/src/lib/api/hooks/app/crm/tasks/useCreateCRMTask.ts
@@ -1,5 +1,5 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
-import type CRMTask from "@/lib/api/models/app/crm/CRMTask";
+import type { CRMTaskWrite } from "@/lib/api/models/app/crm/CRMTask";
import createCRMTask from "@/lib/api/client/app/crm/tasks/createCRMTask";
import { useLivePatch } from "@/hooks/useLivePatch";
@@ -8,7 +8,7 @@ export default function useCreateCRMTask() {
const { pushPatch } = useLivePatch("crm_tasks");
return useMutation({
- mutationFn: (data: Partial) => createCRMTask(data),
+ mutationFn: (data: CRMTaskWrite) => createCRMTask(data),
onSuccess: () => {
// Broad prefix so the server-driven search list, the summary totals,
// and the sidebar overdue indicator all refresh on create.
diff --git a/web/src/lib/api/hooks/app/crm/tasks/useUpdateCRMTask.ts b/web/src/lib/api/hooks/app/crm/tasks/useUpdateCRMTask.ts
index bc69a79ca..353e9a727 100644
--- a/web/src/lib/api/hooks/app/crm/tasks/useUpdateCRMTask.ts
+++ b/web/src/lib/api/hooks/app/crm/tasks/useUpdateCRMTask.ts
@@ -1,5 +1,5 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
-import type CRMTask from "@/lib/api/models/app/crm/CRMTask";
+import type { CRMTaskWrite } from "@/lib/api/models/app/crm/CRMTask";
import updateCRMTask from "@/lib/api/client/app/crm/tasks/updateCRMTask";
import { useLivePatch } from "@/hooks/useLivePatch";
@@ -10,7 +10,7 @@ export default function useUpdateCRMTask() {
const { pushPatch } = useLivePatch("crm_tasks");
return useMutation({
- mutationFn: ({ id, data }: { id: string; data: Partial }) => updateCRMTask(id, data),
+ mutationFn: ({ id, data }: { id: string; data: CRMTaskWrite }) => updateCRMTask(id, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["crm", "tasks"],
diff --git a/web/src/lib/api/models/app/advisor/Advisor.ts b/web/src/lib/api/models/app/advisor/Advisor.ts
index b17776f0b..03cdabdf5 100644
--- a/web/src/lib/api/models/app/advisor/Advisor.ts
+++ b/web/src/lib/api/models/app/advisor/Advisor.ts
@@ -107,10 +107,10 @@ export interface AdvisorFinding {
evidence?: Record;
action?: AdvisorAction;
- first_seen_at: string;
- last_seen_at: string;
- snoozed_until?: string;
- applied_at?: string;
+ first_seen_at: Date;
+ last_seen_at: Date;
+ snoozed_until?: Date;
+ applied_at?: Date;
applied_result?: string;
}
@@ -129,7 +129,7 @@ export interface AdvisorSummary {
medium: number;
low: number;
surfaces: AdvisorSurfaceCount[];
- last_run_at?: string;
+ last_run_at?: Date;
}
export interface AdvisorSettings {
@@ -142,7 +142,7 @@ export interface AdvisorSettings {
// it on.
autopilot: boolean;
autopilot_actor_id?: string;
- updated_at: string;
+ updated_at: Date;
}
export interface AdvisorFindingsQuery {
diff --git a/web/src/lib/api/models/app/agent/Agent.ts b/web/src/lib/api/models/app/agent/Agent.ts
index 9d28598e0..f6c628289 100644
--- a/web/src/lib/api/models/app/agent/Agent.ts
+++ b/web/src/lib/api/models/app/agent/Agent.ts
@@ -14,8 +14,8 @@ export interface AgentSession {
model?: string;
pending?: PendingAgentTool | null;
};
- created_at: string;
- updated_at: string;
+ created_at: Date;
+ updated_at: Date;
}
export interface PendingAgentTool {
diff --git a/web/src/lib/api/models/app/analytics/AccountStatus.ts b/web/src/lib/api/models/app/analytics/AccountStatus.ts
index 8fd55d04d..0c4e9f6c0 100644
--- a/web/src/lib/api/models/app/analytics/AccountStatus.ts
+++ b/web/src/lib/api/models/app/analytics/AccountStatus.ts
@@ -17,7 +17,7 @@ export interface AccountError {
title: string;
message: string;
action_required?: string;
- created_at: string;
+ created_at: Date;
}
export interface AccountDailyUsage {
@@ -31,8 +31,8 @@ export interface AccountDailyUsage {
export interface WarmupStatusInfo {
enabled: boolean;
paused: boolean;
- paused_at?: string | null;
- started_at: string;
+ paused_at?: Date | null;
+ started_at: Date;
current_volume: number;
target_volume: number;
max_volume: number;
@@ -58,7 +58,7 @@ export interface WarmupRampHold {
placements: number;
sends: number;
volume_cut: boolean;
- resumes_at: string;
+ resumes_at: Date;
}
// Warmup-pool reputation for this mailbox. Folded into health.score and also
@@ -71,8 +71,8 @@ export interface WarmupHealthInfo {
/** @deprecated Always 0 since the warmup spam score was retired; read score and reason. */
spam_score: number;
reason?: string;
- blocked_until?: string | null;
- evaluated_at?: string | null;
+ blocked_until?: Date | null;
+ evaluated_at?: Date | null;
/** Distinct warmup partners over the last 7 days: mailboxes, their domains, and the workspaces behind them. */
partner_mailboxes_7d: number;
partner_domains_7d: number;
@@ -87,7 +87,7 @@ export default interface AccountStatus {
email: string;
provider: string;
status: string;
- last_synced_at: string | null;
+ last_synced_at: Date | null;
health: AccountHealth;
errors: AccountError[];
daily_usage: AccountDailyUsage;
@@ -117,6 +117,6 @@ export type SendLifecycle = "active" | "resting" | "reserve";
// Why a mailbox is not being offered cold sends.
export interface SendLifecycleState {
state: SendLifecycle;
- since?: string;
+ since?: Date;
reason?: string;
}
diff --git a/web/src/lib/api/models/app/analytics/DashboardOverview.ts b/web/src/lib/api/models/app/analytics/DashboardOverview.ts
index 67e1cf9cf..0295c8ae5 100644
--- a/web/src/lib/api/models/app/analytics/DashboardOverview.ts
+++ b/web/src/lib/api/models/app/analytics/DashboardOverview.ts
@@ -29,7 +29,7 @@ export interface RecentActivityItem {
campaign_name: string
contact_email: string
contact_id?: string
- timestamp: string
+ timestamp: Date
link?: string
// Client, device and location of a person's open or click, when logged.
origin?: EngagementOrigin
diff --git a/web/src/lib/api/models/app/analytics/Deliverability.ts b/web/src/lib/api/models/app/analytics/Deliverability.ts
index c3c56476c..7f86a6d09 100644
--- a/web/src/lib/api/models/app/analytics/Deliverability.ts
+++ b/web/src/lib/api/models/app/analytics/Deliverability.ts
@@ -64,8 +64,8 @@ export interface WarmupHostPlacement {
}
export default interface DeliverabilityDashboard {
- from: string;
- to: string;
+ from: Date;
+ to: Date;
events_total: number;
bounce_count: number;
complaint_count: number;
diff --git a/web/src/lib/api/models/app/analytics/DirectMailAnalytics.ts b/web/src/lib/api/models/app/analytics/DirectMailAnalytics.ts
index 6ad19a4ae..634ed22c7 100644
--- a/web/src/lib/api/models/app/analytics/DirectMailAnalytics.ts
+++ b/web/src/lib/api/models/app/analytics/DirectMailAnalytics.ts
@@ -41,7 +41,7 @@ export interface DirectMailTracking {
}
export interface DirectMailDailyStats {
- date: string;
+ date: Date;
sent: number;
received: number;
}
@@ -58,7 +58,7 @@ export interface DirectMailContact {
email: string;
sent: number;
received: number;
- last_at: string;
+ last_at: Date;
}
export default interface DirectMailAnalytics {
diff --git a/web/src/lib/api/models/app/apikeys/APIKey.ts b/web/src/lib/api/models/app/apikeys/APIKey.ts
index 261e95f11..83d484223 100644
--- a/web/src/lib/api/models/app/apikeys/APIKey.ts
+++ b/web/src/lib/api/models/app/apikeys/APIKey.ts
@@ -23,14 +23,14 @@ export default interface APIKey {
rate_limit_per_minute: number;
status: APIKeyStatus;
- last_used_at?: string | null;
+ last_used_at?: Date | null;
last_request_ip?: string | null;
- expires_at?: string | null;
- revoked_at?: string | null;
+ expires_at?: Date | null;
+ revoked_at?: Date | null;
revoked_reason?: string | null;
- created_at: string;
- updated_at: string;
+ created_at: Date;
+ updated_at: Date;
}
// The `status` column only ever holds "active" or "revoked": expiry is applied
diff --git a/web/src/lib/api/models/app/apikeys/APIKeyAnalytics.ts b/web/src/lib/api/models/app/apikeys/APIKeyAnalytics.ts
index eb0681177..0425f539c 100644
--- a/web/src/lib/api/models/app/apikeys/APIKeyAnalytics.ts
+++ b/web/src/lib/api/models/app/apikeys/APIKeyAnalytics.ts
@@ -5,11 +5,11 @@ export interface APIKeyUsageSummary {
requests_24h: number;
errors_24h: number;
avg_latency_ms_24h: number;
- last_call_at?: string | null;
+ last_call_at?: Date | null;
}
export interface APIKeyUsageBucket {
- bucket: string;
+ bucket: Date;
total: number;
success: number;
client_errors: number;
@@ -27,8 +27,8 @@ export interface APIKeyEndpointStat {
export interface APIKeyAnalytics {
api_key_id: string;
- from: string;
- to: string;
+ from: Date;
+ to: Date;
interval: "minute" | "hour" | "day";
buckets: APIKeyUsageBucket[];
endpoints: APIKeyEndpointStat[];
@@ -45,7 +45,7 @@ export interface APIKeyUsageLog {
user_agent: string;
response_code: number;
response_time_ms: number;
- created_at: string;
+ created_at: Date;
}
export interface APIKeyUsageLogsResult {
diff --git a/web/src/lib/api/models/app/audit/AuditLog.ts b/web/src/lib/api/models/app/audit/AuditLog.ts
index b6e0f5917..ab337176b 100644
--- a/web/src/lib/api/models/app/audit/AuditLog.ts
+++ b/web/src/lib/api/models/app/audit/AuditLog.ts
@@ -82,7 +82,7 @@ export default interface AuditLog {
org_id: string;
user_id: string; // actor id (kept for backwards compatibility)
actor?: AuditActor;
- action_date: string;
+ action_date: Date;
action: AuditAction;
entity_type: AuditEntityType;
entity_id?: string;
@@ -90,7 +90,7 @@ export default interface AuditLog {
user_agent: string;
changes?: Record;
metadata?: Record;
- timestamp: string;
+ timestamp: Date;
}
export interface AuditLogsResult {
diff --git a/web/src/lib/api/models/app/automations/Automation.ts b/web/src/lib/api/models/app/automations/Automation.ts
index 12663a7d9..53db5c337 100644
--- a/web/src/lib/api/models/app/automations/Automation.ts
+++ b/web/src/lib/api/models/app/automations/Automation.ts
@@ -104,8 +104,8 @@ export interface Automation {
trigger_event: string;
filter?: Record;
graph: AutomationGraph;
- created_at: string;
- updated_at: string;
+ created_at: Date;
+ updated_at: Date;
// Public POST path that fires this automation, set only when its trigger is
// the inbound webhook. Append to the API origin for the full URL.
inbound_url?: string;
@@ -139,8 +139,8 @@ export interface AutomationRun {
status: string; // running | success | error
node_results: AutomationNodeResult[];
error_detail?: string;
- started_at: string;
- finished_at?: string | null;
+ started_at: Date;
+ finished_at?: Date | null;
}
// Dry-run (test) response: the trace of the walk + the sample data used.
diff --git a/web/src/lib/api/models/app/campaigns/Campaign.ts b/web/src/lib/api/models/app/campaigns/Campaign.ts
index 2249dd256..6bb4790cc 100644
--- a/web/src/lib/api/models/app/campaigns/Campaign.ts
+++ b/web/src/lib/api/models/app/campaigns/Campaign.ts
@@ -61,7 +61,7 @@ export default interface Campaign {
ramp_increment: number;
ramp_ceiling: number;
ramp_level: number;
- ramp_level_date?: string | null;
+ ramp_level_date?: Date | null;
// Match the sending mailbox provider to the recipient's provider.
esp_match_mode: 'off' | 'prefer' | 'strict';
@@ -77,7 +77,7 @@ export default interface Campaign {
// Keep running for new leads: out of leads, the campaign waits (idle_since
// set) instead of finishing. Linking a segment turns it on.
continuous: boolean;
- idle_since?: string | null;
+ idle_since?: Date | null;
// Auto-pause guardrails. Bounce and complaint rates are ceilings (pause at
// or above); the reply rate is a floor (pause below). A rate of 0 turns its
@@ -88,13 +88,13 @@ export default interface Campaign {
guardrail_reply_rate_min: number;
guardrail_min_sample: number;
guardrail_window_days: number;
- guardrail_tripped_at?: string | null;
+ guardrail_tripped_at?: Date | null;
guardrail_reason?: string;
// Campaign-scoped tracking-domain override (honored only when verified).
tracking_domain: string;
tracking_domain_verified: boolean;
- tracking_domain_verified_at?: string | null;
+ tracking_domain_verified_at?: Date | null;
// Automatic UTM tagging of every link. Empty source/medium/campaign keep
// the defaults (warmbly / email / the campaign name); utm_content is
@@ -120,7 +120,7 @@ export interface ScheduleInterval {
export interface CampaignSender {
email_account_id: string;
weight: number;
- last_sent_at?: string | null;
+ last_sent_at?: Date | null;
enabled: boolean;
}
diff --git a/web/src/lib/api/models/app/campaigns/EmailImage.ts b/web/src/lib/api/models/app/campaigns/EmailImage.ts
index bf66f02ee..03a00adba 100644
--- a/web/src/lib/api/models/app/campaigns/EmailImage.ts
+++ b/web/src/lib/api/models/app/campaigns/EmailImage.ts
@@ -10,7 +10,7 @@ export default interface EmailImage {
width: number;
height: number;
url: string;
- created_at: string;
+ created_at: Date;
}
// One keyset page of the library, newest first.
diff --git a/web/src/lib/api/models/app/campaigns/Preflight.ts b/web/src/lib/api/models/app/campaigns/Preflight.ts
index decd8c46d..5ed8e7199 100644
--- a/web/src/lib/api/models/app/campaigns/Preflight.ts
+++ b/web/src/lib/api/models/app/campaigns/Preflight.ts
@@ -17,7 +17,7 @@ export interface PreflightReport {
score: number;
checks: PreflightCheckResult[];
recommendations: string[];
- created_at: string;
+ created_at: Date;
}
// Only failures are worth a user's attention; a passing check is noise in a
diff --git a/web/src/lib/api/models/app/campaigns/SendPlan.ts b/web/src/lib/api/models/app/campaigns/SendPlan.ts
index 723fcf5cf..de7ba220b 100644
--- a/web/src/lib/api/models/app/campaigns/SendPlan.ts
+++ b/web/src/lib/api/models/app/campaigns/SendPlan.ts
@@ -43,11 +43,11 @@ export interface SendLimit {
export interface SendWindow {
sending_day: boolean;
open_now: boolean;
- opens_at?: string;
- closes_at?: string;
+ opens_at?: Date;
+ closes_at?: Date;
minutes_left: number;
- starts_at?: string;
- ends_at?: string;
+ starts_at?: Date;
+ ends_at?: Date;
}
export interface LeadSupply {
@@ -60,7 +60,7 @@ export interface LeadSupply {
waiting_on_sender: number;
new_leads_started_today: number;
max_new_leads_per_day: number;
- next_due_at?: string;
+ next_due_at?: Date;
}
export interface ColdRampInfo {
@@ -81,7 +81,7 @@ export interface MailboxPlan {
sent_by_other_campaigns: number;
expected_remaining: number;
state: MailboxPlanState;
- reopens_at?: string;
+ reopens_at?: Date;
health?: string;
min_gap_seconds: number;
graduation?: ColdRampInfo;
@@ -98,7 +98,7 @@ export default interface SendPlan {
status: string;
day: string;
timezone: string;
- computed_at: string;
+ computed_at: Date;
configured_ceiling: number;
projected_today: number;
sent_today: number;
@@ -109,7 +109,7 @@ export default interface SendPlan {
leads: LeadSupply;
mailboxes: MailboxPlan[];
organization?: OrgAllowance;
- next_wake_at?: string;
+ next_wake_at?: Date;
}
// What the workspace's mailboxes can send today between them, on the
diff --git a/web/src/lib/api/models/app/cliauth/CLIAuth.ts b/web/src/lib/api/models/app/cliauth/CLIAuth.ts
index bce72f178..ae9de4df3 100644
--- a/web/src/lib/api/models/app/cliauth/CLIAuth.ts
+++ b/web/src/lib/api/models/app/cliauth/CLIAuth.ts
@@ -13,6 +13,6 @@ export interface CLIAuthCode {
status: CLIAuthCodeStatus;
organization_id?: string;
api_key_id?: string;
- expires_at: string;
- created_at: string;
+ expires_at: Date;
+ created_at: Date;
}
diff --git a/web/src/lib/api/models/app/contacts/Contact.ts b/web/src/lib/api/models/app/contacts/Contact.ts
index 3c26fa531..d3f523688 100644
--- a/web/src/lib/api/models/app/contacts/Contact.ts
+++ b/web/src/lib/api/models/app/contacts/Contact.ts
@@ -24,8 +24,8 @@ export type LeadStatus =
// when an auto-reply parked it and "manual" when a member did; `until` absent
// means the hold has no end and only a resume lifts it.
export interface LeadHold {
- since: string;
- until?: string | null;
+ since: Date;
+ until?: Date | null;
reason?: string;
source: "manual" | "out_of_office" | string;
}
@@ -70,7 +70,7 @@ export interface ContactCampaignProgress {
clicked: number;
replied: number;
bounced: number;
- last_activity_at?: string | null;
+ last_activity_at?: Date | null;
// Label of the step the lead is on now (latest step sent). Empty when the
// lead hasn't been contacted yet.
current_step?: string;
@@ -114,13 +114,13 @@ export default interface Contact {
verification_sub_status?: string;
verification_source?: VerificationSource;
verification_provider?: string;
- verification_checked_at?: string | null;
+ verification_checked_at?: Date | null;
// How sure the platform is of the status, 0 to 100, scored from the last
// check plus what real mail to the address showed.
verification_confidence?: number;
// Set while a re-check a member asked for waits to run; the verdict
// above stands until it lands.
- verification_requested_at?: string | null;
+ verification_requested_at?: Date | null;
is_catch_all?: boolean;
// Who hosts the contact's inbox, read from its domain's MX; "" until
diff --git a/web/src/lib/api/models/app/contacts/ContactCampaignState.ts b/web/src/lib/api/models/app/contacts/ContactCampaignState.ts
index aa946d9af..f926737a1 100644
--- a/web/src/lib/api/models/app/contacts/ContactCampaignState.ts
+++ b/web/src/lib/api/models/app/contacts/ContactCampaignState.ts
@@ -10,12 +10,12 @@ export interface ContactCampaignStep {
position: number;
subject?: string;
- sent_at?: string | null;
- opened_at?: string | null;
- clicked_at?: string | null;
- replied_at?: string | null;
- bounced_at?: string | null;
- failed_at?: string | null;
+ sent_at?: Date | null;
+ opened_at?: Date | null;
+ clicked_at?: Date | null;
+ replied_at?: Date | null;
+ bounced_at?: Date | null;
+ failed_at?: Date | null;
attempts?: number;
in_flight?: boolean;
}
@@ -38,8 +38,8 @@ export interface ContactNextAction {
// Only when due now, and then it is the campaign chain's own next wakeup,
// not a slot reserved for this contact: leads queued ahead can still push
// this step to a later pass. Absent while the chain is being re-seeded.
- scheduled_at?: string | null;
- not_before?: string | null;
+ scheduled_at?: Date | null;
+ not_before?: Date | null;
constraint?: string;
}
@@ -62,7 +62,7 @@ export default interface ContactCampaignState {
current_step?: ContactCampaignStep | null;
last_action?: string;
- last_action_at?: string | null;
+ last_action_at?: Date | null;
// The live per-lead hold: an out-of-office auto-reply parked the contact,
// or a member paused them. Absent when the lead is not held.
diff --git a/web/src/lib/api/models/app/contacts/ContactDetail.ts b/web/src/lib/api/models/app/contacts/ContactDetail.ts
index 2e2fc9cc9..03320a7cd 100644
--- a/web/src/lib/api/models/app/contacts/ContactDetail.ts
+++ b/web/src/lib/api/models/app/contacts/ContactDetail.ts
@@ -8,11 +8,11 @@ export interface ContactEngagement {
total_bounced: number;
total_complained: number;
- last_sent_at?: string | null;
- last_opened_at?: string | null;
- last_clicked_at?: string | null;
- last_replied_at?: string | null;
- last_bounced_at?: string | null;
+ last_sent_at?: Date | null;
+ last_opened_at?: Date | null;
+ last_clicked_at?: Date | null;
+ last_replied_at?: Date | null;
+ last_bounced_at?: Date | null;
// How the contact reads your mail: each client and device a person's
// opens came from, most recent first.
@@ -27,7 +27,7 @@ export interface ContactReadingOrigin {
os?: string;
browser?: string;
opens: number;
- last_opened_at: string;
+ last_opened_at: Date;
}
export interface ContactSuppression {
@@ -38,8 +38,8 @@ export interface ContactSuppression {
value: string;
reason: string;
source: "bounce" | "complaint" | "unsubscribe" | "manual" | "import" | string;
- expires_at?: string | null;
- created_at: string;
+ expires_at?: Date | null;
+ created_at: Date;
}
// Where a contact first came from. Mirrors the backend CHECK; "unknown" is
@@ -69,7 +69,7 @@ export type VerificationEvidenceKind =
export interface ContactVerificationEvidence {
kind: VerificationEvidenceKind;
detail?: string;
- observed_at: string;
+ observed_at: Date;
}
export interface ContactVerificationDetail {
@@ -87,9 +87,9 @@ export interface ContactVerificationDetail {
provider_label?: string;
// What that check said, before real mail was weighed against it.
check_status: "" | "valid" | "risky" | "invalid" | "unknown";
- checked_at?: string | null;
+ checked_at?: Date | null;
// Set while a re-check a member asked for waits to run.
- requested_at?: string | null;
+ requested_at?: Date | null;
}
export default interface ContactDetail extends Contact {
@@ -100,5 +100,5 @@ export default interface ContactDetail extends Contact {
// First-touch attribution; never changes after creation.
source: ContactSource;
source_detail: string;
- first_seen_at: string;
+ first_seen_at: Date;
}
diff --git a/web/src/lib/api/models/app/contacts/ContactTimelineEvent.ts b/web/src/lib/api/models/app/contacts/ContactTimelineEvent.ts
index 0d64f2cb1..25e34fbd8 100644
--- a/web/src/lib/api/models/app/contacts/ContactTimelineEvent.ts
+++ b/web/src/lib/api/models/app/contacts/ContactTimelineEvent.ts
@@ -27,7 +27,7 @@ export interface ContactPageHit {
id: string;
visitor_id: string;
session_key: string;
- occurred_at: string;
+ occurred_at: Date;
url: string;
path: string;
title: string;
@@ -86,7 +86,7 @@ export interface EngagementOrigin {
export default interface ContactTimelineEvent {
type: ContactTimelineEventType;
- at: string;
+ at: Date;
// Engagement classification (email_opened / email_clicked): true when an
// automated fetcher (mail privacy proxy, security gateway) did it rather
@@ -120,7 +120,7 @@ export default interface ContactTimelineEvent {
content?: string | null;
// Meeting events (meeting_booked / rescheduled / canceled).
- scheduled_for?: string | null;
+ scheduled_for?: Date | null;
join_url?: string | null;
meeting_state?: string | null;
diff --git a/web/src/lib/api/models/app/contacts/Research.ts b/web/src/lib/api/models/app/contacts/Research.ts
index 71c8b2fb5..15544730c 100644
--- a/web/src/lib/api/models/app/contacts/Research.ts
+++ b/web/src/lib/api/models/app/contacts/Research.ts
@@ -60,6 +60,6 @@ export interface ContactResearchRun {
credits_charged: number;
model_used: string;
tokens_used: number;
- created_at: string;
- updated_at: string;
+ created_at: Date;
+ updated_at: Date;
}
diff --git a/web/src/lib/api/models/app/crm/CRMTask.ts b/web/src/lib/api/models/app/crm/CRMTask.ts
index accd14c8c..f8d103e81 100644
--- a/web/src/lib/api/models/app/crm/CRMTask.ts
+++ b/web/src/lib/api/models/app/crm/CRMTask.ts
@@ -11,16 +11,19 @@ export default interface CRMTask {
created_by: string;
title: string;
description?: string;
- due_date?: string;
+ due_date?: Date;
priority: CRMTaskPriority;
// The task type's name (user-managed; empty = no type).
type: string;
status: CRMTaskStatus;
- completed_at?: string;
- created_at: string;
- updated_at: string;
+ completed_at?: Date;
+ created_at: Date;
+ updated_at: Date;
}
+// Create/update body: due_date goes out as the RFC3339 string the form builds.
+export type CRMTaskWrite = Partial> & { due_date?: string };
+
export interface CRMTasksResult {
data: CRMTask[];
pagination: {
diff --git a/web/src/lib/api/models/app/crm/Deal.ts b/web/src/lib/api/models/app/crm/Deal.ts
index 5e40aa8d0..5a25ba01d 100644
--- a/web/src/lib/api/models/app/crm/Deal.ts
+++ b/web/src/lib/api/models/app/crm/Deal.ts
@@ -10,17 +10,17 @@ export default interface Deal {
value?: number;
currency: string;
status: DealStatus;
- expected_close_date?: string;
- won_at?: string;
- lost_at?: string;
+ expected_close_date?: Date;
+ won_at?: Date;
+ lost_at?: Date;
lost_reason?: string;
assigned_to?: string;
// Attribution: the campaign + sender mailbox that produced the originating
// reply. Nullable; editable. Lets won revenue trace back to outreach.
campaign_id?: string;
source_mailbox_id?: string;
- created_at: string;
- updated_at: string;
+ created_at: Date;
+ updated_at: Date;
// Optional joined fields the API may return
contact?: {
@@ -39,6 +39,9 @@ export default interface Deal {
campaign_name?: string;
}
+// Create/update body: expected_close_date goes out as the RFC3339 string the form builds.
+export type DealWrite = Partial> & { expected_close_date?: string };
+
export interface DealsResult {
data: Deal[];
pagination: {
diff --git a/web/src/lib/api/models/app/crm/Pipeline.ts b/web/src/lib/api/models/app/crm/Pipeline.ts
index 434187a08..05511da7d 100644
--- a/web/src/lib/api/models/app/crm/Pipeline.ts
+++ b/web/src/lib/api/models/app/crm/Pipeline.ts
@@ -5,8 +5,8 @@ export interface Stage {
color: string;
position: number;
deal_count?: number;
- created_at: string;
- updated_at: string;
+ created_at: Date;
+ updated_at: Date;
}
export default interface Pipeline {
@@ -15,6 +15,6 @@ export default interface Pipeline {
name: string;
position: number;
stages: Stage[];
- created_at: string;
- updated_at: string;
+ created_at: Date;
+ updated_at: Date;
}
diff --git a/web/src/lib/api/models/app/crm/TaskType.ts b/web/src/lib/api/models/app/crm/TaskType.ts
index 8963e92fe..c7df852ed 100644
--- a/web/src/lib/api/models/app/crm/TaskType.ts
+++ b/web/src/lib/api/models/app/crm/TaskType.ts
@@ -6,6 +6,6 @@ export default interface TaskType {
name: string;
color: string;
position: number;
- created_at: string;
- updated_at: string;
+ created_at: Date;
+ updated_at: Date;
}
diff --git a/web/src/lib/api/models/app/emails/SendIdentity.ts b/web/src/lib/api/models/app/emails/SendIdentity.ts
index fb7f45b2d..020d74653 100644
--- a/web/src/lib/api/models/app/emails/SendIdentity.ts
+++ b/web/src/lib/api/models/app/emails/SendIdentity.ts
@@ -22,7 +22,7 @@ export default interface SendIdentity {
/** Empty means the mailbox's own address. */
send_as_email: string;
identities: SendAsIdentity[];
- synced_at?: string | null;
+ synced_at?: Date | null;
signature_source: "manual" | "provider";
- signature_imported_at?: string | null;
+ signature_imported_at?: Date | null;
}
diff --git a/web/src/lib/api/models/app/emails/SendingBehavior.ts b/web/src/lib/api/models/app/emails/SendingBehavior.ts
index d3dd56518..eb4c52632 100644
--- a/web/src/lib/api/models/app/emails/SendingBehavior.ts
+++ b/web/src/lib/api/models/app/emails/SendingBehavior.ts
@@ -33,8 +33,8 @@ export default interface SendingBehavior {
timezone?: string;
- created_at: string;
- updated_at: string;
+ created_at: Date;
+ updated_at: Date;
}
export type SendingBehaviorPatch = Partial>;
@@ -52,7 +52,7 @@ export interface DailyPlan {
lunch_end_minute: number | null;
gap_min_seconds: number;
gap_max_seconds: number;
- created_at: string;
+ created_at: Date;
}
export interface DailyPlanView extends DailyPlan {
diff --git a/web/src/lib/api/models/app/emails/SyncState.ts b/web/src/lib/api/models/app/emails/SyncState.ts
index fd0accda5..9c7e76b70 100644
--- a/web/src/lib/api/models/app/emails/SyncState.ts
+++ b/web/src/lib/api/models/app/emails/SyncState.ts
@@ -13,10 +13,10 @@ export type SyncThrottleReason =
export interface SyncState {
backfill_status: SyncBackfillStatus;
backfill_synced: number;
- backfill_since?: string;
- backfill_started_at?: string;
- backfill_completed_at?: string;
- throttled_until?: string;
+ backfill_since?: Date;
+ backfill_started_at?: Date;
+ backfill_completed_at?: Date;
+ throttled_until?: Date;
throttle_reason?: SyncThrottleReason | "";
// Live messages seen on the server but waiting on budget.
deferred: number;
@@ -24,7 +24,7 @@ export interface SyncState {
folders_skipped_cap?: number;
/** Folders left out because the mail server listed their name more than once. */
folders_skipped_conflict?: number;
- last_synced_at?: string;
+ last_synced_at?: Date;
}
export interface SyncPolicy {
diff --git a/web/src/lib/api/models/app/emails/WarmupBanStatus.ts b/web/src/lib/api/models/app/emails/WarmupBanStatus.ts
index 4a470a500..054e24249 100644
--- a/web/src/lib/api/models/app/emails/WarmupBanStatus.ts
+++ b/web/src/lib/api/models/app/emails/WarmupBanStatus.ts
@@ -7,8 +7,8 @@ export default interface WarmupBanStatus {
blocked: boolean;
health_state: string;
reason: string;
- blocked_at: string | null;
- blocked_until: string | null;
+ blocked_at: Date | null;
+ blocked_until: Date | null;
can_appeal: boolean;
pending_appeal: boolean;
}
diff --git a/web/src/lib/api/models/app/forms/FormStats.ts b/web/src/lib/api/models/app/forms/FormStats.ts
index b70930b80..d6e1682f0 100644
--- a/web/src/lib/api/models/app/forms/FormStats.ts
+++ b/web/src/lib/api/models/app/forms/FormStats.ts
@@ -36,7 +36,7 @@ export interface FormIdentifiedVisitor {
contact_id: string;
name: string;
email: string;
- last_seen: string;
+ last_seen: Date;
furthest_page: number;
completed: boolean;
/** Campaign whose email brought this contact here, when known. */
diff --git a/web/src/lib/api/models/app/forms/FormsDomain.ts b/web/src/lib/api/models/app/forms/FormsDomain.ts
index 514b8c47a..9bbd93afe 100644
--- a/web/src/lib/api/models/app/forms/FormsDomain.ts
+++ b/web/src/lib/api/models/app/forms/FormsDomain.ts
@@ -4,7 +4,7 @@
export default interface FormsDomainStatus {
forms_domain: string;
forms_domain_verified: boolean;
- forms_domain_verified_at?: string;
+ forms_domain_verified_at?: Date;
/** The value to put in the CNAME: this install's forms host. */
cname_target: string;
/** verified | unset | no_target | not_found | wrong_target | lookup_error | pending */
diff --git a/web/src/lib/api/models/app/inboxtag/InboxTagReview.ts b/web/src/lib/api/models/app/inboxtag/InboxTagReview.ts
index d05f95337..8f8310500 100644
--- a/web/src/lib/api/models/app/inboxtag/InboxTagReview.ts
+++ b/web/src/lib/api/models/app/inboxtag/InboxTagReview.ts
@@ -30,7 +30,7 @@ export interface InboxTagRow {
answers: Record;
model: string;
input_tokens: number;
- created_at: string;
+ created_at: Date;
}
export default interface InboxTagReview {
diff --git a/web/src/lib/api/models/app/integrations/Integration.ts b/web/src/lib/api/models/app/integrations/Integration.ts
index 158ff7e07..405f9b8e3 100644
--- a/web/src/lib/api/models/app/integrations/Integration.ts
+++ b/web/src/lib/api/models/app/integrations/Integration.ts
@@ -121,7 +121,7 @@ export interface IntegrationFieldMapping {
transform: string;
static_value: string;
is_default: boolean;
- created_at: string;
+ created_at: Date;
}
export interface IntegrationConnection {
@@ -136,15 +136,15 @@ export interface IntegrationConnection {
external_account_id?: string;
external_account_name?: string;
granted_scopes?: string[];
- token_expires_at?: string | null;
+ token_expires_at?: Date | null;
health: IntegrationHealth;
health_detail?: string | null;
- health_checked_at?: string | null;
- last_synced_at?: string | null;
+ health_checked_at?: Date | null;
+ last_synced_at?: Date | null;
last_error?: string | null;
- last_error_at?: string | null;
- created_at: string;
- updated_at: string;
+ last_error_at?: Date | null;
+ created_at: Date;
+ updated_at: Date;
/** Per-connection onboarding/capability snapshot (selected use-cases, picker
* selections, scheduling_url for meeting providers). */
@@ -187,8 +187,8 @@ export interface IntegrationEventSubscription {
action: IntegrationAction;
config: Record;
enabled: boolean;
- created_at: string;
- updated_at: string;
+ created_at: Date;
+ updated_at: Date;
}
export interface IntegrationSyncRun {
@@ -199,8 +199,8 @@ export interface IntegrationSyncRun {
status: "running" | "success" | "error";
detail: string;
records_processed: number;
- started_at: string;
- finished_at?: string | null;
+ started_at: Date;
+ finished_at?: Date | null;
}
export interface IntegrationOAuthStartResponse {
@@ -226,8 +226,8 @@ export interface MeetingBooking {
invitee_name: string;
event_name: string;
event_type?: string;
- scheduled_for?: string;
- end_time?: string;
+ scheduled_for?: Date;
+ end_time?: Date;
join_url?: string;
location?: string;
cancel_url?: string;
@@ -236,8 +236,8 @@ export interface MeetingBooking {
contact_id?: string;
campaign_id?: string;
contact_name?: string;
- created_at: string;
- updated_at?: string;
+ created_at: Date;
+ updated_at?: Date;
}
export interface MeetingsSummary {
diff --git a/web/src/lib/api/models/app/leadsync/LeadSync.ts b/web/src/lib/api/models/app/leadsync/LeadSync.ts
index 558a22d65..c36066281 100644
--- a/web/src/lib/api/models/app/leadsync/LeadSync.ts
+++ b/web/src/lib/api/models/app/leadsync/LeadSync.ts
@@ -48,11 +48,11 @@ export interface LeadSyncSource {
subscribed_default: boolean;
label?: string;
status: LeadSyncStatus;
- last_synced_at?: string;
+ last_synced_at?: Date;
last_result?: ImportResult;
last_error?: string;
- created_at: string;
- updated_at: string;
+ created_at: Date;
+ updated_at: Date;
}
// Result of POST /sources/:id/sync.
diff --git a/web/src/lib/api/models/app/mcp/MCPServer.ts b/web/src/lib/api/models/app/mcp/MCPServer.ts
index b7a37ec1b..b21180c96 100644
--- a/web/src/lib/api/models/app/mcp/MCPServer.ts
+++ b/web/src/lib/api/models/app/mcp/MCPServer.ts
@@ -13,8 +13,8 @@ export interface MCPServer {
enabled: boolean;
discovered_tools: MCPTool[];
last_error?: string;
- created_at: string;
- updated_at: string;
+ created_at: Date;
+ updated_at: Date;
}
export interface CreateMCPServer {
diff --git a/web/src/lib/api/models/app/notifications/Notification.ts b/web/src/lib/api/models/app/notifications/Notification.ts
index 1f72e0898..bd9266eb9 100644
--- a/web/src/lib/api/models/app/notifications/Notification.ts
+++ b/web/src/lib/api/models/app/notifications/Notification.ts
@@ -97,6 +97,6 @@ export interface AppNotification {
body?: string;
link?: string;
metadata?: Record;
- read_at?: string | null;
- created_at: string;
+ read_at?: Date | null;
+ created_at: Date;
}
diff --git a/web/src/lib/api/models/app/oauth/OAuthApp.ts b/web/src/lib/api/models/app/oauth/OAuthApp.ts
index b90296e9d..a65d7ee9e 100644
--- a/web/src/lib/api/models/app/oauth/OAuthApp.ts
+++ b/web/src/lib/api/models/app/oauth/OAuthApp.ts
@@ -24,8 +24,8 @@ export interface OAuthApplication {
// Bitmask of the API permissions this app may request (same bits as API keys).
scopes: number;
status: OAuthAppStatus;
- created_at: string;
- updated_at: string;
+ created_at: Date;
+ updated_at: Date;
}
// Returned once on create / secret rotation; client_secret is shown a single time.
@@ -68,8 +68,8 @@ export interface OAuthAuthorizedApp {
logo_url: string;
website_url: string;
scopes: number;
- authorized_at: string;
- last_used_at?: string;
+ authorized_at: Date;
+ last_used_at?: Date;
}
export interface OAuthAuthorizedAppsResult {
diff --git a/web/src/lib/api/models/app/organizations/LimitIncreaseRequest.ts b/web/src/lib/api/models/app/organizations/LimitIncreaseRequest.ts
index ef9111639..4f683ebbf 100644
--- a/web/src/lib/api/models/app/organizations/LimitIncreaseRequest.ts
+++ b/web/src/lib/api/models/app/organizations/LimitIncreaseRequest.ts
@@ -21,9 +21,9 @@ export default interface LimitIncreaseRequest {
reason: string;
status: LimitRequestStatus;
submitted_by: string;
- submitted_at: string;
+ submitted_at: Date;
reviewed_by?: string | null;
- reviewed_at?: string | null;
+ reviewed_at?: Date | null;
review_notes: string;
}
diff --git a/web/src/lib/api/models/app/organizations/OrganizationRole.ts b/web/src/lib/api/models/app/organizations/OrganizationRole.ts
index 888314354..90f5a51a6 100644
--- a/web/src/lib/api/models/app/organizations/OrganizationRole.ts
+++ b/web/src/lib/api/models/app/organizations/OrganizationRole.ts
@@ -8,6 +8,6 @@ export default interface OrganizationRole {
color: string;
permissions: number;
member_count: number;
- created_at: string;
- updated_at: string;
+ created_at: Date;
+ updated_at: Date;
}
diff --git a/web/src/lib/api/models/app/segments/Segment.ts b/web/src/lib/api/models/app/segments/Segment.ts
index 06558792e..70b97b901 100644
--- a/web/src/lib/api/models/app/segments/Segment.ts
+++ b/web/src/lib/api/models/app/segments/Segment.ts
@@ -96,7 +96,7 @@ export interface CampaignSegmentLink {
contact_count: number;
lead_count: number;
held_out_count: number;
- linked_at: string;
+ linked_at: Date;
}
// Operators per field kind, mirrored from the backend catalog.
diff --git a/web/src/lib/api/models/app/skills/Skill.ts b/web/src/lib/api/models/app/skills/Skill.ts
index cbd36d926..20e61fe90 100644
--- a/web/src/lib/api/models/app/skills/Skill.ts
+++ b/web/src/lib/api/models/app/skills/Skill.ts
@@ -5,8 +5,8 @@ export interface AISkill {
description: string;
content: string;
enabled: boolean;
- created_at: string;
- updated_at: string;
+ created_at: Date;
+ updated_at: Date;
}
export interface CreateAISkill {
diff --git a/web/src/lib/api/models/app/subscription/Credits.ts b/web/src/lib/api/models/app/subscription/Credits.ts
index 73f6ddedc..82e877a3e 100644
--- a/web/src/lib/api/models/app/subscription/Credits.ts
+++ b/web/src/lib/api/models/app/subscription/Credits.ts
@@ -21,9 +21,9 @@ export interface CreditBalance {
// Lifetime purchased credits.
total_purchased: number;
// When the monthly pool was last reset.
- monthly_reset_at: string;
+ monthly_reset_at: Date;
// When the monthly pool next resets (subscription period end), if any.
- next_reset_at: string | null;
+ next_reset_at: Date | null;
packs: CreditPack[];
}
@@ -43,7 +43,7 @@ export interface CreditTransaction {
// what ran (campaign/step/contact, automation/node/run, thread, session).
actor_user_id?: string | null;
context?: CreditContext;
- created_at: string;
+ created_at: Date;
}
export interface CreditTransactionsPage {
@@ -66,7 +66,7 @@ export interface AISpendSettings {
member_limit_weekly: number | null;
member_limit_monthly: number | null;
low_balance_threshold: number;
- low_balance_notified_at?: string | null;
+ low_balance_notified_at?: Date | null;
auto_topup_enabled: boolean;
auto_topup_pack: string;
auto_topup_threshold: number;
diff --git a/web/src/lib/api/models/app/subscription/DiscountRedemption.ts b/web/src/lib/api/models/app/subscription/DiscountRedemption.ts
index 2de68e808..988d429f0 100644
--- a/web/src/lib/api/models/app/subscription/DiscountRedemption.ts
+++ b/web/src/lib/api/models/app/subscription/DiscountRedemption.ts
@@ -9,8 +9,8 @@ export interface DiscountRedemption {
currency?: string | null;
trial_extension_days?: number | null;
status: string;
- redeemed_at: string;
- applied_at?: string | null;
+ redeemed_at: Date;
+ applied_at?: Date | null;
}
export interface DiscountRedemptionsResult {
diff --git a/web/src/lib/api/models/app/subscription/Referral.ts b/web/src/lib/api/models/app/subscription/Referral.ts
index b62887c39..899e84ac0 100644
--- a/web/src/lib/api/models/app/subscription/Referral.ts
+++ b/web/src/lib/api/models/app/subscription/Referral.ts
@@ -24,8 +24,8 @@ export interface ReferralCode {
owner_user_id: string;
owner_org_id: string;
discount_code_id: string;
- created_at: string;
- updated_at: string;
+ created_at: Date;
+ updated_at: Date;
}
export type ReferralAttributionStatus = "pending" | "qualified" | "rewarded" | "void";
@@ -36,9 +36,9 @@ export interface ReferralAttribution {
reward_cents: number;
reward_currency: string;
invitee_org_id: string;
- qualified_at?: string;
- rewarded_at?: string;
- created_at: string;
+ qualified_at?: Date;
+ rewarded_at?: Date;
+ created_at: Date;
}
// Positive amount = reward, negative = clawback.
@@ -48,7 +48,7 @@ export interface ReferralEarningsTransaction {
currency: string;
reason: string;
balance_after_cents: number;
- created_at: string;
+ created_at: Date;
}
export interface ReferralPagination {
diff --git a/web/src/lib/api/models/app/suppressions/Suppression.ts b/web/src/lib/api/models/app/suppressions/Suppression.ts
index e67c3fbd1..497eea493 100644
--- a/web/src/lib/api/models/app/suppressions/Suppression.ts
+++ b/web/src/lib/api/models/app/suppressions/Suppression.ts
@@ -11,10 +11,10 @@ export default interface Suppression {
reason: string;
source: SuppressionSource | string;
campaign_id?: string | null;
- expires_at?: string | null;
+ expires_at?: Date | null;
metadata?: Record;
- created_at: string;
- updated_at: string;
+ created_at: Date;
+ updated_at: Date;
}
export interface SuppressionListResult {
diff --git a/web/src/lib/api/models/app/teams/Team.ts b/web/src/lib/api/models/app/teams/Team.ts
index ddd8947b6..e55504786 100644
--- a/web/src/lib/api/models/app/teams/Team.ts
+++ b/web/src/lib/api/models/app/teams/Team.ts
@@ -5,7 +5,7 @@ export interface TeamMember {
user_id: string;
email: string;
name: string;
- added_at: string;
+ added_at: Date;
}
export default interface Team {
@@ -13,7 +13,7 @@ export default interface Team {
organization_id: string;
name: string;
color: string;
- created_at: string;
- updated_at: string;
+ created_at: Date;
+ updated_at: Date;
members: TeamMember[];
}
diff --git a/web/src/lib/api/models/app/unibox/Compose.ts b/web/src/lib/api/models/app/unibox/Compose.ts
index 20eb2f782..2bb1f5d7b 100644
--- a/web/src/lib/api/models/app/unibox/Compose.ts
+++ b/web/src/lib/api/models/app/unibox/Compose.ts
@@ -17,7 +17,7 @@ export interface ComposeCandidate {
/** Prior messages exchanged with the recipient from this mailbox. */
history_messages: number;
/** RFC3339; omitted when this mailbox never contacted the recipient. */
- last_contact_at?: string | null;
+ last_contact_at?: Date | null;
score: number;
/** Human-readable scoring signals ("has history", "low load today", ...). */
reasons: string[];
@@ -55,7 +55,7 @@ export interface ComposeSendInput {
export interface ComposeSendResponse {
task_id: string;
- scheduled_at: string;
+ scheduled_at: Date;
send_mode: string;
account_id: string;
account_email: string;
diff --git a/web/src/lib/api/models/app/unibox/UniboxOverview.ts b/web/src/lib/api/models/app/unibox/UniboxOverview.ts
index a52994ab2..b4d47446a 100644
--- a/web/src/lib/api/models/app/unibox/UniboxOverview.ts
+++ b/web/src/lib/api/models/app/unibox/UniboxOverview.ts
@@ -60,7 +60,7 @@ export default interface UniboxOverview {
mailboxes: UniboxMailboxOverview[];
tags: UniboxTagOverview[];
categories: UniboxCategoryOverview[];
- generated_at: string;
- window_today_start: string;
- window_week_start: string;
+ generated_at: Date;
+ window_today_start: Date;
+ window_week_start: Date;
}
diff --git a/web/src/lib/api/models/app/unibox/UniboxScheduled.ts b/web/src/lib/api/models/app/unibox/UniboxScheduled.ts
index 184a1d3c4..e30e13072 100644
--- a/web/src/lib/api/models/app/unibox/UniboxScheduled.ts
+++ b/web/src/lib/api/models/app/unibox/UniboxScheduled.ts
@@ -3,8 +3,8 @@
export default interface UniboxScheduledItem {
task_id: string
- scheduled_at: string
- created_at: string
+ scheduled_at: Date
+ created_at: Date
account_id: string
account_email: string
diff --git a/web/src/lib/api/models/app/unibox/UniboxThread.ts b/web/src/lib/api/models/app/unibox/UniboxThread.ts
index d3230e29f..0de62af37 100644
--- a/web/src/lib/api/models/app/unibox/UniboxThread.ts
+++ b/web/src/lib/api/models/app/unibox/UniboxThread.ts
@@ -12,7 +12,7 @@ export interface UniboxThreadMessage {
to_addr: string[]
subject: string
snippet: string
- internal_date: string
+ internal_date: Date
seen: boolean
}
diff --git a/web/src/lib/api/models/app/views/ViewPreferences.ts b/web/src/lib/api/models/app/views/ViewPreferences.ts
index ae3e48527..f3dd516e4 100644
--- a/web/src/lib/api/models/app/views/ViewPreferences.ts
+++ b/web/src/lib/api/models/app/views/ViewPreferences.ts
@@ -15,7 +15,7 @@ export interface ViewPreferences {
columns: string[];
// Null or absent means the list's default sort.
sort?: ViewSort | null;
- updated_at?: string | null;
+ updated_at?: Date | null;
}
export interface ViewPreferencesEnvelope {
diff --git a/web/src/lib/api/models/app/webhooks/Webhook.ts b/web/src/lib/api/models/app/webhooks/Webhook.ts
index 4ef82a748..a1b7f79af 100644
--- a/web/src/lib/api/models/app/webhooks/Webhook.ts
+++ b/web/src/lib/api/models/app/webhooks/Webhook.ts
@@ -18,18 +18,18 @@ export interface WebhookEndpoint {
description: string;
event_types: string[];
enabled: boolean;
- last_success_at?: string;
- last_failure_at?: string;
+ last_success_at?: Date;
+ last_failure_at?: Date;
last_failure_reason?: string;
consecutive_failures: number;
oauth_application_id?: string;
created_by?: string;
- verified_at?: string;
+ verified_at?: Date;
ownership_confirmed: boolean;
- auto_disabled_at?: string;
+ auto_disabled_at?: Date;
disabled_reason?: string;
- created_at: string;
- updated_at: string;
+ created_at: Date;
+ updated_at: Date;
}
// Returned once on create; secret is shown a single time and never again.
@@ -56,21 +56,21 @@ export interface WebhookDelivery {
status: WebhookDeliveryStatus;
attempt_count: number;
max_attempts: number;
- next_attempt_at: string;
- last_attempt_at?: string;
+ next_attempt_at: Date;
+ last_attempt_at?: Date;
response_status?: number;
response_body_excerpt?: string;
error_reason?: string;
- created_at: string;
- updated_at: string;
+ created_at: Date;
+ updated_at: Date;
}
// A high-volume event family that was throttled (dropped windows) for a day.
export interface WebhookEventDrop {
event_type: string;
- day: string;
+ day: Date;
dropped_windows: number;
- last_dropped_at: string;
+ last_dropped_at: Date;
}
// GET /webhooks
diff --git a/web/src/lib/api/models/app/websitetracking/WebsiteTrackingSettings.ts b/web/src/lib/api/models/app/websitetracking/WebsiteTrackingSettings.ts
index 12650853d..ef75a5465 100644
--- a/web/src/lib/api/models/app/websitetracking/WebsiteTrackingSettings.ts
+++ b/web/src/lib/api/models/app/websitetracking/WebsiteTrackingSettings.ts
@@ -9,7 +9,7 @@ export interface WebsiteTrackingSettings {
location_precision: WebsiteLocationPrecision;
allowed_hosts: string[];
retention_days: number;
- updated_at: string;
+ updated_at: Date;
// The deployment's tracking host; empty when the install has none.
tracking_host: string;
}
diff --git a/web/src/lib/helper/dueDate.ts b/web/src/lib/helper/dueDate.ts
index 01c5fcb62..3232ea97d 100644
--- a/web/src/lib/helper/dueDate.ts
+++ b/web/src/lib/helper/dueDate.ts
@@ -12,7 +12,7 @@ export function dueInDaysToISO(days: number): string {
// Convert an existing absolute due date back into a whole-day offset from
// today (used to seed the control when editing a task). Negative = overdue.
-export function isoToDueInDays(iso?: string): number | null {
+export function isoToDueInDays(iso?: string | Date): number | null {
if (!iso) return null;
const due = new Date(iso);
if (Number.isNaN(due.getTime())) return null;
diff --git a/web/src/lib/helper/reviveDates.test.ts b/web/src/lib/helper/reviveDates.test.ts
new file mode 100644
index 000000000..4b139a958
--- /dev/null
+++ b/web/src/lib/helper/reviveDates.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+import reviveDates from "./reviveDates";
+
+describe("reviveDates", () => {
+ it("revives the timestamps Go writes, with and without fractions or an offset", () => {
+ const r = reviveDates({
+ a: "2026-09-27T11:53:26Z",
+ b: "2026-09-27T11:53:26.123456789Z",
+ c: "2026-09-27T13:53:26+02:00",
+ }) as unknown as Record;
+ expect(r.a).toBeInstanceOf(Date);
+ expect(r.b.toISOString()).toBe("2026-09-27T11:53:26.123Z");
+ expect(r.c.toISOString()).toBe("2026-09-27T11:53:26.000Z");
+ });
+
+ it("leaves text that only starts like a timestamp as text", () => {
+ const subject = "2026-09-27T03:00Z nightly backup failed";
+ const r = reviveDates({ subject, snippet: "2026-09-27T03:00:00Z: disk full", local: "2026-09-27T10:00" });
+ expect(r).toEqual({ subject, snippet: "2026-09-27T03:00:00Z: disk full", local: "2026-09-27T10:00" });
+ });
+
+ it("keeps a day string and a custom field value as the string they are", () => {
+ const r = reviveDates({ date: "2026-09-27", custom_fields: { renewal: "2026-09-27T00:00:00Z" } });
+ expect(r).toEqual({ date: "2026-09-27", custom_fields: { renewal: "2026-09-27T00:00:00Z" } });
+ });
+
+ it("revives inside arrays and nested objects", () => {
+ const r = reviveDates({ data: [{ created_at: "2026-09-27T00:00:00Z" }] }) as unknown as { data: { created_at: Date }[] };
+ expect(r.data[0].created_at).toBeInstanceOf(Date);
+ });
+});
diff --git a/web/src/lib/helper/reviveDates.ts b/web/src/lib/helper/reviveDates.ts
index 15c0d77aa..e1b5e1505 100644
--- a/web/src/lib/helper/reviveDates.ts
+++ b/web/src/lib/helper/reviveDates.ts
@@ -1,7 +1,14 @@
+// A whole RFC3339 timestamp, the shape Go writes a time.Time as. Anchored at both
+// ends so free text that merely starts with a date (a subject, a snippet) stays text.
+const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/
+
+// User-authored string maps: a value that looks like a timestamp is still the text someone typed.
+const VERBATIM_KEYS = new Set(["custom_fields"])
+
export default function reviveDates(obj: T): T {
if (obj === null || obj === undefined) return obj
- if (typeof obj === "string" && /^\d{4}-\d{2}-\d{2}T/.test(obj)) {
+ if (typeof obj === "string" && RFC3339.test(obj)) {
return new Date(obj) as unknown as T
}
@@ -15,7 +22,7 @@ export default function reviveDates(obj: T): T {
if (prototype !== Object.prototype && prototype !== null) return obj
const entries = Object.entries(obj as Record).map(
- ([key, value]) => [key, reviveDates(value)]
+ ([key, value]) => [key, VERBATIM_KEYS.has(key) ? value : reviveDates(value)]
)
return Object.fromEntries(entries) as T
}
diff --git a/web/src/lib/helper/shareDeep.test.ts b/web/src/lib/helper/shareDeep.test.ts
new file mode 100644
index 000000000..a97600874
--- /dev/null
+++ b/web/src/lib/helper/shareDeep.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from "vitest";
+import reviveDates from "./reviveDates";
+import shareDeep from "./shareDeep";
+
+const wire = () => reviveDates({ id: "c1", name: "Q4", updated_at: "2026-09-27T11:53:26Z", tags: ["a"], range: { from: "2026-09-01T00:00:00Z" } });
+
+describe("shareDeep", () => {
+ it("keeps the previous object when a refetch returns the same data", () => {
+ const prev = wire();
+ expect(shareDeep(prev, wire())).toBe(prev);
+ });
+
+ it("gives a changed value a new object and keeps the unchanged branches", () => {
+ const prev = wire();
+ const next = { ...wire(), name: "Q1" };
+ const shared = shareDeep(prev, next) as typeof prev;
+ expect(shared).not.toBe(prev);
+ expect(shared.name).toBe("Q1");
+ expect(shared.updated_at).toBe(prev.updated_at);
+ expect(shared.tags).toBe(prev.tags);
+ expect(shared.range).toBe(prev.range);
+ });
+
+ it("hands out the new Date when the time moved", () => {
+ const prev = wire();
+ const next = reviveDates({ ...prev, updated_at: "2026-09-27T12:00:00Z" });
+ const shared = shareDeep(prev, next) as typeof prev;
+ expect(shared).not.toBe(prev);
+ expect(shared.updated_at).toBe(next.updated_at);
+ });
+
+ it("notices a key that was added or removed", () => {
+ expect(shareDeep({ a: 1 }, { a: 1, b: undefined })).toEqual({ a: 1, b: undefined });
+ expect(shareDeep({ a: 1, b: 2 }, { a: 1 })).toEqual({ a: 1 });
+ expect(shareDeep([1, 2], [1])).toEqual([1]);
+ });
+});
diff --git a/web/src/lib/helper/shareDeep.ts b/web/src/lib/helper/shareDeep.ts
new file mode 100644
index 000000000..c0081c373
--- /dev/null
+++ b/web/src/lib/helper/shareDeep.ts
@@ -0,0 +1,32 @@
+// React Query's structural sharing, extended to Dates: the default never reuses one, so every
+// refetch gave unchanged data a new identity and reset the effects keyed on it.
+export default function shareDeep(prev: unknown, next: unknown): unknown {
+ if (prev === next) return prev;
+
+ if (prev instanceof Date && next instanceof Date) {
+ return Object.is(prev.getTime(), next.getTime()) ? prev : next;
+ }
+
+ const arrays = Array.isArray(prev) && Array.isArray(next);
+ if (!arrays && !(isPlain(prev) && isPlain(next))) return next;
+
+ const before = prev as Record;
+ const after = next as Record;
+ const keys = Object.keys(after);
+ const out: Record = arrays ? ([] as unknown as Record) : {};
+ let same = Object.keys(before).length === keys.length;
+
+ for (const key of keys) {
+ const value = shareDeep(before[key], after[key]);
+ out[key] = value;
+ if (value !== before[key] || !(key in before)) same = false;
+ }
+
+ return same ? prev : out;
+}
+
+function isPlain(value: unknown): value is Record {
+ if (value === null || typeof value !== "object") return false;
+ const prototype = Object.getPrototypeOf(value);
+ return prototype === Object.prototype || prototype === null;
+}
diff --git a/web/src/lib/leadHold.test.ts b/web/src/lib/leadHold.test.ts
index cf87da835..99a0d8026 100644
--- a/web/src/lib/leadHold.test.ts
+++ b/web/src/lib/leadHold.test.ts
@@ -34,7 +34,7 @@ describe("leadCanBePaused", () => {
});
it("refuses a lead that is already held", () => {
expect(
- leadCanBePaused(state({ lead_status: "paused", hold: { since: "2026-09-01T00:00:00Z", source: "manual" } })),
+ leadCanBePaused(state({ lead_status: "paused", hold: { since: new Date("2026-09-01T00:00:00Z"), source: "manual" } })),
).toBe(false);
});
it("refuses ended leads, finished campaigns and an unknown next step", () => {
diff --git a/web/src/main.tsx b/web/src/main.tsx
index 57447bdd4..23d576665 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -102,6 +102,7 @@ import CloudOAuthDonePage from './app/cloud-oauth/done/page';
import WarmblyCloudSettingsPage from './app/app/settings/warmbly-cloud/page';
import SetupPage from './app/setup/page';
import SSOCallbackPage from './app/auth/sso/page';
+import shareDeep from './lib/helper/shareDeep';
// React-Query defaults tuned for a dashboard. The library's
// out-of-the-box behaviour treats every query as immediately stale
@@ -119,6 +120,8 @@ import SSOCallbackPage from './app/auth/sso/page';
// comes back, do refresh once.
// - retry: 1 — react-query's default of 3 turns a 500ms backend
// hiccup into ~5s of stacked retries on the user.
+// - structuralSharing: shareDeep — keeps equal revived Dates, so
+// unchanged data keeps its identity across a refetch.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@@ -127,6 +130,7 @@ const queryClient = new QueryClient({
refetchOnWindowFocus: false,
refetchOnReconnect: "always",
retry: 1,
+ structuralSharing: shareDeep,
},
mutations: {
retry: 0,
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 53a7458b7..08bbe8471 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -1,9 +1,12 @@
-import { defineConfig } from "vite";
+if (!process.env.VITE_SENTRY_RELEASE && process.env.CF_PAGES_COMMIT_SHA) process.env.VITE_SENTRY_RELEASE = process.env.CF_PAGES_COMMIT_SHA;import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
import tailwindcss from "@tailwindcss/vite";
import { sentryVitePlugin } from "@sentry/vite-plugin";
+// A Cloudflare Pages build sets no release, so its events are tagged with the commit it built.
+if (!process.env.VITE_SENTRY_RELEASE && process.env.CF_PAGES_COMMIT_SHA) process.env.VITE_SENTRY_RELEASE = process.env.CF_PAGES_COMMIT_SHA;
+
// Source maps, and nothing else, is what this section decides.
//
// Uploading them is never a required build step: a fork, a self-host build or a