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:
@@ -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
107
app/components/DotMap.vue
Normal 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 (~8–12) 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>
|
||||
@@ -1,6 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
// Defect-viewing route (`/defects`) — the read-only board with the weak-point
|
||||
// map and feed. Same domain as reporting (ADR 0004).
|
||||
import { sections } from '~/board/definition'
|
||||
|
||||
interface DefectView {
|
||||
id: string
|
||||
sectionId: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// Fetch enough of the feed to fill every section's dot cap, plus the
|
||||
// authoritative per-section totals that drive each "+N more" (T6).
|
||||
const { data: defects } = await useFetch<DefectView[]>('/api/defects', {
|
||||
query: { limit: sections.length * 12 },
|
||||
default: () => [],
|
||||
})
|
||||
const { data: counts } = await useFetch<Record<string, number>>('/api/defects/counts', {
|
||||
default: () => ({}),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -10,8 +27,8 @@
|
||||
<p class="opacity-70">Every defect filed on the board.</p>
|
||||
</header>
|
||||
|
||||
<BoardView />
|
||||
<DotMap :defects="defects" :counts="counts" />
|
||||
|
||||
<p class="opacity-60">The red-dot map (T7) and defect feed (T8) land next.</p>
|
||||
<p class="opacity-60">The defect feed and verbatim modal (T8) land next.</p>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -17,7 +17,7 @@ review.
|
||||
|
||||
## Phase 3 — See weak points (G2)
|
||||
- [x] **T6** Defects read API: feed + per-section counts (C7) — F6 (M) — _deps: T3_
|
||||
- [ ] **T7** Dashboard DotMap (C5) — F4 (M) — _deps: T2, T6_
|
||||
- [x] **T7** Dashboard DotMap (C5) — F4 (M) — _deps: T2, T6_
|
||||
- [ ] **T8** VerbatimModal + Feed (C6) — F5 (M) — _deps: T6, T7_
|
||||
- [ ] ⛳ **Checkpoint:** dashboard dot map + feed + modal work, ≤350ms @2k, human review
|
||||
|
||||
|
||||
59
tests/dotmap.nuxt.spec.ts
Normal file
59
tests/dotmap.nuxt.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
// @vitest-environment nuxt
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mountSuspended } from '@nuxt/test-utils/runtime'
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import DotMap from '../app/components/DotMap.vue'
|
||||
|
||||
// The section box the overlay dots are rendered inside (BoardView marks each
|
||||
// section with `data-section-id`).
|
||||
const section = (wrapper: VueWrapper, id: string) =>
|
||||
wrapper.find(`[data-section-id="${id}"]`)
|
||||
|
||||
describe('DotMap', () => {
|
||||
it('renders a red dot with a date label inside the defect’s section', async () => {
|
||||
const wrapper = await mountSuspended(DotMap, {
|
||||
props: {
|
||||
defects: [{ id: 'd1', sectionId: 'macroplan', createdAt: '2026-05-28T10:00:00Z' }],
|
||||
counts: { macroplan: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
const dots = section(wrapper, 'macroplan').findAll('[data-test="dot"]')
|
||||
expect(dots).toHaveLength(1)
|
||||
expect(dots[0]!.text()).toContain('28/05')
|
||||
|
||||
// A section with no defects shows no dots.
|
||||
expect(section(wrapper, 'problem-solving').findAll('[data-test="dot"]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('caps visible dots and collapses the overflow to "+N more"', async () => {
|
||||
const cap = 10
|
||||
const defects = Array.from({ length: 15 }, (_, i) => ({
|
||||
id: `d${i}`,
|
||||
sectionId: 'feature-kanban',
|
||||
createdAt: '2026-05-28T10:00:00Z',
|
||||
}))
|
||||
|
||||
const wrapper = await mountSuspended(DotMap, {
|
||||
// Feed holds 15 for this section but the true total is 42 (counts).
|
||||
props: { defects, counts: { 'feature-kanban': 42 }, cap },
|
||||
})
|
||||
|
||||
const box = section(wrapper, 'feature-kanban')
|
||||
expect(box.findAll('[data-test="dot"]')).toHaveLength(cap)
|
||||
const more = box.find('[data-test="more"]')
|
||||
expect(more.exists()).toBe(true)
|
||||
expect(more.text()).toContain('+32 more') // 42 total − 10 shown
|
||||
})
|
||||
|
||||
it('shows no overflow marker when a section is at or below the cap', async () => {
|
||||
const wrapper = await mountSuspended(DotMap, {
|
||||
props: {
|
||||
defects: [{ id: 'd1', sectionId: 'macroplan', createdAt: '2026-05-28T10:00:00Z' }],
|
||||
counts: { macroplan: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
expect(section(wrapper, 'macroplan').find('[data-test="more"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user