@carlonicora/nextjs-jsonapi 6.24.0 → 6.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/dist/{BlockNoteEditor-MONT74RQ.js → BlockNoteEditor-TNX77GCD.js} +28 -25
  2. package/dist/BlockNoteEditor-TNX77GCD.js.map +1 -0
  3. package/dist/{BlockNoteEditor-ENDOVTAW.mjs → BlockNoteEditor-Z5LDF47Z.mjs} +13 -10
  4. package/dist/{BlockNoteEditor-ENDOVTAW.mjs.map → BlockNoteEditor-Z5LDF47Z.mjs.map} +1 -1
  5. package/dist/{BlockNoteViewer-K2HBF3IP.js → BlockNoteViewer-6KQJODNF.js} +9 -7
  6. package/dist/BlockNoteViewer-6KQJODNF.js.map +1 -0
  7. package/dist/{BlockNoteViewer-2TJZ6JET.mjs → BlockNoteViewer-TQ452ZYW.mjs} +8 -6
  8. package/dist/{BlockNoteViewer-2TJZ6JET.mjs.map → BlockNoteViewer-TQ452ZYW.mjs.map} +1 -1
  9. package/dist/JsonApiRequest-OKNQZLS6.js +26 -0
  10. package/dist/{JsonApiRequest-KC6DOHKT.js.map → JsonApiRequest-OKNQZLS6.js.map} +1 -1
  11. package/dist/{JsonApiRequest-ADLBJ5RX.mjs → JsonApiRequest-WNPDS356.mjs} +2 -2
  12. package/dist/billing/index.js +504 -517
  13. package/dist/billing/index.js.map +1 -1
  14. package/dist/billing/index.mjs +20 -33
  15. package/dist/billing/index.mjs.map +1 -1
  16. package/dist/{chunk-VNUT2D27.mjs → chunk-2PWYG2WJ.mjs} +2 -2
  17. package/dist/{chunk-W4ML6D2X.mjs → chunk-3U7YJ73T.mjs} +7 -7
  18. package/dist/{chunk-BQ2WAG4K.js → chunk-3VNDFKT7.js} +19 -19
  19. package/dist/{chunk-BQ2WAG4K.js.map → chunk-3VNDFKT7.js.map} +1 -1
  20. package/dist/{chunk-BE6UMM5X.mjs → chunk-47HNJQ7U.mjs} +3 -3
  21. package/dist/chunk-5T2AQMQ2.mjs +90 -0
  22. package/dist/chunk-5T2AQMQ2.mjs.map +1 -0
  23. package/dist/{chunk-A5M3MORY.js → chunk-5WM72MLE.js} +3 -3
  24. package/dist/{chunk-A5M3MORY.js.map → chunk-5WM72MLE.js.map} +1 -1
  25. package/dist/{chunk-S7SPAC6X.mjs → chunk-73NKUBMP.mjs} +3 -3
  26. package/dist/chunk-743M4TIB.mjs +76 -0
  27. package/dist/chunk-743M4TIB.mjs.map +1 -0
  28. package/dist/{chunk-HN7L6RRW.js → chunk-CSBQAPHD.js} +903 -955
  29. package/dist/chunk-CSBQAPHD.js.map +1 -0
  30. package/dist/{chunk-YTDGYI62.mjs → chunk-DWPHHUMV.mjs} +3 -3
  31. package/dist/{chunk-53SD4YRF.js → chunk-FBHUUPRV.js} +5 -5
  32. package/dist/{chunk-53SD4YRF.js.map → chunk-FBHUUPRV.js.map} +1 -1
  33. package/dist/{chunk-T6G2F2BK.mjs → chunk-HJT7TXGG.mjs} +16 -68
  34. package/dist/chunk-HJT7TXGG.mjs.map +1 -0
  35. package/dist/chunk-HROKKUBQ.js +156 -0
  36. package/dist/chunk-HROKKUBQ.js.map +1 -0
  37. package/dist/{chunk-TMV7TL7O.mjs → chunk-JELEQV5T.mjs} +8 -6
  38. package/dist/{chunk-TMV7TL7O.mjs.map → chunk-JELEQV5T.mjs.map} +1 -1
  39. package/dist/{chunk-TNTCLVUR.js → chunk-JGL4P3DS.js} +17 -15
  40. package/dist/chunk-JGL4P3DS.js.map +1 -0
  41. package/dist/{chunk-7OIWOPM5.mjs → chunk-K7KLUYS7.mjs} +4 -3
  42. package/dist/chunk-K7KLUYS7.mjs.map +1 -0
  43. package/dist/{chunk-OZADFYE7.mjs → chunk-KVYYPKRO.mjs} +308 -241
  44. package/dist/chunk-KVYYPKRO.mjs.map +1 -0
  45. package/dist/{chunk-RCOKQ3R4.js → chunk-LNO2JLU3.js} +2 -2
  46. package/dist/{chunk-RCOKQ3R4.js.map → chunk-LNO2JLU3.js.map} +1 -1
  47. package/dist/{chunk-BDWV4KYR.js → chunk-M6SNTMK6.js} +1386 -1319
  48. package/dist/chunk-M6SNTMK6.js.map +1 -0
  49. package/dist/{chunk-GMMDXINC.js → chunk-N3NQS7X6.js} +12 -12
  50. package/dist/{chunk-GMMDXINC.js.map → chunk-N3NQS7X6.js.map} +1 -1
  51. package/dist/chunk-NP3AFULU.js +76 -0
  52. package/dist/chunk-NP3AFULU.js.map +1 -0
  53. package/dist/{chunk-SO3YUCL6.js → chunk-OOM54Z5Z.js} +6 -5
  54. package/dist/chunk-OOM54Z5Z.js.map +1 -0
  55. package/dist/{chunk-YSE2Z4RB.js → chunk-PQQFOONG.js} +12 -12
  56. package/dist/{chunk-YSE2Z4RB.js.map → chunk-PQQFOONG.js.map} +1 -1
  57. package/dist/chunk-RW6XZGZG.mjs +156 -0
  58. package/dist/chunk-RW6XZGZG.mjs.map +1 -0
  59. package/dist/chunk-SYMI4IDW.js +90 -0
  60. package/dist/chunk-SYMI4IDW.js.map +1 -0
  61. package/dist/{chunk-N2BFT7KI.mjs → chunk-W7U6O3OE.mjs} +2 -71
  62. package/dist/chunk-W7U6O3OE.mjs.map +1 -0
  63. package/dist/{chunk-YMCL3JXZ.mjs → chunk-YPBIPE2B.mjs} +2 -2
  64. package/dist/{chunk-P7L6OFX5.js → chunk-Z6T2575A.js} +12 -81
  65. package/dist/chunk-Z6T2575A.js.map +1 -0
  66. package/dist/client/index.js +13 -10
  67. package/dist/client/index.js.map +1 -1
  68. package/dist/client/index.mjs +17 -14
  69. package/dist/components/forms/DatePickerPopover.d.ts.map +1 -1
  70. package/dist/components/forms/DateRangeSelector.d.ts.map +1 -1
  71. package/dist/components/forms/FormDate.d.ts.map +1 -1
  72. package/dist/components/forms/FormDateTime.d.ts.map +1 -1
  73. package/dist/components/forms/time-cycle.d.ts +14 -0
  74. package/dist/components/forms/time-cycle.d.ts.map +1 -0
  75. package/dist/components/index.js +11 -8
  76. package/dist/components/index.js.map +1 -1
  77. package/dist/components/index.mjs +10 -7
  78. package/dist/components/tables/cells/cell.date.d.ts.map +1 -1
  79. package/dist/components/tables/cells/cell.dateTime.d.ts.map +1 -1
  80. package/dist/contexts/DateLocaleContext.d.ts +56 -0
  81. package/dist/contexts/DateLocaleContext.d.ts.map +1 -0
  82. package/dist/contexts/index.d.ts +1 -0
  83. package/dist/contexts/index.d.ts.map +1 -1
  84. package/dist/contexts/index.js +24 -8
  85. package/dist/contexts/index.js.map +1 -1
  86. package/dist/contexts/index.mjs +23 -7
  87. package/dist/core/index.js +17 -6
  88. package/dist/core/index.js.map +1 -1
  89. package/dist/core/index.mjs +19 -8
  90. package/dist/date-locale/index.d.ts +2 -0
  91. package/dist/date-locale/index.d.ts.map +1 -0
  92. package/dist/date-locale/index.js +21 -0
  93. package/dist/date-locale/index.js.map +1 -0
  94. package/dist/date-locale/index.mjs +21 -0
  95. package/dist/email-automations/index.js +131 -123
  96. package/dist/email-automations/index.js.map +1 -1
  97. package/dist/email-automations/index.mjs +23 -15
  98. package/dist/email-automations/index.mjs.map +1 -1
  99. package/dist/features/account-deletion/components/AccountDeletionAdminTrigger.d.ts.map +1 -1
  100. package/dist/features/analytics/components/AnalyticsJourneyContainer.d.ts.map +1 -1
  101. package/dist/features/analytics/components/AnalyticsSessionsTable.d.ts.map +1 -1
  102. package/dist/features/analytics/index.js +154 -147
  103. package/dist/features/analytics/index.js.map +1 -1
  104. package/dist/features/analytics/index.mjs +32 -25
  105. package/dist/features/analytics/index.mjs.map +1 -1
  106. package/dist/features/analytics/server.js +11 -9
  107. package/dist/features/analytics/server.js.map +1 -1
  108. package/dist/features/analytics/server.mjs +9 -7
  109. package/dist/features/analytics/server.mjs.map +1 -1
  110. package/dist/features/billing/components/utils/date.d.ts +3 -3
  111. package/dist/features/billing/components/utils/format-options.d.ts +6 -4
  112. package/dist/features/billing/components/utils/format-options.d.ts.map +1 -1
  113. package/dist/features/billing/components/utils/useBillingFormatOptions.d.ts +5 -1
  114. package/dist/features/billing/components/utils/useBillingFormatOptions.d.ts.map +1 -1
  115. package/dist/features/billing/stripe-usage/components/details/UsageSummaryCard.d.ts.map +1 -1
  116. package/dist/features/billing/stripe-usage/components/lists/UsageHistoryTable.d.ts.map +1 -1
  117. package/dist/features/blog/components/admin/BlogTranslationStatus.d.ts.map +1 -1
  118. package/dist/features/blog/components/public/BlogPostMeta.d.ts.map +1 -1
  119. package/dist/features/blog/hooks/useBlogPostTableStructure.d.ts.map +1 -1
  120. package/dist/features/blog/index.js +168 -162
  121. package/dist/features/blog/index.js.map +1 -1
  122. package/dist/features/blog/index.mjs +25 -19
  123. package/dist/features/blog/index.mjs.map +1 -1
  124. package/dist/features/blog/server.js +29 -27
  125. package/dist/features/blog/server.js.map +1 -1
  126. package/dist/features/blog/server.mjs +8 -6
  127. package/dist/features/blog/server.mjs.map +1 -1
  128. package/dist/features/email-automation/hooks/useEmailAutomationEnrollmentTableStructure.d.ts.map +1 -1
  129. package/dist/features/handbook/components/parts/HandbookIndexStatus.d.ts.map +1 -1
  130. package/dist/features/help/components/HelpArticleBody.d.ts.map +1 -1
  131. package/dist/features/help/index.js +48 -43
  132. package/dist/features/help/index.js.map +1 -1
  133. package/dist/features/help/index.mjs +15 -10
  134. package/dist/features/help/index.mjs.map +1 -1
  135. package/dist/features/notification/components/notifications/Notification.d.ts +2 -1
  136. package/dist/features/notification/components/notifications/Notification.d.ts.map +1 -1
  137. package/dist/features/notification/contexts/NotificationContext.d.ts.map +1 -1
  138. package/dist/features/notification/hooks/useNotificationTableStructure.d.ts.map +1 -1
  139. package/dist/features/oauth/components/OAuthClientCard.d.ts.map +1 -1
  140. package/dist/features/oauth/components/OAuthClientDetail.d.ts.map +1 -1
  141. package/dist/features/tokenusage/index.js +133 -130
  142. package/dist/features/tokenusage/index.js.map +1 -1
  143. package/dist/features/tokenusage/index.mjs +10 -7
  144. package/dist/features/tokenusage/index.mjs.map +1 -1
  145. package/dist/features/tokenusage/lib/formatters.d.ts +6 -3
  146. package/dist/features/tokenusage/lib/formatters.d.ts.map +1 -1
  147. package/dist/index.js +19 -7
  148. package/dist/index.js.map +1 -1
  149. package/dist/index.mjs +23 -11
  150. package/dist/local-transport-AT4TE264.js +15 -0
  151. package/dist/{local-transport-MJPOKPTU.js.map → local-transport-AT4TE264.js.map} +1 -1
  152. package/dist/{local-transport-RVD4FZGA.mjs → local-transport-KWEUZ4GX.mjs} +5 -5
  153. package/dist/offline/index.js +56 -54
  154. package/dist/offline/index.js.map +1 -1
  155. package/dist/offline/index.mjs +23 -21
  156. package/dist/offline/index.mjs.map +1 -1
  157. package/dist/{request-4AWGPIGH.mjs → request-6GAIWWTG.mjs} +2 -2
  158. package/dist/{request-H5FZBZA2.js → request-BWO4Y434.js} +3 -3
  159. package/dist/{request-H5FZBZA2.js.map → request-BWO4Y434.js.map} +1 -1
  160. package/dist/server/ServerSession.d.ts +2 -0
  161. package/dist/server/ServerSession.d.ts.map +1 -1
  162. package/dist/server/index.js +10 -8
  163. package/dist/server/index.js.map +1 -1
  164. package/dist/server/index.mjs +9 -7
  165. package/dist/utils/date-formatter.d.ts +45 -4
  166. package/dist/utils/date-formatter.d.ts.map +1 -1
  167. package/dist/utils/index.d.ts +1 -1
  168. package/dist/utils/index.d.ts.map +1 -1
  169. package/dist/write-through-KNRI5L4V.mjs +15 -0
  170. package/dist/write-through-KNRI5L4V.mjs.map +1 -0
  171. package/dist/write-through-MYHQ6GJU.js +15 -0
  172. package/dist/{write-through-HE2QSOVI.js.map → write-through-MYHQ6GJU.js.map} +1 -1
  173. package/messages/en.json +1 -0
  174. package/messages/it.json +1 -0
  175. package/package.json +6 -1
  176. package/src/components/forms/DatePickerPopover.tsx +5 -3
  177. package/src/components/forms/DateRangeSelector.tsx +9 -7
  178. package/src/components/forms/FormDate.tsx +5 -3
  179. package/src/components/forms/FormDateTime.tsx +75 -39
  180. package/src/components/forms/__tests__/time-cycle.spec.ts +37 -0
  181. package/src/components/forms/time-cycle.ts +27 -0
  182. package/src/components/tables/cells/cell.date.tsx +3 -5
  183. package/src/components/tables/cells/cell.dateTime.tsx +3 -7
  184. package/src/contexts/DateLocaleContext.tsx +106 -0
  185. package/src/contexts/__tests__/DateLocaleContext.spec.tsx +175 -0
  186. package/src/contexts/index.ts +1 -0
  187. package/src/date-locale/index.ts +12 -0
  188. package/src/features/account-deletion/components/AccountDeletionAdminTrigger.tsx +3 -4
  189. package/src/features/analytics/components/AnalyticsJourneyContainer.tsx +8 -8
  190. package/src/features/analytics/components/AnalyticsSessionsTable.tsx +4 -6
  191. package/src/features/auth/components/two-factor/PasskeyList.tsx +3 -3
  192. package/src/features/auth/components/two-factor/TotpAuthenticatorList.tsx +3 -3
  193. package/src/features/billing/components/utils/__tests__/billing-formatters.spec.ts +14 -6
  194. package/src/features/billing/components/utils/date.ts +5 -5
  195. package/src/features/billing/components/utils/format-options.ts +6 -4
  196. package/src/features/billing/components/utils/useBillingFormatOptions.ts +8 -2
  197. package/src/features/billing/stripe-usage/components/details/UsageSummaryCard.tsx +4 -12
  198. package/src/features/billing/stripe-usage/components/lists/UsageHistoryTable.tsx +4 -17
  199. package/src/features/blog/components/admin/BlogTranslationStatus.tsx +3 -2
  200. package/src/features/blog/components/public/BlogPostMeta.tsx +5 -4
  201. package/src/features/blog/components/public/__tests__/BlogList.spec.tsx +1 -2
  202. package/src/features/blog/components/public/__tests__/BlogPostView.spec.tsx +2 -3
  203. package/src/features/blog/hooks/useBlogPostTableStructure.tsx +4 -8
  204. package/src/features/email-automation/hooks/useEmailAutomationEnrollmentTableStructure.tsx +11 -5
  205. package/src/features/email-blast/components/containers/EmailBlastContainer.tsx +3 -3
  206. package/src/features/email-blast/hooks/useEmailBlastTableStructure.tsx +4 -4
  207. package/src/features/handbook/components/parts/HandbookIndexStatus.tsx +4 -3
  208. package/src/features/help/components/HelpArticleBody.tsx +4 -3
  209. package/src/features/notification/components/notifications/Notification.tsx +38 -7
  210. package/src/features/notification/contexts/NotificationContext.tsx +6 -3
  211. package/src/features/notification/hooks/useNotificationTableStructure.tsx +6 -5
  212. package/src/features/oauth/components/OAuthClientCard.tsx +8 -5
  213. package/src/features/oauth/components/OAuthClientDetail.tsx +3 -6
  214. package/src/features/tokenusage/lib/__tests__/formatters.spec.ts +19 -0
  215. package/src/features/tokenusage/lib/formatters.ts +11 -6
  216. package/src/features/waitlist/hooks/useWaitlistTableStructure.tsx +5 -5
  217. package/src/server/ServerSession.ts +3 -0
  218. package/src/server/__tests__/ServerSession.read.spec.ts +18 -1
  219. package/src/utils/__tests__/date-formatter.spec.ts +134 -10
  220. package/src/utils/date-formatter.ts +200 -44
  221. package/src/utils/index.ts +11 -1
  222. package/dist/BlockNoteEditor-MONT74RQ.js.map +0 -1
  223. package/dist/BlockNoteViewer-K2HBF3IP.js.map +0 -1
  224. package/dist/JsonApiRequest-KC6DOHKT.js +0 -26
  225. package/dist/chunk-7OIWOPM5.mjs.map +0 -1
  226. package/dist/chunk-BDWV4KYR.js.map +0 -1
  227. package/dist/chunk-HN7L6RRW.js.map +0 -1
  228. package/dist/chunk-N2BFT7KI.mjs.map +0 -1
  229. package/dist/chunk-OZADFYE7.mjs.map +0 -1
  230. package/dist/chunk-P7L6OFX5.js.map +0 -1
  231. package/dist/chunk-SO3YUCL6.js.map +0 -1
  232. package/dist/chunk-T6G2F2BK.mjs.map +0 -1
  233. package/dist/chunk-TNTCLVUR.js.map +0 -1
  234. package/dist/local-transport-MJPOKPTU.js +0 -15
  235. package/dist/write-through-7I3LA2WN.mjs +0 -15
  236. package/dist/write-through-HE2QSOVI.js +0 -15
  237. /package/dist/{JsonApiRequest-ADLBJ5RX.mjs.map → JsonApiRequest-WNPDS356.mjs.map} +0 -0
  238. /package/dist/{chunk-VNUT2D27.mjs.map → chunk-2PWYG2WJ.mjs.map} +0 -0
  239. /package/dist/{chunk-W4ML6D2X.mjs.map → chunk-3U7YJ73T.mjs.map} +0 -0
  240. /package/dist/{chunk-BE6UMM5X.mjs.map → chunk-47HNJQ7U.mjs.map} +0 -0
  241. /package/dist/{chunk-S7SPAC6X.mjs.map → chunk-73NKUBMP.mjs.map} +0 -0
  242. /package/dist/{chunk-YTDGYI62.mjs.map → chunk-DWPHHUMV.mjs.map} +0 -0
  243. /package/dist/{chunk-YMCL3JXZ.mjs.map → chunk-YPBIPE2B.mjs.map} +0 -0
  244. /package/dist/{local-transport-RVD4FZGA.mjs.map → date-locale/index.mjs.map} +0 -0
  245. /package/dist/{request-4AWGPIGH.mjs.map → local-transport-KWEUZ4GX.mjs.map} +0 -0
  246. /package/dist/{write-through-7I3LA2WN.mjs.map → request-6GAIWWTG.mjs.map} +0 -0
