Kanban-Archiv mit automatischem Löschen, Reise-Karte als Bild herunterladen

Kanban:
- Erledigte Aufgaben wandern 2 Tage nach "Erledigt" ins Archiv (Button
  "Archiv (n)" im Board, dort "Zurückholen") und werden nach 14 Tagen
  automatisch gelöscht (stündlich im Hintergrund und beim Öffnen)
- Neue Spalte done_at; schon erledigte Aufgaben zählen ab dem Update

Reisen:
- "Karte herunterladen": eigene Städte und besuchte Länder als PNG
  (4000/8000 px, nur besuchte Hauptstädte beschriftet) oder SVG (kleine
  Punkte, alle Städte beschriftet, beliebig zoombar); Ausschnitt "Meine
  Orte" oder ganze Welt; Beschriftungen weichen einander aus
- Hauptstadt-Kennzeichen (GeoNames PPLC) wird beim Hinzufügen gespeichert
  und für ältere Städte im Hintergrund bei Open-Meteo nachgetragen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-15 18:05:26 +02:00
1 parent f0e17b06b5
commit dc4ce18593
9 files changed
+489 -12

No files matched your search

+17 -1
View File
@@ -162,6 +162,17 @@ Der Schalter **Städte anderer zeigen** (über der Karte) blendet die Städte de
einzelne Personen lassen sich über ihre Schaltfläche aus- und einblenden (gemerkt pro Browser). Fremde
Städte kann man nur ansehen, nicht ändern. Alle angemeldeten Konten können die Städte der anderen sehen.
**⤓ Karte herunterladen** speichert die eigenen Städte und besuchten Länder als Bild; Ausschnitt
„Meine Orte“ (auf die Städte gezoomt) oder ganze Welt:
- **PNG** (4000 oder 8000 px breit): gut sichtbare Punkte, beschriftet sind nur besuchte **Hauptstädte**.
- **SVG** (beliebig scharf zoombar, z. B. zum Drucken): kleine Punkte und Schrift, **alle** Städte
beschriftet – dicht liegende Orte trennen sich beim Hineinzoomen.
Beschriftungen weichen einander aus, wenn nötig mit Linie zum Punkt. Farben kommen aus dem gewählten
Design; erstellt wird das Bild im Browser. Ob eine Stadt Hauptstadt ist, liefert die Städtesuche
(GeoNames-Merkmal „PPLC“); bei älteren Einträgen trägt der Server das im Hintergrund nach.
### Kanban
Beliebig viele Projekte mit je einem Board (Offen, In Arbeit, Wartet, Erledigt). Aufgaben
@@ -169,6 +180,11 @@ haben Titel, Beschreibung, Status und Bearbeiter; verschieben per Drag & Drop od
Projekte lassen sich mit Artikeln aus „P42 Projekte“ verknüpfen, abschließen, wieder öffnen
und löschen (löschen: wer angelegt hat und Administratoren).
Erledigte Aufgaben räumen sich selbst auf: **2 Tage** nach „Erledigt“ verschwinden sie ins **Archiv**
(Button „Archiv (n)“ im Board, dort auch „Zurückholen“), **14 Tage** nach „Erledigt“ werden sie
automatisch gelöscht. Zurück aus „Erledigt“ hält die Uhr an; „Zurückholen“ startet beide Fristen neu.
Gelöscht wird stündlich im Hintergrund und beim Öffnen eines Boards.
### Designs
Im Menü unter **Design** umschaltbar, gespeichert pro Browser:
@@ -213,7 +229,7 @@ backend/
app/haushalt.py Listen (je Vorrat + Einkauf) mit Sichtbarkeit, Produktkatalog, Einheiten
app/rezept.py Rezeptabgleich (Seite abrufen, Zutaten zerlegen, zuordnen)
app/reisen.py Reise-Karte, Städtesuche, Infos aus Wikipedia/Wikidata
app/kanban.py Kanban-Projekte, Aufgaben, Artikel-Verknüpfungen
app/kanban.py Kanban-Projekte, Aufgaben, Artikel-Verknüpfungen, Archiv + automatisches Löschen
app/tracking/ Paketdienst-Erkennung, 17TRACK, DHL
frontend/
+14 -2
View File
@@ -50,6 +50,7 @@ KANBAN_TASKS_TABLE = """(
assignee INTEGER REFERENCES users(id) ON DELETE SET NULL,
priority INTEGER NOT NULL DEFAULT 2 CHECK (priority IN (1, 2, 3)), -- 1 hoch, 2 mittel, 3 niedrig
position REAL NOT NULL,
done_at TEXT, -- seit wann "erledigt": nach 2 Tagen im Archiv, nach 14 Tagen gelöscht
created_at TEXT NOT NULL,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
updated_at TEXT NOT NULL,
@@ -164,6 +165,7 @@ CREATE TABLE IF NOT EXISTS travel_cities (
population INTEGER,
elevation REAL,
timezone TEXT,
capital INTEGER, -- Hauptstadt (GeoNames PPLC); NULL = noch nicht abgefragt
created_at TEXT NOT NULL,
UNIQUE (user_id, geoname_id)
);
@@ -303,9 +305,9 @@ def _migrate_checked(conn) -> None:
def _migrate_travel(conn) -> None:
# Reise-Städte aus einer Vorversion ohne Höhe/Zeitzone: Spalten ergänzen
# Reise-Städte aus einer Vorversion ohne Höhe/Zeitzone/Hauptstadt: Spalten ergänzen (Hauptstadt füllt reisen.py nach)
columns = {r["name"] for r in conn.execute("PRAGMA table_info(travel_cities)")}
for column, kind in (("elevation", "REAL"), ("timezone", "TEXT")):
for column, kind in (("elevation", "REAL"), ("timezone", "TEXT"), ("capital", "INTEGER")):
if column not in columns:
conn.execute(f"ALTER TABLE travel_cities ADD COLUMN {column} {kind}")
@@ -352,6 +354,15 @@ def _migrate_kanban_priority(conn) -> None:
)
def _migrate_kanban_done(conn) -> None:
"""Aufgaben ohne "erledigt seit": Spalte ergänzen. Schon erledigte Aufgaben zählen ab jetzt,
damit beim Update nichts sofort archiviert oder gelöscht wird."""
columns = {r["name"] for r in conn.execute("PRAGMA table_info(kanban_tasks)")}
if "done_at" not in columns:
conn.execute("ALTER TABLE kanban_tasks ADD COLUMN done_at TEXT")
conn.execute("UPDATE kanban_tasks SET done_at = ? WHERE status = 'erledigt'", (now_iso(),))
def _migrate_monitor_token(conn) -> None:
# Monitore aus der Zeit vor P42-S.M.: Spalte für den Token ergänzen
columns = {r["name"] for r in conn.execute("PRAGMA table_info(monitors)")}
@@ -402,6 +413,7 @@ def init() -> None:
_migrate_kanban_completed(conn)
_migrate_kanban(conn)
_migrate_kanban_priority(conn)
_migrate_kanban_done(conn)
_import_legacy(conn)
+68 -4
View File
@@ -3,9 +3,13 @@
Alles ist für alle Konten gemeinsam. Löschen dürfen nur, wer etwas angelegt hat, und Administratoren.
Projekte lassen sich als abgeschlossen markieren und mit Artikeln aus "P42 Projekte" verknüpfen.
`position` (Kommazahl) sortiert Projekte und Aufgaben innerhalb einer Spalte.
Erledigte Aufgaben wandern nach 2 Tagen ins Archiv und werden nach 14 Tagen gelöscht (gezählt ab "erledigt").
"""
import asyncio
import logging
import math
import re
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Body, Depends, HTTPException
@@ -22,6 +26,38 @@ MAX_DESCRIPTION = 5000
MAX_TASKS = 1000 # pro Projekt
MAX_PROJECTS = 300
MAX_LINKS = 20
ARCHIVE_AFTER = timedelta(days=2)
DELETE_AFTER = timedelta(days=14)
CLEANUP_SECONDS = 3600
log = logging.getLogger(__name__)
def _ago(delta: timedelta) -> str:
# Gleiches Format wie now_iso(), damit Textvergleich in SQLite stimmt
return (datetime.now(timezone.utc) - delta).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def _iso_after(iso: str, delta: timedelta) -> str:
return (datetime.fromisoformat(iso.replace("Z", "+00:00")) + delta).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def cleanup(conn) -> int:
"""Seit 14 Tagen erledigte Aufgaben löschen."""
return conn.execute(
"DELETE FROM kanban_tasks WHERE status = 'erledigt' AND done_at <= ?", (_ago(DELETE_AFTER),)
).rowcount
async def cleanup_loop() -> None:
"""Stündlich aufräumen, auch wenn niemand das Kanban öffnet."""
while True:
try:
with transaction() as conn:
cleanup(conn)
except Exception: # nie die Schleife beenden
log.exception("Kanban-Aufräumen fehlgeschlagen")
await asyncio.sleep(CLEANUP_SECONDS)
def _person(user_id, name) -> dict | None:
@@ -158,6 +194,7 @@ def _projects(conn, project_id=None) -> list:
def _overview(conn) -> dict:
cleanup(conn)
articles = conn.execute("SELECT id, title FROM projects ORDER BY title COLLATE NOCASE").fetchall()
return {
"projects": _projects(conn),
@@ -176,6 +213,7 @@ def _task(row) -> dict:
"assignee": row["assignee"],
"priority": PRIORITY_NAMES[row["priority"]],
"position": row["position"],
"doneAt": row["done_at"],
"createdAt": row["created_at"],
"createdBy": _person(row["created_by"], row["created_by_name"]),
"updatedAt": row["updated_at"],
@@ -188,6 +226,7 @@ def _board(conn, project_id: int) -> dict:
if not projects:
raise HTTPException(404, "Kanban-Projekt nicht gefunden – vielleicht wurde es gerade gelöscht.")
project = projects[0]
cleanup(conn)
tasks = conn.execute(
"""SELECT t.*, cu.display_name AS created_by_name, uu.display_name AS updated_by_name
FROM kanban_tasks t
@@ -197,9 +236,18 @@ def _board(conn, project_id: int) -> dict:
(project_id,),
).fetchall()
articles = conn.execute("SELECT id, title FROM projects ORDER BY title COLLATE NOCASE").fetchall()
archive_before = _ago(ARCHIVE_AFTER)
archived = sorted(
(r for r in tasks if r["status"] == "erledigt" and r["done_at"] and r["done_at"] <= archive_before),
key=lambda r: r["done_at"],
reverse=True,
)
archived_ids = {r["id"] for r in archived}
return {
"project": project,
"tasks": [_task(r) for r in tasks],
"tasks": [_task(r) for r in tasks if r["id"] not in archived_ids],
# Archiv: nur mit eigenem Button sichtbar; deleteAt = automatisches Löschen
"archived": [{**_task(r), "deleteAt": _iso_after(r["done_at"], DELETE_AFTER)} for r in archived],
"users": _users(conn),
"articles": [{"id": a["id"], "title": a["title"]} for a in articles],
}
@@ -310,10 +358,10 @@ def create_task(project_id: int, data: dict = Body(...), user: dict = Depends(cu
now = now_iso()
conn.execute(
"""INSERT INTO kanban_tasks (project_id, title, description, status, assignee, priority, position,
created_at, created_by, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
done_at, created_at, created_by, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(project_id, title, description, status, _assignee(conn, data.get("assignee")), priority,
_end_of(conn, project_id, status), now, user["id"], now, user["id"]),
_end_of(conn, project_id, status), now if status == "erledigt" else None, now, user["id"], now, user["id"]),
)
return _board(conn, project_id)
@@ -332,6 +380,9 @@ def update_task(task_id: int, data: dict = Body(...), user: dict = Depends(curre
values["assignee"] = _assignee(conn, data["assignee"])
if "status" in data:
values["status"] = _status(data["status"])
if values["status"] != row["status"]:
# Erledigt-Uhr starten bzw. beim Zurückholen aus "erledigt" anhalten
values["done_at"] = now_iso() if values["status"] == "erledigt" else None
if "priority" in data:
values["priority"] = _priority(data["priority"])
if "position" in data:
@@ -346,6 +397,19 @@ def update_task(task_id: int, data: dict = Body(...), user: dict = Depends(curre
return _board(conn, row["project_id"])
@router.post("/api/kanban/aufgaben/{task_id}/zurueckholen")
def restore_task(task_id: int, user: dict = Depends(current_user)):
"""Archivierte Aufgabe zurück in die Spalte "Erledigt"; Archiv- und Lösch-Frist beginnen neu."""
with transaction() as conn:
row = _get_task(conn, task_id)
now = now_iso()
conn.execute(
"UPDATE kanban_tasks SET status = 'erledigt', done_at = ?, updated_at = ?, updated_by = ? WHERE id = ?",
(now, now, user["id"], task_id),
)
return _board(conn, row["project_id"])
@router.delete("/api/kanban/aufgaben/{task_id}")
def delete_task(task_id: int, user: dict = Depends(current_user)):
with transaction() as conn:
+6
View File
@@ -22,8 +22,14 @@ from .tracking import track
async def lifespan(_: FastAPI):
# Verlauf der Server-Auslastung (P42-S.M.) im Hintergrund sammeln
sampler = asyncio.create_task(monitoring.sample_loop())
# Erledigte Kanban-Aufgaben nach 14 Tagen löschen
cleaner = asyncio.create_task(kanban.cleanup_loop())
# Hauptstadt-Kennzeichen älterer Reise-Städte nachtragen (für den Karten-Export)
capitals = asyncio.create_task(reisen.capitals_loop())
yield
sampler.cancel()
cleaner.cancel()
capitals.cancel()
app = FastAPI(title="P42 Dashboard", docs_url="/api/docs", openapi_url="/api/openapi.json", lifespan=lifespan)
+43 -3
View File
@@ -6,7 +6,9 @@ Städte werden über die Geocoding-API von Open-Meteo gesucht (Daten: GeoNames,
Infotexte kommen aus der deutschen Wikipedia, Einwohnerzahlen von Ländern aus Wikidata.
Alle Abrufe laufen über den Server und werden zwischengespeichert; der Browser fragt keine Fremdserver.
"""
import asyncio
import json
import logging
import math
import re
import time
@@ -31,7 +33,10 @@ CACHE_OK = 30 * 24 * 3600 # Infos ändern sich selten
CACHE_MISS = 24 * 3600 # nichts gefunden: morgen noch mal versuchen
COUNTRY_CODE = re.compile(r"[A-Z]{2}|X-[A-Za-z. ]{1,40}") # X-…: Gebiete ohne ISO-Code auf der Karte
CAPITALS_SECONDS = 6 * 3600
_search_cache: dict[str, list] = {}
log = logging.getLogger(__name__)
def _city(row) -> dict:
@@ -39,6 +44,7 @@ def _city(row) -> dict:
"id": row["id"], "geonameId": row["geoname_id"], "name": row["name"], "admin1": row["admin1"],
"country": row["country"], "lat": row["lat"], "lon": row["lon"], "population": row["population"],
"elevation": row["elevation"], "timezone": row["timezone"], "createdAt": row["created_at"],
"capital": None if row["capital"] is None else bool(row["capital"]),
}
@@ -107,18 +113,20 @@ def add_city(data: dict = Body(...), user: dict = Depends(current_user)):
timezone = data.get("timezone")
if not isinstance(timezone, str) or not re.fullmatch(r"[A-Za-z_]+(?:/[A-Za-z0-9_+-]+){0,2}", timezone):
timezone = None
# Hauptstadt kommt aus der Suche; fehlt die Angabe, trägt fill_capitals() sie nach
capital = int(data["capital"]) if isinstance(data.get("capital"), bool) else None
values = (
user["id"], geoname_id, _text(data.get("name"), "Name", 100), _text(data.get("admin1"), "Region", 100, False),
country, _number(data.get("lat"), "Breitengrad", -90, 90), _number(data.get("lon"), "Längengrad", -180, 180),
population, elevation, timezone, now_iso(),
population, elevation, timezone, capital, now_iso(),
)
with transaction() as conn:
if conn.execute("SELECT COUNT(*) FROM travel_cities WHERE user_id = ?", (user["id"],)).fetchone()[0] >= MAX_CITIES:
raise ValidationError(f"Maximal {MAX_CITIES} Städte.")
conn.execute(
"""INSERT INTO travel_cities
(user_id, geoname_id, name, admin1, country, lat, lon, population, elevation, timezone, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (user_id, geoname_id) DO NOTHING""",
(user_id, geoname_id, name, admin1, country, lat, lon, population, elevation, timezone, capital, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (user_id, geoname_id) DO NOTHING""",
values,
)
return _state(conn, user["id"])
@@ -180,6 +188,7 @@ async def search_city(q: str = "", _: dict = Depends(current_user)):
"geonameId": r["id"], "name": r["name"], "admin1": r.get("admin1") or "", "countryName": r.get("country") or "",
"country": r["country_code"], "lat": r["latitude"], "lon": r["longitude"], "population": r.get("population"),
"elevation": r.get("elevation"), "timezone": r.get("timezone"),
"capital": r.get("feature_code") == "PPLC",
}
for r in res.json().get("results", [])
if r.get("country_code") and r.get("id")
@@ -190,6 +199,37 @@ async def search_city(q: str = "", _: dict = Depends(current_user)):
return results
# ---------- Hauptstadt-Kennzeichen für ältere Einträge nachtragen ----------
async def fill_capitals() -> None:
"""Städte ohne Hauptstadt-Angabe bei Open-Meteo nachschlagen (GeoNames-Merkmal PPLC)."""
with transaction() as conn:
ids = [r[0] for r in conn.execute("SELECT DISTINCT geoname_id FROM travel_cities WHERE capital IS NULL")]
if not ids:
return
async with httpx.AsyncClient(timeout=TIMEOUT, headers=HEADERS) as client:
for geoname_id in ids:
try:
res = await client.get("https://geocoding-api.open-meteo.com/v1/get", params={"id": geoname_id})
res.raise_for_status()
capital = int(res.json().get("feature_code") == "PPLC")
except (httpx.HTTPError, ValueError):
continue # beim nächsten Durchlauf erneut versuchen
with transaction() as conn:
conn.execute("UPDATE travel_cities SET capital = ? WHERE geoname_id = ? AND capital IS NULL", (capital, geoname_id))
await asyncio.sleep(0.2) # Open-Meteo schonen
async def capitals_loop() -> None:
while True:
try:
await fill_capitals()
except Exception: # nie die Schleife beenden
log.exception("Hauptstädte nachtragen fehlgeschlagen")
await asyncio.sleep(CAPITALS_SECONDS)
# ---------- Infos aus Wikipedia / Wikidata ----------
+175
View File
@@ -0,0 +1,175 @@
// Reise-Karte als Bild exportieren: eigene Städte und besuchte Länder in hoher Auflösung.
// Gezeichnet wird ein eigenes SVG (Farben aus dem aktiven Design); daraus wird bei Bedarf ein PNG.
// SVG: kleine Punkte und Schrift, alle Städte beschriftet (man kann beliebig hineinzoomen).
// PNG: größere Punkte, beschriftet nur besuchte Hauptstädte (sonst wird es im festen Raster zu voll).
import { geoCircle, geoGraticule10, geoNaturalEarth1, geoPath } from 'd3-geo';
const FONT = 'Arial, Helvetica, sans-serif'; // Systemschrift: Webfonts lädt ein SVG-Bild nicht nach
const MAX_PIXELS = 64e6; // Browser-Grenze für Canvas sicher unterschreiten
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const n = (v) => Math.round(v * 10) / 10;
function themeColors() {
const style = getComputedStyle(document.documentElement);
const get = (name, fallback) => style.getPropertyValue(name).trim() || fallback;
const rgb = get('--accent-rgb', '255 70 70').split(/[\s,]+/).map(Number);
const accent = (a) => `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`;
return {
ocean: get('--map-ocean', '#050102'),
land: get('--map-land', '#1a0506'),
bg: get('--bg', '#080203'),
bright: get('--bright', '#ffffff'),
muted: get('--muted', '#999999'),
visited: accent(0.55),
visitedLine: accent(0.9),
line: accent(0.35),
grid: accent(0.1),
dot: accent(1),
};
}
// Beschriftungen nacheinander setzen: erst direkt neben den Punkt, sonst schrittweise weiter weg (mit Linie),
// bis sie weder andere Beschriftungen noch Punkte überdecken. Nur Punkte mit label werden beschriftet,
// alle Punkte bleiben aber Hindernisse.
function placeLabels(points, s, width, height) {
const ctx = document.createElement('canvas').getContext('2d');
const fs = 13 * s;
ctx.font = `600 ${fs}px ${FONT}`;
const r = 4.5 * s;
const h = fs * 1.25;
const gap = r + 3 * s;
const dirs = [[1, 0], [-1, 0], [1, -1], [1, 1], [-1, -1], [-1, 1], [0, -1], [0, 1]];
const steps = [1, 2.2, 3.8, 6, 9, 13, 18];
const placed = [];
const dots = points.map((p) => ({ x0: p.x - r, y0: p.y - r, x1: p.x + r, y1: p.y + r }));
const hits = (a, b) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
// Größere Städte bekommen die besten Plätze
const order = points.filter((p) => p.label).sort((a, b) => (b.population || 0) - (a.population || 0));
return order.map((p) => {
const w = ctx.measureText(p.name).width + 2 * s;
let best = null;
search: for (const step of steps) {
for (const [dx, dy] of dirs) {
const len = Math.hypot(dx, dy);
const ax = p.x + (dx / len) * gap * step;
const ay = p.y + (dy / len) * gap * step;
const x0 = dx > 0 ? ax : dx < 0 ? ax - w : ax - w / 2;
const y0 = dy > 0 ? ay : dy < 0 ? ay - h : ay - h / 2;
const box = { x0, y0, x1: x0 + w, y1: y0 + h };
if (box.x0 < 0 || box.y0 < 0 || box.x1 > width || box.y1 > height) continue;
if (placed.some((b) => hits(box, b)) || dots.some((d) => hits(box, d))) continue;
best = { box, step };
break search;
}
}
// Kein freier Platz: rechts daneben, notfalls überlappend
if (!best) best = { box: { x0: p.x + gap, y0: p.y - h / 2, x1: p.x + gap + w, y1: p.y + h / 2 }, step: 1 };
placed.push(best.box);
return { ...p, ...best };
});
}
/**
* area: 'orte' (auf die eigenen Städte zoomen) oder 'welt'. width: Breite in Pixeln.
* marks: Größe von Punkten, Linien und Schrift (1 = gut lesbar im Ganzen, kleiner = erst beim Zoomen).
* label: 'alle' oder 'hauptstaedte' (welche Städte beschriftet werden). Liefert { svg, width, height }.
*/
export function buildMapSvg({ shapes, visited, cities, area, width, caption, marks = 1, label = 'alle' }) {
const page = width / 2400; // Maßstab für Ränder und Fußzeile
const s = page * marks; // Maßstab für Punkte, Linien und Schrift
const pad = Math.round(50 * page);
const footer = Math.round(46 * page);
const focus = area === 'orte' && cities.length
? { type: 'GeometryCollection', geometries: cities.map((c) => geoCircle().center([c.lon, c.lat]).radius(1.2)()) }
: { type: 'Sphere' };
// Höhe aus dem Seitenverhältnis des Ausschnitts, in vernünftigen Grenzen
const probe = geoNaturalEarth1().fitWidth(width - 2 * pad, focus);
const [[, py0], [, py1]] = geoPath(probe).bounds(focus);
let height = Math.round(Math.min(Math.max(py1 - py0, width * 0.3), width * 1.3) + 2 * pad + footer);
const projection = geoNaturalEarth1().fitExtent([[pad, pad], [width - pad, height - pad - footer]], focus);
const path = geoPath(projection);
const c = themeColors();
const points = cities
.map((city) => {
const p = projection([city.lon, city.lat]);
return p && {
name: city.name, population: city.population, x: p[0], y: p[1],
label: label === 'alle' || city.capital === true,
};
})
.filter((p) => p && p.x >= 0 && p.x <= width && p.y >= 0 && p.y <= height - footer);
const labels = placeLabels(points, s, width, height - footer);
const lw = n(0.6 * s);
const countries = shapes.map((shape) => {
const d = path(shape.feature);
if (!d) return '';
const on = visited.has(shape.key);
return `<path d="${d}" fill="${on ? c.visited : c.land}" stroke="${on ? c.visitedLine : c.line}" stroke-width="${lw}"/>`;
}).join('');
const r = n(4.5 * s);
const fs = n(13 * s);
const leaders = labels.filter((l) => l.step > 1).map((l) => {
// Linie vom Punkt zur nächsten Kante der Beschriftung
const tx = Math.max(l.box.x0, Math.min(l.x, l.box.x1));
const ty = Math.max(l.box.y0, Math.min(l.y, l.box.y1));
return `<line x1="${n(l.x)}" y1="${n(l.y)}" x2="${n(tx)}" y2="${n(ty)}" stroke="${c.bright}" stroke-opacity="0.7" stroke-width="${n(1 * s)}"/>`;
}).join('');
const dots = points.map((p) =>
`<circle cx="${n(p.x)}" cy="${n(p.y)}" r="${r}" fill="${c.bright}" stroke="${c.dot}" stroke-width="${n(2 * s)}"/>`).join('');
const texts = labels.map((l) =>
`<text x="${n(l.box.x0 + s)}" y="${n(l.box.y0 + fs * 0.98)}">${esc(l.name)}</text>`).join('');
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<rect width="100%" height="100%" fill="${c.bg}"/>
<path d="${path({ type: 'Sphere' })}" fill="${c.ocean}"/>
<path d="${path(geoGraticule10())}" fill="none" stroke="${c.grid}" stroke-width="${lw}"/>
<g stroke-linejoin="round">${countries}</g>
<g>${leaders}</g>
<g>${dots}</g>
<g font-family="${FONT}" font-size="${fs}" font-weight="600" fill="${c.bright}" stroke="${c.bg}" stroke-width="${n(3.2 * s)}" stroke-linejoin="round" paint-order="stroke">${texts}</g>
<text x="${pad}" y="${height - Math.round(18 * page)}" font-family="${FONT}" font-size="${n(15 * page)}" fill="${c.muted}">${esc(caption)}</text>
</svg>`;
return { svg, width, height };
}
/** Wie buildMapSvg, aber fürs PNG so verkleinert, dass der Browser das Bild noch zeichnen kann. */
export function buildMapImage(options) {
const out = buildMapSvg(options);
if (out.width * out.height <= MAX_PIXELS) return out;
return buildMapSvg({ ...options, width: Math.floor(Math.sqrt(MAX_PIXELS * (out.width / out.height))) });
}
export async function svgToPng(svg, width, height) {
const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
try {
const img = new Image();
img.src = url;
await img.decode();
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
return await new Promise((resolve, reject) => {
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('Bild zu groß für diesen Browser.'))), 'image/png');
});
} finally {
URL.revokeObjectURL(url);
}
}
export function download(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.append(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
+43 -2
View File
@@ -49,6 +49,7 @@ export default function KanbanBoard() {
const [filter, setFilter] = useState(loadFilter);
const [editing, setEditing] = useState(null); // null | { task } | { status } (neu)
const [drag, setDrag] = useState(null); // { id, status, index } während Drag & Drop
const [showArchive, setShowArchive] = useState(false);
const dialog = useRef(null);
const projectDialog = useRef(null);
const [projectForm, setProjectForm] = useState(null);
@@ -128,7 +129,7 @@ export default function KanbanBoard() {
}
async function deleteProject() {
const n = data.tasks.length;
const n = data.tasks.length + data.archived.length;
if (!window.confirm(`Kanban-Projekt „${project.name}“${n ? ` mit ${n} ${n === 1 ? 'Aufgabe' : 'Aufgaben'}` : ''} endgültig löschen?`)) return;
try {
await api('DELETE', `/api/kanban/projekte/${project.id}`);
@@ -141,11 +142,13 @@ export default function KanbanBoard() {
if (!data) {
return <><BackLink />{error ? <p className="error">{error}</p> : <p className="hint">Lade …</p>}</>;
}
const visible = (data?.tasks || []).filter((t) =>
const matches = (t) => (
filter === 'alle' ? true
: filter === 'meine' ? t.assignee === user.id
: filter === 'niemand' ? t.assignee == null
: t.assignee === Number(filter));
const visible = (data?.tasks || []).filter(matches);
const archived = (data?.archived || []).filter(matches);
const byColumn = Object.fromEntries(COLUMNS.map((c) => [c.key, visible.filter((t) => t.status === c.key).sort(byPriority)]));
// ---------- Drag & Drop (Desktop) ----------
@@ -214,6 +217,15 @@ export default function KanbanBoard() {
{users.filter((u) => u.id !== user.id).map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
</label>
<button
className="btn ghost"
type="button"
aria-expanded={showArchive}
aria-controls="kanban-archive"
onClick={() => setShowArchive((v) => !v)}
>
Archiv ({data.archived.length})
</button>
<button className="btn" type="button" onClick={() => open({ status: 'offen' })}>+ Neue Aufgabe</button>
</div>
</div>
@@ -267,6 +279,35 @@ export default function KanbanBoard() {
})}
</div>
{showArchive && (
<section className="card kanban-archive" id="kanban-archive" aria-labelledby="archive-title">
<header className="household-head">
<h2 id="archive-title">Archiv</h2>
<span className="hint">Erledigtes kommt nach 2 Tagen hierher und wird nach 14 Tagen automatisch gelöscht.</span>
</header>
{archived.length === 0 ? (
<p className="hint">
{data.archived.length ? 'Keine archivierten Aufgaben für diesen Bearbeiter-Filter.' : 'Noch nichts archiviert.'}
</p>
) : (
<ul className="archive-list">
{archived.map((task) => (
<li key={task.id} className="archive-item">
<button type="button" className="archive-title" onClick={() => open({ task })}>{task.title}</button>
<Assignee id={task.assignee} users={users} />
<small className="archive-dates">
erledigt {fmtDate(task.doneAt)} · wird am {fmtDate(task.deleteAt)} gelöscht
</small>
<button className="link-btn" type="button" onClick={() => run('POST', `/api/kanban/aufgaben/${task.id}/zurueckholen`)}>
Zurückholen
</button>
</li>
))}
</ul>
)}
</section>
)}
<TaskDialog
key={`aufgabe-${editing?.opened ?? 'zu'}`}
ref={dialog}
+115
View File
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { feature } from 'topojson-client';
import { api } from '../api.js';
import { useAuth } from '../auth.jsx';
import { buildMapImage, download, svgToPng } from '../components/mapExport.js';
import WorldMap from '../components/WorldMap.jsx';
import LAENDER from '../data/laender.json';
@@ -52,6 +54,7 @@ export default function Reisen() {
const [showOthers, setShowOthers] = useState(() => loadFlag(OTHERS_KEY));
const [hiddenPeople, setHiddenPeople] = useState(() => loadSet(HIDDEN_KEY));
const map = useRef(null);
const exportDialog = useRef(null);
useEffect(() => {
api('GET', '/api/reisen').then(setData).catch((e) => setError(e.message));
@@ -161,7 +164,13 @@ export default function Reisen() {
{' '}· Klick auf ein Land oder einen Punkt zeigt Infos.
</p>
</div>
{shapes && visited.size > 0 && (
<button className="link-btn" type="button" onClick={() => exportDialog.current.showModal()}>⤓ Karte herunterladen</button>
)}
</div>
{shapes && data && (
<ExportDialog ref={exportDialog} shapes={shapes} visited={visited} cities={cities} />
)}
<CitySearch cities={cities} onAdd={addCity} busy={busy} />
{error && <p className="error travel-error" role="alert">{error}</p>}
@@ -256,6 +265,112 @@ export default function Reisen() {
);
}
// ---------- Karte als Bild herunterladen (nur eigene Orte) ----------
const SIZES = [
{ width: 4000, title: 'Mittel (4000 px breit)' },
{ width: 8000, title: 'Groß (8000 px breit) – zum Hineinzoomen' },
];
function ExportDialog({ ref, shapes, visited, cities }) {
const { user } = useAuth();
const [area, setArea] = useState(cities.length ? 'orte' : 'welt');
const [format, setFormat] = useState('png');
const [width, setWidth] = useState(8000);
const [state, setState] = useState(null); // null | 'busy' | Fehlermeldung
async function save(e) {
e.preventDefault();
setState('busy');
// Kurz Luft lassen, damit "Erstelle Bild …" erscheint, bevor der Browser rechnet
await new Promise((resolve) => { setTimeout(resolve, 30); });
try {
const date = new Date();
const caption = `Reisen · ${user.displayName} · ${visited.size} ${visited.size === 1 ? 'Land' : 'Länder'} · `
+ `${cities.length} ${cities.length === 1 ? 'Stadt' : 'Städte'} · Stand ${date.toLocaleDateString('de-DE')}`
+ ' · Karte: Natural Earth';
const name = `reisekarte-${user.username.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${date.toISOString().slice(0, 10)}`;
// SVG: klein und vollständig beschriftet (zoombar); PNG: nur Hauptstädte beschriftet
const options = format === 'svg'
? { shapes, visited, cities, area, caption, width: 2400, marks: 0.4, label: 'alle' }
: { shapes, visited, cities, area, caption, width, marks: 1, label: 'hauptstaedte' };
if (format === 'svg') {
const { svg } = buildMapImage(options);
download(new Blob([svg], { type: 'image/svg+xml' }), `${name}.svg`);
} else {
const out = buildMapImage(options);
download(await svgToPng(out.svg, out.width, out.height), `${name}.png`);
}
setState(null);
ref.current.close();
} catch (err) {
setState(err.message || 'Das Bild konnte nicht erstellt werden.');
}
}
const busy = state === 'busy';
const capitals = cities.filter((c) => c.capital === true).length;
const unknown = cities.filter((c) => c.capital == null).length;
return (
<dialog className="modal" ref={ref} onClose={() => setState(null)}>
<form className="form" onSubmit={save}>
<h2>Karte herunterladen</h2>
<p className="hint">Deine Städte und besuchten Länder als Bild – Städte anderer Konten sind nicht dabei.</p>
<fieldset className="link-picker">
<legend>Ausschnitt</legend>
<ul>
<li>
<label className="checkbox">
<input type="radio" name="area" checked={area === 'orte'} disabled={!cities.length} onChange={() => setArea('orte')} />
Meine Orte (auf die Städte gezoomt)
</label>
</li>
<li>
<label className="checkbox">
<input type="radio" name="area" checked={area === 'welt'} onChange={() => setArea('welt')} />
Ganze Welt
</label>
</li>
</ul>
</fieldset>
<fieldset className="link-picker">
<legend>Format</legend>
<ul>
<li>
<label className="checkbox">
<input type="radio" name="format" checked={format === 'png'} onChange={() => setFormat('png')} />
PNG-Bild – beschriftet sind nur besuchte Hauptstädte ({capitals})
</label>
</li>
<li>
<label className="checkbox">
<input type="radio" name="format" checked={format === 'svg'} onChange={() => setFormat('svg')} />
SVG – alle Städte beschriftet, beliebig scharf zoombar (z. B. zum Drucken)
</label>
</li>
</ul>
</fieldset>
{format === 'png' && (
<label>
Größe
<select value={width} onChange={(e) => setWidth(Number(e.target.value))}>
{SIZES.map((s) => <option key={s.width} value={s.width}>{s.title}</option>)}
</select>
</label>
)}
{format === 'png' && unknown > 0 && (
<p className="hint">Bei {unknown} {unknown === 1 ? 'Stadt' : 'Städten'} wird gerade noch nachgeschlagen, ob es eine Hauptstadt ist – kurz warten und die Seite neu laden.</p>
)}
{state && !busy && <p className="error">{state}</p>}
<div className="form-actions">
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Abbrechen</button>
<button className="btn" type="submit" disabled={busy}>{busy ? 'Erstelle Bild …' : 'Herunterladen'}</button>
</div>
</form>
</dialog>
);
}
// ---------- Besuchte Orte: Kontinent → Land (ausklappbar) → Städte ----------
const CONTINENTS = ['Europa', 'Asien', 'Afrika', 'Nordamerika', 'Südamerika', 'Ozeanien', 'Antarktis'];
+8
View File
@@ -1745,5 +1745,13 @@ html.vt-items .household-item { view-transition-name: var(--vt); }
}
html.calm .tile:hover, html.calm .project-card:hover, html.calm .kanban-project-card:hover, html.calm .task-card:hover { transform: none; }
/* Kanban-Archiv */
.kanban-archive { margin-top: clamp(16px, 2vw, 28px); display: flex; flex-direction: column; gap: 12px; }
.archive-list { list-style: none; margin: 0; padding: 0; }
.archive-item { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 14px; padding: 10px 0; border-top: 1px dashed var(--border); }
.archive-title { flex: 1 1 220px; min-width: 0; text-align: left; font: inherit; font-weight: 600; color: var(--bright); background: none; border: 0; padding: 0; cursor: pointer; text-decoration: line-through; text-decoration-color: var(--muted); }
.archive-title:hover { color: var(--accent); }
.archive-dates { color: var(--muted); font-size: 0.78rem; }
/* Auswahlknöpfe (z. B. Sichtbarkeit einer Einkaufsliste) nicht wie Eingabefelder gestalten */
.form label.checkbox input[type='radio'] { width: 18px; height: 18px; min-height: 0; padding: 0; margin: 0; border: 0; background: none; box-shadow: none; accent-color: var(--accent); flex: none; }