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:
39
spa/tests/api/session.test.ts
Normal file
39
spa/tests/api/session.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest"
|
||||
import {
|
||||
accessToken,
|
||||
forgetSession,
|
||||
hasSession,
|
||||
refreshToken,
|
||||
rememberSession,
|
||||
} from "@/api/session"
|
||||
|
||||
const TOKENS = { accessToken: "access-1", refreshToken: "refresh-1" }
|
||||
|
||||
describe("session", () => {
|
||||
beforeEach(() => localStorage.clear())
|
||||
|
||||
it("has no session before anything is remembered", () => {
|
||||
expect(hasSession()).toBe(false)
|
||||
expect(accessToken()).toBeNull()
|
||||
})
|
||||
|
||||
it("remembers and reads back both tokens", () => {
|
||||
rememberSession(TOKENS)
|
||||
|
||||
expect(hasSession()).toBe(true)
|
||||
expect(accessToken()).toBe("access-1")
|
||||
expect(refreshToken()).toBe("refresh-1")
|
||||
})
|
||||
|
||||
it("forgets every key it wrote", () => {
|
||||
rememberSession(TOKENS)
|
||||
forgetSession()
|
||||
|
||||
expect(hasSession()).toBe(false)
|
||||
expect(accessToken()).toBeNull()
|
||||
expect(refreshToken()).toBeNull()
|
||||
// The bug this guards: deleting an account cleared snake_case keys that
|
||||
// were never written, leaving a live token behind.
|
||||
expect(localStorage.length).toBe(0)
|
||||
})
|
||||
})
|
||||
42
spa/tests/lib/activity-groups.test.ts
Normal file
42
spa/tests/lib/activity-groups.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
118
spa/tests/lib/entry-draft.test.ts
Normal file
118
spa/tests/lib/entry-draft.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
42
spa/tests/lib/instant-range.test.ts
Normal file
42
spa/tests/lib/instant-range.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
30
spa/tests/lib/media.test.ts
Normal file
30
spa/tests/lib/media.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
133
spa/tests/lib/outbox-sync.test.ts
Normal file
133
spa/tests/lib/outbox-sync.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
51
spa/tests/lib/trend-buckets.test.ts
Normal file
51
spa/tests/lib/trend-buckets.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
21
spa/tests/lib/week.test.ts
Normal file
21
spa/tests/lib/week.test.ts
Normal 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]))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user