spa hardening, offline logging, rate limit fixes

server:
- backup exporter, auth extractors, error shapes, CONTEXT (prior work)
- spa assets served outside the rate limit via route_layer
- requests_per_second went to per_second(), which takes an interval not a
  rate: 50 meant one request per 50s once burst was spent. now converted
  properly. 15/s, burst 60

spa fixes:
- account delete cleared snake_case token keys that were never written
- refresh interceptor could retry forever
- date ranges used local day boundaries stamped +00:00
- "all" period trend plotted one page; calendar days fabricated mood 3
- chart grid invisible: hsl(var(--border)) against rgba tokens
- blob url leak, orphaned media on failed save, devtools in prod bundle
- pt-safe/safe-area-pb classes never existed

spa features:
- offline outbox: entries queue to IndexedDB, replay with backoff, only
  server refusals count against an entry
- drafts persist, quick-log sheet, diary infinite scroll + filters
- route error boundary, stale-chunk recovery, no service worker in dev

a11y + perf:
- mood picker is a radiogroup, activity picker keyboard-operable,
  text alternatives for colour/emoji, locale week start
- dark glass over the bright photo: worst case 1.4:1 -> 4.9-9.6:1
- initial payload 1095->769kB raw, 306->230kB gzip; 38 unused components
  and 5 deps dropped; fonts 218->133kB

53 tests added (43 spa, 10 server)
This commit is contained in:
2026-08-28 14:59:21 +02:00
parent 23d052278a
commit bf148902ab
395 changed files with 13972 additions and 10635 deletions

View File

@@ -0,0 +1,133 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import type * as ApiClient from "@/api/client"
import { ApiError } from "@/api/client"
import { emptyDraft } from "@/lib/entry-draft"
import type { QueuedEntry } from "@/lib/store/outbox"
// Hoisted alongside vi.mock, which runs before the imports above.
const { entries, media, store } = vi.hoisted(() => ({
entries: { create: vi.fn() },
media: {
uploadPhoto: vi.fn(),
uploadVoiceMemo: vi.fn(),
deletePhoto: vi.fn(),
deleteVoiceMemo: vi.fn(),
},
store: {
queued: [] as unknown[],
dequeued: [] as string[],
attempts: [] as Array<{ id: string; error: string; counted: boolean }>,
},
}))
vi.mock("@/api/client", async () => {
const actual = await vi.importActual<typeof ApiClient>("@/api/client")
return { ApiError: actual.ApiError, entries, media }
})
vi.mock("@/lib/store/outbox", () => ({
queuedEntries: async () => store.queued,
dequeue: async (id: string) => {
store.dequeued.push(id)
},
noteAttempt: async (id: string, error: string, counted: boolean) => {
store.attempts.push({ id, error, counted })
},
}))
const { flushOutbox, MAX_AUTOMATIC_ATTEMPTS } =
await import("@/lib/outbox-sync")
function queued(id: string, changes: Partial<QueuedEntry> = {}): QueuedEntry {
return {
id,
draft: { ...emptyDraft(), mood: 3 },
queuedAt: `2026-01-0${id}T10:00:00.000Z`,
attempts: 0,
...changes,
}
}
describe("flushing the outbox", () => {
beforeEach(() => {
vi.clearAllMocks()
store.queued = []
store.dequeued = []
store.attempts = []
entries.create.mockResolvedValue({ id: "server-id" })
})
it("sends the oldest entry first", async () => {
store.queued = [queued("1"), queued("2"), queued("3")]
await flushOutbox()
expect(store.dequeued).toEqual(["1", "2", "3"])
})
it("reports how many it sent", async () => {
store.queued = [queued("1"), queued("2")]
await expect(flushOutbox()).resolves.toEqual({ sent: 2, failed: 0 })
})
it("counts a refusal against the entry and records why", async () => {
store.queued = [queued("1")]
entries.create.mockRejectedValue(
new ApiError(422, "UNPROCESSABLE", "That activity is not yours")
)
await expect(flushOutbox()).resolves.toEqual({ sent: 0, failed: 1 })
expect(store.dequeued).toEqual([])
expect(store.attempts).toEqual([
{ id: "1", error: "That activity is not yours", counted: true },
])
})
it("does not count a failure that never reached the server", async () => {
// A connection that is down says nothing about whether the entry is good,
// so it must not burn through the entry's attempts.
store.queued = [queued("1")]
entries.create.mockRejectedValue(new Error("Network Error"))
await flushOutbox()
expect(store.attempts).toEqual([
{ id: "1", error: "No connection to the server", counted: false },
])
})
it("keeps sending the rest after one fails", async () => {
store.queued = [queued("1"), queued("2")]
entries.create
.mockRejectedValueOnce(new ApiError(422, "NOPE", "refused"))
.mockResolvedValueOnce({ id: "server-id" })
await expect(flushOutbox()).resolves.toEqual({ sent: 1, failed: 1 })
expect(store.dequeued).toEqual(["2"])
})
it("takes back media it uploaded when the entry never lands", async () => {
const withPhoto = queued("1")
withPhoto.draft.photos = [
{ kind: "pending", localId: "local-1", file: new File([""], "a.jpg") },
]
store.queued = [withPhoto]
media.uploadPhoto.mockResolvedValue({ id: "uploaded-photo" })
entries.create.mockRejectedValue(new ApiError(500, "BOOM", "server broke"))
await flushOutbox()
expect(media.uploadPhoto).toHaveBeenCalledOnce()
expect(media.deletePhoto).toHaveBeenCalledWith("uploaded-photo")
})
it("leaves alone an entry the server has refused too many times", async () => {
store.queued = [queued("1", { attempts: MAX_AUTOMATIC_ATTEMPTS })]
await expect(flushOutbox()).resolves.toEqual({ sent: 0, failed: 0 })
expect(entries.create).not.toHaveBeenCalled()
})
})