Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions website/app/logbook/pdfArchive.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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) {
Expand Down
24 changes: 23 additions & 1 deletion website/app/routes/admin/devices.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getDb } from "~/routeContext";
import { getCloudflareContext, getDb } from "~/routeContext";
import {
Button,
Container,
Expand All @@ -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,
Expand Down Expand Up @@ -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({
Expand All @@ -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);
}
Comment on lines +268 to +274

return { success: true };
}

Expand Down
10 changes: 8 additions & 2 deletions website/app/routes/date/logbook.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
);
Comment on lines +126 to +130

return { error: null };
}
Expand Down