Skip to content

Commit 87352bb

Browse files
authored
feat(export): include self-evaluation data in PDF/PPTX/Markdown exports (#90)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 39f5db7 commit 87352bb

10 files changed

Lines changed: 205 additions & 16 deletions

File tree

‎src/App.jsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export default function App() {
107107
setView={setView}
108108
assessmentMode={assessmentMode}
109109
onToggleAssessment={() => setAssessmentMode((v) => !v)}
110+
assessmentState={assessmentState}
110111
/>
111112

112113
<p className="app__hint">{t(lang, activeView.hint)}</p>
@@ -316,7 +317,7 @@ export default function App() {
316317
</p>
317318
</section>
318319

319-
<PrintDocument lang={lang} />
320+
<PrintDocument lang={lang} assessment={assessmentState} />
320321
</div>
321322
);
322323
}

‎src/components/AppHeader.jsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export default function AppHeader({
99
setView,
1010
assessmentMode,
1111
onToggleAssessment,
12+
assessmentState,
1213
}) {
1314
return (
1415
<header className="app-header">
@@ -85,7 +86,7 @@ export default function AppHeader({
8586
))}
8687
</div>
8788

88-
<ExportMenu lang={lang} />
89+
<ExportMenu lang={lang} assessment={assessmentState} />
8990
</div>
9091
</header>
9192
);

‎src/components/ExportMenu.jsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useRef, useState, useEffect } from 'react';
22
import { t } from '../i18n/strings.js';
33
import { exportPDF, exportPPTX, exportMarkdown } from '../data/export.js';
44

