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 component

The 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.

ExportTypeNotes
localStorageAdapter(key?, legacyKeys?)() => PersistenceAdapterThe 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_KEYSreadonly string[]Keys still read from, newest first — including pre-rename chrono:* keys.
PAYLOAD_VERSION / PayloadV22The versioned envelope: { v: 2, events }.
readPayload(raw)ReadResult | nullReads 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 CalEventPer-event validation — id present, both instants parseable, all-day instants date-only.
buildSeedEvents(now?) / SEED_EVENTSCalEvent[]The sample week, anchored on the week containing today.
newEventId()() => stringThe 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.

on this page