Compare commits

...

9 Commits

Author SHA1 Message Date
Julien Calixte
bd7613e2d0 docs: document dual Coolify and Pages deploy in README
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 21s
Deploy to GitHub Pages / deploy (push) Has been skipped
2026-06-26 14:45:01 +01:00
Julien Calixte
01eece9796 docs: correct persistence backend to localStorage in README 2026-06-26 14:44:46 +01:00
Julien Calixte
9616ab749d docs: link DESIGN.md and ADRs from README
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 21s
Deploy to GitHub Pages / deploy (push) Has been skipped
2026-06-26 14:40:12 +01:00
Julien Calixte
9974d899c2 docs: add QFD design doc with House of Quality 2026-06-26 14:39:48 +01:00
Julien Calixte
8d46c0f98b docs: add ADR-0001 for self-contained share links 2026-06-26 14:39:41 +01:00
Julien Calixte
7a22e40268 feat: reorder criteria and profiles by dragging
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 1m8s
Deploy to GitHub Pages / deploy (push) Has been skipped
Add a drag handle to each criterion and profile row, backed by a
pointer-based composable that works with mouse and touch. Reordering
criteria changes the chart spoke order; reordering profiles changes
draw/legend order. Scores are keyed by id, so they follow their row.
2026-06-26 10:36:00 +01:00
Julien Calixte
a51da74cbb chore(ci): add GitHub Pages build and deploy workflow
Some checks failed
Deploy to GitHub Pages / build (push) Failing after 21s
Deploy to GitHub Pages / deploy (push) Has been skipped
2026-06-19 18:23:16 +02:00
Julien Calixte
a371f11811 feat: share radars via self-contained URL links
The app has no backend, so a share link embeds the whole radar as
base64url in the query string. Opening /share?d=... validates and
imports a fresh copy into the recipient's radars, then opens it.
2026-06-19 15:43:43 +02:00
Julien Calixte
3f9dd514ce feat: add duplicate button to radar list cards 2026-06-19 15:43:33 +02:00
13 changed files with 867 additions and 9 deletions

57
.github/workflows/deploy-pages.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: Deploy to GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
# Allow the deploy job to publish to Pages via OIDC.
permissions:
contents: read
pages: write
id-token: write
# Only one Pages deployment at a time; cancel superseded runs.
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
# Served from a custom domain at the root, so no --base override.
- run: pnpm build
# SPA fallback: vue-router runs in history mode, so unmatched paths
# (deep links, refreshes) must fall through to the app instead of 404.
- run: cp dist/index.html dist/404.html
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4

478
DESIGN.md Normal file
View File

