From 6ba7c5210064a36330cbbdf63bfc2eca40e1f74c Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:00:45 +1200 Subject: [PATCH 1/2] Add UnixTimestamp component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Epoch numbers (seconds or milliseconds) rendered as a readable date, reusing the swappable DatePicker widget for editing. - epoch.ts: plausible-epoch band (1990-2100). isPlausibleEpoch doubles as the guard and the seconds-vs-ms detector (non-overlapping bands); epochToDate/dateToEpoch round-trip preserving the unit. - component.tsx: edits via the injected DatePicker widget; view is displayAs:'number' (default — originalNode + a UNIX badge, the ErrorIndicator pattern) or 'date' (formatted). unit defaults to 'auto'. - definition.ts: guard = epoch band; the factory wrapper flips showOnEdit:true only when a DatePicker widget is supplied (else core's number editor edits), and drops passOriginalNode in 'date' mode. Value stays a plain JSON number, so no serialization plumbing. README documents the condition-vs-guard split — the first component whose guard is a pure heuristic (safe to replace) rather than a safety contract. Demo wires two fields (seconds + ms) under "Date & Time" plus a "Show Unix as raw number?" toggle, with calendar editing. Also includes a small z-index fix on the ReactDatePicker popup. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/unix-timestamp-component.md | 7 + demo/src/demoData/data.tsx | 3 + demo/src/demoData/dataDefinitions.tsx | 12 ++ packages/components/README.md | 14 ++ .../src/UnixTimestamp/component.tsx | 120 ++++++++++++++++++ .../src/UnixTimestamp/definition.ts | 71 +++++++++++ .../components/src/UnixTimestamp/epoch.ts | 40 ++++++ .../components/src/UnixTimestamp/index.ts | 2 + .../components/src/UnixTimestamp/style.css | 14 ++ packages/components/src/index.ts | 1 + .../src/widgets/ReactDatePicker/style.css | 1 + 11 files changed, 285 insertions(+) create mode 100644 .changeset/unix-timestamp-component.md create mode 100644 packages/components/src/UnixTimestamp/component.tsx create mode 100644 packages/components/src/UnixTimestamp/definition.ts create mode 100644 packages/components/src/UnixTimestamp/epoch.ts create mode 100644 packages/components/src/UnixTimestamp/index.ts create mode 100644 packages/components/src/UnixTimestamp/style.css diff --git a/.changeset/unix-timestamp-component.md b/.changeset/unix-timestamp-component.md new file mode 100644 index 00000000..0c675f48 --- /dev/null +++ b/.changeset/unix-timestamp-component.md @@ -0,0 +1,7 @@ +--- +'@json-edit-react/components': minor +--- + +Add a `UnixTimestamp` component: epoch numbers (seconds or milliseconds) rendered as a readable date, reusing the same swappable `DatePicker` widget for editing. + +The guard matches numbers in a plausible epoch window (years 1990–2100, as seconds or ms) — a heuristic, so target real timestamp fields with a `condition` override (ANDed with the guard) to avoid catching unrelated numbers. The unit defaults to `'auto'` (detected from magnitude, since the seconds and millisecond ranges don't overlap) and is preserved on commit; force it with `componentProps.unit`. The read-only view defaults to `displayAs: 'number'` (the ordinary number node plus a badge, default `'UNIX'`, via `badgeLabel`); `displayAs: 'date'` shows a formatted date with an optional `formatter`. Editing uses the picker passed via `componentProps.DatePicker` (e.g. `ReactDatePicker` from `@json-edit-react/components/widgets`); with none, the standard number editor handles edits. The value stays a plain JSON number throughout. diff --git a/demo/src/demoData/data.tsx b/demo/src/demoData/data.tsx index ac020070..a300e14d 100644 --- a/demo/src/demoData/data.tsx +++ b/demo/src/demoData/data.tsx @@ -37,6 +37,9 @@ const customComponentLibraryData = { 'Date Picker': new Date().toISOString(), 'Date Object': new Date(), 'Show Time in Date?': true, + 'Unix Timestamp (seconds)': Math.floor(Date.now() / 1000), + 'Unix Timestamp (ms)': Date.now(), + 'Show Unix as raw number?': true, // info: 'Inserted in App.tsx', }, diff --git a/demo/src/demoData/dataDefinitions.tsx b/demo/src/demoData/dataDefinitions.tsx index 69a18574..cbaa35bb 100644 --- a/demo/src/demoData/dataDefinitions.tsx +++ b/demo/src/demoData/dataDefinitions.tsx @@ -14,6 +14,7 @@ import { enhancedLinkDefinition, imageDefinition, colorPickerDefinition, + unixTimestampDefinition, } from '@json-edit-react/components' import { ReactDatePicker } from '@json-edit-react/components/widgets' import { @@ -1131,6 +1132,17 @@ export const demoDataDefinitions: Record = { DatePicker: ReactDatePicker, }, }), + unixTimestampDefinition({ + componentProps: { + DatePicker: ReactDatePicker, + showTime: libraryData?.['Date & Time']?.['Show Time in Date?'] ?? false, + // The `unit` defaults to 'auto', so the seconds and millisecond + // fields are each detected by magnitude. + displayAs: (libraryData?.['Date & Time']?.['Show Unix as raw number?'] ?? true) + ? 'number' + : 'date', + }, + }), imageDefinition({ componentProps: { imageStyles: { diff --git a/packages/components/README.md b/packages/components/README.md index f8d712be..8093bac8 100644 --- a/packages/components/README.md +++ b/packages/components/README.md @@ -24,6 +24,7 @@ Each component ships a React component plus a definition factory that produces a | `EnhancedLink` | Object-shaped data with `{text, url}` rendered as a link | | `DatePicker` | ISO date strings, edited via a swappable date-picker widget — pass `ReactDatePicker` (or your own) via `componentProps.DatePicker` | | `DateObject` | JavaScript `Date` objects | +| `UnixTimestamp` | Epoch numbers (seconds or milliseconds) shown as a date, edited via the same swappable picker as `DatePicker` | | `ColorPicker` | Hex/RGB/HSL color strings, edited via `react-colorful` | | `Markdown` | Markdown-formatted strings, rendered via `react-markdown` | | `Image` | Image URLs displayed inline | @@ -86,6 +87,19 @@ datePickerDefinition({ The read-only display defaults to the locale date/time; pass a `formatter: (date: Date) => string` in `componentProps` to customise it independently of the picker. +### `UnixTimestamp` — epoch numbers as dates + +`UnixTimestamp` matches numbers in a plausible epoch window (years 1990–2100, as seconds or milliseconds) and renders them as a date, reusing the same swappable `DatePicker` widget for editing. The unit defaults to `'auto'` (detected from the value's magnitude, since the seconds and millisecond ranges don't overlap) and is preserved on commit; force it with `componentProps.unit` (`'seconds' | 'milliseconds'`). + +The match is a heuristic, so targeting real timestamp fields and avoiding unrelated numbers is up to you. There are two override surfaces, and `UnixTimestamp` is the component where the difference matters most: + +- **`condition`** narrows — it's ANDed with the guard, so a node matches only if it's *both* targeted *and* a plausible epoch: `unixTimestampDefinition({ condition: byKey(/(^|_)(created|updated)(At|_at)?$/i) })` (see `@json-edit-react/utils`). +- **`guard`** replaces the heuristic entirely, making your targeting the sole criterion: `unixTimestampDefinition({ guard: byKey(/(^|_)(created|updated)(At|_at)?$/i) })`. + +Unlike `DatePicker` (whose guard is a safety contract — the date parser would choke on a non-ISO string), `UnixTimestamp`'s guard is *only* a heuristic: any number renders fine as a date, so there's nothing to protect and replacing it is safe. Prefer `guard` when you have many numbers that *look* like epochs but aren't, and want the key alone to decide — it also avoids the band silently rejecting a real timestamp that falls outside 1990–2100 (microsecond/nanosecond epochs, historical or far-future dates), which the ANDed `condition` would. + +The read-only view defaults to `displayAs: 'number'` — the ordinary number node with a small badge (default `'UNIX'`, set via `badgeLabel`) marking it as a timestamp. Set `displayAs: 'date'` for a formatted date instead (with the same optional `formatter`). Editing uses the widget when one is passed via `componentProps.DatePicker`; with none, the standard number editor handles edits. + ### `ErrorIndicator` — flag nodes with a glyph Unlike the other components, `ErrorIndicator` has no intrinsic value type: it wraps a value (leaf) node and adds a glyph beside whichever nodes you point it at via `condition`. It pairs naturally with `useValidationState` from `@json-edit-react/utils` to mark invalid nodes. diff --git a/packages/components/src/UnixTimestamp/component.tsx b/packages/components/src/UnixTimestamp/component.tsx new file mode 100644 index 00000000..eadfa177 --- /dev/null +++ b/packages/components/src/UnixTimestamp/component.tsx @@ -0,0 +1,120 @@ +/** + * Renders Unix-epoch numbers (seconds or milliseconds) as a readable date. + * + * Editing reuses the same swappable picker as `DatePicker`: pass `ReactDatePicker` + * (or any `DatePickerWidgetProps` component) via `componentProps.DatePicker`. + * The definition enables editing-mode rendering only when a widget is supplied; + * with none, the node's standard number editor handles edits (see + * `definition.ts`). + * + * The read-only view has two modes (`componentProps.displayAs`): + * - `'number'` (default): the standard number node (`originalNode`) plus a + * small badge (default "UNIX") marking it as a timestamp — uses the + * `ErrorIndicator` decorator pattern. + * - `'date'`: a formatted date (locale, or a custom `formatter`). + */ + +import React from 'react' +import { type CustomComponentProps } from 'json-edit-react' +import { type DatePickerWidgetProps } from '../_common/DatePickerWidget' +import { epochToDate, dateToEpoch, type UnixTimeUnit } from './epoch' +import './style.css' + +export interface UnixTimestampCustomProps { + // The picker rendered while editing. The definition enables edit rendering + // only when this is supplied; otherwise core's standard number editor edits. + DatePicker?: React.ComponentType + showTime?: boolean + // Whether stored values are epoch seconds or milliseconds. `'auto'` (default) + // detects per value from its magnitude. + unit?: UnixTimeUnit + // Read-only display mode. `'number'` (default) shows the raw number with a + // badge; `'date'` shows a formatted date. + displayAs?: 'date' | 'number' + // Badge text for the `'number'` display mode. Default `'UNIX'`. + badgeLabel?: React.ReactNode + // Customises the `'date'` display. Defaults to locale date/time per showTime. + formatter?: (date: Date) => string +} + +export const UnixTimestamp = (props: CustomComponentProps) => { + const { + value, + setValue, + handleEdit, + handleCancel, + onKeyDown, + isEditing, + setIsEditing, + getStyles, + nodeData, + canEdit, + originalNode, + componentProps, + } = props + + const { + DatePicker, + showTime = true, + unit = 'auto', + displayAs = 'number', + badgeLabel = 'UNIX', + formatter, + } = componentProps ?? {} + + const numericValue = typeof value === 'number' ? value : Number(value) + + // Editing only reaches this component when a widget is supplied (the + // definition sets `showOnEdit` accordingly); without one, core's number + // editor handles edits and this branch never runs. + if (isEditing && DatePicker) { + const date = epochToDate(numericValue, unit) + return ( + newDate && setValue(dateToEpoch(newDate, unit, numericValue))} + onConfirm={(newDate) => + handleEdit(newDate instanceof Date ? dateToEpoch(newDate, unit, numericValue) : undefined) + } + onCancel={handleCancel} + onKeyDown={onKeyDown} + /> + ) + } + + // View mode — `'number'`: the standard number node plus a UNIX badge. + if (displayAs === 'number') + return ( + + {originalNode} + {badgeLabel} + + ) + + // View mode — `'date'`: a formatted date (raw value if it doesn't parse). + const date = epochToDate(numericValue, unit) + const isValidDate = !isNaN(date.getTime()) + const displayValue = !isValidDate + ? String(value) + : formatter + ? formatter(date) + : showTime + ? date.toLocaleString() + : date.toLocaleDateString() + + return ( +
canEdit && setIsEditing(true)} + className="jer-value-string" + style={getStyles('string', nodeData)} + > + {displayValue} +
+ ) +} diff --git a/packages/components/src/UnixTimestamp/definition.ts b/packages/components/src/UnixTimestamp/definition.ts new file mode 100644 index 00000000..71f24883 --- /dev/null +++ b/packages/components/src/UnixTimestamp/definition.ts @@ -0,0 +1,71 @@ +/** + * A Unix-timestamp component: epoch numbers (seconds or milliseconds) shown as + * a readable date, edited via a swappable date-picker widget. Database dumps + * and API responses are full of them. + */ + +import { type CustomNodeDefinition } from 'json-edit-react' +import { + createDefinitionFactory, + type DefinitionOverrides, +} from '../_common/createDefinitionFactory' +import { UnixTimestamp, type UnixTimestampCustomProps } from './component' +import { isPlausibleEpoch } from './epoch' +// Imported here too (as in DatePicker / ErrorIndicator) so `sideEffects: false` +// can't tree-shake the badge styles out for consumers who only reach the +// factory. +import './style.css' + +const UnixTimestampDefinition: CustomNodeDefinition = { + // The condition doubles as the guard: a number in the plausible epoch window + // (1990–2100), as seconds or ms. It's a heuristic — consumer `condition` + // overrides are targeting, ANDed with this, so narrow by key (`createdAt`, + // `updatedAt`, …) to avoid matching unrelated numbers. Replacing the guard + // needs the explicit `guard` override. + condition: ({ value }) => isPlausibleEpoch(value), + component: UnixTimestamp, + showOnView: true, + // Defaults: a view decorator. `showOnEdit` and `passOriginalNode` are set per + // the consumer's componentProps by the factory below. + showOnEdit: false, + passOriginalNode: true, + name: 'Unix Timestamp', // shown in the Type selector menu + showInTypeSelector: true, + editOnTypeSwitch: true, + // Seed a new value as the current time. Seconds is the common "unix + // timestamp" meaning; a forced ms unit re-seeds via `fromStandardType`. + defaultValue: Math.floor(Date.now() / 1000), + // Coerce the committed buffer to a number (the standard number editor already + // yields one; a type-switch may hand us anything). Unparseable input seeds + // 'now' in the configured unit. + fromStandardType: (value, _, componentProps) => { + const num = typeof value === 'number' ? value : Number(value) + if (Number.isFinite(num)) return num + const now = Date.now() + return componentProps?.unit === 'milliseconds' ? now : Math.floor(now / 1000) + }, + componentProps: { showTime: true }, +} + +const baseFactory = createDefinitionFactory(UnixTimestampDefinition) + +/** + * Build a Unix-timestamp `CustomNodeDefinition`. Pass `ReactDatePicker` (from + * `@json-edit-react/components/widgets`) as `componentProps.DatePicker` for + * calendar editing; without a widget, editing falls back to the standard number + * editor. `componentProps.displayAs` (`'number'` default | `'date'`) chooses the + * read-only view. + */ +export const unixTimestampDefinition = ( + overrides: DefinitionOverrides = {} +): CustomNodeDefinition => { + const definition = baseFactory(overrides) + // A DatePicker widget enables custom editing; without one, defer to core's + // standard number editor (`showOnEdit` stays false). + if (overrides.componentProps?.DatePicker) definition.showOnEdit = true + // `originalNode` is only needed for the `'number'` badge view; skip computing + // it in `'date'` mode. + if ((overrides.componentProps?.displayAs ?? 'number') !== 'number') + definition.passOriginalNode = false + return definition +} diff --git a/packages/components/src/UnixTimestamp/epoch.ts b/packages/components/src/UnixTimestamp/epoch.ts new file mode 100644 index 00000000..14f23ea5 --- /dev/null +++ b/packages/components/src/UnixTimestamp/epoch.ts @@ -0,0 +1,40 @@ +/** + * Epoch heuristics for the Unix-timestamp component. + * + * Plausible window: 1990-01-01 to 2100-01-01. The seconds and millisecond + * bands don't overlap, so a single magnitude check serves two jobs: it's the + * component's guard (keeping it off unrelated numbers like prices or IDs) and + * it tells a seconds-epoch from a millisecond-epoch for the `'auto'` unit. + */ + +export type UnixTimeUnit = 'auto' | 'seconds' | 'milliseconds' + +const SECONDS_MIN = 631152000 // 1990-01-01T00:00:00Z, in seconds +const SECONDS_MAX = 4102444800 // 2100-01-01T00:00:00Z, in seconds +const MS_MIN = SECONDS_MIN * 1000 +const MS_MAX = SECONDS_MAX * 1000 + +export const isSecondsEpoch = (value: number) => value >= SECONDS_MIN && value <= SECONDS_MAX + +export const isMillisecondsEpoch = (value: number) => value >= MS_MIN && value <= MS_MAX + +// Doubles as the component's guard — see file header. +export const isPlausibleEpoch = (value: unknown): value is number => + typeof value === 'number' && + Number.isFinite(value) && + (isSecondsEpoch(value) || isMillisecondsEpoch(value)) + +// Resolve `'auto'` to a concrete unit from the value's magnitude; a forced unit +// wins. Outside both bands (only reachable via a forced or garbage value), +// `'auto'` assumes milliseconds (JS-native). +export const resolveUnit = (value: number, unit: UnixTimeUnit): 'seconds' | 'milliseconds' => + unit === 'auto' ? (isSecondsEpoch(value) ? 'seconds' : 'milliseconds') : unit + +export const epochToDate = (value: number, unit: UnixTimeUnit): Date => + new Date(resolveUnit(value, unit) === 'seconds' ? value * 1000 : value) + +// Serialise a picked Date back to an epoch number, preserving the unit. The +// `reference` value (the one being edited) resolves the `'auto'` unit, so a +// commit keeps the field in whatever unit it started in. +export const dateToEpoch = (date: Date, unit: UnixTimeUnit, reference: number): number => + resolveUnit(reference, unit) === 'seconds' ? Math.round(date.getTime() / 1000) : date.getTime() diff --git a/packages/components/src/UnixTimestamp/index.ts b/packages/components/src/UnixTimestamp/index.ts new file mode 100644 index 00000000..618b7d06 --- /dev/null +++ b/packages/components/src/UnixTimestamp/index.ts @@ -0,0 +1,2 @@ +export * from './definition' +export * from './component' diff --git a/packages/components/src/UnixTimestamp/style.css b/packages/components/src/UnixTimestamp/style.css new file mode 100644 index 00000000..0f3f2664 --- /dev/null +++ b/packages/components/src/UnixTimestamp/style.css @@ -0,0 +1,14 @@ +/* Small "UNIX" badge shown after the number in the `displayAs: 'number'` view, + distinguishing a timestamp from an ordinary number. */ +.jer-unix-badge { + font-size: 0.7em; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + padding: 0.3em 0.45em; + border-radius: 0.4em; + background: rgba(127, 127, 127, 0.18); + opacity: 0.75; + vertical-align: middle; + user-select: none; +} diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 58537ac9..65ba7fc0 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -3,6 +3,7 @@ export * from './EnhancedLink' export * from './DateObject' export * from './Undefined' export * from './DatePicker' +export * from './UnixTimestamp' export * from './BooleanToggle' export * from './NaN' export * from './Symbol' diff --git a/packages/components/src/widgets/ReactDatePicker/style.css b/packages/components/src/widgets/ReactDatePicker/style.css index 9a7fe126..3a60295d 100644 --- a/packages/components/src/widgets/ReactDatePicker/style.css +++ b/packages/components/src/widgets/ReactDatePicker/style.css @@ -6,6 +6,7 @@ box-shadow: rgba(0, 0, 0, 0.1) 0px 10px 15px -3px, rgba(0, 0, 0, 0.05) 0px 4px 6px -2px; + z-index: 100; } .react-datepicker { From efa234767383636d08674d467e2bc8b5a119ef01 Mon Sep 17 00:00:00 2001 From: Carl Smith <5456533+CarlosNZ@users.noreply.github.com> Date: Thu, 18 Jun 2026 01:02:45 +1200 Subject: [PATCH 2/2] UnixTimestamp: badge double-click-to-edit, styling, demo intro - Double-clicking the UNIX badge enters edit mode (parity with the number node beside it). - Badge background tweak. - List "UNIX Timestamp" in the demo Custom Component Library intro. Co-Authored-By: Claude Opus 4.8 (1M context) --- demo/src/demoData/data.tsx | 3 ++- packages/components/src/UnixTimestamp/component.tsx | 4 +++- packages/components/src/UnixTimestamp/style.css | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/demo/src/demoData/data.tsx b/demo/src/demoData/data.tsx index a300e14d..93c8e951 100644 --- a/demo/src/demoData/data.tsx +++ b/demo/src/demoData/data.tsx @@ -9,11 +9,12 @@ const customComponentLibraryData = { ### Components available: - Hyperlink + - "Enhanced" link - DatePicker - DateObject + - UNIX Timestamp - Undefined - Markdown - - "Enhanced" link - BigInt - BooleanToggle - NaN diff --git a/packages/components/src/UnixTimestamp/component.tsx b/packages/components/src/UnixTimestamp/component.tsx index eadfa177..b359ecba 100644 --- a/packages/components/src/UnixTimestamp/component.tsx +++ b/packages/components/src/UnixTimestamp/component.tsx @@ -91,7 +91,9 @@ export const UnixTimestamp = (props: CustomComponentProps {originalNode} - {badgeLabel} + canEdit && setIsEditing(true)}> + {badgeLabel} + ) diff --git a/packages/components/src/UnixTimestamp/style.css b/packages/components/src/UnixTimestamp/style.css index 0f3f2664..8deed5db 100644 --- a/packages/components/src/UnixTimestamp/style.css +++ b/packages/components/src/UnixTimestamp/style.css @@ -7,7 +7,7 @@ text-transform: uppercase; padding: 0.3em 0.45em; border-radius: 0.4em; - background: rgba(127, 127, 127, 0.18); + background: rgba(222, 219, 28, 0.286); opacity: 0.75; vertical-align: middle; user-select: none;