diff --git a/solstone/apps/reflections/copy.py b/solstone/apps/reflections/copy.py index 5c357cf8c..878cc6d6e 100644 --- a/solstone/apps/reflections/copy.py +++ b/solstone/apps/reflections/copy.py @@ -16,6 +16,8 @@ EMPTY_UNTIL_THEN = ( "Until then, this page will be empty. " "Reflections appear here every week, newest first." ) +DETAIL_EMPTY_HEADING = "No reflection for this week" +DETAIL_EMPTY_DESC = "This week does not have a reflection yet." SAMPLE_LINK_LABEL = "see a sample reflection →" diff --git a/solstone/apps/reflections/routes.py b/solstone/apps/reflections/routes.py index 571d96b02..cc0a155a6 100644 --- a/solstone/apps/reflections/routes.py +++ b/solstone/apps/reflections/routes.py @@ -237,7 +237,16 @@ def api_week(day: str) -> Any: try: _path, _raw_markdown, post = _load_reflection(canonical_day) except FileNotFoundError: - return error_response(FILE_NOT_FOUND, detail="Reflection not found") + return error_response( + FILE_NOT_FOUND, + detail="Reflection not found", + extra={ + "copy": { + "heading": reflections_copy.DETAIL_EMPTY_HEADING, + "desc": reflections_copy.DETAIL_EMPTY_DESC, + } + }, + ) return jsonify( { diff --git a/solstone/apps/reflections/tests/test_spa_routes.py b/solstone/apps/reflections/tests/test_spa_routes.py index 18b6412b5..3abd64871 100644 --- a/solstone/apps/reflections/tests/test_spa_routes.py +++ b/solstone/apps/reflections/tests/test_spa_routes.py @@ -5,6 +5,7 @@ from __future__ import annotations import ast import json +import re from datetime import datetime, timedelta from pathlib import Path from zoneinfo import ZoneInfo @@ -69,6 +70,21 @@ def _render_template_call_functions() -> list[str]: return functions +def _function_source(source: str, name: str) -> str: + start = source.index(f"function {name}(") + open_brace = source.index("{", start) + depth = 0 + for index in range(open_brace, len(source)): + char = source[index] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return source[start : index + 1] + raise AssertionError(f"function {name} is not closed") + + def test_reflections_page_routes_serve_spa_shell(reflections_env): for path in ( "/app/reflections/", @@ -140,6 +156,40 @@ def test_reflections_empty_body_matches_approved_replacement(): ) +def test_reflections_workspace_missing_detail_uses_copy_gated_empty_surface(): + source = WORKSPACE_PATH.read_text(encoding="utf-8") + render_empty = _function_source(source, "renderDetailEmpty") + load = _function_source(source, "load") + + assert "window.SurfaceState.empty({" in render_empty + assert "window.ConveyIcons.svg('calendar-days')" in render_empty + assert "heading: copy.heading" in render_empty + assert "desc: copy.desc" in render_empty + assert "err?.status === 404 && err?.payload?.copy" in load + assert "renderDetailEmpty(err.payload.copy);" in load + assert "renderError(err, load);" in load + assert load.index("renderDetailEmpty(err.payload.copy);") < load.index( + "renderError(err, load);" + ) + + +def test_reflections_workspace_non_copy_errors_fall_through_to_retry_error_surface(): + source = WORKSPACE_PATH.read_text(encoding="utf-8") + render_error = _function_source(source, "renderError") + load = _function_source(source, "load") + + assert "window.SurfaceState.error({" in render_error + assert "retry: true" in render_error + assert re.search( + r"if \(err\?\.status === 404 && err\?\.payload\?\.copy\) \{" + r"[\s\S]*?renderDetailEmpty\(err\.payload\.copy\);" + r"[\s\S]*?return;" + r"[\s\S]*?\}" + r"\s*renderError\(err, load\);", + load, + ) + + def test_reflections_index_payload_shape(reflections_env): _seed_reflection(reflections_env.journal, "20260308") _seed_reflection(reflections_env.journal, "20260405") diff --git a/solstone/apps/reflections/workspace.html b/solstone/apps/reflections/workspace.html index 344b6bcca..a5cfee7e5 100644 --- a/solstone/apps/reflections/workspace.html +++ b/solstone/apps/reflections/workspace.html @@ -408,6 +408,16 @@ renderReflectionGrid(copy); } + function renderDetailEmpty(copy) { + content.innerHTML = window.SurfaceState + ? window.SurfaceState.empty({ + icon: window.ConveyIcons.svg('calendar-days'), + heading: copy.heading, + desc: copy.desc + }) + : `

${escapeHtml(copy.heading)}

${escapeHtml(copy.desc)}

`; + } + function renderDetail(payload, requestedDay) { if (payload.day && requestedDay && payload.day !== requestedDay) { window.history.replaceState( @@ -465,6 +475,10 @@ renderIndex(payload); } } catch (err) { + if (err?.status === 404 && err?.payload?.copy) { + renderDetailEmpty(err.payload.copy); + return; + } renderError(err, load); } } diff --git a/tests/test_app_reflections.py b/tests/test_app_reflections.py index d7be4c2e7..4d96aabac 100644 --- a/tests/test_app_reflections.py +++ b/tests/test_app_reflections.py @@ -222,11 +222,27 @@ def test_reflections_missing_week_returns_api_404(journal_copy): page_response = client.get("/app/reflections/20260315") api_response = client.get("/app/reflections/api/20260315") + body = api_response.get_json() assert page_response.status_code == 200 assert b'data-solstone-shell="spa"' in page_response.data assert api_response.status_code == 404 - assert api_response.get_json()["reason_code"] == "file_not_found" + assert body["reason_code"] == "file_not_found" + assert body["copy"] == { + "heading": reflections_copy.DETAIL_EMPTY_HEADING, + "desc": reflections_copy.DETAIL_EMPTY_DESC, + } + + +def test_reflections_unparseable_week_error_has_no_empty_copy(journal_copy): + client = _make_client(journal_copy) + + response = client.get("/app/reflections/api/notaday") + body = response.get_json() + + assert response.status_code == 404 + assert body["reason_code"] == "file_not_found" + assert "copy" not in body def test_reflections_raw_returns_markdown(journal_copy):