From 4d65702e9a8373e19fa47b3751283eeaeb5fa551 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 09:35:37 +0000 Subject: [PATCH] Scope logbook PDF invalidation to the day that actually changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a remark to one day dropped every archived PDF the device had, because `invalidateLogbookArchive` deletes the whole `logbook//` prefix. With a single device in the bucket that is "delete everything", so one remark threw away weeks of renders and left only whatever the nightly Workflow wrote next — the expensive re-rendering this cache exists to avoid. A remark is filed against its own `dateString` and cannot appear in any other day's log, so the new `invalidateLogbookArchiveDay` drops just that one object. The whole-device invalidation is still right for timing points and logbook config, which do rewrite every past day, and is now also called when a device is renamed or its display distance unit changes on the admin devices page. Both are printed into the PDF (the name in its title, the unit on every distance), so archived copies stopped matching the page after either edit and nothing dropped them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KdZht5FMiuDuD4HhKJHupa --- Readme.md | 2 ++ website/app/logbook/pdfArchive.server.ts | 20 ++++++++++++++++++++ website/app/routes/admin/devices.tsx | 24 +++++++++++++++++++++++- website/app/routes/date/logbook.tsx | 10 ++++++++-- 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/Readme.md b/Readme.md index bb19559..80690b5 100644 --- a/Readme.md +++ b/Readme.md @@ -33,6 +33,8 @@ The schedule lives on the Workflow binding in `wrangler.jsonc` (`schedules`), no Rendered PDFs for finished days are kept in the `R2_BUCKET` R2 bucket, so a day is only ever rendered once — whichever of the nightly email or the page's **Download PDF** link comes first warms the cache for the other. Today's log is never cached, because it is still growing; the download link only appears once the UTC day has ended. Deleting an object from the bucket simply causes it to be re-rendered on next request, so the bucket is safe to prune. +Nothing on a schedule ever deletes from the bucket — neither the nightly Workflow nor the deploy Action touches an existing object. Archived copies are dropped only when an edit makes them disagree with what the page would now render, by `invalidateLogbookArchive` (the whole of one device's archive, for changes that rewrite every past day: timing points, logbook config, the device name, the display distance unit) or `invalidateLogbookArchiveDay` (one day, for a remark). If the bucket looks emptier than the number of days that have passed, that is where to look first — followed by any object lifecycle rule set on the bucket in the Cloudflare dashboard, which lives outside this repository. + Setup that cannot be done from the repository: - Onboard the sender domain (`EMAIL_FROM` in `wrangler.jsonc`) in Cloudflare Email Sending. The zone must use Cloudflare DNS. diff --git a/website/app/logbook/pdfArchive.server.ts b/website/app/logbook/pdfArchive.server.ts index 4d30af2..8e2932a 100644 --- a/website/app/logbook/pdfArchive.server.ts +++ b/website/app/logbook/pdfArchive.server.ts @@ -115,6 +115,21 @@ export async function getOrRenderLogbookPdf( export const logbookPdfFilename = (deviceName: string, dateString: string) => `logbook-${deviceName.replace(/[^\w-]+/g, "-")}-${dateString}.pdf`; +/** + * Drop the archived PDF for a single day. + * + * The right call whenever an edit is confined to one day — a remark is filed against the + * day it was written for and cannot appear in any other day's log, so wiping the rest of + * the archive would only force pointless re-renders of days that have not changed. + */ +export async function invalidateLogbookArchiveDay( + env: Env, + deviceId: number, + dateString: string, +) { + await env.R2_BUCKET.delete(logbookPdfKey(deviceId, dateString)); +} + /** * Drop every archived PDF for a device. * @@ -123,6 +138,11 @@ export const logbookPdfFilename = (deviceName: string, dateString: string) => * voltage bands changes which power lines appear. Both rewrite history, so the whole * device's archive is dropped rather than trying to work out which days were affected. * + * Reach for `invalidateLogbookArchiveDay` instead when the change only affects one day. + * With a single device in the bucket this function is "delete everything", so calling it + * for a day-scoped edit throws away the entire archive and every day has to be rendered + * again — the expensive thing this cache exists to avoid. + * * Cheap to be wrong about — a missing object is simply re-rendered on next request. */ export async function invalidateLogbookArchive(env: Env, deviceId: number) { diff --git a/website/app/routes/admin/devices.tsx b/website/app/routes/admin/devices.tsx index 23327ba..0d7cbec 100644 --- a/website/app/routes/admin/devices.tsx +++ b/website/app/routes/admin/devices.tsx @@ -1,4 +1,4 @@ -import { getDb } from "~/routeContext"; +import { getCloudflareContext, getDb } from "~/routeContext"; import { Button, Container, @@ -19,6 +19,7 @@ import { import { AccessPasswords } from "~/database/schema/AccessPasswords"; import { Events } from "~/database/schema/Events"; import { Devices } from "~/database/schema/Devices"; +import { invalidateLogbookArchive } from "~/logbook/pdfArchive.server"; import { isSpeedUnit, SPEED_UNIT_OPTIONS, @@ -240,6 +241,18 @@ export async function action({ context, request }: Route.ActionArgs) { await ensureNameIsUnique(db, name, id); await ensureMatcherIsUnique(db, matchId, id); + // Both of these are printed into the logbook PDF — the name in its title, the unit on + // every distance in it — so read them before the write to see whether the archived + // copies still match what the page would now render. + const [before] = await db + .select({ + name: Devices.name, + displayDistanceUnit: Devices.displayDistanceUnit, + }) + .from(Devices) + .where(eq(Devices.id, id)) + .limit(1); + await db .update(Devices) .set({ @@ -251,6 +264,15 @@ export async function action({ context, request }: Route.ActionArgs) { displayDistanceUnit, }) .where(eq(Devices.id, id)); + + if ( + before && + (before.name !== name || + before.displayDistanceUnit !== displayDistanceUnit) + ) { + await invalidateLogbookArchive(getCloudflareContext(context).env, id); + } + return { success: true }; } diff --git a/website/app/routes/date/logbook.tsx b/website/app/routes/date/logbook.tsx index 3e90b87..c256bd7 100644 --- a/website/app/routes/date/logbook.tsx +++ b/website/app/routes/date/logbook.tsx @@ -45,6 +45,7 @@ import { import { parseLogbookConfig } from "~/logbook/config"; import { invalidateLogbookArchive, + invalidateLogbookArchiveDay, isDayComplete, } from "~/logbook/pdfArchive.server"; import { DISPLAY_TIME_ZONE, formatTime24, formatUtcDay } from "~/utils/dateTime"; @@ -120,8 +121,13 @@ export async function action({ context, request }: Route.ActionArgs) { // A finished day's PDF is rendered once and cached in R2 (see pdfArchive.server.ts) — // a remark added afterwards would otherwise never appear in a downloaded copy until - // someone happens to hit "Regenerate PDF". - await invalidateLogbookArchive(getCloudflareContext(context).env, deviceId); + // someone happens to hit "Regenerate PDF". Only this day's copy, though: the remark is + // filed against `urlDate` and cannot show up in any other day's log. + await invalidateLogbookArchiveDay( + getCloudflareContext(context).env, + deviceId, + urlDate, + ); return { error: null }; }