diff --git a/src/apps/dashboard/static/apps/dashboard/recognition.js b/src/apps/dashboard/static/apps/dashboard/recognition.js new file mode 100644 index 0000000..f4f891c --- /dev/null +++ b/src/apps/dashboard/static/apps/dashboard/recognition.js @@ -0,0 +1,49 @@ +const Recognition = (() => { + 'use strict'; + + function showBanner(recognition) { + if (!recognition) return; + + const earned = new Date(recognition.earned_at); + const dateStr = earned.toLocaleDateString(undefined, { + year: 'numeric', month: 'short', day: 'numeric', + }); + + const banner = document.createElement('div'); + banner.className = 'recog-banner'; + banner.innerHTML = + '
' + + '

' + _esc(recognition.title) + '

' + + '

' + _esc(recognition.description) + '

' + + '' + + '
' + + '
' + + '' + + 'FYI'; + + banner.querySelector('.recog-banner__nvm').addEventListener('click', function () { + banner.remove(); + }); + + var h2 = document.querySelector('h2'); + if (h2 && h2.parentNode) { + h2.parentNode.insertBefore(banner, h2.nextSibling); + } else { + document.body.insertBefore(banner, document.body.firstChild); + } + } + + function handleSaveResponse(data) { + showBanner(data && data.recognition); + } + + function _esc(str) { + var d = document.createElement('div'); + d.textContent = str || ''; + return d.innerHTML; + } + + return { showBanner: showBanner, handleSaveResponse: handleSaveResponse }; +})(); diff --git a/src/static/tests/RecognitionSpec.js b/src/static/tests/RecognitionSpec.js new file mode 100644 index 0000000..740006f --- /dev/null +++ b/src/static/tests/RecognitionSpec.js @@ -0,0 +1,143 @@ +// ── RecognitionSpec.js ──────────────────────────────────────────────────────── +// +// Unit specs for recognition.js — banner injection from sky/save response. +// +// DOM contract assumed by showBanner(): +// Any

in the document — banner is inserted immediately after it. +// If absent, banner is prepended to . +// +// API under test: +// Recognition.showBanner(recognition) +// recognition = { slug, title, description, earned_at } → inject .recog-banner +// recognition = null → no-op +// +// Recognition.handleSaveResponse(data) +// data = { saved: true, recognition: {...} } → delegates to showBanner +// data = { saved: true, recognition: null } → no-op +// +// ───────────────────────────────────────────────────────────────────────────── + +const SAMPLE_RECOGNITION = { + slug: 'stargazer', + title: 'Stargazer', + description: 'You saved your first personal sky chart.', + earned_at: '2026-04-22T02:00:00+00:00', +}; + +describe('Recognition.showBanner', () => { + + let fixture; + + beforeEach(() => { + fixture = document.createElement('div'); + fixture.id = 'recognition-fixture'; + fixture.innerHTML = '

Dash

'; + document.body.appendChild(fixture); + }); + + afterEach(() => { + document.querySelectorAll('.recog-banner').forEach(b => b.remove()); + fixture.remove(); + }); + + // ── T1 ── null → no banner ──────────────────────────────────────────────── + + it('T1: showBanner(null) does not inject a banner', () => { + Recognition.showBanner(null); + expect(document.querySelector('.recog-banner')).toBeNull(); + }); + + // ── T2 ── recognition present → banner in DOM ───────────────────────────── + + it('T2: showBanner(recognition) injects .recog-banner into the document', () => { + Recognition.showBanner(SAMPLE_RECOGNITION); + expect(document.querySelector('.recog-banner')).not.toBeNull(); + }); + + // ── T3 ── title ─────────────────────────────────────────────────────────── + + it('T3: banner .recog-banner__title contains recognition.title', () => { + Recognition.showBanner(SAMPLE_RECOGNITION); + const el = document.querySelector('.recog-banner__title'); + expect(el).not.toBeNull(); + expect(el.textContent).toContain('Stargazer'); + }); + + // ── T4 ── description ───────────────────────────────────────────────────── + + it('T4: banner .recog-banner__description contains recognition.description', () => { + Recognition.showBanner(SAMPLE_RECOGNITION); + const el = document.querySelector('.recog-banner__description'); + expect(el).not.toBeNull(); + expect(el.textContent).toContain('You saved your first personal sky chart.'); + }); + + // ── T5 ── timestamp ─────────────────────────────────────────────────────── + + it('T5: banner has a .recog-banner__timestamp element', () => { + Recognition.showBanner(SAMPLE_RECOGNITION); + expect(document.querySelector('.recog-banner__timestamp')).not.toBeNull(); + }); + + // ── T6 ── image area ────────────────────────────────────────────────────── + + it('T6: banner has a .recog-banner__image element', () => { + Recognition.showBanner(SAMPLE_RECOGNITION); + expect(document.querySelector('.recog-banner__image')).not.toBeNull(); + }); + + // ── T7 ── NVM button ────────────────────────────────────────────────────── + + it('T7: banner has a .btn.btn-danger NVM button', () => { + Recognition.showBanner(SAMPLE_RECOGNITION); + expect(document.querySelector('.recog-banner .btn.btn-danger')).not.toBeNull(); + }); + + // ── T8 ── FYI link ──────────────────────────────────────────────────────── + + it('T8: banner has a .btn.btn-caution FYI link pointing to /billboard/recognition/', () => { + Recognition.showBanner(SAMPLE_RECOGNITION); + const fyi = document.querySelector('.recog-banner .btn.btn-caution'); + expect(fyi).not.toBeNull(); + expect(fyi.getAttribute('href')).toBe('/billboard/recognition/'); + }); + + // ── T9 ── NVM dismissal ─────────────────────────────────────────────────── + + it('T9: clicking the NVM button removes the banner from the DOM', () => { + Recognition.showBanner(SAMPLE_RECOGNITION); + document.querySelector('.recog-banner .btn.btn-danger').click(); + expect(document.querySelector('.recog-banner')).toBeNull(); + }); + + // ── T10 ── placement after h2 ───────────────────────────────────────────── + + it('T10: banner is inserted immediately after the first h2 in the document', () => { + Recognition.showBanner(SAMPLE_RECOGNITION); + const h2 = fixture.querySelector('h2'); + expect(h2.nextElementSibling.classList.contains('recog-banner')).toBeTrue(); + }); + +}); + +describe('Recognition.handleSaveResponse', () => { + + afterEach(() => { + document.querySelectorAll('.recog-banner').forEach(b => b.remove()); + }); + + // ── T11 ── delegates when recognition present ───────────────────────────── + + it('T11: handleSaveResponse shows banner when data.recognition is present', () => { + Recognition.handleSaveResponse({ saved: true, recognition: SAMPLE_RECOGNITION }); + expect(document.querySelector('.recog-banner')).not.toBeNull(); + }); + + // ── T12 ── no banner when recognition null ──────────────────────────────── + + it('T12: handleSaveResponse does not show banner when data.recognition is null', () => { + Recognition.handleSaveResponse({ saved: true, recognition: null }); + expect(document.querySelector('.recog-banner')).toBeNull(); + }); + +}); diff --git a/src/static/tests/SpecRunner.html b/src/static/tests/SpecRunner.html index 4783f1a..6e68d9f 100644 --- a/src/static/tests/SpecRunner.html +++ b/src/static/tests/SpecRunner.html @@ -23,8 +23,10 @@ + + diff --git a/src/static_src/tests/RecognitionSpec.js b/src/static_src/tests/RecognitionSpec.js new file mode 100644 index 0000000..740006f --- /dev/null +++ b/src/static_src/tests/RecognitionSpec.js @@ -0,0 +1,143 @@ +// ── RecognitionSpec.js ──────────────────────────────────────────────────────── +// +// Unit specs for recognition.js — banner injection from sky/save response. +// +// DOM contract assumed by showBanner(): +// Any

in the document — banner is inserted immediately after it. +// If absent, banner is prepended to . +// +// API under test: +// Recognition.showBanner(recognition) +// recognition = { slug, title, description, earned_at } → inject .recog-banner +// recognition = null → no-op +// +// Recognition.handleSaveResponse(data) +// data = { saved: true, recognition: {...} } → delegates to showBanner +// data = { saved: true, recognition: null } → no-op +// +// ───────────────────────────────────────────────────────────────────────────── + +const SAMPLE_RECOGNITION = { + slug: 'stargazer', + title: 'Stargazer', + description: 'You saved your first personal sky chart.', + earned_at: '2026-04-22T02:00:00+00:00', +}; + +describe('Recognition.showBanner', () => { + + let fixture; + + beforeEach(() => { + fixture = document.createElement('div'); + fixture.id = 'recognition-fixture'; + fixture.innerHTML = '

Dash

'; + document.body.appendChild(fixture); + }); + + afterEach(() => { + document.querySelectorAll('.recog-banner').forEach(b => b.remove()); + fixture.remove(); + }); + + // ── T1 ── null → no banner ──────────────────────────────────────────────── + + it('T1: showBanner(null) does not inject a banner', () => { + Recognition.showBanner(null); + expect(document.querySelector('.recog-banner')).toBeNull(); + }); + + // ── T2 ── recognition present → banner in DOM ───────────────────────────── + + it('T2: showBanner(recognition) injects .recog-banner into the document', () => { + Recognition.showBanner(SAMPLE_RECOGNITION); + expect(document.querySelector('.recog-banner')).not.toBeNull(); + }); + + // ── T3 ── title ─────────────────────────────────────────────────────────── + + it('T3: banner .recog-banner__title contains recognition.title', () => { + Recognition.showBanner(SAMPLE_RECOGNITION); + const el = document.querySelector('.recog-banner__title'); + expect(el).not.toBeNull(); + expect(el.textContent).toContain('Stargazer'); + }); + + // ── T4 ── description ───────────────────────────────────────────────────── + + it('T4: banner .recog-banner__description contains recognition.description', () => { + Recognition.showBanner(SAMPLE_RECOGNITION); + const el = document.querySelector('.recog-banner__description'); + expect(el).not.toBeNull(); + expect(el.textContent).toContain('You saved your first personal sky chart.'); + }); + + // ── T5 ── timestamp ─────────────────────────────────────────────────────── + + it('T5: banner has a .recog-banner__timestamp element', () => { + Recognition.showBanner(SAMPLE_RECOGNITION); + expect(document.querySelector('.recog-banner__timestamp')).not.toBeNull(); + }); + + // ── T6 ── image area ────────────────────────────────────────────────────── + + it('T6: banner has a .recog-banner__image element', () => { + Recognition.showBanner(SAMPLE_RECOGNITION); + expect(document.querySelector('.recog-banner__image')).not.toBeNull(); + }); + + // ── T7 ── NVM button ────────────────────────────────────────────────────── + + it('T7: banner has a .btn.btn-danger NVM button', () => { + Recognition.showBanner(SAMPLE_RECOGNITION); + expect(document.querySelector('.recog-banner .btn.btn-danger')).not.toBeNull(); + }); + + // ── T8 ── FYI link ──────────────────────────────────────────────────────── + + it('T8: banner has a .btn.btn-caution FYI link pointing to /billboard/recognition/', () => { + Recognition.showBanner(SAMPLE_RECOGNITION); + const fyi = document.querySelector('.recog-banner .btn.btn-caution'); + expect(fyi).not.toBeNull(); + expect(fyi.getAttribute('href')).toBe('/billboard/recognition/'); + }); + + // ── T9 ── NVM dismissal ─────────────────────────────────────────────────── + + it('T9: clicking the NVM button removes the banner from the DOM', () => { + Recognition.showBanner(SAMPLE_RECOGNITION); + document.querySelector('.recog-banner .btn.btn-danger').click(); + expect(document.querySelector('.recog-banner')).toBeNull(); + }); + + // ── T10 ── placement after h2 ───────────────────────────────────────────── + + it('T10: banner is inserted immediately after the first h2 in the document', () => { + Recognition.showBanner(SAMPLE_RECOGNITION); + const h2 = fixture.querySelector('h2'); + expect(h2.nextElementSibling.classList.contains('recog-banner')).toBeTrue(); + }); + +}); + +describe('Recognition.handleSaveResponse', () => { + + afterEach(() => { + document.querySelectorAll('.recog-banner').forEach(b => b.remove()); + }); + + // ── T11 ── delegates when recognition present ───────────────────────────── + + it('T11: handleSaveResponse shows banner when data.recognition is present', () => { + Recognition.handleSaveResponse({ saved: true, recognition: SAMPLE_RECOGNITION }); + expect(document.querySelector('.recog-banner')).not.toBeNull(); + }); + + // ── T12 ── no banner when recognition null ──────────────────────────────── + + it('T12: handleSaveResponse does not show banner when data.recognition is null', () => { + Recognition.handleSaveResponse({ saved: true, recognition: null }); + expect(document.querySelector('.recog-banner')).toBeNull(); + }); + +}); diff --git a/src/static_src/tests/SpecRunner.html b/src/static_src/tests/SpecRunner.html index 4783f1a..6e68d9f 100644 --- a/src/static_src/tests/SpecRunner.html +++ b/src/static_src/tests/SpecRunner.html @@ -23,8 +23,10 @@ + + diff --git a/src/templates/apps/dashboard/_partials/_applet-my-sky.html b/src/templates/apps/dashboard/_partials/_applet-my-sky.html index b5782c5..3ea55da 100644 --- a/src/templates/apps/dashboard/_partials/_applet-my-sky.html +++ b/src/templates/apps/dashboard/_partials/_applet-my-sky.html @@ -1,4 +1,5 @@ {% load static %} +
{ + .then(data => { formWrap.style.display = 'none'; svgEl.style.display = ''; NatusWheel.preload().then(() => NatusWheel.draw(svgEl, _lastChartData)); + Recognition.handleSaveResponse(data); }) .catch(err => { setStatus(`Save failed: ${err.message}`, 'error'); diff --git a/src/templates/apps/dashboard/sky.html b/src/templates/apps/dashboard/sky.html index d877c0c..79ee79d 100644 --- a/src/templates/apps/dashboard/sky.html +++ b/src/templates/apps/dashboard/sky.html @@ -93,6 +93,7 @@ +