From b494d29790e5ed23450e783fd82c5fad114c8eeb Mon Sep 17 00:00:00 2001 From: Daneel Date: Fri, 20 Feb 2026 17:58:56 +0100 Subject: [PATCH] feat: v4.2.0 - series blocks (add to all days, delete one/all in series); 37 tests --- README.md | 7 +++-- app/models/event.py | 1 + app/static/css/app.css | 58 +++++++++++++++++++++++++++++++++++++++ app/static/index.html | 24 ++++++++++++---- app/static/js/app.js | 62 ++++++++++++++++++++++++++++++++++++++---- tests/test_api.py | 18 ++++++++++++ tests/test_core.py | 7 +++++ 7 files changed, 165 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9d27c3d..4d9ac16 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,14 @@ Webový nástroj pro tvorbu časových scénářů zážitkových kurzů a výje - **Grafický editor** — bloky na časové ose, přetahování myší, změna délky tažením pravého okraje, snap na 15 minut - **Vícedenní scénář** — nastavíš rozsah Od/Do, každý den = jeden řádek +- **Série bloků** — checkbox „Přidat do každého dne kurzu" vytvoří identický blok pro všechny dny najednou; při smazání lze smazat jen jeden blok nebo celou sérii - **JSON import/export** — uložíš scénář, kdykoli ho znovu načteš - **Vzorový JSON** — `GET /api/sample` - **PDF výstup** — A4 na šířku, vždy 1 stránka, barevné bloky dle typů, legenda - Garant viditelný přímo v bloku - Bloky s poznámkou mají horní index (¹ ² ³...) - Stránka 2 (pokud jsou poznámky): výpis všech poznámek ke scénáři +- **České dny** — v editoru i PDF formát „Pondělí (20.2.)", LiberationSans font pro správnou diakritiku - **Dokumentace na webu** — záložka "Dokumentace" přímo v aplikaci - **Swagger UI** — `GET /docs` @@ -60,7 +62,7 @@ open http://localhost:8080 python3 -m pytest tests/ -v ``` -35 testů pokrývá API endpointy, PDF generátor a validaci dat. +37 testů pokrývá API endpointy, PDF generátor, validaci dat, overnight bloky a series_id. --- @@ -118,7 +120,8 @@ Kubernetes manifest: `sukany-org/rke2-deployments` → `scenar/scenar.yaml` "title": "Název bloku", "type_id": "main", "responsible": "Garant (volitelné)", - "notes": "Poznámka → horní index v PDF (volitelné)" + "notes": "Poznámka → horní index v PDF (volitelné)", + "series_id": "ID sdílené série (volitelné, generováno automaticky)" } ] } diff --git a/app/models/event.py b/app/models/event.py index 9a7da6a..83ad8be 100644 --- a/app/models/event.py +++ b/app/models/event.py @@ -16,6 +16,7 @@ class Block(BaseModel): type_id: str responsible: Optional[str] = None notes: Optional[str] = None + series_id: Optional[str] = None # shared across blocks added via "add to all days" class ProgramType(BaseModel): diff --git a/app/static/css/app.css b/app/static/css/app.css index 6c52535..ff68946 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -946,3 +946,61 @@ body { margin-bottom: 4px; color: var(--header-bg); } + +/* Modal footer with left/right split */ +.modal-footer-left { + display: flex; + gap: 6px; + align-items: center; +} + +/* Outline danger button (for "Smazat sérii") */ +.btn-danger-outline { + background: transparent; + color: var(--danger); + border: 1px solid var(--danger); +} + +.btn-danger-outline:hover { + background: var(--danger); + color: white; +} + +/* Series checkbox row */ +.series-row { + margin-top: 4px; + padding: 10px 12px; + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: var(--radius-sm); +} + +.series-label { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 500; + color: var(--text); + cursor: pointer; + margin-bottom: 0 !important; +} + +.series-label input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent); + cursor: pointer; + flex-shrink: 0; +} + +.series-hint { + margin-top: 4px; + font-size: 11px; + color: var(--text-light); + padding-left: 24px; +} + +.hidden { + display: none !important; +} diff --git a/app/static/index.html b/app/static/index.html index 3cccd63..46846ad 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -13,7 +13,7 @@

Scenár Creator

