A Radar gains an optional Concept — a short subtitle naming the product and its typical performances, rendered under the title and wrapping to a balanced second line when long. Old radars default to an empty Concept on load.
121 lines
3.0 KiB
TypeScript
121 lines
3.0 KiB
TypeScript
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)
|
|
if (!Array.isArray(parsed)) return []
|
|
// Default concept for radars saved before the field existed.
|
|
return parsed.map((r: Radar) => ({ ...r, concept: r.concept ?? '' }))
|
|
} 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,
|
|
concept: '',
|
|
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,
|
|
}
|
|
}
|