feat: implement radar app — list, editor, chart, scoring, PNG export

- vue-router with list (/) and editor (/radar/:id) views
- localStorage persistence via useRadars() composable
- types.ts: Radar / Criterion / Profile / Score with cardinality constants
- RadarChart.vue: SVG renderer (rings, axes, profile polygons, labels, legend)
- CriteriaEditor / ProfilesEditor / ScoreGrid components
- PNG export: SVG → canvas → blob, download + clipboard copy
- Remove 'coming soon' placeholder from App.vue
This commit is contained in:
Julien Calixte
2026-06-16 22:06:11 +02:00
parent a8b83b79ce
commit 636fba170f
14 changed files with 1294 additions and 22 deletions

117
src/storage.ts Normal file
View File

@@ -0,0 +1,117 @@
import { ref, watch } from 'vue'
import type { Radar, Criterion, Profile } from './types'
import { MIN_CRITERIA, MIN_PROFILES, PROFILE_PALETTE } from './types'
const STORAGE_KEY = 'product-radar:radars'
function loadAll(): Radar[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
const radars = ref<Radar[]>(loadAll())
watch(
radars,
(next) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
},
{ deep: true },
)
function uid(): string {
return crypto.randomUUID()
}
function blankCriterion(name = ''): Criterion {
return { id: uid(), name }
}
function blankProfile(name = '', colorIndex = 0): Profile {
return { id: uid(), name, color: PROFILE_PALETTE[colorIndex % PROFILE_PALETTE.length] }
}
function ensureScores(radar: Radar): void {
for (const profile of radar.profiles) {
if (!radar.scores[profile.id]) radar.scores[profile.id] = {}
for (const criterion of radar.criteria) {
if (radar.scores[profile.id][criterion.id] == null) {
radar.scores[profile.id][criterion.id] = 0
}
}
}
// garbage-collect scores for removed entities
for (const profileId of Object.keys(radar.scores)) {
if (!radar.profiles.find((p) => p.id === profileId)) {
delete radar.scores[profileId]
continue
}
for (const criterionId of Object.keys(radar.scores[profileId])) {
if (!radar.criteria.find((c) => c.id === criterionId)) {
delete radar.scores[profileId][criterionId]
}
}
}
}
export function useRadars() {
function list(): Radar[] {
return radars.value
}
function get(id: string): Radar | undefined {
return radars.value.find((r) => r.id === id)
}
function createBlank(name = 'Untitled radar'): Radar {
const criteria: Criterion[] = []
for (let i = 0; i < MIN_CRITERIA; i++) criteria.push(blankCriterion(`Criterion ${i + 1}`))
const profiles: Profile[] = []
for (let i = 0; i < MIN_PROFILES; i++) profiles.push(blankProfile(`Profile ${i + 1}`, i))
const now = Date.now()
const radar: Radar = {
id: uid(),
name,
criteria,
profiles,
scores: {},
createdAt: now,
updatedAt: now,
}
ensureScores(radar)
radars.value.push(radar)
return radar
}
function update(id: string, mutate: (r: Radar) => void): void {
const radar = get(id)
if (!radar) return
mutate(radar)
ensureScores(radar)
radar.updatedAt = Date.now()
}
function remove(id: string): void {
const idx = radars.value.findIndex((r) => r.id === id)
if (idx >= 0) radars.value.splice(idx, 1)
}
return {
radars,
list,
get,
createBlank,
update,
remove,
blankCriterion,
blankProfile,
}
}