feat: complete automatic mobile board UX

This commit is contained in:
ajnart committed 2026-07-28 19:39:13 +02:00
1 parent cf83e0d849
commit 7959c20d88
87 files changed
+2850 -570

No files matched your search

@@ -0,0 +1,50 @@
// @vitest-environment node
import { describe, expect, test, vi } from "vitest";
import { createRequestHandler } from "./request-handler";
describe("createRequestHandler", () => {
test("keeps the standard result contract free of cache provenance", async () => {
const requestHandler = createRequestHandler({
requestAsync: vi.fn().mockResolvedValue("current"),
});
const result = await requestHandler.handler({ integrationId: "first" }).getDataAsync();
expect(result).toMatchObject({ data: "current", timestamp: expect.any(Date) });
expect(result).not.toHaveProperty("isStale");
});
test("marks an expired cache fallback as stale while preserving its timestamp", async () => {
const requestAsync = vi.fn().mockResolvedValueOnce("cached").mockRejectedValueOnce(new Error("offline"));
const requestHandler = createRequestHandler({
requestAsync,
cacheTtlMs: 0,
fallbackToStaleOnError: true,
});
const handler = requestHandler.handler({ integrationId: "first" });
const fresh = await handler.getDataWithProvenanceAsync();
const fallback = await handler.getDataWithProvenanceAsync();
expect(fresh).toMatchObject({ data: "cached", isStale: false });
expect(fallback).toEqual({
data: "cached",
timestamp: fresh.timestamp,
isStale: true,
});
expect(requestAsync).toHaveBeenCalledTimes(2);
});
test("does not hide an initial error when no cache exists", async () => {
const requestHandler = createRequestHandler({
requestAsync: vi.fn().mockRejectedValue(new Error("offline")),
fallbackToStaleOnError: true,
});
await expect(requestHandler.handler({ integrationId: "first" }).getDataWithProvenanceAsync()).rejects.toThrow(
"offline",
);
});
});
@@ -5,6 +5,7 @@ interface Options<TData, TInput extends Record<string, unknown>> {
}
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number };
type RequestResult<TData> = Pick<CacheEntry<TData>, "data" | "timestamp"> & { isStale: boolean };
const MAX_CACHE_SIZE = 1000;
const DEFAULT_TTL_MS = 10_000;
@@ -20,21 +21,21 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
options: Options<TData, TInput>,
) => {
const cache = new Map<string, CacheEntry<TData>>();
const inflight = new Map<string, Promise<CacheEntry<TData>>>();
const inflight = new Map<string, Promise<RequestResult<TData>>>();
return {
invalidateCache: () => {
cache.clear();
inflight.clear();
},
handler: (input: TInput) => ({
async getDataAsync(): Promise<{ data: TData; timestamp: Date }> {
handler: (input: TInput) => {
const getDataWithProvenanceAsync = async (): Promise<RequestResult<TData>> => {
const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
const key = JSON.stringify(input);
const cached = cache.get(key);
if (cached && Date.now() < cached.expiresAt) {
return { data: cached.data, timestamp: cached.timestamp };
return { data: cached.data, timestamp: cached.timestamp, isStale: false };
}
const existing = inflight.get(key);
@@ -51,19 +52,27 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
const entry: CacheEntry<TData> = { data, timestamp: new Date(), expiresAt: Date.now() + ttl };
cache.set(key, entry);
inflight.delete(key);
return entry;
return { data: entry.data, timestamp: entry.timestamp, isStale: false };
})
.catch((err) => {
inflight.delete(key);
if (options.fallbackToStaleOnError && cached) {
return cached;
return { data: cached.data, timestamp: cached.timestamp, isStale: true };
}
throw err;
});
inflight.set(key, promise);
return promise;
},
}),
};
return {
async getDataAsync(): Promise<Pick<RequestResult<TData>, "data" | "timestamp">> {
const { data, timestamp } = await getDataWithProvenanceAsync();
return { data, timestamp };
},
getDataWithProvenanceAsync,
};
},
};
};
@@ -13,4 +13,5 @@ export const mediaReleaseRequestHandler = createIntegrationRequestHandler<
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getMediaReleasesAsync();
},
fallbackToStaleOnError: true,
});