8 Commits
13 changed files with 2619 additions and 702 deletions
+3
View File
@@ -32,3 +32,6 @@ node_modules/
# Backup files # Backup files
*.bak *.bak
*.backup *.backup
# runtime-generated files
access_log.json
+52
View File
@@ -16,6 +16,10 @@ Backend:
- HTTP Basic Auth is implemented with `Flask-HTTPAuth`. - HTTP Basic Auth is implemented with `Flask-HTTPAuth`.
- Users are currently configured in `BETA_USERS` with Werkzeug password hashes. - Users are currently configured in `BETA_USERS` with Werkzeug password hashes.
- `/` renders the active calculator template. - `/` renders the active calculator template.
- `/help` renders the authenticated Help landing page.
- `/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/`.
- `/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.
- `/api/user` returns the authenticated user. - `/api/user` returns the authenticated user.
@@ -50,6 +54,54 @@ If `build_info.json` is missing, malformed, or does not contain a usable value,
The data is loaded centrally in `app.py` and made available to every template as `build_info`. The data is loaded centrally in `app.py` and made available to every template as `build_info`.
## Help and User Manual
The in-app Help area keeps `docs/de/user_manual.md` as the leading German documentation source. RollCalc reads the Markdown file on each `/help/user-manual` request and renders it server-side with `markdown-it-py`.
The documentation structure is prepared for language-specific manuals:
```text
docs/
de/
user_manual.md
screenshots/
```
Additional languages can follow the same structure later. The current public route still opens the German manual and no language switcher is implemented.
Rendering rules:
- Raw HTML from the Markdown source is disabled.
- Heading ids and the table of contents are generated from the Markdown content.
- Relative image references such as `screenshots/disclaimer.png` are rewritten to the authenticated manual screenshot route.
- Screenshots belong in the active language's `screenshots/` directory, currently `docs/de/screenshots/`; the screenshot route must remain limited to that directory.
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:
+72 -1
View File
@@ -9,6 +9,7 @@ This README is intended for developers maintaining the project, not for end user
- Python 3 - Python 3
- Flask 2.3.3 - Flask 2.3.3
- Flask-HTTPAuth 4.8.0 - Flask-HTTPAuth 4.8.0
- markdown-it-py 3.0.0 for server-side Markdown rendering
- Vanilla HTML, CSS, and JavaScript - Vanilla HTML, CSS, and JavaScript
- JSON files for product data and forklift/load rules - JSON files for product data and forklift/load rules
@@ -58,7 +59,9 @@ The app uses HTTP Basic Auth. Current users are defined in `BETA_USERS` in `app.
├── fix_article_data.py ├── fix_article_data.py
├── service-worker.js ├── service-worker.js
├── templates/ ├── templates/
│ └── roll_calculator.html │ ├── help_index.html
│ ├── roll_calculator.html
│ └── user_manual.html
├── static/ ├── static/
│ ├── article-data.json │ ├── article-data.json
│ ├── config.json │ ├── config.json
@@ -71,6 +74,9 @@ The app uses HTTP Basic Auth. Current users are defined in `BETA_USERS` in `app.
│ ├── rollcalc_stddev_ranges.js │ ├── rollcalc_stddev_ranges.js
│ └── rollcalc_stddev_integration.js │ └── rollcalc_stddev_integration.js
└── docs/ └── docs/
├── de/
│ ├── screenshots/
│ └── user_manual.md
├── DEPLOYMENT_GUIDE.md ├── DEPLOYMENT_GUIDE.md
├── QOL_UPDATE_SUMMARY.md ├── QOL_UPDATE_SUMMARY.md
├── STDDEV_IMPLEMENTATION_CHECKLIST.md ├── STDDEV_IMPLEMENTATION_CHECKLIST.md
@@ -86,6 +92,10 @@ Implemented routes:
| Route | Methods | Auth | Purpose | | Route | Methods | Auth | Purpose |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `/` | `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/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. |
| `/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. |
| `/api/user` | `GET` | Basic Auth | Returns current authenticated user info. | | `/api/user` | `GET` | Basic Auth | Returns current authenticated user info. |
@@ -109,6 +119,67 @@ Do not commit real passwords or print them in logs.
Access logging is handled by `log_access()`, which reads `access_log.json`, appends a record, and writes the whole file back. Access logging is handled by `log_access()`, which reads `access_log.json`, appends a record, and writes the whole file back.
## Help and User Manual
RollCalc exposes an authenticated Help area at:
```text
/help
```
The user manual is available at:
```text
/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/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:
```text
docs/
de/
user_manual.md
screenshots/
```
The public route `/help/user-manual` currently opens the German manual. No language switcher is implemented yet.
Screenshots referenced from the manual as `screenshots/filename.png` should be placed in:
```text
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.
### 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`.
-667
View File
@@ -1,667 +0,0 @@
[
{
"timestamp": "2026-05-28T13:06:19.854940",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:06:58.223722",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:07:05.902636",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:07:51.345366",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:09:37.481260",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:14:02.867188",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:28:08.386403",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:29:06.541184",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:30:47.979071",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:46:05.447815",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:46:19.317688",
"username": "beta",
"endpoint": "/admin",
"method": "GET",
"status": 403
},
{
"timestamp": "2026-05-28T14:42:29.477028",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:43:31.374462",
"username": "admin",
"endpoint": "/admin",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:43:31.411860",
"username": "admin",
"endpoint": "/api/admin/stats",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:43:31.562482",
"username": "admin",
"endpoint": "/api/admin/logs",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:44:01.420524",
"username": "admin",
"endpoint": "/api/admin/stats",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:44:01.438789",
"username": "admin",
"endpoint": "/api/admin/logs",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:44:13.773466",
"username": "admin",
"endpoint": "/admin",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:44:13.817782",
"username": "admin",
"endpoint": "/api/admin/stats",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:44:13.894128",
"username": "admin",
"endpoint": "/api/admin/logs",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-06-03T05:20:48.808775",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:31:48.451519",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:31:48.676701",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:31:54.481445",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:31:58.011356",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:32:48.747837",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:33:07.753648",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:42:11.820817",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:43:37.598978",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:47:39.689201",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T08:25:47.585793",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:32:59.419063",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:35:05.236655",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:40:47.890997",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:41:02.461403",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:42:11.914989",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:42:57.125161",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:54:34.067541",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T10:02:39.927663",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:00:50.808697",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:09:28.615255",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:09:40.011779",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:09:53.681061",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:10:06.334747",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:17:18.862106",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:44:00.451943",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:44:20.277301",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:49:56.333142",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:50:11.692907",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:56:07.569410",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:59:03.417506",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:59:21.102453",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:04:52.131022",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:05:09.915771",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:09:55.899370",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:10:20.387853",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:10:20.736005",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:12:48.924277",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:15:13.236624",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:17:32.470202",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:23:23.613251",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:23:24.369105",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:26:48.783516",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:47:07.888072",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:52:38.653855",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:53:02.662397",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:57:19.316896",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T14:05:28.671663",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T14:06:26.413418",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T14:06:42.715163",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T14:12:15.337499",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T14:12:19.510992",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T14:13:17.018760",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-02T11:28:30.320494",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-02T11:28:46.119436",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-02T11:36:55.439798",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-02T11:41:31.868950",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-02T16:07:56.334163",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-02T16:08:38.097844",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-03T08:39:44.284544",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-03T08:41:57.673023",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-03T08:42:19.877155",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-06T10:34:02.710459",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-06T13:14:58.113794",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-06T13:15:20.071507",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-06T13:15:32.538576",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-07T11:44:53.524315",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-07T11:47:36.702455",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-07T12:18:47.866971",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-07T14:46:22.335316",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-07T16:13:42.026433",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-07T16:19:41.488752",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-07T16:34:58.280178",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-07T16:36:56.896657",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-07T16:41:46.568545",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
}
]
+247 -6
View File
@@ -3,13 +3,27 @@ Naue Roll Calculator - Beta
Flask app with HTTP Basic Authentication Flask app with HTTP Basic Authentication
""" """
from flask import Flask, render_template, request, send_file, send_from_directory, jsonify from flask import (
Flask,
abort,
jsonify,
render_template,
request,
send_file,
send_from_directory,
url_for,
)
from flask_httpauth import HTTPBasicAuth from flask_httpauth import HTTPBasicAuth
from markdown_it import MarkdownIt
from markupsafe import Markup
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
from functools import wraps from functools import wraps
import os import os
from datetime import datetime from datetime import datetime
import json import json
import re
import socket
import unicodedata
app = Flask(__name__) app = Flask(__name__)
auth = HTTPBasicAuth() auth = HTTPBasicAuth()
@@ -43,6 +57,11 @@ BETA_USERS = {
# Logging für Auditing # Logging für Auditing
LOG_FILE = "access_log.json" LOG_FILE = "access_log.json"
BUILD_INFO_FILE = "build_info.json" BUILD_INFO_FILE = "build_info.json"
DOCS_DIR = os.path.join(app.root_path, "docs")
DEFAULT_DOC_LANGUAGE = "de"
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",
@@ -75,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
@@ -116,10 +161,119 @@ 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
# ============================================================================ # ============================================================================
def slugify_heading(text):
"""Create stable, URL-friendly heading ids for rendered Markdown."""
normalized = unicodedata.normalize("NFKD", text)
ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^a-zA-Z0-9]+", "-", ascii_text).strip("-").lower()
return slug or "section"
def build_manual_renderer():
"""Build a Markdown renderer with raw HTML disabled."""
renderer = MarkdownIt("commonmark", {"html": False})
renderer.enable("table")
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):
"""Return the Markdown path for a language-specific manual."""
return get_document_path(USER_MANUAL_FILENAME, language)
def get_manual_screenshot_dir(language=DEFAULT_DOC_LANGUAGE):
"""Return the screenshot directory for a language-specific manual."""
return os.path.join(DOCS_DIR, language, "screenshots")
def rewrite_manual_asset_paths(markdown_text, language=DEFAULT_DOC_LANGUAGE):
"""Route relative manual screenshot links through the protected docs route."""
screenshot_url = url_for("manual_screenshot", filename="")
return markdown_text.replace("](screenshots/", f"]({screenshot_url}")
def add_heading_ids_and_toc(tokens):
"""Attach ids to headings and create a compact table of contents."""
toc = []
used_slugs = {}
for index, token in enumerate(tokens):
if token.type != "heading_open":
continue
level = int(token.tag[1])
inline_token = tokens[index + 1] if index + 1 < len(tokens) else None
title = (
inline_token.content
if inline_token and inline_token.type == "inline"
else ""
)
base_slug = slugify_heading(title)
count = used_slugs.get(base_slug, 0)
used_slugs[base_slug] = count + 1
slug = base_slug if count == 0 else f"{base_slug}-{count + 1}"
token.attrSet("id", slug)
if level <= 2:
toc.append({
"level": level,
"title": title,
"id": slug
})
return toc
def render_markdown_document(filename, language=DEFAULT_DOC_LANGUAGE):
"""Read and render a Markdown help document into HTML and a generated TOC."""
renderer = build_manual_renderer()
document_path = get_document_path(filename, language)
with open(document_path, "r", encoding="utf-8") as document_file:
markdown_text = document_file.read()
markdown_text = rewrite_manual_asset_paths(markdown_text, language)
tokens = renderer.parse(markdown_text)
toc = add_heading_ids_and_toc(tokens)
html = renderer.renderer.render(tokens, renderer.options, {})
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():
@@ -127,6 +281,92 @@ def index():
log_access(auth.current_user(), "/", "GET") log_access(auth.current_user(), "/", "GET")
return render_template("roll_calculator.html") return render_template("roll_calculator.html")
@app.route("/help", methods=["GET"])
@auth.login_required
def help_index():
"""Help landing page - requires authentication."""
log_access(auth.current_user(), "/help", "GET")
return render_template("help_index.html")
@app.route("/help/user-manual", methods=["GET"])
@auth.login_required
def user_manual():
"""Render the Markdown user manual inside the RollCalc layout."""
log_access(auth.current_user(), "/help/user-manual", "GET")
try:
manual_html, toc = render_user_manual(DEFAULT_DOC_LANGUAGE)
return render_template(
"user_manual.html",
document_title="User Manual",
document_subtitle="RollCalc technical user documentation",
error_title="User Manual unavailable",
manual_html=manual_html,
toc=toc,
render_error=None
)
except Exception:
app.logger.exception("Could not render user manual")
return render_template(
"user_manual.html",
document_title="User Manual",
document_subtitle="RollCalc technical user documentation",
error_title="User Manual unavailable",
manual_html=None,
toc=[],
render_error=(
"The user manual could not be loaded. "
"Please contact the RollCalc maintainer if the problem persists."
)
), 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"])
@auth.login_required
def manual_screenshot(filename, language=DEFAULT_DOC_LANGUAGE):
"""Serve user-manual screenshots from the selected language directory."""
log_access(
auth.current_user(),
f"/help/user-manual/screenshots/{filename}",
"GET"
)
try:
return send_from_directory(get_manual_screenshot_dir(language), filename)
except Exception:
app.logger.warning("Missing or inaccessible manual screenshot: %s", filename)
abort(404)
@app.route("/static/<path:filename>", methods=["GET"]) @app.route("/static/<path:filename>", methods=["GET"])
@auth.login_required @auth.login_required
def serve_static(filename): def serve_static(filename):
@@ -177,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
) )
+39
View File
@@ -0,0 +1,39 @@
# Documentation Guidelines
## Language
The primary documentation language is German.
An English version will be maintained as a translation of the German master document.
## UI terminology
All user interface terms remain in English.
Examples:
- Direct Calculation
- Extrapolation
- Roll Diameter
- Product Length
- Capacity Summary
- Capacity Analysis
- Governing Constraint
Do not translate UI labels.
## Style
- Technical user manual
- Workflow-oriented
- Explain why, not only how
- Engineering language
- No marketing language
## Workflow
1. Write German chapter.
2. Technical review.
3. Add screenshots.
4. Approve.
5. Translate to English.
+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.
File diff suppressed because it is too large Load Diff
+1
View File
@@ -5,3 +5,4 @@ click==8.1.7
itsdangerous==2.1.2 itsdangerous==2.1.2
Jinja2==3.1.2 Jinja2==3.1.2
MarkupSafe==2.1.3 MarkupSafe==2.1.3
markdown-it-py==3.0.0
+8
View File
@@ -0,0 +1,8 @@
{
"items": [
"User Manual integrated",
"Capacity Analysis improved",
"Governing Constraint clarified",
"Remaining Capacity added"
]
}
+80
View File
@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="RollCalc Help">
<title>RollCalc Help</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f4f6f9; color: #2f3437; min-height: 100vh; }
.help-header { background: linear-gradient(135deg, #1F5438 0%, #2BAC70 100%); color: #fff; padding: 20px 32px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); }
.help-header-inner { max-width: 980px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.help-header h1 { font-size: 24px; margin-bottom: 5px; }
.help-header p { font-size: 13px; opacity: 0.9; }
.help-nav { display: flex; gap: 10px; flex-wrap: wrap; }
.help-nav a { color: #fff; border: 1px solid rgba(255,255,255,0.58); border-radius: 5px; padding: 7px 13px; text-decoration: none; font-size: 13px; font-weight: 700; background: rgba(255,255,255,0.08); }
.help-nav a:hover { background: rgba(255,255,255,0.18); }
.help-container { max-width: 980px; margin: 30px auto; padding: 0 16px 60px; }
.help-intro { margin-bottom: 22px; line-height: 1.55; color: #4b555a; }
.help-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 18px; }
.help-card { background: #fff; border: 1px solid #dfe7e2; border-radius: 8px; padding: 20px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
.help-card h2 { color: #1F5438; font-size: 18px; margin-bottom: 10px; }
.help-card p { color: #4f5b60; font-size: 14px; line-height: 1.55; margin-bottom: 18px; }
.help-card a { display: inline-block; background: #2BAC70; color: #fff; text-decoration: none; border-radius: 6px; padding: 10px 16px; font-size: 14px; font-weight: 700; }
.help-card a:hover { background: #1F5438; }
.footer { text-align: center; font-size: 11px; color: #aaa; padding: 18px; }
.footer-build-info { margin-top: 6px; color: #bbb; }
@media (max-width: 640px) {
.help-header { padding: 18px 16px; }
.help-header-inner { align-items: flex-start; flex-direction: column; }
}
</style>
</head>
<body>
<header class="help-header">
<div class="help-header-inner">
<div>
<h1>RollCalc Help</h1>
<p>Documentation for roll calculation and transport capacity analysis</p>
</div>
<nav class="help-nav" aria-label="Help navigation">
<a href="{{ url_for('index') }}">Calculator</a>
</nav>
</div>
</header>
<main class="help-container">
<p class="help-intro">
This area provides internal technical documentation for RollCalc. The documents are rendered from the maintained Markdown sources.
</p>
<section class="help-grid" aria-label="Available help documents">
<article class="help-card">
<h2>User Manual</h2>
<p>
Workflow-oriented manual for roll calculation, handling evaluation, Load Optimizer usage, and interpretation of Capacity Summary and Capacity Analysis.
</p>
<a href="{{ url_for('user_manual') }}">Open User Manual</a>
</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>
</main>
<footer class="footer">
RollCalc Beta &copy; Naue
<div class="footer-build-info">
Version {{ build_info.version }} |
Branch {{ build_info.branch }} |
Commit {{ build_info.commit }} |
Built {{ build_info.timestamp }}
</div>
</footer>
</body>
</html>
+254 -28
View File
@@ -9,8 +9,11 @@
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f4f6f9; color: #333; min-height: 100vh; } body { font-family: 'Segoe UI', Arial, sans-serif; background: #f4f6f9; color: #333; min-height: 100vh; }
.header { background: linear-gradient(135deg, #1F5438 0%, #2BAC70 100%); color: #fff; padding: 20px 32px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); } .header { background: linear-gradient(135deg, #1F5438 0%, #2BAC70 100%); color: #fff; padding: 20px 32px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); }
.header-inner { max-width: 900px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.header h1 { font-size: 24px; margin-bottom: 5px; } .header h1 { font-size: 24px; margin-bottom: 5px; }
.header p { font-size: 13px; opacity: 0.9; } .header p { font-size: 13px; opacity: 0.9; }
.header-help-link { color: #fff; border: 1px solid rgba(255,255,255,0.58); border-radius: 5px; padding: 7px 13px; text-decoration: none; font-size: 13px; font-weight: 700; background: rgba(255,255,255,0.08); }
.header-help-link:hover { background: rgba(255,255,255,0.18); }
.container { max-width: 900px; margin: 30px auto; padding: 0 16px 60px; } .container { max-width: 900px; margin: 30px auto; padding: 0 16px 60px; }
.card { background: #fff; border-radius: 10px; box-shadow: 0 2px 12px rgba(0,0,0,0.07); margin-bottom: 24px; overflow: hidden; } .card { background: #fff; border-radius: 10px; box-shadow: 0 2px 12px rgba(0,0,0,0.07); margin-bottom: 24px; overflow: hidden; }
.card-header { background: #f0f4fa; padding: 16px 22px; border-bottom: 1px solid #e0e8f0; } .card-header { background: #f0f4fa; padding: 16px 22px; border-bottom: 1px solid #e0e8f0; }
@@ -143,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
@@ -289,6 +345,23 @@
border-left: 4px solid #2BAC70; border-left: 4px solid #2BAC70;
} }
.lo-results-summary h4 { margin-top: 0; color: #333; font-size: 14px; margin-bottom: 12px; } .lo-results-summary h4 { margin-top: 0; color: #333; font-size: 14px; margin-bottom: 12px; }
.lo-summary-section {
padding-top: 12px;
margin-top: 12px;
border-top: 1px solid #e6ece9;
}
.lo-summary-section:first-of-type {
padding-top: 0;
margin-top: 0;
border-top: 0;
}
.lo-summary-section-title {
margin-bottom: 8px;
color: #1F5438;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.lo-summary-item { .lo-summary-item {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -297,9 +370,23 @@
font-size: 13px; font-size: 13px;
} }
.lo-summary-item:last-child { margin-bottom: 0; } .lo-summary-item:last-child { margin-bottom: 0; }
.lo-summary-item.lo-governing-summary {
background: #eef8f2;
border-left: 3px solid #2BAC70;
padding: 8px 10px;
border-radius: 4px;
}
.lo-value-highlight { color: #2BAC70; font-weight: bold; font-size: 16px; } .lo-value-highlight { color: #2BAC70; font-weight: bold; font-size: 16px; }
.lo-value-number { color: #1F5438; font-weight: bold; } .lo-value-number { color: #1F5438; font-weight: bold; }
.lo-util-group { display: flex; flex-direction: column; gap: 4px; text-align: right; } .lo-util-group { display: flex; flex-direction: column; gap: 4px; text-align: right; }
.lo-reserve-note {
margin-top: 4px;
color: #666;
font-size: 12px;
line-height: 1.35;
text-align: right;
max-width: 300px;
}
.lo-results-table { .lo-results-table {
background: white; background: white;
padding: 16px; padding: 16px;
@@ -315,10 +402,15 @@
font-weight: 600; font-weight: 600;
color: #333; color: #333;
border-bottom: 2px solid #2BAC70; border-bottom: 2px solid #2BAC70;
white-space: normal;
} }
.lo-results-table td { padding: 8px; border-bottom: 1px solid #e0e0e0; } .lo-results-table td { padding: 8px; border-bottom: 1px solid #e0e0e0; }
.lo-results-table tbody tr:hover { background: #f9f9f9; } .lo-results-table tbody tr:hover { background: #f9f9f9; }
.lo-results-table tbody tr.lo-recommended { background: #fffacd; font-weight: bold; } .lo-results-table tbody tr.lo-governing {
background: #eef8f2;
font-weight: 600;
border-left: 3px solid #2BAC70;
}
.lo-warning { .lo-warning {
background: #fff8e1; background: #fff8e1;
border: 1px solid #f0b429; border: 1px solid #f0b429;
@@ -396,6 +488,11 @@
} }
.lo-sideview-section.show { display: block; } .lo-sideview-section.show { display: block; }
.lo-sideview-section h4 { margin: 0 0 12px 0; font-size: 14px; color: #333; } .lo-sideview-section h4 { margin: 0 0 12px 0; font-size: 14px; color: #333; }
.lo-sideview-context {
margin: -6px 0 12px;
color: #666;
font-size: 12px;
}
.lo-sideview-wrap { .lo-sideview-wrap {
background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 4px; overflow: hidden; background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 4px; overflow: hidden;
} }
@@ -407,6 +504,10 @@
.margin-color { background: repeating-linear-gradient(45deg,#fff7e0,#fff7e0 3px,#ffe8a0 3px,#ffe8a0 6px); border-color:#f0b429; } .margin-color { background: repeating-linear-gradient(45deg,#fff7e0,#fff7e0 3px,#ffe8a0 3px,#ffe8a0 6px); border-color:#f0b429; }
@media (max-width: 768px) { @media (max-width: 768px) {
.header { padding: 18px 16px; }
.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%; }
@@ -450,9 +551,30 @@
</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">
<h1>Naue Roll Diameter Calculator</h1> <div class="header-inner">
<p>Roll Diameter • Product Length • Roll Weight • Load Optimizer</p> <div>
<h1>Naue Roll Diameter Calculator</h1>
<p>Roll Diameter • Product Length • Roll Weight • Load Optimizer</p>
</div>
<a class="header-help-link" href="{{ url_for('user_manual') }}">User Manual</a>
</div>
</div> </div>
<div class="container"> <div class="container">
@@ -807,38 +929,58 @@
<div class="lo-results" id="resultsContainer"> <div class="lo-results" id="resultsContainer">
<div class="lo-results-summary"> <div class="lo-results-summary">
<h4>Results:</h4> <h4>Capacity Summary:</h4>
<div class="lo-summary-item"> <div class="lo-summary-section">
<span><strong>Limiting Factor:</strong></span> <div class="lo-summary-section-title">Governing Constraint</div>
<span id="limitingFactorValue" class="lo-value-highlight">–</span> <div class="lo-summary-item lo-governing-summary">
</div> <span><strong>Constraint:</strong></span>
<div class="lo-summary-item"> <span id="limitingFactorValue" class="lo-value-highlight">–</span>
<span><strong>Total Rolls:</strong></span>
<div style="text-align:right;">
<span id="totalRollsValue" class="lo-value-highlight">–</span>
<small id="rollsDetailValue" style="display:block;color:#888;"></small>
</div> </div>
</div> </div>
<div class="lo-summary-item">
<span><strong>Utilization:</strong></span> <div class="lo-summary-section">
<div class="lo-util-group"> <div class="lo-summary-section-title">Transport Result</div>
<div>Weight: <span id="weightUtilValue" class="lo-value-number">–</span> %</div> <div class="lo-summary-item">
<div>Volume: <span id="volumeUtilValue" class="lo-value-number">–</span> %</div> <span><strong>Total Rolls:</strong></span>
<div>Product: <span id="productM2Value" class="lo-value-number">–</span> m²</div> <div style="text-align:right;">
<span id="totalRollsValue" class="lo-value-highlight">–</span>
<small id="rollsDetailValue" style="display:block;color:#888;"></small>
</div>
</div>
<div class="lo-summary-item">
<span><strong>Utilization:</strong></span>
<div class="lo-util-group">
<div>Weight Utilization: <span id="weightUtilValue" class="lo-value-number">–</span> %</div>
<div>Volume Utilization: <span id="volumeUtilValue" class="lo-value-number">–</span> %</div>
<div>Transported Product Area: <span id="productM2Value" class="lo-value-number">–</span> m²</div>
</div>
</div>
</div>
<div class="lo-summary-section">
<div class="lo-summary-section-title">Remaining Capacity</div>
<div class="lo-summary-item">
<span><strong>Remaining Capacity:</strong></span>
<div class="lo-util-group">
<div>Remaining Payload: <span id="remainingPayloadValue" class="lo-value-number">–</span> kg</div>
<div>Unused Payload: <span id="unusedPayloadValue" class="lo-value-number">–</span> %</div>
<div>Unused Volume: <span id="unusedVolumeValue" class="lo-value-number">–</span> %</div>
<div id="capacityInterpretationValue" class="lo-reserve-note"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="lo-results-table"> <div class="lo-results-table">
<h4>Comparison Table:</h4> <h4>Capacity Analysis:</h4>
<table id="comparisonTable"> <table id="comparisonTable">
<thead> <thead>
<tr> <tr>
<th>Scenario</th> <th>Constraint</th>
<th>Rolls</th> <th>Max Rolls</th>
<th>kg</th> <th>Total Weight</th>
<th>m²</th> <th>Transported Area [m²]</th>
<th>Util. %</th> <th>Payload Util. %</th>
</tr> </tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>
@@ -847,6 +989,7 @@
<div class="lo-sideview-section" id="sideViewSection"> <div class="lo-sideview-section" id="sideViewSection">
<h4>🔍 Front View – Cross-Section</h4> <h4>🔍 Front View – Cross-Section</h4>
<p class="lo-sideview-context">Cross-section of the actual loading configuration.</p>
<div class="lo-sideview-wrap"> <div class="lo-sideview-wrap">
<svg id="sideViewSVG" width="100%" viewBox="0 0 800 300" xmlns="http://www.w3.org/2000/svg"> <svg id="sideViewSVG" width="100%" viewBox="0 0 800 300" xmlns="http://www.w3.org/2000/svg">
<text x="400" y="130" text-anchor="middle" fill="#aaa" font-size="14">Run a calculation to see the side view.</text> <text x="400" y="130" text-anchor="middle" fill="#aaa" font-size="14">Run a calculation to see the side view.</text>
@@ -1350,6 +1493,14 @@ class LoadOptimizerUI {
showResults(res, maxW) { showResults(res, maxW) {
const m2PerRoll = this.optimizer.state.m2PerRoll || 0; const m2PerRoll = this.optimizer.state.m2PerRoll || 0;
const remainingPayload = Math.max(0, maxW - res.finalWeight);
const unusedPayload = Math.max(0, 100 - res.utilizationWeight);
const unusedVolume = Math.max(0, 100 - res.utilizationVolume);
const interpretations = {
geometry: 'Geometry prevents loading additional rolls although payload capacity remains available.',
weight: 'Payload limit is reached before the available geometry or volume is fully used.',
volume: 'Volume capacity is reached before payload or geometric capacity is fully used.'
};
document.getElementById('limitingFactorValue').textContent = res.limiting.name; document.getElementById('limitingFactorValue').textContent = res.limiting.name;
document.getElementById('totalRollsValue').textContent = res.finalRolls; document.getElementById('totalRollsValue').textContent = res.finalRolls;
document.getElementById('rollsDetailValue').textContent = document.getElementById('rollsDetailValue').textContent =
@@ -1357,16 +1508,21 @@ class LoadOptimizerUI {
document.getElementById('weightUtilValue').textContent = this.optimizer.fmt(res.utilizationWeight); document.getElementById('weightUtilValue').textContent = this.optimizer.fmt(res.utilizationWeight);
document.getElementById('volumeUtilValue').textContent = this.optimizer.fmt(res.utilizationVolume); document.getElementById('volumeUtilValue').textContent = this.optimizer.fmt(res.utilizationVolume);
document.getElementById('productM2Value').textContent = this.optimizer.fmt(res.finalM2, 0); document.getElementById('productM2Value').textContent = this.optimizer.fmt(res.finalM2, 0);
document.getElementById('remainingPayloadValue').textContent = this.optimizer.fmt(remainingPayload, 0);
document.getElementById('unusedPayloadValue').textContent = this.optimizer.fmt(unusedPayload);
document.getElementById('unusedVolumeValue').textContent = this.optimizer.fmt(unusedVolume);
document.getElementById('capacityInterpretationValue').textContent =
interpretations[res.limiting.type] || '';
const tbody = document.querySelector('#comparisonTable tbody'); const tbody = document.querySelector('#comparisonTable tbody');
tbody.innerHTML = ''; tbody.innerHTML = '';
res.scenarios.forEach(sc => { res.scenarios.forEach(sc => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
const lim = (sc.type === res.limiting.type); const lim = (sc.type === res.limiting.type);
if (lim) tr.classList.add('lo-recommended'); if (lim) tr.classList.add('lo-governing');
const util = maxW > 0 ? (sc.weight / maxW * 100).toFixed(1) : '–'; const util = maxW > 0 ? (sc.weight / maxW * 100).toFixed(1) : '–';
const scM2 = isFinite(sc.rolls) && m2PerRoll > 0 ? Math.round(sc.rolls * m2PerRoll) : '–'; const scM2 = isFinite(sc.rolls) && m2PerRoll > 0 ? Math.round(sc.rolls * m2PerRoll) : '–';
tr.innerHTML = tr.innerHTML =
'<td>'+ sc.name +(lim?' ★':'')+'</td>'+ '<td>'+ sc.name +(lim?' &rarr; Governing Constraint':'')+'</td>'+
'<td>'+ (sc.rolls === Infinity ? '–' : sc.rolls) +'</td>'+ '<td>'+ (sc.rolls === Infinity ? '–' : sc.rolls) +'</td>'+
'<td>'+ (isFinite(sc.weight) ? Math.round(sc.weight) : '–') +'</td>'+ '<td>'+ (isFinite(sc.weight) ? Math.round(sc.weight) : '–') +'</td>'+
'<td>'+ scM2 +'</td>'+ '<td>'+ scM2 +'</td>'+
@@ -1488,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;
@@ -1502,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>
+110
View File
@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="RollCalc {{ document_title }}">
<title>RollCalc {{ document_title }}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f4f6f9; color: #2f3437; min-height: 100vh; }
.help-header { background: linear-gradient(135deg, #1F5438 0%, #2BAC70 100%); color: #fff; padding: 20px 32px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); }
.help-header-inner { max-width: 1180px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.help-header h1 { font-size: 24px; margin-bottom: 5px; }
.help-header p { font-size: 13px; opacity: 0.9; }
.help-nav { display: flex; gap: 10px; flex-wrap: wrap; }
.help-nav a { color: #fff; border: 1px solid rgba(255,255,255,0.58); border-radius: 5px; padding: 7px 13px; text-decoration: none; font-size: 13px; font-weight: 700; background: rgba(255,255,255,0.08); }
.help-nav a:hover { background: rgba(255,255,255,0.18); }
.manual-shell { max-width: 1180px; margin: 28px auto; padding: 0 16px 60px; }
.manual-layout { display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 24px; align-items: start; }
.manual-toc { position: sticky; top: 18px; background: #fff; border: 1px solid #dfe7e2; border-radius: 8px; padding: 16px; box-shadow: 0 2px 12px rgba(0,0,0,0.05); max-height: calc(100vh - 36px); overflow: auto; }
.manual-toc h2 { color: #1F5438; font-size: 14px; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0; }
.manual-toc ul { list-style: none; }
.manual-toc li { margin: 0 0 8px; }
.manual-toc a { color: #34443b; text-decoration: none; font-size: 13px; line-height: 1.35; }
.manual-toc a:hover { color: #1F5438; text-decoration: underline; }
.manual-toc .toc-level-2 { padding-left: 14px; }
.manual-content { background: #fff; border: 1px solid #dfe7e2; border-radius: 8px; padding: 34px 42px; box-shadow: 0 2px 12px rgba(0,0,0,0.05); line-height: 1.62; font-size: 15px; }
.manual-content h1, .manual-content h2, .manual-content h3, .manual-content h4 { color: #1F5438; line-height: 1.25; scroll-margin-top: 18px; }
.manual-content h1 { font-size: 30px; margin: 0 0 24px; padding-bottom: 14px; border-bottom: 2px solid #dfe7e2; }
.manual-content h2 { font-size: 22px; margin: 34px 0 14px; padding-top: 4px; }
.manual-content h3 { font-size: 17px; margin: 26px 0 10px; color: #244b39; }
.manual-content h4 { font-size: 15px; margin: 20px 0 8px; color: #244b39; }
.manual-content p { margin: 0 0 14px; }
.manual-content ul, .manual-content ol { margin: 0 0 16px 24px; }
.manual-content li { margin: 5px 0; }
.manual-content a { color: #1F5438; font-weight: 600; }
.manual-content code { background: #eef4f1; border: 1px solid #d7e4de; border-radius: 4px; padding: 1px 4px; font-family: Consolas, 'Courier New', monospace; font-size: 0.92em; }
.manual-content pre { background: #f5f7f6; border: 1px solid #dfe7e2; border-radius: 6px; padding: 14px; margin: 16px 0; overflow-x: auto; }
.manual-content pre code { background: transparent; border: 0; padding: 0; }
.manual-content blockquote { margin: 18px 0; padding: 14px 18px; border-left: 4px solid #2BAC70; background: #f1f8f5; color: #34443b; }
.manual-content blockquote p:last-child, .manual-content blockquote ul:last-child { margin-bottom: 0; }
.manual-content table { width: 100%; border-collapse: collapse; margin: 18px 0; display: block; overflow-x: auto; }
.manual-content th, .manual-content td { border: 1px solid #d7e4de; padding: 9px 11px; text-align: left; vertical-align: top; }
.manual-content th { background: #eef4f1; color: #1F5438; }
.manual-content img { max-width: 100%; height: auto; border: 1px solid #dfe7e2; border-radius: 6px; background: #f8fafa; display: block; margin: 18px 0 8px; }
.manual-error { background: #fff; border: 1px solid #e6c7c7; border-left: 4px solid #b84a4a; border-radius: 8px; padding: 20px; color: #5a2d2d; box-shadow: 0 2px 12px rgba(0,0,0,0.05); }
.footer { text-align: center; font-size: 11px; color: #aaa; padding: 18px; }
.footer-build-info { margin-top: 6px; color: #bbb; }
@media (max-width: 840px) {
.help-header { padding: 18px 16px; }
.help-header-inner { align-items: flex-start; flex-direction: column; }
.manual-layout { grid-template-columns: 1fr; }
.manual-toc { position: static; max-height: none; }
.manual-content { padding: 24px 20px; }
.manual-content h1 { font-size: 25px; }
.manual-content h2 { font-size: 20px; }
}
</style>
</head>
<body>
<header class="help-header">
<div class="help-header-inner">
<div>
<h1>{{ document_title }}</h1>
<p>{{ document_subtitle }}</p>
</div>
<nav class="help-nav" aria-label="Help navigation">
<a href="{{ url_for('index') }}">Calculator</a>
<a href="{{ url_for('help_index') }}">Help</a>
</nav>
</div>
</header>
<main class="manual-shell">
{% if render_error %}
<section class="manual-error" role="alert">
<strong>{{ error_title }}</strong>
<p>{{ render_error }}</p>
</section>
{% else %}
<div class="manual-layout">
<nav class="manual-toc" aria-label="Table of contents">
<h2>Contents</h2>
<ul>
{% for entry in toc %}
<li class="toc-level-{{ entry.level }}">
<a href="#{{ entry.id }}">{{ entry.title }}</a>
</li>
{% endfor %}
</ul>
</nav>
<article class="manual-content">
{{ manual_html }}
</article>
</div>
{% endif %}
</main>
<footer class="footer">
RollCalc Beta &copy; Naue
<div class="footer-build-info">
Version {{ build_info.version }} |
Branch {{ build_info.branch }} |
Commit {{ build_info.commit }} |
Built {{ build_info.timestamp }}
</div>
</footer>
</body>
</html>