"""Journal-Routen: Index/Dashboard, Future Log, Wochen-/Monatsübersicht, Tagesansicht. Enthält auch Helfer für Wochen-/Monatsberechnung, Export (CSV) und Notiz-Aktionen (erledigt markieren, in anderen Tag / Future Log verschieben). """ import csv import io import json import calendar from datetime import date, datetime, timedelta from flask import ( Blueprint, render_template, request, redirect, url_for, session, flash, Response, ) from auth import login_required from db import get_connection journal_bp = Blueprint("journal", __name__) # Deutsche Wochentage / Monate WEEKDAYS = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] MONTHS = [ "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember", ] BULLET_LABELS = { ".": "Aufgabe", "x": "Termin", "-": "Notiz", "?": "Frage", } def _user_id() -> int: return session["user_id"] def monday_of_week(d: date) -> date: """Liefert den Montag der Woche, in der d liegt.""" return d - timedelta(days=d.weekday()) def get_week_days(monday: date) -> list[date]: return [monday + timedelta(days=i) for i in range(7)] def parse_date(s: str) -> date | None: try: return datetime.strptime(s, "%Y-%m-%d").date() except (ValueError, TypeError): return None def _fetch_notes(user_id: int, note_date: date) -> list[dict]: with get_connection() as conn: with conn.cursor() as cur: cur.execute( "SELECT * FROM notes WHERE user_id = %s AND note_date = %s " "ORDER BY id ASC", (user_id, note_date), ) return cur.fetchall() def _active_notes(user_id: int, note_date: date) -> list[dict]: return [n for n in _fetch_notes(user_id, note_date) if n["status"] != "moved"] def parse_refs(raw) -> list[dict]: """Parst den JSON-Spaltenwert der Verweise in eine Liste.""" if not raw: return [] if isinstance(raw, (list, dict)): return raw if isinstance(raw, list) else [raw] try: data = json.loads(raw) return data if isinstance(data, list) else [] except (ValueError, TypeError): return [] REF_TYPE_DISPLAY = { "email": "📧", "link": "🔗", "phone": "📞", "mention": "@", } def ref_type_label(ref_type: str) -> str: return REF_TYPE_DISPLAY.get(ref_type, "🔖") def ref_href(ref_type: str, value: str) -> str: value = (value or "").strip() if ref_type == "email": return f"mailto:{value}" if ref_type == "phone": return f"tel:{value.replace(' ', '')}" if ref_type == "mention": return f"mailto:{value}" # link und Standard if value.startswith(("http://", "https://")): return value return f"https://{value}" def bullet_label(bullet_type: str) -> str: return BULLET_LABELS.get(bullet_type, "Notiz") @journal_bp.route("/") @login_required def index(): """Dashboard: aktuelle Woche + offene Aufgaben.""" today = date.today() monday = monday_of_week(today) week_days = get_week_days(monday) # Detaillierte Info pro Tag day_summary = [] for d in week_days: notes = _active_notes(_user_id(), d) day_summary.append( { "date": d, "weekday": WEEKDAYS[d.weekday()], "open": sum(1 for n in notes if n["status"] == "open"), "done": sum(1 for n in notes if n["status"] == "done"), "total": len(notes), } ) # Offene Aufgaben außerhalb dieser Woche with get_connection() as conn: with conn.cursor() as cur: cur.execute( "SELECT * FROM notes WHERE user_id = %s AND status = 'open' " "AND note_date < %s ORDER BY note_date ASC LIMIT 50", (_user_id(), monday), ) overdue = cur.fetchall() return render_template( "index.html", today=today, monday=monday, day_summary=day_summary, overdue=overdue, weekdays=WEEKDAYS, ) @journal_bp.route("/future", methods=["GET", "POST"]) @login_required def future(): if request.method == "POST": note_text = request.form.get("note_text", "").strip() month_str = request.form.get("month", "") month = parse_date(month_str) if note_text and month: with get_connection() as conn: with conn.cursor() as cur: cur.execute( "INSERT INTO future_log (user_id, month_date, note_text) " "VALUES (%s, %s, %s)", (_user_id(), month, note_text), ) flash("Eintrag ins Future Log hinzugefügt.", "success") else: flash("Bitte Text und Monat angeben.", "danger") return redirect(url_for("journal.future")) with get_connection() as conn: with conn.cursor() as cur: cur.execute( "SELECT * FROM future_log WHERE user_id = %s ORDER BY month_date ASC, id ASC", (_user_id(),), ) entries = cur.fetchall() # Gruppieren nach Monat grouped = {} for e in entries: key = e["month_date"].strftime("%Y-%m") grouped.setdefault(key, []).append(e) return render_template("future.html", grouped=grouped, months=MONTHS) @journal_bp.route("/woche") @login_required def week(): """Wochenübersicht: Mon–So mit allen Einzeleinträgen pro Tag.""" week_param = request.args.get("week", "") if week_param: try: iso_year, iso_week = week_param.split("-W") monday = date.fromisocalendar(int(iso_year), int(iso_week), 1) except (ValueError, TypeError): monday = monday_of_week(date.today()) else: monday = monday_of_week(date.today()) week_days = get_week_days(monday) sunday = monday + timedelta(days=6) # Alle Notizen der Woche holen (inkl. erledigter) und nach Tag gruppieren with get_connection() as conn: with conn.cursor() as cur: cur.execute( "SELECT * FROM notes WHERE user_id = %s " "AND note_date BETWEEN %s AND %s ORDER BY note_date ASC, id ASC", (_user_id(), monday, sunday), ) rows = cur.fetchall() by_date: dict[date, list[dict]] = {d: [] for d in week_days} for n in rows: by_date.setdefault(n["note_date"], []).append(n) days_detail = [] for d in week_days: notes = by_date.get(d, []) active = [n for n in notes if n["status"] != "moved"] days_detail.append( { "date": d, "weekday": WEEKDAYS[d.weekday()], "open": sum(1 for n in active if n["status"] == "open"), "done": sum(1 for n in active if n["status"] == "done"), "moved": sum(1 for n in notes if n["status"] == "moved"), "total": len(active), "notes": active, } ) prev_monday = monday - timedelta(days=7) next_monday = monday + timedelta(days=7) return render_template( "week.html", monday=monday, sunday=sunday, days_detail=days_detail, weekdays=WEEKDAYS, prev_monday=prev_monday, next_monday=next_monday, bullet_labels=BULLET_LABELS, ) @journal_bp.route("/monat") @login_required def month(): """Monatsübersicht: Kalenderraster + Liste aller Einträge des Monats.""" month_param = request.args.get("month", "") if month_param: try: year, mon = (int(x) for x in month_param.split("-")) first = date(year, mon, 1) except (ValueError, TypeError): first = date.today().replace(day=1) else: first = date.today().replace(day=1) last = date(first.year, first.month, calendar.monthrange(first.year, first.month)[1]) # Alle Notizen des Monats holen with get_connection() as conn: with conn.cursor() as cur: cur.execute( "SELECT * FROM notes WHERE user_id = %s " "AND note_date BETWEEN %s AND %s ORDER BY note_date ASC, id ASC", (_user_id(), first, last), ) rows = cur.fetchall() by_date: dict[int, list[dict]] = {} for n in rows: if n["status"] != "moved": by_date.setdefault(n["note_date"].day, []).append(n) # Kalenderraster aufbauen (Wochen Montag–Sonntag) start_pad = first.weekday() # 0=Montag start = first - timedelta(days=start_pad) weeks: list[list[dict]] = [] current = start for _ in range(6): week_cells = [] for i in range(7): d = current + timedelta(days=i) in_month = d.month == first.month week_cells.append( { "date": d, "in_month": in_month, "today": d == date.today(), "notes": by_date.get(d.day, []) if in_month else [], } ) weeks.append(week_cells) current += timedelta(days=7) if current > last + timedelta(days=6): break now_first = date.today().replace(day=1) prev_month = (first.replace(day=1) - timedelta(days=1)).replace(day=1) next_month = (first.replace(day=28) + timedelta(days=7)).replace(day=1) return render_template( "monat.html", first=first, last=last, weeks=weeks, weekdays=WEEKDAYS, prev_month=prev_month, next_month=next_month, all_notes=rows, ) def _csv_response(title: str, filename: str, rows: list[dict]) -> Response: """Baut eine CSV-Datei als HTTP-Antwort zusammen.""" output = io.StringIO() writer = csv.writer(output) writer.writerow([ "Datum", "Wochentag", "Bullet", "Typ", "Notiz", "Priorität", "Status", "Verweise", ]) for n in rows: refs = ", ".join( f"{ref_type_label(r.get('type', ''))} {r.get('value', '')}" for r in parse_refs(n.get("refs")) ) writer.writerow([ n["note_date"].strftime("%d.%m.%Y"), WEEKDAYS[n["note_date"].weekday()], n.get("bullet_type", ""), bullet_label(n.get("bullet_type", "-")), n.get("note_text", ""), n.get("priority", 0), {"open": "Offen", "done": "Erledigt", "moved": "Verschoben"}.get( n.get("status"), n.get("status", "") ), refs, ]) output.seek(0) return Response( "\ufeff" + output.getvalue(), # BOM für Excel mimetype="text/csv; charset=utf-8", headers={"Content-Disposition": f"attachment; filename={filename}.csv"}, ) @journal_bp.route("/woche/export") @login_required def export_week(): week_param = request.args.get("week", "") if week_param: try: iso_year, iso_week = week_param.split("-W") monday = date.fromisocalendar(int(iso_year), int(iso_week), 1) except (ValueError, TypeError): monday = monday_of_week(date.today()) else: monday = monday_of_week(date.today()) sunday = monday + timedelta(days=6) with get_connection() as conn: with conn.cursor() as cur: cur.execute( "SELECT * FROM notes WHERE user_id = %s " "AND note_date BETWEEN %s AND %s ORDER BY note_date ASC, id ASC", (_user_id(), monday, sunday), ) rows = cur.fetchall() filename = f"woche_{monday.strftime('%Y-%m-%d')}" return _csv_response("Wochenübersicht", filename, rows) @journal_bp.route("/monat/export") @login_required def export_month(): month_param = request.args.get("month", "") if month_param: try: year, mon = (int(x) for x in month_param.split("-")) first = date(year, mon, 1) except (ValueError, TypeError): first = date.today().replace(day=1) else: first = date.today().replace(day=1) last = date(first.year, first.month, calendar.monthrange(first.year, first.month)[1]) with get_connection() as conn: with conn.cursor() as cur: cur.execute( "SELECT * FROM notes WHERE user_id = %s " "AND note_date BETWEEN %s AND %s ORDER BY note_date ASC, id ASC", (_user_id(), first, last), ) rows = cur.fetchall() filename = f"monat_{first.strftime('%Y-%m')}" return _csv_response("Monatsübersicht", filename, rows) @journal_bp.route("/tag/", methods=["GET", "POST"]) @login_required def day(date_str: str): """Tagesansicht mit 3 Spalten (Bullet | Notiz | Priorität).""" target = parse_date(date_str) if target is None: flash("Ungültiges Datum.", "danger") return redirect(url_for("journal.index")) if request.method == "POST": bullet = request.form.get("bullet", "-") note_text = request.form.get("note_text", "").strip() priority = request.form.get("priority", "0") try: priority = int(priority) except ValueError: priority = 0 # Verweise (optional): Typ + Wert ref_type = request.form.get("ref_type", "").strip() ref_value = request.form.get("ref_value", "").strip() refs = None if ref_value: refs = json.dumps([{"type": ref_type or "link", "value": ref_value}]) if bullet not in (".", "x", "-", "?"): bullet = "-" if note_text: with get_connection() as conn: with conn.cursor() as cur: cur.execute( "INSERT INTO notes (user_id, note_date, bullet_type, " "note_text, priority, refs) VALUES (%s, %s, %s, %s, %s, %s)", (_user_id(), target, bullet, note_text, priority, refs), ) flash("Notiz hinzugefügt.", "success") else: flash("Notiz darf nicht leer sein.", "danger") return redirect(url_for("journal.day", date_str=date_str)) notes = _active_notes(_user_id(), target) prev = target - timedelta(days=1) nxt = target + timedelta(days=1) return render_template( "day.html", target=target, weekday=WEEKDAYS[target.weekday()], notes=notes, bullet_labels=BULLET_LABELS, prev=prev, nxt=nxt, ref_types=list(REF_TYPE_DISPLAY.keys()), ) @journal_bp.route("/note//mark", methods=["POST"]) @login_required def mark_done(note_id: int): with get_connection() as conn: with conn.cursor() as cur: cur.execute( "SELECT * FROM notes WHERE id = %s AND user_id = %s", (note_id, _user_id()), ) note = cur.fetchone() if not note: flash("Notiz nicht gefunden.", "danger") return redirect(url_for("journal.index")) new_status = "done" if note["status"] != "done" else "open" cur.execute( "UPDATE notes SET status = %s WHERE id = %s", (new_status, note_id), ) flash("Status aktualisiert.", "success") return redirect(request.referrer or url_for("journal.index")) @journal_bp.route("/note//move", methods=["POST"]) @login_required def move_note(note_id: int): """Verschiebt eine Notiz in einen anderen Tag oder ins Future Log. Verbessert: Beim Verschieben in einen anderen Tag wird KEINE Kopie angelegt, sondern der Eintrag selbst auf das neue Datum gesetzt und `moved_from_date` gespeichert (Herkunft bleibt nachvollziehbar). Formularfelder: target=date|future, target_date=YYYY-MM-DD """ with get_connection() as conn: with conn.cursor() as cur: cur.execute( "SELECT * FROM notes WHERE id = %s AND user_id = %s", (note_id, _user_id()), ) note = cur.fetchone() if not note: flash("Notiz nicht gefunden.", "danger") return redirect(url_for("journal.index")) target = request.form.get("target", "") if target == "future": # In Future Log übernehmen refs = note.get("refs") cur.execute( "INSERT INTO future_log (user_id, month_date, note_text, refs) " "VALUES (%s, %s, %s, %s)", (_user_id(), note["note_date"], note["note_text"], refs), ) cur.execute( "UPDATE notes SET status = 'moved', moved_to_future = 1 " "WHERE id = %s", (note_id,), ) flash("In Future Log verschoben.", "success") else: target_date = parse_date(request.form.get("target_date", "")) if not target_date: flash("Bitte ein Zieldatum auswählen.", "danger") elif target_date == note["note_date"]: flash("Das Zieldatum entspricht dem aktuellen Tag.", "warning") else: cur.execute( "UPDATE notes SET note_date = %s, status = 'open', " "moved_to_future = 0, moved_to_date = NULL, " "moved_from_date = %s WHERE id = %s", (target_date, note["note_date"], note_id), ) flash(f"Verschoben nach {target_date.strftime('%d.%m.%Y')}.", "success") return redirect(request.referrer or url_for("journal.index"))