feat(dashboard): red-dot weak-point map on /defects (T7)

Overlay each filed defect as a red dot inside its section box with a
diagonal date label; cap visible dots and collapse the remainder to a
"+N more" using the authoritative per-section counts (T6).

Add an `overlay` scoped slot to BoardView (C2) so the dashboard can layer
dots without forking the board definition (F1); the reporting view leaves
it empty. DotMap groups the bounded feed by section and the /defects page
wires both endpoints.
This commit is contained in:
Julien Calixte
2026-05-28 00:31:17 +02:00
parent c6b991646a
commit 3e5f7d3643
5 changed files with 189 additions and 3 deletions

View File

@@ -40,6 +40,7 @@ const hoveredId = ref<string | null>(null);
>
<span class="section__label">{{ section.label }}</span>
<span class="section__size">{{ section.size }}</span>
<slot name="overlay" :section-id="section.id" />
</button>
</div>
</div>
@@ -104,6 +105,8 @@ const hoveredId = ref<string | null>(null);
cursor: pointer;
padding: 0.5rem;
box-sizing: border-box;
/* Positioning context for the dashboard's absolutely-placed dots (C5). */
position: relative;
/* ISO 216: every A-series sheet is landscape √2 : 1 (width : height). */
aspect-ratio: 1.41421356 / 1;
}

107
app/components/DotMap.vue Normal file
View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
// The weak-point dot map (C5, F4): overlays each defect as a red dot inside its
// Section box with a diagonal date label. Visible dots are capped (~812) and
// the remainder collapses to a "+N more" marker, so a hot Section stays legible
// (DESIGN §5, ADR T7 — dot positions are cosmetic; the Section is the pin).
//
// Reuses BoardView (C2) via its `overlay` slot, and the feed + per-section
// counts from T6: `defects` are the newest-first feed slice (bounded), `counts`
// the true per-section totals that drive the "+N more" arithmetic.
interface DotDefect {
id: string
sectionId: string
createdAt: string | Date
}
const props = defineProps<{
defects: DotDefect[]
counts: Record<string, number>
cap?: number
}>()
const cap = computed(() => props.cap ?? 10)
// Group the feed by section, preserving its newest-first order.
const bySection = computed(() => {
const map: Record<string, DotDefect[]> = {}
for (const d of props.defects) (map[d.sectionId] ??= []).push(d)
return map
})
const dotsFor = (id: string) => (bySection.value[id] ?? []).slice(0, cap.value)
// Hidden defects = true total (counts, authoritative) minus what we drew.
function overflowFor(id: string): number {
const total = props.counts[id] ?? bySection.value[id]?.length ?? 0
return Math.max(0, total - dotsFor(id).length)
}
// Compact, locale-free date stamp (DD/MM, UTC) for the diagonal label.
function formatDate(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}`
}
// Cosmetic scatter: a stable 5-wide grid inside the box (ADR T7).
function dotStyle(index: number) {
return {
left: `${12 + (index % 5) * 17}%`,
top: `${20 + Math.floor(index / 5) * 22}%`,
}
}
</script>
<template>
<BoardView>
<template #overlay="{ sectionId }">
<span
v-for="(defect, index) in dotsFor(sectionId)"
:key="defect.id"
class="dot"
:style="dotStyle(index)"
data-test="dot"
>
<span class="dot__date">{{ formatDate(defect.createdAt) }}</span>
</span>
<span v-if="overflowFor(sectionId) > 0" class="dot-more" data-test="more">
+{{ overflowFor(sectionId) }} more
</span>
</template>
</BoardView>
</template>
<style scoped>
.dot {
position: absolute;
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background: #e11d48;
/* Centre the dot on its grid point so labels splay outward consistently. */
transform: translate(-50%, -50%);
}
.dot__date {
position: absolute;
left: 0.7rem;
bottom: 0.7rem;
font-size: 0.6rem;
line-height: 1;
white-space: nowrap;
/* Diagonal label, as on the physical board. */
transform: rotate(-45deg);
transform-origin: bottom left;
}
.dot-more {
position: absolute;
right: 0.4rem;
bottom: 0.4rem;
font-size: 0.7rem;
font-weight: 600;
color: #e11d48;
}
</style>