Skip to content

Mutable Notes Analysis

This is a medium-difficulty feature but with some hidden complexity worth surfacing. Let me break it down.

What’s straightforward:

The edit form, add source shortcut, and delete flow are all standard CRUD. You already have useNoteForm handling note creation, so extending it to handle pre-populated edit mode is a natural expansion. The soft-delete pattern already exists in your Supabase setup. The confirmation prompt for delete is trivial. Probably 60% of the acceptance criteria falls into “well-understood work.”

Where the real complexity lives:

1. Long-press on mobile is deceptively tricky.

The touchstart/touchend timer approach sounds simple, but you’re implementing this on a scrollable list. You need to distinguish between: scroll (finger moves), tap (short press, presumably opens the note), and long-press (opens action sheet). That means you need to track touch movement during the timer window and cancel if the finger travels beyond a threshold (typically 10px). You also need to suppress the tap handler when a long-press fires. And you need to handle the case where the user long-presses, the action sheet appears, and then the underlying list still thinks a scroll is happening. This isn’t architecturally complex, but it’s fiddly interaction code that’s hard to get right without real-device testing. Budget more time here than it looks like on paper.

2. (Re)discover Ideas has a state management question.

Your current callDiscoverIdeas in api.js was designed for the initial tagging flow during note creation. Now you’re calling it on an already-saved note. The question is: what happens to the note’s state during the API call? The note is already persisted in Dexie and synced to Supabase. You’re now mutating it in place. That means you need a loading state per note (not a global loading spinner), you need to handle the case where the API call fails and the note should remain unchanged, and you need to write the updated ideas back through the same outbox sync path. None of this is hard, but it’s a different pattern from your current “create then save” flow — it’s “load, mutate, re-persist.” Make sure the hook supports both paths cleanly rather than forking logic.

rediscover path

Let me trace this end to end, from the moment the user taps “(Re)discover Ideas” on the action sheet to the moment the note is persisted with updated tags.

Starting state — what you have when the flow begins:

Saved note in Dexie:
{
id: "note_abc123",
text: "Aristotle argues that happiness is not a feeling but an activity...",
ideas: ["Happiness", "Virtue and Vice", "My Custom Idea: Eudaimonia"],
source: "Nicomachean Ethics",
...
}

The ideas array is a flat list mixing canonical and custom. Your constants.js has the canonical list. That’s your discriminator.

Step 1 — Partition existing tags

Before calling the API, split the current ideas into two sets:

const canonical = note.ideas.filter(idea => GREAT_IDEAS.includes(idea))
// → ["Happiness", "Virtue and Vice"]
const custom = note.ideas.filter(idea => !GREAT_IDEAS.includes(idea))
// → ["My Custom Idea: Eudaimonia"]

The custom set is now parked. It won’t be sent to the API and it won’t be touched. It’s just held in memory until merge.

Step 2 — Set per-note loading state

This is important because the user might be looking at a list of notes. You don’t want a global spinner — you want this specific note to show a loading indicator while its ideas are being re-evaluated.

// In whatever state manages the note list or note detail
setRediscoveringNoteId("note_abc123")

The UI reads this to show a shimmer or spinner on that note’s idea pills. Every other note remains interactive.

Step 3 — Call the Anthropic API

You’re reusing callDiscoverIdeas from api.js, but the call context is different. During initial creation, you pass the freshly transcribed text. Here you pass note.text — same function, different caller.

const newCanonical = await callDiscoverIdeas(note.text, apiKey)
// → ["Happiness", "Soul", "Pleasure and Pain"]

The API has no knowledge of what the previous tags were. It evaluates the text fresh. That’s the point — the user’s index has matured, their custom ideas prompt weighting may have changed, and the model might surface ideas that were missed the first time.

Step 4 — Merge
const updatedIdeas = [...newCanonical, ...custom]
// → ["Happiness", "Soul", "Pleasure and Pain", "My Custom Idea: Eudaimonia"]

