Skip to content

Commit eda8f72

Browse files
authored
feat: auto-refresh c-display distance formatting (#734)
1 parent c8c1805 commit eda8f72

5 files changed

Lines changed: 174 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
- Added `returnViewModel` prop to `c-select`, enabling ViewModel instances to be returned directly when bound with `for="TypeName"`.
1616
- Added `adminOverrides` option to `createCoalesceVuetify()`, allowing custom Vue components to replace the default input and/or display components used in admin pages (`c-admin-editor`, `c-admin-method`, `c-table`) for specific model properties, method parameters, or method return values.
1717
- `c-datetime-picker`: Assorted UI and UX improvements and fixes.
18+
- `c-display` now auto-refreshes date distance formatting (`format: { distance: true }`) using an adaptive refresh interval based on the displayed distance.
1819
- Added `headerComment` generator configuration option to emit custom comments at the start of all generated files. Supports cascading hierarchical configuration—define on a parent generator and child generators automatically inherit the value.
1920
- `[DefaultOrderBy(Suppress = true)]` can now be placed on a collection navigation property to suppress the default sorting of that collection in the generated response DTO.
2021

docs/stacks/vue/coalesce-vue-vuetify/components/c-display.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ Displaying a standalone date value without a model or other source of metadata:
3636
<c-display :value="dateProp" format="M/d/yyyy" />
3737
```
3838

39+
Displaying relative time that updates automatically as time passes:
40+
41+
``` vue-html
42+
<c-display :value="dateProp" :format="{ distance: true, addSuffix: true }" />
43+
```
44+
3945
For comprehensive information about working with and displaying dates in Coalesce, see [Working with Dates](/topics/working-with-dates.md).
4046

4147
## Props
@@ -80,4 +86,3 @@ For properties and other values annotated with [DataTypeAttribute], the followin
8086
* `DataType.PhoneNumber`: Renders as a clickable `tel` link.
8187
* `DataType.ImageUrl`: Renders as an `img` element.
8288
* `"Color"`: Renders a colored dot next to the value, interpreting the field value as a 7-character HTML hex color code.
83-

src/coalesce-vue-vuetify3/src/components/admin/c-admin-editor.vue

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ import { getRefNavRoute } from "./util";
245245
import { isPropReadOnly } from "../../util";
246246
import { useAdminOverrides } from "../../composables/useAdminOverrides";
247247
import { useAdminExtensions } from "../../composables/useAdminExtensions";
248-
import { watch, computed, useTemplateRef, ref, onUnmounted } from "vue";
248+
import { watch, computed, useTemplateRef, ref } from "vue";
249249
250250
defineOptions({
251251
name: "c-admin-editor",
@@ -291,16 +291,6 @@ watch(
291291
},
292292
);
293293
294-
// Update the display every 10 seconds to keep the "ago" text fresh
295-
const updateInterval = setInterval(() => {
296-
if (lastSavedAt.value) {
297-
// Force a re-render by reassigning the same value
298-
lastSavedAt.value = new Date(lastSavedAt.value);
299-
}
300-
}, 10000);
301-
302-
onUnmounted(() => clearInterval(updateInterval));
303-
304294
function propInputBinds(p: Property) {
305295
const readonly = isPropReadOnly(p, props.model);
306296

src/coalesce-vue-vuetify3/src/components/display/c-display.spec.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,29 @@ describe("CDisplay", () => {
109109
expect(wrapper.text()).toContain("1990-01-02 03:04:05 AM");
110110
});
111111

112+
test(":modelValue date distance format auto-updates", async () => {
113+
vi.useFakeTimers();
114+
try {
115+
vi.setSystemTime(new Date("2024-01-01T00:00:00.000Z"));
116+
const value = new Date("2023-12-31T23:59:50.000Z");
117+
118+
const wrapper = mount(() => (
119+
<CDisplay
120+
modelValue={value}
121+
format={{ distance: true, addSuffix: true, includeSeconds: true }}
122+
/>
123+
));
124+
125+
const initialText = wrapper.text();
126+
await vi.advanceTimersByTimeAsync(11000);
127+
const updatedText = wrapper.text();
128+
129+
expect(updatedText).not.toEqual(initialText);
130+
} finally {
131+
vi.useRealTimers();
132+
}
133+
});
134+
112135
test("password", async () => {
113136
const model = new PersonViewModel({ secretPhrase: "Hunter2" });
114137
const wrapper = mount(() => <CDisplay model={model} for="secretPhrase" />);

src/coalesce-vue-vuetify3/src/components/display/c-display.vue

Lines changed: 143 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
46145
export 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";
78186
import { type ForSpec, useMetadataProps } from "../c-metadata-component";
187+
import type { PropType } from "vue";
79188
import { valueDisplay } from "coalesce-vue";
80189
import type {
81190
DisplayOptions,
82191
DateValue,
83192
Model,
84193
AnyArgCaller,
85194
} from "coalesce-vue";
86-
import { useAttrs } from "vue";
87-
import { useSlots } from "vue";
88195
89196
const props = withDefaults(defineProps<CDisplayProps<TModel>>(), {
90197
element: "span",
@@ -105,15 +212,12 @@ const attrs = useAttrs();
105212
defineSlots(); // Empty defineSlots() prevents TS errors for passthrough slots.
106213
const 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

Comments
 (0)