From 27dbd344d67433eab2a4a853f17b7137266ee398 Mon Sep 17 00:00:00 2001 From: Ignace Date: Thu, 16 Jul 2026 07:00:42 +0200 Subject: [PATCH] First fully working model with data from 2025 and 2026 --- app.py | 276 ++++++++++++++++++++++++++++++++++++++++ config.example.yaml | 13 ++ pytest.ini | 3 + requirements.txt | 3 + scripts/import_excel.py | 138 ++++++++++++++++++++ static/calendar.js | 248 ++++++++++++++++++++++++++++++++++++ static/styles.css | 256 +++++++++++++++++++++++++++++++++++++ templates/base.html | 13 ++ templates/calendar.html | 60 +++++++++ templates/login.html | 23 ++++ templates/report.html | 44 +++++++ tests/test_app.py | 119 +++++++++++++++++ 12 files changed, 1196 insertions(+) create mode 100644 app.py create mode 100644 config.example.yaml create mode 100644 pytest.ini create mode 100644 requirements.txt create mode 100644 scripts/import_excel.py create mode 100644 static/calendar.js create mode 100644 static/styles.css create mode 100644 templates/base.html create mode 100644 templates/calendar.html create mode 100644 templates/login.html create mode 100644 templates/report.html create mode 100644 tests/test_app.py diff --git a/app.py b/app.py new file mode 100644 index 0000000..8201e34 --- /dev/null +++ b/app.py @@ -0,0 +1,276 @@ +import os +import secrets +import sqlite3 +from datetime import date +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP +from functools import wraps +from pathlib import Path + +from flask import Flask, jsonify, redirect, render_template, request, session, url_for +import yaml + + +def load_yaml_config(path): + config_path = Path(path) + if not config_path.is_file(): + raise RuntimeError( + f"Configuration file not found: {config_path}. " + "Copy config.example.yaml to config.yaml and set its values." + ) + + with config_path.open(encoding="utf-8") as config_file: + values = yaml.safe_load(config_file) or {} + + auth = values.get("auth", {}) + flask_config = values.get("flask", {}) + amount_config = values.get("amounts", {}) + required = { + "SECRET_KEY": flask_config.get("secret_key"), + "APP_USERNAME": auth.get("username"), + "APP_PASSWORD": auth.get("password"), + } + missing = [name for name, value in required.items() if not isinstance(value, str) or not value] + if missing: + raise RuntimeError(f"Missing or empty values in {config_path}: {', '.join(missing)}") + presets = amount_config.get("presets", ["55", "27.50"]) + if not isinstance(presets, list) or len(presets) != 2: + raise RuntimeError(f"{config_path}: amounts.presets must contain exactly two amounts") + try: + normalized_presets = [] + for preset in presets: + amount = Decimal(str(preset)) + if not amount.is_finite() or amount <= 0: + raise InvalidOperation + normalized_presets.append(f"{amount:.2f}".rstrip("0").rstrip(".")) + except (InvalidOperation, ValueError): + raise RuntimeError(f"{config_path}: amount presets must be positive numbers") from None + required["PRESET_AMOUNTS"] = normalized_presets + return required + + +def create_app(test_config=None): + app = Flask(__name__) + config_path = os.environ.get("APP_CONFIG", Path(app.root_path) / "config.yaml") + app.config.from_mapping(load_yaml_config(config_path)) + app.config.setdefault("DATABASE", str(Path(app.instance_path) / "calendar.sqlite")) + + # Environment variables remain useful when deploying without a local file. + for name in ("SECRET_KEY", "APP_USERNAME", "APP_PASSWORD"): + if os.environ.get(name): + app.config[name] = os.environ[name] + if test_config: + app.config.update(test_config) + + Path(app.config["DATABASE"]).parent.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(app.config["DATABASE"]) as database: + database.execute( + """ + CREATE TABLE IF NOT EXISTS date_choices ( + choice_date TEXT PRIMARY KEY, + amount_cents INTEGER NOT NULL CHECK (amount_cents >= 0), + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + database.execute( + """ + CREATE TABLE IF NOT EXISTS date_statuses ( + status_date TEXT PRIMARY KEY, + day_status TEXT NOT NULL DEFAULT 'worked' + CHECK (day_status IN ('worked', 'vacation', 'sick')), + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + + def get_database(): + connection = sqlite3.connect(app.config["DATABASE"]) + connection.row_factory = sqlite3.Row + return connection + + def login_required(view): + @wraps(view) + def wrapped_view(*args, **kwargs): + if not session.get("authorized"): + return redirect(url_for("login")) + return view(*args, **kwargs) + + return wrapped_view + + @app.route("/login", methods=("GET", "POST")) + def login(): + if session.get("authorized"): + return redirect(url_for("calendar")) + + error = None + if request.method == "POST": + username = request.form.get("username", "") + password = request.form.get("password", "") + valid_user = secrets.compare_digest(username, app.config["APP_USERNAME"]) + valid_password = secrets.compare_digest(password, app.config["APP_PASSWORD"]) + if valid_user and valid_password: + session.clear() + session["authorized"] = True + return redirect(url_for("calendar")) + error = "That username or password is not correct." + + return render_template("login.html", error=error) + + @app.route("/") + @login_required + def calendar(): + with get_database() as database: + choices = { + row["choice_date"]: row["amount_cents"] + for row in database.execute("SELECT choice_date, amount_cents FROM date_choices") + } + statuses = { + row["status_date"]: row["day_status"] + for row in database.execute("SELECT status_date, day_status FROM date_statuses") + } + return render_template( + "calendar.html", + today=date.today().isoformat(), + choices=choices, + statuses=statuses, + preset_amounts=app.config["PRESET_AMOUNTS"], + ) + + @app.post("/api/choices") + @login_required + def save_choice(): + data = request.get_json(silent=True) or {} + choice_date = data.get("date", "") + try: + date.fromisoformat(choice_date) + except (TypeError, ValueError): + return jsonify(error="Enter a valid date."), 400 + + try: + amount = Decimal(str(data.get("amount", "")).replace(",", ".")) + if not amount.is_finite() or amount < 0: + raise InvalidOperation + amount_cents = int((amount * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) + except (InvalidOperation, ValueError): + return jsonify(error="Enter a valid amount of zero or more."), 400 + + with get_database() as database: + database.execute( + """ + INSERT INTO date_choices (choice_date, amount_cents) + VALUES (?, ?) + ON CONFLICT(choice_date) DO UPDATE SET + amount_cents = excluded.amount_cents, + updated_at = CURRENT_TIMESTAMP + """, + (choice_date, amount_cents), + ) + return jsonify(date=choice_date, amount_cents=amount_cents) + + @app.post("/api/statuses") + @login_required + def save_status(): + data = request.get_json(silent=True) or {} + status_date = data.get("date", "") + day_status = data.get("status", "") + try: + date.fromisoformat(status_date) + except (TypeError, ValueError): + return jsonify(error="Enter a valid date."), 400 + if day_status not in {"worked", "vacation", "sick"}: + return jsonify(error="Choose worked, vacation, or sick."), 400 + + with get_database() as database: + database.execute( + """ + INSERT INTO date_statuses (status_date, day_status) + VALUES (?, ?) + ON CONFLICT(status_date) DO UPDATE SET + day_status = excluded.day_status, + updated_at = CURRENT_TIMESTAMP + """, + (status_date, day_status), + ) + return jsonify(date=status_date, status=day_status) + + @app.post("/api/delete-date") + @login_required + def delete_date_data(): + data = request.get_json(silent=True) or {} + selected_date = data.get("date", "") + try: + date.fromisoformat(selected_date) + except (TypeError, ValueError): + return jsonify(error="Enter a valid date."), 400 + + with get_database() as database: + database.execute("DELETE FROM date_choices WHERE choice_date = ?", (selected_date,)) + database.execute("DELETE FROM date_statuses WHERE status_date = ?", (selected_date,)) + return jsonify(date=selected_date, deleted=True) + + @app.get("/report") + @login_required + def report(): + current_year = request.args.get("year", default=date.today().year, type=int) + if current_year < 1900 or current_year > 9998: + return redirect(url_for("report")) + start_date = f"{current_year}-01-01" + end_date = f"{current_year + 1}-01-01" + with get_database() as database: + rows = database.execute( + """ + WITH recorded_dates AS ( + SELECT choice_date AS selected_date FROM date_choices + WHERE choice_date >= ? AND choice_date < ? + UNION + SELECT status_date AS selected_date FROM date_statuses + WHERE status_date >= ? AND status_date < ? + ) + SELECT + COALESCE(date_statuses.day_status, 'worked') AS category, + COUNT(*) AS day_count, + COALESCE(SUM(date_choices.amount_cents), 0) AS amount_cents + FROM recorded_dates + LEFT JOIN date_choices + ON date_choices.choice_date = recorded_dates.selected_date + LEFT JOIN date_statuses + ON date_statuses.status_date = recorded_dates.selected_date + GROUP BY category + """, + (start_date, end_date, start_date, end_date), + ).fetchall() + + values = {row["category"]: row for row in rows} + categories = [ + { + "key": key, + "label": label, + "days": values[key]["day_count"] if key in values else 0, + "amount_cents": values[key]["amount_cents"] if key in values else 0, + } + for key, label in ( + ("worked", "Worked"), + ("vacation", "Vacation"), + ("sick", "Sick"), + ) + ] + return render_template( + "report.html", + year=current_year, + categories=categories, + total_days=sum(item["days"] for item in categories), + total_cents=sum(item["amount_cents"] for item in categories), + ) + + @app.post("/logout") + def logout(): + session.clear() + return redirect(url_for("login")) + + return app + + +app = create_app() + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "9009")), debug=True) diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..0b1abd9 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,13 @@ +flask: + # Generate with: python -c "import secrets; print(secrets.token_hex(32))" + secret_key: "replace-with-a-long-random-value" + +auth: + username: "your-username" + password: "your-password" + +amounts: + # The two paid quick-choice buttons. The €0 button is always included. + presets: + - "55" + - "27.50" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c7b23ec --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = . +testpaths = tests diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..29ed046 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.1.1 +PyYAML==6.0.2 +openpyxl==3.1.5 diff --git a/scripts/import_excel.py b/scripts/import_excel.py new file mode 100644 index 0000000..b0c53a6 --- /dev/null +++ b/scripts/import_excel.py @@ -0,0 +1,138 @@ +import argparse +import shutil +import sqlite3 +from datetime import date, datetime, timedelta +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP +from pathlib import Path + +import openpyxl + + +GREEN = "FFC2F1C8" +YELLOW = "FFFFFF00" +ORANGE_THEME = 5 +MONTH_HEADER_ROWS = (2, 11, 20) +MONTH_START_COLUMNS = (2, 10, 18, 26) + + +def cell_color(cell): + color = cell.fill.fgColor + if cell.fill.fill_type != "solid": + return None + if color.type == "rgb": + return color.rgb + if color.type == "theme" and color.theme == ORANGE_THEME: + return "orange" + return None + + +def calendar_date(year, month, week_index, weekday_index): + first = date(year, month, 1) + first_sunday = first - timedelta(days=(first.weekday() + 1) % 7) + result = first_sunday + timedelta(days=week_index * 7 + weekday_index) + return result if result.month == month else None + + +def amount_cents(value, coordinate): + try: + amount = Decimal(str(value).strip()) + if not amount.is_finite() or amount < 0: + raise InvalidOperation + return int((amount * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) + except (InvalidOperation, ValueError): + raise ValueError(f"Invalid amount in {coordinate}: {value!r}") from None + + +def read_workbook(path): + workbook = openpyxl.load_workbook(path, data_only=True, read_only=False) + amounts = {} + statuses = {} + + for sheet in workbook.worksheets: + try: + year = int(sheet.title) + except ValueError: + continue + for quarter, header_row in enumerate(MONTH_HEADER_ROWS): + for position, start_column in enumerate(MONTH_START_COLUMNS): + month = quarter * 4 + position + 1 + for week_index in range(6): + row = header_row + 2 + week_index + for weekday_index in range(7): + cell = sheet.cell(row=row, column=start_column + weekday_index) + selected_date = calendar_date(year, month, week_index, weekday_index) + if selected_date is None: + continue + color = cell_color(cell) + key = selected_date.isoformat() + if color == GREEN: + # A green blank means worked, but supplies no amount to import. + if cell.value is not None and str(cell.value).strip(): + amounts[key] = amount_cents(cell.value, f"{sheet.title}!{cell.coordinate}") + elif color == YELLOW: + statuses[key] = "sick" + elif color == "orange": + statuses[key] = "vacation" + return amounts, statuses + + +def existing_conflicts(database_path, amounts, statuses): + if not database_path.exists(): + return 0, 0 + with sqlite3.connect(database_path) as database: + existing_amounts = dict(database.execute("SELECT choice_date, amount_cents FROM date_choices")) + existing_statuses = dict(database.execute("SELECT status_date, day_status FROM date_statuses")) + amount_conflicts = sum(key in existing_amounts and existing_amounts[key] != value for key, value in amounts.items()) + status_conflicts = sum(key in existing_statuses and existing_statuses[key] != value for key, value in statuses.items()) + return amount_conflicts, status_conflicts + + +def import_data(database_path, amounts, statuses): + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + backup_path = database_path.with_name(f"{database_path.name}.backup-{timestamp}") + if database_path.exists(): + shutil.copy2(database_path, backup_path) + + with sqlite3.connect(database_path) as database: + database.executemany( + """ + INSERT INTO date_choices (choice_date, amount_cents) VALUES (?, ?) + ON CONFLICT(choice_date) DO UPDATE SET + amount_cents = excluded.amount_cents, + updated_at = CURRENT_TIMESTAMP + """, + amounts.items(), + ) + database.executemany( + """ + INSERT INTO date_statuses (status_date, day_status) VALUES (?, ?) + ON CONFLICT(status_date) DO UPDATE SET + day_status = excluded.day_status, + updated_at = CURRENT_TIMESTAMP + """, + statuses.items(), + ) + return backup_path if backup_path.exists() else None + + +def main(): + parser = argparse.ArgumentParser(description="Import Fatima calendar data from Excel.") + parser.add_argument("workbook", type=Path) + parser.add_argument("--database", type=Path, default=Path("instance/calendar.sqlite")) + parser.add_argument("--apply", action="store_true", help="Write the data; otherwise perform a dry run.") + args = parser.parse_args() + + amounts, statuses = read_workbook(args.workbook) + amount_conflicts, status_conflicts = existing_conflicts(args.database, amounts, statuses) + print(f"Found {len(amounts)} amounts, {sum(v == 'vacation' for v in statuses.values())} vacation days, and {sum(v == 'sick' for v in statuses.values())} sick days.") + print(f"Existing differing values to overwrite: {amount_conflicts} amounts, {status_conflicts} statuses.") + if not args.apply: + print("Dry run only; no database changes made.") + return + + backup = import_data(args.database, amounts, statuses) + print(f"Import complete. Backup: {backup}" if backup else "Import complete; no previous database existed.") + + +if __name__ == "__main__": + main() diff --git a/static/calendar.js b/static/calendar.js new file mode 100644 index 0000000..0b8da0e --- /dev/null +++ b/static/calendar.js @@ -0,0 +1,248 @@ +(() => { + const weeksEl = document.querySelector("#weeks"); + if (!weeksEl) return; + + const appShell = document.querySelector(".app-shell"); + const today = fromISO(appShell.dataset.today); + const saveUrl = appShell.dataset.saveUrl; + const statusUrl = appShell.dataset.statusUrl; + const deleteUrl = appShell.dataset.deleteUrl; + const savedChoices = JSON.parse(document.querySelector("#saved-choices").textContent); + const savedStatuses = JSON.parse(document.querySelector("#saved-statuses").textContent); + const monthLabel = document.querySelector("#visible-month"); + const selectedLabel = document.querySelector("#selected-date"); + const amountControls = document.querySelector("#amount-controls"); + const customAmount = document.querySelector("#custom-amount"); + const saveStatus = document.querySelector("#save-status"); + const dayFormatter = new Intl.DateTimeFormat(undefined, { weekday: "long", month: "long", day: "numeric", year: "numeric" }); + const monthFormatter = new Intl.DateTimeFormat(undefined, { month: "long", year: "numeric" }); + let firstSunday = startOfWeek(addDays(today, -14)); + let lastSunday = addDays(firstSunday, 7 * 13); + let selectedButton = null; + let loading = false; + let previousScrollTop = 0; + let lastExpansionAt = 0; + + function fromISO(value) { + const [year, month, day] = value.split("-").map(Number); + return new Date(year, month - 1, day, 12); + } + + function addDays(date, count) { + const next = new Date(date); + next.setDate(next.getDate() + count); + return next; + } + + function startOfWeek(date) { return addDays(date, -date.getDay()); } + function iso(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + } + + function createWeek(sunday) { + const row = document.createElement("div"); + row.className = "week"; + row.dataset.sunday = iso(sunday); + row.setAttribute("role", "row"); + + for (let index = 0; index < 7; index += 1) { + const date = addDays(sunday, index); + const button = document.createElement("button"); + button.type = "button"; + button.className = `day${index === 0 || index === 6 ? " is-weekend" : ""}${iso(date) === iso(today) ? " is-today" : ""}`; + button.dataset.date = iso(date); + button.innerHTML = `${date.getDate()}`; + button.setAttribute("role", "gridcell"); + button.setAttribute("aria-label", dayFormatter.format(date)); + if (iso(date) === iso(today)) button.setAttribute("aria-current", "date"); + applySavedChoice(button); + applySavedStatus(button); + button.addEventListener("click", () => selectDate(button, date)); + row.appendChild(button); + } + return row; + } + + function appendWeeks(count) { + const fragment = document.createDocumentFragment(); + for (let i = 0; i < count; i += 1) { + lastSunday = addDays(lastSunday, 7); + fragment.appendChild(createWeek(lastSunday)); + } + weeksEl.appendChild(fragment); + } + + function prependWeeks(count) { + const oldHeight = weeksEl.scrollHeight; + const fragment = document.createDocumentFragment(); + for (let i = count; i >= 1; i -= 1) fragment.appendChild(createWeek(addDays(firstSunday, -7 * i))); + firstSunday = addDays(firstSunday, -7 * count); + weeksEl.prepend(fragment); + weeksEl.scrollTop += weeksEl.scrollHeight - oldHeight; + } + + function selectDate(button, date) { + selectedButton?.classList.remove("is-selected"); + selectedButton?.removeAttribute("aria-selected"); + selectedButton = button; + button.classList.add("is-selected"); + button.setAttribute("aria-selected", "true"); + selectedLabel.textContent = dayFormatter.format(date); + amountControls.hidden = false; + saveStatus.textContent = savedChoices[iso(date)] === undefined ? "Choose an amount." : `${formatAmount(savedChoices[iso(date)])} saved.`; + customAmount.value = ""; + updateStatusButtons(savedStatuses[iso(date)] || "worked"); + } + + function formatAmount(cents) { + return new Intl.NumberFormat(undefined, { style: "currency", currency: "EUR" }).format(cents / 100); + } + + function applySavedChoice(button) { + const cents = savedChoices[button.dataset.date]; + button.classList.toggle("has-amount", cents !== undefined); + const amountLabel = button.querySelector(".day-amount"); + if (amountLabel) amountLabel.textContent = cents === undefined ? "" : formatAmount(cents); + } + + function applySavedStatus(button) { + const status = savedStatuses[button.dataset.date] || "worked"; + button.classList.toggle("is-vacation", status === "vacation"); + button.classList.toggle("is-sick", status === "sick"); + } + + function updateStatusButtons(status) { + document.querySelectorAll("[data-status]").forEach((button) => { + const selected = button.dataset.status === status; + button.classList.toggle("is-active", selected); + button.setAttribute("aria-pressed", String(selected)); + }); + } + + async function saveAmount(amount) { + if (!selectedButton) return; + saveStatus.textContent = "Saving…"; + try { + const response = await fetch(saveUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ date: selectedButton.dataset.date, amount }), + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || "Could not save the amount."); + savedChoices[result.date] = result.amount_cents; + applySavedChoice(selectedButton); + saveStatus.textContent = `${formatAmount(result.amount_cents)} saved.`; + } catch (error) { + saveStatus.textContent = error.message; + } + } + + async function saveDayStatus(status) { + if (!selectedButton) return; + saveStatus.textContent = "Saving…"; + try { + const response = await fetch(statusUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ date: selectedButton.dataset.date, status }), + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || "Could not save the day status."); + savedStatuses[result.date] = result.status; + applySavedStatus(selectedButton); + updateStatusButtons(result.status); + saveStatus.textContent = `${result.status[0].toUpperCase()}${result.status.slice(1)} saved.`; + } catch (error) { + saveStatus.textContent = error.message; + } + } + + async function deleteDateData() { + if (!selectedButton) return; + const selectedDate = selectedButton.dataset.date; + if (!window.confirm(`Delete all saved data for ${dayFormatter.format(fromISO(selectedDate))}?`)) return; + saveStatus.textContent = "Deleting…"; + try { + const response = await fetch(deleteUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ date: selectedDate }), + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || "Could not delete the date data."); + delete savedChoices[result.date]; + delete savedStatuses[result.date]; + applySavedChoice(selectedButton); + applySavedStatus(selectedButton); + updateStatusButtons("worked"); + customAmount.value = ""; + saveStatus.textContent = "All saved data for this date was deleted."; + } catch (error) { + saveStatus.textContent = error.message; + } + } + + function updateMonth() { + const rows = [...weeksEl.querySelectorAll(".week")]; + const marker = weeksEl.getBoundingClientRect().top + Math.min(100, weeksEl.clientHeight / 3); + const visible = rows.find((row) => row.getBoundingClientRect().bottom >= marker) || rows[0]; + if (visible) monthLabel.textContent = monthFormatter.format(addDays(fromISO(visible.dataset.sunday), 3)); + } + + for (let i = 0; i < 14; i += 1) weeksEl.appendChild(createWeek(addDays(firstSunday, i * 7))); + lastSunday = addDays(firstSunday, 13 * 7); + updateMonth(); + + weeksEl.addEventListener("scroll", () => { + if (loading) return; + const currentScrollTop = weeksEl.scrollTop; + const direction = currentScrollTop > previousScrollTop ? "down" : "up"; + previousScrollTop = currentScrollTop; + loading = true; + requestAnimationFrame(() => { + const now = Date.now(); + const canExpand = now - lastExpansionAt > 250; + if (canExpand && direction === "up" && weeksEl.scrollTop < 80) { + prependWeeks(3); + previousScrollTop = weeksEl.scrollTop; + lastExpansionAt = now; + } else if (canExpand && direction === "down" && weeksEl.scrollHeight - weeksEl.scrollTop - weeksEl.clientHeight < 160) { + appendWeeks(3); + lastExpansionAt = now; + } + updateMonth(); + loading = false; + }); + }, { passive: true }); + + document.querySelectorAll("[data-today-button]").forEach((button) => { + button.addEventListener("click", () => { + const target = weeksEl.querySelector(`[data-date="${iso(today)}"]`); + if (target) { + target.scrollIntoView({ block: "center", behavior: "smooth" }); + selectDate(target, today); + } else { + window.location.reload(); + } + }); + }); + + document.querySelectorAll("[data-amount]").forEach((button) => { + button.addEventListener("click", () => saveAmount(button.dataset.amount)); + }); + + document.querySelectorAll("[data-status]").forEach((button) => { + button.addEventListener("click", () => saveDayStatus(button.dataset.status)); + }); + + document.querySelector("#custom-amount-form").addEventListener("submit", (event) => { + event.preventDefault(); + if (customAmount.reportValidity()) saveAmount(customAmount.value); + }); + + document.querySelector("#delete-date-button").addEventListener("click", deleteDateData); +})(); diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..bccadd7 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,256 @@ +:root { + color-scheme: light; + --paper: #f6f4ef; + --ink: #17211d; + --muted: #6c756f; + --line: #dcded8; + --accent: #d7603e; + --green: #274e42; +} + +* { box-sizing: border-box; } + +html, body { margin: 0; min-height: 100%; } + +body { + background: var(--paper); + color: var(--ink); + font-family: ui-rounded, "SF Pro Rounded", "SF Pro Display", -apple-system, BlinkMacSystemFont, sans-serif; + -webkit-font-smoothing: antialiased; +} + +button, input { font: inherit; } +button { -webkit-tap-highlight-color: transparent; } + +.eyebrow { + margin: 0 0 4px; + color: var(--accent); + font-size: 11px; + font-weight: 800; + letter-spacing: .11em; + text-transform: uppercase; +} + +.login-page { + background: var(--green); + min-height: 100dvh; +} + +.login-shell { + display: grid; + min-height: 100dvh; + place-items: center; + padding: max(24px, env(safe-area-inset-top)) 22px max(24px, env(safe-area-inset-bottom)); +} + +.login-card { + width: min(100%, 390px); + padding: 32px 24px 28px; + border-radius: 26px; + background: var(--paper); + box-shadow: 0 24px 70px #0b1c174d; +} + +.brand-mark { + display: grid; + width: 52px; + height: 52px; + margin-bottom: 24px; + place-items: center; + border-radius: 16px; + background: var(--accent); + color: white; + font-size: 20px; + font-weight: 850; +} + +.login-card h1 { margin: 0; font-size: 32px; letter-spacing: -.04em; } +.login-copy { margin: 8px 0 24px; color: var(--muted); } +.login-card label { display: block; margin: 16px 0 7px; font-size: 13px; font-weight: 750; } +.login-card input { + width: 100%; + min-height: 50px; + padding: 0 14px; + border: 1px solid var(--line); + border-radius: 13px; + background: white; + color: var(--ink); + font-size: 16px; + outline: none; +} +.login-card input:focus { border-color: var(--green); box-shadow: 0 0 0 3px #274e421f; } +.login-card button { + width: 100%; + min-height: 52px; + margin-top: 24px; + border: 0; + border-radius: 14px; + background: var(--green); + color: white; + font-weight: 800; +} +.form-error { padding: 11px 12px; border-radius: 10px; background: #f8ded7; color: #8b2e18; font-size: 13px; } + +.app-shell { height: 100dvh; overflow: hidden; } +.calendar-panel { height: 70dvh; display: flex; flex-direction: column; } +.calendar-header { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + padding: max(14px, env(safe-area-inset-top)) 16px 10px; +} +.calendar-header h1 { margin: 0; font-size: 25px; letter-spacing: -.035em; } +.header-actions { display: flex; align-items: center; gap: 7px; } +.header-actions form { margin: 0; } +.logout-button, .today-button { + min-height: 36px; + padding: 0 13px; + border: 1px solid var(--line); + border-radius: 999px; + background: transparent; + color: var(--ink); + font-size: 12px; + font-weight: 750; +} +.weekday-row, .week { display: grid; grid-template-columns: repeat(7, 1fr); } +.weekday-row { flex: 0 0 auto; padding: 7px 9px; border-bottom: 1px solid var(--line); } +.weekday-row span { color: var(--muted); font-size: 10px; font-weight: 800; text-align: center; text-transform: uppercase; } +.weeks { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior-y: contain; scroll-behavior: auto; scroll-snap-type: y proximity; -webkit-overflow-scrolling: touch; } +.week { min-height: 76px; border-bottom: 1px solid var(--line); scroll-snap-align: start; } +.day { + position: relative; + display: flex; + min-width: 0; + padding: 12px 2px; + flex-direction: column; + align-items: center; + justify-content: center; + border: 0; + border-right: 1px solid var(--line); + background: transparent; + color: var(--ink); + font-size: 16px; + font-weight: 650; +} +.day-number { line-height: 1; } +.day-amount { min-height: 12px; margin-top: 6px; font-size: 9px; font-weight: 800; letter-spacing: -.02em; } +.day.has-amount { background: #ccebd6; color: #173d2c; } +.day.has-amount.is-weekend { color: #9b432d; } +.day.is-vacation, .day.has-amount.is-vacation { background: #f8d5ad; color: #643912; } +.day.is-sick, .day.has-amount.is-sick { background: #fff0a8; color: #594a09; } +.day:last-child { border-right: 0; } +.day.is-weekend { color: var(--accent); } +.day.is-today::after { content: ""; position: absolute; bottom: 10px; width: 5px; height: 5px; border-radius: 50%; background: var(--accent); } +.day.is-selected, .day.has-amount.is-selected, .day.is-vacation.is-selected, .day.is-sick.is-selected { background: var(--green); color: white; } +.day.is-selected::after { background: white; } +.day:active { background: #e8e9e3; } +.day.is-selected:active { background: var(--green); } + +.detail-panel { + height: 30dvh; + padding: 14px 16px max(10px, env(safe-area-inset-bottom)); + border-top: 1px solid #355e51; + background: var(--green); + color: white; +} +.detail-panel .eyebrow { color: #f19c7f; } +.selected-date { margin: 4px 0 2px; font-family: ui-serif, Georgia, serif; font-size: clamp(21px, 6vw, 30px); line-height: 1.05; letter-spacing: -.03em; } +.today-button { border-color: #6d897f; color: white; } +.calendar-header .today-button { border-color: var(--green); color: var(--green); } +.amount-controls[hidden] { display: none; } +.status-options { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; } +.amount-row { display: grid; grid-template-columns: auto auto auto minmax(0, 1fr); gap: 5px; margin-top: 7px; } +.amount-row > button, .status-options button, .custom-amount-form > button { + min-height: 34px; + border: 1px solid #789187; + border-radius: 10px; + background: #345f51; + color: white; + font-size: 12px; + font-weight: 800; +} +.status-options button { background: transparent; } +.status-options button.is-active { border-color: white; background: white; color: var(--green); } +.amount-row > button { padding: 0 8px; } +.custom-amount-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 5px; align-items: center; } +.amount-input { display: flex; height: 34px; align-items: center; border-radius: 10px; background: white; color: var(--ink); overflow: hidden; } +.amount-input span { padding-left: 9px; font-size: 13px; } +.amount-input input { width: 100%; min-width: 0; height: 100%; padding: 0 8px 0 3px; border: 0; outline: 0; background: transparent; color: var(--ink); font-size: 16px; } +.custom-amount-form > button { padding: 0 13px; } +.report-row { display: flex; justify-content: flex-end; margin-bottom: 7px; } +.delete-row { display: flex; justify-content: flex-end; margin-top: 7px; } +.report-button { + display: flex; + min-height: 32px; + padding: 0 14px; + align-items: center; + justify-content: center; + border: 1px solid #9bb1a9; + border-radius: 10px; + background: #466f61; + color: white; + font-size: 11px; + font-weight: 800; + text-decoration: none; +} +.report-row .report-button { min-width: 92px; } +.delete-row button { + min-height: 28px; + padding: 0 10px; + border: 1px solid #8e6f66; + border-radius: 8px; + background: transparent; + color: #d9b1a5; + font-size: 10px; + font-weight: 650; +} +.delete-row button:active { background: #623d33; color: white; } +.save-status { min-height: 14px; margin: 5px 0 0; color: #c9d6d1; font-size: 10px; } + +.report-page { min-height: 100dvh; background: var(--paper); } +.report-shell { width: min(100%, 560px); min-height: 100dvh; margin: 0 auto; padding: max(14px, env(safe-area-inset-top)) 18px max(24px, env(safe-area-inset-bottom)); } +.report-header { display: flex; align-items: center; justify-content: space-between; } +.report-header form { margin: 0; } +.back-button { color: var(--green); font-size: 13px; font-weight: 800; text-decoration: none; } +.report-intro { padding: 36px 2px 22px; } +.report-intro h1 { margin: 0; font-family: ui-serif, Georgia, serif; font-size: 38px; letter-spacing: -.04em; } +.report-intro > p:last-child { margin: 8px 0 0; color: var(--muted); font-size: 14px; } +.year-navigation { display: grid; grid-template-columns: 42px 1fr 42px; gap: 8px; align-items: center; } +.year-navigation h1 { text-align: center; } +.year-navigation a { + display: grid; + width: 42px; + height: 42px; + place-items: center; + border: 1px solid var(--line); + border-radius: 50%; + background: white; + color: var(--green); + font-size: 20px; + font-weight: 800; + text-decoration: none; +} +.year-navigation a:active { background: #e8e9e3; } +.report-categories { display: grid; gap: 10px; } +.report-card { padding: 18px; border: 1px solid var(--line); border-radius: 18px; } +.report-card--worked { background: white; } +.report-card--vacation { background: #f8d5ad; border-color: #efbd84; } +.report-card--sick { background: #fff0a8; border-color: #ead66f; } +.category-title { display: flex; align-items: center; gap: 8px; } +.category-title h2 { margin: 0; font-size: 14px; } +.category-swatch { width: 10px; height: 10px; border-radius: 50%; background: var(--paper); border: 1px solid var(--line); } +.report-card--vacation .category-swatch { background: #e98b3d; border: 0; } +.report-card--sick .category-swatch { background: #d8b91f; border: 0; } +.category-amount { margin: 14px 0 2px; font-family: ui-serif, Georgia, serif; font-size: 30px; font-weight: 700; letter-spacing: -.03em; } +.category-days { margin: 0; color: #59635e; font-size: 12px; } +.report-total { display: flex; align-items: end; justify-content: space-between; margin-top: 16px; padding: 20px; border-radius: 18px; background: var(--green); color: white; } +.report-total .eyebrow { color: #f19c7f; } +.total-amount { margin: 4px 0 0; font-family: ui-serif, Georgia, serif; font-size: 32px; } +.total-days { margin: 0 0 5px; color: #c9d6d1; font-size: 12px; } + +@media (min-width: 700px) { + .app-shell { width: min(100%, 520px); margin: 0 auto; border-inline: 1px solid var(--line); } +} + +@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } } diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..b2a105c --- /dev/null +++ b/templates/base.html @@ -0,0 +1,13 @@ + + + + + + + {% block title %}Fatimas Calendar{% endblock %} + + + + {% block content %}{% endblock %} + + diff --git a/templates/calendar.html b/templates/calendar.html new file mode 100644 index 0000000..fea8cdf --- /dev/null +++ b/templates/calendar.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% block body_class %}calendar-page{% endblock %} +{% block content %} +
+
+
+
+