@@ -0,0 +1,478 @@
# Product Radar — Design (QFD)
Goal-driven design for Product Radar: a solo, browser-local tool for positioning a
product against alternatives on a radar chart. This document translates user-facing
goals into measurable functions, the components that realise them, and the trade-offs
taken. It relies on [CONTEXT.md](./CONTEXT.md) for the ubiquitous language (Radar,
Criterion, Profile, Score, Concept) and does not redefine those terms here.
Strength weights used in matrices: **9** strong, **3** medium, **1** weak, blank none.
---
## House of Quality
```tikz
% =====================================================================
% QFD "House of Quality" preamble
% =====================================================================
\usetikzlibrary{arrows.meta, positioning, shapes.geometric, shapes.misc, calc, fit, backgrounds}
\newif\ifqfdshowroof \qfdshowrooftrue
\newif\ifqfdshowbasement \qfdshowbasementtrue
\newif\ifqfdshowcompetitive \qfdshowcompetitivetrue
\newif\ifqfdshowlegend \qfdshowlegendtrue
\newif\ifqfdshowimportance \qfdshowimportancetrue
\newif\ifqfdshowcorrlegend \qfdshowcorrlegendtrue
\newif\ifqfdshowevallegend \qfdshowevallegendtrue
\newif\ifqfdshowtitle \qfdshowtitletrue
\def\qfdNW{5}
\def\qfdNH{5}
\def\qfdWhatW{4.0}
\def\qfdImpW{0.9}
\def\qfdCmpW{3}
\def\qfdHdrH{2.6}
\def\qfdBasementN{4}
\def\qfdWhatsTitle{Customer needs}
\def\qfdImpTitle{Imp.\ \%}
\def\qfdPerceptionTitle{Comparative evaluation}
\def\qfdPoorLabel{poor}
\def\qfdExcellentLabel{excellent}
\def\qfdAltOneLabel{Our product}
\def\qfdAltTwoLabel{Competitor A}
\def\qfdAltThreeLabel{Competitor B}
\def\qfdRelTitle{Relation}
\def\qfdCorrTitle{Correlation}
\def\qfdEvalTitle{Evaluation}
\def\qfdProjectTitle{}
\def\qfdConcept{}
\tikzset{
qfdthin/.style ={line width=0.35pt},
qfdmed/.style ={line width=0.7pt},
qfdstrong/.style={circle, draw, fill=black,
minimum size=7pt, inner sep=0pt},
qfdmod/.style ={circle, draw,
minimum size=7pt, inner sep=0pt, line width=0.8pt},
qfdweak/.style ={regular polygon, regular polygon sides=3, draw,
minimum size=8.5pt, inner sep=0pt, line width=0.7pt},
qfdrel/.is choice,
qfdrel/S/.style={qfdstrong},
qfdrel/M/.style={qfdmod},
qfdrel/W/.style={qfdweak},
qfdalt1mk/.style={circle, draw, fill=black,
minimum size=6pt, inner sep=0pt, line width=1pt},
qfdalt1ln/.style={line width=1.2pt},
qfdalt2mk/.style={regular polygon, regular polygon sides=3, draw,
fill=black, minimum size=6pt, inner sep=0pt,
line width=0.7pt},
qfdalt2ln/.style={line width=0.7pt, dashed},
qfdalt3mk/.style={rectangle, draw, fill=black,
minimum size=5pt, inner sep=0pt, line width=0.7pt},
qfdalt3ln/.style={line width=0.7pt, dotted},
}
\newcommand{\qfdDrawGrid}{%
\foreach \c in {1,...,\qfdNHm} \draw[qfdthin] (\c, 0) -- (\c, -\qfdNW);
\foreach \r in {1,...,\qfdNWm} \draw[qfdthin] (0, -\r) -- (\qfdNH, -\r);
\foreach \r in {1,...,\qfdNWm}
\draw[qfdthin] (\qfdLeftEdge, -\r) -- (0, -\r);
\ifqfdshowroof
\foreach \c in {1,...,\qfdNHm}
\draw[qfdthin] (\c, 0) -- (\c, \qfdHdrH);
\fi
\ifqfdshowcompetitive
\foreach \r in {1,...,\qfdNWm}
\draw[qfdthin] (\qfdNH, -\r) -- (\qfdNH+\qfdCmpW, -\r);
\fi
\ifqfdshowbasement
\foreach \r in {1,...,\qfdBasementN}
\draw[qfdthin] (0, -\qfdNW-\r) -- (\qfdNH, -\qfdNW-\r);
\foreach \c in {1,...,\qfdNHm}
\draw[qfdthin] (\c, -\qfdNW) -- (\c, -\qfdNW-\qfdBasementN);
\fi
}
\newcommand{\qfdDrawRoof}{%
\ifqfdshowroof
\foreach \k in {1,...,\qfdNHm} {%
\pgfmathsetmacro{\rx}{(\k+\qfdNH)/2}
\pgfmathsetmacro{\ry}{\qfdHdrH + (\qfdNH-\k)/2}
\pgfmathsetmacro{\lx}{\k/2}
\pgfmathsetmacro{\ly}{\qfdHdrH + \k/2}
\draw[qfdthin] (\k, \qfdHdrH) -- (\rx, \ry);
\draw[qfdthin] (\k, \qfdHdrH) -- (\lx, \ly);
}%
\draw[qfdmed] (0, \qfdHdrH)
-- (\qfdNH/2, \qfdApexY) -- (\qfdNH, \qfdHdrH);
\foreach \i in {1,...,\qfdNH}
\foreach \k in {1,...,\qfdNH} {%
\pgfmathtruncatemacro{\jj}{\i+\k}
\ifnum\jj>\qfdNH\relax\else
\pgfmathsetmacro{\xx}{\i + \k/2 - 0.5}
\pgfmathsetmacro{\yy}{\qfdHdrH + \k/2}
\coordinate (C-\i-\jj) at (\xx, \yy);
\fi
}%
\fi
}
\newcommand{\qfdDrawScale}{%
\ifqfdshowcompetitive
\foreach \tk in {0,1,2,3,4,5} {%
\pgfmathsetmacro{\tx}{\qfdNH + (\tk+0.5)*\qfdCmpW/6}
\node[anchor=south, font=\scriptsize] at (\tx, 0.02) {\tk};
}%
\node[anchor=south, font=\scriptsize\bfseries, align=center]
at ({\qfdNH + \qfdCmpW/2}, 0.7) {\qfdPerceptionTitle};
\node[anchor=north, font=\scriptsize\itshape]
at ({\qfdNH + 0.45}, -\qfdNW) {\qfdPoorLabel};
\node[anchor=north, font=\scriptsize\itshape]
at ({\qfdNH + \qfdCmpW - 0.45}, -\qfdNW) {\qfdExcellentLabel};
\fi
}
\newcommand{\qfdDrawZoneTitles}{%
\ifqfdshowimportance
\node[rotate=90, anchor=west, font=\footnotesize\bfseries]
at ({-\qfdImpW/2}, 0.12) {\qfdImpTitle};
\fi
\node[font=\scriptsize\bfseries, align=center, text width=\qfdWhatW cm]
at ({\qfdLeftEdge + \qfdWhatW/2},
{\ifqfdshowroof \qfdHdrH/2 \else 0.6 \fi}) {\qfdWhatsTitle};
}
\newcommand{\qfdDrawTitle}{%
\ifqfdshowtitle
\ifx\qfdProjectTitle\empty\else
\pgfmathsetmacro{\qfdTitleX}{\qfdNH/2}
\pgfmathsetmacro{\qfdTitleY}{\ifqfdshowroof \qfdApexY \else \qfdHdrH \fi + 0.9}
\pgfmathsetmacro{\qfdSubW}{\qfdNH + 2}
\node[anchor=south, font=\large\bfseries, align=center]
at (\qfdTitleX, \qfdTitleY) {\qfdProjectTitle};
\ifx\qfdConcept\empty\else
\node[anchor=north, font=\footnotesize\itshape, align=center,
text width=\qfdSubW cm]
at (\qfdTitleX, {\qfdTitleY - 0.1}) {\qfdConcept};
\fi
\fi
\fi
}
\newcommand{\qfdDrawFrames}{%
\begin{scope}[qfdmed]
\draw (\qfdLeftEdge, 0) rectangle (\qfdNH, -\qfdNW);
\ifqfdshowimportance \draw (-\qfdImpW, 0) -- (-\qfdImpW, -\qfdNW); \fi
\draw (0, 0) -- (0, -\qfdNW);
\ifqfdshowroof
\draw (0, 0) rectangle (\qfdNH, \qfdHdrH); \fi
\ifqfdshowbasement
\draw (0, -\qfdNW) rectangle (\qfdNH, -\qfdNW-\qfdBasementN); \fi
\ifqfdshowcompetitive
\draw (\qfdNH, 0) rectangle (\qfdNH+\qfdCmpW, -\qfdNW); \fi
\end{scope}
}
\newcommand{\qfdDrawLegend}{%
\ifqfdshowlegend
\pgfmathsetmacro{\qfdLegX}{%
\qfdNH + \ifqfdshowcompetitive \qfdCmpW + 0.7 \else 0.7 \fi}
\pgfmathsetmacro{\qfdLegBottom}{%
-2.05
\ifqfdshowroof \ifqfdshowcorrlegend - 2.55 \fi \fi
\ifqfdshowcompetitive \ifqfdshowevallegend - 2.20 \fi \fi}
\pgfmathsetmacro{\qfdLegY}{\qfdHdrH - 0.4}
\begin{scope}[shift={(\qfdLegX, \qfdLegY)}]
\draw[qfdmed, rounded corners=2pt]
(-0.15, 0.4) rectangle (4.5, \qfdLegBottom);
\node[anchor=west, font=\footnotesize\bfseries] at (0, 0.1)
{\qfdRelTitle};
\draw[qfdthin] (0, -0.15) -- (4.35, -0.15);
\node[qfdstrong] at (0.22, -0.5) {};
\node[anchor=west] at (0.5, -0.5) {Strong (9)};
\node[qfdmod] at (0.22, -0.95) {};
\node[anchor=west] at (0.5, -0.95) {Medium (3)};
\node[qfdweak] at (0.22, -1.4) {};
\node[anchor=west] at (0.5, -1.4) {Weak (1)};
\ifqfdshowroof \ifqfdshowcorrlegend
\node[anchor=west, font=\footnotesize\bfseries] at (0, -2.10)
{\qfdCorrTitle};
\draw[qfdthin] (0, -2.35) -- (4.35, -2.35);
\node[anchor=west] at (0, -2.70) {{$+\!+$}\quad very positive};
\node[anchor=west] at (0, -3.05) {{$+$\phantom{$+$}}\quad positive};
\node[anchor=west] at (0, -3.40) {{$-$\phantom{$-$}}\quad negative};
\node[anchor=west] at (0, -3.75) {{$-\!-$}\quad very negative};
\fi \fi
\ifqfdshowcompetitive \ifqfdshowevallegend
\pgfmathsetmacro{\qfdEvalTop}{%
-2.10 \ifqfdshowroof\ifqfdshowcorrlegend - 2.55 \fi\fi}
\node[anchor=west, font=\footnotesize\bfseries]
at (0, \qfdEvalTop) {\qfdEvalTitle};
\pgfmathsetmacro{\qfdEvalSep}{\qfdEvalTop - 0.25}
\draw[qfdthin] (0, \qfdEvalSep) -- (4.35, \qfdEvalSep);
\pgfmathsetmacro{\qfdLegA}{\qfdEvalTop - 0.55}
\draw[qfdalt1ln] (0.05, \qfdLegA) -- (0.45, \qfdLegA);
\node[qfdalt1mk] at (0.25, \qfdLegA) {};
\node[anchor=west, font=\bfseries] at (0.55, \qfdLegA)
{\qfdAltOneLabel};
\pgfmathsetmacro{\qfdLegB}{\qfdEvalTop - 0.95}
\draw[qfdalt2ln] (0.05, \qfdLegB) -- (0.45, \qfdLegB);
\node[qfdalt2mk] at (0.25, \qfdLegB) {};
\node[anchor=west] at (0.55, \qfdLegB) {\qfdAltTwoLabel};
\pgfmathsetmacro{\qfdLegC}{\qfdEvalTop - 1.35}
\draw[qfdalt3ln] (0.05, \qfdLegC) -- (0.45, \qfdLegC);
\node[qfdalt3mk] at (0.25, \qfdLegC) {};
\node[anchor=west] at (0.55, \qfdLegC) {\qfdAltThreeLabel};
\fi \fi
\end{scope}
\fi
}
\newenvironment{qfdhouse}{%
\begin{tikzpicture}[x=1cm, y=1cm, font=\scriptsize,
line cap=round, line join=round]
\ifqfdshowimportance
\pgfmathsetmacro{\qfdLeftEdge}{-\qfdWhatW-\qfdImpW}
\else
\pgfmathsetmacro{\qfdLeftEdge}{-\qfdWhatW}
\fi
\pgfmathsetmacro{\qfdApexY}{\qfdHdrH + \qfdNH/2}
\pgfmathtruncatemacro{\qfdNHm}{\qfdNH - 1}
\pgfmathtruncatemacro{\qfdNWm}{\qfdNW - 1}
\qfdDrawGrid
\qfdDrawRoof
\qfdDrawScale
\qfdDrawZoneTitles
\qfdDrawTitle
}{%
\qfdDrawFrames
\qfdDrawLegend
\end{tikzpicture}%
}
% --- Product Radar overrides ---
\def\qfdNW{4}
\def\qfdNH{6}
\def\qfdWhatW{3.8}
\def\qfdHdrH{3.6}
\def\qfdWhatsTitle{Goals}
\def\qfdImpTitle{Imp.}
\qfdshowcompetitivefalse
\def\qfdProjectTitle{Product Radar}
\def\qfdConcept{Score up to 5 \textbf{Profiles} against shared
\textbf{Criteria} on a \textbf{radar chart} to position a product
at a glance, then share it as a self-contained link.}
\begin{document}
\begin{qfdhouse}
% --- WHATs (Goals) + importance weights (1-10) ---
\pgfmathsetmacro{\qfdWhatTextW}{\qfdWhatW - 0.2}
\foreach \r/\t in {1/{Position at a glance}, 2/{Communicate it},
3/{Zero-setup persistence}, 4/{Low-friction editing}}
\node[anchor=west, font=\scriptsize, text width=\qfdWhatTextW cm, align=left]
at ({\qfdLeftEdge + 0.1}, {-\r + 0.5}) {\t};
\foreach \r/\imp in {1/10, 2/8, 3/7, 4/6}
\node[font=\scriptsize] at ({-\qfdImpW/2}, {-\r + 0.5}) {\imp};
% --- HOWs (Functions, rotated) ---
\foreach \c/\t in {1/{Live chart redraw}, 2/{Distinct Profiles},
3/{Faithful PNG export}, 4/{Self-contained link},
5/{Browser-local library}, 6/{Valid, fluid editing}}
\node[rotate=90, anchor=west, font=\scriptsize] at ({\c - 0.5}, 0.15) {\t};
% --- Relations (Goals x Functions) ---
\node[qfdrel/S] at ({1 - 0.5}, {-1 + 0.5}) {};
\node[qfdrel/S] at ({2 - 0.5}, {-1 + 0.5}) {};
\node[qfdrel/M] at ({6 - 0.5}, {-1 + 0.5}) {};
\node[qfdrel/M] at ({2 - 0.5}, {-2 + 0.5}) {};
\node[qfdrel/S] at ({3 - 0.5}, {-2 + 0.5}) {};
\node[qfdrel/S] at ({4 - 0.5}, {-2 + 0.5}) {};
\node[qfdrel/W] at ({4 - 0.5}, {-3 + 0.5}) {};
\node[qfdrel/S] at ({5 - 0.5}, {-3 + 0.5}) {};
\node[qfdrel/S] at ({1 - 0.5}, {-4 + 0.5}) {};
\node[qfdrel/M] at ({5 - 0.5}, {-4 + 0.5}) {};
\node[qfdrel/S] at ({6 - 0.5}, {-4 + 0.5}) {};
% --- Roof correlations (Function x Function) ---
\node[font=\scriptsize] at (C-1-2) {$+$};
\node[font=\scriptsize] at (C-1-3) {$+\!+$};
\node[font=\scriptsize] at (C-1-5) {$-$};
\node[font=\scriptsize] at (C-1-6) {$+\!+$};
\node[font=\scriptsize] at (C-2-3) {$+$};
\node[font=\scriptsize] at (C-3-4) {$+$};
\node[font=\scriptsize] at (C-4-5) {$+$};
\node[font=\scriptsize] at (C-4-6) {$+$};
\node[font=\scriptsize] at (C-5-6) {$+$};
% --- Basement: target / difficulty / abs weight / rel weight % ---
\foreach \r/\lbl in {0/Target, 1/Difficulty, 2/Abs.\ wt, 3/Rel.\ wt \%}
\node[anchor=east, font=\scriptsize\itshape]
at ({-0.2}, {-\qfdNW - \r - 0.5}) {\lbl};
\foreach \c/\tgt/\diff/\abs/\rel in
{1/{$\leq$100ms}/2/144/25,
2/{5 distinct}/2/114/20,
3/{$\geq$2$\times$}/3/72/12,
4/{lossless}/3/79/14,
5/{0 network}/2/81/14,
6/{$\leq$1 click}/3/84/15} {
\node[font=\scriptsize] at ({\c - 0.5}, {-\qfdNW - 0.5}) {\tgt};
\node[font=\scriptsize] at ({\c - 0.5}, {-\qfdNW - 1.5}) {\diff};
\node[font=\scriptsize] at ({\c - 0.5}, {-\qfdNW - 2.5}) {\abs};
\node[font=\scriptsize\bfseries] at ({\c - 0.5}, {-\qfdNW - 3.5}) {\rel};
}
\end{qfdhouse}
\end{document}
```
---
## 1. Goals — the WHATs
| ID | Goal | Weight | Source |
|-----|-----------------------------------------------------------------------------------|:------:|-----------------------------------------------------|
| G1 | Position a product against alternatives across several dimensions, readable in one glance | 10 | `README.md`, `RadarChart.vue` |
| G2 | Communicate that positioning to others without them needing the app | 8 | `utils/png.ts`, `utils/share.ts`, `ShareImport.vue` |
| G3 | Keep and revisit my radars with zero setup — no account, no server | 7 | `storage.ts`, `RadarList.vue` |
| G4 | Build and adjust a radar with minimal friction | 6 | `CriteriaEditor.vue`, `ScoreGrid.vue`, `utils/reorder.ts` |
_Fairness of comparison (the "higher is better" discipline in `CONTEXT.md`) is treated
as a constraint on G1, not a separate goal._
## 2. Functions — the HOWs
Direction: ↑ higher is better, ↓ lower is better, → fixed capability / invariant.
| ID | Function | Dir | Target (now) | Target (future) |
|-----|---------------------------------------------------|:---:|--------------------------------------------------------------------------|-----------------|
| F1 | Redraw the chart live as the Radar changes | ↓ | ≤ 100 ms per edit (feels instant) | ≤ 50 ms |
| F2 | Keep up to 5 Profiles distinguishable on one chart| → | 5 Profiles × 7 Criteria, distinct palette colour + legend, no manual disambiguation | — |
| F3 | Export the chart as a faithful raster image | ↑ | PNG ≥ 2× resolution, white bg, includes name + Concept, 1 click | — |
| F4 | Encode an entire Radar into a self-contained link | → | Lossless round-trip, no server, untrusted import validated & clamped | — |
| F5 | Keep the whole Radar library in the browser | → | 0 network calls, survives reload/restart, auto-save; open/duplicate/delete each ≤ 1 action | — |
| F6 | Frictionless, always-valid editing | ↓ | Drag-reorder Criteria & Profiles; set a Score in ≤ 1 interaction; cardinality 37 / 15, Score 05, no nulls, unique names all unbreakable | — |
## 3. Cascade — Goals → Functions → How → Components
- **G1** Position a product at a glance _W:10_
- **F1** Live chart redraw _↓ ≤100 ms_
- **How**: Vue 3 reactivity drives a hand-built inline **SVG** chart that recomputes polygon points from Scores.
- **Component**: `RadarChart.vue`, Vue reactivity
- **How**: canvas charting lib (Chart.js) or imperative D3 — _rejected, see T1_
- **F2** Distinguishable Profiles __
- **How**: fixed 8-colour `PROFILE_PALETTE` (≥ MAX_PROFILES) + legend + semi-transparent fills.
- **Component**: `PROFILE_PALETTE` (`types.ts`), `RadarChart.vue` legend
- **G2** Communicate the positioning _W:8_
- **F3** Faithful raster export __
- **How**: serialise the live SVG → draw into a 2× `<canvas>``toBlob` PNG; white bg; download or clipboard.
- **Component**: `utils/png.ts`
- **How**: server-side render / `html2canvas` / raw SVG download — _rejected, see T2_
- **F4** Self-contained share link __
- **How**: `base64url(JSON(payload))` in `/share?d=` query; history-mode route; import validates & clamps.
- **Component**: `utils/share.ts`, `RadarEditor.vue` (build link), `ShareImport.vue`, `useRadars.importRadar`, SPA fallback (`nginx try_files`, Pages `404.html`)
- **How**: server-stored short link, or compressed payload (LZ) — _rejected, see T3_
- **G3** Zero-setup persistence _W:7_
- **F5** Browser-local library __
- **How**: one `localStorage` key holds the `Radar[]`; deep `watch` auto-persists; CRUD via `useRadars`.
- **Component**: `storage.ts`
- **How**: IndexedDB, or backend sync — _rejected, see T4_
- **G4** Low-friction editing _W:6_
- **F6** Frictionless, always-valid editing _↓ / → invariant_
- **How**: pointer-based drag reorder + grid score entry; invariants enforced by `MIN/MAX` constants, `ensureScores`, and `importRadar` clamping.
- **Component**: `utils/reorder.ts`, `ScoreGrid.vue`, `CriteriaEditor.vue`, `ProfilesEditor.vue`, `storage.ensureScores`
## 4. House — Goals × Functions
Cells: link strength (9 strong / 3 medium / 1 weak / blank none). Importance row = `Σ(weight × strength)`.
| | F1 | F2 | F3 | F4 | F5 | F6 |
|------------|:--:|:--:|:--:|:--:|:--:|:--:|
| G1 (10) | 9 | 9 | | | | 3 |
| G2 (8) | | 3 | 9 | 9 | | |
| G3 (7) | | | | 1 | 9 | |
| G4 (6) | 9 | | | | 3 | 9 |
| **Σ** |144 |114 | 72 | 79 | 81 | 84 |
| **Rel %** | 25 | 20 | 13 | 14 | 14 | 15 |
**Top engineering priorities (from importance):** the chart-rendering pair **F1 + F2 carries 45%** of the weight — they are the only functions that strongly serve the heaviest goal (G1 = 10), and F1 doubles into G4's editing feel. Sharing (F3 + F4 = 27%) is the most feature-visible part of the app but ranks below rendering: **getting the chart live and legible is where correctness matters most.** F3 (PNG), the headline README feature, ranks last by weight.
## 5. Roof — Function × Function tradeoffs
Symbols: `◎` strong reinforcement, `○` mild reinforcement, `×` mild conflict, `⊗` strong conflict.
| | F1 | F2 | F3 | F4 | F5 | F6 |
|--------|:--:|:--:|:--:|:--:|:--:|:--:|
| **F1** | — | ○ | ◎ | | × | ◎ |
| **F2** | | — | ○ | | | |
| **F3** | | | — | ○ | | |
| **F4** | | | | — | ○ | ○ |
| **F5** | | | | | — | ○ |
| **F6** | | | | | | — |
**Conflicts that actually shape the design:**
- **F1 × F5 (auto-save vs live redraw).** Persistence deep-`watch`es the whole `radars` array and synchronously `JSON.stringify`s the entire store on every change, so each Score edit pays a full-store serialize on the main thread — competing with F1's ≤100 ms redraw budget. Harmless at current scale; degrades as the library grows. Owned by the §7 budget (F1 row) and the §8 watched tension; no ADR yet.
**Reinforcements that justify the architecture:**
- **F1 ◎ F3** — the hand-built SVG chosen for F1 is what makes PNG export faithful and dependency-free; the T1 bet pays off twice.
- **F1 ◎ F6** — live redraw is the feedback loop that makes editing feel frictionless.
- **F4 ○ F6** — F6's cardinality caps (≤7 Criteria, ≤5 Profiles) bound the uncompressed payload, protecting F4's long-URL weakness.
- **F4 ○ F5** — share-import reuses F5's `importRadar` validation/clamping; one code path.
## 6. Components & anchors
Function → component mapping is in the §3 cascade tree. Components whose decision is
anchored in an ADR:
| ID | Component | Realises | ADR |
|----|----------------------------------------|----------|----------|
| C1 | `RadarChart.vue` (hand-built SVG) | F1, F2 | — |
| C2 | `utils/png.ts` | F3 | — |
| C3 | `utils/share.ts` + `ShareImport.vue` | F4 | ADR-0001 |
| C4 | `storage.ts` (`useRadars`) | F5, F6 | — |
| C5 | `utils/reorder.ts`, editors, `ScoreGrid.vue` | F6 | — |
## 7. Critical performance budget
Ranked from §4 importance and the §5 conflict. Each row names the fallback if the target
is missed — a target without a fallback is a wish.
| Rank | Function | Target | Watched on | If we miss it |
|------|--------------|-------------------|-----------------------------------|----------------------------------------------------------------------------|
| 1 | F1 redraw | ≤ 100 ms per edit | devtools profile, 5×7 radar | Debounce / decouple the deep-`watch` persist from the keystroke path (also resolves F1×F5) |
| 2 | F2 distinct | 5 Profiles legible| visual review at 5 Profiles | Pattern fills in addition to colour + per-Profile show/hide toggle |
| 3 | F6 invariants| states unbreakable| hostile import + editor edge cases| Centralise validation in one module (today split editor ↔ `importRadar`) |
| 4 | F4 link size | fits URL limits | URL length at 7×5 + names/Concept | LZ-compress payload before base64; else fall back to a server short-link |
## 8. Tradeoffs — Got / Paid / ADR
| ID | Tradeoff | Got | Paid | ADR |
|----|-------------------------------------------------|--------------------------------------------------|--------------------------------------------------------------|----------|
| T1 | Hand-built SVG over a charting library | Geometry control, exportable SVG (enables F3), tiny deps | Must hand-build axes / legend / scaling | — |
| T2 | Client SVG→canvas PNG over server / `html2canvas` | Zero infra, faithful (chart is already SVG), no heavy deps | Raster not vector; font-embedding & canvas-taint caveats | — |
| T3 | Self-contained link over server short-link / compression | Zero backend, links never expire, works on static hosting | Long URLs, full data in URL, no revoke / edit-after-share | ADR-0001 |
| T4 | localStorage over IndexedDB / backend | Trivially simple, synchronous, enough at small scale | ~5 MB quota; whole-store serialize per edit (F1×F5); single-origin | — |
### Tensions being watched (unresolved by design)
- **F1 × F5 store-serialize.** Not optimising while the library is tiny. **Trigger to revisit:** a stored library large enough that a Score edit's full-store serialize is perceptible, or the ≤100 ms budget is breached in profiling.
- **Validation split across editor and `importRadar` (F6).** Same rules live in two places; drift risk. **Trigger:** a third entry path appears, or the two diverge in a bug.
- **"Higher is better" is a hint, not enforced.** **Trigger:** users repeatedly create inverted Criteria (e.g. _Price_ instead of _Affordability_) and misread charts.
## 9. Inconsistencies spotted
- **README persistence claim.** `README.md` says persistence is "IndexedDB or localStorage"; `storage.ts` uses localStorage only (see T4). **Recommended resolution:** correct the README to say localStorage. _(Not changed in this design pass.)_
- **Two deploy pipelines.** Both Coolify/nginx and the GitHub Pages workflow (`.github/workflows/deploy-pages.yml`) deploy on push to `main`; the README documents only Coolify and the canonical domain (`product-radar.apoena.dev` vs the Pages custom domain) is unstated. **Recommended resolution:** state which target is canonical and whether Pages is a mirror. _(Not changed in this design pass.)_
---
## How to keep this honest
- When a new ADR lands → add its components to §6 and re-score affected rows.
- When a spike / measurement returns numbers → update §7 `Target` / `Watched on`.
- WHATs change rarely; HOWs change with each release; matrices (§§46) are recomputed when either side changes.
- If a section becomes empty after edits, delete it — empty sections lie.

