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,42 @@
import { describe, expect, it } from "vitest"
import { UNCATEGORISED, groupByCategory } from "@/lib/activity-groups"
const activity = (name: string, category: string | null) => ({ name, category })
describe("grouping activities", () => {
it("sorts categories alphabetically", () => {
const groups = groupByCategory([
activity("run", "sport"),
activity("read", "hobby"),
])
expect(groups.map((group) => group.category)).toEqual(["hobby", "sport"])
})
it("puts activities with no category last", () => {
const groups = groupByCategory([
activity("nap", null),
activity("run", "sport"),
activity("read", "hobby"),
])
expect(groups.map((group) => group.category)).toEqual([
"hobby",
"sport",
UNCATEGORISED,
])
})
it("keeps activities within a category in the order given", () => {
const groups = groupByCategory([
activity("run", "sport"),
activity("swim", "sport"),
])
expect(groups[0].items.map((item) => item.name)).toEqual(["run", "swim"])
})
it("has no groups for no activities", () => {
expect(groupByCategory([])).toEqual([])
})
})

View File

@@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest"
import {
emptyDraft,
hasExtras,
isWorthKeeping,
toCreateRequest,
} from "@/lib/entry-draft"
import type { EntryDraft } from "@/lib/entry-draft"
const NOTHING_UPLOADED = { photoIds: [], voiceMemoIds: [] }
function draftWith(changes: Partial<EntryDraft>): EntryDraft {
return { ...emptyDraft(), mood: 4, ...changes }
}
describe("entry drafts", () => {
it("is not worth keeping when nothing has been entered", () => {
expect(isWorthKeeping(emptyDraft())).toBe(false)
})
it("is worth keeping once a mood is picked", () => {
expect(isWorthKeeping(draftWith({}))).toBe(true)
})
it("is worth keeping for a note with no mood", () => {
const draft = { ...emptyDraft(), content: "a rough morning" }
expect(isWorthKeeping(draft)).toBe(true)
})
it("does not count whitespace as a note", () => {
expect(hasExtras(draftWith({ content: " " }))).toBe(false)
})
it("does not treat a tagged activity as an extra", () => {
// Activities live in the always-visible part of the form; counting them
// sprang the "add more" section open the moment one was tapped.
expect(hasExtras(draftWith({ activityIds: ["reading"] }))).toBe(false)
})
it("still keeps a draft that only has activities on it", () => {
const draft = { ...emptyDraft(), activityIds: ["reading"] }
expect(isWorthKeeping(draft)).toBe(true)
})
it("refuses to build a request without a mood", () => {
expect(() => toCreateRequest(emptyDraft(), NOTHING_UPLOADED)).toThrowError(
/without a mood/
)
})
it("omits dimensions that hold nothing", () => {
const request = toCreateRequest(draftWith({}), NOTHING_UPLOADED)
expect(request.dimensions).toEqual([])
expect(request.mood).toBe(4)
})
it("trims the note and keeps it as a content dimension", () => {
const request = toCreateRequest(
draftWith({ content: " tired " }),
NOTHING_UPLOADED
)
expect(request.dimensions).toContainEqual({
kind: "content",
text: "tired",
})
})
it("keeps a song only when both title and artist are given", () => {
const halfNamed = toCreateRequest(
draftWith({ song: { title: "Teardrop", artist: "" } }),
NOTHING_UPLOADED
)
expect(halfNamed.dimensions).toEqual([])
const named = toCreateRequest(
draftWith({ song: { title: "Teardrop", artist: "Massive Attack" } }),
NOTHING_UPLOADED
)
expect(named.dimensions).toContainEqual({
kind: "song",
title: "Teardrop",
artist: "Massive Attack",
})
})
it("uses the ids the upload returned, not the local media", () => {
const request = toCreateRequest(draftWith({}), {
photoIds: ["photo-a", "photo-b"],
voiceMemoIds: ["memo-a"],
})
expect(request.dimensions).toContainEqual({
kind: "photos",
ids: ["photo-a", "photo-b"],
})
expect(request.dimensions).toContainEqual({
kind: "voiceMemos",
ids: ["memo-a"],
})
})
it("carries coordinates through as a location dimension", () => {
const request = toCreateRequest(
draftWith({ location: { latitude: 52.23, longitude: 21.01 } }),
NOTHING_UPLOADED
)
expect(request.dimensions).toContainEqual({
kind: "location",
latitude: 52.23,
longitude: 21.01,
})
})
})

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest"
import { rangeOfDays, rangeOfMonth, rangeOfYear } from "@/lib/instant-range"
const OFFSET = /[+-]\d{2}:\d{2}$/
describe("instant ranges", () => {
it("carries the reader's own offset rather than assuming UTC", () => {
const { from, to } = rangeOfMonth(new Date(2026, 7, 15))
expect(from).toMatch(OFFSET)
expect(to).toMatch(OFFSET)
})
it("starts a month at local midnight on the first", () => {
const { from } = rangeOfMonth(new Date(2026, 7, 15))
expect(from.startsWith("2026-08-01T00:00:00")).toBe(true)
})
it("ends a month on the last day, not the first of the next", () => {
const { to } = rangeOfMonth(new Date(2026, 7, 15))
expect(to.startsWith("2026-08-31T23:59:59")).toBe(true)
})
it("covers a whole year", () => {
const { from, to } = rangeOfYear(2025)
expect(from.startsWith("2025-01-01T00:00:00")).toBe(true)
expect(to.startsWith("2025-12-31T23:59:59")).toBe(true)
})
it("widens a span to whole days at both ends", () => {
const { from, to } = rangeOfDays(
new Date(2026, 0, 5, 13, 30),
new Date(2026, 0, 7, 9, 15)
)
expect(from.startsWith("2026-01-05T00:00:00")).toBe(true)
expect(to.startsWith("2026-01-07T23:59:59")).toBe(true)
})
})

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest"
import {
existingIds,
existingItem,
keyOf,
newPendingItem,
pendingFiles,
} from "@/lib/media"
const file = (name: string) => new File(["x"], name, { type: "image/jpeg" })
describe("media items", () => {
it("separates what the server holds from what is still local", () => {
const items = [existingItem("on-server"), newPendingItem(file("new.jpg"))]
expect(existingIds(items)).toEqual(["on-server"])
expect(pendingFiles(items).map((each) => each.name)).toEqual(["new.jpg"])
})
it("gives every pending item its own key", () => {
const first = newPendingItem(file("a.jpg"))
const second = newPendingItem(file("a.jpg"))
expect(keyOf(first)).not.toBe(keyOf(second))
})
it("keys an existing item by its server id", () => {
expect(keyOf(existingItem("photo-7"))).toBe("photo-7")
})
})

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()
})
})

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest"
import { aggregateTrend, bucketFor } from "@/lib/trend-buckets"
import type { TrendPoint } from "@/hooks/use-trend"
function day(date: string, mood: number, entryCount = 1): TrendPoint {
return { date, mood, entryCount }
}
describe("trend buckets", () => {
it("plots a day at a time over a week or a month", () => {
expect(bucketFor("week", 7)).toBe("day")
expect(bucketFor("month", 30)).toBe("day")
})
it("widens to weeks for a year and months beyond one", () => {
expect(bucketFor("year", 300)).toBe("week")
expect(bucketFor("all", 900)).toBe("month")
})
it("averages the days inside a bucket", () => {
const bars = aggregateTrend(
[day("2026-03-01", 2), day("2026-03-15", 4)],
"month"
)
expect(bars).toHaveLength(1)
expect(bars[0].average).toBe(3)
})
it("sums the entries behind a bucket, not the days", () => {
const bars = aggregateTrend(
[day("2026-03-01", 2, 3), day("2026-03-15", 4, 2)],
"month"
)
expect(bars[0].entryCount).toBe(5)
})
it("orders buckets oldest first", () => {
const bars = aggregateTrend(
[day("2026-05-02", 3), day("2026-01-02", 5), day("2026-03-02", 1)],
"month"
)
expect(bars.map((bar) => bar.average)).toEqual([5, 1, 3])
})
it("has nothing to plot for no days", () => {
expect(aggregateTrend([], "day")).toEqual([])
})
})

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest"
import { rowOfWeek, weekStartsOn, weekdayInitials } from "@/lib/week"
describe("week start", () => {
it("names seven weekdays beginning with the first day", () => {
const initials = weekdayInitials()
expect(initials).toHaveLength(7)
expect(new Set(initials).size).toBe(7)
})
it("puts the week's first day in the first row", () => {
expect(rowOfWeek(weekStartsOn())).toBe(0)
})
it("maps every weekday to a distinct row", () => {
const rows = [0, 1, 2, 3, 4, 5, 6].map(rowOfWeek)
expect(new Set(rows)).toEqual(new Set([0, 1, 2, 3, 4, 5, 6]))
})
})