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 = ` + + + +${countries} +${leaders} +${dots} +${texts} +${esc(caption)} +`; + 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 ( + setState(null)}> +
+

Karte herunterladen

+

Deine Städte und besuchten Länder als Bild – Städte anderer Konten sind nicht dabei.

+
+ Ausschnitt +
    +
  • + +
  • +
  • + +
  • +
+
+
+ Format +
    +
  • + +
  • +
  • + +
  • +
+
+ {format === 'png' && ( + + )} + {format === 'png' && unknown > 0 && ( +

Bei {unknown} {unknown === 1 ? 'Stadt' : 'Städten'} wird gerade noch nachgeschlagen, ob es eine Hauptstadt ist – kurz warten und die Seite neu laden.

+ )} + {state && !busy &&

{state}

} +
+ + +
+
+
+ ); +} + // ---------- 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; }