release: v1.32.0 — hardening and scalability (#580)

* release: prepare v1.32.0

* test: stabilize water capture re-entry
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-22 04:14:15 +02:00
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
View File
@@ -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;