View File

@@ -4,13 +4,13 @@ Compare up to 5 Profiles across 37 Criteria on a 05 scale, export the rada
Deployed at https://product-radar.apoena.dev. Deployed at https://product-radar.apoena.dev.
See [CONTEXT.md](./CONTEXT.md) for the domain glossary. See [CONTEXT.md](./CONTEXT.md) for the domain glossary and [DESIGN.md](./DESIGN.md) for the goal-driven design (QFD). Architecture decisions live in [docs/adr/](./docs/adr/).
## Stack ## Stack
- Vite + Vue 3 + TypeScript - Vite + Vue 3 + TypeScript
- Tailwind CSS v4 + DaisyUI - Tailwind CSS v4 + DaisyUI
- Solo, browser-local persistence (IndexedDB or localStorage) - Solo, browser-local persistence via localStorage
## Develop ## Develop
@@ -22,4 +22,7 @@ pnpm build # production build into ./dist
## Deploy ## Deploy
Pushes to `main` are picked up by Coolify at https://platform.apoena.dev. The Dockerfile builds the SPA and serves it through nginx on port 80. Every push to `main` triggers two independent deployments:
- **Coolify** (primary) — picked up at https://platform.apoena.dev and served at https://product-radar.apoena.dev. The Dockerfile builds the SPA and serves it through nginx on port 80, with `try_files … /index.html` as the history-mode SPA fallback.
- **GitHub Pages** — `.github/workflows/deploy-pages.yml` builds with pnpm and publishes `dist/`, copying `index.html` to `404.html` as the SPA fallback.

