feat: add in-app release notes and what's new dialog

This commit is contained in:
2026-07-28 12:59:12 +02:00
parent 8903452e96
commit 216f53c641
8 changed files with 336 additions and 18 deletions
+25
View File
@@ -18,6 +18,7 @@ Backend:
- `/` renders the active calculator template. - `/` renders the active calculator template.
- `/help` renders the authenticated Help landing page. - `/help` renders the authenticated Help landing page.
- `/help/user-manual` renders `docs/de/user_manual.md` server-side inside the RollCalc Help layout. - `/help/user-manual` renders `docs/de/user_manual.md` server-side inside the RollCalc Help layout.
- `/help/recent-changes` renders `docs/de/recent_changes.md` server-side inside the RollCalc Help layout.
- `/help/user-manual/screenshots/<path:filename>` serves German manual screenshots from `docs/de/screenshots/`. - `/help/user-manual/screenshots/<path:filename>` serves German manual screenshots from `docs/de/screenshots/`.
- `/static/<path:filename>` is intended to serve static files behind Basic Auth. - `/static/<path:filename>` is intended to serve static files behind Basic Auth.
- `/api/health` returns health/version information. - `/api/health` returns health/version information.
@@ -77,6 +78,30 @@ Rendering rules:
The Help pages use the same HTTP Basic Auth protection as the calculator. The Help pages use the same HTTP Basic Auth protection as the calculator.
### Recent Changes and What's New
Full user-facing release notes are maintained in:
```text
docs/de/recent_changes.md
```
The calculator header links directly to `/help/user-manual`. The Help landing page lists only existing documents, currently User Manual and Recent Changes.
The startup What's New dialog uses `build_info.version` and is suppressed when the version is empty or `unknown`. The last acknowledged version is stored only in browser `localStorage` under:
```text
rollcalc_last_seen_version
```
The short dialog item list is maintained separately in:
```text
static/recent_changes.json
```
This JSON is a compact summary for the dialog, not the complete release history. Keep it aligned with the most relevant current entries from `docs/de/recent_changes.md`.
## Authentication ## Authentication
RollCalc uses HTTP Basic Auth. User entries in `BETA_USERS` have this shape: RollCalc uses HTTP Basic Auth. User entries in `BETA_USERS` have this shape:
+27
View File
@@ -94,6 +94,7 @@ Implemented routes:
| `/` | `GET` | Basic Auth | Renders `templates/roll_calculator.html`. | | `/` | `GET` | Basic Auth | Renders `templates/roll_calculator.html`. |
| `/help` | `GET` | Basic Auth | Renders the Help landing page. | | `/help` | `GET` | Basic Auth | Renders the Help landing page. |
| `/help/user-manual` | `GET` | Basic Auth | Reads `docs/de/user_manual.md`, renders it as HTML, and displays it in the RollCalc Help layout. | | `/help/user-manual` | `GET` | Basic Auth | Reads `docs/de/user_manual.md`, renders it as HTML, and displays it in the RollCalc Help layout. |
| `/help/recent-changes` | `GET` | Basic Auth | Reads `docs/de/recent_changes.md`, renders it as HTML, and displays it in the RollCalc Help layout. |
| `/help/user-manual/screenshots/<path:filename>` | `GET` | Basic Auth | Serves German user-manual screenshots from `docs/de/screenshots/` only. | | `/help/user-manual/screenshots/<path:filename>` | `GET` | Basic Auth | Serves German user-manual screenshots from `docs/de/screenshots/` only. |
| `/static/<path:filename>` | `GET` | Basic Auth | Intended protected static-file serving from `static/`. | | `/static/<path:filename>` | `GET` | Basic Auth | Intended protected static-file serving from `static/`. |
| `/api/health` | `GET` | Basic Auth | Returns app health and version. | | `/api/health` | `GET` | Basic Auth | Returns app health and version. |
@@ -132,8 +133,16 @@ The user manual is available at:
/help/user-manual /help/user-manual
``` ```
Recent changes are available at:
```text
/help/recent-changes
```
`docs/de/user_manual.md` is the leading source for the German user manual. The app reads this Markdown file on request and renders it server-side with `markdown-it-py`; no generated static HTML copy is maintained. Raw HTML in the Markdown source is disabled during rendering. `docs/de/user_manual.md` is the leading source for the German user manual. The app reads this Markdown file on request and renders it server-side with `markdown-it-py`; no generated static HTML copy is maintained. Raw HTML in the Markdown source is disabled during rendering.
`docs/de/recent_changes.md` is the leading source for full German release notes. It is rendered through the same authenticated Markdown infrastructure.
The documentation directory is prepared for additional languages: The documentation directory is prepared for additional languages:
```text ```text
@@ -153,6 +162,24 @@ docs/de/screenshots/
The application rewrites those relative Markdown image paths to the authenticated manual screenshot route, which is limited to the active language's screenshot directory. The application rewrites those relative Markdown image paths to the authenticated manual screenshot route, which is limited to the active language's screenshot directory.
### What's New Dialog
The calculator page shows a compact What's New dialog once per build version. The current version comes from `build_info.version`; if that value is empty or `unknown`, the dialog is not shown.
The last acknowledged version is stored only in the browser:
```text
rollcalc_last_seen_version
```
The short dialog summary is maintained in:
```text
static/recent_changes.json
```
Keep `docs/de/recent_changes.md` as the complete release-note source and `static/recent_changes.json` as the short 3-5 item summary for the startup dialog.
## Frontend Entry Point ## Frontend Entry Point
The active UI is `templates/roll_calculator.html`. The active UI is `templates/roll_calculator.html`.
+112 -11
View File
@@ -22,6 +22,7 @@ import os
from datetime import datetime from datetime import datetime
import json import json
import re import re
import socket
import unicodedata import unicodedata
app = Flask(__name__) app = Flask(__name__)
@@ -59,6 +60,8 @@ BUILD_INFO_FILE = "build_info.json"
DOCS_DIR = os.path.join(app.root_path, "docs") DOCS_DIR = os.path.join(app.root_path, "docs")
DEFAULT_DOC_LANGUAGE = "de" DEFAULT_DOC_LANGUAGE = "de"
USER_MANUAL_FILENAME = "user_manual.md" USER_MANUAL_FILENAME = "user_manual.md"
RECENT_CHANGES_FILENAME = "recent_changes.md"
WHATS_NEW_SUMMARY_FILE = os.path.join(app.root_path, "static", "recent_changes.json")
UNKNOWN_BUILD_INFO = { UNKNOWN_BUILD_INFO = {
"version": "unknown", "version": "unknown",
"branch": "unknown", "branch": "unknown",
@@ -91,10 +94,36 @@ def load_build_info():
BUILD_INFO = load_build_info() BUILD_INFO = load_build_info()
def load_whats_new_summary():
"""Load compact release notes for the What's New dialog."""
try:
with open(WHATS_NEW_SUMMARY_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception as e:
print(f"[What's New Error] {e}")
return []
items = data.get("items") if isinstance(data, dict) else None
if not isinstance(items, list):
return []
return [
item.strip()
for item in items
if isinstance(item, str) and item.strip()
][:5]
WHATS_NEW_SUMMARY = load_whats_new_summary()
@app.context_processor @app.context_processor
def inject_build_info(): def inject_build_info():
"""Make build metadata available in all templates.""" """Make build metadata and release-note summary available in templates."""
return {"build_info": BUILD_INFO} return {
"build_info": BUILD_INFO,
"whats_new_summary": WHATS_NEW_SUMMARY
}
# ============================================================================ # ============================================================================
# AUTHENTICATION # AUTHENTICATION
@@ -132,6 +161,23 @@ def log_access(username, endpoint, method, status=200):
except Exception as e: except Exception as e:
print(f"[Logging Error] {e}") print(f"[Logging Error] {e}")
def find_free_port(start_port=5000, max_attempts=10, host="0.0.0.0"):
"""Find the first available TCP port at or above start_port."""
for port in range(start_port, start_port + max_attempts):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind((host, port))
except OSError:
continue
return port
raise RuntimeError(
f"No free port found from {start_port} to "
f"{start_port + max_attempts - 1}"
)
# ============================================================================ # ============================================================================
# ROUTES # ROUTES
# ============================================================================ # ============================================================================
@@ -151,9 +197,14 @@ def build_manual_renderer():
return renderer return renderer
def get_document_path(filename, language=DEFAULT_DOC_LANGUAGE):
"""Return the Markdown path for a language-specific help document."""
return os.path.join(DOCS_DIR, language, filename)
def get_manual_path(language=DEFAULT_DOC_LANGUAGE): def get_manual_path(language=DEFAULT_DOC_LANGUAGE):
"""Return the Markdown path for a language-specific manual.""" """Return the Markdown path for a language-specific manual."""
return os.path.join(DOCS_DIR, language, USER_MANUAL_FILENAME) return get_document_path(USER_MANUAL_FILENAME, language)
def get_manual_screenshot_dir(language=DEFAULT_DOC_LANGUAGE): def get_manual_screenshot_dir(language=DEFAULT_DOC_LANGUAGE):
@@ -199,13 +250,13 @@ def add_heading_ids_and_toc(tokens):
return toc return toc
def render_user_manual(language=DEFAULT_DOC_LANGUAGE): def render_markdown_document(filename, language=DEFAULT_DOC_LANGUAGE):
"""Read and render the Markdown user manual into HTML and a generated TOC.""" """Read and render a Markdown help document into HTML and a generated TOC."""
renderer = build_manual_renderer() renderer = build_manual_renderer()
manual_path = get_manual_path(language) document_path = get_document_path(filename, language)
with open(manual_path, "r", encoding="utf-8") as manual_file: with open(document_path, "r", encoding="utf-8") as document_file:
markdown_text = manual_file.read() markdown_text = document_file.read()
markdown_text = rewrite_manual_asset_paths(markdown_text, language) markdown_text = rewrite_manual_asset_paths(markdown_text, language)
tokens = renderer.parse(markdown_text) tokens = renderer.parse(markdown_text)
@@ -213,6 +264,16 @@ def render_user_manual(language=DEFAULT_DOC_LANGUAGE):
html = renderer.renderer.render(tokens, renderer.options, {}) html = renderer.renderer.render(tokens, renderer.options, {})
return Markup(html), toc return Markup(html), toc
def render_user_manual(language=DEFAULT_DOC_LANGUAGE):
"""Read and render the Markdown user manual."""
return render_markdown_document(USER_MANUAL_FILENAME, language)
def render_recent_changes(language=DEFAULT_DOC_LANGUAGE):
"""Read and render the Markdown recent changes document."""
return render_markdown_document(RECENT_CHANGES_FILENAME, language)
@app.route("/", methods=["GET"]) @app.route("/", methods=["GET"])
@auth.login_required @auth.login_required
def index(): def index():
@@ -236,6 +297,9 @@ def user_manual():
manual_html, toc = render_user_manual(DEFAULT_DOC_LANGUAGE) manual_html, toc = render_user_manual(DEFAULT_DOC_LANGUAGE)
return render_template( return render_template(
"user_manual.html", "user_manual.html",
document_title="User Manual",
document_subtitle="RollCalc technical user documentation",
error_title="User Manual unavailable",
manual_html=manual_html, manual_html=manual_html,
toc=toc, toc=toc,
render_error=None render_error=None
@@ -244,6 +308,9 @@ def user_manual():
app.logger.exception("Could not render user manual") app.logger.exception("Could not render user manual")
return render_template( return render_template(
"user_manual.html", "user_manual.html",
document_title="User Manual",
document_subtitle="RollCalc technical user documentation",
error_title="User Manual unavailable",
manual_html=None, manual_html=None,
toc=[], toc=[],
render_error=( render_error=(
@@ -252,6 +319,39 @@ def user_manual():
) )
), 500 ), 500
@app.route("/help/recent-changes", methods=["GET"])
@auth.login_required
def recent_changes():
"""Render recent changes inside the RollCalc Help layout."""
log_access(auth.current_user(), "/help/recent-changes", "GET")
try:
changes_html, toc = render_recent_changes(DEFAULT_DOC_LANGUAGE)
return render_template(
"user_manual.html",
document_title="Recent Changes",
document_subtitle="RollCalc release notes",
error_title="Recent Changes unavailable",
manual_html=changes_html,
toc=toc,
render_error=None
)
except Exception:
app.logger.exception("Could not render recent changes")
return render_template(
"user_manual.html",
document_title="Recent Changes",
document_subtitle="RollCalc release notes",
error_title="Recent Changes unavailable",
manual_html=None,
toc=[],
render_error=(
"Recent changes could not be loaded. "
"Please contact the RollCalc maintainer if the problem persists."
)
), 500
@app.route("/help/user-manual/screenshots/<path:filename>", methods=["GET"]) @app.route("/help/user-manual/screenshots/<path:filename>", methods=["GET"])
@auth.login_required @auth.login_required
def manual_screenshot(filename, language=DEFAULT_DOC_LANGUAGE): def manual_screenshot(filename, language=DEFAULT_DOC_LANGUAGE):
@@ -317,21 +417,22 @@ def internal_error(e):
if __name__ == "__main__": if __name__ == "__main__":
os.makedirs("templates", exist_ok=True) os.makedirs("templates", exist_ok=True)
os.makedirs("static", exist_ok=True) os.makedirs("static", exist_ok=True)
port = find_free_port(start_port=5000, max_attempts=10, host="0.0.0.0")
print(""" print(f"""
╔═══════════════════════════════════════════════════════════╗ ╔═══════════════════════════════════════════════════════════╗
║ Naue Roll Calculator - V14 + QoL Update ║ ║ Naue Roll Calculator - V14 + QoL Update ║
║ HTTP Basic Auth Enabled ║ ║ HTTP Basic Auth Enabled ║
║ ║ ║ ║
║ ⚠️ BEFORE PRODUCTION: Update passwords in app.py ║ ║ ⚠️ BEFORE PRODUCTION: Update passwords in app.py ║
║ ║ ║ ║
║ Starten auf: http://localhost:5000 ║ ║ Starten auf: http://localhost:{port:<5} ║
╚═══════════════════════════════════════════════════════════╝ ╚═══════════════════════════════════════════════════════════╝
""") """)
app.run( app.run(
host="0.0.0.0", host="0.0.0.0",
port=5000, port=port,
debug=False, debug=False,
use_reloader=False use_reloader=False
) )
+9
View File
@@ -0,0 +1,9 @@
# Recent Changes
## Version 0.4
- User Manual integrated into RollCalc.
- Help area added for internal documentation.
- Capacity Analysis presentation improved.
- Governing Constraint highlighted more clearly.
- Remaining Capacity added to the Capacity Summary.
+8
View File
@@ -0,0 +1,8 @@
{
"items": [
"User Manual integrated",
"Capacity Analysis improved",
"Governing Constraint clarified",
"Remaining Capacity added"
]
}
+7
View File
@@ -57,6 +57,13 @@
</p> </p>
<a href="{{ url_for('user_manual') }}">Open User Manual</a> <a href="{{ url_for('user_manual') }}">Open User Manual</a>
</article> </article>
<article class="help-card">
<h2>Recent Changes</h2>
<p>
Compact release notes for relevant user-facing changes in RollCalc.
</p>
<a href="{{ url_for('recent_changes') }}">Open Recent Changes</a>
</article>
</section> </section>
</main> </main>
+143 -2
View File
@@ -146,6 +146,59 @@
background: #b8c7bf; background: #b8c7bf;
cursor: not-allowed; cursor: not-allowed;
} }
.whats-new-overlay {
position: fixed;
inset: 0;
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(20, 28, 34, 0.55);
}
.whats-new-overlay.is-hidden { display: none; }
.whats-new-dialog {
width: min(520px, 100%);
background: #fff;
border-radius: 8px;
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.24);
overflow: hidden;
}
.whats-new-header {
padding: 16px 20px;
background: #1F5438;
color: #fff;
}
.whats-new-header h2 { font-size: 18px; margin: 0 0 4px; }
.whats-new-version { font-size: 12px; opacity: 0.88; }
.whats-new-content {
padding: 18px 20px 8px;
font-size: 14px;
line-height: 1.5;
color: #333;
}
.whats-new-content ul { margin: 8px 0 0 20px; }
.whats-new-content li { margin-bottom: 5px; }
.whats-new-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 20px 18px;
border-top: 1px solid #e0e8f0;
background: #f8fafa;
}
.whats-new-btn {
border: none;
border-radius: 6px;
padding: 9px 14px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.whats-new-btn-primary { background: #2BAC70; color: #fff; }
.whats-new-btn-primary:hover { background: #1F5438; }
.whats-new-btn-secondary { background: #fff; color: #1F5438; border: 1px solid #a8c8ba; }
.whats-new-btn-secondary:hover { background: #eef4f1; }
/* ============================================ /* ============================================
LOAD OPTIMIZER STYLES LOAD OPTIMIZER STYLES
@@ -453,6 +506,8 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.header { padding: 18px 16px; } .header { padding: 18px 16px; }
.header-inner { align-items: flex-start; } .header-inner { align-items: flex-start; }
.whats-new-actions { flex-direction: column; }
.whats-new-btn { width: 100%; }
.lo-accordion-header h3 { font-size: 14px; } .lo-accordion-header h3 { font-size: 14px; }
.lo-preset-buttons { flex-direction: column; } .lo-preset-buttons { flex-direction: column; }
.lo-preset-btn { width: 100%; } .lo-preset-btn { width: 100%; }
@@ -496,13 +551,29 @@
</div> </div>
</div> </div>
<div class="whats-new-overlay is-hidden" id="whatsNewOverlay" role="dialog" aria-modal="true" aria-labelledby="whatsNewTitle">
<div class="whats-new-dialog">
<div class="whats-new-header">
<h2 id="whatsNewTitle">What’s New</h2>
<div class="whats-new-version">Version: <span id="whatsNewVersion">–</span></div>
</div>
<div class="whats-new-content">
<ul id="whatsNewList"></ul>
</div>
<div class="whats-new-actions">
<button class="whats-new-btn whats-new-btn-secondary" id="whatsNewOpenChangesBtn" type="button">Open Recent Changes</button>
<button class="whats-new-btn whats-new-btn-primary" id="whatsNewContinueBtn" type="button">Continue</button>
</div>
</div>
</div>
<div class="header"> <div class="header">
<div class="header-inner"> <div class="header-inner">
<div> <div>
<h1>Naue Roll Diameter Calculator</h1> <h1>Naue Roll Diameter Calculator</h1>
<p>Roll Diameter • Product Length • Roll Weight • Load Optimizer</p> <p>Roll Diameter • Product Length • Roll Weight • Load Optimizer</p>
</div> </div>
<a class="header-help-link" href="{{ url_for('help_index') }}">Help</a> <a class="header-help-link" href="{{ url_for('user_manual') }}">User Manual</a>
</div> </div>
</div> </div>
@@ -1573,12 +1644,81 @@ document.addEventListener('DOMContentLoaded', () => {
window.loadOptimizerUI = new LoadOptimizerUI(); window.loadOptimizerUI = new LoadOptimizerUI();
}); });
const ROLLCALC_BUILD_VERSION = {{ build_info.version|tojson }};
const ROLLCALC_WHATS_NEW_ITEMS = {{ whats_new_summary|tojson }};
const ROLLCALC_LAST_SEEN_VERSION_KEY = 'rollcalc_last_seen_version';
function shouldShowWhatsNew(currentVersion, storedVersion) {
return !!currentVersion &&
currentVersion !== 'unknown' &&
currentVersion !== storedVersion;
}
function markWhatsNewSeen(currentVersion) {
if (!currentVersion || currentVersion === 'unknown') return;
try {
localStorage.setItem(ROLLCALC_LAST_SEEN_VERSION_KEY, currentVersion);
} catch (e) {
console.warn('Could not store RollCalc release note state', e);
}
}
function renderWhatsNewItems(items) {
const list = document.getElementById('whatsNewList');
if (!list) return;
list.innerHTML = '';
items.forEach(item => {
const li = document.createElement('li');
li.textContent = item;
list.appendChild(li);
});
}
function maybeShowWhatsNew() {
const overlay = document.getElementById('whatsNewOverlay');
const versionValue = document.getElementById('whatsNewVersion');
const continueBtn = document.getElementById('whatsNewContinueBtn');
const openChangesBtn = document.getElementById('whatsNewOpenChangesBtn');
const items = Array.isArray(ROLLCALC_WHATS_NEW_ITEMS)
? ROLLCALC_WHATS_NEW_ITEMS
: [];
if (!overlay || !versionValue || !continueBtn || !openChangesBtn || items.length === 0) return;
let storedVersion = null;
try {
storedVersion = localStorage.getItem(ROLLCALC_LAST_SEEN_VERSION_KEY);
} catch (e) {
storedVersion = null;
}
if (!shouldShowWhatsNew(ROLLCALC_BUILD_VERSION, storedVersion)) return;
versionValue.textContent = ROLLCALC_BUILD_VERSION;
renderWhatsNewItems(items);
overlay.classList.remove('is-hidden');
continueBtn.addEventListener('click', () => {
markWhatsNewSeen(ROLLCALC_BUILD_VERSION);
overlay.classList.add('is-hidden');
}, { once: true });
openChangesBtn.addEventListener('click', () => {
markWhatsNewSeen(ROLLCALC_BUILD_VERSION);
window.location.href = {{ url_for('recent_changes')|tojson }};
}, { once: true });
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const overlay = document.getElementById('disclaimerOverlay'); const overlay = document.getElementById('disclaimerOverlay');
const checkbox = document.getElementById('disclaimerAccepted'); const checkbox = document.getElementById('disclaimerAccepted');
const confirmBtn = document.getElementById('disclaimerConfirmBtn'); const confirmBtn = document.getElementById('disclaimerConfirmBtn');
if (!overlay || !checkbox || !confirmBtn) return; if (!overlay || !checkbox || !confirmBtn) {
maybeShowWhatsNew();
return;
}
checkbox.addEventListener('change', () => { checkbox.addEventListener('change', () => {
confirmBtn.disabled = !checkbox.checked; confirmBtn.disabled = !checkbox.checked;
@@ -1587,6 +1727,7 @@ document.addEventListener('DOMContentLoaded', () => {
confirmBtn.addEventListener('click', () => { confirmBtn.addEventListener('click', () => {
if (!checkbox.checked) return; if (!checkbox.checked) return;
overlay.classList.add('is-hidden'); overlay.classList.add('is-hidden');
maybeShowWhatsNew();
}); });
}); });
</script> </script>
+5 -5
View File
@@ -3,8 +3,8 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="RollCalc User Manual"> <meta name="description" content="RollCalc {{ document_title }}">
<title>RollCalc User Manual</title> <title>RollCalc {{ document_title }}</title>
<style> <style>
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; } html { scroll-behavior: smooth; }
@@ -62,8 +62,8 @@
<header class="help-header"> <header class="help-header">
<div class="help-header-inner"> <div class="help-header-inner">
<div> <div>
<h1>User Manual</h1> <h1>{{ document_title }}</h1>
<p>RollCalc technical user documentation</p> <p>{{ document_subtitle }}</p>
</div> </div>
<nav class="help-nav" aria-label="Help navigation"> <nav class="help-nav" aria-label="Help navigation">
<a href="{{ url_for('index') }}">Calculator</a> <a href="{{ url_for('index') }}">Calculator</a>
@@ -75,7 +75,7 @@
<main class="manual-shell"> <main class="manual-shell">
{% if render_error %} {% if render_error %}
<section class="manual-error" role="alert"> <section class="manual-error" role="alert">
<strong>User Manual unavailable</strong> <strong>{{ error_title }}</strong>
<p>{{ render_error }}</p> <p>{{ render_error }}</p>
</section> </section>
{% else %} {% else %}