Compare commits
22 Commits
be86b82823
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34219a6bdc | ||
|
|
fbfbbdc0be | ||
|
|
b17de475ff | ||
|
|
f7e856b082 | ||
|
|
8c14ae9c42 | ||
|
|
f535c9ede5 | ||
|
|
616a4c4301 | ||
|
|
83f04af8d7 | ||
|
|
53716a13ea | ||
|
|
d0a34ef28a | ||
|
|
e380e6d336 | ||
|
|
9ee7c05019 | ||
|
|
16b07844a8 | ||
|
|
f20935a85b | ||
|
|
96c3f489a3 | ||
|
|
449be7766e | ||
|
|
db77314a88 | ||
|
|
c72a95d942 | ||
|
|
e392eca51b | ||
|
|
e9c9f04d68 | ||
|
|
122147c19c | ||
|
|
b6f7bcab82 |
@@ -48,6 +48,10 @@ _Avoid_: today marker, cursor
|
||||
The collection of saved **Macroplans** held in the browser's localStorage — the live store. Always holds at least one Macroplan; durability rests on exporting a Macroplan's `.toml` (per ADR-0002), not on the Library itself. Carries no status of its own.
|
||||
_Avoid_: workspace, project, file list
|
||||
|
||||
**Format version**:
|
||||
Which revision of the portable Macroplan `.toml` format a file targets, declared by an optional top-level `macroplan_version` (currently `1`). Absent means the current version; a newer version is rejected rather than silently mis-rendered. The format — its fields and render semantics — is defined in [docs/format.md](docs/format.md) so a Macroplan can be produced or consumed outside this app.
|
||||
_Avoid_: schema version, file version, app version
|
||||
|
||||
## Symbols
|
||||
|
||||
- `┣` start of a Feature's bar
|
||||
|
||||
683
DESIGN.md
683
DESIGN.md
@@ -6,6 +6,376 @@ Strength weights used in matrices: **9** strong, **3** medium, **1** weak, blank
|
||||
|
||||
---
|
||||
|
||||
## House of Quality
|
||||
|
||||
```tikz
|
||||
% =====================================================================
|
||||
% QFD "House of Quality" preamble
|
||||
% =====================================================================
|
||||
% Usage:
|
||||
% \begin{document}
|
||||
% \begin{qfdhouse}
|
||||
% % WHATs at ({\qfdLeftEdge + 0.1}, {-\r + 0.5})
|
||||
% % Importance at ({-\qfdImpW/2}, {-\r + 0.5})
|
||||
% % HOWs rotated 90 at ({\c - 0.5}, 0.15)
|
||||
% % Relations in cells with [qfdrel/S], [qfdrel/M], [qfdrel/W]
|
||||
% % Roof correlations at (C-i-j)
|
||||
% % Perception markers at ({\qfdNH + (s+0.5)*\qfdCmpW/6}, {-\r + 0.5})
|
||||
% % Basement: target / difficulty / abs weight / rel weight, per HOW
|
||||
% \end{qfdhouse}
|
||||
% \end{document}
|
||||
%
|
||||
% NB: in node text, use $<$ / $>$ for less-than / greater-than under
|
||||
% OT1 encoding; bare < and > render as Spanish ¡ ¿.
|
||||
% =====================================================================
|
||||
|
||||
\usetikzlibrary{arrows.meta, positioning, shapes.geometric, shapes.misc, calc, fit, backgrounds}
|
||||
|
||||
% Toggles — flip before \begin{qfdhouse} to hide sections.
|
||||
\newif\ifqfdshowroof \qfdshowrooftrue
|
||||
\newif\ifqfdshowbasement \qfdshowbasementtrue
|
||||
\newif\ifqfdshowcompetitive \qfdshowcompetitivetrue
|
||||
\newif\ifqfdshowlegend \qfdshowlegendtrue
|
||||
\newif\ifqfdshowimportance \qfdshowimportancetrue
|
||||
\newif\ifqfdshowcorrlegend \qfdshowcorrlegendtrue
|
||||
\newif\ifqfdshowevallegend \qfdshowevallegendtrue
|
||||
\newif\ifqfdshowtitle \qfdshowtitletrue % title block above the roof
|
||||
|
||||
% Dimensions — override before \begin{qfdhouse} to resize.
|
||||
\def\qfdNW{5} % number of WHATs (rows)
|
||||
\def\qfdNH{5} % number of HOWs (columns)
|
||||
\def\qfdWhatW{4.0} % width of WHATs column
|
||||
\def\qfdImpW{0.9} % width of importance column
|
||||
\def\qfdCmpW{3} % width of perception zone
|
||||
\def\qfdHdrH{2.6} % height of column-titles band
|
||||
\def\qfdBasementN{4} % number of basement rows
|
||||
|
||||
% Titles & labels — override before \begin{qfdhouse}.
|
||||
\def\qfdWhatsTitle{Customer needs}
|
||||
\def\qfdImpTitle{Imp.\ \%}
|
||||
\def\qfdPerceptionTitle{Comparative evaluation}
|
||||
\def\qfdPoorLabel{poor}
|
||||
\def\qfdExcellentLabel{excellent}
|
||||
\def\qfdAltOneLabel{Our product} % highlighted in legend
|
||||
\def\qfdAltTwoLabel{Competitor A}
|
||||
\def\qfdAltThreeLabel{Competitor B}
|
||||
\def\qfdRelTitle{Relation}
|
||||
\def\qfdCorrTitle{Correlation}
|
||||
\def\qfdEvalTitle{Evaluation}
|
||||
|
||||
% Title block above the roof — set both to name the house. Leave
|
||||
% \qfdProjectTitle empty (default) to draw no title at all.
|
||||
\def\qfdProjectTitle{} % project / feature name (large, bold)
|
||||
\def\qfdConcept{} % concept in one sentence; \textbf{} the keywords
|
||||
|
||||
% Styles.
|
||||
\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},
|
||||
% Three perception-zone alternatives. Index 1 is emphasised.
|
||||
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},
|
||||
}
|
||||
|
||||
% --- Grid lines for every zone. ---
|
||||
\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
|
||||
}
|
||||
|
||||
% --- Roof: diagonal grid + named coordinates (C-i-j) for correlations. ---
|
||||
\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
|
||||
}
|
||||
|
||||
% --- Perception scale 0..5 + poor/excellent endpoints + zone title. ---
|
||||
\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
|
||||
}
|
||||
|
||||
% --- Importance title (left) and WHATs title (header band). ---
|
||||
\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};
|
||||
}
|
||||
|
||||
% --- Title + concept subtitle, centred above the roof apex. ---
|
||||
\newcommand{\qfdDrawTitle}{%
|
||||
\ifqfdshowtitle
|
||||
\ifx\qfdProjectTitle\empty\else
|
||||
\pgfmathsetmacro{\qfdTitleX}{\qfdNH/2}
|
||||
\pgfmathsetmacro{\qfdTitleY}{\ifqfdshowroof \qfdApexY \else \qfdHdrH \fi + 0.9}
|
||||
\pgfmathsetmacro{\qfdSubW}{\qfdNH + 2} % pre-computed (braces don't do arithmetic)
|
||||
\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
|
||||
}
|
||||
|
||||
% --- Outer frames around each zone. ---
|
||||
\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}
|
||||
}
|
||||
|
||||
% --- Legend on the right (Relations / Correlations / Evaluation). ---
|
||||
\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);
|
||||
% Relations
|
||||
\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)};
|
||||
% Correlations (roof)
|
||||
\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
|
||||
% Evaluation (3 alternatives)
|
||||
\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
|
||||
}
|
||||
|
||||
% --- The environment users wrap their content in. ---
|
||||
\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}%
|
||||
}
|
||||
% --- Macroplan house: 5 WHATs, 7 HOWs, no competitor zone ---
|
||||
\def\qfdNW{5}
|
||||
\def\qfdNH{7}
|
||||
\def\qfdWhatW{4.6}
|
||||
\def\qfdHdrH{3.5} % taller header band — long HOW labels (~24 chars)
|
||||
\def\qfdWhatsTitle{Goals (WHATs)}
|
||||
\def\qfdImpTitle{Weight}
|
||||
\def\qfdProjectTitle{Macroplan}
|
||||
\def\qfdConcept{A \textbf{week-granular} plan view that holds every
|
||||
\textbf{Feature} honest against its \textbf{Original Estimate} and turns
|
||||
the slips into captured \textbf{Learnings}.}
|
||||
\qfdshowcompetitivefalse
|
||||
|
||||
\begin{document}
|
||||
\begin{qfdhouse}
|
||||
% WHATs + weights
|
||||
\pgfmathsetmacro{\qfdWhatTextW}{\qfdWhatW - 0.2}
|
||||
\foreach \r/\t in {%
|
||||
1/{G1 Honest record vs Original Estimate},
|
||||
2/{G2 Capture Learnings},
|
||||
3/{G4 Fast authoring},
|
||||
4/{G3 Milestone risk visible},
|
||||
5/{G5 Shareable view}}
|
||||
\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/8, 4/7, 5/5}
|
||||
\node[font=\scriptsize] at ({-\qfdImpW/2}, {-\r + 0.5}) {\imp};
|
||||
|
||||
% HOWs (rotated)
|
||||
\foreach \c/\t in {%
|
||||
1/{F1 Legible render},
|
||||
2/{F2 Classify on-time/late},
|
||||
3/{F3 Instant edit-to-view},
|
||||
4/{F4 Local CRUD},
|
||||
5/{F5 Easy Learning},
|
||||
6/{F6 Milestone render},
|
||||
7/{F7 Image export}}
|
||||
\node[rotate=90, anchor=west, font=\scriptsize]
|
||||
at ({\c - 0.5}, 0.15) {\t};
|
||||
|
||||
% Relations
|
||||
\node[qfdrel/S] at ({1 - 0.5}, {-1 + 0.5}) {};
|
||||
\node[qfdrel/S] at ({2 - 0.5}, {-1 + 0.5}) {};
|
||||
\node[qfdrel/W] at ({3 - 0.5}, {-1 + 0.5}) {};
|
||||
\node[qfdrel/W] at ({4 - 0.5}, {-1 + 0.5}) {};
|
||||
\node[qfdrel/W] at ({6 - 0.5}, {-1 + 0.5}) {};
|
||||
|
||||
\node[qfdrel/M] at ({1 - 0.5}, {-2 + 0.5}) {};
|
||||
\node[qfdrel/S] at ({5 - 0.5}, {-2 + 0.5}) {};
|
||||
|
||||
\node[qfdrel/W] at ({1 - 0.5}, {-3 + 0.5}) {};
|
||||
\node[qfdrel/S] at ({3 - 0.5}, {-3 + 0.5}) {};
|
||||
\node[qfdrel/S] at ({4 - 0.5}, {-3 + 0.5}) {};
|
||||
\node[qfdrel/M] at ({5 - 0.5}, {-3 + 0.5}) {};
|
||||
\node[qfdrel/W] at ({6 - 0.5}, {-3 + 0.5}) {};
|
||||
|
||||
\node[qfdrel/M] at ({1 - 0.5}, {-4 + 0.5}) {};
|
||||
\node[qfdrel/M] at ({2 - 0.5}, {-4 + 0.5}) {};
|
||||
\node[qfdrel/S] at ({6 - 0.5}, {-4 + 0.5}) {};
|
||||
|
||||
\node[qfdrel/M] at ({1 - 0.5}, {-5 + 0.5}) {};
|
||||
\node[qfdrel/S] at ({7 - 0.5}, {-5 + 0.5}) {};
|
||||
|
||||
% Roof correlations
|
||||
\node[font=\scriptsize] at (C-1-2) {$+$};
|
||||
\node[font=\scriptsize] at (C-1-3) {$-$};
|
||||
\node[font=\scriptsize] at (C-3-4) {$+\!+$};
|
||||
\node[font=\scriptsize] at (C-2-4) {$+$};
|
||||
\node[font=\scriptsize] at (C-1-7) {$-$};
|
||||
|
||||
% Basement: target / difficulty / abs / rel
|
||||
\foreach \c/\tgt/\diff/\abs/\rel in {%
|
||||
1/{state $\leq$1s}/4/158/24,
|
||||
2/{100\%}/2/111/17,
|
||||
3/{$\leq$1s}/2/82/13,
|
||||
4/{1 block}/2/82/13,
|
||||
5/{1 field}/1/96/15,
|
||||
6/{line@wk}/3/81/12,
|
||||
7/{1-click}/3/45/7} {
|
||||
\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}
|
||||
```
|
||||
|
||||
Basement rows (top→bottom): **target · difficulty (1–5) · absolute weight · relative weight %**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goals — the WHATs
|
||||
|
||||
| ID | Goal | Weight | Source |
|
||||
@@ -20,7 +390,7 @@ Strength weights used in matrices: **9** strong, **3** medium, **1** weak, blank
|
||||
|
||||
| ID | Function | Dir | Target (now) |
|
||||
| --- | -------------------------------------------------------------------------------- | :-: | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| F1 | Render the plan legibly — bars, symbols, status colors, now-line, hover comments | → | reader IDs any Feature's state (on-time/late/overdue/slip-count) in ≤3s; Feature name + week axis never lost even when the plan exceeds the viewport |
|
||||
| F1 | Render the plan legibly — bars, symbols, status colors, now-line, hover comments | → | reader IDs any Feature's state (on-time/late/overdue/slip-count) in ≤1s; Feature name + week axis never lost even when the plan exceeds the viewport |
|
||||
| F2 | Classify each Delivery on-time/late against the Original Estimate | → | 100% correct per ADR-0001; markers land on the right Week |
|
||||
| F3 | Reflect a source edit in the rendered view | ↓ | ≤1s (live reload while authoring) |
|
||||
| F4 | Add / edit / remove a Feature with a single local edit | ↓ | one contiguous block per op; no ripple edits — Weeks auto-layout, Milestones reference Features by name |
|
||||
@@ -31,7 +401,7 @@ Strength weights used in matrices: **9** strong, **3** medium, **1** weak, blank
|
||||
## 3. Cascade — Goals → Functions → How → Components
|
||||
|
||||
- **G1** See where every Feature stands vs. its Original Estimate — the honest record _W:10_
|
||||
- **F1** Render the plan legibly (incl. at scale) _Dir→ reader IDs a state ≤3s; name + axis never lost_
|
||||
- **F1** Render the plan legibly (incl. at scale) _Dir→ reader IDs a state ≤1s; name + axis never lost_
|
||||
- **How**: DOM rendering with CSS Grid (symbols are the visual vocabulary _inside_ cells; layout is real DOM) — chosen over a preformatted monospace text block, which can't pin a column on scroll. See T1.
|
||||
- **Component**: **C3 Grid renderer** — bars (`┣━`), markers (`◯△◉▲`), status colors, Now line, sticky name column + sticky week-header row, hover tooltips for status notes
|
||||
- **G2** Turn estimation misses into captured Learnings _W:8_
|
||||
@@ -121,319 +491,12 @@ Strength of each Component in realising each Function (9/3/1/blank). Component l
|
||||
|
||||
**C3 (Grid renderer)** and **C2 (Plan model)** each anchor three functions — they're the load-bearing components and the most important to get right and test hard.
|
||||
|
||||
### House of Quality (rendered)
|
||||
|
||||
```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
|
||||
|
||||
\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}
|
||||
|
||||
\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{\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
|
||||
}{%
|
||||
\qfdDrawFrames
|
||||
\qfdDrawLegend
|
||||
\end{tikzpicture}%
|
||||
}
|
||||
|
||||
% --- Macroplan house: 5 WHATs, 7 HOWs, no competitor zone ---
|
||||
\def\qfdNW{5}
|
||||
\def\qfdNH{7}
|
||||
\def\qfdWhatW{4.6}
|
||||
\def\qfdWhatsTitle{Goals (WHATs)}
|
||||
\def\qfdImpTitle{Weight}
|
||||
\qfdshowcompetitivefalse
|
||||
|
||||
\begin{document}
|
||||
\begin{qfdhouse}
|
||||
% WHATs + weights
|
||||
\pgfmathsetmacro{\qfdWhatTextW}{\qfdWhatW - 0.2}
|
||||
\foreach \r/\t in {%
|
||||
1/{G1 Honest record vs Original Estimate},
|
||||
2/{G2 Capture Learnings},
|
||||
3/{G4 Fast authoring},
|
||||
4/{G3 Milestone risk visible},
|
||||
5/{G5 Shareable view}}
|
||||
\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/8, 4/7, 5/5}
|
||||
\node[font=\scriptsize] at ({-\qfdImpW/2}, {-\r + 0.5}) {\imp};
|
||||
|
||||
% HOWs (rotated)
|
||||
\foreach \c/\t in {%
|
||||
1/{F1 Legible render},
|
||||
2/{F2 Classify on-time/late},
|
||||
3/{F3 Instant edit-to-view},
|
||||
4/{F4 Local CRUD},
|
||||
5/{F5 Easy Learning},
|
||||
6/{F6 Milestone render},
|
||||
7/{F7 Image export}}
|
||||
\node[rotate=90, anchor=west, font=\scriptsize]
|
||||
at ({\c - 0.5}, 0.15) {\t};
|
||||
|
||||
% Relations
|
||||
\node[qfdrel/S] at ({1 - 0.5}, {-1 + 0.5}) {};
|
||||
\node[qfdrel/S] at ({2 - 0.5}, {-1 + 0.5}) {};
|
||||
\node[qfdrel/W] at ({3 - 0.5}, {-1 + 0.5}) {};
|
||||
\node[qfdrel/W] at ({4 - 0.5}, {-1 + 0.5}) {};
|
||||
\node[qfdrel/W] at ({6 - 0.5}, {-1 + 0.5}) {};
|
||||
|
||||
\node[qfdrel/M] at ({1 - 0.5}, {-2 + 0.5}) {};
|
||||
\node[qfdrel/S] at ({5 - 0.5}, {-2 + 0.5}) {};
|
||||
|
||||
\node[qfdrel/W] at ({1 - 0.5}, {-3 + 0.5}) {};
|
||||
\node[qfdrel/S] at ({3 - 0.5}, {-3 + 0.5}) {};
|
||||
\node[qfdrel/S] at ({4 - 0.5}, {-3 + 0.5}) {};
|
||||
\node[qfdrel/M] at ({5 - 0.5}, {-3 + 0.5}) {};
|
||||
\node[qfdrel/W] at ({6 - 0.5}, {-3 + 0.5}) {};
|
||||
|
||||
\node[qfdrel/M] at ({1 - 0.5}, {-4 + 0.5}) {};
|
||||
\node[qfdrel/M] at ({2 - 0.5}, {-4 + 0.5}) {};
|
||||
\node[qfdrel/S] at ({6 - 0.5}, {-4 + 0.5}) {};
|
||||
|
||||
\node[qfdrel/M] at ({1 - 0.5}, {-5 + 0.5}) {};
|
||||
\node[qfdrel/S] at ({7 - 0.5}, {-5 + 0.5}) {};
|
||||
|
||||
% Roof correlations
|
||||
\node[font=\scriptsize] at (C-1-2) {$+$};
|
||||
\node[font=\scriptsize] at (C-1-3) {$-$};
|
||||
\node[font=\scriptsize] at (C-3-4) {$+\!+$};
|
||||
\node[font=\scriptsize] at (C-2-4) {$+$};
|
||||
\node[font=\scriptsize] at (C-1-7) {$-$};
|
||||
|
||||
% Basement: target / difficulty / abs / rel
|
||||
\foreach \c/\tgt/\diff/\abs/\rel in {%
|
||||
1/{state $\leq$3s}/4/158/24,
|
||||
2/{100\%}/2/111/17,
|
||||
3/{$\leq$1s}/2/82/13,
|
||||
4/{1 block}/2/82/13,
|
||||
5/{1 field}/1/96/15,
|
||||
6/{line@wk}/3/81/12,
|
||||
7/{1-click}/3/45/7} {
|
||||
\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}
|
||||
```
|
||||
|
||||
Basement rows (top→bottom): **target · difficulty (1–5) · absolute weight · relative weight %**.
|
||||
|
||||
## 7. Critical performance budget
|
||||
|
||||
| Rank | Function | Target | Watched on | If we miss it |
|
||||
| ---- | -------- | ------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| 1 | F2 | 100% correct on-time/late vs Original Estimate | unit tests over sample plans (incl. multi-slip, deliver-early, overdue) | classification is the product — block release; it's pure logic, so a failing test is a hard stop |
|
||||
| 2 | F1 | reader IDs a state ≤3s; legible past the viewport | manual review on a 30-feature / 26-week sample; check sticky panes | drop hover-only data into always-visible cells; simplify symbol styling |
|
||||
| 2 | F1 | reader IDs a state ≤1s; legible past the viewport | manual review on a 30-feature / 26-week sample; check sticky panes | drop hover-only data into always-visible cells; simplify symbol styling |
|
||||
| 3 | F3 | ≤1s edit→view | eyeball on keystroke with a large plan | debounce parse; parse only changed blocks |
|
||||
| 4 | F7 | one-click PNG, faithful to on-screen | manual export of a real plan; diff against screen | fall back to download-only if clipboard API is flaky; document "what you see is what exports" |
|
||||
|
||||
|
||||
@@ -31,7 +31,8 @@ Dashboard ┣━━━━━◯ 🔴 n
|
||||
## Documentation
|
||||
|
||||
| Document | What it covers |
|
||||
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [docs/format.md](docs/format.md) | **The Macroplan TOML format (v1)** — the portable, tool-independent schema: fields, value types, the `macroplan_version` marker, and the render semantics a consumer must honor. Ships a [JSON Schema](public/macroplan.schema.json) and a [reference `.toml`](docs/macroplan.example.toml). |
|
||||
| [CONTEXT.md](CONTEXT.md) | **Ubiquitous language** — the glossary: Macroplan, Feature, Original Estimate, Re-estimate, Delivery, Milestone, Week, Now line, Learning, Status, and the symbol legend |
|
||||
| [DESIGN.md](DESIGN.md) | **Goal-driven design (QFD)** — goals, functions, the Goal→Function→How→Component cascade, the House/Roof matrices + rendered House of Quality, critical performance budget, trade-offs, and watched tensions |
|
||||
| [docs/adr/0001-original-estimate-as-baseline.md](docs/adr/0001-original-estimate-as-baseline.md) | Why on-time/late is judged against the **Original Estimate**, never a re-estimate |
|
||||
|
||||
154
docs/format.md
Normal file
154
docs/format.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Macroplan TOML Format — v1
|
||||
|
||||
The portable, tool-independent definition of a **Macroplan**: a week-by-week
|
||||
delivery plan that records what a team promised, every time an estimate slipped,
|
||||
and when work actually shipped — judged against the **first** estimate, never a
|
||||
moved goalpost.
|
||||
|
||||
This document is the contract. Any tool can produce or consume a `.toml` file
|
||||
that conforms to it and render the same plan. The [macroplan app](../README.md)
|
||||
is one such consumer; nothing here is specific to it.
|
||||
|
||||
- **Machine-readable schema:** [`macroplan.schema.json`](../public/macroplan.schema.json),
|
||||
served at `https://macroplan.apoena.dev/macroplan.schema.json` (JSON Schema
|
||||
2020-12; validates the TOML-decoded structure).
|
||||
- **Reference file:** [`macroplan.example.toml`](macroplan.example.toml)
|
||||
(exercises every field and state; a conformance sample).
|
||||
- **Serialization:** [TOML v1.0.0](https://toml.io). Values use TOML's native
|
||||
types — dates are **bare date literals**, not quoted strings (see _Dates_).
|
||||
|
||||
## Versioning
|
||||
|
||||
A file MAY declare the format version at the top level:
|
||||
|
||||
```toml
|
||||
macroplan_version = 1
|
||||
```
|
||||
|
||||
- **Optional.** An absent marker means the current version (`1`). Every existing
|
||||
file therefore stays valid.
|
||||
- **Integer**, monotonically increasing. This document defines version **1**.
|
||||
- A consumer that understands version _N_ **MUST reject** a file declaring a
|
||||
version **greater than** _N_ (rather than silently mis-rendering it), and
|
||||
SHOULD accept any version ≤ _N_ it still supports.
|
||||
- The version is bumped only for a **breaking** change to the schema or render
|
||||
semantics below — a new optional field does not require a bump.
|
||||
|
||||
Producers SHOULD emit `macroplan_version` so a file self-describes its target.
|
||||
|
||||
## Top-level fields
|
||||
|
||||
| Key | Required | Type | Default | Meaning |
|
||||
| ------------------- | -------- | ------------- | ---------------------- | ------------------------------------------------------------------------------------- |
|
||||
| `macroplan_version` | no | integer (`1`) | `1` | Format version (see above). |
|
||||
| `title` | no | string | `"Untitled Macroplan"` | Display name of the plan. |
|
||||
| `start` | no | date | auto-fit | Left edge of the plan's week span. **Only extends** the span; never clips a feature. |
|
||||
| `end` | no | date | auto-fit | Right edge of the plan's week span. **Only extends** the span; never clips a feature. |
|
||||
|
||||
Zero or more `[[feature]]` blocks and zero or more `[[milestone]]` blocks follow.
|
||||
|
||||
## `[[feature]]`
|
||||
|
||||
A unit of work, read left-to-right as a story: where it started, what was first
|
||||
promised, every slip, and when it shipped.
|
||||
|
||||
| Key | Required | Type | Default | Meaning |
|
||||
| ------------- | -------- | -------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `name` | **yes** | non-empty string | — | Identifies the feature; the **join key** for a milestone's `requires`. |
|
||||
| `start` | **yes** | date | — | The week work began. |
|
||||
| `original` | **yes** | date | — | The **Original Estimate** — the immovable baseline all lateness is judged against ([ADR-0001](adr/0001-original-estimate-as-baseline.md)). |
|
||||
| `reestimates` | no | array of dates | `[]` | Each later week the estimate slipped to (a slip, `△`). Order-independent. |
|
||||
| `delivered` | no | date | — | The week the feature actually shipped. Absent → still in flight. |
|
||||
| `learning` | no | string | — | A post-delivery takeaway. |
|
||||
| `status` | no | `"on-track"` \| `"at-risk"` \| `"off-track"` | — | In-flight confidence. Meaningful only while undelivered. |
|
||||
| `note` | no | string | — | A short note accompanying `status`. |
|
||||
|
||||
**Constraint — unique names:** feature `name` values MUST be unique within a
|
||||
plan. Because milestone `requires` resolves by name, a duplicate would make a
|
||||
requirement ambiguous. Conforming consumers reject duplicates.
|
||||
|
||||
## `[[milestone]]`
|
||||
|
||||
A dated checkpoint that depends on a set of features being delivered by then.
|
||||
|
||||
| Key | Required | Type | Default | Meaning |
|
||||
| ---------- | -------- | ---------------- | ------- | ------------------------------------------------------------------- |
|
||||
| `name` | **yes** | non-empty string | — | Display name of the milestone. |
|
||||
| `week` | **yes** | date | — | When the milestone falls due. |
|
||||
| `requires` | no | array of strings | `[]` | Feature `name`s that must be delivered on/before `week` to meet it. |
|
||||
|
||||
A `requires` entry that names no existing feature counts as **unmet** (it can
|
||||
never be satisfied) — useful for catching typos.
|
||||
|
||||
## Value types
|
||||
|
||||
### Dates → weeks
|
||||
|
||||
Every date field is a **calendar date**, written as a TOML bare date literal:
|
||||
|
||||
```toml
|
||||
original = 2026-06-15
|
||||
reestimates = [2026-06-29, 2026-07-13]
|
||||
```
|
||||
|
||||
- **Canonical form is the bare date literal.** A quoted `"2026-06-15"` string is
|
||||
_tolerated_ on input for convenience, but producers SHOULD emit bare literals
|
||||
so the same plan always serializes identically.
|
||||
- **Any day snaps to its ISO-week Monday.** A Macroplan's unit is the week, keyed
|
||||
by the Monday (yyyy-mm-dd) of the ISO week containing the date. `2026-06-17`
|
||||
(a Wednesday) and `2026-06-15` (that Monday) denote the **same week**. Every
|
||||
conforming renderer MUST apply this snapping, or dates land in the wrong column.
|
||||
|
||||
### Status
|
||||
|
||||
The enum `on-track` \| `at-risk` \| `off-track`. No other value is valid. Lateness
|
||||
is **derived, never authored** — there is deliberately no "late" status.
|
||||
|
||||
## Render semantics (the contract a renderer must honor)
|
||||
|
||||
Two tools should draw the same plan from the same file. These rules are as much
|
||||
a part of the format as the fields:
|
||||
|
||||
1. **On-time vs. late is judged only against `original`** — never a re-estimate
|
||||
([ADR-0001](adr/0001-original-estimate-as-baseline.md)). `delivered ≤ original`
|
||||
(by week) is on time; `delivered > original` is late.
|
||||
2. **Marker vocabulary** per feature:
|
||||
- `◯` **original** — the Original Estimate week, while undelivered.
|
||||
- `△` **re-estimate** — one per `reestimates` entry.
|
||||
- `◉` **delivered on time** — a delivery on/before `original`. It **subsumes**
|
||||
the `◯` (no separate original marker is drawn).
|
||||
- `▲` **delivered late** — a delivery after `original`; the `◯` baseline is
|
||||
**kept** alongside it.
|
||||
3. **Bar extent.** A feature's bar runs from `start` to its furthest marker. A
|
||||
delivered bar ends at its delivery. An **overdue** feature (undelivered, and
|
||||
already past its furthest estimate relative to "now") keeps running to the
|
||||
current week.
|
||||
4. **Plan span.** The rendered week range runs from the earliest to the latest
|
||||
week among all features' markers and all milestones. Optional `start`/`end`
|
||||
only widen this range with lead-in / trailing weeks; a marker outside them is
|
||||
never clipped.
|
||||
5. **Milestone met/unmet.** A required feature is **unmet** at a milestone if it
|
||||
is undelivered, delivered _after_ the milestone `week`, or names no existing
|
||||
feature.
|
||||
6. **"Now".** The current week (Monday of today) drives overdue extension and the
|
||||
"now" line. It is contextual, not stored in the file.
|
||||
|
||||
## Notes for implementers
|
||||
|
||||
- **Unknown keys.** The JSON Schema is strict (`additionalProperties: false`) so
|
||||
editors flag typos like `titel`. The reference parser is more lenient — it
|
||||
ignores unrecognized top-level keys. Producers SHOULD NOT emit unknown keys.
|
||||
- **Empty plan.** A file with no features and no milestones is valid; with an
|
||||
authored `start`/`end` it renders as an empty grid across that span.
|
||||
- **Editor integration.** Point [Taplo](https://taplo.tamasfe.dev)-based editors
|
||||
at the schema with a directive on the first line of a plan file:
|
||||
`#:schema https://macroplan.apoena.dev/macroplan.schema.json`.
|
||||
|
||||
## See also
|
||||
|
||||
- [CONTEXT.md](../CONTEXT.md) — the ubiquitous language (Feature, Original
|
||||
Estimate, Re-estimate, Delivery, Milestone, Week, Status, Learning).
|
||||
- [ADR-0001](adr/0001-original-estimate-as-baseline.md) — why lateness is judged
|
||||
against the Original Estimate.
|
||||
- [ADR-0002](adr/0002-local-first-no-backend.md) — why the `.toml` file is the
|
||||
portable source of truth.
|
||||
79
docs/macroplan.example.toml
Normal file
79
docs/macroplan.example.toml
Normal file
@@ -0,0 +1,79 @@
|
||||
#:schema https://macroplan.apoena.dev/macroplan.schema.json
|
||||
#
|
||||
# Reference Macroplan — a conformance sample exercising every field and state.
|
||||
# The authoritative definition of this format is docs/format.md.
|
||||
|
||||
# Format version. Optional; the current version is 1. Omitting it means "1".
|
||||
macroplan_version = 1
|
||||
|
||||
title = "Q3 — Checkout revamp"
|
||||
|
||||
# Optional plan span: pad the plan with lead-in / trailing weeks.
|
||||
# Rule: `start` only ever extends the span left, `end` extends it right;
|
||||
# neither clips a feature. Omit both to auto-fit the columns.
|
||||
start = 2026-05-25
|
||||
end = 2026-08-03
|
||||
|
||||
# Dates are TOML date literals. Any day snaps to that week's Monday.
|
||||
|
||||
# On-time delivery: delivered on/before the Original Estimate → ◉ (subsumes ◯).
|
||||
[[feature]]
|
||||
name = "Auth"
|
||||
start = 2026-06-01
|
||||
original = 2026-06-15
|
||||
delivered = 2026-06-15
|
||||
learning = "Spiking the OAuth flow first paid off — do discovery spikes earlier."
|
||||
|
||||
# Late delivery with slips: ◯ baseline kept, one △ per re-estimate, then ▲.
|
||||
# Late because delivery (07-20) is after `original` (06-15) — the re-estimates
|
||||
# never move the goalpost (ADR-0001).
|
||||
[[feature]]
|
||||
name = "Payments"
|
||||
start = 2026-06-01
|
||||
original = 2026-06-15
|
||||
reestimates = [2026-06-29, 2026-07-13]
|
||||
delivered = 2026-07-20
|
||||
learning = "Vendor lead time was the real constraint — derisk vendors up front."
|
||||
|
||||
# In-flight and overdue: undelivered, `original` already in the past → ◯ only,
|
||||
# bar runs to "now". `status` + `note` express confidence while it's open.
|
||||
[[feature]]
|
||||
name = "Dashboard"
|
||||
start = 2026-06-01
|
||||
original = 2026-06-08
|
||||
status = "off-track"
|
||||
note = "No recovery plan yet — needs an owner."
|
||||
|
||||
# In-flight, re-estimated once: ◯ + △, still open.
|
||||
[[feature]]
|
||||
name = "Search"
|
||||
start = 2026-06-08
|
||||
original = 2026-06-22
|
||||
reestimates = [2026-07-06]
|
||||
status = "at-risk"
|
||||
note = "Third-party search API is flaky; spike a fallback."
|
||||
|
||||
# In-flight, on track: the minimal feature — only the three required fields
|
||||
# plus a status.
|
||||
[[feature]]
|
||||
name = "Notifications"
|
||||
start = 2026-06-22
|
||||
original = 2026-07-06
|
||||
status = "on-track"
|
||||
|
||||
# Milestones tie a due week to the features that must ship by then.
|
||||
[[milestone]]
|
||||
name = "Code freeze"
|
||||
week = 2026-06-29
|
||||
requires = ["Auth", "Payments"]
|
||||
|
||||
[[milestone]]
|
||||
name = "MVP go-live"
|
||||
week = 2026-07-06
|
||||
requires = ["Auth", "Payments", "Dashboard"]
|
||||
|
||||
# A milestone with no requirements is valid too.
|
||||
[[milestone]]
|
||||
name = "Beta launch"
|
||||
week = 2026-07-13
|
||||
requires = ["Search", "Notifications"]
|
||||
108
public/macroplan.schema.json
Normal file
108
public/macroplan.schema.json
Normal file
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://macroplan.apoena.dev/macroplan.schema.json",
|
||||
"title": "Macroplan",
|
||||
"description": "A week-by-week delivery plan. Portable TOML format v1 — see docs/format.md. Validates the TOML-decoded structure; dates are TOML date literals, surfaced to JSON Schema as date-formatted strings.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"macroplan_version": {
|
||||
"description": "Format version. Omit for the current version (1). A newer version must be rejected by a consumer that only understands 1.",
|
||||
"type": "integer",
|
||||
"const": 1
|
||||
},
|
||||
"title": {
|
||||
"description": "Display name of the plan. Defaults to \"Untitled Macroplan\" when absent.",
|
||||
"type": "string"
|
||||
},
|
||||
"start": {
|
||||
"description": "Optional left edge of the plan's week span. Only extends the span; never clips a feature. Snapped to its ISO-week Monday.",
|
||||
"type": "string",
|
||||
"format": "date"
|
||||
},
|
||||
"end": {
|
||||
"description": "Optional right edge of the plan's week span. Only extends the span; never clips a feature. Snapped to its ISO-week Monday.",
|
||||
"type": "string",
|
||||
"format": "date"
|
||||
},
|
||||
"feature": {
|
||||
"description": "The features of the plan. Written as [[feature]] blocks. Names must be unique.",
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/feature" }
|
||||
},
|
||||
"milestone": {
|
||||
"description": "Dated checkpoints. Written as [[milestone]] blocks.",
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/milestone" }
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"date": {
|
||||
"description": "A calendar date (TOML date literal, e.g. 2026-06-15). Snapped to its ISO-week Monday.",
|
||||
"type": "string",
|
||||
"format": "date"
|
||||
},
|
||||
"feature": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "start", "original"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "Identifies the feature; the join key for a milestone's `requires`. Must be unique within the plan.",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"start": {
|
||||
"description": "The week work began.",
|
||||
"$ref": "#/$defs/date"
|
||||
},
|
||||
"original": {
|
||||
"description": "The Original Estimate — the immovable baseline all lateness is judged against (ADR-0001).",
|
||||
"$ref": "#/$defs/date"
|
||||
},
|
||||
"reestimates": {
|
||||
"description": "Each later week the estimate slipped to. Order-independent.",
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/date" }
|
||||
},
|
||||
"delivered": {
|
||||
"description": "The week the feature actually shipped. Absent means still in flight.",
|
||||
"$ref": "#/$defs/date"
|
||||
},
|
||||
"learning": {
|
||||
"description": "A post-delivery takeaway.",
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"description": "In-flight confidence. Meaningful only while undelivered. Lateness is derived, never authored.",
|
||||
"enum": ["on-track", "at-risk", "off-track"]
|
||||
},
|
||||
"note": {
|
||||
"description": "A short note accompanying `status`.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"milestone": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "week"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "Display name of the milestone.",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"week": {
|
||||
"description": "When the milestone falls due.",
|
||||
"$ref": "#/$defs/date"
|
||||
},
|
||||
"requires": {
|
||||
"description": "Feature names that must be delivered on/before `week` to meet the milestone.",
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,4 +179,12 @@ function confirmDelete() {
|
||||
.export-root.exporting :deep(.macroplan) {
|
||||
overflow: visible;
|
||||
}
|
||||
/* A PNG has no hover, so text that ellipses on screen (feature names, learning /
|
||||
status notes) must wrap to its full length in the capture instead of being cut.
|
||||
The column width is unchanged, so the milestone band stays aligned. */
|
||||
.export-root.exporting :deep(.namecell .truncate),
|
||||
.export-root.exporting :deep(.learncell .note) {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -46,7 +46,7 @@ const TONE_DOT: Record<Tone, string> = {
|
||||
}
|
||||
|
||||
// Layout constants (must match the CSS vars --name-w / --wk) for stacking math.
|
||||
const NAME_W = 9 * 16
|
||||
const NAME_W = 16 * 16
|
||||
const WK = 3.5 * 16
|
||||
const BAND_CHAR = 6.6 // ≈ Fira Code advance (px) at the band font size
|
||||
|
||||
@@ -167,7 +167,7 @@ const bandStyle = computed(() => ({
|
||||
|
||||
<div class="plan-grid" :style="gridStyle">
|
||||
<!-- header row -->
|
||||
<div class="hcell corner">{{ plan.title }}</div>
|
||||
<div class="hcell corner">Feature</div>
|
||||
<div
|
||||
v-for="(c, ci) in cols"
|
||||
:key="'h-' + c.w"
|
||||
@@ -224,7 +224,7 @@ const bandStyle = computed(() => ({
|
||||
|
||||
<style scoped>
|
||||
.macroplan {
|
||||
--name-w: 9rem;
|
||||
--name-w: 16rem;
|
||||
--wk: 3.5rem;
|
||||
}
|
||||
.plan-grid {
|
||||
|
||||
74
src/components/PlanEditor.test.ts
Normal file
74
src/components/PlanEditor.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect, beforeAll } from "vitest"
|
||||
import { mount } from "@vue/test-utils"
|
||||
import PlanEditor from "./PlanEditor.vue"
|
||||
|
||||
// happy-dom ships no canvas 2D context; measure() only needs measureText, so a
|
||||
// tiny stub keeps onMounted from throwing without pulling in a canvas polyfill.
|
||||
beforeAll(() => {
|
||||
HTMLCanvasElement.prototype.getContext = (() => ({
|
||||
measureText: () => ({ width: 80 }),
|
||||
font: "",
|
||||
})) as unknown as HTMLCanvasElement["getContext"]
|
||||
})
|
||||
|
||||
/** Mount with a working v-model so emitted edits flow back into the prop. */
|
||||
function mountEditor(initial = "") {
|
||||
const wrapper = mount(PlanEditor, {
|
||||
props: {
|
||||
modelValue: initial,
|
||||
error: null,
|
||||
"onUpdate:modelValue": (v: string) => wrapper.setProps({ modelValue: v }),
|
||||
},
|
||||
})
|
||||
return wrapper
|
||||
}
|
||||
|
||||
/** Type `text` into the textarea (caret at the end) and let the popup refresh. */
|
||||
async function type(wrapper: ReturnType<typeof mountEditor>, text: string) {
|
||||
const ta = wrapper.find("textarea")
|
||||
const el = ta.element as HTMLTextAreaElement
|
||||
el.value = text
|
||||
el.selectionStart = el.selectionEnd = text.length
|
||||
await ta.trigger("input")
|
||||
}
|
||||
|
||||
const lastEmit = (wrapper: ReturnType<typeof mountEditor>) => {
|
||||
const events = wrapper.emitted("update:modelValue")
|
||||
return events?.at(-1)?.[0] as string | undefined
|
||||
}
|
||||
|
||||
describe("PlanEditor completion keys", () => {
|
||||
it("opens the popup as the author types a key prefix", async () => {
|
||||
const wrapper = mountEditor()
|
||||
await type(wrapper, "t")
|
||||
expect(wrapper.find(".completion").exists()).toBe(true)
|
||||
expect(wrapper.findAll(".completion .label").map((el) => el.text())).toContain("title")
|
||||
})
|
||||
|
||||
it("Tab accepts the selected item without navigating first", async () => {
|
||||
const wrapper = mountEditor()
|
||||
await type(wrapper, "t")
|
||||
await wrapper.find("textarea").trigger("keydown", { key: "Tab" })
|
||||
expect(lastEmit(wrapper)).toBe("title = ")
|
||||
expect(wrapper.find(".completion").exists()).toBe(false)
|
||||
})
|
||||
|
||||
it("Enter accepts the selected item without navigating first", async () => {
|
||||
const wrapper = mountEditor()
|
||||
await type(wrapper, "t")
|
||||
await wrapper.find("textarea").trigger("keydown", { key: "Enter" })
|
||||
expect(lastEmit(wrapper)).toBe("title = ")
|
||||
expect(wrapper.find(".completion").exists()).toBe(false)
|
||||
})
|
||||
|
||||
it("Escape dismisses the popup so the next Enter is a newline, not an accept", async () => {
|
||||
const wrapper = mountEditor()
|
||||
await type(wrapper, "t")
|
||||
await wrapper.find("textarea").trigger("keydown", { key: "Escape" })
|
||||
expect(wrapper.find(".completion").exists()).toBe(false)
|
||||
await wrapper.find("textarea").trigger("keydown", { key: "Enter" })
|
||||
// Popup is gone, so Enter accepts nothing — the value stays as typed.
|
||||
expect(lastEmit(wrapper)).toBe("t")
|
||||
})
|
||||
})
|
||||
@@ -2,12 +2,14 @@
|
||||
import { ref, computed, onMounted, nextTick } from "vue"
|
||||
import type { HighlighterCore } from "shiki/core"
|
||||
import { getCompletions, type CompletionContext } from "./completion"
|
||||
import { fitPopup } from "./popupFit"
|
||||
|
||||
const props = defineProps<{ modelValue: string; error: string | null }>()
|
||||
const emit = defineEmits<{ "update:modelValue": [value: string] }>()
|
||||
|
||||
const textarea = ref<HTMLTextAreaElement>()
|
||||
const backdrop = ref<HTMLElement>()
|
||||
const popupEl = ref<HTMLElement>()
|
||||
const highlighter = ref<HighlighterCore>()
|
||||
|
||||
// ── Completion popup state ───────────────────────────────────────────────────
|
||||
@@ -70,10 +72,29 @@ function position() {
|
||||
const line = before.slice(before.lastIndexOf("\n") + 1)
|
||||
let col = 0
|
||||
for (const ch of line) col = ch === "\t" ? col + (2 - (col % 2)) : col + 1
|
||||
popup.value = {
|
||||
left: metrics.padLeft + col * metrics.charWidth - el.scrollLeft,
|
||||
top: metrics.padTop + (lineIndex + 1) * metrics.lineHeight - el.scrollTop,
|
||||
}
|
||||
const caretLeft = metrics.padLeft + col * metrics.charWidth - el.scrollLeft
|
||||
const caretTop = metrics.padTop + lineIndex * metrics.lineHeight - el.scrollTop
|
||||
popup.value = { left: caretLeft, top: caretTop + metrics.lineHeight }
|
||||
// The popup has no measurable size until Vue has rendered it; adjust once it does.
|
||||
nextTick(() => keepInView(caretLeft, caretTop))
|
||||
}
|
||||
|
||||
/** Nudge the popup back inside the window once it has a measurable size. */
|
||||
function keepInView(caretLeft: number, caretTop: number) {
|
||||
const el = textarea.value
|
||||
const list = popupEl.value
|
||||
if (!el || !list) return
|
||||
// popup left/top are relative to `.code`, which the textarea fills exactly, so
|
||||
// the textarea rect bridges those local coordinates to the window.
|
||||
const ta = el.getBoundingClientRect()
|
||||
const box = list.getBoundingClientRect()
|
||||
popup.value = fitPopup({
|
||||
caret: { left: caretLeft, top: caretTop },
|
||||
lineHeight: metrics.lineHeight,
|
||||
box: { width: box.width, height: box.height },
|
||||
origin: { left: ta.left, top: ta.top },
|
||||
viewport: { width: window.innerWidth, height: window.innerHeight },
|
||||
})
|
||||
}
|
||||
|
||||
function accept(i: number) {
|
||||
@@ -115,6 +136,8 @@ function onKeydown(e: KeyboardEvent) {
|
||||
break
|
||||
case "Enter":
|
||||
case "Tab":
|
||||
// Both accept the highlighted item whenever the popup is open. To insert a
|
||||
// newline instead, dismiss the popup with Escape first, then press Enter.
|
||||
e.preventDefault()
|
||||
accept(selected.value)
|
||||
break
|
||||
@@ -184,6 +207,7 @@ function syncScroll() {
|
||||
></textarea>
|
||||
<ul
|
||||
v-if="completion"
|
||||
ref="popupEl"
|
||||
class="completion"
|
||||
:style="{ left: popup.left + 'px', top: popup.top + 'px' }"
|
||||
>
|
||||
@@ -228,7 +252,11 @@ function syncScroll() {
|
||||
padding: 0.9rem 1rem;
|
||||
font-family: inherit; /* Fira Code, from the global theme */
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
/* Integer px, NOT a unitless ratio: a fractional line box (0.8rem × 1.6 =
|
||||
20.48px) is rounded differently by a <textarea> than by the <pre> backdrop,
|
||||
so the caret drifts further from its glyph the more lines precede it —
|
||||
visible at the bottom of a tall file. An integer height rounds identically. */
|
||||
line-height: 20px;
|
||||
tab-size: 2;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
@@ -14,11 +14,12 @@ function labelsAt(marked: string): string[] {
|
||||
}
|
||||
|
||||
describe("completion context", () => {
|
||||
it("offers plan keys and block headers on an empty top-level line", () => {
|
||||
expect(labelsAt("|")).toEqual(["title", "start", "end", "[[feature]]", "[[milestone]]"])
|
||||
it("offers plan keys on an empty top-level line", () => {
|
||||
// Headers wait for a blank line above; at the very top there's nothing above.
|
||||
expect(labelsAt("|")).toEqual(["title", "start", "end"])
|
||||
})
|
||||
|
||||
it("offers feature keys inside a [[feature]] block", () => {
|
||||
it("offers feature keys inside a [[feature]] block, no headers on the first line", () => {
|
||||
expect(labelsAt("[[feature]]\n|")).toEqual([
|
||||
"name",
|
||||
"start",
|
||||
@@ -28,14 +29,27 @@ describe("completion context", () => {
|
||||
"status",
|
||||
"learning",
|
||||
"note",
|
||||
"[[feature]]",
|
||||
"[[milestone]]",
|
||||
])
|
||||
})
|
||||
|
||||
it("offers milestone keys inside a [[milestone]] block", () => {
|
||||
expect(labelsAt("[[milestone]]\n|")).toEqual([
|
||||
"name",
|
||||
it("offers milestone keys inside a [[milestone]] block, no headers on the first line", () => {
|
||||
expect(labelsAt("[[milestone]]\n|")).toEqual(["name", "week", "requires"])
|
||||
})
|
||||
|
||||
it("keeps offering remaining keys on a single new line, still without headers", () => {
|
||||
expect(labelsAt('[[milestone]]\nname = "M"\n|')).toEqual(["week", "requires"])
|
||||
})
|
||||
|
||||
it("volunteers block headers only after a blank line (two new lines)", () => {
|
||||
const filled = '[[milestone]]\nname = "M"\nweek = 2026-01-01\nrequires = []'
|
||||
// Directly under the last property: nothing left to add ⇒ no popup.
|
||||
expect(getCompletions(...atCaret(`${filled}\n|`))).toBeNull()
|
||||
// A blank line signals a new block is wanted ⇒ offer the headers.
|
||||
expect(labelsAt(`${filled}\n\n|`)).toEqual(["[[feature]]", "[[milestone]]"])
|
||||
})
|
||||
|
||||
it("volunteers headers after a blank line even with keys still open", () => {
|
||||
expect(labelsAt('[[milestone]]\nname = "M"\n\n|')).toEqual([
|
||||
"week",
|
||||
"requires",
|
||||
"[[feature]]",
|
||||
@@ -143,3 +157,25 @@ describe("completion replace range", () => {
|
||||
expect(ctx.items[0].insert).toBe("[[feature]]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("bracket header block separation", () => {
|
||||
/** The inserts offered at the `|` marker. */
|
||||
const insertsAt = (marked: string) => {
|
||||
const caret = marked.indexOf("|")
|
||||
const source = marked.slice(0, caret) + marked.slice(caret + 1)
|
||||
return getCompletions(source, caret)!.items.map((i) => i.insert)
|
||||
}
|
||||
|
||||
it("prepends a blank line when a bare [ glues onto the block above", () => {
|
||||
// No blank line above ⇒ accepting must not weld the new block to the old one.
|
||||
expect(insertsAt('[[feature]]\nname = "X"\n[|')).toEqual(["\n[[feature]]", "\n[[milestone]]"])
|
||||
})
|
||||
|
||||
it("adds no leading newline when a blank line already separates the block", () => {
|
||||
expect(insertsAt('[[feature]]\nname = "X"\n\n[|')).toEqual(["[[feature]]", "[[milestone]]"])
|
||||
})
|
||||
|
||||
it("adds no leading newline at the very top of the document", () => {
|
||||
expect(insertsAt("[|")).toEqual(["[[feature]]", "[[milestone]]"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -103,12 +103,21 @@ export function getCompletions(
|
||||
if (key) {
|
||||
const [, indent, brackets, word] = key
|
||||
if (brackets) {
|
||||
return result(lineStart + indent.length, caret, filter(HEADERS, word))
|
||||
return result(lineStart + indent.length, caret, filter(headerItems(source, lineStart), word))
|
||||
}
|
||||
const keys =
|
||||
block === "feature" ? FEATURE_KEYS : block === "milestone" ? MILESTONE_KEYS : PLAN_KEYS
|
||||
const taken = presentKeys(source, lineStart, block)
|
||||
const items = filter([...keys.filter((k) => !taken.has(k.label)), ...HEADERS], word)
|
||||
const remaining = keys.filter((k) => !taken.has(k.label))
|
||||
// Volunteer a new block header only once there's a blank line above: a new
|
||||
// [[…]] wants a gap before it, so offering one right under the last property
|
||||
// (or straight after the header line) pops the menu up too eagerly. Typing a
|
||||
// bare `[` still summons headers anywhere (handled above). With all keys set
|
||||
// and no gap, `remaining` is empty ⇒ result() returns null ⇒ no popup.
|
||||
const items = filter(
|
||||
blankLineAbove(source, lineStart) ? [...remaining, ...HEADERS] : remaining,
|
||||
word,
|
||||
)
|
||||
return result(caret - word.length, caret, items)
|
||||
}
|
||||
|
||||
@@ -119,6 +128,23 @@ function result(from: number, to: number, items: Completion[]): CompletionContex
|
||||
return items.length ? { from, to, items } : null
|
||||
}
|
||||
|
||||
/** Header completions for a bare `[`. When the block above isn't already set off
|
||||
* by a blank line, the insert carries a leading newline so accepting `[[…]]`
|
||||
* never glues the new block onto the previous one. (At the top of the document
|
||||
* there's nothing to separate, so no gap is added.) */
|
||||
function headerItems(source: string, lineStart: number): Completion[] {
|
||||
if (lineStart === 0 || blankLineAbove(source, lineStart)) return HEADERS
|
||||
return HEADERS.map((h) => ({ ...h, insert: "\n" + h.insert }))
|
||||
}
|
||||
|
||||
/** Is the line directly above the caret's line blank (empty or whitespace only)?
|
||||
* Gates header suggestions to the "two new lines" rule — see the key branch. */
|
||||
function blankLineAbove(source: string, lineStart: number): boolean {
|
||||
if (lineStart === 0) return false // top of the document — nothing above
|
||||
const prevStart = source.lastIndexOf("\n", lineStart - 2) + 1
|
||||
return /^[ \t]*$/.test(source.slice(prevStart, lineStart - 1))
|
||||
}
|
||||
|
||||
/** Today as yyyy-mm-dd in the author's local zone — the calendar day they see,
|
||||
* not UTC's (which slips a day near midnight). Injectable keeps tests stable. */
|
||||
function todayYmd(): string {
|
||||
|
||||
54
src/components/popupFit.test.ts
Normal file
54
src/components/popupFit.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { fitPopup } from "./popupFit"
|
||||
|
||||
// A roomy window with the editor pinned at the top-left, so `origin` adds nothing
|
||||
// and only the caret/box geometry drives the result.
|
||||
const base = {
|
||||
lineHeight: 20,
|
||||
box: { width: 256, height: 224 },
|
||||
origin: { left: 0, top: 0 },
|
||||
viewport: { width: 1000, height: 800 },
|
||||
margin: 8,
|
||||
}
|
||||
|
||||
describe("fitPopup", () => {
|
||||
it("anchors just below the caret when it fits", () => {
|
||||
const { left, top } = fitPopup({ ...base, caret: { left: 100, top: 200 } })
|
||||
expect(left).toBe(100)
|
||||
expect(top).toBe(220) // caret.top + lineHeight
|
||||
})
|
||||
|
||||
it("shifts left so the right edge clears the window", () => {
|
||||
// caret.left 900 + box 256 = 1156, past the 992 usable width by 164.
|
||||
const { left } = fitPopup({ ...base, caret: { left: 900, top: 200 } })
|
||||
expect(left).toBe(736) // 900 - 164
|
||||
expect(left + base.box.width).toBe(base.viewport.width - base.margin)
|
||||
})
|
||||
|
||||
it("flips above the caret line when there's no room below", () => {
|
||||
// caret near the bottom: below would be 780+224 = 1004, past 792.
|
||||
const { top } = fitPopup({ ...base, caret: { left: 100, top: 780 } })
|
||||
expect(top).toBe(780 - 224) // popup bottom now sits on the caret line
|
||||
})
|
||||
|
||||
it("never pushes the flipped popup off the top of the window", () => {
|
||||
// Tiny window: neither below nor a full flip fits, so clamp to the margin.
|
||||
const { top } = fitPopup({
|
||||
...base,
|
||||
caret: { left: 100, top: 40 },
|
||||
viewport: { width: 1000, height: 120 },
|
||||
})
|
||||
expect(top).toBe(base.margin)
|
||||
})
|
||||
|
||||
it("accounts for the editor's offset within the window (origin)", () => {
|
||||
// Editor pushed 800px right; a caret at local 100 sits at window x=900, so the
|
||||
// 256-wide box overflows and must shift left even though caret.left is small.
|
||||
const { left } = fitPopup({
|
||||
...base,
|
||||
caret: { left: 100, top: 200 },
|
||||
origin: { left: 800, top: 0 },
|
||||
})
|
||||
expect(800 + left + base.box.width).toBe(base.viewport.width - base.margin)
|
||||
})
|
||||
})
|
||||
33
src/components/popupFit.ts
Normal file
33
src/components/popupFit.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/** Geometry for keeping the completion popup inside the browser window.
|
||||
*
|
||||
* The popup is anchored just below the caret. Near the right or bottom edge of
|
||||
* the window that anchor spills off-screen and the popup gets clipped, so we:
|
||||
* • shift it left until its right edge clears the window, and
|
||||
* • flip it above the caret line when there's no room below.
|
||||
*
|
||||
* Coordinates are local to the editor (the textarea's border box); `origin` is
|
||||
* that box's own top-left in the window, the bridge from local to window space.
|
||||
*/
|
||||
export function fitPopup(opts: {
|
||||
caret: { left: number; top: number }
|
||||
lineHeight: number
|
||||
box: { width: number; height: number }
|
||||
origin: { left: number; top: number }
|
||||
viewport: { width: number; height: number }
|
||||
margin?: number
|
||||
}): { left: number; top: number } {
|
||||
const { caret, lineHeight, box, origin, viewport } = opts
|
||||
const margin = opts.margin ?? 8
|
||||
|
||||
let left = caret.left
|
||||
const overRight = origin.left + left + box.width - (viewport.width - margin)
|
||||
if (overRight > 0) left = Math.max(margin - origin.left, left - overRight)
|
||||
|
||||
let top = caret.top + lineHeight
|
||||
if (origin.top + top + box.height > viewport.height - margin) {
|
||||
// Not enough room below — flip above, but never off the top of the window.
|
||||
top = Math.max(margin - origin.top, caret.top - box.height)
|
||||
}
|
||||
|
||||
return { left, top }
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
// Default Macroplan shown on first load — exercises every state:
|
||||
// on-time delivery, late delivery with slips, in-flight (on-track/at-risk/off-track),
|
||||
// an overdue Feature, and a Milestone with unmet required Features.
|
||||
export const SAMPLE_PLAN = `title = "Q3 — Checkout revamp"
|
||||
export const SAMPLE_PLAN = `# Macroplan format version — optional; current is 1. See docs/format.md.
|
||||
macroplan_version = 1
|
||||
|
||||
title = "Q3 — Checkout revamp"
|
||||
|
||||
# Optional plan span: pad the plan with lead-in / trailing weeks.
|
||||
# Rule: start ≤ every Feature's start, and end ≥ every Feature's last week.
|
||||
|
||||
@@ -45,6 +45,11 @@ const MilestoneSchema = v.object({
|
||||
),
|
||||
})
|
||||
|
||||
/** The Macroplan format version this build understands (see docs/format.md).
|
||||
* Files may declare `macroplan_version`; a newer version is rejected rather
|
||||
* than silently mis-rendered against a schema we don't know. */
|
||||
export const FORMAT_VERSION = 1
|
||||
|
||||
/** Parse + validate a Macroplan TOML source into the raw model. */
|
||||
export function parseMacroplan(source: string): RawPlan {
|
||||
let data: Record<string, unknown>
|
||||
@@ -54,19 +59,51 @@ export function parseMacroplan(source: string): RawPlan {
|
||||
throw new PlanParseError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
|
||||
checkVersion(data.macroplan_version)
|
||||
|
||||
const features = asBlocks(data.feature, "feature").map((f, i) =>
|
||||
check(FeatureSchema, f, blockWhere("feature", f, i)),
|
||||
)
|
||||
requireUniqueFeatureNames(features)
|
||||
|
||||
return {
|
||||
title: data.title != null ? String(data.title) : "Untitled Macroplan",
|
||||
start: data.start != null ? check(Ymd, data.start, "plan", "start") : undefined,
|
||||
end: data.end != null ? check(Ymd, data.end, "plan", "end") : undefined,
|
||||
features: asBlocks(data.feature, "feature").map((f, i) =>
|
||||
check(FeatureSchema, f, blockWhere("feature", f, i)),
|
||||
),
|
||||
features,
|
||||
milestones: asBlocks(data.milestone, "milestone").map((m, i) =>
|
||||
check(MilestoneSchema, m, blockWhere("milestone", m, i)),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate the optional `macroplan_version` marker. Absent means the current
|
||||
* version; a future version we don't understand is rejected rather than
|
||||
* silently mis-rendered. */
|
||||
function checkVersion(value: unknown): void {
|
||||
if (value == null) return
|
||||
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
||||
throw new PlanParseError("`macroplan_version` must be a positive integer")
|
||||
}
|
||||
if (value > FORMAT_VERSION) {
|
||||
throw new PlanParseError(
|
||||
`unsupported macroplan_version ${value} — this build understands up to ${FORMAT_VERSION}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Feature names are the join key for a Milestone's `requires`, so they must be
|
||||
* unique — otherwise a requirement resolves ambiguously to the first match. */
|
||||
function requireUniqueFeatureNames(features: readonly { name: string }[]): void {
|
||||
const seen = new Set<string>()
|
||||
for (const f of features) {
|
||||
if (seen.has(f.name)) {
|
||||
throw new PlanParseError(`duplicate feature name "${f.name}" — feature names must be unique`)
|
||||
}
|
||||
seen.add(f.name)
|
||||
}
|
||||
}
|
||||
|
||||
function asBlocks(value: unknown, key: string): unknown[] {
|
||||
if (value == null) return []
|
||||
if (!Array.isArray(value)) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import exampleToml from "../../docs/macroplan.example.toml?raw"
|
||||
import { parseMacroplan, PlanParseError } from "./parse"
|
||||
import { buildPlan } from "./plan"
|
||||
import { mondayOf, weekRange } from "./week"
|
||||
@@ -181,3 +182,42 @@ describe("parse validation", () => {
|
||||
expect(raw.milestones).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe("format version (macroplan_version)", () => {
|
||||
const feature = '[[feature]]\nname = "A"\nstart = 2026-06-01\noriginal = 2026-06-08\n'
|
||||
|
||||
it("accepts an explicit current version", () => {
|
||||
expect(() => parseMacroplan(`macroplan_version = 1\n${feature}`)).not.toThrow()
|
||||
})
|
||||
|
||||
it("accepts an absent version (defaults to current)", () => {
|
||||
expect(() => parseMacroplan(feature)).not.toThrow()
|
||||
})
|
||||
|
||||
it("rejects a future/unsupported version rather than mis-rendering it", () => {
|
||||
expect(() => parseMacroplan(`macroplan_version = 2\n${feature}`)).toThrow(/unsupported/)
|
||||
})
|
||||
|
||||
it("rejects a non-integer version", () => {
|
||||
expect(() => parseMacroplan(`macroplan_version = 1.5\n${feature}`)).toThrow(/macroplan_version/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("feature-name uniqueness", () => {
|
||||
it("rejects duplicate feature names (they are the milestone join key)", () => {
|
||||
const dup =
|
||||
'[[feature]]\nname = "A"\nstart = 2026-06-01\noriginal = 2026-06-08\n' +
|
||||
'[[feature]]\nname = "A"\nstart = 2026-06-01\noriginal = 2026-06-15\n'
|
||||
expect(() => parseMacroplan(dup)).toThrow(/duplicate/)
|
||||
expect(() => parseMacroplan(dup)).toThrow(PlanParseError)
|
||||
})
|
||||
})
|
||||
|
||||
describe("reference example (docs/macroplan.example.toml)", () => {
|
||||
it("conforms to the format the parser implements", () => {
|
||||
const raw = parseMacroplan(exampleToml)
|
||||
expect(raw.title).toBe("Q3 — Checkout revamp")
|
||||
expect(raw.features).toHaveLength(5)
|
||||
expect(raw.milestones).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user