Canonical tags are fully replaced. Custom tags are carried over unchanged. The user’s own thinking is preserved. The model’s contribution is refreshed.

Step 5 — Persist to Dexie
await db.notes.update("note_abc123", {
ideas: updatedIdeas,
updatedAt: new Date().toISOString()
})

Dexie’s liveQuery picks this up. Every view that displays this note — the Index screen, the Idea Detail view, the Library — reactively updates. No manual cache invalidation needed.

Step 6 — Queue for Supabase sync

This follows your existing outbox pattern. The mutation goes into the sync queue:

await db.outbox.add({
table: 'notes',
operation: 'update',
recordId: 'note_abc123',
payload: { ideas: updatedIdeas, updated_at: new Date().toISOString() },
createdAt: new Date().toISOString()
})

If online, sync fires immediately. If offline, it queues and fires on reconnect. Same path as any other mutation.

Step 7 — Clear loading state
setRediscoveringNoteId(null)

The note’s idea pills update to reflect the new set. Done.

Error path — what happens when the API call fails:
try {
const newCanonical = await callDiscoverIdeas(note.text, apiKey)
const updatedIdeas = [...newCanonical, ...custom]
await db.notes.update(note.id, { ideas: updatedIdeas, ... })
await db.outbox.add(...)
} catch (error) {
// Note stays exactly as it was — no partial mutation
showToast("Couldn't re-discover ideas. Try again.", "error")
} finally {
setRediscoveringNoteId(null)
}

The critical property here is that the note is never mutated until the API call succeeds. The partition in Step 1 is read-only. The merge in Step 4 only happens after Step 3 returns. If the API fails, the note is untouched. No rollback needed because there’s nothing to roll back.

Edge cases to handle:

Empty canonical result. The API returns an empty ideas array. The note ends up with only custom tags. This is valid — the user can re-run again later. Show a toast: “No canonical ideas found. Your custom tags are preserved.”

Duplicate between new canonical and existing custom. Unlikely but possible if the user created a custom idea with the same name as a canonical one. The GREAT_IDEAS.includes() check in Step 1 handles this correctly — if the custom idea name happens to match a canonical name, it gets classified as canonical and goes through replacement. This is the right behaviour because canonicals are the system’s domain.

Note has no existing ideas. Fresh note that was saved without tagging. canonical and custom are both empty arrays. The flow still works — it’s just a first-time discovery on a saved note.

API key not set. Check before Step 3. If no API key in settings, show a toast pointing to Settings. Don’t enter the loading state at all.

Where this logic should live:

Not in a component. Not in api.js. This is an orchestration function that coordinates UI state, business logic, API calls, and persistence. It belongs in a new hook or as a method in a hook you already have.

My recommendation: a useNoteActions hook that exposes rediscoverIdeas(noteId), deleteNote(noteId), editNote(noteId, updates). The action sheet calls these. Each method owns its own loading state, error handling, and persistence. This keeps the action sheet component thin — it’s just buttons wired to hook methods.

const { rediscoverIdeas, deleteNote, isRediscovering } = useNoteActions()
// In action sheet:
<button onClick={() => rediscoverIdeas(note.id)}>
(Re)discover Ideas
</button>

That gives you a clean seam for testing too — you can test the orchestration logic independent of the UI.

3. The custom ideas preservation question is the most important architectural decision in this issue.

The technical note flags it but doesn’t resolve it. Here’s why it matters: if a user has manually refined their tags — removed some canonical ideas, added custom ones — and then hits (Re)discover, do they lose that curation? Full replacement (as the AC states) is simpler to build but destructive to user effort. The alternative — merge new discoveries with existing custom tags — is more respectful but introduces ambiguity about what “rediscovery” means.

