feat: track task scope status and version

This commit is contained in:
user
2026-07-17 18:08:24 +02:00
parent 3589eaf709
commit 586644bcff
5 changed files with 264 additions and 31 deletions
+35
View File
@@ -45,6 +45,41 @@ której powstaje `https://<url_host_uuid>/<doc_uuid>`.
`show_repository_qr: true` wymaga `repository_url`. Adres podglądu `localhost`
nie powinien być publikowany na karcie.
### Status i wersja tasków w zakresie karty
Wiersze `front_page_scope.scope_table.rows[]` mogą opisywać stan opracowania
pojedynczego tasku. JSON przechowuje stabilny angielski klucz `status`, a
renderer pokazuje jego krótką polską etykietę w HTML i PDF.
| Klucz | Etykieta | Znaczenie |
|---|---|---|
| `not-started` | brak | task nie został jeszcze opracowany |
| `draft` | robocze | istnieje niepełna wersja robocza |
| `in-progress` | w trakcie | task jest aktualnie opracowywany |
| `review` | do przeglądu | materiał czeka na sprawdzenie |
| `ready` | opracowane | materiał jest sprawdzony i gotowy do użycia |
| `blocked` | zablokowane | dalsza praca wymaga rozwiązania zależności |
Pole `version` ma format `vNN.NN`, na przykład `v00.01`. Drugi człon
zwiększamy przy zaakceptowanej rewizji treści lub materiałów tasku. Pierwszy
człon zwiększamy, gdy zmienia się kontrakt dydaktyczny albo wymagany rezultat;
po takim zwiększeniu drugi człon zaczyna się od `00`.
```json
{
"chapter": "5.4",
"task": "Task04",
"idea_tex": "arytmetyka adresów, alokator",
"priority": "obowiązkowe",
"status": "in-progress",
"version": "v00.01",
"key": true
}
```
Status i wersję zmieniamy wyłącznie w źródłowym `card_source.json`; pliki
HTML, TeX i PDF pozostają artefaktami generatora.
### Efekty i kryteria
- `educational_requirements` — wymagania `WE`;
+23 -8
View File
@@ -1775,7 +1775,27 @@ function NeovimPanel({
);
}
function ScopeTableCell({ column, row }: { column: string; row: any }) {
if (column === "chapter" || column === "task" || column === "version") {
return <td><code>{row[column]}</code></td>;
}
if (column === "idea") {
return <td dangerouslySetInnerHTML={{ __html: row.idea_html }} />;
}
if (column === "status") {
return (
<td>
<span className="scope-status" data-status={row.status} title={row.status}>
{row.status_label}
</span>
</td>
);
}
return <td>{row[column]}</td>;
}
function FrontPage({ front }: { front: any }) {
const scopeColumns: string[] = front.scope_columns ?? ["chapter", "task", "idea", "priority"];
return (
<Sheet
header={front.header_html}
@@ -1805,14 +1825,9 @@ function FrontPage({ front }: { front: any }) {
key={`${row.chapter}-${row.task}`}
className={row.key ? "scope-row--key" : ""}
>
<td>
<code>{row.chapter}</code>
</td>
<td>
<code>{row.task}</code>
</td>
<td dangerouslySetInnerHTML={{ __html: row.idea_html }} />
<td>{row.priority}</td>
{scopeColumns.map(column => (
<ScopeTableCell key={column} column={column} row={row} />
))}
</tr>
))}
</tbody>
+50
View File
@@ -42,6 +42,9 @@
"title_block": {
"$ref": "#/$defs/titleBlock"
},
"front_page_scope": {
"$ref": "#/$defs/frontPageScope"
},
"dictionary_refs": {
"type": "object",
"properties": {
@@ -355,6 +358,53 @@
],
"additionalProperties": false
},
"frontPageScope": {
"type": "object",
"required": ["title", "content_tex"],
"properties": {
"title": { "type": "string", "minLength": 1 },
"content_tex": { "type": "string" },
"scope_title": { "type": "string", "minLength": 1 },
"scope_content_tex": { "type": "string" },
"scope_footer_tex": { "type": "string" },
"scope_table": { "$ref": "#/$defs/scopeTable" }
},
"additionalProperties": false
},
"scopeTable": {
"type": "object",
"required": ["rows"],
"properties": {
"headers": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"rows": {
"type": "array",
"items": { "$ref": "#/$defs/scopeTaskRow" }
}
},
"additionalProperties": false
},
"scopeTaskRow": {
"type": "object",
"required": ["chapter", "idea_tex", "priority"],
"properties": {
"chapter": { "type": "string", "minLength": 1 },
"task": { "type": "string" },
"idea_tex": { "type": "string" },
"priority": { "type": "string" },
"status": {
"enum": ["not-started", "draft", "in-progress", "review", "ready", "blocked"]
},
"version": {
"type": "string",
"pattern": "^v[0-9]{2}\\.[0-9]{2}$"
},
"key": { "type": "boolean" }
},
"additionalProperties": false
},
"sideMarginLayout": {
"type": "object",
"required": ["columns"],
+54
View File
@@ -16,6 +16,14 @@ SCHEMAS = ROOT / "schemas"
TEMPLATES = ROOT / "templates"
EXAMPLE_CARD = ROOT / "examples" / "card"
EXAMPLE_SERIES = ROOT / "examples" / "series" / "series.json"
sys.path.insert(0, str(ROOT))
from tools.render_card import ( # noqa: E402
SCOPE_TASK_STATUS_LABELS,
scope_table_columns,
scope_task_status_label,
scope_task_version,
)
def load_json(path: Path) -> dict:
@@ -47,6 +55,51 @@ def check_json_files() -> None:
assert series.get("cards")
def check_scope_task_metadata() -> None:
rows = [
{
"chapter": "5.4",
"task": "Task04",
"idea_tex": "alokator",
"priority": "obowiązkowe",
"status": "in-progress",
"version": "v00.01",
}
]
assert scope_table_columns(rows) == [
"chapter",
"task",
"idea",
"priority",
"status",
"version",
]
assert SCOPE_TASK_STATUS_LABELS == {
"not-started": "brak",
"draft": "robocze",
"in-progress": "w trakcie",
"review": "do przeglądu",
"ready": "opracowane",
"blocked": "zablokowane",
}
assert scope_task_status_label("in-progress") == "w trakcie"
assert scope_task_version("v00.01") == "v00.01"
for invalid in ("started", "done"):
try:
scope_task_status_label(invalid)
except ValueError:
pass
else:
raise AssertionError(f"status {invalid!r} nie został odrzucony")
for invalid in ("0.1", "v0.01", "v00.1"):
try:
scope_task_version(invalid)
except ValueError:
pass
else:
raise AssertionError(f"wersja {invalid!r} nie została odrzucona")
def check_optional_json_schema() -> None:
try:
import jsonschema
@@ -269,6 +322,7 @@ def check_external_references() -> None:
def main() -> int:
check_json_files()
check_scope_task_metadata()
check_optional_json_schema()
check_render()
check_section_assets()
+102 -23
View File
@@ -40,6 +40,15 @@ UUID_RE = re.compile(
UUID_PATH_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
)
SCOPE_TASK_STATUS_LABELS = {
"not-started": "brak",
"draft": "robocze",
"in-progress": "w trakcie",
"review": "do przeglądu",
"ready": "opracowane",
"blocked": "zablokowane",
}
SCOPE_TASK_VERSION_RE = re.compile(r"^v\d{2}\.\d{2}$")
def configure_card_root(card_root: Path) -> None:
@@ -850,25 +859,72 @@ def render_front_matter(card: dict) -> list[str]:
return lines
def scope_task_status_label(value: object) -> str:
status = str(value or "").strip()
if not status:
return ""
try:
return SCOPE_TASK_STATUS_LABELS[status]
except KeyError as error:
allowed = ", ".join(SCOPE_TASK_STATUS_LABELS)
raise ValueError(f"nieznany status tasku {status!r}; dozwolone: {allowed}") from error
def scope_task_version(value: object) -> str:
version = str(value or "").strip()
if version and not SCOPE_TASK_VERSION_RE.fullmatch(version):
raise ValueError(
f"niepoprawna wersja tasku {version!r}; oczekiwany format v00.01"
)
return version
def scope_table_columns(rows: list[dict]) -> list[str]:
columns = ["chapter"]
if any(str(row.get("task", "")).strip() for row in rows):
columns.append("task")
columns.extend(["idea", "priority"])
if any(str(row.get("status", "")).strip() for row in rows):
columns.append("status")
if any(str(row.get("version", "")).strip() for row in rows):
columns.append("version")
return columns
def scope_table_default_headers(columns: list[str]) -> list[str]:
labels = {
"chapter": "K&R",
"task": "Numer tasku",
"idea": "Najważniejsza idea",
"priority": "Priorytet",
"status": "Status",
"version": "Version",
}
return [labels[column] for column in columns]
def render_front_scope_table_tex(scope: dict) -> list[str]:
table = scope.get("scope_table") or {}
rows = table.get("rows") or []
if not rows:
return []
has_task_column = any(str(row.get("task", "")).strip() for row in rows)
default_headers = (
["K&R", "Numer tasku", "Najważniejsza idea", "Priorytet"]
if has_task_column
else ["K&R", "Najważniejsza idea", "Priorytet"]
)
columns = scope_table_columns(rows)
has_task_column = "task" in columns
has_status_column = "status" in columns
has_version_column = "version" in columns
default_headers = scope_table_default_headers(columns)
headers = table.get("headers") or default_headers
if len(headers) != len(default_headers):
headers = default_headers
column_spec = (
r"@{}>{\ttfamily}p{1.05cm}>{\ttfamily}p{1.55cm}X>{\raggedright\arraybackslash}p{3.25cm}@{}"
if has_task_column
else r"@{}>{\ttfamily}p{1.25cm}X>{\raggedright\arraybackslash}p{3.55cm}@{}"
)
column_parts = [r">{\ttfamily\raggedright\arraybackslash}p{0.85cm}"]
if has_task_column:
column_parts.append(r">{\ttfamily\raggedright\arraybackslash}p{1.45cm}")
column_parts.extend(["X", r">{\raggedright\arraybackslash}p{2.45cm}"])
if has_status_column:
column_parts.append(r">{\raggedright\arraybackslash}p{1.75cm}")
if has_version_column:
column_parts.append(r">{\ttfamily\raggedright\arraybackslash}p{1.50cm}")
column_spec = "@{}" + "".join(column_parts) + "@{}"
lines = [
r"\vspace{0.55em}",
rf"\small\begin{{tabularx}}{{\textwidth}}{{{column_spec}}}",
@@ -879,7 +935,12 @@ def render_front_scope_table_tex(scope: dict) -> list[str]:
if has_task_column:
cells.append(rf"\texttt{{{tex_escape(row.get('task', ''))}}}")
cells.extend([row.get("idea_tex", ""), tex_escape(row.get("priority", ""))])
if str(row.get("task", "")).strip().lower() == "task04":
if has_status_column:
cells.append(tex_escape(scope_task_status_label(row.get("status", ""))))
if has_version_column:
cells.append(rf"\texttt{{{tex_escape(scope_task_version(row.get('version', '')))}}}")
is_key = bool(row.get("key", str(row.get("task", "")).strip().lower() == "task04"))
if is_key:
framed_cells = []
for index, cell in enumerate(cells):
alignment = "|l|" if index == 0 else "l|"
@@ -3080,23 +3141,32 @@ def render_main_html(data: dict) -> str:
if scope.get("scope_title"):
table = scope.get("scope_table") or {}
rows = table.get("rows") or []
has_task_column = any(str(row.get("task", "")).strip() for row in rows)
default_headers = (
["K&R", "Numer tasku", "Najważniejsza idea", "Priorytet"]
if has_task_column
else ["K&R", "Najważniejsza idea", "Priorytet"]
)
columns = scope_table_columns(rows)
has_task_column = "task" in columns
has_status_column = "status" in columns
has_version_column = "version" in columns
default_headers = scope_table_default_headers(columns)
headers = table.get("headers") or default_headers
if len(headers) != len(default_headers):
headers = default_headers
scope_table_html = ""
if rows:
table_rows = "".join(
f'<tr{" class=\"scope-row--key\"" if str(row.get("task", "")).strip().lower() == "task04" else ""}>'
f'<tr{" class=\"scope-row--key\"" if bool(row.get("key", str(row.get("task", "")).strip().lower() == "task04")) else ""}>'
f'<td><code>{html_escape(row.get("chapter", ""))}</code></td>'
+ (f'<td><code>{html_escape(row.get("task", ""))}</code></td>' if has_task_column else "")
+ f'<td>{latex_inline_to_html(row.get("idea_tex", ""), equation_numbers, figure_numbers)}</td>'
+ f'<td>{html_escape(row.get("priority", ""))}</td></tr>'
+ f'<td>{html_escape(row.get("priority", ""))}</td>'
+ (
f'<td><span class="scope-status" data-status="{html_escape(row.get("status", ""))}">'
f'{html_escape(scope_task_status_label(row.get("status", "")))}</span></td>'
if has_status_column else ""
)
+ (
f'<td><code>{html_escape(scope_task_version(row.get("version", "")))}</code></td>'
if has_version_column else ""
)
+ '</tr>'
for row in rows
)
table_class = "scope-table scope-table--task" if has_task_column else "scope-table"
@@ -3133,7 +3203,7 @@ def render_main_html(data: dict) -> str:
.hero h1{font-size:24px;line-height:1.15;margin:0 0 4px;border-bottom:1px solid #111;padding-bottom:4px}.hero .byline{float:right;margin-top:-31px;font-size:15px}
.meta{clear:both;display:grid;grid-template-columns:148px minmax(0,1fr);gap:4px 18px;padding:34px 0 24px;border-bottom:1px solid #111;font-size:17px}.meta dt{color:#333}.meta dd{margin:0;font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace}
.front-scope{padding:0 0 3mm;border-bottom:1px solid var(--line-soft)}.front-scope h2{font-size:24px;line-height:1.15;margin:0 0 3mm}.front-scope p{font-size:16px}
.scope-table-wrap{margin:3mm 0;overflow:hidden}.scope-table{width:100%;border-collapse:collapse;font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:11px;line-height:1.22}.scope-table th,.scope-table td{border:0;border-bottom:1px solid #888;padding:4px 7px;text-align:left;vertical-align:top}.scope-table th{border-bottom:2px solid #555;background:#f3f3f3;font-weight:700}.scope-table th:first-child,.scope-table td:first-child{width:12%}.scope-table th:last-child,.scope-table td:last-child{width:34%}.scope-table--task th:first-child,.scope-table--task td:first-child{width:9%}.scope-table--task th:nth-child(2),.scope-table--task td:nth-child(2){width:14%}.scope-table--task th:last-child,.scope-table--task td:last-child{width:29%}.scope-table tr.scope-row--key td{font-weight:700;background:#f4f4f4;border-top:2px solid #111;border-bottom:2px solid #111}.scope-table tr.scope-row--key td:first-child{border-left:2px solid #111}.scope-table tr.scope-row--key td:last-child{border-right:2px solid #111}.scope-table td p{font:inherit;margin:0}.scope-table code{font-size:inherit}
.scope-table-wrap{margin:3mm 0;overflow:hidden}.scope-table{width:100%;border-collapse:collapse;font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:11px;line-height:1.22}.scope-table th,.scope-table td{border:0;border-bottom:1px solid #888;padding:4px 7px;text-align:left;vertical-align:top}.scope-table th{border-bottom:2px solid #555;background:#f3f3f3;font-weight:700}.scope-table th:first-child,.scope-table td:first-child{width:7%}.scope-table--task th:nth-child(2),.scope-table--task td:nth-child(2){width:11%}.scope-table--task th:nth-child(4),.scope-table--task td:nth-child(4){width:19%}.scope-table--task th:nth-child(5),.scope-table--task td:nth-child(5){width:14%}.scope-table--task th:nth-child(6),.scope-table--task td:nth-child(6){width:10%}.scope-table tr.scope-row--key td{font-weight:700;background:#f4f4f4;border-top:2px solid #111;border-bottom:2px solid #111}.scope-table tr.scope-row--key td:first-child{border-left:2px solid #111}.scope-table tr.scope-row--key td:last-child{border-right:2px solid #111}.scope-table td p{font:inherit;margin:0}.scope-table code{font-size:inherit}.scope-status{display:inline-block;white-space:nowrap;border:1px solid #aaa;border-radius:2px;padding:1px 4px;font-weight:600;background:#f5f5f5}.scope-status[data-status="in-progress"]{border-color:#307da1;background:#eaf5fa;color:#174f69}.scope-status[data-status="ready"]{border-color:#4c7d56;background:#edf6ee;color:#295b33}.scope-status[data-status="draft"],.scope-status[data-status="review"]{border-color:#9a7932;background:#faf4e5;color:#694f16}.scope-status[data-status="blocked"]{border-color:#9c4b4b;background:#faeded;color:#712f2f}
.card-section h2{font-size:24px;line-height:1.15;margin:0 0 4mm}.section-heading{display:flex;align-items:flex-start;gap:0}.section-heading .section-index{font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:23px;margin-right:12mm;font-weight:400}.section-heading .section-title{min-width:0}.task-identity{margin-left:auto;padding-left:22px;text-align:right;font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px;line-height:1.25;color:#555;white-space:nowrap}.task-identity strong,.task-identity-name,.task-identity code{display:block}.task-identity strong{font-size:12px;color:#111}.task-identity-name{max-width:240px;overflow:hidden;text-overflow:ellipsis}.task-identity code{font-size:9px;color:#777}
p{font-size:16px;margin:0 0 3mm}.pdf-main ul{font-size:16px;margin:2mm 0 3mm 7mm;padding:0}.pdf-main li{margin:1.2mm 0}
.pdf-margin{position:absolute;top:34mm;width:18mm;font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:5px;line-height:1.12;color:#333;max-height:245mm;overflow:hidden}.pdf-margin.left{left:2mm;text-align:left}.pdf-margin.right{right:2mm;text-align:left}
@@ -7182,6 +7252,11 @@ def react_card_model(data: dict) -> dict:
)
scope = data.get("front_page_scope", {}) or {}
scope_table = scope.get("scope_table", {}) or {}
scope_rows = scope_table.get("rows", []) or []
scope_columns = scope_table_columns(scope_rows)
scope_headers = list(scope_table.get("headers", []))
if len(scope_headers) != len(scope_columns):
scope_headers = scope_table_default_headers(scope_columns)
front = {
"header_html": render_page_header_html(data, 1, total_pages),
"left_margin_html": front_left,
@@ -7194,7 +7269,8 @@ def react_card_model(data: dict) -> dict:
"scope_html": render_latex_fragment_html(
str(scope.get("scope_content_tex", "")), equation_numbers, figure_numbers
),
"scope_headers": list(scope_table.get("headers", [])),
"scope_columns": scope_columns,
"scope_headers": scope_headers,
"scope_rows": [
{
"chapter": str(row.get("chapter", "")),
@@ -7203,9 +7279,12 @@ def react_card_model(data: dict) -> dict:
str(row.get("idea_tex", row.get("idea", ""))), equation_numbers, figure_numbers
),
"priority": str(row.get("priority", "")),
"status": str(row.get("status", "")),
"status_label": scope_task_status_label(row.get("status", "")),
"version": scope_task_version(row.get("version", "")),
"key": bool(row.get("key", str(row.get("task", "")).lower() == "task04")),
}
for row in scope_table.get("rows", []) or []
for row in scope_rows
],
}
section_models: list[dict] = []