diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock
new file mode 100644
index 0000000..f6fdb0d
--- /dev/null
+++ b/.claude/scheduled_tasks.lock
@@ -0,0 +1 @@
+{"sessionId":"39ad9ffb-6cac-40b2-8c2d-b3974db3a4b8","pid":1946,"procStart":"Sat Apr 25 14:22:02 2026","acquiredAt":1777133270339}
\ No newline at end of file
diff --git a/PROJEKT.md b/PROJEKT.md
index 246ea6b..da216fe 100644
--- a/PROJEKT.md
+++ b/PROJEKT.md
@@ -46,6 +46,50 @@ Maps: Leaflet.js + OpenStreetMap (kostenlos, kein Google-Lock)
---
+## Implementierungsstand (aktuell: 2026-04-25, SW by-v405, APP_VER 385)
+
+### Sprint 12+13 (2026-04-25) ✅
+
+#### Tagebuch — Day-One-Redesign
+- Listenansicht: Wochentag-Kürzel + große Tageszahl links, Titel fett, Textvorschau, Meta-Zeile (Zeit · Ort · Wetter), Thumbnail quadratisch rechts
+- Vier Ansichten: Liste, Medien (3-Spalten-Mosaik), Kalender (mit Fotos auf Tagen), Karte (Leaflet alle GPS-Standorte)
+- Kalender: Doppelpfeil-Sprungbuttons «/» zum nächsten Monat mit Einträgen
+- Karten-Ansicht: Foto-Marker, Popup-Vorschau, Klick öffnet Eintrag direkt
+- Detail-Ansicht: inline im Content-Bereich (nicht mehr als Fullscreen-Overlay), Sidebar bleibt sichtbar
+- Detail: Hero-Foto (vollständig sichtbar, object-fit:contain), Thumbnails, 2-Spalten-Layout Desktop (Text + Karte/POI)
+- Detail: Karte zeigt GPS-Position, POI-Liste darunter ("In der Nähe")
+- Lightbox: Back-Button + Prev/Next in Bottom-Bar, Safe-Area für Querformat
+- Stats-Leiste: Einträge/Medien/Tage (Gesamtzahlen vom Backend), View-Switcher, kompakt auf Mobile
+- "Weitere laden" nur in Listenansicht sichtbar
+
+#### Tagebuch — Daten & Import
+- EXIF-GPS-Extraktion beim Foto-Upload (Pillow), auto-Wetter+POI bei GPS aus EXIF
+- Wetter (Open-Meteo Archive-API historisch): 106 Einträge retroaktiv angereichert
+- POIs (osm_pois DB-Cache, 437k Einträge): 85 Einträge retroaktiv angereichert
+- NoteStation-Import Fix: Fotos in diary_media statt altem media_url-Feld
+- Migration: 80 importierte media_url-Einträge in diary_media (94 statt 15 Medien für Ban Yaro)
+- Neue API-Endpoints: /diary/stats, /diary/calendar, /diary/locations
+
+#### Notiz-Feature
+- Generische notes-Tabelle (parent_type + parent_id + meta_json)
+- REST-API /api/notes mit GET/POST/PATCH/DELETE
+- API.notes in api.js
+- 📝-Button in: Übungen, Gesundheit, Tagebuch, Routen, Events, Gassi-Treffen, Sitting, Erste Hilfe
+- Notizblock-Seite: Filter nach Rubrik, Suche, Sortierung, KI-Muster-Erkennung (abschaltbar)
+- KI-Toggle in Einstellungen
+
+#### Design & Icons
+- fill:currentColor Fix für SVGs ohne ph-icon-Klasse (welcome.js, onboarding.js, friends.js)
+- --c-icon CSS-Variable, --c-text-muted in Dark Mode aufgehellt (#9A8878)
+- 15+ neue Phosphor-Icons: note-pencil, images, caret-left/right/double, coffee, bed, tree, church, etc.
+- Phosphor-Workflow: fill-Variante aus lokaler Kopie /icons/phosphor-icons/SVGs/fill/
+
+#### Infrastruktur
+- CSS Network-First im Service Worker (kein iOS-Caching-Problem mehr)
+- Cache-Control-Middleware: versioned URLs immutable, andere no-cache
+- Python open(w)-vor-read Bug dokumentiert (leert Datei)
+- Scheduler: Wiki-Anreicherungs-Jobs entfernt (abgeschlossen)
+
## Implementierungsstand (aktuell: 2026-04-25, SW by-v370, APP_VER 355)
### Sprint 11 (2026-04-25) ✅
@@ -222,7 +266,7 @@ Maps: Leaflet.js + OpenStreetMap (kostenlos, kein Google-Lock)
#### 1.2 Gesundheit & Impfpass
- [ ] Impfungen, Entwurmungen, Tierarztbesuche digital
- [ ] Medikamenten-Reminder (Push Notification)
-- [ ] Gewichtsverlauf-Chart
+- [x] Gewichtsverlauf-Chart ✅
- [ ] Einfacher Symptom-Checker (KI-gestützt, Triage: beobachten/Tierarzt/Notfall)
#### 1.3 Giftköder-Alarm
diff --git a/backend/auth.py b/backend/auth.py
index 9e01700..beedb65 100644
--- a/backend/auth.py
+++ b/backend/auth.py
@@ -87,7 +87,7 @@ def get_current_user(
user_id = int(payload["sub"])
with db() as conn:
row = conn.execute(
- "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason FROM users WHERE id=?",
+ "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled FROM users WHERE id=?",
(user_id,)
).fetchone()
diff --git a/backend/database.py b/backend/database.py
index 4d656d1..e4c43ed 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -534,6 +534,13 @@ def _migrate(conn_factory):
("pflege_tipps", "fell_pflege_art", "TEXT"),
# Wiki-Foto-Einreichungen: Bildrechte-Bestätigung
("wiki_foto_submissions", "rights_confirmed", "INTEGER NOT NULL DEFAULT 0"),
+ # Tagebuch: Wetter + POI-Metadaten beim Eintrag
+ ("diary", "weather_json", "TEXT"),
+ ("diary", "poi_json", "TEXT"),
+ # Notizen: Ort + Label + KI-Assistent User-Setting
+ ("notes", "location_name", "TEXT"),
+ ("notes", "parent_label", "TEXT"),
+ ("users", "notes_ki_enabled", "INTEGER NOT NULL DEFAULT 1"),
]
with conn_factory() as conn:
for table, column, col_type in migrations:
@@ -1131,3 +1138,22 @@ def _migrate(conn_factory):
CREATE INDEX IF NOT EXISTS idx_ki_daily_source ON ki_daily_calls(date, source);
""")
logger.info("Migration: ki_daily_calls.source bereit.")
+
+ # Notizen: generische polymorphe Notiz-Tabelle
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS notes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ parent_type TEXT NOT NULL,
+ parent_id INTEGER NOT NULL,
+ text TEXT NOT NULL DEFAULT '',
+ meta_json TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+ CREATE INDEX IF NOT EXISTS idx_notes_parent
+ ON notes(parent_type, parent_id, created_at DESC);
+ CREATE INDEX IF NOT EXISTS idx_notes_user
+ ON notes(user_id, created_at DESC);
+ """)
+ logger.info("Migration: notes Tabelle bereit.")
diff --git a/backend/main.py b/backend/main.py
index 4cfad18..d9e0d71 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -80,6 +80,24 @@ class _UploadSizeMiddleware(BaseHTTPMiddleware):
app.add_middleware(_UploadSizeMiddleware)
+class _CacheControlMiddleware(BaseHTTPMiddleware):
+ """Setzt Cache-Control-Header für statische Assets.
+ CSS/JS: no-cache (ETag-Validierung) — iOS cached sonst ewig ohne Ablaufdatum.
+ Versioned Assets (?v=…): immutable — URL ändert sich bei Updates.
+ """
+ async def dispatch(self, request: Request, call_next):
+ response = await call_next(request)
+ path = request.url.path
+ if path.startswith(("/css/", "/js/", "/icons/phosphor.svg")):
+ if "v=" in str(request.url.query):
+ response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
+ else:
+ response.headers["Cache-Control"] = "no-cache, must-revalidate"
+ return response
+
+app.add_middleware(_CacheControlMiddleware)
+
+
# ------------------------------------------------------------------
# API-Router registrieren (werden nach und nach hinzugefügt)
# ------------------------------------------------------------------
@@ -122,6 +140,7 @@ from routes.praise import router as praise_router
from routes.weather import router as weather_router
from routes.social import router as social_router
from routes.moderation import router as moderation_router
+from routes.notes import router as notes_router
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@@ -163,6 +182,7 @@ app.include_router(achievements_router, prefix="/api/achievements", tags=
app.include_router(training_router, prefix="/api/training", tags=["Training"])
app.include_router(praise_router, prefix="/api/praise", tags=["Praise"])
app.include_router(moderation_router, prefix="/api/moderation", tags=["Moderation"])
+app.include_router(notes_router, prefix="/api/notes", tags=["Notes"])
# ------------------------------------------------------------------
diff --git a/backend/media_utils.py b/backend/media_utils.py
index 4b70300..10f1f5f 100644
--- a/backend/media_utils.py
+++ b/backend/media_utils.py
@@ -117,6 +117,36 @@ def to_mp4_if_needed(data: bytes, filename: str) -> Tuple[bytes, str]:
pass
+def extract_gps_from_exif(data: bytes) -> tuple | None:
+ """EXIF-GPS aus Bilddaten lesen. Gibt (lat, lon) zurück oder None."""
+ try:
+ from PIL import Image
+ img = Image.open(io.BytesIO(data))
+ exif = img._getexif()
+ if not exif:
+ return None
+ gps = exif.get(34853) # GPSInfo tag
+ if not gps:
+ return None
+ lat_dms = gps.get(2)
+ lon_dms = gps.get(4)
+ lat_ref = gps.get(1, 'N')
+ lon_ref = gps.get(3, 'E')
+ if not lat_dms or not lon_dms:
+ return None
+
+ def dms(v):
+ return float(v[0]) + float(v[1]) / 60 + float(v[2]) / 3600
+
+ lat = dms(lat_dms) * (-1 if lat_ref == 'S' else 1)
+ lon = dms(lon_dms) * (-1 if lon_ref == 'W' else 1)
+ if not (-90 <= lat <= 90 and -180 <= lon <= 180):
+ return None
+ return round(lat, 6), round(lon, 6)
+ except Exception:
+ return None
+
+
def convert_media(data: bytes, filename: str) -> Tuple[bytes, str]:
"""Convert HEIC→JPEG and MOV/AVI/M4V→MP4; pass everything else through."""
ext = os.path.splitext(filename or "")[1].lower()
diff --git a/backend/routes/diary.py b/backend/routes/diary.py
index b469bbe..e2ead8f 100644
--- a/backend/routes/diary.py
+++ b/backend/routes/diary.py
@@ -1,14 +1,17 @@
"""BAN YARO — Tagebuch Routes"""
-import os, uuid, json, math
+import os, uuid, json, math, logging
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from typing import Optional
from database import db
-from auth import get_current_user
+from auth import get_current_user, require_admin
import ki as KI
import httpx
-from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload
+import weather as weather_mod
+from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload, extract_gps_from_exif
+
+logger = logging.getLogger(__name__)
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
@@ -142,6 +145,69 @@ def _entry_dict(row, dog_ids_map: dict, media_map: dict = None) -> dict:
return e
+@router.get("/{dog_id}/diary/stats")
+async def diary_stats(dog_id: int, user=Depends(get_current_user)):
+ """Gesamtstatistik für das Tagebuch (unabhängig von Pagination)."""
+ with db() as conn:
+ _can_read_dog(dog_id, user["id"], conn)
+ total = conn.execute(
+ "SELECT COUNT(*) FROM diary d LEFT JOIN diary_dogs dd ON dd.diary_id=d.id "
+ "WHERE (d.dog_id=? OR dd.dog_id=?)", (dog_id, dog_id)
+ ).fetchone()[0]
+ photos = conn.execute(
+ "SELECT COUNT(*) FROM diary_media dm "
+ "JOIN diary d ON d.id=dm.diary_id LEFT JOIN diary_dogs dd ON dd.diary_id=d.id "
+ "WHERE (d.dog_id=? OR dd.dog_id=?)", (dog_id, dog_id)
+ ).fetchone()[0]
+ days = conn.execute(
+ "SELECT COUNT(DISTINCT d.datum) FROM diary d LEFT JOIN diary_dogs dd ON dd.diary_id=d.id "
+ "WHERE d.datum IS NOT NULL AND (d.dog_id=? OR dd.dog_id=?)", (dog_id, dog_id)
+ ).fetchone()[0]
+ return {"entries": total, "photos": photos, "days": days}
+
+
+@router.get("/{dog_id}/diary/calendar")
+async def diary_calendar(dog_id: int, user=Depends(get_current_user)):
+ """Alle Einträge minimal für Kalenderansicht: id, datum, cover_url."""
+ with db() as conn:
+ _can_read_dog(dog_id, user["id"], conn)
+ rows = conn.execute(
+ """SELECT DISTINCT d.id, d.datum,
+ (SELECT dm.url FROM diary_media dm
+ WHERE dm.diary_id=d.id AND dm.media_type='image'
+ ORDER BY dm.is_cover DESC, dm.sort_order LIMIT 1) AS cover_url
+ FROM diary d
+ LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
+ WHERE (d.dog_id=? OR dd.dog_id=?)
+ AND d.datum IS NOT NULL
+ ORDER BY d.datum DESC""",
+ (dog_id, dog_id)
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+
+@router.get("/{dog_id}/diary/locations")
+async def diary_locations(dog_id: int, user=Depends(get_current_user)):
+ """Alle Tagebucheinträge mit GPS — minimal für Karten-Ansicht."""
+ with db() as conn:
+ _can_read_dog(dog_id, user["id"], conn)
+ rows = conn.execute(
+ """SELECT DISTINCT d.id, d.datum, d.titel, d.gps_lat, d.gps_lon,
+ d.location_name, d.weather_json,
+ (SELECT dm.url FROM diary_media dm
+ WHERE dm.diary_id=d.id AND dm.media_type='image'
+ ORDER BY dm.is_cover DESC, dm.sort_order LIMIT 1) AS cover_url,
+ (SELECT COUNT(*) FROM diary_media dm WHERE dm.diary_id=d.id) AS media_count
+ FROM diary d
+ LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
+ WHERE (d.dog_id=? OR dd.dog_id=?)
+ AND d.gps_lat IS NOT NULL AND d.gps_lon IS NOT NULL
+ ORDER BY d.datum DESC""",
+ (dog_id, dog_id)
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+
@router.get("/{dog_id}/diary")
async def list_diary(dog_id: int, limit: int = 20, offset: int = 0,
q: Optional[str] = None, milestone: int = 0,
@@ -226,10 +292,95 @@ async def create_diary(dog_id: int, data: DiaryCreate,
_set_dog_ids(conn, entry["id"], all_dogs)
dogs_map = _fetch_dog_ids(conn, [entry["id"]])
media_map = _fetch_media_items(conn, [entry["id"]])
+ entry_id = entry["id"]
+
+ # Wetter + POIs asynchron nach dem DB-Commit holen (außerhalb des with-Blocks)
+ if data.gps_lat is not None and data.gps_lon is not None:
+ weather_json = None
+ poi_json = None
+
+ # Wetter holen
+ try:
+ wd = await weather_mod.get_weather_for_location(data.gps_lat, data.gps_lon)
+ weather_json = json.dumps(wd)
+ except Exception as exc:
+ logger.warning("Wetter-Abfrage beim Diary-Create fehlgeschlagen: %s", exc)
+
+ # POIs holen
+ try:
+ pois = await _fetch_pois_for_coords(data.gps_lat, data.gps_lon, limit=5)
+ if pois:
+ poi_json = json.dumps(pois)
+ except Exception as exc:
+ logger.warning("POI-Abfrage beim Diary-Create fehlgeschlagen: %s", exc)
+
+ # In DB speichern und Entry aktualisieren
+ if weather_json is not None or poi_json is not None:
+ with db() as conn:
+ conn.execute(
+ "UPDATE diary SET weather_json=?, poi_json=? WHERE id=?",
+ (weather_json, poi_json, entry_id)
+ )
+ entry = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
return _entry_dict(entry, dogs_map, media_map)
+async def _fetch_pois_for_coords(lat: float, lon: float, limit: int = 5) -> list:
+ """Holt POIs für Koordinaten via Overpass (analog zu nearby_places, aber ohne DB/Auth)."""
+ results = []
+ try:
+ async with httpx.AsyncClient(timeout=6) as client:
+ def _overpass_q(radius):
+ return (
+ f'[out:json][timeout:6];'
+ f'('
+ f' node["name"]["tourism"](around:{radius},{lat},{lon});'
+ f' node["name"]["historic"](around:{radius},{lat},{lon});'
+ f' node["name"]["leisure"](around:{radius},{lat},{lon});'
+ f' node["name"]["amenity"](around:{radius},{lat},{lon});'
+ f' node["name"]["shop"](around:{radius},{lat},{lon});'
+ f' way["name"]["tourism"](around:{radius},{lat},{lon});'
+ f' way["name"]["historic"](around:{radius},{lat},{lon});'
+ f' way["name"]["leisure"](around:{radius},{lat},{lon});'
+ f');'
+ f'out center;'
+ )
+ ov = await client.post(
+ "https://overpass-api.de/api/interpreter",
+ data={"data": _overpass_q(800)},
+ headers={"User-Agent": "BanYaro/1.0"},
+ )
+ elements = ov.json().get("elements", []) if ov.status_code == 200 else []
+ if not elements:
+ ov2 = await client.post(
+ "https://overpass-api.de/api/interpreter",
+ data={"data": _overpass_q(2000)},
+ headers={"User-Agent": "BanYaro/1.0"},
+ )
+ elements = ov2.json().get("elements", []) if ov2.status_code == 200 else []
+ seen = set()
+ for el in elements:
+ n = el.get("tags", {}).get("name")
+ if not n or n.lower() in seen:
+ continue
+ seen.add(n.lower())
+ elat = el.get("lat") or el.get("center", {}).get("lat")
+ elon = el.get("lon") or el.get("center", {}).get("lon")
+ if elat and elon:
+ km = _haversine_km(lat, lon, elat, elon)
+ typ = next((el["tags"].get(k) for k in
+ ["tourism", "historic", "leisure", "amenity", "shop"]
+ if el["tags"].get(k)), "place")
+ results.append({"name": n, "type": typ,
+ "distance_m": int(km * 1000)})
+ if len(results) >= limit:
+ break
+ except Exception as exc:
+ logger.debug("_fetch_pois_for_coords Fehler: %s", exc)
+ return results[:limit]
+
+
def _haversine_km(lat1, lon1, lat2, lon2) -> float:
R = 6371
dlat = math.radians(lat2 - lat1)
@@ -508,6 +659,11 @@ async def upload_media(dog_id: int, entry_id: int,
media_url = f"/media/diary/{filename}"
+ # EXIF-GPS aus Bild extrahieren (nur bei Bilddateien)
+ exif_gps = None
+ if media_type == "image":
+ exif_gps = extract_gps_from_exif(raw_data)
+
with db() as conn:
# sort_order = nächste freie Position
max_order = conn.execute(
@@ -525,8 +681,38 @@ async def upload_media(dog_id: int, entry_id: int,
(entry_id,)
).fetchone()["id"]
- return {"id": new_id, "url": media_url, "media_type": media_type,
+ # GPS aus EXIF in den Eintrag schreiben, wenn noch keine Koordinaten vorhanden
+ gps_written = False
+ if exif_gps:
+ existing = conn.execute(
+ "SELECT gps_lat FROM diary WHERE id=?", (entry_id,)
+ ).fetchone()
+ if existing and existing["gps_lat"] is None:
+ conn.execute(
+ "UPDATE diary SET gps_lat=?, gps_lon=? WHERE id=?",
+ (exif_gps[0], exif_gps[1], entry_id)
+ )
+ gps_written = True
+
+ # Wetter + POI nachladen wenn GPS frisch gesetzt
+ if gps_written and exif_gps:
+ try:
+ wd = await weather_mod.get_weather_for_location(exif_gps[0], exif_gps[1])
+ pois = await _fetch_pois_for_coords(exif_gps[0], exif_gps[1], limit=5)
+ with db() as conn:
+ conn.execute(
+ "UPDATE diary SET weather_json=COALESCE(weather_json,?), poi_json=COALESCE(poi_json,?) WHERE id=?",
+ (json.dumps(wd) if wd else None, json.dumps(pois) if pois else None, entry_id)
+ )
+ except Exception as e:
+ logger.warning("EXIF-GPS Wetter/POI Fehler: %s", e)
+
+ resp = {"id": new_id, "url": media_url, "media_type": media_type,
"sort_order": max_order + 1, "is_cover": is_cover}
+ if exif_gps:
+ resp["exif_lat"] = exif_gps[0]
+ resp["exif_lon"] = exif_gps[1]
+ return resp
@router.delete("/{dog_id}/diary/{entry_id}/media/{media_id}", status_code=204)
@@ -587,3 +773,55 @@ async def set_cover_media(dog_id: int, entry_id: int, media_id: int,
conn.execute("UPDATE diary_media SET is_cover=0 WHERE diary_id=?", (entry_id,))
conn.execute("UPDATE diary_media SET is_cover=1 WHERE id=?", (media_id,))
return {"ok": True}
+
+
+# ------------------------------------------------------------------
+# Admin: retroaktive Metadaten-Anreicherung bestehender Einträge
+# ------------------------------------------------------------------
+@router.post("/admin/enrich-metadata", status_code=200)
+async def admin_enrich_diary_metadata(limit: int = 20, _=Depends(require_admin)):
+ """Reichert bestehende Tagebucheinträge mit GPS-Koordinaten mit Wetter + POI nach."""
+ with db() as conn:
+ rows = conn.execute(
+ """SELECT id, gps_lat, gps_lon FROM diary
+ WHERE gps_lat IS NOT NULL AND gps_lon IS NOT NULL
+ AND (weather_json IS NULL OR poi_json IS NULL)
+ LIMIT ?""",
+ (limit,)
+ ).fetchall()
+
+ enriched = 0
+ skipped = 0
+ for row in rows:
+ entry_id, lat, lon = row["id"], row["gps_lat"], row["gps_lon"]
+ weather_json = None
+ poi_json = None
+ try:
+ wd = await weather_mod.get_weather_for_location(lat, lon)
+ weather_json = json.dumps(wd)
+ except Exception as e:
+ logger.warning("enrich-metadata Wetter id=%s: %s", entry_id, e)
+ try:
+ pois = await _fetch_pois_for_coords(lat, lon, limit=5)
+ if pois:
+ poi_json = json.dumps(pois)
+ except Exception as e:
+ logger.warning("enrich-metadata POI id=%s: %s", entry_id, e)
+
+ if weather_json is not None or poi_json is not None:
+ with db() as conn:
+ conn.execute(
+ "UPDATE diary SET weather_json=COALESCE(weather_json,?), poi_json=COALESCE(poi_json,?) WHERE id=?",
+ (weather_json, poi_json, entry_id)
+ )
+ enriched += 1
+ else:
+ skipped += 1
+
+ with db() as conn:
+ remaining = conn.execute(
+ """SELECT COUNT(*) FROM diary
+ WHERE gps_lat IS NOT NULL AND (weather_json IS NULL OR poi_json IS NULL)"""
+ ).fetchone()[0]
+
+ return {"enriched": enriched, "skipped": skipped, "remaining": remaining}
diff --git a/backend/routes/import_data.py b/backend/routes/import_data.py
index 76c3099..8dbe887 100644
--- a/backend/routes/import_data.py
+++ b/backend/routes/import_data.py
@@ -155,9 +155,9 @@ async def import_notestation(
(entry_id, dog_id),
)
- # Erstes Bild speichern
+ # Anhänge in diary_media speichern (statt veraltetem media_url-Feld)
attachments = note.get("attachment") or {}
- media_url = None
+ first = True
for att in attachments.values():
md5 = att.get("md5", "")
mime = att.get("type", "image/jpeg")
@@ -165,13 +165,11 @@ async def import_notestation(
continue
media_url = _save_image_from_zip(zf, md5, mime)
if media_url:
- break
-
- if media_url:
- conn.execute(
- "UPDATE diary SET media_url=? WHERE id=?",
- (media_url, entry_id),
- )
+ conn.execute(
+ "INSERT INTO diary_media (diary_id, url, media_type, sort_order, is_cover) VALUES (?,?,?,?,?)",
+ (entry_id, media_url, "image", 0 if first else 1, 1 if first else 0),
+ )
+ first = False
imported += 1
diff --git a/backend/routes/notes.py b/backend/routes/notes.py
new file mode 100644
index 0000000..a85a2a2
--- /dev/null
+++ b/backend/routes/notes.py
@@ -0,0 +1,263 @@
+"""BAN YARO — Notizen Routes"""
+
+import json
+import logging
+from datetime import datetime
+from fastapi import APIRouter, Depends, HTTPException, Query
+from pydantic import BaseModel
+from typing import Optional, Any, List
+from database import db
+from auth import get_current_user
+
+router = APIRouter()
+logger = logging.getLogger(__name__)
+
+
+# ------------------------------------------------------------------
+# Schemas
+# ------------------------------------------------------------------
+class NoteCreate(BaseModel):
+ text: str
+ meta_json: Optional[Any] = None
+ location_name: Optional[str] = None
+ parent_label: Optional[str] = None
+
+
+class NoteUpdate(BaseModel):
+ text: Optional[str] = None
+ meta_json: Optional[Any] = None
+ location_name: Optional[str] = None
+ parent_label: Optional[str] = None
+
+
+# ------------------------------------------------------------------
+# Hilfsfunktionen
+# ------------------------------------------------------------------
+def _serialize(row) -> dict:
+ d = dict(row)
+ if d.get("meta_json") and isinstance(d["meta_json"], str):
+ try:
+ d["meta_json"] = json.loads(d["meta_json"])
+ except Exception:
+ pass
+ return d
+
+
+# ------------------------------------------------------------------
+# GET /api/notes — Gesamt-Notizblock mit Filtern
+# Alias: GET /api/notes/all/0 (Rückwärtskompatibilität)
+# WICHTIG: Diese Route muss VOR /{parent_type}/{parent_id} stehen!
+# ------------------------------------------------------------------
+@router.get("")
+async def list_all_notes_filtered(
+ parent_type: Optional[List[str]] = Query(default=None),
+ date_from: Optional[str] = Query(default=None),
+ date_to: Optional[str] = Query(default=None),
+ q: Optional[str] = Query(default=None),
+ sort: Optional[str] = Query(default="date_desc"),
+ user=Depends(get_current_user),
+):
+ """Alle Notizen des Users mit optionalen Filtern."""
+ conditions = ["user_id=?"]
+ params: list = [user["id"]]
+
+ if parent_type:
+ placeholders = ",".join("?" * len(parent_type))
+ conditions.append(f"parent_type IN ({placeholders})")
+ params.extend(parent_type)
+
+ if date_from:
+ conditions.append("DATE(created_at) >= ?")
+ params.append(date_from)
+
+ if date_to:
+ conditions.append("DATE(created_at) <= ?")
+ params.append(date_to)
+
+ if q:
+ conditions.append("(text LIKE ? OR COALESCE(parent_label,'') LIKE ?)")
+ like = f"%{q}%"
+ params.extend([like, like])
+
+ where = " AND ".join(conditions)
+
+ if sort == "rubrik":
+ order = "parent_type ASC, created_at DESC"
+ elif sort == "ort":
+ order = "CASE WHEN location_name IS NULL OR location_name='' THEN 1 ELSE 0 END ASC, location_name ASC, created_at DESC"
+ elif sort == "date_asc":
+ order = "created_at ASC"
+ else:
+ order = "created_at DESC"
+
+ with db() as conn:
+ rows = conn.execute(
+ f"SELECT * FROM notes WHERE {where} ORDER BY {order}",
+ params
+ ).fetchall()
+
+ return [_serialize(r) for r in rows]
+
+
+@router.get("/all/0")
+async def list_all_notes(user=Depends(get_current_user)):
+ """Alias für Rückwärtskompatibilität."""
+ with db() as conn:
+ rows = conn.execute(
+ "SELECT * FROM notes WHERE user_id=? ORDER BY created_at DESC",
+ (user["id"],)
+ ).fetchall()
+ return [_serialize(r) for r in rows]
+
+
+# ------------------------------------------------------------------
+# POST /api/notes/ki-analyse
+# WICHTIG: Fixe Route MUSS vor /{parent_type}/{parent_id} stehen!
+# ------------------------------------------------------------------
+@router.post("/ki-analyse")
+async def ki_analyse(user=Depends(get_current_user)):
+ """KI analysiert die Notizen des Users und gibt Muster/Vorschläge zurück."""
+ with db() as conn:
+ # User-Setting prüfen
+ setting = conn.execute(
+ "SELECT notes_ki_enabled FROM users WHERE id=?",
+ (user["id"],)
+ ).fetchone()
+
+ if not setting or not setting["notes_ki_enabled"]:
+ raise HTTPException(403, "KI-Assistent ist deaktiviert.")
+
+ with db() as conn:
+ rows = conn.execute(
+ """SELECT text, parent_type, parent_label, location_name, created_at
+ FROM notes
+ WHERE user_id=?
+ ORDER BY created_at DESC
+ LIMIT 50""",
+ (user["id"],)
+ ).fetchall()
+
+ note_count = len(rows)
+ if note_count == 0:
+ return {"suggestions": "", "note_count": 0}
+
+ notes_data = [dict(r) for r in rows]
+
+ prompt = (
+ "Du bist ein freundlicher Assistent für Hundebesitzer. "
+ "Analysiere diese Notizen und erkenne Muster (Gesundheit, Training, Verhalten, "
+ "Lieblingsrouten, saisonale Besonderheiten). "
+ "Gib 2-4 kurze, konkrete Vorschläge auf Deutsch. "
+ "Keine langen Texte, bullet points. "
+ f"Daten: {json.dumps(notes_data, ensure_ascii=False)}"
+ )
+
+ try:
+ import ki as ki_module
+ suggestions, _ = await ki_module.complete(
+ prompt,
+ requires_premium=False,
+ user_is_premium=False,
+ )
+ except Exception as e:
+ logger.warning("KI-Analyse fehlgeschlagen: %s", e)
+ suggestions = ""
+
+ return {"suggestions": suggestions, "note_count": note_count}
+
+
+# ------------------------------------------------------------------
+# GET /api/notes/{parent_type}/{parent_id}
+# parent_id kann ein Integer oder ein String-Schlüssel sein.
+# SQLite ist dynamisch getypt — wir übergeben den Wert als Text.
+# ------------------------------------------------------------------
+@router.get("/{parent_type}/{parent_id}")
+async def list_notes(parent_type: str, parent_id: str,
+ user=Depends(get_current_user)):
+ with db() as conn:
+ rows = conn.execute(
+ """SELECT * FROM notes
+ WHERE user_id=? AND parent_type=? AND CAST(parent_id AS TEXT)=?
+ ORDER BY created_at DESC""",
+ (user["id"], parent_type, parent_id)
+ ).fetchall()
+ return [_serialize(r) for r in rows]
+
+
+# ------------------------------------------------------------------
+# POST /api/notes/{parent_type}/{parent_id}
+# ------------------------------------------------------------------
+@router.post("/{parent_type}/{parent_id}", status_code=201)
+async def create_note(parent_type: str, parent_id: str, data: NoteCreate,
+ user=Depends(get_current_user)):
+ if not data.text.strip():
+ raise HTTPException(400, "Notiz darf nicht leer sein.")
+
+ meta_str = json.dumps(data.meta_json) if data.meta_json is not None else None
+ now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
+
+ with db() as conn:
+ conn.execute(
+ """INSERT INTO notes
+ (user_id, parent_type, parent_id, text, meta_json,
+ location_name, parent_label, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (user["id"], parent_type, parent_id, data.text.strip(), meta_str,
+ data.location_name, data.parent_label, now, now)
+ )
+ row = conn.execute(
+ "SELECT * FROM notes WHERE user_id=? AND parent_type=? AND parent_id=? ORDER BY id DESC LIMIT 1",
+ (user["id"], parent_type, parent_id)
+ ).fetchone()
+ return _serialize(row)
+
+
+# ------------------------------------------------------------------
+# PATCH /api/notes/{id}
+# ------------------------------------------------------------------
+@router.patch("/{note_id}")
+async def update_note(note_id: int, data: NoteUpdate,
+ user=Depends(get_current_user)):
+ with db() as conn:
+ note = conn.execute(
+ "SELECT * FROM notes WHERE id=? AND user_id=?", (note_id, user["id"])
+ ).fetchone()
+ if not note:
+ raise HTTPException(404, "Notiz nicht gefunden.")
+
+ updates = {}
+ if data.text is not None:
+ if not data.text.strip():
+ raise HTTPException(400, "Notiz darf nicht leer sein.")
+ updates["text"] = data.text.strip()
+ if data.meta_json is not None:
+ updates["meta_json"] = json.dumps(data.meta_json)
+ if data.location_name is not None:
+ updates["location_name"] = data.location_name
+ if data.parent_label is not None:
+ updates["parent_label"] = data.parent_label
+
+ if not updates:
+ return _serialize(note)
+
+ updates["updated_at"] = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
+ set_clause = ", ".join(f"{k}=?" for k in updates)
+ values = list(updates.values()) + [note_id]
+ conn.execute(f"UPDATE notes SET {set_clause} WHERE id=?", values)
+ row = conn.execute("SELECT * FROM notes WHERE id=?", (note_id,)).fetchone()
+ return _serialize(row)
+
+
+# ------------------------------------------------------------------
+# DELETE /api/notes/{id}
+# ------------------------------------------------------------------
+@router.delete("/{note_id}", status_code=204)
+async def delete_note(note_id: int, user=Depends(get_current_user)):
+ with db() as conn:
+ note = conn.execute(
+ "SELECT id FROM notes WHERE id=? AND user_id=?", (note_id, user["id"])
+ ).fetchone()
+ if not note:
+ raise HTTPException(404, "Notiz nicht gefunden.")
+ conn.execute("DELETE FROM notes WHERE id=?", (note_id,))
+ return None
diff --git a/backend/routes/profile.py b/backend/routes/profile.py
index a706949..08f403a 100644
--- a/backend/routes/profile.py
+++ b/backend/routes/profile.py
@@ -25,6 +25,7 @@ class ProfileUpdate(BaseModel):
erfahrung: Optional[str] = None
social_link: Optional[str] = None
profil_sichtbarkeit: Optional[str] = None
+ notes_ki_enabled: Optional[int] = None
def _load_user(user_id: int) -> dict:
diff --git a/backend/scheduler.py b/backend/scheduler.py
index c80a583..84d5072 100644
--- a/backend/scheduler.py
+++ b/backend/scheduler.py
@@ -90,14 +90,6 @@ def start():
id="seed_wikidata_startup",
replace_existing=True,
)
- # Täglich 02:30 Uhr — KI-Anreicherung für 20 noch nicht angereicherte Rassen
- _scheduler.add_job(
- _job_wiki_enrich,
- CronTrigger(hour=2, minute=30),
- id="wiki_enrich_nightly",
- replace_existing=True,
- misfire_grace_time=3600,
- )
# Jeden Montag 09:00 — Wöchentlicher Fortschritts-Lober
_scheduler.add_job(
_job_weekly_praise,
@@ -114,16 +106,8 @@ def start():
replace_existing=True,
misfire_grace_time=1800,
)
- # Einmalig beim Start (nach 90s) — erste 50 Rassen sofort anreichern
- _scheduler.add_job(
- _job_wiki_enrich_startup,
- 'date',
- run_date=datetime.now(tz=_TZ) + timedelta(seconds=90),
- id="wiki_enrich_startup",
- replace_existing=True,
- )
_scheduler.start()
- logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed beim Start, Wiki-KI-Anreicherung 02:30.")
+ logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed beim Start.")
def stop():
@@ -629,35 +613,6 @@ def _log_job(job_id: str, status: str, result: str):
}
-# ------------------------------------------------------------------
-# JOB: KI-Anreicherung der Rassen-Daten (nächtlich)
-# ------------------------------------------------------------------
-async def _job_wiki_enrich():
- """Reichert alle noch nicht angereicherten Rassen mit KI-Daten an."""
- try:
- from scraper.breed_enricher import enrich_breeds
- enriched = await enrich_breeds(limit=2000)
- msg = f"{enriched} Rassen angereichert"
- logger.info(f"Wiki-KI-Anreicherung (nächtlich): {msg}.")
- _log_job("wiki_enrich_nightly", "ok", msg)
- except Exception as e:
- logger.error(f"Wiki-KI-Anreicherung: Fehler: {e}")
- _log_job("wiki_enrich_nightly", "error", str(e))
-
-
-async def _job_wiki_enrich_startup():
- """Beim Start: alle Rassen sofort anreichern."""
- try:
- from scraper.breed_enricher import enrich_breeds
- enriched = await enrich_breeds(limit=2000)
- msg = f"{enriched} Rassen angereichert (Startup)"
- logger.info(f"Wiki-KI-Anreicherung (Startup): {msg}.")
- _log_job("wiki_enrich_startup", "ok", msg)
- except Exception as e:
- logger.error(f"Wiki-KI-Anreicherung (Startup): Fehler: {e}")
- _log_job("wiki_enrich_startup", "error", str(e))
-
-
# ------------------------------------------------------------------
# Hilfsfunktion: Lob-Text für einen Hund generieren
# ------------------------------------------------------------------
@@ -815,12 +770,6 @@ async def _job_status_report():
metrics = {}
try:
with db() as conn:
- # Rassen-Anreicherung
- metrics["rassen_total"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen").fetchone()[0]
- metrics["rassen_enriched"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen WHERE ki_enriched=1").fetchone()[0]
- metrics["rassen_mit_foto"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen WHERE foto_url IS NOT NULL AND foto_url NOT LIKE 'http%'").fetchone()[0]
- metrics["rassen_mit_desc"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen WHERE beschreibung IS NOT NULL AND beschreibung != ''").fetchone()[0]
-
# Züchter
try:
metrics["zuchter_pending"] = conn.execute("SELECT COUNT(*) FROM wiki_zuchter WHERE verified=0").fetchone()[0]
@@ -848,16 +797,6 @@ async def _job_status_report():
logger.error(f"Status-Report: DB-Fehler: {e}")
return
- # --- Wiki-Fortschritt berechnen ---
- total = metrics["rassen_total"] or 1
- enriched = metrics["rassen_enriched"]
- pct = round(enriched / total * 100)
- remaining = total - enriched
- nights_left = (remaining + 19) // 20 # bei 20/Nacht
-
- bar_filled = round(pct / 5)
- progress_bar = "█" * bar_filled + "░" * (20 - bar_filled)
-
# --- Job-Log-Tabelle ---
job_labels = {
"health_reminders": "Gesundheits-Erinnerungen",
@@ -865,8 +804,6 @@ async def _job_status_report():
"weather_alert": "Wetter-Alert",
"milestone_check": "Meilenstein-Check",
"import_events": "Event-Import (VDH)",
- "wiki_enrich_nightly": "Wiki KI-Anreicherung (nächtlich)",
- "wiki_enrich_startup": "Wiki KI-Anreicherung (Startup)",
"seed_breeds_startup": "Rassen-Seed (TheDogAPI)",
"seed_wikidata_startup":"Rassen-Seed (Wikidata)",
"weekly_praise": "Wöchentlicher Lober (Mo 09:00)",
@@ -899,18 +836,6 @@ async def _job_status_report():
{now_str} Uhr
-
-
-
Wiki KI-Anreicherung
-
- {progress_bar} {pct}%
- ✅ Angereichert: {enriched} / {total}
- ⏳ Verbleibend: {remaining} Rassen (~{nights_left} Nächte)
- 📷 Mit lokalem Foto: {metrics['rassen_mit_foto']}
- 📝 Mit Beschreibung: {metrics['rassen_mit_desc']}
-
-
-
Scheduler-Jobs
@@ -947,13 +872,6 @@ async def _job_status_report():
plain = f"""Ban Yaro Status-Report — {now_str}
-=== Wiki KI-Anreicherung ===
-{progress_bar} {pct}%
-Angereichert: {enriched}/{total}
-Verbleibend: {remaining} Rassen (~{nights_left} Nächte à 20/Nacht)
-Mit Foto: {metrics['rassen_mit_foto']}
-Mit Beschreibung: {metrics['rassen_mit_desc']}
-
=== Scheduler-Jobs ===
{job_rows_txt}
=== Community ===
diff --git a/backend/static/css/components.css b/backend/static/css/components.css
index 3102262..875b2f7 100644
--- a/backend/static/css/components.css
+++ b/backend/static/css/components.css
@@ -963,82 +963,326 @@ html.modal-open {
}
/* ------------------------------------------------------------
- 12. TAGEBUCH
+ 12. TAGEBUCH — Day One Style
------------------------------------------------------------ */
-/* Monats-Trennlinie */
-.diary-month-header {
- font-size: var(--text-sm);
- font-weight: var(--weight-semibold);
- color: var(--c-text-secondary);
- text-transform: uppercase;
- letter-spacing: 0.06em;
- padding: var(--space-4) 0 var(--space-2);
- border-bottom: 1px solid var(--c-border);
- margin-bottom: var(--space-3);
+/* Stats-Leiste */
+.diary-stats-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0;
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--c-divider, var(--c-border));
+ background: var(--c-surface);
+ flex-shrink: 0;
}
-.diary-month-header:first-child {
- padding-top: 0;
+.diary-stats-numbers {
+ display: flex;
+ gap: 0;
+ overflow-x: auto;
+ scrollbar-width: none;
+ flex: 1;
+ min-width: 0;
+}
+.diary-stats-numbers::-webkit-scrollbar { display: none; }
+.diary-stat {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ min-width: 0;
+ padding: 0 8px;
+ border-right: 1px solid var(--c-border);
+}
+.diary-stat:last-child { border-right: none; }
+.diary-stat-num {
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--c-text);
+ line-height: 1.2;
+ white-space: nowrap;
+}
+.diary-stat-label {
+ font-size: 9px;
+ color: var(--c-text-muted);
+ margin-top: 2px;
+ white-space: nowrap;
+ text-transform: uppercase;
+ letter-spacing: .04em;
}
-/* Eintragskarte */
+/* View-Switcher */
+.diary-view-switcher {
+ display: flex;
+ gap: 0;
+ flex-shrink: 0;
+ margin-left: 8px;
+}
+.diary-view-btn {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 5px 6px;
+ border-radius: 8px;
+ color: var(--c-text-secondary);
+ display: flex;
+ align-items: center;
+ transition: background .15s, color .15s;
+}
+.diary-view-btn:hover { background: var(--c-surface-2); color: var(--c-text); }
+.diary-view-btn.active { color: var(--c-primary); background: var(--c-primary-subtle); }
+.diary-view-btn .ph-icon { width: 18px; height: 18px; }
+@media (min-width: 640px) {
+ .diary-stat { padding: 0 12px; }
+ .diary-stat-num { font-size: 20px; }
+ .diary-view-btn { padding: 6px 8px; }
+ .diary-view-btn .ph-icon { width: 20px; height: 20px; }
+}
+
+/* Meta-Zeile in der Karte */
+.diary-meta-loc {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ max-width: 140px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.diary-meta-dot { color: var(--c-text-muted); opacity: .5; }
+
+/* Medien-Mosaic */
+.diary-media-mosaic {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 2px;
+ padding: 2px;
+}
+.diary-mosaic-item {
+ aspect-ratio: 1;
+ overflow: hidden;
+ cursor: pointer;
+ position: relative;
+}
+.diary-mosaic-item img {
+ width: 100%; height: 100%;
+ object-fit: cover;
+ display: block;
+ transition: opacity .2s;
+}
+.diary-mosaic-item:hover img { opacity: .85; }
+
+/* Kalender-Ansicht */
+.diary-calendar { padding: 0 0 80px; }
+.diary-cal-nav {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px 16px 8px;
+}
+.diary-cal-nav button {
+ background: none; border: none; cursor: pointer;
+ padding: 6px; border-radius: 8px; color: var(--c-text-muted);
+ display: flex; align-items: center;
+}
+.diary-cal-nav button:hover { background: var(--c-surface-2); }
+.diary-cal-nav button .ph-icon { width: 20px; height: 20px; }
+.diary-cal-month { font-size: 16px; font-weight: 600; color: var(--c-text); }
+.diary-cal-weekdays {
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+ padding: 0 8px 4px;
+ text-align: center;
+ font-size: 11px;
+ color: var(--c-text-muted);
+ font-weight: 500;
+}
+.diary-cal-grid {
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+ gap: 3px;
+ padding: 0 8px;
+}
+.diary-cal-cell {
+ aspect-ratio: 1;
+ border-radius: 8px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 3px;
+ position: relative;
+ overflow: hidden;
+ font-size: 13px;
+ color: var(--c-text-secondary);
+}
+.diary-cal-cell.has-entry {
+ cursor: pointer;
+ color: var(--c-text);
+ font-weight: 600;
+}
+.diary-cal-cell.has-entry:active { opacity: .7; }
+/* Oranger Punkt unter der Tageszahl — sichtbar auch ohne Foto */
+.diary-cal-cell.has-entry::after {
+ content: '';
+ display: block;
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: var(--c-primary);
+ flex-shrink: 0;
+}
+/* Foto als Hintergrund */
+.diary-cal-cell.has-entry img {
+ position: absolute; inset: 0;
+ width: 100%; height: 100%;
+ object-fit: cover;
+ opacity: .4;
+ border-radius: 8px;
+}
+.diary-cal-cell.has-entry:hover img,
+.diary-cal-cell.has-entry:active img { opacity: .6; }
+/* Punkt ausblenden wenn Foto vorhanden (Foto reicht als Indikator) */
+.diary-cal-cell.has-entry:has(img)::after { display: none; }
+.diary-cal-cell.today .diary-cal-day {
+ background: var(--c-primary);
+ color: var(--c-text-inverse);
+ border-radius: 50%;
+ width: 26px; height: 26px;
+ display: flex; align-items: center; justify-content: center;
+ font-weight: 700;
+}
+.diary-cal-day { position: relative; z-index: 1; font-size: 13px; }
+
+/* Monats-Section */
+.diary-month-header {
+ font-size: 22px;
+ font-weight: 700;
+ color: var(--c-text);
+ padding: 12px 16px;
+ background: var(--c-surface-2, #f5f5f5);
+ margin: 0;
+ border-top: 1px solid var(--c-border);
+ border-bottom: none;
+ letter-spacing: -0.01em;
+}
+.diary-month-header:first-child {
+ border-top: none;
+ margin-top: 0;
+}
+@media (prefers-color-scheme: dark) {
+ .diary-month-header { background: var(--c-surface-2); }
+}
+[data-theme="dark"] .diary-month-header { background: var(--c-surface-2); }
+
+/* Monats-Eintrags-Container (umschließt alle Karten einer Section) */
+.diary-month-entries {
+ background: var(--c-surface);
+ border-bottom: 1px solid var(--c-border);
+}
+
+/* Eintragskarte — Day One Row-Style */
.diary-card {
- background: var(--c-surface);
- border: 1px solid var(--c-border);
- border-radius: var(--radius-lg);
- margin-bottom: var(--space-3);
- overflow: hidden;
- cursor: pointer;
- transition: box-shadow var(--transition-fast),
- transform var(--transition-fast);
- box-shadow: var(--shadow-xs);
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 14px 16px;
+ background: transparent;
+ border: none;
+ border-bottom: 1px solid var(--c-divider, var(--c-border));
+ border-radius: 0;
+ margin-bottom: 0;
+ overflow: visible;
+ cursor: pointer;
+ transition: background var(--transition-fast);
+ box-shadow: none;
-webkit-tap-highlight-color: transparent;
}
+.diary-card:last-child {
+ border-bottom: none;
+}
.diary-card:hover {
- box-shadow: var(--shadow-md);
- transform: translateY(-1px);
+ background: rgba(0,0,0,0.025);
+ box-shadow: none;
+ transform: none;
}
.diary-card:active {
- transform: scale(0.99);
+ background: rgba(0,0,0,0.05);
+ transform: none;
+}
+[data-theme="dark"] .diary-card:hover { background: rgba(255,255,255,0.04); }
+[data-theme="dark"] .diary-card:active { background: rgba(255,255,255,0.07); }
+
+/* Datum-Spalte links */
+.diary-card-date-col {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 44px;
+ flex-shrink: 0;
+ padding-top: 1px;
+}
+.diary-card-weekday {
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--c-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ line-height: 1;
+ margin-bottom: 2px;
+}
+.diary-card-daynum {
+ font-size: 28px;
+ font-weight: 700;
+ color: var(--c-text);
+ line-height: 1;
+}
+
+/* Meilenstein-Icon auf der Datum-Spalte */
+.diary-card-date-col .diary-milestone-icon {
+ font-size: 14px;
+ color: #c4a000;
+ margin-top: 4px;
}
/* Meilenstein-Hervorhebung */
.diary-card--milestone {
- border-color: #d4a017;
- border-width: 2px;
- background: linear-gradient(
- 135deg,
- var(--c-surface) 0%,
- color-mix(in srgb, #d4a017 8%, var(--c-surface)) 100%
- );
+ background: color-mix(in srgb, #d4a017 4%, transparent);
+}
+.diary-card--milestone .diary-card-daynum {
+ color: #b8860b;
}
/* Meilenstein-Badge innerhalb der Karte */
.diary-card-milestone-badge {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- background: color-mix(in srgb, #d4a017 15%, transparent);
- color: #8a6400;
- font-weight: 600;
- font-size: var(--text-xs);
- padding: 2px var(--space-2);
- border-radius: var(--radius-full);
- margin-bottom: var(--space-2);
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ background: color-mix(in srgb, #d4a017 15%, transparent);
+ color: #8a6400;
+ font-weight: 600;
+ font-size: var(--text-xs);
+ padding: 2px var(--space-2);
+ border-radius: var(--radius-full);
+ margin-bottom: 4px;
letter-spacing: 0.03em;
}
-/* Foto / Video oben */
+/* Foto / Thumbnail rechts — 72×72px */
.diary-card-photo {
- width: 100%;
- height: 180px;
- overflow: hidden;
+ width: 72px;
+ height: 72px;
+ flex-shrink: 0;
+ border-radius: 8px;
+ overflow: hidden;
+ position: relative;
+ margin-top: 2px;
}
.diary-card-photo img {
- width: 100%;
- height: 100%;
- object-fit: cover;
- display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
}
.diary-media-picker {
display: flex;
@@ -1165,7 +1409,7 @@ html.modal-open {
border-radius: 50%;
border: none;
background: rgba(0,0,0,.50);
- color: #9ca3af;
+ color: rgba(255,255,255,.55);
font-size: 14px;
cursor: pointer;
display: flex;
@@ -1177,7 +1421,7 @@ html.modal-open {
transition: color .15s, background .15s;
}
.diary-cover-btn--active {
- color: #f5c518;
+ color: var(--c-amber);
background: rgba(0,0,0,.65);
}
.diary-cover-btn--form {
@@ -1185,48 +1429,46 @@ html.modal-open {
left: var(--space-1);
}
-/* Card Body */
+/* Card Body — mittlere Spalte */
.diary-card-body {
- padding: var(--space-3) var(--space-4);
+ flex: 1;
+ min-width: 0;
+ padding: 0;
}
-/* Meta-Zeile: Typ + Datum */
-.diary-card-meta {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: var(--space-1);
-}
-.diary-card-type {
- font-size: var(--text-xs);
- font-weight: var(--weight-semibold);
- color: var(--c-primary);
- text-transform: uppercase;
- letter-spacing: 0.04em;
-}
-.diary-card-date {
- font-size: var(--text-xs);
- color: var(--c-text-secondary);
-}
-
-/* Titel */
+/* Titel in Karte */
.diary-card-title {
- font-size: var(--text-base);
- font-weight: var(--weight-semibold);
- color: var(--c-text);
- margin-bottom: var(--space-1);
+ font-size: 15px;
+ font-weight: 700;
+ color: var(--c-text);
+ margin: 0 0 3px;
+ line-height: 1.3;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
}
+/* Meta-Zeile: nur noch für Compat — im neuen Design nicht als flex-row genutzt */
+.diary-card-meta {
+ display: none;
+}
+.diary-card-type { display: none; }
+.diary-card-date { display: none; }
+
/* Ort-Zeile in Karte */
.diary-card-location {
display: flex;
align-items: center;
- gap: var(--space-1);
- font-size: var(--text-sm);
- color: var(--c-primary);
- margin: 0 0 var(--space-1);
+ gap: 4px;
+ font-size: 12px;
+ color: var(--c-text-muted);
+ margin: 0 0 2px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
-.diary-card-location .ph-icon { flex-shrink: 0; }
+.diary-card-location .ph-icon { flex-shrink: 0; width: 12px; height: 12px; }
/* Ort in Detail-Ansicht */
.diary-detail-location {
@@ -1292,12 +1534,12 @@ html.modal-open {
/* Text-Vorschau */
.diary-card-text {
- font-size: var(--text-sm);
+ font-size: 13px;
color: var(--c-text-secondary);
- line-height: 1.5;
- margin: 0 0 var(--space-2);
+ line-height: 1.45;
+ margin: 0 0 4px;
display: -webkit-box;
- -webkit-line-clamp: 3;
+ -webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
@@ -1310,6 +1552,62 @@ html.modal-open {
margin-top: var(--space-1);
}
+/* Meta-Zeile unten in der Karte: Zeit · Ort · Wetter */
+.diary-card-meta-row {
+ font-size: 12px;
+ color: var(--c-text-muted);
+ line-height: 1.4;
+ margin-top: 4px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* Wetter-Badge in Karten-Meta */
+.diary-weather-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ font-size: var(--text-xs);
+ color: var(--c-text-secondary);
+ white-space: nowrap;
+}
+
+/* FAB — Floating Action Button */
+.diary-fab {
+ position: fixed;
+ bottom: calc(var(--nav-bottom-height, 64px) + env(safe-area-inset-bottom, 0px) + 16px);
+ right: 20px;
+ width: 56px;
+ height: 56px;
+ border-radius: 50%;
+ background: var(--c-primary);
+ color: #fff;
+ border: none;
+ cursor: pointer;
+ box-shadow: 0 4px 16px rgba(196,132,58,.4);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 100;
+ transition: transform .15s, box-shadow .15s;
+ -webkit-tap-highlight-color: transparent;
+}
+.diary-fab:hover { transform: scale(1.06); box-shadow: 0 6px 20px rgba(196,132,58,.5); }
+.diary-fab:active { transform: scale(0.94); }
+
+/* POI-Chips in Karte und Detail */
+.diary-poi-chips,
+.diary-detail-poi-chips {
+ font-size: var(--text-xs);
+ color: var(--c-text-muted);
+ line-height: 1.5;
+ margin: var(--space-1) 0 var(--space-1);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
/* Detail-Ansicht */
.diary-detail-milestone-badge {
display: inline-flex;
@@ -1324,6 +1622,312 @@ html.modal-open {
margin-bottom: var(--space-3);
}
+/* Detail-View: Hero-Bild */
+.diary-detail-hero {
+ width: 100%;
+ max-height: 80vh;
+ background: #000;
+ flex-shrink: 0;
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+}
+@media (min-width: 768px) {
+ .diary-detail-hero {
+ max-width: 1100px;
+ margin: 0 auto;
+ border-radius: 0 0 12px 12px;
+ max-height: 60vh;
+ }
+}
+@media (min-width: 1200px) {
+ .diary-detail-hero { max-width: 1300px; }
+}
+.diary-detail-hero img {
+ width: 100%;
+ height: auto;
+ max-height: 80vh;
+ object-fit: contain;
+ display: block;
+ cursor: zoom-in;
+}
+.diary-detail-hero video {
+ width: 100%;
+ height: auto;
+ max-height: 80vh;
+ object-fit: contain;
+ display: block;
+ background: #000;
+}
+
+/* Detail-View: inline im Content-Bereich (kein Overlay mehr) */
+.diary-detail-view-inner {
+ display: flex;
+ flex-direction: column;
+ min-height: calc(100vh - 120px);
+ background: var(--c-bg);
+}
+
+/* Detail-View: Header-Bar */
+.diary-detail-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ background: var(--c-surface);
+ border-bottom: 1px solid var(--c-border);
+ flex-shrink: 0;
+ min-height: 48px;
+}
+.diary-detail-back {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: none;
+ border: none;
+ color: var(--c-primary);
+ font-size: 16px;
+ cursor: pointer;
+ padding: 4px 0;
+ font-weight: 500;
+}
+.diary-detail-date-center {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--c-text);
+ text-align: center;
+ flex: 1;
+ padding: 0 8px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.diary-detail-edit {
+ background: none;
+ border: none;
+ color: var(--c-primary);
+ cursor: pointer;
+ padding: 4px 0;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+/* Detail-View: Body-Wrapper (text links, Karte rechts auf Desktop) */
+.diary-detail-body-wrap {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ width: 100%;
+}
+@media (min-width: 768px) {
+ .diary-detail-body-wrap {
+ flex-direction: row;
+ align-items: flex-start;
+ max-width: 1100px;
+ margin: 0 auto;
+ padding: 0 24px;
+ box-sizing: border-box;
+ }
+}
+@media (min-width: 1200px) {
+ .diary-detail-body-wrap { max-width: 1300px; }
+}
+
+/* Detail-View: Inhalt */
+.diary-detail-content {
+ padding: 24px 24px 60px;
+ flex: 1;
+ min-width: 0;
+}
+@media (max-width: 767px) {
+ .diary-detail-content { padding: 20px 16px 40px; }
+}
+
+/* Detail-View: Karte + POI-Sektion */
+.diary-detail-map-wrap {
+ padding: 16px 16px 40px;
+ flex-shrink: 0;
+ width: 100%;
+}
+@media (min-width: 768px) {
+ .diary-detail-map-wrap {
+ width: 380px;
+ min-width: 300px;
+ max-width: 420px;
+ flex-shrink: 0;
+ padding: 24px 0 40px 32px;
+ position: sticky;
+ top: 60px;
+ align-self: flex-start;
+ }
+}
+.diary-detail-map {
+ width: 100%;
+ height: 200px;
+ border-radius: 12px;
+ overflow: hidden;
+ border: 1px solid var(--c-border);
+ margin-bottom: 12px;
+}
+@media (min-width: 768px) {
+ .diary-detail-map { height: 280px; }
+}
+
+/* POI-Liste */
+.diary-detail-poi-list {
+ background: var(--c-surface);
+ border: 1px solid var(--c-border);
+ border-radius: 12px;
+ overflow: hidden;
+}
+.diary-detail-poi-heading {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: .06em;
+ color: var(--c-text-muted);
+ padding: 10px 14px 8px;
+ border-bottom: 1px solid var(--c-border);
+}
+.diary-detail-poi-heading .ph-icon { width:14px;height:14px; }
+.diary-detail-poi-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 9px 14px;
+ border-bottom: 1px solid var(--c-border);
+ font-size: 13px;
+}
+.diary-detail-poi-row:last-child { border-bottom: none; }
+.diary-detail-poi-icon { width:16px;height:16px;color:var(--c-primary);flex-shrink:0; }
+.diary-detail-poi-name { flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--c-text); }
+.diary-detail-poi-dist { font-size:12px;color:var(--c-text-muted);flex-shrink:0; }
+.diary-detail-title {
+ font-size: 22px;
+ font-weight: 700;
+ color: var(--c-text);
+ margin: 0 0 16px;
+ line-height: 1.3;
+}
+.diary-detail-body {
+ font-size: 16px;
+ line-height: 1.7;
+ color: var(--c-text);
+ white-space: pre-wrap;
+ margin: 0 0 20px;
+}
+.diary-detail-divider {
+ border: none;
+ border-top: 1px solid var(--c-border);
+ margin: 20px 0;
+}
+
+/* Detail-View: Meta-Bar unten */
+.diary-detail-meta-bar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 16px;
+ font-size: 13px;
+ color: var(--c-text-muted);
+ margin-bottom: 16px;
+ align-items: center;
+}
+.diary-detail-meta-bar .ph-icon {
+ width: 14px;
+ height: 14px;
+ flex-shrink: 0;
+}
+.diary-detail-meta-item {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+
+/* Detail-View: Thumbnail-Strip */
+.diary-detail-thumbs {
+ display: flex;
+ gap: 4px;
+ padding: 6px 16px;
+ overflow-x: auto;
+ background: rgba(0,0,0,.6);
+ flex-shrink: 0;
+ -webkit-overflow-scrolling: touch;
+ scrollbar-width: none;
+}
+@media (min-width: 768px) {
+ .diary-detail-thumbs {
+ max-width: 1100px;
+ margin: 0 auto;
+ border-radius: 0 0 8px 8px;
+ padding-left: 16px;
+ padding-right: 16px;
+ background: rgba(0,0,0,.75);
+ width: 100%;
+ box-sizing: border-box;
+ }
+}
+@media (min-width: 1200px) {
+ .diary-detail-thumbs { max-width: 1300px; }
+}
+.diary-detail-thumbs::-webkit-scrollbar { display: none; }
+.diary-detail-thumb {
+ flex-shrink: 0;
+ width: 56px;
+ height: 56px;
+ border-radius: 6px;
+ overflow: hidden;
+ cursor: pointer;
+ border: 2px solid transparent;
+ box-sizing: border-box;
+ transition: border-color .15s, opacity .15s;
+ opacity: .7;
+}
+.diary-detail-thumb:hover { opacity: 1; }
+.diary-detail-thumb--active {
+ border-color: var(--c-primary);
+ opacity: 1;
+}
+@media (min-width: 768px) {
+ .diary-detail-thumb { width: 72px; height: 72px; }
+}
+
+/* Detail-View: Foto-Galerie horizontal */
+.diary-detail-gallery {
+ display: flex;
+ gap: 8px;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ scroll-snap-type: x mandatory;
+ margin: 0 -20px 20px;
+ padding: 0 20px;
+ scrollbar-width: none;
+}
+.diary-detail-gallery::-webkit-scrollbar { display: none; }
+.diary-detail-gallery-item {
+ flex: 0 0 auto;
+ width: min(75vw, 280px);
+ height: 200px;
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ scroll-snap-align: start;
+ cursor: zoom-in;
+}
+.diary-detail-gallery-item img,
+.diary-detail-gallery-item video {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
/* Leaflet-Attribution ausblenden */
.leaflet-control-attribution { display: none !important; }
diff --git a/backend/static/css/design-system.css b/backend/static/css/design-system.css
index dc1a0b2..5b6f1e0 100644
--- a/backend/static/css/design-system.css
+++ b/backend/static/css/design-system.css
@@ -44,6 +44,7 @@
--c-warning: #D4923A;
--c-warning-subtle: #FDF3E3;
--c-amber: #E4A020; /* Goldgelb — "Heute"-Akzent, distinct von Primary */
+ --c-icon: #7A6A58; /* Standard-Icon-Farbe (= text-secondary im Light-Mode) */
--c-info: #4A7A9B;
--c-info-subtle: #E8F2F8;
@@ -137,8 +138,11 @@
--c-text: #F0EAE0;
--c-text-secondary: #C0B0A0;
- --c-text-muted: #806A58;
+ --c-text-muted: #9A8878;
--c-text-inverse: #2A1F14;
+ --c-icon: #B0A090;
+ --c-amber: #C48820;
+ --c-success: #6A9E58;
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.30);
--shadow-sm: 0 1px 4px rgba(0, 0, 0, 0.35), 0 1px 2px rgba(0, 0, 0, 0.25);
diff --git a/backend/static/css/layout.css b/backend/static/css/layout.css
index ed396fb..65b62e3 100644
--- a/backend/static/css/layout.css
+++ b/backend/static/css/layout.css
@@ -202,7 +202,7 @@
justify-content: center;
gap: 3px;
cursor: pointer;
- color: var(--c-text-muted);
+ color: var(--c-icon, var(--c-text-secondary));
transition: color var(--transition-fast);
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
diff --git a/backend/static/icons/phosphor.svg b/backend/static/icons/phosphor.svg
index 7512a15..87a2ec2 100644
--- a/backend/static/icons/phosphor.svg
+++ b/backend/static/icons/phosphor.svg
@@ -30,6 +30,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/static/index.html b/backend/static/index.html
index 85f466e..8222594 100644
--- a/backend/static/index.html
+++ b/backend/static/index.html
@@ -88,9 +88,9 @@
-
-
-
+
+
+
@@ -124,6 +124,9 @@
+
-
+
+
-