DECISION: full replacement of canonical ideas, preserve custom ideas. This is clean to implement because you already distinguish canonical from custom in your data model (canonical ideas come from the Adler list, custom ideas don’t). The logic is just: filter out canonical tags, run discovery, merge the new canonical set with the retained custom set. This gives users a reason to re-run discovery without fear of losing their own thinking.

4. Offline mutation sequencing is subtle.

Edit, then delete, then go back online. Or edit, then re-discover, then edit again, all offline. Your outbox needs to handle ordered mutations to the same record. If the outbox treats each as an independent queued write, you’re fine — last-write-wins on sync. But confirm that your current outbox implementation handles multiple pending writes to the same noteId without creating conflicts. If it queues them as separate Supabase calls, you could get a race condition on sync. Simplest fix: collapse multiple pending mutations to the same record into a single queued write before sync fires.

Outbox Mutation collapsing

Set up the problem first, then work through the solution.

Current outbox pattern (verify against codebase):

Each mutation creates a new row in the outbox table. When the app comes online, a sync worker processes the queue in order, sending each operation to Supabase and deleting the outbox row on success.

outbox table in Dexie:
{ id: 1, table: 'notes', operation: 'update', recordId: 'note_abc', payload: {...}, createdAt: ... }
{ id: 2, table: 'notes', operation: 'update', recordId: 'note_xyz', payload: {...}, createdAt: ... }

This works perfectly when each row in the outbox targets a different record. The problem emerges when the same record gets mutated multiple times before sync fires.

The scenario that breaks things:

User is offline. They do the following in sequence:

  1. Edit note text → outbox row 1: update note_abc { text: "revised text" }
  2. Re-discover ideas → outbox row 2: update note_abc { ideas: ["Soul", "Happiness"] }
  3. Edit source → outbox row 3: update note_abc { source: "Nicomachean Ethics, Book I" }

Sync comes online. Three separate PATCH/UPDATE calls fire against Supabase for the same record. Three problems:

Race condition. If these fire concurrently (or near-concurrently), the final state in Supabase depends on which one lands last. Row 3 might write source but its updated_at might be earlier than row 2’s, depending on network latency. You get a state in Supabase that doesn’t match what the user sees locally.

Wasted bandwidth. Three round trips for what’s logically one update.

Partial failure ambiguity. Row 1 syncs, row 2 fails (network blip), row 3 syncs. Now Supabase has the new text and new source but old ideas. The user’s local Dexie has all three changes applied. The databases have diverged, and the outbox has lost row 1 and 3 (they were deleted on success) but still holds row 2. When row 2 eventually syncs, it writes ideas but with an updated_at that’s now out of order.

The fix — collapse before sync, not at write time.

The important design choice is when to collapse. You have two options:

Option A — collapse at write time. When a new outbox entry is created, check if there’s already a pending entry for the same record and merge them. This keeps the outbox small but makes every write more expensive (read-before-write on the outbox) and introduces risk if the app crashes between the read and the write.

Option B — collapse at sync time. Let the outbox accumulate freely. When the sync worker wakes up, it groups pending entries by table + recordId, merges them into a single operation, sends one request, and deletes all the collapsed rows on success.

Option B is better. It’s simpler at write time, crash-safe (duplicate outbox rows are harmless — they just get collapsed next sync), and keeps the hot path fast.

The collapsing algorithm:

async function processOutbox() {
const pending = await db.outbox.orderBy('createdAt').toArray()
const grouped = groupByKey(pending, entry => `${entry.table}:${entry.recordId}`)
for (const [key, entries] of Object.entries(grouped)) {
const collapsed = collapseEntries(entries)
try {
await syncToSupabase(collapsed)
// Delete ALL original entries for this record
const ids = entries.map(e => e.id)
await db.outbox.bulkDelete(ids)
} catch (error) {
// Leave all entries in outbox — retry next sync
console.error(`Sync failed for ${key}:`, error)
}
}
}

The collapseEntries function is where the logic lives:

