Skip to content

Commit 617c266

Browse files
authored
feat: self-assessment mode (browser-only, with import/export)
1 parent b1e1980 commit 617c266

11 files changed

Lines changed: 985 additions & 9 deletions

File tree

‎src/App.jsx‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import JourneyView from './components/JourneyView.jsx';
55
import ValueA3A5View from './components/ValueA3A5View.jsx';
66
import PrioritizeView from './components/PrioritizeView.jsx';
77
import MeasureDetailPanel from './components/MeasureDetailPanel.jsx';
8+
import AssessmentScorecard from './components/AssessmentScorecard.jsx';
89
import { findMeasure, getMeta } from './data/grip.js';
10+
import { useAssessment } from './data/assessment.js';
911
import { t } from './i18n/strings.js';
1012

1113
const REPO_URL = 'https://github.com/DevSecNinja/grip-visualizer';
@@ -49,6 +51,17 @@ export default function App() {
4951
const [typeFilter, setTypeFilter] = useState(null);
5052
const [tierFilter, setTierFilter] = useState(null);
5153
const [selectedCode, setSelectedCode] = useState(null);
54+
const [assessmentMode, setAssessmentMode] = useState(false);
55+
56+
const {
57+
state: assessmentState,
58+
setStatus,
59+
setNote,
60+
reset,
61+
importMeasures,
62+
getStatus,
63+
getEntry,
64+
} = useAssessment();
5265

5366
useEffect(() => {
5467
document.documentElement.lang = lang;
@@ -68,10 +81,21 @@ export default function App() {
6881
views={VIEWS}
6982
view={view}
7083
setView={setView}
84+
assessmentMode={assessmentMode}
85+
onToggleAssessment={() => setAssessmentMode((v) => !v)}
7186
/>
7287

7388
<p className="app__hint">{t(lang, activeView.hint)}</p>
7489

90+
{assessmentMode && (
91+
<AssessmentScorecard
92+
lang={lang}
93+
state={assessmentState}
94+
onImport={importMeasures}
95+
onReset={reset}
96+
/>
97+
)}
98+
7599
<div className="app__body">
76100
<main className="app__main">
77101
<ActiveComponent
@@ -80,13 +104,19 @@ export default function App() {
80104
onSelect={setSelectedCode}
81105
typeFilter={filterable ? typeFilter : null}
82106
tierFilter={filterable ? tierFilter : null}
107+
getAssessmentStatus={assessmentMode ? getStatus : null}
83108
/>
84109
</main>
85110

86111
<MeasureDetailPanel
87112
measure={selected}
88113
lang={lang}
89114
onClose={() => setSelectedCode(null)}
115+
assessmentEntry={
116+
assessmentMode && selected ? getEntry(selected.code) : undefined
117+
}
118+
onStatusChange={assessmentMode ? setStatus : undefined}
119+
onNoteChange={assessmentMode ? setNote : undefined}
90120
/>
91121
</div>
92122

‎src/components/AppHeader.jsx‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { t, LANGS } from '../i18n/strings.js';
22

3-
export default function AppHeader({ lang, setLang, views, view, setView }) {
3+
export default function AppHeader({
4+
lang,
5+
setLang,
6+
views,
7+
view,
8+
setView,
9+
assessmentMode,
10+
onToggleAssessment,
11+
}) {
412
return (
513
<header className="app-header">
614
<div className="app-header__brand">
@@ -53,6 +61,15 @@ export default function AppHeader({ lang, setLang, views, view, setView }) {
5361
))}
5462
</div>
5563

64+
<label
65+
className={`switch${assessmentMode ? ' switch--checked' : ''}`}
66+
title={t(lang, 'assessmentModeHint')}
67+
>
68+
<input type="checkbox" checked={assessmentMode} onChange={onToggleAssessment} />
69+
<span className="switch__track" />
70+
{t(lang, 'assessmentMode')}
71+
</label>
72+
5673
<div className="seg seg--lang" role="group" aria-label={t(lang, 'langLabel')}>
5774
{LANGS.map((l) => (
5875
<button
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { useRef, useState } from 'react';
2+
import { getMeasures, measuresByBasis, BASIS_LEVELS } from '../data/grip.js';
3+
import {
4+
ASSESSMENT_STATUSES,
5+
exportAssessment,
6+
parseImport,
7+
} from '../data/assessment.js';
8+
import { t } from '../i18n/strings.js';
9+
10+
function countStatuses(measures, state) {
11+
const counts = Object.fromEntries(ASSESSMENT_STATUSES.map((s) => [s, 0]));
12+
for (const m of measures) {
13+
const status = state.measures[m.code]?.status ?? 'not_started';
14+
counts[status] = (counts[status] || 0) + 1;
15+
}
16+
return { counts, total: measures.length };
17+
}
18+
19+
export default function AssessmentScorecard({ lang, state, onImport, onReset }) {
20+
const fileRef = useRef(null);
21+
const [importMsg, setImportMsg] = useState(null);
22+
23+
const allMeasures = getMeasures();
24+
const { counts: overall, total } = countStatuses(allMeasures, state);
25+
const doneCount = overall.done;
26+
const inProgressCount = overall.in_progress;
27+
const donePercent = total > 0 ? Math.round((doneCount / total) * 100) : 0;
28+
const inProgressPercent = total > 0 ? Math.round((inProgressCount / total) * 100) : 0;
29+
30+
function handleExport() {
31+
exportAssessment(state);
32+
}
33+
34+
function handleImportClick() {
35+
setImportMsg(null);
36+
fileRef.current?.click();
37+
}
38+
39+
function handleFileChange(e) {
40+
const file = e.target.files?.[0];
41+
if (!file) return;
42+
const reader = new FileReader();
43+
reader.onload = (ev) => {
44+
try {
45+
const { measures, warnings } = parseImport(ev.target.result);
46+
onImport(measures);
47+
const parts = [t(lang, 'importSuccess')];
48+
if (warnings.unknown.length > 0) {
49+
const MAX_SHOWN = 5;
50+
const shown = warnings.unknown.slice(0, MAX_SHOWN).join(', ');
51+
const extra = warnings.unknown.length - MAX_SHOWN;
52+
const suffix = extra > 0 ? ` (+${extra})` : '';
53+
parts.push(`${t(lang, 'importWarnUnknown')}: ${shown}${suffix}.`);
54+
}
55+
if (warnings.missing.length > 0) {
56+
parts.push(`${t(lang, 'importWarnMissing')}: ${warnings.missing.length}.`);
57+
}
58+
setImportMsg({ type: 'success', text: parts.join(' ') });
59+
} catch (err) {
60+
const key =
61+
err.message === 'invalidJson' ? 'importErrorJson' : 'importErrorSchema';
62+
setImportMsg({ type: 'error', text: t(lang, key) });
63+
}
64+
// Reset input so the same file can be re-imported if needed
65+
e.target.value = '';
66+
};
67+
reader.readAsText(file);
68+
}
69+
70+
function handleReset() {
71+
if (window.confirm(t(lang, 'assessmentResetConfirm'))) {
72+
onReset();
73+
setImportMsg(null);
74+
}
75+
}
76+
77+
return (
78+
<section className="scorecard" aria-label={t(lang, 'scorecardTitle')}>
79+
<div className="scorecard__head">
80+
<div>
81+
<h2 className="scorecard__title">{t(lang, 'scorecardTitle')}</h2>
82+
<p className="scorecard__browser-notice">
83+
<svg className="scorecard__lock-icon" viewBox="0 0 16 16" aria-hidden="true">
84+
<path
85+
d="M4.5 7V5a3.5 3.5 0 0 1 7 0v2M3.5 7h9v6h-9z"
86+
fill="none"
87+
stroke="currentColor"
88+
strokeWidth="1.4"
89+
strokeLinecap="round"
90+
strokeLinejoin="round"
91+
/>
92+
</svg>
93+
{t(lang, 'scorecardBrowserOnly')}
94+
</p>
95+
</div>
96+
<div className="scorecard__actions">
97+
<button type="button" className="scorecard__btn" onClick={handleExport}>
98+
↓ {t(lang, 'assessmentExport')}
99+
</button>
100+
<button type="button" className="scorecard__btn" onClick={handleImportClick}>
101+
↑ {t(lang, 'assessmentImport')}
102+
</button>
103+
<input
104+
ref={fileRef}
105+
type="file"
106+
accept=".json,application/json"
107+
className="scorecard__file-input"
108+
onChange={handleFileChange}
109+
aria-hidden="true"
110+
/>
111+
<button
112+
type="button"
113+
className="scorecard__btn scorecard__btn--danger"
114+
onClick={handleReset}
115+
>
116+
{t(lang, 'assessmentReset')}
117+
</button>
118+
</div>
119+
</div>
120+
121+
{importMsg && (
122+
<p className={`scorecard__msg scorecard__msg--${importMsg.type}`} role="status">
123+
{importMsg.text}
124+
</p>
125+
)}
126+
127+
{/* Overall progress bar */}
128+
<div className="scorecard__overall">
129+
<div
130+
className="scorecard__progress-bar"
131+
role="progressbar"
132+
aria-valuenow={donePercent}
133+
aria-valuemin={0}
134+
aria-valuemax={100}
135+
>
136+
<div
137+
className="scorecard__progress-fill scorecard__progress-fill--done"
138+
style={{ width: `${donePercent}%` }}
139+
/>
140+
<div
141+
className="scorecard__progress-fill scorecard__progress-fill--in-progress"
142+
style={{ width: `${inProgressPercent}%` }}
143+
/>
144+
</div>
145+
<div className="scorecard__overall-stats">
146+
<span className="scorecard__stat">
147+
<span className="scorecard__dot scorecard__dot--done" />
148+
{t(lang, 'status_done')}: {doneCount}/{total} ({donePercent}%)
149+
</span>
150+
<span className="scorecard__stat">
151+
<span className="scorecard__dot scorecard__dot--in-progress" />
152+
{t(lang, 'status_in_progress')}: {inProgressCount}
153+
</span>
154+
<span className="scorecard__stat">
155+
<span className="scorecard__dot scorecard__dot--not-applicable" />
156+
{t(lang, 'status_not_applicable')}: {overall.not_applicable}
157+
</span>
158+
</div>
159+
</div>
160+
161+
{/* Per-basis breakdown */}
162+
<div className="scorecard__basis-grid">
163+
{BASIS_LEVELS.map((basis) => {
164+
const basisMeasures = measuresByBasis(basis);
165+
const { counts, total: bTotal } = countStatuses(basisMeasures, state);
166+
const bDonePercent = bTotal > 0 ? Math.round((counts.done / bTotal) * 100) : 0;
167+
const bInProgressPercent =
168+
bTotal > 0 ? Math.round((counts.in_progress / bTotal) * 100) : 0;
169+
return (
170+
<div
171+
key={basis}
172+
className={`scorecard__basis-item scorecard__basis-item--b${basis}`}
173+
>
174+
<div className="scorecard__basis-head">
175+
<span className="scorecard__basis-num">{basis}</span>
176+
<span className="scorecard__basis-pct">{bDonePercent}%</span>
177+
</div>
178+
<div className="scorecard__basis-bar">
179+
<div
180+
className="scorecard__basis-fill scorecard__basis-fill--done"
181+
style={{ width: `${bDonePercent}%` }}
182+
/>
183+
<div
184+
className="scorecard__basis-fill scorecard__basis-fill--in-progress"
185+
style={{ width: `${bInProgressPercent}%` }}
186+
/>
187+
</div>
188+
<div className="scorecard__basis-counts">
189+
{counts.done}/{bTotal}
190+
</div>
191+
</div>
192+
);
193+
})}
194+
</div>
195+
</section>
196+
);
197+
}

‎src/components/JourneyView.jsx‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,17 @@ import { BASIS_LEVELS, measuresByBasis, highestTier } from '../data/grip.js';
22
import { t } from '../i18n/strings.js';
33
import MeasureCard from './MeasureCard.jsx';
44

5-
export default function JourneyView({ lang, selectedCode, onSelect, typeFilter, tierFilter }) {
5+
export default function JourneyView({
6+
lang,
7+
selectedCode,
8+
onSelect,
9+
typeFilter,
10+
tierFilter,
11+
getAssessmentStatus,
12+
}) {
613
const isDimmed = (m) =>
7-
(typeFilter && m.type !== typeFilter) || (tierFilter && highestTier(m) !== tierFilter);
14+
(typeFilter && m.type !== typeFilter) ||
15+
(tierFilter && highestTier(m) !== tierFilter);
816

917
return (
1018
<div className="journey" role="region" aria-label={t(lang, 'viewJourney')}>
@@ -34,6 +42,7 @@ export default function JourneyView({ lang, selectedCode, onSelect, typeFilter,
3442
selected={m.code === selectedCode}
3543
dimmed={isDimmed(m)}
3644
onSelect={onSelect}
45+
status={getAssessmentStatus ? getAssessmentStatus(m.code) : null}
3746
/>
3847
))}
3948
</div>

‎src/components/MatrixView.jsx‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,17 @@ import { BASIS_LEVELS, measuresByBasis, highestTier } from '../data/grip.js';
22
import { t } from '../i18n/strings.js';
33
import MeasureCard from './MeasureCard.jsx';
44

5-
export default function MatrixView({ lang, selectedCode, onSelect, typeFilter, tierFilter }) {
5+
export default function MatrixView({
6+
lang,
7+
selectedCode,
8+
onSelect,
9+
typeFilter,
10+
tierFilter,
11+
getAssessmentStatus,
12+
}) {
613
const isDimmed = (m) =>
7-
(typeFilter && m.type !== typeFilter) || (tierFilter && highestTier(m) !== tierFilter);
14+
(typeFilter && m.type !== typeFilter) ||
15+
(tierFilter && highestTier(m) !== tierFilter);
816

917
return (
1018
<div className="matrix" role="region" aria-label={t(lang, 'viewMatrix')}>
@@ -28,6 +36,7 @@ export default function MatrixView({ lang, selectedCode, onSelect, typeFilter, t
2836
selected={m.code === selectedCode}
2937
dimmed={isDimmed(m)}
3038
onSelect={onSelect}
39+
status={getAssessmentStatus ? getAssessmentStatus(m.code) : null}
3140
/>
3241
))}
3342
</div>

‎src/components/MeasureCard.jsx‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { highestTier, localized } from '../data/grip.js';
22

3-
export default function MeasureCard({ measure, lang, selected, dimmed, onSelect }) {
3+
export default function MeasureCard({
4+
measure,
5+
lang,
6+
selected,
7+
dimmed,
8+
onSelect,
9+
status,
10+
}) {
411
const title = localized(measure, 'title', lang);
512
const tier = highestTier(measure);
613

@@ -22,8 +29,18 @@ export default function MeasureCard({ measure, lang, selected, dimmed, onSelect
2229
>
2330
<span className="measure-card__head">
2431
<span className="measure-card__code">{measure.code}</span>
25-
<span className={`measure-card__tier measure-card__tier--${tier.toLowerCase()}`}>
26-
{tier}
32+
<span className="measure-card__head-right">
33+
{status && (
34+
<span
35+
className={`measure-card__status-dot measure-card__status-dot--${status.replace(/_/g, '-')}`}
36+
aria-hidden="true"
37+
/>
38+
)}
39+
<span
40+
className={`measure-card__tier measure-card__tier--${tier.toLowerCase()}`}
41+
>
42+
{tier}
43+
</span>
2744
</span>
2845
</span>
2946
<span className="measure-card__title">{title}</span>

0 commit comments

Comments
 (0)