View File

@@ -0,0 +1,25 @@
# Self-contained share links
Status: Accepted
Sharing a Radar must work with **no backend** — the app is a static SPA deployed to
nginx (Coolify) and GitHub Pages. We encode the entire Radar as `base64url(JSON(payload))`
in a `/share?d=…` query parameter rather than storing it server-side behind a short id;
the importing client validates and clamps the untrusted payload (`useRadars.importRadar`)
before it ever touches the store.
## Considered options
- **Self-contained link (chosen)** — full Radar in the URL.
- **Server-stored short link** — POST the Radar, share a short id. Rejected: requires a
backend + datastore, which the zero-infra constraint forbids.
- **Compressed payload (LZ-string)** — shorter URLs, same self-contained property.
Deferred, not rejected: the §7 fallback if link length becomes a problem.
## Consequences
- **Got:** zero infrastructure; links never expire; works on any static host; offline-capable.
- **Paid:** long URLs (no compression today); the full Radar is exposed in the URL (no
privacy boundary); no revoke and no edit-after-share — a shared link is a frozen copy.
- Mitigated by the cardinality caps (≤7 Criteria, ≤5 Profiles) which bound payload size,
keeping URLs within browser limits at current scale.

View File

@@ -1,11 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"
import type { Radar } from "../types" import type { Radar } from "../types"
import { MIN_CRITERIA, MAX_CRITERIA } from "../types" import { MIN_CRITERIA, MAX_CRITERIA } from "../types"
import { useRadars } from "../storage" import { useRadars } from "../storage"
import { useDragReorder } from "../utils/reorder"
const props = defineProps<{ radar: Radar }>() const props = defineProps<{ radar: Radar }>()
const { update, blankCriterion } = useRadars() const { update, blankCriterion } = useRadars()
const listEl = ref<HTMLElement | null>(null)
const { draggingId, startDrag } = useDragReorder({
container: listEl,
getIds: () => props.radar.criteria.map((c) => c.id),
reorder: (from, to) =>
update(props.radar.id, (r) => {
const [moved] = r.criteria.splice(from, 1)
r.criteria.splice(to, 0, moved)
}),
})
function addCriterion(): void { function addCriterion(): void {
update(props.radar.id, (r) => { update(props.radar.id, (r) => {
if (r.criteria.length >= MAX_CRITERIA) return if (r.criteria.length >= MAX_CRITERIA) return
@@ -39,8 +52,22 @@ function renameCriterion(criterionId: string, name: string): void {
<p class="text-xs text-base-content/60 mb-3"> <p class="text-xs text-base-content/60 mb-3">
Phrase each Criterion so higher is better (e.g. <em>Affordability</em>, not <em>Price</em>). Phrase each Criterion so higher is better (e.g. <em>Affordability</em>, not <em>Price</em>).
</p> </p>
<ul class="flex flex-col gap-2"> <ul ref="listEl" class="flex flex-col gap-2">
<li v-for="c in radar.criteria" :key="c.id" class="flex items-center gap-2"> <li
v-for="c in radar.criteria"
:key="c.id"
data-reorder-item
class="flex items-center gap-2 rounded-lg transition-opacity"
:class="draggingId === c.id ? 'opacity-50' : ''"
>
<span
class="cursor-grab touch-none select-none px-1 text-base-content/40 hover:text-base-content/70 active:cursor-grabbing"
aria-label="Drag to reorder"
title="Drag to reorder"
@pointerdown="startDrag($event, c.id)"
>
</span>
<input <input
type="text" type="text"
class="input input-bordered input-sm flex-1" class="input input-bordered input-sm flex-1"

View File

@@ -1,11 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"
import type { Radar } from "../types" import type { Radar } from "../types"
import { MIN_PROFILES, MAX_PROFILES, PROFILE_PALETTE } from "../types" import { MIN_PROFILES, MAX_PROFILES, PROFILE_PALETTE } from "../types"
import { useRadars } from "../storage" import { useRadars } from "../storage"
import { useDragReorder } from "../utils/reorder"
const props = defineProps<{ radar: Radar }>() const props = defineProps<{ radar: Radar }>()
const { update, blankProfile } = useRadars() const { update, blankProfile } = useRadars()
const listEl = ref<HTMLElement | null>(null)
const { draggingId, startDrag } = useDragReorder({
container: listEl,
getIds: () => props.radar.profiles.map((p) => p.id),
reorder: (from, to) =>
update(props.radar.id, (r) => {
const [moved] = r.profiles.splice(from, 1)
r.profiles.splice(to, 0, moved)
}),
})
function addProfile(): void { function addProfile(): void {
update(props.radar.id, (r) => { update(props.radar.id, (r) => {
if (r.profiles.length >= MAX_PROFILES) return if (r.profiles.length >= MAX_PROFILES) return
@@ -47,13 +60,23 @@ function setColor(profileId: string, color: string): void {
Each Profile is one shape on the radar (e.g. <em>Us today</em>, <em>Us 2027</em>, Each Profile is one shape on the radar (e.g. <em>Us today</em>, <em>Us 2027</em>,
<em>Acme Corp</em>). <em>Acme Corp</em>).
</p> </p>
<ul class="flex flex-col gap-3"> <ul ref="listEl" class="flex flex-col gap-3">
<li <li
v-for="p in radar.profiles" v-for="p in radar.profiles"
:key="p.id" :key="p.id"
class="flex flex-col gap-2 p-3 rounded-lg border border-base-300 bg-base-100" data-reorder-item
class="flex flex-col gap-2 p-3 rounded-lg border border-base-300 bg-base-100 transition-opacity"
:class="draggingId === p.id ? 'opacity-50' : ''"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span
class="cursor-grab touch-none select-none text-base-content/40 hover:text-base-content/70 active:cursor-grabbing"
aria-label="Drag to reorder"
title="Drag to reorder"
@pointerdown="startDrag($event, p.id)"
>
</span>
<span <span
class="inline-block w-4 h-4 rounded" class="inline-block w-4 h-4 rounded"
:style="{ background: p.color }" :style="{ background: p.color }"

View File

@@ -1,11 +1,13 @@
import { createRouter, createWebHistory } from "vue-router" import { createRouter, createWebHistory } from "vue-router"
import RadarList from "./views/RadarList.vue" import RadarList from "./views/RadarList.vue"
import RadarEditor from "./views/RadarEditor.vue" import RadarEditor from "./views/RadarEditor.vue"
import ShareImport from "./views/ShareImport.vue"
export const router = createRouter({ export const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
{ path: "/", name: "list", component: RadarList }, { path: "/", name: "list", component: RadarList },
{ path: "/radar/:id", name: "editor", component: RadarEditor, props: true }, { path: "/radar/:id", name: "editor", component: RadarEditor, props: true },
{ path: "/share", name: "share", component: ShareImport },
], ],
}) })

View File

@@ -1,6 +1,14 @@
import { ref, watch } from "vue" import { ref, watch } from "vue"
import type { Radar, Criterion, Profile } from "./types.ts" import type { Radar, Criterion, Profile } from "./types.ts"
import { MIN_CRITERIA, MIN_PROFILES, PROFILE_PALETTE } from "./types.ts" import {
MIN_CRITERIA,
MAX_CRITERIA,
MIN_PROFILES,
MAX_PROFILES,
MIN_SCORE,
MAX_SCORE,
PROFILE_PALETTE,
} from "./types.ts"
const STORAGE_KEY = "product-radar:radars" const STORAGE_KEY = "product-radar:radars"
@@ -106,18 +114,91 @@ export function useRadars() {
radar.updatedAt = Date.now() radar.updatedAt = Date.now()
} }
function duplicate(id: string): Radar | undefined {
const source = get(id)
if (!source) return undefined
const now = Date.now()
const radar: Radar = {
...JSON.parse(JSON.stringify(source)),
id: uid(),
name: `${source.name || "Untitled radar"} (copy)`,
createdAt: now,
updatedAt: now,
}
radars.value.push(radar)
return radar
}
function remove(id: string): void { function remove(id: string): void {
const idx = radars.value.findIndex((r) => r.id === id) const idx = radars.value.findIndex((r) => r.id === id)
if (idx >= 0) radars.value.splice(idx, 1) if (idx >= 0) radars.value.splice(idx, 1)
} }
// Build a fresh, owned radar from an untrusted payload (e.g. a share link).
// Everything is validated and clamped — a malformed payload yields undefined
// rather than corrupting the store. Inner criterion/profile ids are kept so
// the scores map stays consistent; only the radar id and timestamps are new.
function importRadar(payload: unknown): Radar | undefined {
if (!payload || typeof payload !== "object") return undefined
const p = payload as Partial<Radar>
const criteria: Criterion[] = (Array.isArray(p.criteria) ? p.criteria : [])
.filter((c): c is Criterion => !!c && typeof c === "object")
.slice(0, MAX_CRITERIA)
.map((c) => ({ id: typeof c.id === "string" ? c.id : uid(), name: String(c.name ?? "") }))
const profiles: Profile[] = (Array.isArray(p.profiles) ? p.profiles : [])
.filter((pr): pr is Profile => !!pr && typeof pr === "object")
.slice(0, MAX_PROFILES)
.map((pr, i) => ({
id: typeof pr.id === "string" ? pr.id : uid(),
name: String(pr.name ?? ""),
color:
typeof pr.color === "string" ? pr.color : PROFILE_PALETTE[i % PROFILE_PALETTE.length],
}))
if (criteria.length < MIN_CRITERIA || profiles.length < MIN_PROFILES) return undefined
const rawScores = (p.scores && typeof p.scores === "object" ? p.scores : {}) as Record<
string,
Record<string, unknown>
>
const scores: Radar["scores"] = {}
for (const profile of profiles) {
scores[profile.id] = {}
for (const criterion of criteria) {
const v = Number(rawScores[profile.id]?.[criterion.id])
scores[profile.id][criterion.id] = Number.isFinite(v)
? Math.min(MAX_SCORE, Math.max(MIN_SCORE, Math.round(v)))
: 0
}
}
const now = Date.now()
const radar: Radar = {
id: uid(),
name: String(p.name ?? "Shared radar"),
concept: String(p.concept ?? ""),
criteria,
profiles,
scores,
createdAt: now,
updatedAt: now,
}
ensureScores(radar)
radars.value.push(radar)
return radar
}
return { return {
radars, radars,
list, list,
get, get,
createBlank, createBlank,
update, update,
duplicate,
remove, remove,
importRadar,
blankCriterion, blankCriterion,
blankProfile, blankProfile,
} }

62
src/utils/reorder.ts Normal file
View File

@@ -0,0 +1,62 @@
import { onScopeDispose, ref, type Ref } from "vue"
/**
* Pointer-based drag-to-reorder for a vertical list. Works with mouse and touch
* (via Pointer Events). The list must render its items in array order, each item
* element carrying a `data-reorder-item` attribute, inside `container`.
*
* Reordering is live: as the pointer crosses a neighbour's midpoint, `reorder`
* is called to move the dragged item, and the list re-renders in the new order.
*/
export function useDragReorder(opts: {
container: Ref<HTMLElement | null>
getIds: () => string[]
reorder: (from: number, to: number) => void
}) {
const draggingId = ref<string | null>(null)
// Insertion index (in current DOM order) for the given pointer Y.
function indexFromPoint(clientY: number): number {
const el = opts.container.value
if (!el) return -1
const items = Array.from(el.querySelectorAll<HTMLElement>("[data-reorder-item]"))
for (let i = 0; i < items.length; i++) {
const r = items[i].getBoundingClientRect()
if (clientY < r.top + r.height / 2) return i
}
return items.length
}
function onMove(e: PointerEvent): void {
const id = draggingId.value
if (id == null) return
const ids = opts.getIds()
const from = ids.indexOf(id)
if (from === -1) return
let to = indexFromPoint(e.clientY)
if (to === -1) return
// The dragged item still occupies its slot, so an insertion point below it
// shifts down by one once it's removed.
if (from < to) to--
if (to !== from) opts.reorder(from, to)
}
function stop(): void {
draggingId.value = null
window.removeEventListener("pointermove", onMove)
window.removeEventListener("pointerup", stop)
window.removeEventListener("pointercancel", stop)
}
function startDrag(e: PointerEvent, id: string): void {
e.preventDefault()
draggingId.value = id
window.addEventListener("pointermove", onMove)
window.addEventListener("pointerup", stop)
window.addEventListener("pointercancel", stop)
}
onScopeDispose(stop)
return { draggingId, startDrag }
}

42
src/utils/share.ts Normal file
View File

@@ -0,0 +1,42 @@
import type { Radar } from "../types.ts"
/** The slice of a radar that travels in a share link. Volatile fields (id,
* timestamps) are dropped — they get regenerated when the link is imported. */
export type SharePayload = Pick<Radar, "name" | "concept" | "criteria" | "profiles" | "scores">
// btoa/atob only handle Latin-1, but names and concepts can be any Unicode, so
// we round-trip through UTF-8 bytes and use the URL-safe base64 alphabet.
function toBase64Url(input: string): string {
const bytes = new TextEncoder().encode(input)
let binary = ""
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
function fromBase64Url(input: string): string {
const base64 = input.replace(/-/g, "+").replace(/_/g, "/")
const binary = atob(base64)
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0))
return new TextDecoder().decode(bytes)
}
export function encodeRadar(radar: Radar): string {
const payload: SharePayload = {
name: radar.name,
concept: radar.concept,
criteria: radar.criteria,
profiles: radar.profiles,
scores: radar.scores,
}
return toBase64Url(JSON.stringify(payload))
}
export function decodeRadar(encoded: string): SharePayload | null {
try {
const parsed = JSON.parse(fromBase64Url(encoded))
if (!parsed || typeof parsed !== "object") return null
return parsed as SharePayload
} catch {
return null
}
}

View File

@@ -7,6 +7,7 @@ import CriteriaEditor from "../components/CriteriaEditor.vue"
import ProfilesEditor from "../components/ProfilesEditor.vue" import ProfilesEditor from "../components/ProfilesEditor.vue"
import ScoreGrid from "../components/ScoreGrid.vue" import ScoreGrid from "../components/ScoreGrid.vue"
import { svgToPngBlob, downloadBlob, copyBlobToClipboard, slugify } from "../utils/png" import { svgToPngBlob, downloadBlob, copyBlobToClipboard, slugify } from "../utils/png"
import { encodeRadar } from "../utils/share"
const props = defineProps<{ id: string }>() const props = defineProps<{ id: string }>()
const router = useRouter() const router = useRouter()
@@ -59,6 +60,18 @@ async function downloadPng(): Promise<void> {
} }
} }
async function copyShareLink(): Promise<void> {
if (!radar.value) return
const href = router.resolve({ name: "share", query: { d: encodeRadar(radar.value) } }).href
try {
await navigator.clipboard.writeText(`${location.origin}${href}`)
flash("success", "Share link copied")
} catch (err) {
console.error(err)
flash("error", "Could not copy link")
}
}
async function copyPng(): Promise<void> { async function copyPng(): Promise<void> {
const svg = getSvg() const svg = getSvg()
if (!svg) return if (!svg) return
@@ -99,6 +112,7 @@ async function copyPng(): Promise<void> {
/> />
</div> </div>
<div class="flex gap-2 mt-1"> <div class="flex gap-2 mt-1">
<button class="btn btn-outline" @click="copyShareLink">Share link</button>
<button class="btn btn-outline" @click="copyPng">Copy PNG</button> <button class="btn btn-outline" @click="copyPng">Copy PNG</button>
<button class="btn btn-primary" @click="downloadPng">Download PNG</button> <button class="btn btn-primary" @click="downloadPng">Download PNG</button>
</div> </div>

View File

@@ -4,7 +4,7 @@ import { useRouter } from "vue-router"
import { useRadars } from "../storage" import { useRadars } from "../storage"
const router = useRouter() const router = useRouter()
const { radars, createBlank, remove } = useRadars() const { radars, createBlank, duplicate, remove } = useRadars()
const sorted = computed(() => [...radars.value].sort((a, b) => b.updatedAt - a.updatedAt)) const sorted = computed(() => [...radars.value].sort((a, b) => b.updatedAt - a.updatedAt))
@@ -65,6 +65,9 @@ function formatDate(ts: number): string {
</p> </p>
<p class="text-xs text-base-content/40 mt-1">Updated {{ formatDate(r.updatedAt) }}</p> <p class="text-xs text-base-content/40 mt-1">Updated {{ formatDate(r.updatedAt) }}</p>
<div class="card-actions justify-end mt-2"> <div class="card-actions justify-end mt-2">
<button class="btn btn-ghost btn-xs" @click.stop="duplicate(r.id)">
Duplicate
</button>
<button <button
class="btn btn-ghost btn-xs text-error" class="btn btn-ghost btn-xs text-error"
@click.stop="confirmDelete(r.id, r.name)" @click.stop="confirmDelete(r.id, r.name)"

41
src/views/ShareImport.vue Normal file
View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { onMounted, ref } from "vue"
import { useRoute, useRouter } from "vue-router"
import { useRadars } from "../storage"
import { decodeRadar } from "../utils/share"
const route = useRoute()
const router = useRouter()
const { importRadar } = useRadars()
const failed = ref(false)
onMounted(() => {
const raw = route.query.d
const encoded = Array.isArray(raw) ? raw[0] : raw
if (typeof encoded === "string" && encoded.length > 0) {
const payload = decodeRadar(encoded)
const radar = payload ? importRadar(payload) : undefined
if (radar) {
router.replace({ name: "editor", params: { id: radar.id } })
return
}
}
failed.value = true
})
</script>
<template>
<section class="max-w-lg mx-auto px-6 py-16 text-center">
<div v-if="failed" class="card bg-base-100 border border-base-300">
<div class="card-body items-center">
<h1 class="text-xl font-semibold">This share link is invalid</h1>
<p class="text-base-content/60">The link may be incomplete or corrupted.</p>
<button class="btn btn-primary mt-2" @click="router.replace({ name: 'list' })">
Go to my radars
</button>
</div>
</div>
<p v-else class="text-base-content/60">Importing shared radar</p>
</section>
</template>