From dc4ce1859371d74b23165a07eea6fa37992600f0 Mon Sep 17 00:00:00 2001
From: guyg1474 <340572510+guyg1474@users.noreply.github.com>
Date: Thu, 15 Oct 2026 18:05:26 +0200
Subject: [PATCH] =?UTF-8?q?Kanban-Archiv=20mit=20automatischem=20L=C3=B6sc?=
=?UTF-8?q?hen,=20Reise-Karte=20als=20Bild=20herunterladen?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
README.md | 18 ++-
backend/app/db.py | 16 ++-
backend/app/kanban.py | 72 ++++++++++-
backend/app/main.py | 6 +
backend/app/reisen.py | 46 ++++++-
frontend/src/components/mapExport.js | 175 +++++++++++++++++++++++++++
frontend/src/pages/KanbanBoard.jsx | 45 ++++++-
frontend/src/pages/Reisen.jsx | 115 ++++++++++++++++++
frontend/src/styles.css | 8 ++
9 files changed, 489 insertions(+), 12 deletions(-)
create mode 100644 frontend/src/components/mapExport.js
diff --git a/README.md b/README.md
index 53c0612..baa1d84 100644
--- a/README.md
+++ b/README.md
@@ -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/
diff --git a/backend/app/db.py b/backend/app/db.py
index 09d7dbe..0c60ebd 100644
--- a/backend/app/db.py
+++ b/backend/app/db.py
@@ -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)
diff --git a/backend/app/kanban.py b/backend/app/kanban.py
index e4f65c5..d339c19 100644
--- a/backend/app/kanban.py
+++ b/backend/app/kanban.py
@@ -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:
diff --git a/backend/app/main.py b/backend/app/main.py
index 2271986..fd9529b 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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)
diff --git a/backend/app/reisen.py b/backend/app/reisen.py
index f4a3292..16854f7 100644
--- a/backend/app/reisen.py
+++ b/backend/app/reisen.py
@@ -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 ----------
diff --git a/frontend/src/components/mapExport.js b/frontend/src/components/mapExport.js
new file mode 100644
index 0000000..040c843
--- /dev/null
+++ b/frontend/src/components/mapExport.js
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 ``;
+ }).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 ``;
+ }).join('');
+ const dots = points.map((p) =>
+ ``).join('');
+ const texts = labels.map((l) =>
+ `${esc(l.name)}`).join('');
+
+ const 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);
+}
diff --git a/frontend/src/pages/KanbanBoard.jsx b/frontend/src/pages/KanbanBoard.jsx
index f655970..a81569a 100644
--- a/frontend/src/pages/KanbanBoard.jsx
+++ b/frontend/src/pages/KanbanBoard.jsx
@@ -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 <>{error ? {error}
: Lade …
}>;
}
- 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) => )}
+
@@ -267,6 +279,35 @@ export default function KanbanBoard() {
})}
+ {showArchive && (
+
+
+ Archiv
+ Erledigtes kommt nach 2 Tagen hierher und wird nach 14 Tagen automatisch gelöscht.
+
+ {archived.length === 0 ? (
+
+ {data.archived.length ? 'Keine archivierten Aufgaben für diesen Bearbeiter-Filter.' : 'Noch nichts archiviert.'}
+
+ ) : (
+
+ {archived.map((task) => (
+ -
+
+
+
+ erledigt {fmtDate(task.doneAt)} · wird am {fmtDate(task.deleteAt)} gelöscht
+
+
+
+ ))}
+
+ )}
+
+ )}
+
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.
+ {shapes && visited.size > 0 && (
+
+ )}
+ {shapes && data && (
+
+ )}
{error && {error}
}
@@ -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 (
+
+ );
+}
+
// ---------- Besuchte Orte: Kontinent → Land (ausklappbar) → Städte ----------
const CONTINENTS = ['Europa', 'Asien', 'Afrika', 'Nordamerika', 'Südamerika', 'Ozeanien', 'Antarktis'];
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 0af2eaf..0443961 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -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; }