From ea5dd435d72db71cbfae3b69c13ca75ae4415919 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sun, 27 Sep 2026 14:59:06 +0200 Subject: [PATCH] feat: revive only whole RFC3339 timestamps (never custom field values or text that starts with a date), keep equal revived Dates across React Query refetches so unchanged data keeps its identity, type every revived response timestamp in the dashboard models as Date, reset the launch dialog only when a different campaign opens, show the build on the page error panel, and tag Cloudflare Pages dashboard and admin builds with their commit as the release --- admin/vite.config.ts | 5 ++- .../api-keys/_components/KeyDetailDrawer.tsx | 6 +-- .../app/api-keys/_components/Sparkline.tsx | 6 +-- web/src/app/app/api-keys/page.tsx | 4 +- web/src/app/app/audit/page.tsx | 2 +- web/src/app/app/crm/deals/page.tsx | 7 ++-- web/src/app/app/crm/meetings/page.tsx | 4 +- web/src/app/app/crm/tasks/page.tsx | 13 ++++--- .../app/app/settings/billing/CreditsCard.tsx | 4 +- web/src/app/app/settings/billing/page.tsx | 2 +- web/src/app/app/settings/referral/page.tsx | 2 +- .../settings/security/TwoFactorManager.tsx | 2 +- web/src/components/app/agent/AgentPanel.tsx | 2 +- .../app/analytics/DirectMailSection.tsx | 3 +- .../app/campaigns/LaunchCampaignDialog.tsx | 3 +- .../components/app/campaigns/SendPlanCard.tsx | 4 +- .../components/app/contacts/ImportWizard.tsx | 2 +- .../app/contacts/VerificationBadge.test.ts | 2 +- web/src/components/app/contacts/columns.tsx | 4 +- .../app/contacts/contact-edit/OverviewTab.tsx | 2 +- web/src/components/app/crm/DealsTable.tsx | 2 +- .../components/app/emails/SyncStatusCard.tsx | 4 +- .../app/unibox/ContactContextPanel.tsx | 10 +++-- .../components/app/unibox/ScheduledList.tsx | 2 +- web/src/components/app/unibox/ThreadView.tsx | 4 +- .../app/unibox/compose/ComposeDraftsItem.tsx | 2 +- .../unibox/compose/ComposeHistoryPanel.tsx | 2 +- .../app/unibox/compose/ComposeWindow.tsx | 6 +-- web/src/components/layout/CreditsMeter.tsx | 2 +- web/src/components/layout/ErrorBoundary.tsx | 2 + .../components/layout/NotificationBell.tsx | 2 +- .../client/app/campaigns/estimateCampaign.ts | 2 +- .../client/app/campaigns/getCampaignLogs.ts | 2 +- .../api/client/app/contacts/importContacts.ts | 5 ++- .../api/client/app/crm/deals/createDeal.ts | 3 +- .../api/client/app/crm/deals/updateDeal.ts | 3 +- .../api/client/app/crm/tasks/createCRMTask.ts | 3 +- .../api/client/app/crm/tasks/updateCRMTask.ts | 3 +- .../app/organizations/getOrganizations.ts | 2 +- .../lib/api/client/app/unibox/agentDrafts.ts | 4 +- .../api/client/app/unibox/composeDrafts.ts | 4 +- .../api/client/app/unibox/searchIncoming.ts | 2 +- .../lib/api/client/app/unibox/snoozeThread.ts | 2 +- web/src/lib/api/client/auth/twoFactor.ts | 2 +- .../api/hooks/app/crm/deals/useCreateDeal.ts | 4 +- .../api/hooks/app/crm/deals/useUpdateDeal.ts | 4 +- .../hooks/app/crm/tasks/useCreateCRMTask.ts | 4 +- .../hooks/app/crm/tasks/useUpdateCRMTask.ts | 4 +- web/src/lib/api/models/app/advisor/Advisor.ts | 12 +++--- web/src/lib/api/models/app/agent/Agent.ts | 4 +- .../api/models/app/analytics/AccountStatus.ts | 16 ++++---- .../models/app/analytics/DashboardOverview.ts | 2 +- .../models/app/analytics/Deliverability.ts | 4 +- .../app/analytics/DirectMailAnalytics.ts | 4 +- web/src/lib/api/models/app/apikeys/APIKey.ts | 10 ++--- .../api/models/app/apikeys/APIKeyAnalytics.ts | 10 ++--- web/src/lib/api/models/app/audit/AuditLog.ts | 4 +- .../api/models/app/automations/Automation.ts | 8 ++-- .../lib/api/models/app/campaigns/Campaign.ts | 10 ++--- .../api/models/app/campaigns/EmailImage.ts | 2 +- .../lib/api/models/app/campaigns/Preflight.ts | 2 +- .../lib/api/models/app/campaigns/SendPlan.ts | 16 ++++---- web/src/lib/api/models/app/cliauth/CLIAuth.ts | 4 +- .../lib/api/models/app/contacts/Contact.ts | 10 ++--- .../app/contacts/ContactCampaignState.ts | 18 ++++----- .../api/models/app/contacts/ContactDetail.ts | 24 ++++++------ .../app/contacts/ContactTimelineEvent.ts | 6 +-- .../lib/api/models/app/contacts/Research.ts | 4 +- web/src/lib/api/models/app/crm/CRMTask.ts | 11 ++++-- web/src/lib/api/models/app/crm/Deal.ts | 13 ++++--- web/src/lib/api/models/app/crm/Pipeline.ts | 8 ++-- web/src/lib/api/models/app/crm/TaskType.ts | 4 +- .../lib/api/models/app/emails/SendIdentity.ts | 4 +- .../api/models/app/emails/SendingBehavior.ts | 6 +-- .../lib/api/models/app/emails/SyncState.ts | 10 ++--- .../api/models/app/emails/WarmupBanStatus.ts | 4 +- web/src/lib/api/models/app/forms/FormStats.ts | 2 +- .../lib/api/models/app/forms/FormsDomain.ts | 2 +- .../api/models/app/inboxtag/InboxTagReview.ts | 2 +- .../models/app/integrations/Integration.ts | 30 +++++++-------- .../lib/api/models/app/leadsync/LeadSync.ts | 6 +-- web/src/lib/api/models/app/mcp/MCPServer.ts | 4 +- .../models/app/notifications/Notification.ts | 4 +- web/src/lib/api/models/app/oauth/OAuthApp.ts | 8 ++-- .../app/organizations/LimitIncreaseRequest.ts | 4 +- .../app/organizations/OrganizationRole.ts | 4 +- .../lib/api/models/app/segments/Segment.ts | 2 +- web/src/lib/api/models/app/skills/Skill.ts | 4 +- .../api/models/app/subscription/Credits.ts | 8 ++-- .../app/subscription/DiscountRedemption.ts | 4 +- .../api/models/app/subscription/Referral.ts | 12 +++--- .../models/app/suppressions/Suppression.ts | 6 +-- web/src/lib/api/models/app/teams/Team.ts | 6 +-- web/src/lib/api/models/app/unibox/Compose.ts | 4 +- .../api/models/app/unibox/UniboxOverview.ts | 6 +-- .../api/models/app/unibox/UniboxScheduled.ts | 4 +- .../lib/api/models/app/unibox/UniboxThread.ts | 2 +- .../api/models/app/views/ViewPreferences.ts | 2 +- .../lib/api/models/app/webhooks/Webhook.ts | 24 ++++++------ .../WebsiteTrackingSettings.ts | 2 +- web/src/lib/helper/dueDate.ts | 2 +- web/src/lib/helper/reviveDates.test.ts | 31 ++++++++++++++++ web/src/lib/helper/reviveDates.ts | 11 +++++- web/src/lib/helper/shareDeep.test.ts | 37 +++++++++++++++++++ web/src/lib/helper/shareDeep.ts | 32 ++++++++++++++++ web/src/lib/leadHold.test.ts | 2 +- web/src/main.tsx | 4 ++ web/vite.config.ts | 5 ++- 108 files changed, 406 insertions(+), 270 deletions(-) create mode 100644 web/src/lib/helper/reviveDates.test.ts create mode 100644 web/src/lib/helper/shareDeep.test.ts create mode 100644 web/src/lib/helper/shareDeep.ts 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