function collapseEntries(entries) {
// Entries are already ordered by createdAt (from the query)
const first = entries[0]
const last = entries[entries.length - 1]
// Check if any entry is a delete
const hasDelete = entries.some(e => e.operation === 'delete')
if (hasDelete) {
// Delete wins. Nothing else matters.
return {
table: first.table,
recordId: first.recordId,
operation: 'delete',
payload: { deleted_at: new Date().toISOString() }
}
}
// Check if the first entry is a create
if (first.operation === 'create') {
// Merge all subsequent updates INTO the create payload
const mergedPayload = entries.reduce(
(acc, entry) => ({ ...acc, ...entry.payload }),
{}
)
return {
table: first.table,
recordId: first.recordId,
operation: 'create',
payload: mergedPayload
}
}
// All updates — merge payloads in order, last write wins per field
const mergedPayload = entries.reduce(
(acc, entry) => ({ ...acc, ...entry.payload }),
{}
)
// Ensure updated_at reflects the final mutation
mergedPayload.updated_at = new Date().toISOString()
return {
table: first.table,
recordId: first.recordId,
operation: 'update',
payload: mergedPayload
}
}

The three collapsing rules:

1. Delete absorbs everything. If the user edits a note, rediscovers ideas, then deletes it — all while offline — the only thing that needs to sync is the soft delete. The edits are irrelevant. This is the simplest and most important rule.

2. Create absorbs subsequent updates. If a note is created offline, then edited, then ideas are discovered — all before sync — Supabase has never seen this record. Send a single INSERT with the final state. Don’t create then immediately update.

3. Updates merge with last-write-wins per field. Edit changes text, rediscover changes ideas, source edit changes source. The collapsed payload is { text, ideas, source, updated_at }. One PATCH call. Each field carries the value from whichever mutation touched it last.

What makes this safe:

The key property is that Dexie is always the source of truth for local state. The outbox doesn’t represent the current state — it represents pending sync operations. The user sees what’s in Dexie, which is always up-to-date because each mutation writes to Dexie immediately and synchronously (from the user’s perspective). The outbox is just the mechanism for eventually getting Supabase to match.

So if collapsing produces a slightly stale updated_at timestamp or drops an intermediate state, it doesn’t matter — Supabase only needs to end up matching Dexie’s current state, not replaying every step that got there.

One edge case worth handling — the outbox entry that’s currently mid-sync:

If the sync worker is in the middle of sending an entry and a new mutation comes in for the same record, you don’t want the new mutation to be collapsed with the in-flight one (because that one might succeed and get deleted). Solution: mark entries as syncing: true when the worker picks them up. The collapsing logic only groups entries where syncing === false.

// When sync worker picks up a batch:
await db.outbox.where('id').anyOf(entryIds).modify({ syncing: true })
// On success: delete them
// On failure: reset syncing flag
await db.outbox.where('id').anyOf(entryIds).modify({ syncing: false })

New mutations that arrive during sync are untouched and will be picked up in the next sync cycle.

What this changes in your codebase:

The write side stays clean — useNoteActions and any other hook just keeps calling db.outbox.add() as it does today. No change there. The collapsing logic lives entirely in the sync worker. It’s a single function that sits between “read the outbox” and “send to Supabase.” You can test it in isolation with synthetic outbox entries.

Want me to map out how this interacts with your existing soft-delete pattern specifically, or move on to another concern?

Estimated complexity breakdown:

ComponentEffortRisk
Action sheet UI + bottom sheetLowLow
Long-press gesture handlingMediumMedium — device testing needed
Edit form (pre-populated)LowLow
Add Source (autofocus variant)TrivialNone
Delete + confirmation + soft deleteLowLow
(Re)discover on saved notesMediumMedium — state management
Custom vs canonical tag preservationLow code, high decision weightLow once decided
Offline outbox for mutationsLow if current pattern holdsMedium if it doesn’t — verify first
Cross-view reactivity after mutationLow — Dexie’s liveQuery should handle thisLow