@softr/sdk 0.0.58 → 0.0.59

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.
@@ -1,4 +1,5 @@
1
1
  import { User } from "./user.js";
2
+ import { Formatting } from "../formatting.js";
2
3
  import { Permissions } from "./permissions.js";
3
4
 
4
5
  //#region src/auth/context.d.ts
@@ -10,9 +11,12 @@ type Session = {
10
11
  permissions: Permissions;
11
12
  };
12
13
  /**
13
- * The route context the root route publishes — see `getSoftrContext`. The same
14
- * session, under the name it carries in the public API.
14
+ * The route context the root route publishes — see `getSoftrContext`. The
15
+ * session, and how this request formats dates and numbers.
15
16
  */
16
- type SoftrContext = Session;
17
+ type SoftrContext = Session & {
18
+ /** Read it with `useFormatting()`. */
19
+ formatting: Formatting;
20
+ };
17
21
  //#endregion
18
- export { Session, SoftrContext };
22
+ export { SoftrContext };
@@ -1,16 +1,18 @@
1
1
  import { User } from "./user.js";
2
+ import { Formatting } from "../formatting.js";
2
3
  import { Permissions } from "./permissions.js";
3
- import { Session, SoftrContext } from "./context.js";
4
+ import { SoftrContext } from "./context.js";
4
5
  import * as _tanstack_react_start0 from "@tanstack/react-start";
5
6
 
6
7
  //#region src/auth/isomorphic.d.ts
7
8
 
8
9
  /**
9
- * Resolves the Softr visitor and their permissions for every request, so that
10
- * `getSoftrContext` and the route guards have something to read.
10
+ * Resolves the Softr visitor, their permissions and the app's formatting for
11
+ * every request, so that `getSoftrContext` and the route guards have something
12
+ * to read.
11
13
  *
12
14
  * Register it once, as a global request middleware in the app's `src/start.ts`.
13
- * It touches nothing but the session.
15
+ * It writes nothing but the session cookie.
14
16
  *
15
17
  * @example
16
18
  * export const startInstance = createStart(() => ({
@@ -20,6 +22,7 @@ import * as _tanstack_react_start0 from "@tanstack/react-start";
20
22
  declare function softrAuthMiddleware(): _tanstack_react_start0.RequestMiddlewareAfterServer<{}, readonly [_tanstack_react_start0.RequestMiddlewareAfterServer<{}, undefined, undefined>, _tanstack_react_start0.RequestMiddlewareAfterServer<{}, undefined, undefined>], {
21
23
  user: User | null;
22
24
  permissions: Permissions;
25
+ formatting: Formatting;
23
26
  }>;
24
27
  /**
25
28
  * Resolves the route context of the root route: the visitor and what they may
@@ -30,7 +33,7 @@ declare function softrAuthMiddleware(): _tanstack_react_start0.RequestMiddleware
30
33
  * beforeLoad: async () => await getSoftrContext(),
31
34
  * })
32
35
  */
33
- declare const getSoftrContext: _tanstack_react_start0.OptionalFetcher<undefined, undefined, Promise<Session>>;
36
+ declare const getSoftrContext: _tanstack_react_start0.OptionalFetcher<undefined, undefined, Promise<SoftrContext>>;
34
37
  /**
35
38
  * The context to hand `createRouter`, before any request has resolved one.
36
39
  *
@@ -1,20 +1,23 @@
1
1
  import { assert, panic } from "../assertions.js";
2
- import { writeCookies } from "../cookies.js";
2
+ import { readUserTimezone, writeCookies } from "../cookies.js";
3
3
  import { sessionConfig } from "./config.js";
4
4
  import { clearSession, makeSessionDataCookie, readSessionData, readSessionToken } from "./session.js";
5
5
  import { makePlatform } from "./platform.js";
6
+ import { defaultFormatting, readLocaleSettings, resolveFormatting } from "../formatting.js";
6
7
  import { bootErrorMiddleware } from "../boot-error.js";
7
8
  import { pwaMiddleware } from "../pwa.js";
8
9
  import { createMiddleware, createServerFn, getGlobalStartContext } from "@tanstack/react-start";
10
+ import { getRequestHeader } from "@tanstack/react-start/server";
9
11
  import { rootRouteId, useRouteContext } from "@tanstack/react-router";
10
12
 
11
13
  //#region src/auth/isomorphic.ts
12
14
  /**
13
- * Resolves the Softr visitor and their permissions for every request, so that
14
- * `getSoftrContext` and the route guards have something to read.
15
+ * Resolves the Softr visitor, their permissions and the app's formatting for
16
+ * every request, so that `getSoftrContext` and the route guards have something
17
+ * to read.
15
18
  *
16
19
  * Register it once, as a global request middleware in the app's `src/start.ts`.
17
- * It touches nothing but the session.
20
+ * It writes nothing but the session cookie.
18
21
  *
19
22
  * @example
20
23
  * export const startInstance = createStart(() => ({
@@ -37,10 +40,11 @@ function softrAuthMiddleware() {
37
40
  writeCookies([makeSessionDataCookie(session, token)]);
38
41
  return session;
39
42
  }
40
- const { user, permissions } = await getSession();
43
+ const [{ user, permissions }, settings] = await Promise.all([getSession(), readLocaleSettings()]);
41
44
  return next({ context: {
42
45
  user,
43
- permissions
46
+ permissions,
47
+ formatting: resolveFormatting(settings, getRequestHeader("accept-language") ?? null, readUserTimezone())
44
48
  } });
45
49
  });
46
50
  }
@@ -55,7 +59,7 @@ function softrAuthMiddleware() {
55
59
  */
56
60
  const getSoftrContext = createServerFn({ method: "GET" }).handler(async () => {
57
61
  const context = getGlobalStartContext();
58
- assert(typeof context === "object" && context !== null && "user" in context && "permissions" in context, "softrAuthMiddleware has not run: the Start global context carries no session. Register it as a global request middleware, e.g. createStart(() => ({ requestMiddleware: [softrAuthMiddleware()] })).");
62
+ assert(typeof context === "object" && context !== null && "user" in context && "permissions" in context && "formatting" in context, "softrAuthMiddleware has not run: the Start global context carries no session. Register it as a global request middleware, e.g. createStart(() => ({ requestMiddleware: [softrAuthMiddleware()] })).");
59
63
  return context;
60
64
  });