Fatimas calendar

+

Calendar

+
+
+ +
+ +
+
+
+ + +
+
+ +
+

Tap a day

+
+ Report +
+ +
+
+ + + +{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..40b8b79 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block title %}Sign in · Fatimas Calendar{% endblock %} +{% block body_class %}login-page{% endblock %} +{% block content %} +
+ +
+{% endblock %} diff --git a/templates/report.html b/templates/report.html new file mode 100644 index 0000000..2c25210 --- /dev/null +++ b/templates/report.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}{{ year }} report · Perpetual Calendar{% endblock %} +{% block body_class %}report-page{% endblock %} +{% block content %} +
+
+ ← Calendar +
+ +
+
+ +
+

Yearly overview

+
+ +

{{ year }} report

+ +
+

Money and recorded days grouped by day category.

+
+ +
+ {% for category in categories %} +
+
+ +

{{ category.label }}

+
+

€{{ "%.2f" | format(category.amount_cents / 100) }}

+

{{ category.days }} {{ "day" if category.days == 1 else "days" }}

+
+ {% endfor %} +
+ +
+
+

Annual total

+

€{{ "%.2f" | format(total_cents / 100) }}

+
+

{{ total_days }} recorded {{ "day" if total_days == 1 else "days" }}

+
+
+{% endblock %} diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..f1612d4 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,119 @@ +import tempfile + +from app import create_app + + +def client(): + database = tempfile.NamedTemporaryFile(suffix=".sqlite") + app = create_app({ + "TESTING": True, + "SECRET_KEY": "test-secret", + "APP_USERNAME": "user", + "APP_PASSWORD": "pass", + "DATABASE": database.name, + }) + test_client = app.test_client() + test_client._database_file = database + return test_client + + +def test_calendar_requires_login(): + response = client().get("/") + assert response.status_code == 302 + assert response.headers["Location"].endswith("/login") + + +def test_login_and_logout(): + test_client = client() + response = test_client.post("/login", data={"username": "user", "password": "pass"}) + assert response.status_code == 302 + assert response.headers["Location"].endswith("/") + assert test_client.get("/").status_code == 200 + assert test_client.post("/logout").headers["Location"].endswith("/login") + + +def test_bad_login_shows_error(): + response = client().post("/login", data={"username": "user", "password": "wrong"}) + assert response.status_code == 200 + assert b"not correct" in response.data + + +def test_choice_is_saved_and_rendered(): + test_client = client() + test_client.post("/login", data={"username": "user", "password": "pass"}) + response = test_client.post("/api/choices", json={"date": "2026-07-15", "amount": "27.50"}) + assert response.status_code == 200 + assert response.json["amount_cents"] == 2750 + assert b'"2026-07-15": 2750' in test_client.get("/").data + + +def test_choice_rejects_invalid_amount(): + test_client = client() + test_client.post("/login", data={"username": "user", "password": "pass"}) + response = test_client.post("/api/choices", json={"date": "2026-07-15", "amount": "-1"}) + assert response.status_code == 400 + + +def test_day_status_is_saved_and_rendered(): + test_client = client() + test_client.post("/login", data={"username": "user", "password": "pass"}) + response = test_client.post( + "/api/statuses", + json={"date": "2026-07-15", "status": "vacation"}, + ) + assert response.status_code == 200 + assert response.json["status"] == "vacation" + assert b'"2026-07-15": "vacation"' in test_client.get("/").data + + +def test_day_status_rejects_unknown_value(): + test_client = client() + test_client.post("/login", data={"username": "user", "password": "pass"}) + response = test_client.post( + "/api/statuses", + json={"date": "2026-07-15", "status": "elsewhere"}, + ) + assert response.status_code == 400 + + +def test_delete_removes_amount_and_status(): + test_client = client() + test_client.post("/login", data={"username": "user", "password": "pass"}) + test_client.post("/api/choices", json={"date": "2026-07-15", "amount": "55"}) + test_client.post("/api/statuses", json={"date": "2026-07-15", "status": "sick"}) + + response = test_client.post("/api/delete-date", json={"date": "2026-07-15"}) + assert response.status_code == 200 + assert response.json["deleted"] is True + page = test_client.get("/").data + assert b'"2026-07-15": 5500' not in page + assert b'"2026-07-15": "sick"' not in page + + +def test_report_groups_money_and_days_by_status(): + from datetime import date + + test_client = client() + test_client.post("/login", data={"username": "user", "password": "pass"}) + year = date.today().year + worked_date = f"{year}-01-10" + vacation_date = f"{year}-02-10" + test_client.post("/api/choices", json={"date": worked_date, "amount": "55"}) + test_client.post("/api/choices", json={"date": vacation_date, "amount": "27.50"}) + test_client.post("/api/statuses", json={"date": vacation_date, "status": "vacation"}) + + response = test_client.get("/report") + assert response.status_code == 200 + assert b"\xe2\x82\xac55.00" in response.data + assert b"\xe2\x82\xac27.50" in response.data + assert b"2 recorded days" in response.data + + +def test_report_can_navigate_to_another_year(): + test_client = client() + test_client.post("/login", data={"username": "user", "password": "pass"}) + response = test_client.get("/report?year=2025") + assert response.status_code == 200 + assert b"2025 report" in response.data + assert b"year=2024" in response.data + assert b"year=2026" in response.data