5-
export default function ExportMenu({ lang }) {
5+
export default function ExportMenu({ lang, assessment }) {
66
const [open, setOpen] = useState(false);
77
const [exporting, setExporting] = useState(false);
88
const menuRef = useRef(null);
@@ -33,7 +33,7 @@ export default function ExportMenu({ lang }) {
3333
setOpen(false);
3434
setExporting(true);
3535
try {
36-
await exportPPTX(lang);
36+
await exportPPTX(lang, assessment);
3737
} finally {
3838
setExporting(false);
3939
}
@@ -46,7 +46,7 @@ export default function ExportMenu({ lang }) {
4646

4747
function handleMarkdown() {
4848
setOpen(false);
49-
exportMarkdown(lang);
49+
exportMarkdown(lang, assessment);
5050
}
5151

5252
return (

‎src/components/PrintDocument.jsx‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ import {
77
localizedStandardsWhy,
88
localizedGuidance,
99
} from '../data/grip.js';
10+
import { entryForCode, hasSelfEvaluation } from '../data/assessment.js';
1011

1112
// Hidden on screen; revealed only when printing (see @media print in index.css).
1213
// Renders one full page per GRIP measure so the browser "Save as PDF" output
1314
// mirrors the PPTX export: every measure with all of its detail.
14-
export default function PrintDocument({ lang }) {
15+
export default function PrintDocument({ lang, assessment }) {
1516
const measures = getMeasures();
1617

1718
return (
@@ -33,6 +34,7 @@ export default function PrintDocument({ lang }) {
3334
const standardsWhy = localizedStandardsWhy(measure, lang);
3435
const typeLabel =
3536
measure.type === 'T' ? t(lang, 'technical') : t(lang, 'organisational');
37+
const selfEval = assessment ? entryForCode(assessment, measure.code) : null;
3638

3739
return (
3840
<article className="print-page" key={measure.code}>
@@ -56,6 +58,28 @@ export default function PrintDocument({ lang }) {
5658
<h2 className="print-page__title">{title}</h2>
5759
<p className="print-page__summary">{summary}</p>
5860

61+
{selfEval && (
62+
<section className="print-section print-self-eval">
63+
<h3 className="print-section__title">{t(lang, 'selfEvaluationTitle')}</h3>
64+
{hasSelfEvaluation(selfEval) ? (
65+
<>
66+
<p className="print-self-eval__status">
67+
<strong>{t(lang, 'assessmentStatusLabel')}:</strong>{' '}
68+
{t(lang, `status_${selfEval.status}`)}
69+
</p>
70+
{selfEval.note && selfEval.note.trim() !== '' && (
71+
<p className="print-self-eval__note">
72+
<strong>{t(lang, 'assessmentNoteLabel')}:</strong>{' '}
73+
{selfEval.note.trim()}
74+
</p>
75+
)}
76+
</>
77+
) : (
78+
<p className="print-section__muted">{t(lang, 'selfEvaluationNone')}</p>
79+
)}
80+
</section>
81+
)}
82+
5983
{guidance && (
6084
<section className="print-section">
6185
<h3 className="print-section__title">{t(lang, 'guidanceTitle')}</h3>

‎src/components/PrintDocument.test.jsx‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,31 @@ describe('PrintDocument', () => {
3737
expect(scoped.getByText(/learn\.microsoft\.com/)).toBeInTheDocument();
3838
});
3939

40-
it('localizes the cover to English', () => {
41-
const { container } = render(<PrintDocument lang="en" />);
40+
it('renders the self-evaluation status and note when assessment data is provided', () => {
41+
const assessment = {
42+
measures: {
43+
T1: { status: 'done', note: 'Implemented and reviewed.' },
44+
},
45+
};
46+
const { container } = render(<PrintDocument lang="en" assessment={assessment} />);
4247
const t1Page = Array.from(container.querySelectorAll('.print-page')).find((page) =>
4348
within(page).queryByText('T1')
4449
);
45-
expect(within(t1Page).getByText('Practical guidance')).toBeInTheDocument();
50+
const scoped = within(t1Page);
51+
expect(scoped.getByText('Self-assessment')).toBeInTheDocument();
52+
expect(scoped.getByText('Implemented and reviewed.')).toBeInTheDocument();
53+
54+
// A measure without an entry shows the empty-state placeholder.
55+
const otherPage = Array.from(container.querySelectorAll('.print-page')).find((page) =>
56+
within(page).queryByText('O1')
57+
);
58+
expect(
59+
within(otherPage).getByText('No self-assessment provided')
60+
).toBeInTheDocument();
61+
});
62+
63+
it('omits the self-evaluation section when no assessment is provided', () => {
64+
const { container } = render(<PrintDocument lang="en" />);
65+
expect(container.querySelector('.print-self-eval')).toBeNull();
4666
});
4767
});

‎src/data/assessment.js‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,21 @@ export function useAssessment() {
147147
return { state, setStatus, setNote, reset, importMeasures, getStatus, getEntry };
148148
}
149149

150+
// Returns the normalized self-evaluation entry ({ status, note }) for a measure
151+
// code from a state object (as produced by useAssessment / parseImport). Safe
152+
// for missing or empty state so export code can call it unconditionally.
153+
export function entryForCode(state, code) {
154+
return normalizeEntry(state?.measures?.[code]);
155+
}
156+
157+
// True when an entry carries meaningful self-evaluation: a non-default status
158+
// or a non-empty note. Used to decide whether to render self-evaluation detail
159+
// in exports.
160+
export function hasSelfEvaluation(entry) {
161+
if (!entry) return false;
162+
return entry.status !== DEFAULT_STATUS || (entry.note ?? '').trim() !== '';
163+
}
164+
150165
// Trigger a browser download of the current assessment as a JSON file.
151166
export function exportAssessment(state) {
152167
const payload = {

‎src/data/export.js‎

Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
localizedGuidance,
99
} from './grip.js';
1010
import { t } from '../i18n/strings.js';
11+
import { entryForCode, hasSelfEvaluation } from './assessment.js';
1112

1213
// ─── Colour palette (mirrors CSS variables) ──────────────────────────────────
1314
const COLORS = {
@@ -53,8 +54,10 @@ function dateLocale(lang) {
5354
}
5455

5556
// Builds the Markdown body for a single measure, mirroring the detail shown in
56-
// the app and the PDF/PPTX exports.
57-
function measureMarkdown(measure, lang) {
57+
// the app and the PDF/PPTX exports. When an assessment state is supplied the
58+
// user's self-evaluation (status + note) is rendered just below the summary,
59+
// matching the on-screen ordering.
60+
function measureMarkdown(measure, lang, assessment) {
5861
const lines = [];
5962
const title = localized(measure, 'title', lang);
6063
const typeLabel =
@@ -71,6 +74,26 @@ function measureMarkdown(measure, lang) {
7174
lines.push(localized(measure, 'summary', lang));
7275
lines.push('');
7376

77+
// Self-evaluation (user-provided status + note)
78+
if (assessment) {
79+
const entry = entryForCode(assessment, measure.code);
80+
lines.push(`### ${t(lang, 'selfEvaluationTitle')}`);
81+
lines.push('');
82+
if (hasSelfEvaluation(entry)) {
83+
lines.push(
84+
`**${t(lang, 'assessmentStatusLabel')}:** ${t(lang, `status_${entry.status}`)}`
85+
);
86+
lines.push('');
87+
if (entry.note && entry.note.trim() !== '') {
88+
lines.push(`**${t(lang, 'assessmentNoteLabel')}:** ${entry.note.trim()}`);
89+
lines.push('');
90+
}
91+
} else {
92+
lines.push(`_${t(lang, 'selfEvaluationNone')}_`);
93+
lines.push('');
94+
}
95+
}
96+
7497
// Practical guidance
7598
const guidance = localizedGuidance(measure, lang);
7699
if (guidance) {
@@ -159,8 +182,10 @@ function measureMarkdown(measure, lang) {
159182
* triggers a client-side download. No data leaves the browser.
160183
*
161184
* @param {string} lang Active UI language ('nl' | 'en' | 'fr')
185+
* @param {object} [assessment] Optional self-evaluation state (state.measures
186+
* keyed by measure code) whose status/note are rendered per measure.
162187
*/
163-
export function exportMarkdown(lang) {
188+
export function exportMarkdown(lang, assessment) {
164189
const measures = getMeasures();
165190
const today = new Date().toLocaleDateString(dateLocale(lang), {
166191
year: 'numeric',
@@ -175,7 +200,7 @@ export function exportMarkdown(lang) {
175200
'',
176201
today,
177202
'',
178-
...measures.map((measure) => measureMarkdown(measure, lang)),
203+
...measures.map((measure) => measureMarkdown(measure, lang, assessment)),
179204
];
180205

181206
const markdown = parts.join('\n');
@@ -261,6 +286,57 @@ function guidanceRuns(measure, lang) {
261286
return runs;
262287
}
263288

289+
// Builds the stacked text runs for the self-evaluation block (status + note).
290+
function selfEvaluationRuns(entry, lang) {
291+
const runs = [
292+
{
293+
text: t(lang, 'selfEvaluationTitle'),
294+
options: {
295+
bold: true,
296+
fontSize: 13,
297+
color: COLORS.accentInk,
298+
breakLine: true,
299+
paraSpaceBefore: 8,
300+
},
301+
},
302+
];
303+
304+
if (!hasSelfEvaluation(entry)) {
305+
runs.push({
306+
text: t(lang, 'selfEvaluationNone'),
307+
options: { italic: true, fontSize: 11, color: COLORS.inkFaint, breakLine: true },
308+
});
309+
return runs;
310+
}
311+
312+
runs.push({
313+
text: `${t(lang, 'assessmentStatusLabel')}: `,
314+
options: { bold: true, fontSize: 11, color: COLORS.ink },
315+
});
316+
runs.push({
317+
text: t(lang, `status_${entry.status}`),
318+
options: {
319+
fontSize: 11,
320+
color: COLORS.inkSoft,
321+
breakLine: true,
322+
paraSpaceAfter: 4,
323+
},
324+
});
325+
326+
if (entry.note && entry.note.trim() !== '') {
327+
runs.push({
328+
text: `${t(lang, 'assessmentNoteLabel')}: `,
329+
options: { bold: true, fontSize: 11, color: COLORS.ink },
330+
});
331+
runs.push({
332+
text: entry.note.trim(),
333+
options: { fontSize: 11, color: COLORS.inkSoft, breakLine: true },
334+
});
335+
}
336+
337+
return runs;
338+
}
339+
264340
// Builds the stacked text runs for the right "mapping + standards" column.
265341
function mappingRuns(measure, lang) {
266342
const runs = [
@@ -382,8 +458,10 @@ function mappingRuns(measure, lang) {
382458
* documentation links, and the standards mapping with control references.
383459
*
384460
* @param {string} lang Active UI language ('nl' | 'en' | 'fr')
461+
* @param {object} [assessment] Optional self-evaluation state (state.measures
462+
* keyed by measure code) whose status/note are rendered per measure slide.
385463
*/
386-
export async function exportPPTX(lang) {
464+
export async function exportPPTX(lang, assessment) {
387465
const measures = getMeasures();
388466
const pptx = new PptxGenJS();
389467

@@ -535,8 +613,12 @@ export async function exportPPTX(lang) {
535613
fit: 'shrink',
536614
});
537615

538-
// Left column — practical guidance
539-
slide.addText(guidanceRuns(measure, lang), {
616+
// Left column — practical guidance (plus self-evaluation when present)
617+
const leftRuns = guidanceRuns(measure, lang);
618+
if (assessment) {
619+
leftRuns.push(...selfEvaluationRuns(entryForCode(assessment, measure.code), lang));
620+
}
621+
slide.addText(leftRuns, {
540622
x: 0.35,
541623
y: 2.12,
542624
w: 6.1,

‎src/data/export.test.jsx‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,20 @@ describe('exportPPTX', () => {
3030
expect(writeSpy).toHaveBeenCalledTimes(3);
3131
writeSpy.mockRestore();
3232
});
33+
34+
it('builds a deck that includes self-evaluation data without throwing', async () => {
35+
const writeSpy = vi
36+
.spyOn(PptxGenJS.prototype, 'writeFile')
37+
.mockResolvedValue(undefined);
38+
const assessment = {
39+
measures: {
40+
O1: { status: 'in_progress', note: 'Rolling this out across teams.' },
41+
},
42+
};
43+
await expect(exportPPTX('en', assessment)).resolves.not.toThrow();
44+
expect(writeSpy).toHaveBeenCalledTimes(1);
45+
writeSpy.mockRestore();
46+
});
3347
});
3448

3549
// ── exportMarkdown ───────────────────────────────────────────────────────────
@@ -79,6 +93,27 @@ describe('exportMarkdown', () => {
7993
// Standards link with a control reference (NIS2)
8094
expect(text).toContain('NIS2 Directive (EU) 2022/2555');
8195
});
96+
97+
it('omits the self-evaluation section when no assessment is provided', async () => {
98+
exportMarkdown('en');
99+
const text = await lastBlob.text();
100+
expect(text).not.toContain('### Self-assessment');
101+
});
102+
103+
it('includes the self-evaluation status and note for measures with data', async () => {
104+
const assessment = {
105+
measures: {
106+
O1: { status: 'done', note: 'We finished this measure last quarter.' },
107+
},
108+
};
109+
exportMarkdown('en', assessment);
110+
const text = await lastBlob.text();
111+
expect(text).toContain('### Self-assessment');
112+
expect(text).toContain('**Progress:** Done');
113+
expect(text).toContain('**Note:** We finished this measure last quarter.');
114+
// A measure without an entry falls back to the empty-state placeholder.
115+
expect(text).toContain('No self-assessment provided');
116+
});
82117
});
83118

84119
describe('ExportMenu', () => {

‎src/i18n/strings.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ export const ui = {
110110
assessmentStatusLabel: 'Voortgang',
111111
assessmentNoteLabel: 'Notitie',
112112
assessmentNotePlaceholder: 'Voeg een notitie toe...',
113+
selfEvaluationTitle: 'Zelfevaluatie',
114+
selfEvaluationNone: 'Geen zelfevaluatie ingevuld',
113115
scorecardTitle: 'Voortgangsoverzicht',
114116
scorecardBrowserOnly:
115117
'Alle evaluatiegegevens blijven uitsluitend in je browser opgeslagen. Er worden geen gegevens verzonden.',
@@ -218,6 +220,8 @@ export const ui = {
218220
assessmentStatusLabel: 'Progress',
219221
assessmentNoteLabel: 'Note',
220222
assessmentNotePlaceholder: 'Add a note...',
223+
selfEvaluationTitle: 'Self-assessment',
224+
selfEvaluationNone: 'No self-assessment provided',
221225
scorecardTitle: 'Progress overview',
222226
scorecardBrowserOnly:
223227
'All assessment data is stored exclusively in your browser. Nothing is ever sent to a server.',
@@ -329,6 +333,8 @@ export const ui = {
329333
assessmentStatusLabel: 'Avancement',
330334
assessmentNoteLabel: 'Note',
331335
assessmentNotePlaceholder: 'Ajouter une note...',
336+
selfEvaluationTitle: 'Auto-évaluation',
337+
selfEvaluationNone: 'Aucune auto-évaluation fournie',
332338
scorecardTitle: "Vue d'ensemble",
333339
scorecardBrowserOnly:
334340
"Toutes les données d'évaluation sont stockées exclusivement dans votre navigateur. Rien n'est jamais envoyé à un serveur.",

0 commit comments

Comments
 (0)