61
65
  /**
@@ -70,7 +74,8 @@ const getSoftrContext = createServerFn({ method: "GET" }).handler(async () => {
70
74
  function initialSoftrContext() {
71
75
  return {
72
76
  user: null,
73
- permissions: { datasources: {} }
77
+ permissions: { datasources: {} },
78
+ formatting: defaultFormatting()
74
79
  };
75
80
  }
76
81
  /**
@@ -122,7 +122,7 @@ type ChartSeed<TShape extends ReadShape> = {
122
122
  * @example
123
123
  * ds.chart(sales).groupBy((q) => ({ month: q.closedAt.byMonth, region: q.region }))
124
124
  */
125
- groupBy<TGroups extends Record<string, Dimension<unknown>>>(build: (q: DimensionSet<TShape>) => TGroups & GroupByBounds<TGroups>): ChartBuilder<TShape, TGroups, {}>;
125
+ groupBy<TGroups extends Record<string, Dimension<unknown>>>(build: (q: DimensionSet<TShape>) => TGroups & GroupByBounds<TGroups>): ChartBuilder<TShape, TGroups, Record<never, never>>;
126
126
  /**
127
127
  * Narrows which records the chart aggregates. Same filters as `ds.list`.
128
128
  */
@@ -1,4 +1,4 @@
1
- import * as react1 from "react";
1
+ import * as react5 from "react";
2
2
 
3
3
  //#region src/default-error-overlay.d.ts
4
4
  type Props = {
@@ -8,6 +8,6 @@ type Props = {
8
8
  declare function DefaultErrorOverlay({
9
9
  error,
10
10
  reset
11
- }: Props): react1.JSX.Element;
11
+ }: Props): react5.JSX.Element;
12
12
  //#endregion
13
13
  export { DefaultErrorOverlay };
