Merge the document layout default and the air quality history into release/v1.42.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-09 10:02:56 +02:00
commit 57c44006ee
35 files changed
+1884 -76

No files matched your search

@@ -89,9 +89,27 @@ describe("GET /api/documents/inbound/layout", () => {
const res = await callGet();
expect(res.status).toBe(200);
expect((await res.json()).data).toEqual(DEFAULT_DOCUMENTS_LAYOUT);
expect(DEFAULT_DOCUMENTS_LAYOUT).toEqual({
version: 1,
view: "cards",
arrangement: "flow",
});
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("keeps a saved stacked arrangement although the default is flow", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
documentsLayoutJson: {
version: 1,
view: "cards",
arrangement: "stacked",
},
} as never);
const res = await callGet();
expect((await res.json()).data.arrangement).toBe("stacked");
});
it("serves the stored choice, repairing an unknown value field by field", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
documentsLayoutJson: { version: 1, view: "list", arrangement: "spiral" },
@@ -101,7 +119,7 @@ describe("GET /api/documents/inbound/layout", () => {
expect((await res.json()).data).toEqual({
version: 1,
view: "list",
arrangement: "stacked",
arrangement: "flow",
});
});
@@ -146,6 +164,23 @@ describe("PUT /api/documents/inbound/layout", () => {
});
});
it("stores only the chosen field, so the untouched one keeps following the default", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
documentsLayoutJson: null,
} as never);
const res = await put({ version: 1, view: "list" });
expect((await res.json()).data).toEqual({
version: 1,
view: "list",
arrangement: "flow",
});
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { documentsLayoutJson: { version: 1, view: "list" } },
});
});
it("refuses an unknown value and an unknown key with 422 and writes nothing", async () => {
const bad = await put({ version: 1, view: "carousel" });
expect(bad.status).toBe(422);
+14 -10
View File
@@ -20,6 +20,7 @@ import {
DOCUMENTS_LAYOUT_ARRANGEMENTS,
DOCUMENTS_LAYOUT_VIEWS,
resolveDocumentsLayout,
storedDocumentsLayoutChoices,
type DocumentsLayout,
} from "@/lib/documents/documents-layout";
import { annotate } from "@/lib/logging/context";
@@ -35,12 +36,12 @@ const layoutPutSchema = z
})
.strict();
async function readLayout(userId: string): Promise<DocumentsLayout> {
async function readStoredLayout(userId: string): Promise<unknown> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { documentsLayoutJson: true },
});
return resolveDocumentsLayout(row?.documentsLayoutJson);
return row?.documentsLayoutJson;
}
export const GET = apiHandler(async () => {
@@ -51,7 +52,7 @@ export const GET = apiHandler(async () => {
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
const layout = await readLayout(user.id);
const layout = resolveDocumentsLayout(await readStoredLayout(user.id));
annotate({
action: { name: "documents.layout.read" },
meta: { view: layout.view, arrangement: layout.arrangement },
@@ -78,17 +79,20 @@ export const PUT = apiHandler(async (request: Request) => {
}
// Preserve-when-absent: one stored read fills whichever field the body
// left out.
const existing = await readLayout(user.id);
const next: DocumentsLayout = {
version: 1,
view: parsed.data.view ?? existing.view,
arrangement: parsed.data.arrangement ?? existing.arrangement,
// left out. Only fields the user has chosen are written, so a field never
// picked keeps following the default (which can change between releases).
const choices = {
...storedDocumentsLayoutChoices(await readStoredLayout(user.id)),
...(parsed.data.view ? { view: parsed.data.view } : {}),
...(parsed.data.arrangement
? { arrangement: parsed.data.arrangement }
: {}),
};
const next: DocumentsLayout = resolveDocumentsLayout(choices);
await prisma.user.update({
where: { id: user.id },
data: { documentsLayoutJson: toJson(next) },
data: { documentsLayoutJson: toJson({ version: 1, ...choices }) },
});
annotate({
+4
View File
@@ -18,6 +18,7 @@ import { homeLocationSchema } from "@/lib/validations/environment";
import { roundCoarse } from "@/lib/environment/open-meteo";
import { sealLocation } from "@/lib/environment/location-cipher";
import { enqueueEnvironmentFetch } from "@/lib/jobs/environment-fetch";
import { enqueueAirQualityHistory } from "@/lib/jobs/environment-air-quality-history";
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -67,6 +68,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// Kick a lookback refresh so recent days populate promptly. No-ops cleanly
// when no worker is bound; the nightly cron still covers it.
await enqueueEnvironmentFetch({ userId: user.id });
// And the past: air quality for every earlier day with entries, in the
// background and inside the request budget. A no-op while it is off.
await enqueueAirQualityHistory(user.id);
annotate({ action: { name: "environment.home.set" } });
@@ -20,6 +20,7 @@ import { prisma } from "@/lib/db";
import { environmentPreferencesSchema } from "@/lib/validations/environment";
import { isAirQualityOperatorDisabled } from "@/lib/environment/open-meteo-air-quality";
import { enqueueEnvironmentFetch } from "@/lib/jobs/environment-fetch";
import { enqueueAirQualityHistory } from "@/lib/jobs/environment-air-quality-history";
export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -57,6 +58,8 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
before?.environmentAirQualityEnabled === false
) {
await enqueueEnvironmentFetch({ userId: user.id });
// Switched on: fill the past as well, every earlier day with entries.
await enqueueAirQualityHistory(user.id);
}
annotate({
+18 -1
View File
@@ -11,7 +11,8 @@
* (the account switch, the operator switch, how many days carry air-quality
* values), the newest stored day for the dashboard chips (with its air
* quality only while the part is on), and every attribution line in
* `attributions`.
* `attributions`, and the progress of the air-quality history backfill
* (`airQuality.history`).
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
@@ -23,6 +24,7 @@ import { isAirQualityOperatorDisabled } from "@/lib/environment/open-meteo-air-q
import { environmentAttributionLines } from "@/lib/environment/air-quality-contract";
import { readLocation } from "@/lib/environment/location-cipher";
import { isAirQualityActive } from "@/lib/environment/service";
import { readAirQualityHistoryState } from "@/lib/environment/air-quality-history";
import { ENVIRONMENT_FETCH_QUEUE } from "@/lib/jobs/environment-fetch";
import { readQueueFailureForUser } from "@/lib/jobs/job-failures";
@@ -54,6 +56,7 @@ export const GET = apiHandler(async () => {
homeSince: true,
timezone: true,
environmentAirQualityEnabled: true,
environmentAqHistoryJson: true,
},
}),
prisma.environmentTravelLocation.findMany({
@@ -157,6 +160,19 @@ export const GET = apiHandler(async () => {
const accountEnabled = profile?.environmentAirQualityEnabled ?? true;
const operatorDisabled = isAirQualityOperatorDisabled();
const airActive = isAirQualityActive(accountEnabled);
// The history backfill's progress, while the part is on: what the last run
// counted, so the card can say how far the past has been filled.
const historyState = airActive
? readAirQualityHistoryState(profile?.environmentAqHistoryJson)
: null;
const history = historyState
? {
total: historyState.total,
done: historyState.done,
complete: historyState.complete,
checkedAt: historyState.checkedAt,
}
: null;
const latestYear = Number(
(latestAir?.date ?? latest?.date ?? new Date().toISOString()).slice(0, 4),
);
@@ -219,6 +235,7 @@ export const GET = apiHandler(async () => {
days: airDays,
latestDate: latestAir?.date ?? null,
domain: latestAir?.aqDomain ?? null,
history,
},
latestDay,
// null = the last background runs did not fail (or there is no queue to