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

+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'];