@@ -0,0 +1,120 @@
1
+ import * as react0 from "react";
2
+ import { ReactNode } from "react";
3
+ import { z } from "zod";
4
+
5
+ //#region src/formatting.d.ts
6
+ type FixedDateFormat = "FRIENDLY" | "DD-MM-YYYY" | "MM-DD-YYYY" | "YYYY-MM-DD";
7
+ type DateSeparator = "-" | "/" | ".";
8
+ /** ISO week day: 1 is Monday, 7 is Sunday. */
9
+ type WeekDay = 1 | 2 | 3 | 4 | 5 | 6 | 7;
10
+ /** How this request formats dates and numbers. Read it with `useFormatting()`. */
11
+ type Formatting = {
12
+ /** The locale for dates when the app uses the visitor's format. */
13
+ dateLocale: string;
14
+ /** The locale for numbers. */
15
+ numberLocale: string;
16
+ /**
17
+ * The IANA time zone for timestamps. `null` when the app uses the visitor's
18
+ * zone and the browser has not reported it yet, which is the first visit.
19
+ */
20
+ timeZone: string | null;
21
+ /** The app's fixed date format, or `null` to format in `dateLocale`. */
22
+ fixedDate: {
23
+ format: FixedDateFormat;
24
+ separator: DateSeparator;
25
+ hour12: boolean;
26
+ } | null;
27
+ /** The first day of the week, or `null` to use the locale's own. */
28
+ weekStart: WeekDay | null;
29
+ /** The app's language, for `<html lang>`. */
30
+ language: string;
31
+ /** Whether the app's language reads right to left, for `<html dir>`. */
32
+ rtl: boolean;
33
+ };
34
+ /** A date as a record field holds it: a `Date`, epoch milliseconds, or an ISO string. */
35
+ type DateValue = Date | number | string;
36
+ type DateOptions = {
37
+ /** How long the date reads when the app uses the visitor's format. A fixed format ignores it. */
38
+ dateStyle?: "short" | "medium" | "long" | "full";
39
+ /**
40
+ * The parts to show, such as `{ month: 'long', year: 'numeric' }` or `{ hour: 'numeric', minute:
41
+ * 'numeric', timeZoneName: 'short' }`. Wins over `dateStyle` and the app's fixed format.
42
+ */
43
+ parts?: DateParts;
44
+ /**
45
+ * The IANA zone for this value, such as `America/New_York`, when the builder names one. Wins over
46
+ * a `FormattingScope` and the app's settings. An unknown zone is ignored.
47
+ */
48
+ timeZone?: string;
49
+ };
50
+ type DateParts = Pick<Intl.DateTimeFormatOptions, "weekday" | "year" | "month" | "day" | "hour" | "minute" | "timeZoneName">;
51
+ /**
52
+ * The day of a date, in the app's format. A timestamp reads in
53
+ * `formatting.timeZone`, or in UTC while that is unknown. Returns `""` for a
54
+ * value that is not a date.
55
+ */
56
+ declare function formatDate(value: DateValue, formatting: Formatting, options?: DateOptions): string;
57
+ /**
58
+ * The day and the time of a timestamp, in the app's format and zone. In JSX,
59
+ * prefer `<FormattedDate time />`: on a first visit it waits for the
60
+ * visitor's zone instead of showing UTC.
61
+ */
62
+ declare function formatDateTime(value: DateValue, formatting: Formatting, options?: DateOptions): string;
63
+ /** A number in the app's number locale. Returns `""` for a value that is not finite. */
64
+ declare function formatNumber(value: number, formatting: Formatting, options?: Intl.NumberFormatOptions): string;
65
+ /**
66
+ * The formatting of this request. On the server and during hydration it is
67
+ * the one the server resolved. After hydration, a zone the server did not know
68
+ * yet is filled in from the browser.
69
+ *
70
+ * Requires the root route to resolve `getSoftrContext`.
71
+ */
72
+ declare function useFormatting(): Formatting;
73
+ type Scope = {
74
+ timeZone?: string;
75
+ locale?: string;
76
+ };
77
+ /**
78
+ * Formats every date and number inside it in a zone or a locale the builder names, such as "show
79
+ * this page in New York time". A known zone renders on the server at once. An inner scope wins over
80
+ * an outer one. An unknown zone or locale is ignored, because it can come from a record.
81
+ *
82
+ * @example
83
+ * <FormattingScope timeZone="America/New_York">
84
+ * <FormattedDate value={event.fields.starts} time />
85
+ * </FormattingScope>
86
+ */
87
+ declare function FormattingScope({
88
+ timeZone,
89
+ locale,
90
+ children
91
+ }: {
92
+ /** An IANA zone, such as `America/New_York`. */
93
+ timeZone?: string;
94
+ /** A BCP 47 locale, such as `de-DE`. It drops the app's fixed date format inside the scope. */
95
+ locale?: string;
96
+ children?: ReactNode;
97
+ }): react0.FunctionComponentElement<react0.ProviderProps<Scope>>;
98
+ /**
99
+ * A date in the app's format, safe to render on the server. A timestamp whose
100
+ * zone is not known yet renders `fallback` until the browser supplies it, so
101
+ * the visitor never sees a UTC time.
102
+ *
103
+ * @example
104
+ * <FormattedDate value={bug.fields.created} />
105
+ * <FormattedDate value={event.fields.starts} time />
106
+ */
107
+ declare function FormattedDate({
108
+ value,
109
+ time,
110
+ fallback,
111
+ ...options
112
+ }: DateOptions & {
113
+ value: DateValue;
114
+ /** Shows the time as well as the day. A calendar day such as `2026-10-06` has no time to show. */
115
+ time?: boolean;
116
+ /** What renders while the visitor's zone is unknown. */
117
+ fallback?: ReactNode;
118
+ }): react0.FunctionComponentElement<react0.FragmentProps>;
119
+ //#endregion
120
+ export { DateOptions, DateParts, DateSeparator, DateValue, FixedDateFormat, FormattedDate, Formatting, FormattingScope, WeekDay, formatDate, formatDateTime, formatNumber, useFormatting };
@@ -0,0 +1,379 @@
1
+ import { logger } from "./logger.js";
2
+ import { fetch } from "./fetch.js";
3
+ import { readRequestCookies, serializeCookie, writeCookies } from "./cookies.js";
4
+ import { sessionConfig } from "./auth/config.js";
5
+ import { platformApiUrl } from "./auth/platform.js";
6
+ import { Fragment, createContext, createElement, useContext, useMemo, useSyncExternalStore } from "react";
7
+ import { getRequestProtocol } from "@tanstack/react-start/server";
8
+ import { z } from "zod";
9
+ import { rootRouteId, useRouteContext } from "@tanstack/react-router";
10
+
11
+ //#region src/formatting.ts
12
+ const DEFAULT_LOCALE = "en-US";
13
+ /** The formatting before a request has resolved one. */
14
+ function defaultFormatting() {
15
+ return {
16
+ dateLocale: DEFAULT_LOCALE,
17
+ numberLocale: DEFAULT_LOCALE,
18
+ timeZone: null,
19
+ fixedDate: null,
20
+ weekStart: null,
21
+ language: DEFAULT_LOCALE,
22
+ rtl: false
23
+ };
24
+ }
25
+ const localeSettingsSchema = z.object({
26
+ dateAndTimeSettings: z.object({
27
+ formatSettings: z.object({
28
+ type: z.enum(["LOCAL", "FIXED"]),
29
+ fixedSettings: z.object({
30
+ dateFormat: z.enum([
31
+ "FRIENDLY",
32
+ "DD-MM-YYYY",
33
+ "MM-DD-YYYY",
34
+ "YYYY-MM-DD"
35
+ ]).nullish(),
36
+ dateFormatSeparator: z.enum([
37
+ "-",
38
+ "/",
39
+ "."
40
+ ]).nullish(),
41
+ timeFormat: z.enum(["12", "24"]).nullish(),
42
+ weekStart: z.literal([
43
+ 1,
44
+ 2,
45
+ 3,
46
+ 4,
47
+ 5,
48
+ 6,
49
+ 7
50
+ ]).nullish()
51
+ }).nullish()
52
+ }),
53
+ timezoneSettings: z.object({
54
+ type: z.enum(["LOCAL", "FIXED"]),
55
+ fixedTimezone: z.string().nullish()
56
+ })
57
+ }),
58
+ numberFormattingSettings: z.object({
59
+ type: z.enum(["LOCAL", "FIXED"]),
60
+ fixedLocale: z.string().nullish()
61
+ }),
62
+ languageSettings: z.object({
63
+ language: z.string().nullish(),
64
+ rtl: z.boolean()
65
+ })
66
+ });
67
+ const LOCALE_SETTINGS = "locale_settings";
68
+ const LOCALE_SETTINGS_TTL_SECONDS = 300;
69
+ /**
70
+ * The app's locale settings, or `null` when they cannot be read. Never
71
+ * rejects: a failure formats with the visitor's locale and is not remembered,
72
+ * so the next request tries again.
73
+ */
74
+ async function readLocaleSettings() {
75
+ const cached = parseLocaleSettings(readRequestCookies()[LOCALE_SETTINGS]);
76
+ if (cached !== null) return cached;
77
+ try {
78
+ const settings = await fetchLocaleSettings();
79
+ writeCookies([serializeCookie(LOCALE_SETTINGS, JSON.stringify(settings), {
80
+ maxAge: LOCALE_SETTINGS_TTL_SECONDS,
81
+ secure: getRequestProtocol() === "https"
82
+ })]);
83
+ return settings;
84
+ } catch (error) {
85
+ logger.warn("Could not read the locale settings:", error);
86
+ return null;
87
+ }
88
+ }
89
+ /** The settings in `cookie`, or `null` for anything else. The cookie is visitor input. */
90
+ function parseLocaleSettings(cookie) {
91
+ if (cookie === void 0) return null;
92
+ try {
93
+ return localeSettingsSchema.safeParse(JSON.parse(cookie)).data ?? null;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+ async function fetchLocaleSettings() {
99
+ const { applicationId } = sessionConfig();
100
+ const response = await fetch(`${await platformApiUrl()}/v1/vibe/applications/${applicationId}/locale-settings`, { headers: { accept: "application/json" } });
101
+ if (!response.ok) throw new Error(`The platform answered ${response.status}`);
102
+ return localeSettingsSchema.parse(await response.json());
103
+ }
104
+ /**
105
+ * Combines the app's settings with what the request says about the visitor.
106
+ * A `FIXED` setting wins. A `LOCAL` one uses the request: `Accept-Language`
107
+ * for the locale and the `user-timezone` cookie for the zone.
108
+ */
109
+ function resolveFormatting(settings, acceptLanguage, timeZoneCookie) {
110
+ const language = supportedLocale(settings?.languageSettings.language) ?? DEFAULT_LOCALE;
111
+ const visitorLocale = firstSupportedLocale(acceptLanguage) ?? language;
112
+ const format = settings?.dateAndTimeSettings.formatSettings;
113
+ const fixed = format?.type === "FIXED" ? format.fixedSettings : null;
114
+ const zone = settings?.dateAndTimeSettings.timezoneSettings;
115
+ const fixedZone = zone?.type === "FIXED" ? validTimeZone(zone.fixedTimezone) : null;
116
+ const numbers = settings?.numberFormattingSettings;
117
+ return {
118
+ dateLocale: visitorLocale,
119
+ numberLocale: (numbers?.type === "FIXED" ? supportedLocale(numbers.fixedLocale) : null) ?? visitorLocale,
120
+ timeZone: fixedZone ?? validTimeZone(timeZoneCookie),
121
+ fixedDate: fixed?.dateFormat ? {
122
+ format: fixed.dateFormat,
123
+ separator: fixed.dateFormatSeparator ?? "-",
124
+ hour12: fixed.timeFormat === "12"
125
+ } : null,
126
+ weekStart: fixed?.dateFormat ? fixed.weekStart ?? 1 : null,
127
+ language,
128
+ rtl: settings?.languageSettings.rtl ?? false
129
+ };
130
+ }
131
+ /**
132
+ * The day of a date, in the app's format. A timestamp reads in
133
+ * `formatting.timeZone`, or in UTC while that is unknown. Returns `""` for a
134
+ * value that is not a date.
135
+ */
136
+ function formatDate(value, formatting, options = {}) {
137
+ return formatDateValue(value, formatting, false, options);
138
+ }
139
+ /**
140
+ * The day and the time of a timestamp, in the app's format and zone. In JSX,
141
+ * prefer `<FormattedDate time />`: on a first visit it waits for the
142
+ * visitor's zone instead of showing UTC.
143
+ */
144
+ function formatDateTime(value, formatting, options = {}) {
145
+ return formatDateValue(value, formatting, true, options);
146
+ }
147
+ /** A number in the app's number locale. Returns `""` for a value that is not finite. */
148
+ function formatNumber(value, formatting, options) {
149
+ if (!Number.isFinite(value)) return "";
150
+ return new Intl.NumberFormat(formatting.numberLocale, options).format(value);
151
+ }
152
+ /**
153
+ * The formatting of this request. On the server and during hydration it is
154
+ * the one the server resolved. After hydration, a zone the server did not know
155
+ * yet is filled in from the browser.
156
+ *
157
+ * Requires the root route to resolve `getSoftrContext`.
158
+ */
159
+ function useFormatting() {
160
+ const resolved = useRouteContext({
161
+ from: rootRouteId,
162
+ select: (context) => context.formatting
163
+ });
164
+ const scope = useContext(ScopeContext);
165
+ const hydrated = useSyncExternalStore(subscribeNever, () => true, () => false);
166
+ return useMemo(() => {
167
+ const scoped = {
168
+ ...resolved,
169
+ ...scope.timeZone ? { timeZone: scope.timeZone } : {},
170
+ ...scope.locale ? {
171
+ dateLocale: scope.locale,
172
+ numberLocale: scope.locale,
173
+ fixedDate: null
174
+ } : {}
175
+ };
176
+ return scoped.timeZone === null && hydrated ? {
177
+ ...scoped,
178
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone
179
+ } : scoped;
180
+ }, [
181
+ resolved,
182
+ scope,
183
+ hydrated
184
+ ]);
185
+ }
186
+ const ScopeContext = createContext({});
187
+ /**
188
+ * Formats every date and number inside it in a zone or a locale the builder names, such as "show
189
+ * this page in New York time". A known zone renders on the server at once. An inner scope wins over
190
+ * an outer one. An unknown zone or locale is ignored, because it can come from a record.
191
+ *
192
+ * @example
193
+ * <FormattingScope timeZone="America/New_York">
194
+ * <FormattedDate value={event.fields.starts} time />
195
+ * </FormattingScope>
196
+ */
197
+ function FormattingScope({ timeZone, locale, children }) {
198
+ const parent = useContext(ScopeContext);
199
+ const value = useMemo(() => {
200
+ const zone = checkedTimeZone(timeZone);
201
+ const tag = locale === void 0 ? null : supportedLocale(locale);
202
+ if (locale !== void 0 && tag === null) logger.warn(`FormattingScope ignores the unknown locale "${locale}".`);
203
+ return {
204
+ timeZone: zone ?? parent.timeZone,
205
+ locale: tag ?? parent.locale
206
+ };
207
+ }, [
208
+ parent,
209
+ timeZone,
210
+ locale
211
+ ]);
212
+ return createElement(ScopeContext.Provider, { value }, children);
213
+ }
214
+ /**
215
+ * A date in the app's format, safe to render on the server. A timestamp whose
216
+ * zone is not known yet renders `fallback` until the browser supplies it, so
217
+ * the visitor never sees a UTC time.
218
+ *
219
+ * @example
220
+ * <FormattedDate value={bug.fields.created} />
221
+ * <FormattedDate value={event.fields.starts} time />
222
+ */
223
+ function FormattedDate({ value, time = false, fallback = null,...options }) {
224
+ const formatting = useFormatting();
225
+ if (!isCalendarDay(value) && zoneFor(formatting, options) === null) return createElement(Fragment, null, fallback);
226
+ return createElement(Fragment, null, (time ? formatDateTime : formatDate)(value, formatting, options));
227
+ }
228
+ function subscribeNever() {
229
+ return () => {};
230
+ }
231
+ const CALENDAR_DAY = /^\d{4}-\d{2}-\d{2}$/;
232
+ /** A calendar day such as `2026-10-06` names the same day in every zone, so it needs none. */
233
+ function isCalendarDay(value) {
234
+ return typeof value === "string" && CALENDAR_DAY.test(value);
235
+ }
236
+ function formatDateValue(value, formatting, withTime, options) {
237
+ const day = isCalendarDay(value);
238
+ const date = day ? /* @__PURE__ */ new Date(`${value}T00:00:00Z`) : new Date(value);
239
+ if (Number.isNaN(date.getTime())) return "";
240
+ const timeZone = day ? "UTC" : zoneFor(formatting, options) ?? "UTC";
241
+ const showTime = withTime && !day;
242
+ if (options.parts) return new Intl.DateTimeFormat(formatting.dateLocale, {
243
+ ...options.parts,
244
+ ...formatting.fixedDate !== null ? { hour12: formatting.fixedDate.hour12 } : {},
245
+ timeZone
246
+ }).format(date);
247
+ if (formatting.fixedDate !== null) return formatFixed(date, timeZone, showTime, formatting.fixedDate);
248
+ if (options.dateStyle) return new Intl.DateTimeFormat(formatting.dateLocale, {
249
+ dateStyle: options.dateStyle,
250
+ ...showTime ? { timeStyle: "short" } : {},
251
+ timeZone
252
+ }).format(date);
253
+ return new Intl.DateTimeFormat(formatting.dateLocale, {
254
+ year: "numeric",
255
+ month: "numeric",
256
+ day: "numeric",
257
+ ...showTime ? {
258
+ hour: "numeric",
259
+ minute: "numeric"
260
+ } : {},
261
+ timeZone
262
+ }).format(date);
263
+ }
264
+ const MONTHS = [
265
+ "Jan",
266
+ "Feb",
267
+ "Mar",
268
+ "Apr",
269
+ "May",
270
+ "Jun",
271
+ "Jul",
272
+ "Aug",
273
+ "Sep",
274
+ "Oct",
275
+ "Nov",
276
+ "Dec"
277
+ ];
278
+ function formatFixed(date, timeZone, showTime, fixed) {
279
+ const parts = new Intl.DateTimeFormat("en-US", {
280
+ timeZone,
281
+ year: "numeric",
282
+ month: "numeric",
283
+ day: "numeric",
284
+ hour: "numeric",
285
+ minute: "numeric",
286
+ hourCycle: "h23"
287
+ }).formatToParts(date);
288
+ const part = (type) => Number(parts.find((p) => p.type === type)?.value ?? NaN);
289
+ const [year, month, day, hour, minute] = [
290
+ part("year"),
291
+ part("month"),
292
+ part("day"),
293
+ part("hour"),
294
+ part("minute")
295
+ ];
296
+ const sep = fixed.separator;
297
+ const text = {
298
+ FRIENDLY: `${MONTHS[month - 1]} ${ordinal(day)}, ${year}`,
299
+ "DD-MM-YYYY": [
300
+ pad(day),
301
+ pad(month),
302
+ year
303
+ ].join(sep),
304
+ "MM-DD-YYYY": [
305
+ pad(month),
306
+ pad(day),
307
+ year
308
+ ].join(sep),
309
+ "YYYY-MM-DD": [
310
+ year,
311
+ pad(month),
312
+ pad(day)
313
+ ].join(sep)
314
+ }[fixed.format];
315
+ if (!showTime) return text;
316
+ if (!fixed.hour12) return `${text} ${pad(hour)}:${pad(minute)}`;
317
+ return `${text} ${pad(hour % 12 === 0 ? 12 : hour % 12)}:${pad(minute)} ${hour < 12 ? "AM" : "PM"}`;
318
+ }
319
+ function pad(value) {
320
+ return String(value).padStart(2, "0");
321
+ }
322
+ function ordinal(day) {
323
+ if (day % 100 >= 11 && day % 100 <= 13) return `${day}th`;
324
+ return `${day}${[
325
+ "th",
326
+ "st",
327
+ "nd",
328
+ "rd"
329
+ ][day % 10] ?? "th"}`;
330
+ }
331
+ /** The first locale in an `Accept-Language` header that `Intl` supports, by weight. */
332
+ function firstSupportedLocale(header) {
333
+ if (!header) return null;
334
+ const ranked = header.split(",").map((entry, index) => {
335
+ const [tag = "", ...params] = entry.trim().split(";");
336
+ const q = params.map((p) => p.trim()).find((p) => p.startsWith("q="));
337
+ const weight = q === void 0 ? 1 : Number(q.slice(2));
338
+ return {
339
+ tag: tag.trim(),
340
+ weight: Number.isNaN(weight) ? 0 : weight,
341
+ index
342
+ };
343
+ }).filter((entry) => entry.tag !== "" && entry.tag !== "*" && entry.weight > 0).sort((a, b) => b.weight - a.weight || a.index - b.index);
344
+ for (const { tag } of ranked) {
345
+ const locale = supportedLocale(tag);
346
+ if (locale !== null) return locale;
347
+ }
348
+ return null;
349
+ }
350
+ function supportedLocale(tag) {
351
+ if (!tag) return null;
352
+ try {
353
+ return Intl.DateTimeFormat.supportedLocalesOf(tag)[0] ?? null;
354
+ } catch {
355
+ return null;
356
+ }
357
+ }
358
+ /** The zone a value reads in: the one the call names, else the formatting's, else `null` while unknown. */
359
+ function zoneFor(formatting, options) {
360
+ return checkedTimeZone(options.timeZone) ?? formatting.timeZone;
361
+ }
362
+ /** `validTimeZone` for a zone the app's code names. It warns, so a typo shows in the logs. */
363
+ function checkedTimeZone(zone) {
364
+ if (zone === void 0) return null;
365
+ const valid = validTimeZone(zone);
366
+ if (valid === null) logger.warn(`Ignoring the unknown time zone "${zone}".`);
367
+ return valid;
368
+ }
369
+ function validTimeZone(zone) {
370
+ if (!zone) return null;
371
+ try {
372
+ return new Intl.DateTimeFormat("en-US", { timeZone: zone }).resolvedOptions().timeZone;
373
+ } catch {
374
+ return null;
375
+ }
376
+ }
377
+
378
+ //#endregion
379
+ export { FormattedDate, FormattingScope, defaultFormatting, formatDate, formatDateTime, formatNumber, readLocaleSettings, resolveFormatting, useFormatting };
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ import { SoftrUserGroupId, SoftrUserGroups, User } from "./auth/user.js";
2
2
  import { ActionPermissions, SoftrDatasources, defineIntegration, defineMutation, defineQuery, defineWorkflow } from "./action.js";
3
3
  import { FileAttachment } from "./core/file-upload.js";
4
4
  import { FileItem, useFileUpload } from "./core/use-file-upload.js";
5
+ import { DateOptions, DateParts, DateSeparator, DateValue, FixedDateFormat, FormattedDate, Formatting, FormattingScope, WeekDay, formatDate, formatDateTime, formatNumber, useFormatting } from "./formatting.js";
5
6
  import { SoftrContext } from "./auth/context.js";
6
7
  import { checkAccess, defineVisibility, isAuthorized } from "./visibility/visibility.js";
7
8
  import { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError } from "./visibility/route.js";
@@ -11,4 +12,4 @@ import { useUserTimezone } from "./timezone.js";
11
12
  import { PwaInstall, PwaPlatform, SoftrPwaHead, usePwaInstall } from "./pwa.js";
12
13
  import { DefaultErrorOverlay } from "./default-error-overlay.js";
13
14
  import { Audience } from "./visibility/audience.js";
14
- export { type ActionPermissions, Audience, DefaultErrorOverlay, type FileAttachment, type FileItem, type PwaInstall, type PwaPlatform, type SoftrContext, type SoftrDatasources, SoftrPwaHead, type SoftrUserGroupId, type SoftrUserGroups, type User, assertRouteAccess, checkAccess, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, softrMcpMiddleware, useCurrentUser, useFileUpload, usePwaInstall, useUserTimezone };
15
+ export { type ActionPermissions, Audience, type DateOptions, type DateParts, type DateSeparator, type DateValue, DefaultErrorOverlay, type FileAttachment, type FileItem, type FixedDateFormat, FormattedDate, type Formatting, FormattingScope, type PwaInstall, type PwaPlatform, type SoftrContext, type SoftrDatasources, SoftrPwaHead, type SoftrUserGroupId, type SoftrUserGroups, type User, type WeekDay, assertRouteAccess, checkAccess, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, formatDate, formatDateTime, formatNumber, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, softrMcpMiddleware, useCurrentUser, useFileUpload, useFormatting, usePwaInstall, useUserTimezone };
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ import { useFileUpload } from "./core/use-file-upload.js";
2
2
  import { defineIntegration, defineMutation, defineQuery, defineWorkflow } from "./action.js";
3
3
  import { checkAccess, defineVisibility, isAuthorized } from "./visibility/visibility.js";
4
4
  import { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError } from "./visibility/route.js";
5
+ import { FormattedDate, FormattingScope, formatDate, formatDateTime, formatNumber, useFormatting } from "./formatting.js";
5
6
  import { SoftrPwaHead, usePwaInstall } from "./pwa.js";
6
7
  import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser } from "./auth/isomorphic.js";
7
8
  import { softrMcpMiddleware } from "./mcp.js";
@@ -9,4 +10,4 @@ import { useUserTimezone } from "./timezone.js";
9
10
  import { DefaultErrorOverlay } from "./default-error-overlay.js";
10
11
  import { Audience } from "./visibility/audience.js";
11
12
 
12
- export { Audience, DefaultErrorOverlay, SoftrPwaHead, assertRouteAccess, checkAccess, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, softrMcpMiddleware, useCurrentUser, useFileUpload, usePwaInstall, useUserTimezone };
13
+ export { Audience, DefaultErrorOverlay, FormattedDate, FormattingScope, SoftrPwaHead, assertRouteAccess, checkAccess, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, formatDate, formatDateTime, formatNumber, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, softrMcpMiddleware, useCurrentUser, useFileUpload, useFormatting, usePwaInstall, useUserTimezone };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.0.58";
2
+ var version = "0.0.59";
3
3
 
4
4
  //#endregion
5
5
  export { version };
package/dist/pwa.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import * as react0 from "react";
1
+ import * as react3 from "react";
2
2
  import "@tanstack/react-start";
3
3
 
4
4
  //#region src/pwa.d.ts
@@ -23,7 +23,7 @@ import "@tanstack/react-start";
23
23
  * )
