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__label">{{ section.label }}</span>
|
||||||
<span class="section__size">{{ section.size }}</span>
|
<span class="section__size">{{ section.size }}</span>
|
||||||
|
<slot name="overlay" :section-id="section.id" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,6 +105,8 @@ const hoveredId = ref<string | null>(null);
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
box-sizing: border-box;
|
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). */
|
/* ISO 216: every A-series sheet is landscape √2 : 1 (width : height). */
|
||||||
aspect-ratio: 1.41421356 / 1;
|
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">
|
<script setup lang="ts">
|
||||||
// Defect-viewing route (`/defects`) — the read-only board with the weak-point
|
// Defect-viewing route (`/defects`) — the read-only board with the weak-point
|
||||||
// map and feed. Same domain as reporting (ADR 0004).
|
// 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -10,8 +27,8 @@
|
|||||||
<p class="opacity-70">Every defect filed on the board.</p>
|
<p class="opacity-70">Every defect filed on the board.</p>
|
||||||
</header>
|
</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>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ review.
|
|||||||
|
|
||||||
## Phase 3 — See weak points (G2)
|
## Phase 3 — See weak points (G2)
|
||||||
- [x] **T6** Defects read API: feed + per-section counts (C7) — F6 (M) — _deps: T3_
|
- [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_
|
- [ ] **T8** VerbatimModal + Feed (C6) — F5 (M) — _deps: T6, T7_
|
||||||
- [ ] ⛳ **Checkpoint:** dashboard dot map + feed + modal work, ≤350ms @2k, human review
|
- [ ] ⛳ **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