mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
release: v1.32.0 — hardening and scalability (#580)
* release: prepare v1.32.0 * test: stabilize water capture re-entry
This commit is contained in:
1 parent
b01822be48
commit
62eb431d66
372 files changed
+25178
-8561
No files matched your search
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
flattenWorkoutPages,
|
||||
getNextWorkoutPageOffset,
|
||||
type WorkoutListEntry,
|
||||
type WorkoutListPayload,
|
||||
} from "@/hooks/use-workouts";
|
||||
|
||||
function workout(id: string): WorkoutListEntry {
|
||||
return {
|
||||
id,
|
||||
sportType: "running",
|
||||
startedAt: "2026-07-20T08:00:00.000Z",
|
||||
endedAt: "2026-07-20T08:30:00.000Z",
|
||||
durationSec: 1800,
|
||||
distanceM: null,
|
||||
activeEnergyKcal: null,
|
||||
avgHr: null,
|
||||
maxHr: null,
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function page(
|
||||
ids: string[],
|
||||
{ total = 201, limit = 100, offset = 0 } = {},
|
||||
): WorkoutListPayload {
|
||||
return {
|
||||
workouts: ids.map(workout),
|
||||
meta: { total, limit, offset, droppedDuplicates: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
describe("infinite workout paging", () => {
|
||||
it("uses the accumulated canonical row count as the next offset", () => {
|
||||
const first = page(
|
||||
Array.from({ length: 100 }, (_, index) => `w-${index + 1}`),
|
||||
);
|
||||
const second = page(
|
||||
Array.from({ length: 100 }, (_, index) => `w-${index + 101}`),
|
||||
{ offset: 100 },
|
||||
);
|
||||
|
||||
expect(getNextWorkoutPageOffset(second, [first, second])).toBe(200);
|
||||
});
|
||||
|
||||
it("stops when the server total is reached or the page is short", () => {
|
||||
const complete = page(["w-201"], { total: 201, offset: 200 });
|
||||
expect(
|
||||
getNextWorkoutPageOffset(complete, [
|
||||
page(Array.from({ length: 100 }, (_, index) => `w-${index + 1}`)),
|
||||
page(
|
||||
Array.from({ length: 100 }, (_, index) => `w-${index + 101}`),
|
||||
{
|
||||
offset: 100,
|
||||
},
|
||||
),
|
||||
complete,
|
||||
]),
|
||||
).toBeUndefined();
|
||||
|
||||
const short = page(["w-101"], { total: 250, offset: 100 });
|
||||
expect(
|
||||
getNextWorkoutPageOffset(short, [
|
||||
page(Array.from({ length: 100 }, (_, index) => `w-${index + 1}`)),
|
||||
short,
|
||||
]),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("flattens pages once and keeps the first row for duplicate ids", () => {
|
||||
const first = page(["w-1", "w-2"]);
|
||||
const second = page(["w-2", "w-3"], { offset: 2 });
|
||||
|
||||
expect(flattenWorkoutPages([first, second]).map((row) => row.id)).toEqual([
|
||||
"w-1",
|
||||
"w-2",
|
||||
"w-3",
|
||||
]);
|
||||
});
|
||||
});
|
||||
+107
-1
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -126,6 +127,111 @@ export function useWorkouts(opts: UseWorkoutsOptions = {}): UseWorkoutsResult {
|
||||
};
|
||||
}
|
||||
|
||||
export type UseInfiniteWorkoutsOptions = Omit<UseWorkoutsOptions, "offset">;
|
||||
|
||||
/**
|
||||
* Resolves the next canonical offset from the rows already accumulated by the
|
||||
* infinite query. A short page is terminal even when a stale server `total`
|
||||
* claims more rows remain, which prevents an empty-page fetch loop.
|
||||
*/
|
||||
export function getNextWorkoutPageOffset(
|
||||
lastPage: WorkoutListPayload,
|
||||
allPages: WorkoutListPayload[],
|
||||
): number | undefined {
|
||||
const accumulatedRows = allPages.reduce(
|
||||
(count, page) => count + page.workouts.length,
|
||||
0,
|
||||
);
|
||||
const pageIsFull =
|
||||
lastPage.meta.limit > 0 && lastPage.workouts.length >= lastPage.meta.limit;
|
||||
|
||||
return pageIsFull && accumulatedRows < lastPage.meta.total
|
||||
? accumulatedRows
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Flattens loaded pages while defending the rendered list from duplicate ids. */
|
||||
export function flattenWorkoutPages(
|
||||
pages: WorkoutListPayload[],
|
||||
): WorkoutListEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const workouts: WorkoutListEntry[] = [];
|
||||
|
||||
for (const page of pages) {
|
||||
for (const workout of page.workouts) {
|
||||
if (seen.has(workout.id)) continue;
|
||||
seen.add(workout.id);
|
||||
workouts.push(workout);
|
||||
}
|
||||
}
|
||||
|
||||
return workouts;
|
||||
}
|
||||
|
||||
export interface UseInfiniteWorkoutsResult {
|
||||
workouts: WorkoutListEntry[];
|
||||
total: number;
|
||||
isLoading: boolean;
|
||||
isEmpty: boolean;
|
||||
error: Error | null;
|
||||
/** True when the first page failed and there is no history to preserve. */
|
||||
isError: boolean;
|
||||
/** True when an appended page failed while earlier pages remain visible. */
|
||||
isFetchNextPageError: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
hasNextPage: boolean;
|
||||
fetchNextPage: () => void;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offset-paginated workout history. The first page deliberately uses the same
|
||||
* central key shape as the former `useWorkouts({ limit })` page query so the
|
||||
* RSC wrapper can hydrate it as an InfiniteData value.
|
||||
*/
|
||||
export function useInfiniteWorkouts(
|
||||
opts: UseInfiniteWorkoutsOptions = {},
|
||||
): UseInfiniteWorkoutsResult {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: queryKeys.workoutsRecentList({
|
||||
limit: opts.limit,
|
||||
offset: undefined,
|
||||
since: opts.since,
|
||||
sportType: opts.sportType,
|
||||
}),
|
||||
queryFn: ({ pageParam }) => fetchWorkouts({ ...opts, offset: pageParam }),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: getNextWorkoutPageOffset,
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
const pages = query.data?.pages;
|
||||
const workouts = useMemo(
|
||||
() => (pages ? flattenWorkoutPages(pages) : []),
|
||||
[pages],
|
||||
);
|
||||
const total = query.data?.pages[0]?.meta.total ?? 0;
|
||||
const isEmpty =
|
||||
Boolean(isAuthenticated) &&
|
||||
query.data !== undefined &&
|
||||
workouts.length === 0;
|
||||
|
||||
return {
|
||||
workouts,
|
||||
total,
|
||||
isLoading: query.isLoading,
|
||||
isEmpty,
|
||||
error: query.error as Error | null,
|
||||
isError: query.isError && query.data === undefined,
|
||||
isFetchNextPageError: query.isFetchNextPageError,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
hasNextPage: Boolean(query.hasNextPage),
|
||||
fetchNextPage: () => void query.fetchNextPage(),
|
||||
refetch: () => void query.refetch(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkoutHrSeriesPoint {
|
||||
tSec: number;
|
||||
mean: number;
|
||||
|
||||
Reference in new issue
Block a user