Label the chart range tabs as the day windows they are

The range selector said points — 7 pts, Last 7 data points — while the
fetch has always treated the value as a calendar-day window. Someone
who weighs in twice a week picked 7 pts and saw two, and read that as
a broken chart; the labels were the broken part. The tabs now say 7d /
30d / 90d / All across all six locales.

Behaviour is unified on the same reading everywhere. The measurement
chart's visible slice becomes a real day-window filter — the old
slice(-N) was a no-op on the self-fetched path but silently meant
last N readings on the dashboard's preloaded batch slice, so the same
tab meant two different things depending on the mount path. The mood
chart sliced the last N logged days out of the whole history and now
windows by days like its siblings. The header chip reads the same
grain decision the chart and the data table use instead of re-deriving
it in a second memo.

Component fixtures move to now-anchored dates: the windows are anchored
on now, so absolute-dated fixtures age out of the window and would have
exercised only the empty states. The span-dependent cases (weekly chip,
bucketed-caption table) mount the All range, which is now the only
range that can span past 90 days.
This commit is contained in:
Marc-André Bombeck committed 2026-08-26 17:55:56 +02:00
1 parent 0823a4e222
commit 240c15ff15
14 files changed
+203 -127

No files matched your search

+8 -8
View File
@@ -2198,14 +2198,14 @@
"noData": "Keine Daten im gewählten Zeitraum",
"bucketWeekly": "Wochendurchschnitt",
"bucketMonthly": "Monatsdurchschnitt",
"points7Label": "7 Pkt",
"points7Title": "Letzte 7 Messpunkte",
"points30Label": "30 Pkt",
"points30Title": "Letzte 30 Messpunkte",
"points90Label": "90 Pkt",
"points90Title": "Letzte 90 Messpunkte",
"pointsAllLabel": "Alle",
"pointsAllTitle": "Alle verfügbaren Messpunkte",
"days7Label": "7T",
"days7Title": "Letzte 7 Tage",
"days30Label": "30T",
"days30Title": "Letzte 30 Tage",
"days90Label": "90T",
"days90Title": "Letzte 90 Tage",
"daysAllLabel": "Alle",
"daysAllTitle": "Gesamter Zeitraum",
"systolic": "Systolisch",
"diastolic": "Diastolisch",
"bodyWater": "Körperwasser",
+8 -8
View File
@@ -2198,14 +2198,14 @@
"noData": "No data in selected time range",
"bucketWeekly": "Weekly avg",
"bucketMonthly": "Monthly avg",
"points7Label": "7 pts",
"points7Title": "Last 7 data points",
"points30Label": "30 pts",
"points30Title": "Last 30 data points",
"points90Label": "90 pts",
"points90Title": "Last 90 data points",
"pointsAllLabel": "All",
"pointsAllTitle": "All available data points",
"days7Label": "7d",
"days7Title": "Last 7 days",
"days30Label": "30d",
"days30Title": "Last 30 days",
"days90Label": "90d",
"days90Title": "Last 90 days",
"daysAllLabel": "All",
"daysAllTitle": "All time",
"systolic": "Systolic",
"diastolic": "Diastolic",
"bodyWater": "Body water",
+8 -8
View File
@@ -2198,14 +2198,14 @@
"noData": "Sin datos en el periodo seleccionado",
"bucketWeekly": "Media semanal",
"bucketMonthly": "Media mensual",
"points7Label": "7 ptos",
"points7Title": "Últimos 7 puntos",
"points30Label": "30 ptos",
"points30Title": "Últimos 30 puntos",
"points90Label": "90 ptos",
"points90Title": "Últimos 90 puntos",
"pointsAllLabel": "Todos",
"pointsAllTitle": "Todos los puntos disponibles",
"days7Label": "7 d",
"days7Title": "Últimos 7 días",
"days30Label": "30 d",
"days30Title": "Últimos 30 días",
"days90Label": "90 d",
"days90Title": "Últimos 90 días",
"daysAllLabel": "Todos",
"daysAllTitle": "Todo el período",
"systolic": "Sistólica",
"diastolic": "Diastólica",
"bodyWater": "Agua corporal",
+8 -8
View File
@@ -2198,14 +2198,14 @@
"noData": "Aucune donnée sur la période sélectionnée",
"bucketWeekly": "Moyenne hebdomadaire",
"bucketMonthly": "Moyenne mensuelle",
"points7Label": "7 pts",
"points7Title": "Derniers 7 points",
"points30Label": "30 pts",
"points30Title": "Derniers 30 points",
"points90Label": "90 pts",
"points90Title": "Derniers 90 points",
"pointsAllLabel": "Tous",
"pointsAllTitle": "Tous les points disponibles",
"days7Label": "7 j",
"days7Title": "7 derniers jours",
"days30Label": "30 j",
"days30Title": "30 derniers jours",
"days90Label": "90 j",
"days90Title": "90 derniers jours",
"daysAllLabel": "Tous",
"daysAllTitle": "Toute la période",
"systolic": "Systolique",
"diastolic": "Diastolique",
"bodyWater": "Eau corporelle",
+8 -8
View File
@@ -2198,14 +2198,14 @@
"noData": "Nessun dato nel periodo selezionato",
"bucketWeekly": "Media settimanale",
"bucketMonthly": "Media mensile",
"points7Label": "7 pti",
"points7Title": "Ultimi 7 punti",
"points30Label": "30 pti",
"points30Title": "Ultimi 30 punti",
"points90Label": "90 pti",
"points90Title": "Ultimi 90 punti",
"pointsAllLabel": "Tutti",
"pointsAllTitle": "Tutti i punti disponibili",
"days7Label": "7 g",
"days7Title": "Ultimi 7 giorni",
"days30Label": "30 g",
"days30Title": "Ultimi 30 giorni",
"days90Label": "90 g",
"days90Title": "Ultimi 90 giorni",
"daysAllLabel": "Tutti",
"daysAllTitle": "Tutto il periodo",
"systolic": "Sistolica",
"diastolic": "Diastolica",
"bodyWater": "Acqua corporea",
+8 -8
View File
@@ -2198,14 +2198,14 @@
"noData": "Brak danych w wybranym okresie",
"bucketWeekly": "Średnia tygodniowa",
"bucketMonthly": "Średnia miesięczna",
"points7Label": "7 pkt",
"points7Title": "Ostatnie 7 punktów",
"points30Label": "30 pkt",
"points30Title": "Ostatnie 30 punktów",
"points90Label": "90 pkt",
"points90Title": "Ostatnie 90 punktów",
"pointsAllLabel": "Wszystkie",
"pointsAllTitle": "Wszystkie dostępne punkty",
"days7Label": "7 dni",
"days7Title": "Ostatnie 7 dni",
"days30Label": "30 dni",
"days30Title": "Ostatnie 30 dni",
"days90Label": "90 dni",
"days90Title": "Ostatnie 90 dni",
"daysAllLabel": "Wszystkie",
"daysAllTitle": "Cały okres",
"systolic": "Skurczowe",
"diastolic": "Rozkurczowe",
"bodyWater": "Woda w organizmie",
@@ -11,11 +11,14 @@ import deMessages from "../../../../messages/de.json";
function buildData(): unknown[] {
const out: Array<{ date: string; timestamp: number; PULSE: number }> = [];
const base = Date.UTC(2026, 0, 1, 12, 0, 0);
// Ten daily points ending half a day before now — the range tabs are
// calendar-day windows anchored on now (v1.37.29), so an absolute-dated
// fixture would age out of the default window and render the empty state.
const base = Date.now() - 12 * 3_600_000;
for (let i = 0; i < 10; i++) {
out.push({
date: `2026-01-${String(i + 1).padStart(2, "0")}`,
timestamp: base + i * 86_400_000,
date: `p${i}`,
timestamp: base - (9 - i) * 86_400_000,
PULSE: 60 + i,
});
}
@@ -11,12 +11,18 @@ import enMessages from "../../../../messages/en.json";
* table's rows can ever diverge, that is the failure this catches.
*/
/** `spacingDays` apart, so a fixed point count can span a chosen range. */
/**
* `spacingDays` apart, ENDING half a day before now. The range tabs are
* calendar-day windows anchored on now (v1.37.29), so an absolute-dated
* fixture would age out of the window and exercise the empty state
* instead of the table. The half-day offset keeps every point clear of
* the exact window-boundary instant.
*/
function series(count: number, spacingDays = 1) {
const base = Date.UTC(2026, 0, 1, 12, 0, 0);
const base = Date.now() - 12 * 3_600_000;
return Array.from({ length: count }, (_, i) => ({
date: `p${i}`,
timestamp: base + i * spacingDays * 86_400_000,
timestamp: base - (count - 1 - i) * spacingDays * 86_400_000,
PULSE: 60 + i,
}));
}
@@ -101,17 +107,23 @@ describe("<HealthChart> — data table", () => {
});
it("honours the visible range rather than the whole fetched series", async () => {
// 40 points fetched, the default range tab draws the newest 30.
// 40 daily points fetched, the default range tab windows to the last
// 30 calendar days — the 30 points inside the window survive, the 10
// older ones do not.
const html = await renderChart(series(40), { showDataTable: true });
expect(rowCount(html)).toBe(30);
expect(html).toContain("Data points (30)");
});
it("captions a bucketed range as an average instead of implying daily readings", async () => {
// 30 points four days apart span 116 days, so the chart buckets to ISO
// weeks and several points share a bucket. The rows are weekly means and
// the caption has to say so.
const html = await renderChart(series(30, 4), { showDataTable: true });
// 30 points four days apart span 116 days on the All range, so the
// chart buckets to ISO weeks and several points share a bucket. The
// rows are weekly means and the caption has to say so. (The 7/30/90
// tabs are day windows now, so only "All" can span past 90 days.)
const html = await renderChart(series(30, 4), {
showDataTable: true,
windowOverride: "allTime",
});
expect(html).toContain("One row per week of");
expect(html).toContain("the average of that week");
expect(html).not.toContain("One row per day of");
@@ -28,6 +28,18 @@ function buildData(
}));
}
/**
* ISO instant `agoDays` before now (noon-offset). The range tabs are
* calendar-day windows anchored on now (v1.37.29), so an absolute-dated
* fixture would age out of the default window and always paint the
* no-data card instead of the sparse states under test.
*/
function daysAgo(agoDays: number): string {
return new Date(
Date.now() - 12 * 3_600_000 - agoDays * 86_400_000,
).toISOString();
}
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
isAuthenticated: true,
@@ -71,8 +83,8 @@ describe("<HealthChart> — sparse-data render contract", () => {
it("renders the chart (not a withholding card) with a sparse caption for two distinct days", async () => {
const html = await renderChart(
buildData([
{ measuredAt: "2026-05-19T09:00:00.000Z", value: 70 },
{ measuredAt: "2026-05-20T09:00:00.000Z", value: 72 },
{ measuredAt: daysAgo(1), value: 70 },
{ measuredAt: daysAgo(0), value: 72 },
]),
);
@@ -87,7 +99,7 @@ describe("<HealthChart> — sparse-data render contract", () => {
it("renders the single marker + sparse caption for one day instead of a bare hint", async () => {
const html = await renderChart(
buildData([{ measuredAt: "2026-05-20T09:00:00.000Z", value: 70 }]),
buildData([{ measuredAt: daysAgo(0), value: 70 }]),
);
// A single point still renders (Recharts paints the marker) and the
@@ -109,9 +121,9 @@ describe("<HealthChart> — sparse-data render contract", () => {
it("omits the sparse caption once three or more distinct days exist", async () => {
const html = await renderChart(
buildData([
{ measuredAt: "2026-05-18T09:00:00.000Z", value: 68 },
{ measuredAt: "2026-05-19T09:00:00.000Z", value: 70 },
{ measuredAt: "2026-05-20T09:00:00.000Z", value: 72 },
{ measuredAt: daysAgo(2), value: 68 },
{ measuredAt: daysAgo(1), value: 70 },
{ measuredAt: daysAgo(0), value: 72 },
]),
);
@@ -152,9 +152,12 @@ describe("<MoodChart> bucket chip", () => {
it("paints the weekly chip when the data spans > 90 days", async () => {
// Defer the import so the vi.mock above takes effect.
const { I18nProvider } = await import("@/lib/i18n/context");
// The 7/30/90 tabs are calendar-day windows anchored on now
// (v1.37.29), so only the All range can hold a span past 90 days —
// which is the state the weekly chip exists for.
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MoodChart />
<MoodChart windowOverride="allTime" />
</I18nProvider>,
);
// The chip text comes from `charts.bucketWeekly` ("Weekly avg").
@@ -23,6 +23,15 @@ vi.mock("@/hooks/use-auth", () => ({
}),
}));
/**
* Day key `agoDays` before now. The range tabs are calendar-day windows
* anchored on now (v1.37.29), so absolute-dated entries would age out of
* the default window and both cases would collapse into the no-data copy.
*/
function dayKeyAgo(agoDays: number): string {
return new Date(Date.now() - agoDays * 86_400_000).toISOString().slice(0, 10);
}
describe("<MoodChart> — empty-state gate by raw count", () => {
beforeEach(() => {
vi.resetModules();
@@ -31,8 +40,8 @@ describe("<MoodChart> — empty-state gate by raw count", () => {
it("renders need-more-days copy when chartData.length < 3 but rawCount >= 3", async () => {
const data = {
entries: [
{ date: "2026-05-19", score: 4, samples: 25 },
{ date: "2026-05-20", score: 5, samples: 25 },
{ date: dayKeyAgo(1), score: 4, samples: 25 },
{ date: dayKeyAgo(0), score: 5, samples: 25 },
],
trend: null,
};
@@ -65,7 +74,7 @@ describe("<MoodChart> — empty-state gate by raw count", () => {
it("renders no-data copy when rawCount < 3", async () => {
const data = {
entries: [{ date: "2026-05-20", score: 4, samples: 1 }],
entries: [{ date: dayKeyAgo(0), score: 4, samples: 1 }],
trend: null,
};
+47 -30
View File
@@ -65,26 +65,33 @@ import {
} from "@/lib/charts/window-stats";
import { shouldFireDataReady } from "@/lib/charts/data-ready-latch";
// The range tabs select a CALENDAR-DAY window ending now — `days: 7` is
// "the last 7 days", not "the last 7 readings". The labels said "points"
// until v1.37.29 while the fetch below always treated the value as a day
// window; a user who weighs in twice a week picked "7 pts" and saw two.
// The persisted preference field keeps its historical name `rangePoints`
// (it crosses the chart-overlay-prefs wire), but its value has always
// been days.
const TIME_RANGES_KEYS = [
{
labelKey: "charts.points7Label",
points: 7,
titleKey: "charts.points7Title",
labelKey: "charts.days7Label",
days: 7,
titleKey: "charts.days7Title",
},
{
labelKey: "charts.points30Label",
points: 30,
titleKey: "charts.points30Title",
labelKey: "charts.days30Label",
days: 30,
titleKey: "charts.days30Title",
},
{
labelKey: "charts.points90Label",
points: 90,
titleKey: "charts.points90Title",
labelKey: "charts.days90Label",
days: 90,
titleKey: "charts.days90Title",
},
{
labelKey: "charts.pointsAllLabel",
points: 0,
titleKey: "charts.pointsAllTitle",
labelKey: "charts.daysAllLabel",
days: 0,
titleKey: "charts.daysAllTitle",
},
] as const;
@@ -738,8 +745,8 @@ export function HealthChart({
const totalDays = windowDays + compareShift;
const from = new Date(to.getTime() - totalDays * 86_400_000);
// Use ISO strings so the cache key is stable across the day; the
// server treats `to` as the upper bound and the chart truncates to
// `rangePoints` on the client.
// server treats `to` as the upper bound and the chart re-applies the
// same day window client-side (see `visibleSlice`).
return {
from: from.toISOString(),
to: to.toISOString(),
@@ -1124,9 +1131,23 @@ export function HealthChart({
// by it, and the data table below the chart captions its rows with it —
// a table that said "daily" over a weekly mean would be a lie, and
// recomputing the decision separately is how that lie starts.
//
// v1.37.29 — the slice is a CALENDAR-DAY window, matching what the
// range tabs promise and what the fetch window requests. The previous
// `slice(-rangePoints)` was a no-op on the self-fetched path (the fetch
// is already bounded to the window) but silently switched to
// "last N readings" on the dashboard's preloaded ~30-day batch slice —
// the same tab meant two different things depending on the mount path.
const visibleSlice = useMemo(() => {
if (!data?.length) return null;
const sliced = rangePoints > 0 ? data.slice(-rangePoints) : [...data];
const cutoff = new Date().getTime() - rangePoints * 86_400_000;
const sliced =
rangePoints > 0
? data.filter((point) => point.timestamp >= cutoff)
: [...data];
// An empty window stays an empty slice (renders as "no data in this
// range") — returning null here would drop chartData back to the
// UNFILTERED series and quietly widen the window instead.
const rangeDays =
sliced.length < 2
? 0
@@ -1226,7 +1247,7 @@ export function HealthChart({
// v1.12.8 — chart-reactive metric statistics.
//
// Compute the per-type Min / Max / Median / Mean over the data currently
// visible under the active range tab — i.e. the `rangePoints`-sliced,
// visible under the active range tab — i.e. the day-window-filtered,
// bucketed `chartData` the chart already holds (no re-fetch). The result
// recomputes whenever the range tab changes the visible slice, so the
// `<MetricStatStrip>` a sub-page lifts the callback into always reflects the
@@ -1330,16 +1351,12 @@ export function HealthChart({
);
}, [chartDataWithCompare, effectiveCompareBaseline, types]);
const activeBucket: ChartBucketType = useMemo(() => {
if (!data?.length) return "day";
const sliced = rangePoints > 0 ? data.slice(-rangePoints) : data;
if (sliced.length < 2) return "day";
const rangeDays = Math.round(
(sliced[sliced.length - 1].timestamp - sliced[0].timestamp) /
(24 * 60 * 60 * 1000),
);
return pickBucket(rangeDays);
}, [data, rangePoints]);
// v1.37.29 — the header chip reads the SAME grain decision `chartData`
// and the data table use. This was a second memo re-deriving the bucket
// from a `slice(-rangePoints)` of its own — exactly the "recomputing
// the decision separately" drift the visibleSlice doc-comment warns
// about, and the two had already diverged on the windowing semantics.
const activeBucket: ChartBucketType = visibleSlice?.bucketType ?? "day";
const yDomain = useMemo<[number, number] | undefined>(() => {
if (!chartDataWithCompare?.length) return undefined;
@@ -1783,8 +1800,8 @@ export function HealthChart({
{TIME_RANGES_KEYS.map((r) => (
<Button
key={r.labelKey}
variant={rangePoints === r.points ? "default" : "ghost"}
aria-pressed={rangePoints === r.points}
variant={rangePoints === r.days ? "default" : "ghost"}
aria-pressed={rangePoints === r.days}
size="sm"
className="min-h-11 px-2 text-xs sm:px-3"
onClick={() => {
@@ -1792,14 +1809,14 @@ export function HealthChart({
// visible-range stats memo recomputes off the new slice and
// the stat strip follows automatically.
setRangeHydrated(true);
setRangePoints(r.points);
setRangePoints(r.days);
// v1.30.1 M2 — persist the pick per chart, same model
// as the overlay toggles, so the range survives a
// remount instead of resetting to 30 d every visit.
if (chartKey && !windowOverride) {
overlayPrefs.setPrefs({
...overlayPrefs.prefs,
rangePoints: r.points,
rangePoints: r.days,
});
}
}}
+13 -10
View File
@@ -3,22 +3,25 @@
*
* The Oura-style RecommendationCard (B5c) embeds a mini-chart that
* pins to the recommendation's `rationale.dataWindow`. This helper
* maps the rationale enum onto the per-points range used by the
* maps the rationale enum onto the calendar-day window used by the
* existing chart wrappers.
*
* last7days → 7 points
* last30days → 30 points
* last90days → 90 points
* allTime → 0 (HealthChart treats 0 as "no slice — show all")
* last7days → 7-day window
* last30days → 30-day window
* last90days → 90-day window
* allTime → 0 (HealthChart treats 0 as "no window — show all")
*
* The values mirror the existing `TIME_RANGES_KEYS` definition in
* `health-chart.tsx`; the helper is a pure function so unit tests
* pin the contract without depending on Recharts internals.
* pin the contract without depending on Recharts internals. The
* exported name keeps the historical `rangePoints` vocabulary because
* it feeds the chart state/preference field of that name (a persisted
* wire field) — the VALUE has always been calendar days.
*/
export type DataWindow = "last7days" | "last30days" | "last90days" | "allTime";
const POINTS_BY_WINDOW: Record<DataWindow, number> = {
const DAYS_BY_WINDOW: Record<DataWindow, number> = {
last7days: 7,
last30days: 30,
last90days: 90,
@@ -26,9 +29,9 @@ const POINTS_BY_WINDOW: Record<DataWindow, number> = {
};
/**
* Returns the per-points range value the chart wrapper expects for a
* given rationale dataWindow enum value.
* Returns the day-window value the chart wrapper expects for a given
* rationale dataWindow enum value.
*/
export function resolveMiniRangePoints(window: DataWindow): number {
return POINTS_BY_WINDOW[window];
return DAYS_BY_WINDOW[window];
}
+35 -18
View File
@@ -106,26 +106,31 @@ interface MoodChartProps {
// --- Constants ---
// The range tabs select a CALENDAR-DAY window ending now, matching
// `health-chart.tsx` (see the note there). Until v1.37.29 this chart
// sliced the last N LOGGED DAYS out of the whole history instead — the
// same "7" tab meant seven calendar days on a weight chart and seven
// logged days (possibly weeks apart) on the mood chart one card over.
const TIME_RANGES_KEYS = [
{
labelKey: "charts.points7Label",
points: 7,
titleKey: "charts.points7Title",
labelKey: "charts.days7Label",
days: 7,
titleKey: "charts.days7Title",
},
{
labelKey: "charts.points30Label",
points: 30,
titleKey: "charts.points30Title",
labelKey: "charts.days30Label",
days: 30,
titleKey: "charts.days30Title",
},
{
labelKey: "charts.points90Label",
points: 90,
titleKey: "charts.points90Title",
labelKey: "charts.days90Label",
days: 90,
titleKey: "charts.days90Title",
},
{
labelKey: "charts.pointsAllLabel",
points: 0,
titleKey: "charts.pointsAllTitle",
labelKey: "charts.daysAllLabel",
days: 0,
titleKey: "charts.daysAllTitle",
},
] as const;
@@ -444,7 +449,13 @@ export function MoodChart({
}))
.sort((a, b) => a.timestamp - b.timestamp);
const sliced = rangePoints > 0 ? allPoints.slice(-rangePoints) : allPoints;
// v1.37.29 — a calendar-day window, not "the last N logged days":
// the tabs promise days, and the measurement charts window by days.
const cutoff = new Date().getTime() - rangePoints * 86_400_000;
const sliced =
rangePoints > 0
? allPoints.filter((point) => point.timestamp >= cutoff)
: allPoints;
// v1.4.15 Fix 3: parity with health-chart's auto-bucketing. Other
// dashboard charts (BP, weight, pulse) auto-aggregate to weekly /
@@ -581,10 +592,16 @@ export function MoodChart({
// doesn't crash the chip render.
const activeBucket: ChartBucketType = useMemo(() => {
if (!data?.entries?.length) return "day";
const cutoff = new Date().getTime() - rangePoints * 86_400_000;
const sorted = [...data.entries].sort((a, b) =>
a.date.localeCompare(b.date),
);
const sliced = rangePoints > 0 ? sorted.slice(-rangePoints) : sorted;
// v1.37.29 — the same calendar-day window `chartData` applies, so the
// chip describes the points actually painted.
const sliced =
rangePoints > 0
? sorted.filter((entry) => dayKeyToTimestamp(entry.date) >= cutoff)
: sorted;
if (sliced.length < 2) return "day";
const firstTs = dayKeyToTimestamp(sliced[0].date);
const lastTs = dayKeyToTimestamp(sliced[sliced.length - 1].date);
@@ -1079,19 +1096,19 @@ export function MoodChart({
{TIME_RANGES_KEYS.map((r) => (
<Button
key={r.labelKey}
variant={rangePoints === r.points ? "default" : "ghost"}
aria-pressed={rangePoints === r.points}
variant={rangePoints === r.days ? "default" : "ghost"}
aria-pressed={rangePoints === r.days}
size="sm"
className="min-h-11 px-2 text-xs sm:px-3"
onClick={() => {
setRangeHydrated(true);
setRangePoints(r.points);
setRangePoints(r.days);
// v1.30.1 M2 — persist the pick per chart, same
// model as the overlay toggles.
if (chartKey && !windowOverride) {
overlayPrefs.setPrefs({
...overlayPrefs.prefs,
rangePoints: r.points,
rangePoints: r.days,
});
}
}}