04core concepts
Persistence
The PersistenceAdapter contract, the localStorage default, and the versioned payload migration.
Uncontrolled persistence is one prop: omit it for localStorage, pass false
for ephemeral, or bring an adapter. Either way, save fires only on committed
changes — the drag engine never touches the store mid-gesture, so persistence
hangs off exactly the commit boundary.
The adapter contract
import type { PersistenceAdapter } from "@/components/kloq";
const adapter: PersistenceAdapter = {
// may be async; null means "no stored copy" — the seeds stay
load: () => fetchEvents(),
// called only on committed changes — never mid-drag
save: (events) => putEvents(events),
// optional: wipe the stored copy (reset falls back to save(seeds))
clear: () => deleteAll(),
};
<Kloq persistence={adapter} />
<Kloq persistence={false} /> // nothing leaves the componentThe semantics are precise. load runs once, from a layout effect after mount —
never on the server — and may return a promise; the seeds render until it
resolves, then the stored copy swaps in. Returning null means "no stored
copy", which keeps the seeds rather than blanking the calendar. save receives
the full next array on every committed change — one write per commit, and a
batch (a paste, a bulk delete) is one commit. clear is optional: reset
calls it when present and falls back to save(seeds) when not.
The localStorage default
Omit persistence and you get localStorageAdapter() — exported, so you can
also call it yourself with a custom key. It writes a versioned payload
({ v: 2, events }) under STORAGE_KEY ("kloq:events:v2"), and reads
newest-first through LEGACY_STORAGE_KEYS — including the pre-rename
chrono:* keys — migrating anything it rescues forward and never deleting the
old copy, so a downgrade or a bug doesn't cost anyone their calendar.
import { localStorageAdapter } from "@/components/kloq";
// a custom key opts out of the legacy-key rescue by default
<Kloq persistence={localStorageAdapter("myapp:calendar")} />Every failure path degrades to the seeds: corrupted JSON, private mode, quota. Writes that fail are non-fatal — the change still applies in memory.
Versioned payloads
The payload version lives in the data, not the key. readPayload accepts the
current { v: 2, events } shape and a bare array (the v1 format), and returns
null for anything else — corrupt JSON, a future version written by a newer
build — so the caller keeps its seeds rather than rendering nonsense. A v1
payload had no dates, only week-relative offsets, so migrateV1 maps it onto
the Monday-start week containing the migration date and the result is written
back as v2 immediately — the reinterpret happens once, not sliding forward a
week at a time.
One guard worth knowing about: when a non-empty payload survives validation
with nothing left, readPayload returns null instead of []. "We couldn't
read any of your data" is not the same as "you have no data" — returning an
empty array would render a blank calendar that then saves itself over the
original on the next commit. A genuinely emptied calendar round-trips fine.
| Export | Type | Notes |
|---|---|---|
localStorageAdapter(key?, legacyKeys?) | () => PersistenceAdapter | The default adapter; custom keys skip the legacy rescue unless you pass your own list. |
STORAGE_KEY | "kloq:events:v2" | Where the default adapter writes. |
LEGACY_STORAGE_KEYS | readonly string[] | Keys still read from, newest first — including pre-rename chrono:* keys. |
PAYLOAD_VERSION / PayloadV2 | 2 | The versioned envelope: { v: 2, events }. |
readPayload(raw) | ReadResult | null | Reads any known version; migrated: true asks the caller to write v2 back. |
migrateV1(raw, now?) | CalEvent[] | The lossy-but-deliberate v1 mapping onto the current week. |
isV2Event(x) | x is CalEvent | Per-event validation — id present, both instants parseable, all-day instants date-only. |
buildSeedEvents(now?) / SEED_EVENTS | CalEvent[] | The sample week, anchored on the week containing today. |
newEventId() | () => string | The id generator the built-in create paths use. |
An adapter is not a sync engine
An adapter is deliberately dumb — full-array in, full-array out. That is
exactly right for localStorage, an ephemeral demo, or a single-user document.
For a real backend you usually want the change-level seam instead: onCommit
hands you each committed change with enough context to make an API call and
reverts the UI if you reject it. The two compose — the next page is about that
seam.