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 `
{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 && ( ++ {data.archived.length ? 'Keine archivierten Aufgaben für diesen Bearbeiter-Filter.' : 'Noch nichts archiviert.'} +
+ ) : ( +{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; }