fix: hydration mismatches from relative dates

This commit is contained in:
2026-09-03 15:44:48 -04:00
parent 285677ff9a
commit 5bb49738b0
30 changed files with 933 additions and 876 deletions
@@ -8,10 +8,11 @@ import {
IconRefresh,
IconShieldLock,
} from "@tabler/icons-react";
import { formatDistanceToNowStrict } from "date-fns";
import { formatDistanceStrict } from "date-fns";
import { useCallback, useState } from "react";
import { useCalendarFeed } from "@/hooks/use-calendar-feed";
import { useHydratedNow } from "@/hooks/use-hydrated-now";
import {
AlertDialog,
AlertDialogAction,
@@ -77,6 +78,7 @@ export function CalendarInstructionsSkeleton({ className }: { className?: string
export function CalendarInstructions({ className }: { className?: string }) {
const [showRotateDialog, setShowRotateDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const now = useHydratedNow();
const { feed, isPending, enable, rotate, deleteFeed } = useCalendarFeed();
@@ -146,9 +148,11 @@ export function CalendarInstructions({ className }: { className?: string }) {
{feed.lastAccessedAt ? (
<span>
Last accessed{" "}
{formatDistanceToNowStrict(new Date(feed.lastAccessedAt), {
addSuffix: true,
})}
{now
? formatDistanceStrict(new Date(feed.lastAccessedAt), now, {
addSuffix: true,
})
: "…"}
</span>
) : (
<span>Not accessed yet.</span>
@@ -1,9 +1,10 @@
import { IconArchive, IconCircleArrowUp, IconRefresh, IconTrash } from "@tabler/icons-react";
import { formatDistanceToNow } from "date-fns";
import { formatDistanceStrict } from "date-fns";
import { DashboardBannerDismissable } from "@/components/dashboard/dashboard-banner-dismissable";
import { Favicon } from "@/components/icons/favicon";
import { useDashboardActions } from "@/context/dashboard-context";
import { useHydratedNow } from "@/hooks/use-hydrated-now";
import { useSubscription } from "@/hooks/use-subscription";
import type { TrackedDomainWithDetails } from "@domainstack/types";
import { Button } from "@domainstack/ui/button";
@@ -65,13 +66,7 @@ export function ArchivedDomainsList({ domains }: ArchivedDomainsListProps) {
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{domain.domainName}</div>
<div className="text-sm text-muted-foreground">
Archived{" "}
{domain.archivedAt
? formatDistanceToNow(new Date(domain.archivedAt), {
addSuffix: true,
includeSeconds: false,
})
: "recently"}
Archived <ArchivedRelativeTime archivedAt={domain.archivedAt} />
</div>
</div>
</div>
@@ -116,3 +111,19 @@ export function ArchivedDomainsList({ domains }: ArchivedDomainsListProps) {
</div>
);
}
function ArchivedRelativeTime({ archivedAt }: { archivedAt: Date | string | null | undefined }) {
const now = useHydratedNow();
if (!archivedAt || !now) {
return <span>recently</span>;
}
return (
<span>
{formatDistanceStrict(new Date(archivedAt), now, {
addSuffix: true,
})}
</span>
);
}
@@ -38,7 +38,7 @@ import {
type SortOption,
sortDomains,
} from "@/lib/dashboard-utils";
import { usePreferencesStore } from "@/lib/stores/preferences-store";
import { useDashboardViewMode } from "@/lib/stores/preferences-store";
import { useTRPC } from "@/lib/trpc/client";
import { useSession } from "@domainstack/auth/client";
import type { VerificationMethod } from "@domainstack/constants";
@@ -64,7 +64,7 @@ export function DashboardClient() {
.withDefault("active")
.withOptions({ shallow: true, clearOnDefault: true }),
);
const viewMode = usePreferencesStore((s) => s.viewMode);
const viewMode = useDashboardViewMode();
// Grid sort state with URL persistence
const [sortParam, setSortParam] = useQueryState(
@@ -8,7 +8,7 @@ import { DashboardGrid } from "@/components/dashboard/dashboard-grid";
import { DashboardTable } from "@/components/dashboard/dashboard-table";
import { useDashboardFiltersContext } from "@/context/dashboard-context";
import { useIsClient } from "@/hooks/use-is-client";
import { usePreferencesStore } from "@/lib/stores/preferences-store";
import { useDashboardViewMode } from "@/lib/stores/preferences-store";
import type { TrackedDomainWithDetails } from "@domainstack/types";
import { Button, buttonVariants } from "@domainstack/ui/button";
import {
@@ -27,7 +27,7 @@ type DashboardContentProps = {
export function DashboardContent({ domains, totalDomains }: DashboardContentProps) {
const { hasActiveFilters, clearFilters } = useDashboardFiltersContext();
const viewMode = usePreferencesStore((s) => s.viewMode);
const viewMode = useDashboardViewMode();
// Avoid animating the initial view swap during hydration when localStorage preferences reconcile.
const hasHydrated = useIsClient();
const shouldReduceMotion = useReducedMotion();
@@ -19,7 +19,7 @@ import { MobileFiltersCollapsible } from "@/components/dashboard/mobile-filters-
import { ProviderLogo } from "@/components/icons/provider-logo";
import { useDashboardFiltersContext } from "@/context/dashboard-context";
import { HEALTH_OPTIONS } from "@/lib/constants/domain-filters";
import { usePreferencesStore } from "@/lib/stores/preferences-store";
import { useDashboardViewMode } from "@/lib/stores/preferences-store";
import { Button } from "@domainstack/ui/button";
export function DashboardFilters() {
@@ -44,7 +44,7 @@ export function DashboardFilters() {
sortOption,
setSortOption,
} = useDashboardFiltersContext();
const viewMode = usePreferencesStore((s) => s.viewMode);
const viewMode = useDashboardViewMode();
const shouldReduceMotion = useReducedMotion();
// Flat map of all providers for chip rendering
@@ -9,7 +9,6 @@ import {
IconTool,
IconTrash,
} from "@tabler/icons-react";
import { format } from "date-fns";
import * as m from "motion/react-m";
import Link from "next/link";
import { memo, useCallback } from "react";
@@ -45,7 +44,7 @@ import {
} from "@domainstack/ui/responsive-tooltip";
import { Spinner } from "@domainstack/ui/spinner";
import { cn } from "@domainstack/ui/utils";
import { formatDateTimeUtc } from "@domainstack/utils";
import { formatDate, formatDateTimeUtc } from "@domainstack/utils";
type DashboardGridCardProps = {
domain: TrackedDomainWithDetails;
@@ -244,12 +243,10 @@ export const DashboardGridCard = memo(function DashboardGridCard({
<ResponsiveTooltip>
<ResponsiveTooltipTrigger
nativeButton={false}
render={
<span className="truncate">{format(expirationDate, "MMM d, yyyy")}</span>
}
render={<span className="truncate">{formatDate(expirationDate)}</span>}
/>
<ResponsiveTooltipContent>
{formatDateTimeUtc(expirationDate.toISOString())}
{formatDateTimeUtc(expirationDate)}
</ResponsiveTooltipContent>
</ResponsiveTooltip>
) : (
@@ -307,14 +304,10 @@ export const DashboardGridCard = memo(function DashboardGridCard({
<ResponsiveTooltip>
<ResponsiveTooltipTrigger
nativeButton={false}
render={
<span className="truncate">
{format(expirationDate, "MMM d, yyyy")}
</span>
}
render={<span className="truncate">{formatDate(expirationDate)}</span>}
/>
<ResponsiveTooltipContent>
{formatDateTimeUtc(expirationDate.toISOString())}
{formatDateTimeUtc(expirationDate)}
</ResponsiveTooltipContent>
</ResponsiveTooltip>
<span className="shrink-0 text-[11px] leading-none text-muted-foreground">
@@ -5,14 +5,13 @@ import {
IconRocket,
IconTable,
} from "@tabler/icons-react";
import { format } from "date-fns";
import Link from "next/link";
import { CalendarFeedPopover } from "@/components/dashboard/calendar-feed-popover";
import { QuotaBar } from "@/components/dashboard/quota-bar";
import { useSubscription } from "@/hooks/use-subscription";
import type { DashboardViewModeOptions } from "@/lib/dashboard-utils";
import { usePreferencesStore } from "@/lib/stores/preferences-store";
import { useDashboardViewMode, usePreferencesStore } from "@/lib/stores/preferences-store";
import { Button, buttonVariants } from "@domainstack/ui/button";
import {
ResponsiveTooltip,
@@ -22,13 +21,14 @@ import {
import { Separator } from "@domainstack/ui/separator";
import { ToggleGroup, ToggleGroupItem } from "@domainstack/ui/toggle-group";
import { Tooltip, TooltipContent, TooltipTrigger } from "@domainstack/ui/tooltip";
import { formatDate } from "@domainstack/utils";
type DashboardHeaderProps = {
userName: string;
};
export function DashboardHeader({ userName }: DashboardHeaderProps) {
const viewMode = usePreferencesStore((s) => s.viewMode);
const viewMode = useDashboardViewMode();
const setViewMode = usePreferencesStore((s) => s.setViewMode);
const { subscription, handleCheckout } = useSubscription();
@@ -53,7 +53,7 @@ export function DashboardHeader({ userName }: DashboardHeaderProps) {
}
/>
<ResponsiveTooltipContent>
Access until {format(subscription.endsAt, "MMM d, yyyy")}
Access until {formatDate(subscription.endsAt)}
</ResponsiveTooltipContent>
</ResponsiveTooltip>
) : (
@@ -186,7 +186,7 @@ describe("dashboard quota and banners", () => {
render(<SubscriptionEndingBanner />);
expect(screen.getByText("Your Pro subscription is ending")).toBeInTheDocument();
expect(screen.getByText("September 2, 2026")).toBeInTheDocument();
expect(screen.getByText("Sep 2, 2026")).toBeInTheDocument();
expect(
screen.getByText(new RegExp(`free quota of ${PLAN_QUOTAS.free} domains`)),
).toBeInTheDocument();
@@ -1,7 +1,7 @@
import { IconEye, IconTableOptions } from "@tabler/icons-react";
import { HIDEABLE_COLUMNS } from "@/components/dashboard/dashboard-table-columns";
import { usePreferencesStore } from "@/lib/stores/preferences-store";
import { useDashboardColumnVisibility, usePreferencesStore } from "@/lib/stores/preferences-store";
import { Button } from "@domainstack/ui/button";
import {
DropdownMenu,
@@ -17,7 +17,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@domainstack/ui/tooltip
export function DashboardTableColumnMenu() {
// Visibility is controlled by the preferences store, so this menu writes
// there rather than calling `column.toggleVisibility()` on a table instance.
const columnVisibility = usePreferencesStore((s) => s.columnVisibility);
const columnVisibility = useDashboardColumnVisibility();
const setColumnVisibility = usePreferencesStore((s) => s.setColumnVisibility);
const isColumnVisible = (columnId: string) => columnVisibility[columnId] !== false;
@@ -8,7 +8,6 @@ import {
IconTrash,
} from "@tabler/icons-react";
import type { ColumnDef } from "@tanstack/react-table";
import { format } from "date-fns";
import Link from "next/link";
import { DomainHealthBadge } from "@/components/dashboard/domain-health-badge";
@@ -35,7 +34,7 @@ import {
ResponsiveTooltipTrigger,
} from "@domainstack/ui/responsive-tooltip";
import { cn } from "@domainstack/ui/utils";
import { formatDateTimeUtc } from "@domainstack/utils";
import { formatDate, formatDateTimeUtc } from "@domainstack/utils";
/**
* Header labels for every column that renders a plain text header. Shared with
@@ -282,11 +281,9 @@ export function createColumns(
<ResponsiveTooltip>
<ResponsiveTooltipTrigger
nativeButton={false}
render={<span>{format(date, "MMM d, yyyy")}</span>}
render={<span>{formatDate(date)}</span>}
/>
<ResponsiveTooltipContent>
{formatDateTimeUtc(date.toISOString())}
</ResponsiveTooltipContent>
<ResponsiveTooltipContent>{formatDateTimeUtc(date)}</ResponsiveTooltipContent>
</ResponsiveTooltip>
</div>
);
@@ -401,11 +398,9 @@ export function createColumns(
<ResponsiveTooltip>
<ResponsiveTooltipTrigger
nativeButton={false}
render={<span>{format(date, "MMM d, yyyy")}</span>}
render={<span>{formatDate(date)}</span>}
/>
<ResponsiveTooltipContent>
{formatDateTimeUtc(date.toISOString())}
</ResponsiveTooltipContent>
<ResponsiveTooltipContent>{formatDateTimeUtc(date)}</ResponsiveTooltipContent>
</ResponsiveTooltip>
</div>
);
@@ -427,11 +422,9 @@ export function createColumns(
<ResponsiveTooltip>
<ResponsiveTooltipTrigger
nativeButton={false}
render={<span>{format(date, "MMM d, yyyy")}</span>}
render={<span>{formatDate(date)}</span>}
/>
<ResponsiveTooltipContent>
{formatDateTimeUtc(date.toISOString())}
</ResponsiveTooltipContent>
<ResponsiveTooltipContent>{formatDateTimeUtc(date)}</ResponsiveTooltipContent>
</ResponsiveTooltip>
</div>
);
@@ -19,7 +19,7 @@ import {
type DashboardTableFeatures,
} from "@/lib/dashboard-table-features";
import { DEFAULT_SORT, parseSortParam, serializeSortState } from "@/lib/dashboard-utils";
import { usePreferencesStore } from "@/lib/stores/preferences-store";
import { useDashboardColumnVisibility, usePreferencesStore } from "@/lib/stores/preferences-store";
import type { TrackedDomainWithDetails } from "@domainstack/types";
import { ScrollArea } from "@domainstack/ui/scroll-area";
import { cn } from "@domainstack/ui/utils";
@@ -58,7 +58,7 @@ export function DashboardTable({ domains }: DashboardTableProps) {
[sorting, setSortParam, resetPage],
);
const columnVisibility = usePreferencesStore((s) => s.columnVisibility);
const columnVisibility = useDashboardColumnVisibility();
const setColumnVisibility = usePreferencesStore((s) => s.setColumnVisibility);
const withUnverifiedLast = useMemo(
@@ -5,7 +5,7 @@ import {
IconQuestionMark,
type TablerIcon,
} from "@tabler/icons-react";
import { differenceInDays, formatDistanceToNowStrict } from "date-fns";
import { differenceInDays, formatDistanceStrict } from "date-fns";
import { useMemo } from "react";
import { BadgeWithTooltip } from "@/components/dashboard/badge-with-tooltip";
@@ -31,7 +31,7 @@ export function DomainHealthBadge({ expirationDate, verified, className }: Domai
const tooltipText = useMemo(() => {
if (!expirationDate || !now) return null;
const isExpired = expirationDate <= now;
const relativeTime = formatDistanceToNowStrict(expirationDate, {
const relativeTime = formatDistanceStrict(expirationDate, now, {
addSuffix: true,
});
return `${isExpired ? "Expired" : "Expires"} ${relativeTime}`;
@@ -4,7 +4,7 @@ import * as m from "motion/react-m";
import { useState } from "react";
import { DashboardTableColumnMenu } from "@/components/dashboard/dashboard-table-column-menu";
import { usePreferencesStore } from "@/lib/stores/preferences-store";
import { useDashboardViewMode } from "@/lib/stores/preferences-store";
import { Badge } from "@domainstack/ui/badge";
import { Button } from "@domainstack/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@domainstack/ui/collapsible";
@@ -21,7 +21,7 @@ export function MobileFiltersCollapsible({
activeFilterCount,
children,
}: MobileFiltersCollapsibleProps) {
const viewMode = usePreferencesStore((s) => s.viewMode);
const viewMode = useDashboardViewMode();
const shouldReduceMotion = useReducedMotion();
const [mobileOpen, setMobileOpen] = useState(false);
@@ -1,11 +1,11 @@
import { IconLock, IconLockOpen, IconRosetteDiscountCheck, IconSpy } from "@tabler/icons-react";
import { format } from "date-fns";
import { formatRegistrant } from "@/components/domain/registration/registration-section";
import { ProviderLogo } from "@/components/icons/provider-logo";
import type { ProviderCategory } from "@domainstack/constants";
import type { DnsRecord, RegistrationContact } from "@domainstack/types";
import { Spinner } from "@domainstack/ui/spinner";
import { formatDate } from "@domainstack/utils";
type ProviderTooltipContentProps = {
providerId?: string | null;
@@ -178,7 +178,7 @@ export function ProviderTooltipContent({
) : providerType === "ca" ? (
// Certificate expiry for CA providers
hasCertificateExpiry ? (
<div className="text-xs">Expires on {format(certificateExpiryDate, "MMM d, yyyy")}</div>
<div className="text-xs">Expires on {formatDate(certificateExpiryDate)}</div>
) : (
<div className="text-xs text-muted/80">No certificate data available</div>
)
@@ -1,10 +1,11 @@
import { IconCalendarDot } from "@tabler/icons-react";
import { differenceInDays, format, formatDistanceToNow } from "date-fns";
import { differenceInDays, formatDistanceStrict } from "date-fns";
import { DashboardBannerDismissable } from "@/components/dashboard/dashboard-banner-dismissable";
import { useHydratedNow } from "@/hooks/use-hydrated-now";
import { useSubscription } from "@/hooks/use-subscription";
import { PLAN_QUOTAS } from "@domainstack/constants";
import { formatDate } from "@domainstack/utils";
export function SubscriptionEndingBanner() {
const { handleCheckout, isCheckoutLoading, handleCustomerPortal, isCustomerPortalLoading } =
@@ -25,8 +26,8 @@ export function SubscriptionEndingBanner() {
if (isExpired) return null;
const daysRemaining = differenceInDays(subscription.endsAt, now);
const formattedDate = format(subscription.endsAt, "MMMM d, yyyy");
const relativeTime = formatDistanceToNow(subscription.endsAt, {
const formattedDate = formatDate(subscription.endsAt);
const relativeTime = formatDistanceStrict(subscription.endsAt, now, {
addSuffix: true,
});
@@ -46,7 +46,7 @@ export function HeadersSection({ data }: { domain?: string; data?: HeadersRespon
(a, b) =>
Number(IMPORTANT_HEADERS.has(b.name?.toLowerCase() ?? "")) -
Number(IMPORTANT_HEADERS.has(a.name?.toLowerCase() ?? "")) ||
(a.name?.toLowerCase() ?? "").localeCompare(b.name?.toLowerCase() ?? ""),
(a.name?.toLowerCase() ?? "").localeCompare(b.name?.toLowerCase() ?? "", "en"),
);
}, [data?.headers]);
@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@/mocks/react";
describe("RelativeExpiryString", () => {
it("renders an invisible placeholder before hydration", async () => {
vi.resetModules();
const { resetHydratedNow } = await import("@/hooks/use-hydrated-now");
const { RelativeExpiryString } = await import("./relative-expiry");
resetHydratedNow(null);
render(<RelativeExpiryString to="2026-01-01T00:00:00Z" />);
expect(screen.getByText("(loading)")).toHaveClass("invisible");
});
it("renders the expiry from the shared clock after hydration", async () => {
vi.resetModules();
const { resetHydratedNow } = await import("@/hooks/use-hydrated-now");
const { RelativeExpiryString } = await import("./relative-expiry");
resetHydratedNow(new Date("2025-01-01T00:00:00Z"));
render(<RelativeExpiryString to="2026-01-01T00:00:00Z" />);
expect(await screen.findByText("(in 1 year)")).toBeInTheDocument();
});
});
+11 -6
View File
@@ -1,6 +1,6 @@
"use client";
import { differenceInDays, formatDistanceToNowStrict } from "date-fns";
import { differenceInDays, formatDistanceStrict } from "date-fns";
import { useMemo } from "react";
import { useHydratedNow } from "@/hooks/use-hydrated-now";
@@ -21,16 +21,15 @@ export function RelativeExpiryString({
/** className applied to the wrapper span */
className?: string;
}) {
// Use shared hydrated time to avoid render cascades
// Use shared hydrated time so the server and client render the same string
const now = useHydratedNow();
// Calculate state synchronously using memoization
const state = useMemo(() => {
if (!now) return null;
try {
const targetDate = new Date(to);
return {
text: formatDistanceToNowStrict(targetDate, { addSuffix: true }),
text: formatDistanceStrict(targetDate, now, { addSuffix: true }),
daysUntil: differenceInDays(targetDate, now),
};
} catch {
@@ -39,8 +38,14 @@ export function RelativeExpiryString({
}
}, [to, now]);
// SSR: render nothing until client hydrates
if (!state) return null;
// Render invisible placeholder before hydration to prevent layout shift
if (!state) {
return (
<span className={cn("invisible", className)} aria-hidden>
(loading)
</span>
);
}
const { text, daysUntil } = state;
@@ -1,9 +1,10 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import { formatDistanceStrict } from "date-fns";
import Link from "next/link";
import { createElement } from "react";
import { useHydratedNow } from "@/hooks/use-hydrated-now";
import {
getNotificationIcon,
getNotificationSeverity,
@@ -24,6 +25,7 @@ export function NotificationCard({ notification, onClick }: NotificationCardProp
const severity = getNotificationSeverity(notification.type);
const iconColor = getSeverityIconColor(severity, !!notification.readAt);
const isUnread = !notification.readAt;
const now = useHydratedNow();
// Build href with domainId filter when notification is domain-specific
const href = notification.trackedDomainId
@@ -63,9 +65,11 @@ export function NotificationCard({ notification, onClick }: NotificationCardProp
</div>
<p className="line-clamp-3 text-[13px] text-muted-foreground">{notification.message}</p>
<p className="mt-1 text-xs text-muted-foreground/75">
{formatDistanceToNow(notification.sentAt, {
addSuffix: true,
})}
{now
? formatDistanceStrict(notification.sentAt, now, {
addSuffix: true,
})
: "…"}
</p>
</div>
</div>
@@ -25,6 +25,7 @@ import {
makeNotification,
makeNotificationsInfiniteData,
} from "@/components/notifications/test-fixtures";
import { resetHydratedNow } from "@/hooks/use-hydrated-now";
import { createTestQueryClient, render, screen, waitFor, within } from "@/mocks/react";
import {
listNotificationsQuery,
@@ -87,7 +88,9 @@ async function openInbox(user: ReturnType<typeof userEvent.setup>) {
describe("NotificationsPopover", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-08-24T12:00:00.000Z"));
const now = new Date("2026-08-24T12:00:00.000Z");
vi.setSystemTime(now);
resetHydratedNow(now);
resetTrpcMocks();
nav.push.mockClear();
});
+2 -2
View File
@@ -1,9 +1,9 @@
import { IconGift, IconRocket } from "@tabler/icons-react";
import { format } from "date-fns";
import { QuotaBar } from "@/components/dashboard/quota-bar";
import { Badge } from "@domainstack/ui/badge";
import { cn } from "@domainstack/ui/utils";
import { formatDate } from "@domainstack/utils";
interface PlanStatusCardProps {
activeCount: number;
@@ -46,7 +46,7 @@ export function PlanStatusCard({
: "border-accent-blue/30 bg-accent-blue/10 text-accent-blue",
)}
>
{endsAt ? `Ends ${format(endsAt, "MMM d")}` : "Active"}
{endsAt ? `Ends ${formatDate(endsAt)}` : "Active"}
</Badge>
</div>
<p className="text-[13px] text-muted-foreground">
@@ -1,5 +1,4 @@
import { IconCreditCard } from "@tabler/icons-react";
import { format } from "date-fns";
import { PlanStatusCard } from "@/components/plan-status-card";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -8,6 +7,7 @@ import { UpgradeCard } from "@/components/upgrade-card";
import { useSubscription } from "@/hooks/use-subscription";
import { Button } from "@domainstack/ui/button";
import { Spinner } from "@domainstack/ui/spinner";
import { formatDate } from "@domainstack/utils";
export function SubscriptionPanel() {
// Subscription query and hooks
@@ -62,7 +62,7 @@ export function SubscriptionPanel() {
</Button>
{subscription?.endsAt && (
<p className="text-center text-xs text-muted-foreground">
Your Pro access continues until {format(subscription.endsAt, "MMMM d, yyyy")}
Your Pro access continues until {formatDate(subscription.endsAt)}
</p>
)}
</div>
+2 -2
View File
@@ -2,7 +2,7 @@ import { parseAsInteger, useQueryState } from "nuqs";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { type DashboardPageSizeOptions, isPagePastEnd } from "@/lib/dashboard-utils";
import { usePreferencesStore } from "@/lib/stores/preferences-store";
import { useDashboardPageSize, usePreferencesStore } from "@/lib/stores/preferences-store";
// ---------------------------------------------------------------------------
// Types
@@ -47,7 +47,7 @@ export function useDashboardPagination(): UseDashboardPaginationReturn {
);
// Page size from localStorage preferences
const pageSize = usePreferencesStore((s) => s.pageSize);
const pageSize = useDashboardPageSize();
const setPageSizePreference = usePreferencesStore((s) => s.setPageSize);
// Convert to 0-based index for TanStack Table
+22
View File
@@ -157,3 +157,25 @@ export const usePreferencesHydrated = () =>
() => preferencesStore.persist.hasHydrated(),
() => false,
);
/**
* Read a persisted preference, falling back to `ssrValue` until localStorage
* rehydration finishes so the first client render matches SSR.
*/
function useHydratedPreference<T>(selector: (state: PreferencesStore) => T, ssrValue: T): T {
const value = usePreferencesStore(selector);
const hydrated = usePreferencesHydrated();
return hydrated ? value : ssrValue;
}
export function useDashboardViewMode(): DashboardViewModeOptions {
return useHydratedPreference((s) => s.viewMode, DEFAULT_PREFERENCES.viewMode);
}
export function useDashboardPageSize(): DashboardPageSizeOptions {
return useHydratedPreference((s) => s.pageSize, DEFAULT_PREFERENCES.pageSize);
}
export function useDashboardColumnVisibility(): Record<string, boolean> {
return useHydratedPreference((s) => s.columnVisibility, DEFAULT_PREFERENCES.columnVisibility);
}
+10 -10
View File
@@ -17,9 +17,9 @@
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@ai-sdk/gateway": "^4.0.69",
"@ai-sdk/react": "^4.0.88",
"@ai-sdk/workflow": "^2.0.15",
"@ai-sdk/gateway": "^4.0.72",
"@ai-sdk/react": "^4.0.93",
"@ai-sdk/workflow": "^2.0.21",
"@bprogress/next": "^3.2.12",
"@browser-ai/core": "^3.0.2",
"@domainstack/api": "workspace:*",
@@ -41,7 +41,7 @@
"@icons-pack/react-simple-icons": "^13.15.1",
"@modelcontextprotocol/server": "^2.0.0",
"@posthog/mcp": "^0.12.0",
"@posthog/nextjs-config": "^1.10.0",
"@posthog/nextjs-config": "^1.11.0",
"@tabler/icons-react": "catalog:",
"@tanstack/react-hotkeys": "^0.10.0",
"@tanstack/react-query": "^5.102.8",
@@ -51,20 +51,20 @@
"@trpc/server": "^11.18.0",
"@trpc/tanstack-react-query": "^11.18.0",
"@vercel/functions": "^3.9.5",
"ai": "^7.0.85",
"ai": "^7.0.90",
"date-fns": "^4.4.0",
"geist": "^1.7.2",
"jotai": "^2.20.3",
"lru-cache": "^11.5.2",
"maplibre-gl": "^6.6.0",
"maplibre-gl": "^6.7.0",
"mcp-handler": "^2.1.1",
"motion": "catalog:",
"ms": "3.0.0-canary.202508261828",
"next": "16.3.3",
"next": "16.3.4",
"next-themes": "^0.4.6",
"nuqs": "^2.10.1",
"posthog-js": "^1.422.5",
"posthog-node": "^5.51.4",
"posthog-js": "^1.425.1",
"posthog-node": "^5.51.6",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-error-boundary": "^6.1.4",
@@ -88,7 +88,7 @@
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.6",
"@testing-library/user-event": "14.6.7",
"@types/node": "catalog:",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.5",
+2 -2
View File
@@ -29,8 +29,8 @@
"clean": "shx rm -rf .turbo \"apps/*/.turbo\" \"apps/*/node_modules\" \"apps/*/*.tsbuildinfo\" apps/web/.next \"packages/*/.turbo\" \"packages/*/node_modules\" \"packages/*/*.tsbuildinfo\" node_modules pnpm-lock.yaml"
},
"devDependencies": {
"oxfmt": "^0.65.0",
"oxlint": "^1.80.0",
"oxfmt": "^0.66.0",
"oxlint": "^1.81.0",
"shx": "^0.4.0",
"turbo": "^2.10.12",
"typescript": "^7.0.2"
+4 -4
View File
@@ -15,10 +15,10 @@
"fmt:check": "oxfmt --check --config ../../.oxfmtrc.json"
},
"dependencies": {
"@opentelemetry/api-logs": "^0.221.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.221.0",
"@opentelemetry/resources": "^2.10.0",
"@opentelemetry/sdk-logs": "^0.221.0",
"@opentelemetry/api-logs": "^0.222.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.222.0",
"@opentelemetry/resources": "^2.11.0",
"@opentelemetry/sdk-logs": "^0.222.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3"
},
+21 -12
View File
@@ -1,12 +1,21 @@
function toDate(value: string | Date): Date {
return value instanceof Date ? value : new Date(value);
}
function fallbackDateLabel(value: string | Date): string {
return typeof value === "string" ? value : "";
}
/**
* Formats a date string in UTC using the native Intl.DateTimeFormat API.
* @param iso - ISO 8601 date string or any valid date string
* Formats a date in UTC using the native Intl.DateTimeFormat API.
* Always uses UTC so server and client render the same calendar day.
* @param value - ISO 8601 date string or Date
* @returns Formatted date string (e.g., "Oct 2, 2025")
*/
export function formatDate(iso: string): string {
export function formatDate(value: string | Date): string {
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const d = toDate(value);
if (Number.isNaN(d.getTime())) return fallbackDateLabel(value);
// Use Intl.DateTimeFormat for native, zero-bundle formatting
// Output: "Oct 2, 2025"
@@ -17,19 +26,19 @@ export function formatDate(iso: string): string {
timeZone: "UTC",
}).format(d);
} catch {
return iso;
return fallbackDateLabel(value);
}
}
/**
* Formats a date string as ISO-like datetime in UTC using native Intl.DateTimeFormat API.
* @param iso - ISO 8601 date string or any valid date string
* Formats a date as ISO-like datetime in UTC using native Intl.DateTimeFormat API.
* @param value - ISO 8601 date string or Date
* @returns Formatted datetime string (e.g., "2025-10-02 14:30:05 UTC")
*/
export function formatDateTimeUtc(iso: string): string {
export function formatDateTimeUtc(value: string | Date): string {
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const d = toDate(value);
if (Number.isNaN(d.getTime())) return fallbackDateLabel(value);
// Use Intl.DateTimeFormat with formatToParts for precise control
const formatter = new Intl.DateTimeFormat("en-US", {
@@ -52,6 +61,6 @@ export function formatDateTimeUtc(iso: string): string {
// Construct: 2025-10-02 14:30:05 UTC
return `${partMap.year}-${partMap.month}-${partMap.day} ${partMap.hour}:${partMap.minute}:${partMap.second} UTC`;
} catch {
return iso;
return fallbackDateLabel(value);
}
}
+749 -766
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -4,14 +4,14 @@ packages:
catalog:
'@tabler/icons-react': ^3.46.0
'@types/node': ^24.13.3
motion: ^13.1.1
motion: ^13.2.0
sonner: ^2.0.8
vitest: ^4.1.11
zod: ^4.5.4
overrides:
'@types/react': 19.2.18
'@types/react-dom': 19.2.5
next: 16.3.3
next: 16.3.4
react: 19.2.8
react-dom: 19.2.8
publicHoistPattern: