diff --git a/frontend/agenda-calendar.js b/frontend/agenda-calendar.js new file mode 100644 index 0000000..84516e9 --- /dev/null +++ b/frontend/agenda-calendar.js @@ -0,0 +1,180 @@ +(function (root) { + 'use strict'; + + function escapeText(value) { + return String(value || '') + .replace(/\\/g, '\\\\') + .replace(/\r?\n/g, '\\n') + .replace(/,/g, '\\,') + .replace(/;/g, '\\;'); + } + + function calendarDay(value) { + const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})/); + return match ? match.slice(1).join('') : ''; + } + + function nextDay(day) { + const date = new Date(Date.UTC( + Number(day.slice(0, 4)), Number(day.slice(4, 6)) - 1, Number(day.slice(6, 8)) + 1 + )); + return date.toISOString().slice(0, 10).replace(/-/g, ''); + } + + function uid(item) { + const repository = String(item.repository || '').replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').toLowerCase(); + return `issue-${Number(item.number)}@${repository}`; + } + + function foldLine(line) { + const encoder = new TextEncoder(); + const chunks = []; + let chunk = ''; + let bytes = 0; + let limit = 75; + for (const character of String(line)) { + const width = encoder.encode(character).length; + if (chunk && bytes + width > limit) { + chunks.push(chunk); + chunk = character; + bytes = width; + limit = 74; + } else { + chunk += character; + bytes += width; + } + } + chunks.push(chunk); + return chunks.join('\r\n '); + } + + function serializeAgendaCalendar(items, { generatedOn } = {}) { + const stampDay = String(generatedOn || new Date().toISOString().slice(0, 10).replace(/-/g, '')); + const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Stackchain//Agenda Snapshot//EN', + 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'X-WR-CALNAME:Stackchain Agenda']; + (items || []).forEach(item => { + const day = calendarDay(item.due_date); + if (!day || !Number.isInteger(Number(item.number)) || !item.repository) return; + lines.push( + 'BEGIN:VEVENT', + `UID:${uid(item)}`, + `DTSTAMP:${stampDay}T000000Z`, + `DTSTART;VALUE=DATE:${day}`, + `DTEND;VALUE=DATE:${nextDay(day)}`, + `SUMMARY:${escapeText(item.title)}`, + `DESCRIPTION:${escapeText(`${item.repository}#${item.number} · Stackchain Agenda snapshot`)}`, + `URL:${String(item.url || '')}`, + 'TRANSP:TRANSPARENT', + 'END:VEVENT', + ); + }); + lines.push('END:VCALENDAR'); + return lines.map(foldLine).join('\r\n') + '\r\n'; + } + + async function deliverCalendarSnapshot({ + text, + filename, + navigator, + document, + urlApi, + FileCtor, + }) { + const file = new FileCtor([text], filename, { type: 'text/calendar;charset=utf-8' }); + const sharePayload = { files: [file], title: 'Stackchain Agenda', text: 'Agenda calendar snapshot' }; + if (typeof navigator?.share === 'function' && typeof navigator?.canShare === 'function' && + navigator.canShare(sharePayload)) { + await navigator.share(sharePayload); + return 'shared'; + } + const href = urlApi.createObjectURL(file); + try { + const anchor = document.createElement('a'); + anchor.href = href; + anchor.download = filename; + anchor.click(); + } finally { + urlApi.revokeObjectURL(href); + } + return 'downloaded'; + } + + function mountAgendaCalendarExport({ + qs, + getItems, + escapeHtml, + onDone, + windowObject = root, + navigatorObject = root.navigator, + documentObject = root.document, + urlApi = root.URL, + FileCtor = root.File, + }) { + const sheet = qs('#agenda-export-sheet'); + let items = []; + let scrollY = 0; + let trigger = null; + const selectedItems = () => Array.from(qs('#agenda-export-items').querySelectorAll('input[type="checkbox"]')) + .filter(checkbox => checkbox.checked) + .map(checkbox => items[Number(checkbox.value)]) + .filter(Boolean); + const updateSelection = () => { + const selected = selectedItems(); + qs('#share-agenda-export').disabled = selected.length === 0; + qs('#agenda-export-status').textContent = selected.length + ' of ' + items.length + + (items.length === 1 ? ' deadline selected.' : ' deadlines selected.'); + }; + const close = () => { + sheet.close(); + windowObject.scrollTo({ top:scrollY, behavior:'instant' }); + trigger?.focus(); + }; + qs('#open-agenda-export').addEventListener('click', event => { + items = getItems(); + scrollY = windowObject.scrollY; + trigger = event.currentTarget; + qs('#agenda-export-items').innerHTML = items.map((item, index) => + '' + ).join(''); + qs('#agenda-export-items').querySelectorAll('input[type="checkbox"]').forEach(checkbox => + checkbox.addEventListener('change', updateSelection) + ); + updateSelection(); + sheet.showModal(); + qs('#cancel-agenda-export').focus(); + }); + qs('#cancel-agenda-export').addEventListener('click', close); + sheet.addEventListener('cancel', event => { + event.preventDefault(); + close(); + }); + qs('#share-agenda-export').addEventListener('click', async () => { + const selected = selectedItems(); + if (!selected.length) return; + const button = qs('#share-agenda-export'); + button.disabled = true; + qs('#agenda-export-status').textContent = 'Preparing calendar snapshot…'; + const day = new Date().toISOString().slice(0, 10); + try { + const text = serializeAgendaCalendar(selected, { generatedOn:day.replace(/-/g, '') }); + const result = await deliverCalendarSnapshot({ + text, filename:'stackchain-agenda-' + day + '.ics', + navigator:navigatorObject, document:documentObject, urlApi, FileCtor, + }); + onDone(result); + close(); + } catch (error) { + qs('#agenda-export-status').textContent = error?.name === 'AbortError' ? + 'Share cancelled. Nothing was exported.' : 'Calendar export failed. Retry without leaving Agenda.'; + button.disabled = false; + } + }); + } + + const api = { calendarDay, deliverCalendarSnapshot, escapeText, foldLine, mountAgendaCalendarExport, serializeAgendaCalendar }; + if (typeof module !== 'undefined' && module.exports) module.exports = api; + else root.StackchainAgendaCalendar = api; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 85c08c0..b5bc5f6 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -419,6 +419,21 @@ textarea { resize: vertical; min-height: 120px; } .agenda-replan-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:10px; } .agenda-replan-actions label { grid-column:1/-1; } .agenda-replan-actions input { box-sizing:border-box; min-height:44px; width:100%; max-width:100%; } +.agenda-export { margin:10px 0; padding:12px; border:1px solid #2f6f9f; border-radius:12px; background:#0d2136; display:flex; align-items:center; justify-content:space-between; gap:12px; } +.agenda-export p { margin:4px 0 0; } +.agenda-export button { min-height:44px; flex:0 0 auto; } +.agenda-export-sheet { box-sizing:border-box; width:min(560px,100%); max-width:none; max-height:none; height:100dvh; margin:0 0 0 auto; padding:0; border:0; color:var(--text); background:#0b1526; } +.agenda-export-sheet::backdrop { background:rgba(5,12,21,.78); backdrop-filter:blur(4px); } +.agenda-export-panel { box-sizing:border-box; min-height:100%; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow:auto; overflow-x:hidden; } +.agenda-export-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; } +.agenda-export-panel h2, .agenda-export-panel header p { margin:0; } +#agenda-export-items { min-width:0; display:grid; gap:8px; margin:0; padding:0; border:0; } +.agenda-export-item { min-width:0; min-height:44px; display:flex; align-items:center; gap:10px; padding:10px; border:1px solid #31577f; border-radius:10px; background:#10233a; } +.agenda-export-item input { width:22px; height:22px; flex:0 0 auto; } +.agenda-export-item span, .agenda-export-item strong, .agenda-export-item small { min-width:0; display:block; overflow-wrap:anywhere; } +#cancel-agenda-export, #share-agenda-export { min-height:44px; } +#share-agenda-export { position:sticky; bottom:0; width:100%; margin-top:auto; } +@media(max-width:430px) { .agenda-export { align-items:stretch; flex-direction:column; } .agenda-export button { width:100%; } .agenda-export-panel { padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); } } .protect-today { margin:10px 0; padding:12px; border:1px solid #2f6f9f; border-radius:12px; background:#0d2136; display:flex; align-items:center; justify-content:space-between; gap:12px; } .protect-today p { margin:4px 0; } .protect-today button { min-height:44px; flex:0 0 auto; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 09c5852..6da5ff8 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -597,6 +597,16 @@ window.location.hash = '#/my-work/agenda'; qs('#my-work-action-status').textContent = 'Overdue sweep cancelled. No remaining deadline was changed.'; }); + + StackchainAgendaCalendar.mountAgendaCalendarExport({ + qs, + getItems:() => agendaMyWork(activeMyWork), + escapeHtml, + onDone:result => { + qs('#my-work-action-status').textContent = result === 'shared' ? + 'Agenda calendar snapshot shared.' : 'Agenda calendar snapshot downloaded.'; + }, + }); let searchReplyAttachmentTarget = null; let searchReplyRestoreGeneration = 0; let restoringSearchReplyPhotos = false; @@ -3513,6 +3523,14 @@ qs('#start-agenda-replan').textContent = 'Replan overdue (' + overdue.length + ')'; qs('#agenda-replan-controls').hidden = true; } + const agendaExport = qs('#agenda-export'); + const agendaExportButton = qs('#open-agenda-export'); + agendaExport.hidden = selectedWorkFilter !== 'agenda'; + agendaExportButton.disabled = queueItems.length === 0; + qs('#agenda-export-empty').textContent = queueItems.length ? + 'Review ' + queueItems.length + (queueItems.length === 1 ? ' deadline' : ' deadlines') + + ' before sharing a calendar snapshot.' : + 'No deadlines are available to export from this Agenda.'; const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more); const visible = findQueueItems(queueItems, queueFindQuery); const emptyWorkStart = qs('#empty-work-start'); diff --git a/frontend/index.html b/frontend/index.html index f343150..564037a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -277,6 +277,13 @@ + + +
+
+

Calendar handoff

Review Agenda export

+ +
+

This exports a snapshot, not ongoing synchronization. Only checked deadline details leave Stackchain.

+
+

Your issue titles, repositories, dates, and forge links are included in the calendar file.

+

+ +
+

Insights

Context, activity, live signals, and workspace tools.

@@ -1780,6 +1800,7 @@ + diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 0c5e2dc..e96b003 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -45,6 +45,7 @@ const SHELL = [ BASE + 'static/offline-today.js', BASE + 'static/my-work.js', BASE + 'static/agenda-replan.js', + BASE + 'static/agenda-calendar.js', BASE + 'static/protect-today.js', BASE + 'static/notification-undo.js', BASE + 'static/card-planning.js', diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 8df0a97..b5b4a12 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -34,7 +34,7 @@ FEATURE_SOURCES = { "security-center": ("static/security-center.js",), "today-timer": ( "static/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", - "static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.js", + "static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.js", "static/today-rollover.js", "static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js", "static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js", "static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js", diff --git a/tests/e2e/test_mobile_home_bootstrap_release.py b/tests/e2e/test_mobile_home_bootstrap_release.py index 872f3fe..1d50550 100644 --- a/tests/e2e/test_mobile_home_bootstrap_release.py +++ b/tests/e2e/test_mobile_home_bootstrap_release.py @@ -11,7 +11,7 @@ if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1": pytest.importorskip("playwright.sync_api") from playwright.sync_api import expect, sync_playwright -from fake_gitea import FakeGiteaServer +from fake_gitea import AVAILABLE_ISSUES, FakeGiteaServer from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server @@ -250,3 +250,72 @@ def test_release_artifact_keeps_mobile_delivery_recovery_single_flight(tmp_path: fake.shutdown() fake.server_close() fake_thread.join(timeout=5) + + +@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)]) +def test_release_artifact_reviews_and_downloads_mobile_agenda_snapshot( + tmp_path: Path, width: int, height: int +): + archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz")) + assert len(archives) == 1, "browser job must download exactly one assembled release archive" + original_due_dates = [item.get("due_date") for item in AVAILABLE_ISSUES] + AVAILABLE_ISSUES[0]["due_date"] = "2026-08-19" + AVAILABLE_ISSUES[1]["due_date"] = "2026-08-20" + fake = FakeGiteaServer(("127.0.0.1", 0)) + fake_thread = threading.Thread(target=fake.serve_forever, daemon=True) + fake_thread.start() + fake_url = f"http://127.0.0.1:{fake.server_port}" + browser_errors: list[str] = [] + failed_responses: list[str] = [] + + try: + with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright: + browser = playwright.chromium.launch(args=["--ignore-certificate-errors"]) + page = browser.new_page(viewport={"width": width, "height": height}) + page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error))) + page.on("console", lambda message: browser_errors.append(message.text) if message.type == "error" else None) + page.on("response", lambda response: failed_responses.append(f"{response.status} {response.url}") if response.status >= 400 else None) + page.goto(origin + "/", wait_until="networkidle") + page.locator('input[name="device_label"]').fill("Agenda export release phone") + page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) + page.locator("#submit-sign-in").click() + page.wait_for_url(origin + "/", wait_until="networkidle") + + page.locator('[data-mobile-task="queues"]').click() + page.locator('[data-mobile-queue="agenda"]').click() + expect(page.locator("#issue-sheet")).to_be_visible() + page.locator("#close-issue-sheet").click() + expect(page.locator("#issue-sheet")).to_be_hidden() + expect(page.locator("#agenda-export")).to_be_visible() + trigger = page.locator("#open-agenda-export") + expect(trigger).to_be_enabled() + bounds = trigger.bounding_box() + assert bounds and bounds["height"] >= 44 + trigger.click() + expect(page.locator("#agenda-export-sheet")).to_be_visible() + expect(page.locator("#agenda-export-items input:checked")).to_have_count(2) + page.locator("#agenda-export-items input").nth(1).uncheck() + expect(page.locator("#agenda-export-status")).to_have_text("1 of 2 deadlines selected.") + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") + + with page.expect_download() as pending: + page.locator("#share-agenda-export").click() + download = pending.value + text = Path(download.path()).read_text() + assert download.suggested_filename.startswith("stackchain-agenda-") + assert text.count("BEGIN:VEVENT") == 1 + assert "SUMMARY:Ship mobile capture" in text + assert "Polish desktop filters" not in text + expect(trigger).to_be_focused() + assert browser_errors == [] + assert failed_responses == [] + browser.close() + finally: + for item, due_date in zip(AVAILABLE_ISSUES, original_due_dates): + if due_date is None: + item.pop("due_date", None) + else: + item["due_date"] = due_date + fake.shutdown() + fake.server_close() + fake_thread.join(timeout=5) diff --git a/tests/test_agenda_calendar.py b/tests/test_agenda_calendar.py new file mode 100644 index 0000000..2a90ed7 --- /dev/null +++ b/tests/test_agenda_calendar.py @@ -0,0 +1,118 @@ +import json +import subprocess +from pathlib import Path + +import pytest + +from tests.dashboard_bundle import dashboard + + +AGENDA_CALENDAR = Path(__file__).parents[1] / "frontend" / "agenda-calendar.js" +INDEX = Path(__file__).parents[1] / "frontend" / "index.html" + + +def run_node(source: str): + completed = subprocess.run(["node", "-e", source], capture_output=True, text=True) + assert completed.returncode == 0, completed.stderr + return json.loads(completed.stdout) + + +def test_calendar_snapshot_serializes_ordered_all_day_events_with_stable_identity(): + items = [ + { + "repository": "stackchain/dashboard", + "number": 12, + "title": "Plan, ship; verify\\path\nnext", + "due_date": "2026-08-19T23:00:00-07:00", + "url": "https://forge.example/git/stackchain/dashboard/issues/12", + }, + { + "repository": "stackchain/api", + "number": 7, + "title": "API release", + "due_date": "2026-08-20", + "url": "https://forge.example/git/stackchain/api/issues/7", + }, + ] + source = f""" +const calendar = require({json.dumps(str(AGENDA_CALENDAR))}); +const text = calendar.serializeAgendaCalendar({json.dumps(items)}, {{generatedOn:'20260818'}}); +process.stdout.write(JSON.stringify({{text}})); +""" + + text = run_node(source)["text"] + assert text.startswith("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n") + assert text.endswith("END:VCALENDAR\r\n") + assert text.count("BEGIN:VEVENT") == 2 + assert text.index("stackchain/dashboard#12") < text.index("stackchain/api#7") + assert "UID:issue-12@stackchain-dashboard" in text + assert "DTSTART;VALUE=DATE:20260819" in text + assert "SUMMARY:Plan\\, ship\\; verify\\\\path\\nnext" in text + assert "URL:https://forge.example/git/stackchain/dashboard/issues/12" in text + assert "DTSTAMP:20260818T000000Z" in text + assert "\n" not in text.replace("\r\n", "") + + +def test_calendar_snapshot_folds_utf8_lines_to_rfc_octet_limit_without_splitting_characters(): + item = { + "repository": "stackchain/dashboard", + "number": 99, + "title": "Launch 🚀 " + "é" * 60, + "due_date": "2026-08-21", + "url": "https://forge.example/git/stackchain/dashboard/issues/99", + } + source = f""" +const calendar = require({json.dumps(str(AGENDA_CALENDAR))}); +const text = calendar.serializeAgendaCalendar([{json.dumps(item)}], {{generatedOn:'20260818'}}); +process.stdout.write(JSON.stringify({{text}})); +""" + + text = run_node(source)["text"] + physical_lines = text.split("\r\n") + assert all(len(line.encode("utf-8")) <= 75 for line in physical_lines) + assert any(line.startswith(" ") for line in physical_lines) + unfolded = text.replace("\r\n ", "") + assert "SUMMARY:Launch 🚀 " + "é" * 60 in unfolded + + +def test_calendar_snapshot_prefers_native_file_share_and_falls_back_to_one_download(): + source = f""" +const calendar = require({json.dumps(str(AGENDA_CALENDAR))}); +class FakeFile {{ constructor(parts, name, options) {{ this.parts=parts; this.name=name; this.type=options.type; }} }} +const shared=[]; +const nativeNavigator={{canShare:payload => payload.files[0].name.endsWith('.ics'), share:async payload => shared.push(payload)}}; +const clicks=[]; const revoked=[]; +const document={{createElement:() => ({{click(){{clicks.push({{download:this.download,href:this.href}})}}}})}}; +const urlApi={{createObjectURL:()=>'blob:agenda', revokeObjectURL:value=>revoked.push(value)}}; +(async () => {{ + const native = await calendar.deliverCalendarSnapshot({{text:'BEGIN:VCALENDAR', filename:'stackchain-agenda.ics', navigator:nativeNavigator, document, urlApi, FileCtor:FakeFile}}); + const fallback = await calendar.deliverCalendarSnapshot({{text:'BEGIN:VCALENDAR', filename:'stackchain-agenda.ics', navigator:{{}}, document, urlApi, FileCtor:FakeFile}}); + process.stdout.write(JSON.stringify({{native,fallback,shared:shared.length,clicks,revoked}})); +}})(); +""" + + assert run_node(source) == { + "native": "shared", + "fallback": "downloaded", + "shared": 1, + "clicks": [{"download": "stackchain-agenda.ics", "href": "blob:agenda"}], + "revoked": ["blob:agenda"], + } + + +@pytest.mark.anyio +async def test_agenda_export_review_requires_selection_and_exposes_snapshot_privacy_boundary(): + markup = INDEX.read_text() + source = await dashboard() + feature_source = source + AGENDA_CALENDAR.read_text() + + assert 'id="open-agenda-export"' in markup + assert 'id="agenda-export-sheet"' in markup + assert 'id="agenda-export-items"' in markup + assert 'id="share-agenda-export"' in markup + assert "This exports a snapshot" in markup + assert "agendaMyWork(activeMyWork)" in feature_source + assert "checkbox.checked" in feature_source + assert "serializeAgendaCalendar(selected" in feature_source + assert "deliverCalendarSnapshot" in feature_source + assert "agenda-export-empty" in feature_source diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 45580b6..d8206d4 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -1146,6 +1146,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/offline-today.js", "/dashboard/static/my-work.js", "/dashboard/static/agenda-replan.js", + "/dashboard/static/agenda-calendar.js", "/dashboard/static/protect-today.js", "/dashboard/static/notification-undo.js", "/dashboard/static/card-planning.js",