24
24
  * }
25
25
  */
26
- declare function SoftrPwaHead(): react0.FunctionComponentElement<react0.FragmentProps>;
26
+ declare function SoftrPwaHead(): react3.FunctionComponentElement<react3.FragmentProps>;
27
27
  type PwaPlatform = "ios" | "android" | "desktop";
28
28
  type PwaInstall = {
29
29
  available: false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softr/sdk",
3
- "version": "0.0.58",
3
+ "version": "0.0.59",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
@@ -6,7 +6,7 @@ description: >-
6
6
  boundary between them, softrAuthMiddleware, getSoftrContext,
7
7
  initialSoftrContext, SoftrContext, and which sub-skill to load for route
8
8
  visibility, the current user, reading records, charts and metrics, writing
9
- records, or auth. Load this when unsure which sub-skill applies, or when
9
+ records, auth, or showing dates and numbers. Load this when unsure which sub-skill applies, or when
10
10
  touching the wiring; each sub-skill stands alone for its own task.
11
11
  metadata:
12
12
  type: core
@@ -29,7 +29,7 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
29
29
 
30
30
  | Import | Runs | Holds |
31
31
  | --- | --- | --- |
32
- | `@softr/sdk` | Both | `defineQuery`, `defineMutation`, `defineVisibility`, `guardRoute`, `assertRouteAccess`, `isAuthorized`, `checkAccess`, `Audience`, `useCurrentUser`, `useUserTimezone`, `SoftrPwaHead`, `usePwaInstall`, `getSoftrContext`, `initialSoftrContext`, `softrAuthMiddleware`, the error predicates, and every type |
32
+ | `@softr/sdk` | Both | `defineQuery`, `defineMutation`, `defineVisibility`, `guardRoute`, `assertRouteAccess`, `isAuthorized`, `checkAccess`, `Audience`, `useCurrentUser`, `useUserTimezone`, `FormattedDate`, `FormattingScope`, `useFormatting`, `formatDate`, `formatDateTime`, `formatNumber`, `SoftrPwaHead`, `usePwaInstall`, `getSoftrContext`, `initialSoftrContext`, `softrAuthMiddleware`, the error predicates, and every type |
33
33
  | `@softr/sdk/server` | Server only | `ds`, `auth`, `workflow`, `integration` |
34
34
  | `@softr/sdk/client` | Browser only | Nothing yet, reserved for browser-only utilities |
35
35
  | `@softr/sdk/vite` | Build | `softr()`, already in `vite.config.ts` |
@@ -49,6 +49,7 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
49
49
  | Call a connected third-party API (Slack, Stripe, Gmail, …) | [integrations/SKILL.md](./integrations/SKILL.md) |
50
50
  | Open the app to AI assistants over MCP | [mcp/SKILL.md](./mcp/SKILL.md) |
51
51
  | Let visitors install the app on their home screen | [pwa/SKILL.md](./pwa/SKILL.md) |
52
+ | Show a date, a time or a number | [formatting/SKILL.md](./formatting/SKILL.md) |
52
53
 
53
54
  ## Quick Decision Tree
54
55
 
@@ -86,6 +87,9 @@ Opening the app to an AI assistant over MCP?
86
87
  Adding an install button, or making the app installable?
87
88
  load softr-sdk/pwa
88
89
 
90
+ Showing a date, a time or a number on a page?
91
+ load softr-sdk/formatting
92
+
89
93
  Writing a server function, loader, or middleware in general?
90
94
  not a Softr concern. Use the TanStack Start skills.
91
95
  ```
@@ -0,0 +1,212 @@
1
+ ---
2
+ name: formatting
3
+ description: >-
4
+ Show dates, times and numbers in the app's format without hydration
5
+ mismatches: FormattedDate in JSX, useFormatting with formatDate,
6
+ formatDateTime and formatNumber in code, FormattingScope and the timeZone
7
+ option for a zone or locale the user names, parts for a partial date, and
8
+ why toLocaleDateString, toLocaleString and Intl formatters with the
9
+ runtime's defaults must never run in render.
10
+ metadata:
11
+ type: sub-skill
12
+ library: softr-sdk
13
+ library_version: 0.0.58
14
+ ---
15
+
16
+ # Dates, times and numbers
17
+
18
+ Softr stores every date and time as UTC. The builder chooses in Studio how the app shows dates, times and numbers: in each visitor's own format and zone (the default), or in one fixed format or zone for everyone. The SDK resolves that choice once per request. The server and the browser then format with the same values, so the page the server renders and the page the browser hydrates show the same text.
19
+
20
+ > **CRITICAL**: Never call `toLocaleDateString`, `toLocaleTimeString`, `toLocaleString` or `new Intl.DateTimeFormat` / `new Intl.NumberFormat` in code that renders. The server and the browser have different locales and time zones, so the text differs and React throws a hydration mismatch. A hard-coded `'en-US'` or `timeZone: 'UTC'` hides the error but ignores the builder's settings and shows the wrong format and day to most visitors.
21
+ > **CRITICAL**: Format with the helpers below and nothing else. They follow the app's settings, and they are the only formatters that give the same text on the server and in the browser.
22
+
23
+ ## Setup
24
+
25
+ ```ts
26
+ import { FormattedDate, FormattingScope, useFormatting, formatDate, formatDateTime, formatNumber } from '@softr/sdk'
27
+ import type { Formatting, DateValue, DateOptions } from '@softr/sdk'
28
+ ```
29
+
30
+ ```ts
31
+ type DateValue = Date | number | string // a record's date field works as is
32
+
33
+ type Formatting = {
34
+ dateLocale: string
35
+ numberLocale: string
36
+ timeZone: string | null // null until the visitor's zone is known
37
+ fixedDate: { format: 'FRIENDLY' | 'DD-MM-YYYY' | 'MM-DD-YYYY' | 'YYYY-MM-DD'; separator: '-' | '/' | '.'; hour12: boolean } | null
38
+ weekStart: 1 | 2 | 3 | 4 | 5 | 6 | 7 | null // 1 is Monday; null means the locale's own
39
+ language: string
40
+ rtl: boolean
41
+ }
42
+
43
+ type DateOptions = {
44
+ dateStyle?: 'short' | 'medium' | 'long' | 'full'
45
+ parts?: { weekday?, year?, month?, day?, hour?, minute?, timeZoneName? } // Intl.DateTimeFormat options
46
+ timeZone?: string // IANA, such as 'America/New_York'
47
+ }
48
+ ```
49
+
50
+ ## Core Patterns
51
+
52
+ ### A date in JSX: `<FormattedDate />`
53
+
54
+ ```tsx
55
+ <FormattedDate value={bug.fields.created} />
56
+ <FormattedDate value={event.fields.starts} time />
57
+ <FormattedDate value={task.fields.due} dateStyle="medium" />
58
+ ```
59
+
60
+ A timestamp (`DATETIME`, `CREATED_AT`, `UPDATED_AT`) is a UTC instant and needs a time zone. On a visitor's first page load their zone can be unknown, and then `FormattedDate` renders its `fallback` (nothing by default) until the browser supplies it. A calendar day (`DATE`, such as `2026-10-06`) names the same day everywhere and always renders at once. `dateStyle` applies only when the app uses the visitor's format; a fixed format ignores it.
61
+
62
+ ### Part of a date: `parts`
63
+
64
+ A list already filtered to this year needs only the month. A schedule needs only the time. `parts` takes the `Intl.DateTimeFormat` fields and wins over `dateStyle` and the app's fixed format. The locale and the zone still come from the app.
65
+
66
+ ```tsx
67
+ <FormattedDate value={post.fields.published} parts={{ month: 'long', year: 'numeric' }} /> // October 2026
68
+ <FormattedDate value={shift.fields.starts} parts={{ weekday: 'long' }} /> // Monday
69
+ <FormattedDate value={shift.fields.starts} parts={{ hour: 'numeric', minute: 'numeric' }} /> // 14:30
70
+ ```
71
+
72
+ ### A zone or a locale the user names
73
+
74
+ "Show this page in New York time" or "use German formatting here" is a fixed value. It wins over the visitor's and the app's settings. A date in a named zone renders on the server at once, with no fallback.
75
+
76
+ For a page or an area, wrap it in `FormattingScope`. Every `FormattedDate` and `useFormatting()` inside reads the scope. An inner scope wins over an outer one.
77
+
78
+ ```tsx
79
+ <FormattingScope timeZone="America/New_York">
80
+ <EventList events={events} />
81
+ </FormattingScope>
82
+ ```
83
+
84
+ For one value, pass `timeZone`. It wins over a scope. Use it for an event that shows in its own zone, from a record field or a DATETIME field whose `options` in the datasource schema name a `timezone` and a `timezoneType` other than `LOCAL`.
85
+
86
+ ```tsx
87
+ <FormattedDate value={event.fields.starts} time timeZone={event.fields.zone} />
88
+ ```
89
+
90
+ Say which zone a fixed-zone time is in, so a visitor elsewhere does not read it as theirs: add `timeZoneName: 'short'` to `parts`, or write the zone next to it. An unknown zone or locale is ignored with a warning, so a bad record never breaks the page.
91
+
92
+ ### Text outside JSX: `useFormatting()` and the format functions
93
+
94
+ Use these for a string prop, an `aria-label`, a chart axis, or a sort key shown to the visitor.
95
+
96
+ ```tsx
97
+ function BugRow({ bug }: { bug: Bug }) {
98
+ const formatting = useFormatting()
99
+ return (
100
+ <li title={`Created ${formatDateTime(bug.fields.created, formatting)}`}>
101
+ {bug.fields.title}
102
+ <span>{formatNumber(bug.fields.votes, formatting)}</span>
103
+ </li>
104
+ )
105
+ }
106
+ ```
107
+
108
+ `formatNumber` takes the `Intl.NumberFormat` options, for percentages and currencies:
109
+
110
+ ```ts
111
+ formatNumber(0.25, formatting, { style: 'percent' })
112
+ formatNumber(99, formatting, { style: 'currency', currency: 'EUR' })
113
+ ```
114
+
115
+ The currency comes from the field or from the user, never from the locale. A German visitor looking at a price in dollars still sees dollars.
116
+
117
+ `formatDate` and `formatDateTime` show a timestamp in UTC while `formatting.timeZone` is `null`. In JSX, prefer `<FormattedDate />`, which waits instead.
118
+
119
+ ### Outside render: a server function or `beforeLoad`
120
+
121
+ Not every date ends up in a component. A CSV or spreadsheet export, a text the server sends, or a value a loader prepares are formatted before React runs. There, `context.formatting` holds the same `Formatting` that `useFormatting()` returns. See [current-user](../current-user/SKILL.md) for how `context` reaches a handler.
122
+
123
+ ```ts
124
+ import { createServerFn } from '@tanstack/react-start'
125
+ import { formatDateTime } from '@softr/sdk'
126
+
127
+ const exportBugs = createServerFn().handler(async ({ context }) => {
128
+ const bugs = await getBugs()
129
+ return bugs.map((bug) => `${bug.fields.title},${formatDateTime(bug.fields.created, context.formatting)}`).join('\n')
130
+ })
131
+ ```
132
+
133
+ On a visitor's first page load, `context.formatting.timeZone` is `null`, so a timestamp reads in UTC. Only the browser can fill in the zone.
134
+
135
+ ### A range, or any other `Intl` format
136
+
137
+ For a shape the helpers do not give, such as a range (`Oct 6 – 8`), use `Intl` with the values from `useFormatting()` and never the runtime's defaults. Render it only when the zone is known:
138
+
139
+ ```tsx
140
+ const formatting = useFormatting()
141
+ const range = formatting.timeZone === null
142
+ ? null
143
+ : new Intl.DateTimeFormat(formatting.dateLocale, { month: 'short', day: 'numeric', timeZone: formatting.timeZone })
144
+ .formatRange(new Date(trip.fields.starts), new Date(trip.fields.ends))
145
+ ```
146
+
147
+ ### Relative time and now
148
+
149
+ "3 hours ago" and the current time depend on the moment of rendering, which differs between the server and the browser. Render them only after mount, with `Intl.RelativeTimeFormat` and `formatting.dateLocale`.
150
+
151
+ ### Writing a date back
152
+
153
+ A write takes an ISO string, never a `Date`. A timestamp is written as a UTC instant (`toISOString()`), a day as `YYYY-MM-DD`. See [record-mutations](../record-mutations/SKILL.md).
154
+
155
+ ### A calendar's first day
156
+
157
+ Pass `formatting.weekStart` to a date picker or calendar when it is not `null`. Most take Sunday as `0`: use `formatting.weekStart % 7`.
158
+
159
+ ## Common Mistakes
160
+
161
+ ### CRITICAL: Formatting with the runtime's locale in render
162
+
163
+ Wrong:
164
+
165
+ ```tsx
166
+ <span>{new Date(bug.fields.created).toLocaleDateString()}</span>
167
+ ```
168
+
169
+ Correct:
170
+
171
+ ```tsx
172
+ <FormattedDate value={bug.fields.created} />
173
+ ```
174
+
175
+ The server renders `10/6/2026` and a browser in Europe renders `06/10/2026`. React rejects the page, and the builder's date settings are ignored on both sides.
176
+
177
+ ### HIGH: Forcing one locale or zone to silence the mismatch
178
+
179
+ Wrong:
180
+
181
+ ```tsx
182
+ new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeZone: 'UTC' }).format(new Date(value))
183
+ ```
184
+
185
+ Correct:
186
+
187
+ ```tsx
188
+ <FormattedDate value={value} dateStyle="medium" />
189
+ ```
190
+
191
+ The mismatch goes away, but every visitor sees US dates in UTC. A timestamp late in the evening shows the next day. When the user names a zone or a locale, pass it with `timeZone` or `FormattingScope`.
192
+
193
+ ### HIGH: Raw `Intl` while the zone is unknown
194
+
195
+ Wrong:
196
+
197
+ ```tsx
198
+ const formatting = useFormatting()
199
+ new Intl.DateTimeFormat(formatting.dateLocale, { timeZone: formatting.timeZone ?? undefined }).format(new Date(value))
200
+ ```
201
+
202
+ Correct:
203
+
204
+ ```tsx
205
+ <FormattedDate value={value} />
206
+ ```
207
+
208
+ On a first visit `formatting.timeZone` is `null`. `undefined` makes the server use its own zone and the browser use the visitor's, so the text differs and hydration fails. Use the helpers, or render nothing until the zone is known.
209
+
210
+ ## References
211
+
212
+ - [SKILL.md](../SKILL.md): the entrypoints and which one each export lives on