mirror of
https://github.com/jakejarvis/domainstack.io.git
synced 2026-09-11 05:25:33 -04:00
fix: hydration mismatches from relative dates
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+749
-766
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user