@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.
- package/dist/auth/context.d.ts +8 -4
- package/dist/auth/isomorphic.d.ts +8 -5
- package/dist/auth/isomorphic.js +13 -8
- package/dist/datasource/chart.d.ts +1 -1
- package/dist/default-error-overlay.d.ts +2 -2
- package/dist/formatting.d.ts +120 -0
- package/dist/formatting.js +379 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/package.js +1 -1
- package/dist/pwa.d.ts +2 -2
- package/package.json +1 -1
- package/skills/softr-sdk/SKILL.md +6 -2
- package/skills/softr-sdk/formatting/SKILL.md +212 -0
package/dist/auth/context.d.ts
CHANGED
|
@@ -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
|
|
14
|
-
* session,
|
|
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 {
|
|
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 {
|
|
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
|
|
10
|
-
* `getSoftrContext` and the route guards have something
|
|
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
|
|
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<
|
|
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
|
*
|
package/dist/auth/isomorphic.js
CHANGED
|
@@ -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
|
|
14
|
-
* `getSoftrContext` and the route guards have something
|
|
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
|
|
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
|
|
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):
|
|
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
package/dist/pwa.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import * as
|
|
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():
|
|
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
|
@@ -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
|
|
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
|