feat(dashboard): verbatim modal + defect feed on /defects (T8)

This commit is contained in:
Julien Calixte
2026-05-28 00:54:51 +02:00
parent 406a4d5ec5
commit 73e4c56a9e
9 changed files with 316 additions and 4 deletions

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
// The defect feed (C6, F5): every filed defect beside the board, newest-first.
// Pure presentation — the parent passes the already-ordered, bounded slice from
// `/api/defects` (T6); we don't refetch or re-sort. Each entry carries the date,
// section, project, reporter and the verbatim itself (the point of the feed).
import { sections } from '~/board/definition'
interface FeedDefect {
id: string
sectionId: string
projectName: string
reporterEmail: string
verbatim: string
createdAt: string | Date
}
defineProps<{ defects: FeedDefect[] }>()
const labelFor = (id: string) => sections.find((s) => s.id === id)?.label ?? id
</script>
<template>
<section class="flex w-full flex-col gap-3" data-test="defect-feed">
<h2 class="text-lg font-bold">Feed</h2>
<p v-if="defects.length === 0" class="opacity-60" data-test="feed-empty">
No defects filed yet.
</p>
<ul v-else class="flex flex-col gap-2">
<li
v-for="defect in defects"
:key="defect.id"
class="border-base-300 flex flex-col gap-1 border-l-2 pl-3"
data-test="feed-item"
>
<p class="text-sm">{{ defect.verbatim }}</p>
<p class="flex flex-wrap gap-x-2 text-xs opacity-60">
<span>{{ datestamp(defect.createdAt) }}</span>
<span>· {{ labelFor(defect.sectionId) }}</span>
<span>· {{ defect.projectName }}</span>
<span>· {{ defect.reporterEmail }}</span>
</p>
</li>
</ul>
</section>
</template>

View File

@@ -19,6 +19,10 @@ const props = defineProps<{
cap?: number
}>()
// Forward a Section click up so the page can open that section's verbatim modal
// (T8); BoardView owns the click, DotMap just relays the id.
defineEmits<{ select: [sectionId: string] }>()
const cap = computed(() => props.cap ?? 10)
// Group the feed by section, preserving its newest-first order.
@@ -56,7 +60,7 @@ function dotStyle(index: number) {
</script>
<template>
<BoardView>
<BoardView @select="$emit('select', $event)">
<template #overlay="{ sectionId }">
<span
v-for="(defect, index) in dotsFor(sectionId)"

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
// Per-section verbatim list (C6, F5): clicking a Section/dot on the board opens
// this modal over that section's defects without leaving the board. The list is
// loaded lazily — `/api/defects?section=ID` (T6) — only when a section opens, so
// a hot section's full history isn't paid for up front.
//
// Same native <dialog> (DaisyUI `modal`) as DefectForm: Escape, focus-trap and
// backdrop come for free, and the parent-controlled `sectionId` drives open/close.
import { sections } from '~/board/definition'
interface SectionDefect {
id: string
sectionId: string
projectName: string
reporterEmail: string
verbatim: string
createdAt: string
}
const props = defineProps<{ sectionId: string | null }>()
const emit = defineEmits<{ close: [] }>()
const dialog = ref<HTMLDialogElement>()
const defects = ref<SectionDefect[]>([])
const loading = ref(false)
const sectionLabel = computed(
() => sections.find((s) => s.id === props.sectionId)?.label ?? '',
)
// Lazily load the clicked section's history, bounded generously (well past the
// dot cap so the modal shows more than the map does).
async function load(sectionId: string) {
loading.value = true
defects.value = []
try {
defects.value = await $fetch<SectionDefect[]>('/api/defects', {
query: { section: sectionId, limit: 200 },
})
} finally {
loading.value = false
}
}
// Mirror the parent-controlled section into the dialog's open state.
function sync() {
const el = dialog.value
if (!el) return
if (props.sectionId !== null) {
if (!el.open) el.showModal()
load(props.sectionId)
} else if (el.open) {
el.close()
}
}
onMounted(sync)
watch(() => props.sectionId, sync)
</script>
<template>
<dialog ref="dialog" class="modal" @close="emit('close')">
<div class="modal-box">
<h3 class="text-lg font-bold">{{ sectionLabel }} defects</h3>
<p v-if="loading" class="mt-4 opacity-60">Loading</p>
<p
v-else-if="defects.length === 0"
class="mt-4 opacity-60"
data-test="modal-empty"
>
No defects filed against this section yet.
</p>
<ul v-else class="mt-4 flex flex-col gap-3">
<li
v-for="defect in defects"
:key="defect.id"
class="border-base-300 flex flex-col gap-1 border-l-2 pl-3"
data-test="modal-item"
>
<p class="text-sm">{{ defect.verbatim }}</p>
<p class="flex flex-wrap gap-x-2 text-xs opacity-60">
<span>{{ datestamp(defect.createdAt) }}</span>
<span>· {{ defect.projectName }}</span>
<span>· {{ defect.reporterEmail }}</span>
</p>
</li>
</ul>
<div class="modal-action">
<button type="button" class="btn btn-ghost" @click="dialog?.close()">
Close
</button>
</div>
</div>
<!-- Native backdrop: clicking it closes the dialog. -->
<form method="dialog" class="modal-backdrop">
<button aria-label="Close">close</button>
</form>
</dialog>
</template>

View File

@@ -6,6 +6,9 @@ import { sections } from '~/board/definition'
interface DefectView {
id: string
sectionId: string
projectName: string
reporterEmail: string
verbatim: string
createdAt: string
}
@@ -18,6 +21,10 @@ const { data: defects } = await useFetch<DefectView[]>('/api/defects', {
const { data: counts } = await useFetch<Record<string, number>>('/api/defects/counts', {
default: () => ({}),
})
// Which section's verbatim modal is open (null = closed). A click on any
// Section/dot in the DotMap sets it; the modal lazily loads that section (T8).
const openSection = ref<string | null>(null)
</script>
<template>
@@ -27,8 +34,10 @@ const { data: counts } = await useFetch<Record<string, number>>('/api/defects/co
<p class="opacity-70">Every defect filed on the board.</p>
</header>
<DotMap :defects="defects" :counts="counts" />
<DotMap :defects="defects" :counts="counts" @select="openSection = $event" />
<p class="opacity-60">The defect feed and verbatim modal (T8) land next.</p>
<DefectFeed :defects="defects" />
<VerbatimModal :section-id="openSection" @close="openSection = null" />
</main>
</template>

11
app/utils/datestamp.ts Normal file
View File

@@ -0,0 +1,11 @@
/**
* Compact, locale-free date stamp (DD/MM/YYYY, UTC) for defect listings.
* UTC keeps it deterministic across machines and test runs. DotMap keeps its
* own shorter DD/MM stamp for the cramped diagonal dot labels.
*/
export function datestamp(value: string | Date): string {
const d = new Date(value)
const dd = String(d.getUTCDate()).padStart(2, '0')
const mm = String(d.getUTCMonth() + 1).padStart(2, '0')
return `${dd}/${mm}/${d.getUTCFullYear()}`
}