@@ -43,6 +43,105 @@ const passwordWrapper = defineComponent({
4343 },
4444});
4545
46+ type DistanceFormatOption = {
47+ distance: true ;
48+ addSuffix? : boolean ;
49+ includeSeconds? : boolean ;
50+ };
51+
52+ function isDistanceFormat(
53+ formatOption : DisplayOptions [" format" ] | undefined ,
54+ ): formatOption is DistanceFormatOption {
55+ return (
56+ !! formatOption &&
57+ typeof formatOption == " object" &&
58+ " distance" in formatOption
59+ );
60+ }
61+
62+ /** Determine how long until the rendered distance string (e.g. "5 minutes ago")
63+ * would next change, so we can schedule a refresh exactly when it's needed
64+ * rather than on a fixed interval.
65+ *
66+ * Binary-searches the change boundary using `formatDistance` as a black box,
67+ * which keeps us decoupled from date-fns' internal thresholds and lands the
68+ * refresh exactly on the boundary (down to 1s) instead of overshooting.
69+ *
70+ * Capped at one hour: in the minutes/hours range, distance buckets change at
71+ * most once per hour (e.g. "about 3 hours" -> "about 4 hours" boundaries are
72+ * an hour apart), so an hourly cap still catches those right on time. For
73+ * day/month/year distances the cap just means a far-off date re-renders hourly
74+ * and usually produces the same string - a negligible cost that's well worth
75+ * the much smaller search range. */
76+ function getDistanceRefreshDelayMs(
77+ parsed : Date ,
78+ formatOption : DistanceFormatOption ,
79+ ): number {
80+ const opts = {
81+ addSuffix: formatOption .addSuffix ?? true ,
82+ includeSeconds: formatOption .includeSeconds ?? false ,
83+ };
84+
85+ const now = Date .now ();
86+ const current = formatDistance (parsed , new Date (now ), opts );
87+
88+ const maxMs = 3_600_000 ; // 1 hour
89+ if (formatDistance (parsed , new Date (now + maxMs ), opts ) === current ) {
90+ return maxMs ;
91+ }
92+
93+ // Smallest delay (>= 1s) at which the formatted string changes.
94+ let lo = 1000 ;
95+ let hi = maxMs ;
96+ while (lo < hi ) {
97+ const mid = Math .floor ((lo + hi ) / 2 );
98+ if (formatDistance (parsed , new Date (now + mid ), opts ) !== current ) {
99+ hi = mid ;
100+ } else {
101+ lo = mid + 1 ;
102+ }
103+ }
104+ return lo ;
105+ }
106+
107+ /** Wrapper that re-renders a date distance display on an adaptive schedule so
108+ * that relative time text stays current without requiring callers to manually
109+ * force re-renders. Only instantiated for date values formatted with
110+ * `{ distance: true }`, so non-distance displays incur no timer overhead. */
111+ const distanceWrapper = defineComponent ({
112+ name: " CDistanceDisplay" ,
113+ props: {
114+ element: { type: String , default: " span" },
115+ value: { type: Date , required: true },
116+ meta: { type: Object as PropType <DateValue >, required: true },
117+ options: { type: Object as PropType <DisplayOptions >, default: undefined },
118+ },
119+ setup(props , { attrs , slots }) {
120+ const refreshTick = ref (0 );
121+
122+ watchEffect ((onCleanup ) => {
123+ // Re-arm the timer whenever a tick fires or the inputs change.
124+ void refreshTick .value ;
125+ const formatOption = props .options ?.format ;
126+ if (! isDistanceFormat (formatOption ) || isNaN (props .value .getTime ())) {
127+ return ;
128+ }
129+
130+ const delayMs = getDistanceRefreshDelayMs (props .value , formatOption );
131+ const timeout = setTimeout (() => {
132+ refreshTick .value += 1 ;
133+ }, delayMs );
134+ onCleanup (() => clearTimeout (timeout ));
135+ });
136+
137+ return () => {
138+ void refreshTick .value ;
139+ const valueString = valueDisplay (props .value , props .meta , props .options );
140+ return h (props .element , attrs , valueString || slots );
141+ };
142+ },
143+ });
144+
46145export type CDisplayProps <TModel extends Model | AnyArgCaller | undefined > = {
47146 /** An object owning the value to be edited that is specified by the `for` prop. */
48147 model? : TModel | null ;
@@ -74,17 +173,25 @@ export type CDisplayProps<TModel extends Model | AnyArgCaller | undefined> = {
74173 setup
75174 generic =" TModel extends Model | AnyArgCaller | undefined "
76175>
77- import { defineComponent , h , mergeProps } from " vue" ;
176+ import {
177+ defineComponent ,
178+ h ,
179+ mergeProps ,
180+ useAttrs ,
181+ useSlots ,
182+ ref ,
183+ watchEffect ,
184+ } from " vue" ;
185+ import { formatDistance } from " date-fns" ;
78186import { type ForSpec , useMetadataProps } from " ../c-metadata-component" ;
187+ import type { PropType } from " vue" ;
79188import { valueDisplay } from " coalesce-vue" ;
80189import type {
81190 DisplayOptions ,
82191 DateValue ,
83192 Model ,
84193 AnyArgCaller ,
85194} from " coalesce-vue" ;
86- import { useAttrs } from " vue" ;
87- import { useSlots } from " vue" ;
88195
89196const props = withDefaults (defineProps <CDisplayProps <TModel >>(), {
90197 element: " span" ,
@@ -105,15 +212,12 @@ const attrs = useAttrs();
105212defineSlots (); // Empty defineSlots() prevents TS errors for passthrough slots.
106213const slots = useSlots ();
107214
108- function render () {
215+ function resolveDisplayValue () {
109216 const model = props .model ;
110217 let valueProp = props .modelValue ?? props .value ;
111218
112219 if (model == null && valueProp == null ) {
113- // If no model and no value were provided, just display nothing.
114- // This isn't an error case - it just means the thing we're trying to display
115- // is `null`-ish, and should be treated the same way that vue would treat {{null}}
116- return h (props .element );
220+ return null ;
117221 }
118222
119223 const modelMeta = modelMetaRef .value ;
@@ -143,6 +247,37 @@ function render() {
143247 }
144248
145249 valueProp ?? = (valueOwner .value as any )[meta .name ];
250+
251+ return { meta , options , valueProp };
252+ }
253+
254+ function render() {
255+ const resolved = resolveDisplayValue ();
256+ if (resolved == null ) {
257+ // If no model and no value were provided, just display nothing.
258+ // This isn't an error case - it just means the thing we're trying to display
259+ // is `null`-ish, and should be treated the same way that vue would treat {{null}}
260+ return h (props .element );
261+ }
262+
263+ const { meta, valueProp, options } = resolved ;
264+
265+ if (
266+ meta .type === " date" &&
267+ isDistanceFormat (options ?.format ) &&
268+ valueProp instanceof Date &&
269+ ! isNaN (valueProp .getTime ())
270+ ) {
271+ // Delegate to a wrapper that keeps the relative time text up to date.
272+ return h (distanceWrapper , {
273+ ... attrs ,
274+ element: props .element ,
275+ value: valueProp ,
276+ meta ,
277+ options ,
278+ });
279+ }
280+
146281 let valueString = valueDisplay (valueProp , meta , options );
147282
148283 if (meta .type === " string" && valueString ) {
0 commit comments