@@ -4,6 +4,7 @@ import { LoaderCircleIcon, RotateCcwIcon } from "lucide-react";
4
4
  import { useTranslations } from "next-intl";
5
5
  import { useCallback, useMemo, useState } from "react";
6
6
  import { errorToast } from "../../../../components";
7
+ import { useDateFormatter } from "../../../../contexts/DateLocaleContext";
7
8
  import { useI18nLocale } from "../../../../i18n";
8
9
  import { Badge, Button } from "../../../../shadcnui";
9
10
  import { BlogCategoryInterface } from "../../data";
@@ -47,8 +48,8 @@ export function useBlogLanguageName(): (language: string) => string {
47
48
 
48
49
  /** Formats a date for the admin pages in the viewer's locale. */
49
50
  export function useBlogDateFormatter(): (date?: Date) => string {
50
- const locale = useI18nLocale();
51
- return useCallback((date?: Date) => (date ? date.toLocaleDateString(locale, { dateStyle: "medium" }) : ""), [locale]);
51
+ const dates = useDateFormatter();
52
+ return useCallback((date?: Date) => dates.format(date, "dateMedium"), [dates]);
52
53
  }
53
54
 
54
55
  type BlogTranslationStatusProps = {
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { useFormatter, useTranslations } from "next-intl";
3
+ import { useTranslations } from "next-intl";
4
+ import { useDateFormatter } from "../../../../contexts/DateLocaleContext";
4
5
  import { cn } from "../../../../utils/cn";
5
6
  import type { PublicBlogPost } from "../../types/blog-public.types";
6
7
 
@@ -19,12 +20,12 @@ export function BlogPostMeta(props: {
19
20
  }) {
20
21
  const { post, showAuthor, showDate, showUpdated, className } = props;
21
22
  const t = useTranslations();
22
- const format = useFormatter();
23
+ const dates = useDateFormatter();
23
24
 
24
25
  const parts: string[] = [];
25
26
  if (showAuthor && post.authorName) parts.push(t("blog.public.by", { author: post.authorName }));
26
27
  if (showDate && post.publishedAt) {
27
- parts.push(format.dateTime(new Date(post.publishedAt), { dateStyle: "medium" }));
28
+ parts.push(dates.format(post.publishedAt, "dateMedium"));
28
29
  }
29
30
  if (
30
31
  showUpdated &&
@@ -32,7 +33,7 @@ export function BlogPostMeta(props: {
32
33
  post.updatedAt &&
33
34
  new Date(post.updatedAt).getTime() - new Date(post.publishedAt).getTime() > ONE_DAY_MS
34
35
  ) {
35
- parts.push(t("blog.public.updated", { date: format.dateTime(new Date(post.updatedAt), { dateStyle: "medium" }) }));
36
+ parts.push(t("blog.public.updated", { date: dates.format(post.updatedAt, "dateMedium") }));
36
37
  }
37
38
  parts.push(t("blog.public.readingTime", { minutes: post.readingMinutes }));
38
39
 
@@ -9,7 +9,6 @@ import { BlogList } from "../BlogList";
9
9
  vi.mock("next-intl", () => ({
10
10
  useTranslations: () => (key: string, values?: Record<string, unknown>) =>
11
11
  values ? `${key} ${Object.values(values).join(" ")}` : key,
12
- useFormatter: () => ({ dateTime: (date: Date) => date.toISOString().slice(0, 10) }),
13
12
  }));
14
13
  vi.mock("next/image", () => ({
15
14
  default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
@@ -101,7 +100,7 @@ describe("BlogList", () => {
101
100
  expect(screen.getByRole("heading", { level: 1, name: "blog.public.title" })).toBeInTheDocument();
102
101
  expect(screen.getByText("blog.public.eyebrow")).toBeInTheDocument();
103
102
  // The public blog shows no dates in any layout.
104
- expect(screen.queryByText(/2026-10-12/)).not.toBeInTheDocument();
103
+ expect(screen.queryByText(/Oct 12, 2026|12 Oct 2026/)).not.toBeInTheDocument();
105
104
  });
106
105
 
107
106
  it("active category chip is marked and links back to all", () => {
@@ -9,7 +9,6 @@ import { BlogPostView } from "../BlogPostView";
9
9
  vi.mock("next-intl", () => ({
10
10
  useTranslations: () => (key: string, values?: Record<string, unknown>) =>
11
11
  values ? `${key} ${Object.values(values).join(" ")}` : key,
12
- useFormatter: () => ({ dateTime: (date: Date) => date.toISOString().slice(0, 10) }),
13
12
  }));
14
13
  vi.mock("next/image", () => ({
15
14
  default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
@@ -102,8 +101,8 @@ describe("BlogPostView", () => {
102
101
 
103
102
  it("shows neither the publish date nor the updated date", () => {
104
103
  render(<BlogPostView post={main} categories={cats} related={[]} basePath="/blog" />);
105
- expect(screen.queryByText(/2026-10-12/)).not.toBeInTheDocument();
106
- expect(screen.queryByText(/2026-10-15/)).not.toBeInTheDocument();
104
+ expect(screen.queryByText(/Oct 12, 2026|12 Oct 2026/)).not.toBeInTheDocument();
105
+ expect(screen.queryByText(/Oct 15, 2026|15 Oct 2026/)).not.toBeInTheDocument();
107
106
  expect(screen.queryByText(/blog\.public\.updated/)).not.toBeInTheDocument();
108
107
  });
109
108
 
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
5
5
  import { useMemo } from "react";
6
6
  import { Modules } from "../../../core";
7
7
  import { registerTableGenerator, TableContent, usePageUrlGenerator, UseTableStructureHook } from "../../../hooks";
8
- import { useI18nLocale } from "../../../i18n";
8
+ import { useDateFormatter } from "../../../contexts/DateLocaleContext";
9
9
  import { Badge, Link } from "../../../shadcnui";
10
10
  import { BLOG_STATUS_BADGE } from "../components/admin/BlogTranslationStatus";
11
11
  import { useBlogCategoryNames } from "./useBlogCategoryNames";
@@ -19,7 +19,7 @@ import { BlogPostFields, BlogPostInterface } from "../data";
19
19
  */
20
20
  export const useBlogPostTableStructure: UseTableStructureHook<BlogPostInterface, BlogPostFields> = (params) => {
21
21
  const t = useTranslations();
22
- const locale = useI18nLocale();
22
+ const dates = useDateFormatter();
23
23
  const generateUrl = usePageUrlGenerator();
24
24
  const categoryName = useBlogCategoryNames();
25
25
 
@@ -85,11 +85,7 @@ export const useBlogPostTableStructure: UseTableStructureHook<BlogPostInterface,
85
85
  cell: ({ row }: { row: TableContent<BlogPostInterface> }) => {
86
86
  const post: BlogPostInterface = row.original.jsonApiData;
87
87
  if (!post.publishedAt) return <span className="text-muted-foreground text-xs">—</span>;
88
- return (
89
- <span className="text-xs tabular-nums">
90
- {post.publishedAt.toLocaleDateString(locale, { dateStyle: "medium" })}
91
- </span>
92
- );
88
+ return <span className="text-xs tabular-nums">{dates.format(post.publishedAt, "dateMedium")}</span>;
93
89
  },
94
90
  enableSorting: false,
95
91
  enableHiding: false,
@@ -118,7 +114,7 @@ export const useBlogPostTableStructure: UseTableStructureHook<BlogPostInterface,
118
114
  return params.fields.map((field) => fieldColumnMap[field]?.()).filter((col) => col !== undefined) as ColumnDef<
119
115
  TableContent<BlogPostInterface>
120
116
  >[];
121
- }, [params.fields, fieldColumnMap, t, generateUrl, locale, categoryName]);
117
+ }, [params.fields, fieldColumnMap, t, generateUrl, dates, categoryName]);
122
118
 
123
119
  return useMemo(() => ({ data: tableData, columns: columns }), [tableData, columns]);
124
120
  };
@@ -1,11 +1,12 @@
1
1
  "use client";
2
2
 
3
3
  import { ColumnDef } from "@tanstack/react-table";
4
- import { useLocale, useTranslations } from "next-intl";
4
+ import { useTranslations } from "next-intl";
5
5
  import { useMemo } from "react";
6
6
  import { Badge } from "../../../shadcnui";
7
7
  import { registerTableGenerator, TableContent, UseTableStructureHook } from "../../../hooks";
8
- import { dateFormatLocale, formatDate } from "../../../utils/date-formatter";
8
+ import { useDateFormatter, useMountedNow } from "../../../contexts/DateLocaleContext";
9
+ import { formatDate } from "../../../utils/date-formatter";
9
10
  import { EmailAutomationEnrollmentFields } from "../data/email-automation-enrollment.fields";
10
11
  import { EmailAutomationEnrollmentInterface } from "../data/EmailAutomationEnrollmentInterface";
11
12
 
@@ -22,7 +23,8 @@ export const useEmailAutomationEnrollmentTableStructure: UseTableStructureHook<
22
23
  EmailAutomationEnrollmentFields
23
24
  > = (params) => {
24
25
  const t = useTranslations();
25
- const locale = dateFormatLocale(useLocale());
26
+ const { locale } = useDateFormatter();
27
+ const now = useMountedNow();
26
28
 
27
29
  const tableData = useMemo(() => {
28
30
  return params.data.map((enrollment: EmailAutomationEnrollmentInterface) => {
@@ -95,7 +97,11 @@ export const useEmailAutomationEnrollmentTableStructure: UseTableStructureHook<
95
97
  cell: ({ row }: { row: TableContent<EmailAutomationEnrollmentInterface> }) => {
96
98
  const enrollment: EmailAutomationEnrollmentInterface = row.original.jsonApiData;
97
99
  if (!enrollment.nextSendAt) return empty;
98
- return <span className="text-xs tabular-nums">{formatDate(enrollment.nextSendAt, "dateTime", locale)}</span>;
100
+ return (
101
+ <span className="text-xs tabular-nums">
102
+ {now ? formatDate(enrollment.nextSendAt, "dateTime", locale, now) : ""}
103
+ </span>
104
+ );
99
105
  },
100
106
  enableSorting: false,
101
107
  enableHiding: false,
@@ -118,7 +124,7 @@ export const useEmailAutomationEnrollmentTableStructure: UseTableStructureHook<
118
124
  return params.fields.map((field) => fieldColumnMap[field]?.()).filter((col) => col !== undefined) as ColumnDef<
119
125
  TableContent<EmailAutomationEnrollmentInterface>
120
126
  >[];
121
- }, [params.fields, fieldColumnMap, t, locale]);
127
+ }, [params.fields, fieldColumnMap, t, locale, now]);
122
128
 
123
129
  return useMemo(() => ({ data: tableData, columns: columns }), [tableData, columns]);
124
130
  };
@@ -6,10 +6,10 @@ import { ReactNode, useCallback, useEffect, useState } from "react";
6
6
  import { CommonDeleter, RoundPageContainer } from "../../../../components";
7
7
  import { errorToast } from "../../../../components/errors/errorToast";
8
8
  import { DetailField } from "../../../../components/typography";
9
+ import { useDateFormatter } from "../../../../contexts/DateLocaleContext";
9
10
  import { SharedProvider } from "../../../../contexts/SharedContext";
10
11
  import { Modules } from "../../../../core";
11
12
  import { usePageUrlGenerator } from "../../../../hooks";
12
- import { useI18nLocale } from "../../../../i18n";
13
13
  import { BreadcrumbItemData } from "../../../../interfaces";
14
14
  import { Badge, Button } from "../../../../shadcnui";
15
15
  import { showToast } from "../../../../utils/toast";
@@ -54,7 +54,7 @@ function EmailBlastSendTestButton({ blast }: { blast: EmailBlastInterface }) {
54
54
 
55
55
  export function EmailBlastContainer({ blastId }: EmailBlastContainerProps) {
56
56
  const t = useTranslations();
57
- const locale = useI18nLocale();
57
+ const dates = useDateFormatter();
58
58
  const generateUrl = usePageUrlGenerator();
59
59
  const [blast, setBlast] = useState<EmailBlastInterface | undefined>(undefined);
60
60
 
@@ -130,7 +130,7 @@ export function EmailBlastContainer({ blastId }: EmailBlastContainerProps) {
130
130
  </div>
131
131
  <DetailField
132
132
  label={t("emailBlasts.detail.sentAt")}
133
- value={blast.sentAt ? blast.sentAt.toLocaleString(locale) : "—"}
133
+ value={blast.sentAt ? dates.format(blast.sentAt, "dateTimeMedium") : "—"}
134
134
  />
135
135
  <DetailField label={t("emailBlasts.list.columns.subject")} value={blast.subject} />
136
136
  <DetailField label={t("emailBlasts.detail.replyTo")} value={blast.replyTo ?? "—"} />
@@ -6,7 +6,7 @@ import { useMemo } from "react";
6
6
  import { Badge, Link } from "../../../shadcnui";
7
7
  import { Modules } from "../../../core";
8
8
  import { registerTableGenerator, TableContent, usePageUrlGenerator, UseTableStructureHook } from "../../../hooks";
9
- import { useI18nLocale } from "../../../i18n";
9
+ import { useDateFormatter } from "../../../contexts/DateLocaleContext";
10
10
  import { EmailBlastFields } from "../data/email-blast.fields";
11
11
  import { EmailBlastInterface, EmailBlastStatus } from "../data/EmailBlastInterface";
12
12
 
@@ -18,7 +18,7 @@ const STATUS_VARIANTS: Record<EmailBlastStatus, "softGray" | "softAmber" | "soft
18
18
 
19
19
  export const useEmailBlastTableStructure: UseTableStructureHook<EmailBlastInterface, EmailBlastFields> = (params) => {
20
20
  const t = useTranslations();
21
- const locale = useI18nLocale();
21
+ const dates = useDateFormatter();
22
22
  const generateUrl = usePageUrlGenerator();
23
23
 
24
24
  const tableData = useMemo(() => {
@@ -90,7 +90,7 @@ export const useEmailBlastTableStructure: UseTableStructureHook<EmailBlastInterf
90
90
  cell: ({ row }: { row: TableContent<EmailBlastInterface> }) => {
91
91
  const blast: EmailBlastInterface = row.original.jsonApiData;
92
92
  if (!blast.sentAt) return <span className="text-muted-foreground text-xs">—</span>;
93
- return <span className="text-sm">{blast.sentAt.toLocaleDateString(locale)}</span>;
93
+ return <span className="text-sm">{dates.format(blast.sentAt, "date")}</span>;
94
94
  },
95
95
  enableSorting: false,
96
96
  enableHiding: false,
@@ -111,7 +111,7 @@ export const useEmailBlastTableStructure: UseTableStructureHook<EmailBlastInterf
111
111
  return params.fields.map((field) => fieldColumnMap[field]?.()).filter((col) => col !== undefined) as ColumnDef<
112
112
  TableContent<EmailBlastInterface>
113
113
  >[];
114
- }, [params.fields, fieldColumnMap, t, generateUrl, locale]);
114
+ }, [params.fields, fieldColumnMap, t, generateUrl, dates]);
115
115
 
116
116
  return useMemo(() => ({ data: tableData, columns: columns }), [tableData, columns]);
117
117
  };
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
 
3
- import { useFormatter, useTranslations } from "next-intl";
3
+ import { useTranslations } from "next-intl";
4
4
  import { useMemo } from "react";
5
+ import { useDateFormatter } from "../../../../contexts/DateLocaleContext";
5
6
 
6
7
  import { HandbookPageInterface } from "../../data/HandbookPageInterface";
7
8
 
@@ -27,7 +28,7 @@ const FAILURE_STATUSES = new Set(["failed", "error"]);
27
28
  */
28
29
  export function HandbookIndexStatus({ pages }: HandbookIndexStatusProps) {
29
30
  const t = useTranslations();
30
- const format = useFormatter();
31
+ const dates = useDateFormatter();
31
32
 
32
33
  const lastSynced = useMemo(() => {
33
34
  let latest: Date | undefined = undefined;
@@ -50,7 +51,7 @@ export function HandbookIndexStatus({ pages }: HandbookIndexStatusProps) {
50
51
  <span className="text-muted-foreground text-xs tabular-nums">
51
52
  {t("handbook.contents.status", {
52
53
  count: pages.length,
53
- date: lastSynced ? format.dateTime(lastSynced, { dateStyle: "medium" }) : "",
54
+ date: lastSynced ? dates.format(lastSynced, "dateMedium") : "",
54
55
  })}
55
56
  {failures > 0 ? (
56
57
  <span className="text-destructive ms-2 tabular-nums">
@@ -2,8 +2,9 @@
2
2
 
3
3
  import { ReactNode } from "react";
4
4
  import Link from "next/link";
5
- import { useFormatter, useTranslations } from "next-intl";
5
+ import { useTranslations } from "next-intl";
6
6
  import { usePageUrlGenerator } from "../../../client";
7
+ import { useDateFormatter } from "../../../contexts/DateLocaleContext";
7
8
 
8
9
  type Sibling = { howToType: string; slug: string; title: string };
9
10
 
@@ -17,7 +18,7 @@ export function HelpArticleBody(props: {
17
18
  children: ReactNode;
18
19
  }) {
19
20
  const t = useTranslations();
20
- const format = useFormatter();
21
+ const dates = useDateFormatter();
21
22
  const generateUrl = usePageUrlGenerator();
22
23
  const { howToType, title, summary, updatedAt, prev, next, children } = props;
23
24
 
@@ -36,7 +37,7 @@ export function HelpArticleBody(props: {
36
37
  <div className="text-muted-foreground text-xs">
37
38
  <span>
38
39
  {t("help.article.lastUpdated", {
39
- date: format.dateTime(new Date(updatedAt), { dateStyle: "short" }),
40
+ date: dates.format(updatedAt, "date"),
40
41
  })}
41
42
  </span>
42
43
  </div>
@@ -1,11 +1,16 @@
1
1
  "use client";
2
2
 
3
3
  import { useTranslations } from "next-intl";
4
- import { ReactElement, useEffect, useState } from "react";
4
+ import { ReactElement, Suspense, useEffect, useState } from "react";
5
5
  import { usePageUrlGenerator } from "../../../../hooks";
6
6
  import { Link } from "../../../../shadcnui";
7
- import { useI18nLocale } from "../../../../i18n";
8
- import { dateFormatLocale, formatDate } from "../../../../utils";
7
+ import {
8
+ DateLocaleSource,
9
+ DateLocaleSourceProvider,
10
+ useDateFormatter,
11
+ useMountedNow,
12
+ } from "../../../../contexts/DateLocaleContext";
13
+ import { formatDate } from "../../../../utils";
9
14
  import { UserInterface } from "../../../user";
10
15
  import { UserAvatar } from "../../../user/components";
11
16
  import { NotificationInterface } from "../../data";
@@ -32,12 +37,22 @@ export const generateNotificationData = (params: {
32
37
  return response;
33
38
  };
34
39
 
40
+ /**
41
+ * The toast's time. Sonner draws the toast outside the session providers, so the toast
42
+ * re-provides the captured date-locale source around this leaf, which suspends behind its
43
+ * own boundary.
44
+ */
45
+ function NotificationToastTime({ date }: { date: Date }) {
46
+ const { format } = useDateFormatter();
47
+ return <>{format(new Date(date), "dateTime")}</>;
48
+ }
49
+
35
50
  export function NotificationToast(
36
51
  notification: NotificationInterface,
37
52
  t: any,
38
53
  generateUrl: any,
39
54
  reouter: any,
40
- locale?: string,
55
+ dateLocaleSource: DateLocaleSource | null = null,
41
56
  ): {
42
57
  title: string;
43
58
  description: string | ReactElement<any>;
@@ -69,7 +84,11 @@ export function NotificationToast(
69
84
  })}
70
85
  </p>
71
86
  <div className="text-muted-foreground mt-1 w-full text-xs">
72
- {formatDate(new Date(notification.createdAt), "dateTime", dateFormatLocale(locale))}
87
+ <DateLocaleSourceProvider value={dateLocaleSource}>
88
+ <Suspense fallback={null}>
89
+ <NotificationToastTime date={notification.createdAt} />
90
+ </Suspense>
91
+ </DateLocaleSourceProvider>
73
92
  </div>
74
93
  </div>
75
94
  </div>
@@ -85,11 +104,21 @@ export function NotificationToast(
85
104
  };
86
105
  }
87
106
 
107
+ /**
108
+ * The popover row's time ("3 hours ago", else the date and time). A leaf behind its own
109
+ * boundary: the popover lives in the shell, and this read suspends on the session. The
110
+ * relative text renders only after mount, so the clock is never read during render.
111
+ */
112
+ function NotificationMenuItemTime({ date }: { date: Date }) {
113
+ const { locale } = useDateFormatter();
114
+ const now = useMountedNow();
115
+ return <>{now ? formatDate(new Date(date), "default", locale, now) : ""}</>;
116
+ }
117
+
88
118
  export function NotificationMenuItem({ notification, closePopover }: TaskCommentedOnProps) {
89
119
  const generateUrl = usePageUrlGenerator();
90
120
  const [isRead, setIsRead] = useState<boolean>(false);
91
121
  const t = useTranslations();
92
- const dateLocale = dateFormatLocale(useI18nLocale());
93
122
 
94
123
  useEffect(() => {
95
124
  setIsRead(notification.isRead);
@@ -115,7 +144,9 @@ export function NotificationMenuItem({ notification, closePopover }: TaskComment
115
144
  })}
116
145
  </p>
117
146
  <div className="text-muted-foreground mt-0.5 text-xs">
118
- {formatDate(new Date(notification.createdAt), "default", dateLocale)}
147
+ <Suspense fallback={null}>
148
+ <NotificationMenuItemTime date={notification.createdAt} />
149
+ </Suspense>
119
150
  </div>
120
151
  </div>
121
152
  {!isRead && <span className="bg-primary mt-1.5 size-2 shrink-0 rounded-full" aria-hidden />}
@@ -3,8 +3,9 @@
3
3
  import { useTranslations } from "next-intl";
4
4
  import React, { createContext, ReactElement, useCallback, useContext, useEffect, useRef, useState } from "react";
5
5
  import { SharedProvider } from "../../../contexts";
6
+ import { useDateLocaleSource } from "../../../contexts/DateLocaleContext";
6
7
  import { Modules } from "../../../core";
7
- import { useI18nLocale, useI18nRouter } from "../../../i18n";
8
+ import { useI18nRouter } from "../../../i18n";
8
9
  import { BreadcrumbItemData } from "../../../interfaces";
9
10
  import { NotificationMenuItem, NotificationToast } from "../components/notifications/Notification";
10
11
  import { getRoleId, isRolesConfigured } from "../../../roles";
@@ -47,7 +48,9 @@ type NotificationContextProviderProps = {
47
48
 
48
49
  export const NotificationContextProvider = ({ children }: NotificationContextProviderProps) => {
49
50
  const t = useTranslations();
50
- const locale = useI18nLocale();
51
+ // Non-suspending: the raw date-locale source, re-provided around the toast time (sonner
52
+ // renders the toast outside the session providers).
53
+ const dateLocaleSource = useDateLocaleSource();
51
54
  // A ref, not a dependency: the translator only words the fallback errors and must
52
55
  // not re-create loadNotifications, whose identity drives the initial-load effect.
53
56
  const tRef = useRef(t);
@@ -181,7 +184,7 @@ export const NotificationContextProvider = ({ children }: NotificationContextPro
181
184
  onClick: () => void;
182
185
  };
183
186
  } => {
184
- return NotificationToast(notification, t, generateUrl, router, locale);
187
+ return NotificationToast(notification, t, generateUrl, router, dateLocaleSource);
185
188
  };
186
189
 
187
190
  const generateNotification = (notification: NotificationInterface, closePopover: () => void) => {
@@ -6,8 +6,8 @@ import { useTranslations } from "next-intl";
6
6
  import { useMemo } from "react";
7
7
  import { registerTableGenerator, TableContent, usePageUrlGenerator, UseTableStructureHook } from "../../../hooks";
8
8
  import { Button, Tooltip, TooltipContent, TooltipTrigger } from "../../../shadcnui";
9
- import { useI18nLocale } from "../../../i18n";
10
- import { dateFormatLocale, formatDate } from "../../../utils";
9
+ import { useDateFormatter, useMountedNow } from "../../../contexts/DateLocaleContext";
10
+ import { formatDate } from "../../../utils";
11
11
  import { generateNotificationData } from "../components/notifications/Notification";
12
12
  import { useNotificationContext } from "../contexts/NotificationContext";
13
13
  import { NotificationIcon } from "../components/notifications/NotificationIcon";
@@ -33,7 +33,8 @@ export const useNotificationTableStructure: UseTableStructureHook<NotificationIn
33
33
  params,
34
34
  ) => {
35
35
  const t = useTranslations();
36
- const dateLocale = dateFormatLocale(useI18nLocale());
36
+ const { locale: dateLocale } = useDateFormatter();
37
+ const now = useMountedNow();
37
38
  const generateUrl = usePageUrlGenerator();
38
39
  const { loadNotifications } = useNotificationContext();
39
40
 
@@ -125,7 +126,7 @@ export const useNotificationTableStructure: UseTableStructureHook<NotificationIn
125
126
  shows how long ago it arrived. The two must match. */
126
127
  return (
127
128
  <span className="text-muted-foreground text-xs">
128
- {formatDate(new Date(notification.createdAt), "default", dateLocale)}
129
+ {now ? formatDate(new Date(notification.createdAt), "default", dateLocale, now) : ""}
129
130
  </span>
130
131
  );
131
132
  },
@@ -175,7 +176,7 @@ export const useNotificationTableStructure: UseTableStructureHook<NotificationIn
175
176
  return params.fields.map((field) => fieldColumnMap[field]?.()).filter((col) => col !== undefined) as ColumnDef<
176
177
  TableContent<NotificationInterface>
177
178
  >[];
178
- }, [params.fields, fieldColumnMap, t, generateUrl]);
179
+ }, [params.fields, fieldColumnMap, t, generateUrl, dateLocale, now]);
179
180
 
180
181
  return useMemo(() => ({ data: tableData, columns: columns }), [tableData, columns]);
181
182
  };
@@ -1,8 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useTranslations } from "next-intl";
4
- import { formatDistanceToNow } from "date-fns";
5
- import { useI18nDateFnsLocale } from "../../../i18n";
4
+ import { useDateFormatter, useMountedNow } from "../../../contexts/DateLocaleContext";
6
5
  import { Key, MoreVertical, Pencil, Trash2 } from "lucide-react";
7
6
  import {
8
7
  Badge,
@@ -35,13 +34,17 @@ export interface OAuthClientCardProps {
35
34
  */
36
35
  export function OAuthClientCard({ client, onClick, onEdit, onDelete }: OAuthClientCardProps) {
37
36
  const t = useTranslations();
38
- const dateFnsLocale = useI18nDateFnsLocale();
37
+ const { relative } = useDateFormatter();
38
+ const now = useMountedNow();
39
39
  // Truncate client ID for display
40
40
  const truncatedId =
41
41
  client.clientId.length > 12 ? `${client.clientId.slice(0, 8)}...${client.clientId.slice(-4)}` : client.clientId;
42
42
 
43
+ // The relative text renders only after mount: `now` is never read during render.
43
44
  const createdAgo = client.createdAt
44
- ? formatDistanceToNow(new Date(client.createdAt), { addSuffix: true, locale: dateFnsLocale })
45
+ ? now
46
+ ? relative(new Date(client.createdAt), { now })
47
+ : undefined
45
48
  : t("oauth.ui.card.unknown");
46
49
 
47
50
  return (
@@ -100,7 +103,7 @@ export function OAuthClientCard({ client, onClick, onEdit, onDelete }: OAuthClie
100
103
  <CardContent>
101
104
  <div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground">
102
105
  <span className="tabular-nums">{truncatedId}</span>
103
- <span>{t("oauth.ui.card.created", { ago: createdAgo })}</span>
106
+ {createdAgo !== undefined && <span>{t("oauth.ui.card.created", { ago: createdAgo })}</span>}
104
107
  <span>{client.isConfidential ? t("oauth.ui.card.confidential") : t("oauth.ui.card.public")}</span>
105
108
  </div>
106
109
  </CardContent>
@@ -23,8 +23,7 @@ import {
23
23
  AlertDialogHeader,
24
24
  AlertDialogTitle,
25
25
  } from "../../../shadcnui";
26
- import { useI18nLocale } from "../../../i18n";
27
- import { dateFormatLocale } from "../../../utils";
26
+ import { useDateFormatter } from "../../../contexts/DateLocaleContext";
28
27
  import { useOAuthScopeText } from "../hooks/useOAuthScopeText";
29
28
  import { OAuthClientInterface } from "../interfaces/oauth.interface";
30
29
 
@@ -52,7 +51,7 @@ export function OAuthClientDetail({
52
51
  onRegenerateSecret,
53
52
  }: OAuthClientDetailProps) {
54
53
  const t = useTranslations();
55
- const locale = useI18nLocale();
54
+ const { format } = useDateFormatter();
56
55
  const scopeText = useOAuthScopeText();
57
56
  const [copiedField, setCopiedField] = useState<string | null>(null);
58
57
  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -92,9 +91,7 @@ export function OAuthClientDetail({
92
91
  }
93
92
  }, [onRegenerateSecret]);
94
93
 
95
- const createdDate = client.createdAt
96
- ? new Date(client.createdAt).toLocaleDateString(dateFormatLocale(locale), { dateStyle: "long" })
97
- : t("oauth.ui.card.unknown");
94
+ const createdDate = client.createdAt ? format(new Date(client.createdAt), "dateLong") : t("oauth.ui.card.unknown");
98
95
 
99
96
  return (
100
97
  <>
@@ -19,3 +19,22 @@ describe("createUsageFormatters.compact", () => {
19
19
  expect(compact(1500000)).toBe("1.5M");
20
20
  });
21
21
  });
22
+
23
+ describe("createUsageFormatters.bucketDate dateLocale", () => {
24
+ it("formats day buckets with the date locale", () => {
25
+ expect(createUsageFormatters("en", "EUR", "en-GB").bucketDate("2026-04-25", "day")).toBe("25 Apr");
26
+ });
27
+
28
+ it("defaults the date locale to the number locale", () => {
29
+ expect(createUsageFormatters("en", "EUR").bucketDate("2026-04-25", "day")).toBe("Apr 25");
30
+ });
31
+
32
+ it("keeps number output identical between the two", () => {
33
+ const withDateLocale = createUsageFormatters("en", "EUR", "en-GB");
34
+ const withoutDateLocale = createUsageFormatters("en", "EUR");
35
+ for (const value of [0, 0.05, 1250, 1500000]) {
36
+ expect(withDateLocale.compact(value)).toBe(withoutDateLocale.compact(value));
37
+ expect(withDateLocale.decimal(value, 2)).toBe(withoutDateLocale.decimal(value, 2));
38
+ }
39
+ });
40
+ });
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useLocale } from "next-intl";
4
4
  import { useMemo } from "react";
5
+ import { useDateFormatter } from "../../../contexts/DateLocaleContext";
5
6
  import type { Metric } from "../data/tokenusage-admin.types";
6
7
  import { getTokenUsageCurrency } from "./config";
7
8
 
@@ -38,8 +39,11 @@ export type UsageFormatters = {
38
39
  * `bucketDate` formats in UTC, always. The wire value is a `type: "date"` — a
39
40
  * calendar day — parsed to UTC midnight; reading it back with local getters
40
41
  * would shift the label a day early west of UTC.
42
+ *
43
+ * `dateLocale` formats the bucket dates only (the browser's regional order);
44
+ * numbers, currency and collation keep `locale`. It defaults to `locale`.
41
45
  */
42
- export function createUsageFormatters(locale: string, currency: string): UsageFormatters {
46
+ export function createUsageFormatters(locale: string, currency: string, dateLocale: string = locale): UsageFormatters {
43
47
  const symbol =
44
48
  new Intl.NumberFormat(locale, { style: "currency", currency, currencyDisplay: "narrowSymbol" })
45
49
  .formatToParts(0)
@@ -48,8 +52,8 @@ export function createUsageFormatters(locale: string, currency: string): UsageFo
48
52
  const decimal = (value: number, decimals: number): string =>
49
53
  value.toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
50
54
 
51
- const dayFormat = new Intl.DateTimeFormat(locale, { day: "numeric", month: "short", timeZone: "UTC" });
52
- const monthFormat = new Intl.DateTimeFormat(locale, { month: "short", year: "numeric", timeZone: "UTC" });
55
+ const dayFormat = new Intl.DateTimeFormat(dateLocale, { day: "numeric", month: "short", timeZone: "UTC" });
56
+ const monthFormat = new Intl.DateTimeFormat(dateLocale, { month: "short", year: "numeric", timeZone: "UTC" });
53
57
  // Axis ticks step by 1, 2, 2.5 or 5 times a power of ten, so a tick never needs
54
58
  // more than three significant digits. A fixed one-decimal cap rounded a 0.05
55
59
  // step into repeated labels (0.1, 0.1, 0.2, 0.2); significant digits keep every
@@ -94,11 +98,12 @@ export function createUsageFormatters(locale: string, currency: string): UsageFo
94
98
 
95
99
  /**
96
100
  * The formatter set for the current request's locale and the app's configured
97
- * currency. Memoised on both, so the Intl objects are built once per locale
98
- * rather than on every render.
101
+ * currency, with bucket dates in the resolved date locale. Memoised on all
102
+ * three, so the Intl objects are built once per locale rather than on every render.
99
103
  */
100
104
  export function useUsageFormatters(): UsageFormatters {
101
105
  const locale = useLocale();
106
+ const { locale: dateLocale } = useDateFormatter();
102
107
  const currency = getTokenUsageCurrency();
103
- return useMemo(() => createUsageFormatters(locale, currency), [locale, currency]);
108
+ return useMemo(() => createUsageFormatters(locale, currency, dateLocale), [locale, currency, dateLocale]);
104
109
  }