- v4.0 + v4.2
diff --git a/app/static/js/app.js b/app/static/js/app.js index f7d3361..1a7624e 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -178,7 +178,18 @@ const App = { this._populateDaySelect(block.date); this._updateDuration(); - document.getElementById('modalDeleteBtn').style.display = 'inline-block'; + // Show delete buttons; series delete only if block belongs to a series + document.getElementById('modalDeleteBtn').classList.remove('hidden'); + document.getElementById('seriesRow').classList.add('hidden'); + const seriesBtn = document.getElementById('modalDeleteSeriesBtn'); + if (block.series_id) { + const seriesCount = this.state.blocks.filter(b => b.series_id === block.series_id).length; + seriesBtn.textContent = `Smazat sérii (${seriesCount} bloků)`; + seriesBtn.classList.remove('hidden'); + } else { + seriesBtn.classList.add('hidden'); + } + document.getElementById('blockModal').classList.remove('hidden'); }, @@ -196,7 +207,12 @@ const App = { this._populateDaySelect(date); this._updateDuration(); - document.getElementById('modalDeleteBtn').style.display = 'none'; + // Hide delete buttons, show series row + document.getElementById('modalDeleteBtn').classList.add('hidden'); + document.getElementById('modalDeleteSeriesBtn').classList.add('hidden'); + document.getElementById('seriesRow').classList.remove('hidden'); + document.getElementById('modalAddToAllDays').checked = false; + document.getElementById('blockModal').classList.remove('hidden'); }, @@ -281,14 +297,32 @@ const App = { if (!start || !end) { this.toast('Zadejte čas začátku a konce', 'error'); return; } if (blockId) { - // Edit existing + // Edit existing block (no series expansion on edit — user edits only this one) const idx = this.state.blocks.findIndex(b => b.id === blockId); if (idx !== -1) { - Object.assign(this.state.blocks[idx], { date, title, type_id, start, end, responsible, notes }); + const existing = this.state.blocks[idx]; + Object.assign(this.state.blocks[idx], { + date, title, type_id, start, end, responsible, notes, + series_id: existing.series_id || null + }); } } else { // New block - this.state.blocks.push({ id: this.uid(), date, title, type_id, start, end, responsible, notes }); + const addToAll = document.getElementById('modalAddToAllDays').checked; + if (addToAll) { + // Add a copy to every day in the event range, all sharing a series_id + const series_id = this.uid(); + const dates = this.getDates(); + for (const d of dates) { + this.state.blocks.push({ id: this.uid(), date: d, title, type_id, start, end, responsible, notes, series_id }); + } + document.getElementById('blockModal').classList.add('hidden'); + this.renderCanvas(); + this.toast(`Blok přidán do ${dates.length} dnů`, 'success'); + return; + } else { + this.state.blocks.push({ id: this.uid(), date, title, type_id, start, end, responsible, notes, series_id: null }); + } } document.getElementById('blockModal').classList.add('hidden'); @@ -305,6 +339,23 @@ const App = { this.toast('Blok smazán', 'success'); }, + _deleteBlockSeries() { + const blockId = document.getElementById('modalBlockId').value; + if (!blockId) return; + const block = this.state.blocks.find(b => b.id === blockId); + if (!block || !block.series_id) { + // Fallback: delete just this one + this._deleteBlock(); + return; + } + const seriesId = block.series_id; + const count = this.state.blocks.filter(b => b.series_id === seriesId).length; + this.state.blocks = this.state.blocks.filter(b => b.series_id !== seriesId); + document.getElementById('blockModal').classList.add('hidden'); + this.renderCanvas(); + this.toast(`Série smazána (${count} bloků)`, 'success'); + }, + // ─── Toast ──────────────────────────────────────────────────────── toast(message, type = 'success') { @@ -376,6 +427,7 @@ const App = { }); document.getElementById('modalSaveBtn').addEventListener('click', () => this._saveModal()); document.getElementById('modalDeleteBtn').addEventListener('click', () => this._deleteBlock()); + document.getElementById('modalDeleteSeriesBtn').addEventListener('click', () => this._deleteBlockSeries()); // Duration ↔ end sync (hours + minutes fields) const durUpdater = () => { diff --git a/tests/test_api.py b/tests/test_api.py index be471d1..2ed956b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -148,6 +148,24 @@ def test_swagger_docs(client): assert r.status_code == 200 +def test_series_id_accepted(client): + """Blocks with series_id should be accepted by the validate endpoint.""" + doc = { + "version": "1.0", + "event": {"title": "Series Test", "date_from": "2026-03-01", "date_to": "2026-03-02"}, + "program_types": [{"id": "ws", "name": "Workshop", "color": "#FF0000"}], + "blocks": [ + {"id": "b1", "date": "2026-03-01", "start": "09:00", "end": "10:00", + "title": "Morning", "type_id": "ws", "series_id": "s_001"}, + {"id": "b2", "date": "2026-03-02", "start": "09:00", "end": "10:00", + "title": "Morning", "type_id": "ws", "series_id": "s_001"}, + ] + } + r = client.post("/api/validate", json=doc) + assert r.status_code == 200 + assert r.json()["valid"] is True + + def test_backward_compat_date_field(client): """Old JSON with 'date' (not date_from/date_to) should still validate.""" doc = { diff --git a/tests/test_core.py b/tests/test_core.py index d05df87..fe0fbb5 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -22,6 +22,13 @@ def test_block_optional_fields(): b = Block(date="2026-03-01", start="09:00", end="10:00", title="Test", type_id="ws") assert b.responsible is None assert b.notes is None + assert b.series_id is None + + +def test_block_series_id(): + b = Block(date="2026-03-01", start="09:00", end="10:00", title="Test", type_id="ws", + series_id="s_abc123") + assert b.series_id == "s_abc123" def test_block_with_all_fields():