diff --git a/resources/js/components/profile/ProfileHero.jsx b/resources/js/components/profile/ProfileHero.jsx index 0e42e38f..e222ff23 100644 --- a/resources/js/components/profile/ProfileHero.jsx +++ b/resources/js/components/profile/ProfileHero.jsx @@ -7,10 +7,10 @@ import FollowButton from '../social/FollowButton' import FollowersPreview from '../social/FollowersPreview' import MutualFollowersBadge from '../social/MutualFollowersBadge' import { shinyFlagUrl } from '../../utils/flagUrl' +import { formatProfileDate, formatProfileNumber } from '../../lib/profileFormatters' function formatCompactNumber(value) { - const numeric = Number(value ?? 0) - return numeric.toLocaleString('en-US') + return formatProfileNumber(value) } export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing, followerCount, recentFollowers = [], followContext = null, heroBgUrl, countryName, leaderboardRank, extraActions = null }) { @@ -25,7 +25,7 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing, const uname = user.username || user.name || 'Unknown' const displayName = user.name || uname const joinDate = user.created_at - ? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' }) + ? formatProfileDate(user.created_at, { month: 'long', year: 'numeric' }) : null const progressPercent = Math.round(Number(user?.progress_percent ?? 0)) const heroStats = [ diff --git a/resources/js/components/profile/tabs/TabArtworks.hydration.test.jsx b/resources/js/components/profile/tabs/TabArtworks.hydration.test.jsx new file mode 100644 index 00000000..eefda632 --- /dev/null +++ b/resources/js/components/profile/tabs/TabArtworks.hydration.test.jsx @@ -0,0 +1,46 @@ +import React from 'react' +import { hydrateRoot } from 'react-dom/client' +import { renderToString } from 'react-dom/server' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../../artwork/ArtworkGallery', () => ({ + default: () =>
, +})) + +import TabArtworks from './TabArtworks' + +describe('TabArtworks SSR hydration formatting', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('hydrates formatted artwork statistics without recoverable mismatches', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false }))) + const props = { + artworks: [], + featuredArtworks: [{ + id: 1, + name: 'Featured artwork', + views: 9529106, + likes: 1216, + downloads: 12, + }], + username: 'alice', + galleryUrl: '/@alice/artworks', + } + const container = document.createElement('div') + container.innerHTML = renderToString() + document.body.appendChild(container) + const recoverableErrors = [] + + hydrateRoot(container, , { + onRecoverableError: (error) => recoverableErrors.push(error), + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(recoverableErrors).toHaveLength(0) + expect(container.textContent).toContain('9,529,106') + expect(container.textContent).toContain('1,216') + }) +}) diff --git a/resources/js/components/profile/tabs/TabArtworks.jsx b/resources/js/components/profile/tabs/TabArtworks.jsx index a47071f4..d69522d9 100644 --- a/resources/js/components/profile/tabs/TabArtworks.jsx +++ b/resources/js/components/profile/tabs/TabArtworks.jsx @@ -1,5 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react' import ArtworkGallery from '../../artwork/ArtworkGallery' +import { formatProfileNumber } from '../../../lib/profileFormatters' function slugify(value) { return String(value ?? '') @@ -9,7 +10,7 @@ function slugify(value) { } function formatNumber(value) { - return Number(value ?? 0).toLocaleString() + return formatProfileNumber(value) } function sortByPublishedAt(items) { diff --git a/resources/js/lib/profileFormatters.js b/resources/js/lib/profileFormatters.js new file mode 100644 index 00000000..9a6305b8 --- /dev/null +++ b/resources/js/lib/profileFormatters.js @@ -0,0 +1,19 @@ +const PROFILE_LOCALE = 'en-US' +const PROFILE_TIME_ZONE = 'UTC' + +export function formatProfileNumber(value) { + const numeric = Number(value ?? 0) + return Number.isFinite(numeric) ? new Intl.NumberFormat(PROFILE_LOCALE).format(numeric) : '0' +} + +export function formatProfileDate(value, options = {}) { + if (!value) return null + + const date = value instanceof Date ? value : new Date(value) + if (Number.isNaN(date.getTime())) return null + + return new Intl.DateTimeFormat(PROFILE_LOCALE, { + ...options, + timeZone: PROFILE_TIME_ZONE, + }).format(date) +} diff --git a/resources/js/lib/profileFormatters.test.js b/resources/js/lib/profileFormatters.test.js new file mode 100644 index 00000000..249a6d67 --- /dev/null +++ b/resources/js/lib/profileFormatters.test.js @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { formatProfileDate, formatProfileNumber } from './profileFormatters' + +describe('profile formatters', () => { + it('uses a stable en-US number format independent of the browser locale', () => { + expect(formatProfileNumber(9529106)).toBe('9,529,106') + }) + + it('uses UTC for stable calendar dates at timezone boundaries', () => { + expect(formatProfileDate('2024-01-01T00:30:00.000Z', { + month: 'long', + day: 'numeric', + year: 'numeric', + })).toBe('January 1, 2024') + }) +})