Compare commits

..

2 Commits

Author SHA1 Message Date
Julien Calixte
08d2d804ff feat(notes): show loading and failed states for stacked notes
All checks were successful
CI / verify (push) Successful in 1m3s
A note that couldn't load rendered a silent blank div. Add a calm
NoteState placeholder (spinner while loading, message + retry on
failure) and gate the read view on it; content still wins when present.
2026-06-29 00:31:09 +02:00
Julien Calixte
8f50259efe docs: record two-key cache strategy for reference modes
Decide the C3/C4 cache shape: content keyed by its own SHA (write-once,
immutable snapshot store) plus a path key holding the latest content
(live pointer). Notes the current immutability violation to fix.
2026-06-29 00:31:04 +02:00
5 changed files with 109 additions and 8 deletions

View File

@@ -407,6 +407,7 @@ Cells: link strength (9/3/1/blank). Importance row = Σ(weight × strength).
- **Asymmetric file index:** `addFile` deduped by SHA only, `registerUploadedFile` by PATH — so an edit (same path, new SHA) left a stale duplicate entry. **Fixed**`addFile` now dedups by SHA *and* path (a path is unique), with a regression test in `userRepo.store.spec.ts`. (Full bidirectional index C3 still the longer-term direction.)
- **ADR-0001 over-absolute:** it called SHA-keyed references "a fragility". **Refined** by ADR-0002 into two intentional modes (Live/Snapshot).
- **`CLAUDE.md` "edit history tracking"** mislabels the visited-repos History; no edit-history feature exists. (Flagged in CONTEXT.md.)
- **Snapshot cache not immutable:** editing writes new content under the *viewed* (old) SHA's cache key (`prepareNoteCache` keys by the viewed sha), so a re-opened old-SHA snapshot can serve new content from cache. **Decided fix (ADR-0002):** key the immutable store by the content's *own* SHA (write-once), keep the Path key as the latest pointer. *(Not yet implemented — the C3/C4 cache slice; unlocks the snapshot banner.)*
- **Naming drift carried from the language session:** `Flux``Igarapé`, `HistoricNotes``Index`, `Repetition``Card`, `PublicNote*``PublishedNote*`, `useNotes()` returns Files not Notes. (Tracked in CONTEXT.md "Flagged ambiguities".)
---

View File

@@ -17,4 +17,5 @@ This refines ADR-0001, which framed SHA-keyed references as "a fragility." That
## Consequences
- The system **pre-caches** aggressively (content is cached by both SHA and Path on every fetch / freshness pull) so the pinned version is usually present, including offline.
- **Cache shape (C3/C4):** content is cached under two keys — its own **content SHA** (write-once, immutable → the snapshot store) and its **Path** (overwritten with the latest content → the live pointer). Both hold the full content, so the latest survives even if a SHA entry is evicted, maximizing offline availability for any version already encountered. (Today the code violates this by writing edited content under the *viewed* old SHA key; the fix is to write under the content's *new* SHA and never overwrite an existing SHA entry.)
- When a Snapshot reference's pinned content is genuinely unavailable (never-fetched + offline — common on a flaky mobile connection), the system **falls back to the most up-to-date cached version and shows a banner** disclosing "this is the latest available, not the exact shared version." Integrity is preserved by **disclosure, not refusal**: the reader is never silently shown different content, but is also never dead-ended on the metro. (This chooses graceful continuity over a hard "unavailable" stop, given mobile is the primary read context.)

View File

@@ -0,0 +1,29 @@
import { mount } from "@vue/test-utils"
import { describe, expect, it } from "vitest"
import NoteState from "./NoteState.vue"
describe("NoteState", () => {
it("shows a spinner while loading and no retry", () => {
const wrapper = mount(NoteState, { props: { status: "loading" } })
expect(wrapper.find(".loading-spinner").exists()).toBe(true)
expect(wrapper.find(".note-state-retry").exists()).toBe(false)
})
it("shows a message and retry button when failed", () => {
const wrapper = mount(NoteState, { props: { status: "failed" } })
expect(wrapper.find(".loading-spinner").exists()).toBe(false)
expect(wrapper.text()).toContain("Couldn't load this note")
expect(wrapper.find(".note-state-retry").exists()).toBe(true)
})
it("emits retry when the retry button is clicked", async () => {
const wrapper = mount(NoteState, { props: { status: "failed" } })
await wrapper.find(".note-state-retry").trigger("click")
expect(wrapper.emitted("retry")).toHaveLength(1)
})
})

View File

@@ -0,0 +1,48 @@
<script lang="ts" setup>
defineProps<{ status: "loading" | "failed" }>()
defineEmits<{ retry: [] }>()
</script>
<template>
<div class="note-state">
<span
v-if="status === 'loading'"
class="loading loading-spinner loading-md"
aria-label="Loading note"
></span>
<template v-else>
<p class="note-state-message">Couldn't load this note.</p>
<button type="button" class="note-state-retry" @click="$emit('retry')">
Retry
</button>
</template>
</div>
</template>
<style scoped lang="scss">
.note-state {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2rem 1rem;
color: var(--color-base-content);
opacity: 0.55;
text-align: center;
}
.note-state-message {
margin: 0;
}
.note-state-retry {
padding: 0.25rem 0.85rem;
border: 1px solid currentColor;
border-radius: 0.375rem;
background: transparent;
color: inherit;
cursor: pointer;
}
</style>

View File

@@ -40,6 +40,10 @@ const EditNote = defineAsyncComponent(
() => import("@/modules/note/components/EditNote.vue")
)
const NoteState = defineAsyncComponent(
() => import("@/components/NoteState.vue")
)
const props = defineProps<{
user: string
repo: string
@@ -154,10 +158,21 @@ const {
})
const conflictOpen = ref(false)
const loadStatus = ref<"loading" | "ready" | "failed">("loading")
onMounted(async () => {
initialRawContent.value = await getRawContent()
})
const loadNote = async () => {
loadStatus.value = "loading"
const raw = await getRawContent()
if (raw === null) {
loadStatus.value = "failed"
return
}
rawContent.value = raw
initialRawContent.value = raw
loadStatus.value = "ready"
}
onMounted(loadNote)
watch(
path,
@@ -412,11 +427,18 @@ const onBadgeClick = async () => {
<div v-if="mode === 'edit' && isMarkdown" class="edit">
<edit-note :key="editKey" v-model="rawContent" />
</div>
<div
v-if="mode === 'read'"
class="note-content"
v-html="displayedContent"
></div>
<template v-else-if="mode === 'read'">
<div
v-if="displayedContent"
class="note-content"
v-html="displayedContent"
></div>
<note-state
v-else
:status="loadStatus === 'failed' ? 'failed' : 'loading'"
@retry="loadNote"
/>
</template>
</section>
<linked-notes v-if="hasBacklinks && content" :sha="sha" />
<note-conflict-modal