first commit
14
.dockerignore
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.git/
|
||||||
|
data/
|
||||||
|
instance/
|
||||||
|
node_modules/
|
||||||
11
.env.example
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
SECRET_KEY=change-me
|
||||||
|
PORT=8000
|
||||||
|
DATA_DIR=./data
|
||||||
|
DATABASE_PATH=./data/putzliga.db
|
||||||
|
UPLOAD_FOLDER=./data/uploads
|
||||||
|
APP_BASE_URL=http://localhost:8000
|
||||||
|
APP_TIMEZONE=Europe/Berlin
|
||||||
|
VAPID_PUBLIC_KEY=
|
||||||
|
VAPID_PRIVATE_KEY=
|
||||||
|
VAPID_CLAIMS_SUBJECT=mailto:admin@example.com
|
||||||
|
GUNICORN_WORKERS=2
|
||||||
19
.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
data/
|
||||||
|
instance/
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.push.local
|
||||||
|
.cloudron-push.env
|
||||||
24
CloudronManifest.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"id": "io.putzliga.app",
|
||||||
|
"title": "Putzliga",
|
||||||
|
"author": "hnzio <mail@example.com>",
|
||||||
|
"description": "Spielerische Haushalts-App mit Aufgaben, Punkten, Monats-Highscore, Kalender und PWA-Push.",
|
||||||
|
"tagline": "Haushalt mit Liga-Gefühl",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"manifestVersion": 2,
|
||||||
|
"healthCheckPath": "/healthz",
|
||||||
|
"httpPort": 8000,
|
||||||
|
"addons": {
|
||||||
|
"localstorage": {}
|
||||||
|
},
|
||||||
|
"contactEmail": "admin@example.com",
|
||||||
|
"icon": "file://icon.png",
|
||||||
|
"tags": [
|
||||||
|
"household",
|
||||||
|
"tasks",
|
||||||
|
"pwa",
|
||||||
|
"productivity",
|
||||||
|
"flask"
|
||||||
|
],
|
||||||
|
"memoryLimit": 268435456
|
||||||
|
}
|
||||||
16
Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN chmod +x /app/start.sh
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["./start.sh"]
|
||||||
285
README.md
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
# Putzliga
|
||||||
|
|
||||||
|
Putzliga ist eine moderne, leichte Haushaltsaufgaben-Web-App mit spielerischem Charakter. Version 1 setzt auf Flask, SQLite, Jinja-Templates, responsives CSS, minimales JavaScript und eine saubere PWA-/Web-Push-Basis. Die App ist für mehrere Nutzer ausgelegt, läuft lokal sehr unkompliziert und ist so vorbereitet, dass sie auf Cloudron direkt als Container-App betrieben werden kann.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Mehrere Nutzer mit Login, Registrierung und Profil-/Avatar-Einstellungen
|
||||||
|
- Trennung zwischen `TaskTemplate` und `TaskInstance`
|
||||||
|
- Aufgaben anlegen, bearbeiten, zuweisen und erledigen
|
||||||
|
- Wiederholungen für einmalig, alle X Tage, alle X Wochen und alle X Monate
|
||||||
|
- Saubere Erledigungslogik für fremd zugewiesene Aufgaben mit Auswahl, wer wirklich erledigt hat
|
||||||
|
- Statuslogik für offen, bald fällig, überfällig und erledigt
|
||||||
|
- `Meine Aufgaben`, `Alle Aufgaben`, `Aufgabe erstellen`, `Kalender/Liste`, `Highscoreboard`, `Optionen`
|
||||||
|
- Monats-Highscore mit Badge-Boni und Balkendarstellung
|
||||||
|
- Monatsarchiv über `MonthlyScoreSnapshot`
|
||||||
|
- PWA mit `manifest.json`, Service Worker, App-Icons und iOS-freundlicher Installationsbasis
|
||||||
|
- Echte Web-Push-Architektur mit gespeicherten `PushSubscription`s
|
||||||
|
- CLI-Kommandos für Archivierung und serverseitig triggerbare Benachrichtigungen
|
||||||
|
- Cloudron-/Container-tauglicher Start mit `start.sh`, `Dockerfile` und `CloudronManifest.json`
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
routes/
|
||||||
|
services/
|
||||||
|
static/
|
||||||
|
css/
|
||||||
|
fonts/
|
||||||
|
icons/
|
||||||
|
images/
|
||||||
|
js/
|
||||||
|
manifest.json
|
||||||
|
service-worker.js
|
||||||
|
templates/
|
||||||
|
auth/
|
||||||
|
partials/
|
||||||
|
scoreboard/
|
||||||
|
settings/
|
||||||
|
tasks/
|
||||||
|
app.py
|
||||||
|
config.py
|
||||||
|
seed.py
|
||||||
|
start.sh
|
||||||
|
Dockerfile
|
||||||
|
CloudronManifest.json
|
||||||
|
requirements.txt
|
||||||
|
.env.example
|
||||||
|
scripts/
|
||||||
|
data/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lokale Daten vs. Cloudron-Inhalte
|
||||||
|
|
||||||
|
Die App ist jetzt so vorbereitet, dass lokale Entwicklungsdaten nicht versehentlich mit nach Cloudron wandern:
|
||||||
|
|
||||||
|
- `data/` ist in `.gitignore` ausgeschlossen und wird nicht committed
|
||||||
|
- `data/` ist zusätzlich in `.dockerignore` ausgeschlossen und landet nicht im Docker-Build-Kontext
|
||||||
|
- Uploads liegen standardmäßig ebenfalls unter `data/uploads` und bleiben damit lokal bzw. im persistenten Cloudron-Storage
|
||||||
|
- Beim ersten Cloudron-Start wird keine lokale Entwicklungsdatenbank ins Image kopiert
|
||||||
|
|
||||||
|
Damit kannst du lokal mit Seed-Daten entwickeln und online unabhängig davon echte Inhalte pflegen.
|
||||||
|
|
||||||
|
## Lokales Setup
|
||||||
|
|
||||||
|
### 1. Abhängigkeiten installieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
. .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Umgebungsvariablen setzen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Wichtige Variablen:
|
||||||
|
|
||||||
|
- `SECRET_KEY`: Flask Secret Key
|
||||||
|
- `DATA_DIR`: Persistentes Datenverzeichnis
|
||||||
|
- `DATABASE_PATH`: SQLite-Datei
|
||||||
|
- `UPLOAD_FOLDER`: Upload-Verzeichnis für Avatare
|
||||||
|
- `APP_BASE_URL`: Vollständige Basis-URL der App, wichtig für Push-Links
|
||||||
|
- `APP_TIMEZONE`: Standardmäßig `Europe/Berlin`
|
||||||
|
- `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY`: Web-Push-Schlüssel
|
||||||
|
- `VAPID_CLAIMS_SUBJECT`: Kontaktadresse für VAPID
|
||||||
|
|
||||||
|
### 3. App-Icons erzeugen
|
||||||
|
|
||||||
|
Die Raster-Icons liegen als generierte Dateien im Projekt. Falls du sie neu erzeugen willst:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/generate_assets.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Datenbank und Seed-Daten anlegen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flask --app app.py init-db
|
||||||
|
python seed.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Demo-Logins:
|
||||||
|
|
||||||
|
- `anna@putzliga.local` / `putzliga123`
|
||||||
|
- `ben@putzliga.local` / `putzliga123`
|
||||||
|
|
||||||
|
### 5. Entwicklungsserver starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flask --app app.py run --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
oder produktionsnah:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Seed-Daten
|
||||||
|
|
||||||
|
Die Seed-Datei erzeugt:
|
||||||
|
|
||||||
|
- 2 Beispielnutzer
|
||||||
|
- wiederkehrende und einmalige Vorlagen
|
||||||
|
- offene, bald fällige, überfällige und erledigte Aufgaben
|
||||||
|
- Punkte im aktuellen Monat
|
||||||
|
- erledigte Aufgaben aus dem Vormonat, damit das Archiv direkt sichtbar ist
|
||||||
|
- Standard-Badges für Frühstarter, Serien und Monatsmenge
|
||||||
|
|
||||||
|
## Datenmodell
|
||||||
|
|
||||||
|
Umgesetzt sind die Kernmodelle:
|
||||||
|
|
||||||
|
- `User`
|
||||||
|
- `TaskTemplate`
|
||||||
|
- `TaskInstance`
|
||||||
|
- `MonthlyScoreSnapshot`
|
||||||
|
- `PushSubscription`
|
||||||
|
- `NotificationLog`
|
||||||
|
|
||||||
|
Zusätzlich für Version 1:
|
||||||
|
|
||||||
|
- `BadgeDefinition` für pflegbare Badge-Regeln in den Optionen
|
||||||
|
|
||||||
|
Wichtig: `TaskTemplate` beschreibt die wiederverwendbare Vorlage, `TaskInstance` die konkrete Aufgabe mit Fälligkeit, Status und tatsächlicher Erledigung.
|
||||||
|
|
||||||
|
## Monatsarchivierung
|
||||||
|
|
||||||
|
Putzliga speichert keine monatlichen Punktetotale als Live-Zähler. Stattdessen wird der Monatsstand aus erledigten `TaskInstance`s berechnet. Dadurch startet jeder neue Monat automatisch bei 0, weil nur Aufgaben des aktuellen Monats zählen.
|
||||||
|
|
||||||
|
Die Archivierung funktioniert so:
|
||||||
|
|
||||||
|
- Vor Requests wird geprüft, ob bis zum Vormonat Archive fehlen
|
||||||
|
- Fehlende Monate werden als `MonthlyScoreSnapshot` erzeugt
|
||||||
|
- Archivwerte enthalten erledigte Aufgaben und Badge-Boni des jeweiligen Monats
|
||||||
|
- Frühere Monate bleiben dauerhaft sichtbar
|
||||||
|
|
||||||
|
Zusätzlicher CLI-Trigger:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flask --app app.py archive-months
|
||||||
|
```
|
||||||
|
|
||||||
|
## Push-Benachrichtigungen
|
||||||
|
|
||||||
|
Putzliga nutzt echte Web-Push-Benachrichtigungen mit Service Worker und VAPID.
|
||||||
|
|
||||||
|
### Architektur
|
||||||
|
|
||||||
|
- Browser registriert Service Worker
|
||||||
|
- Subscription wird pro Nutzer in `PushSubscription` gespeichert
|
||||||
|
- Server versendet über `pywebpush`
|
||||||
|
- Versand ist getrennt in Prüf- und Ausführungslogik
|
||||||
|
- Logs werden in `NotificationLog` dedupliziert
|
||||||
|
|
||||||
|
### Verfügbare Trigger
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flask --app app.py notify-due
|
||||||
|
flask --app app.py notify-monthly-winner
|
||||||
|
```
|
||||||
|
|
||||||
|
`notify-due`:
|
||||||
|
|
||||||
|
- prüft offene Aufgaben, die heute oder morgen fällig sind
|
||||||
|
- berücksichtigt die Nutzeroption `notification_task_due_enabled`
|
||||||
|
|
||||||
|
`notify-monthly-winner`:
|
||||||
|
|
||||||
|
- sendet am 1. des Monats ab 09:00 Uhr
|
||||||
|
- verweist auf das Scoreboard/Archiv des letzten Monats
|
||||||
|
- berücksichtigt `notification_monthly_winner_enabled`
|
||||||
|
|
||||||
|
### Produktiver Betrieb
|
||||||
|
|
||||||
|
Auf Cloudron oder einem anderen Server solltest du diese Kommandos regelmäßig per Cronjob oder Task ausführen, zum Beispiel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flask --app /app/app.py notify-due
|
||||||
|
flask --app /app/app.py notify-monthly-winner
|
||||||
|
```
|
||||||
|
|
||||||
|
### iPhone-/iOS-Hinweis
|
||||||
|
|
||||||
|
Web-Push auf iPhone/iPad funktioniert nur in neueren iOS-/iPadOS-Versionen, wenn die Web-App über Safari zum Home-Bildschirm hinzugefügt wurde. Innerhalb eines normalen Safari-Tabs stehen Push-Berechtigungen nicht zuverlässig zur Verfügung.
|
||||||
|
|
||||||
|
## PWA
|
||||||
|
|
||||||
|
Enthalten sind:
|
||||||
|
|
||||||
|
- `app/static/manifest.json`
|
||||||
|
- `app/static/service-worker.js`
|
||||||
|
- `app/static/images/pwa-icon-192.png`
|
||||||
|
- `app/static/images/pwa-icon-512.png`
|
||||||
|
- `app/static/images/apple-touch-icon.png`
|
||||||
|
- `app/static/images/pwa-badge.png`
|
||||||
|
|
||||||
|
Der Service Worker cached die App-Shell und Assets pragmatisch für eine stabile Basis. Für Version 1 ist das bewusst schlank gehalten.
|
||||||
|
|
||||||
|
## Branding und Assets aus `heinz.marketing`
|
||||||
|
|
||||||
|
Aus `../heinz.marketing` wurden bewusst nur verwertbare Grundlagen übernommen:
|
||||||
|
|
||||||
|
- `Inter` und `Space Grotesk` aus `css/fonts/`
|
||||||
|
- ausgewählte lokale SVG-Icons aus `css/fontawesome-pro-plus-7.0.0-web/svgs-full/chisel-regular/`
|
||||||
|
|
||||||
|
Diese Assets wurden nicht unverändert als fertiges Branding übernommen. Putzliga nutzt darauf aufbauend:
|
||||||
|
|
||||||
|
- eine eigene helle, iOS-nahe Farbwelt
|
||||||
|
- ein neues App-Logo (`app/static/images/logo.svg`)
|
||||||
|
- ein eigenes Favicon (`app/static/images/favicon.svg`)
|
||||||
|
- eigene generierte PWA-Raster-Icons (`scripts/generate_assets.py`)
|
||||||
|
|
||||||
|
## Cloudron
|
||||||
|
|
||||||
|
Cloudron-Dateien im Projekt:
|
||||||
|
|
||||||
|
- `Dockerfile`
|
||||||
|
- `start.sh`
|
||||||
|
- `CloudronManifest.json`
|
||||||
|
|
||||||
|
Die Manifest- und Docker-Struktur orientiert sich an der aktuellen Cloudron-Dokumentation für Docker-/Container-Apps:
|
||||||
|
https://docs.cloudron.io/docker/
|
||||||
|
|
||||||
|
### Wichtige Punkte für Cloudron
|
||||||
|
|
||||||
|
- App hört auf `PORT` und standardmäßig auf `8000`
|
||||||
|
- `DATA_DIR` und `UPLOAD_FOLDER` sollten im persistenten Storage liegen
|
||||||
|
- SQLite-Datei liegt standardmäßig unter `/app/data/putzliga.db`
|
||||||
|
- `start.sh` initialisiert die DB und startet Gunicorn
|
||||||
|
- `APP_BASE_URL` kann auf Cloudron über `CLOUDRON_APP_ORIGIN` gesetzt oder daraus abgeleitet werden
|
||||||
|
- Lokale Testdaten aus `data/` werden weder committed noch in das Docker-Image gepackt
|
||||||
|
|
||||||
|
### Beispielstart in Cloudron-/Container-Umgebungen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hilfsskripte
|
||||||
|
|
||||||
|
VAPID-Schlüssel generieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/generate_vapid_keys.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Der ausgegebene `VAPID_PRIVATE_KEY` ist bereits `.env`-freundlich mit escaped Newlines formatiert. `config.py` wandelt `\\n` beim Start automatisch in echte Zeilenumbrüche zurück.
|
||||||
|
|
||||||
|
Icons neu generieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/generate_assets.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hinweise für spätere Erweiterungen
|
||||||
|
|
||||||
|
- Maluslogik für verspätete Erledigungen kann an `compute_monthly_scores()` und `TaskInstance` ergänzt werden
|
||||||
|
- echte Admin-/Rollenrechte können ergänzt werden, aktuell dürfen bewusst alle Nutzer Aufgaben pflegen
|
||||||
|
- Scheduler kann auf Cloudron später als separater Task sauber ausgelagert werden
|
||||||
|
- Badge-Awards könnten in einer eigenen Tabelle historisiert werden, falls spätere Regeln rückwirkungsfrei versioniert werden sollen
|
||||||
9
app.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from app import create_app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=app.config["PORT"], debug=True)
|
||||||
|
|
||||||
72
app/__init__.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
|
||||||
|
from .cli import register_cli, seed_badges
|
||||||
|
from .extensions import csrf, db, login_manager
|
||||||
|
from .routes import auth, main, scoreboard, settings, tasks
|
||||||
|
from .routes.main import load_icon_svg
|
||||||
|
from .services.dates import MONTH_NAMES, local_now
|
||||||
|
from .services.monthly import archive_months_missing_up_to_previous
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(config_class: type[Config] = Config) -> Flask:
|
||||||
|
app = Flask(__name__, static_folder="static", template_folder="templates")
|
||||||
|
app.config.from_object(config_class)
|
||||||
|
|
||||||
|
app.config["DATA_DIR"].mkdir(parents=True, exist_ok=True)
|
||||||
|
app.config["UPLOAD_FOLDER"].mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
db.init_app(app)
|
||||||
|
login_manager.init_app(app)
|
||||||
|
csrf.init_app(app)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
seed_badges()
|
||||||
|
|
||||||
|
register_cli(app)
|
||||||
|
|
||||||
|
app.register_blueprint(main.bp)
|
||||||
|
app.register_blueprint(auth.bp)
|
||||||
|
app.register_blueprint(tasks.bp)
|
||||||
|
app.register_blueprint(scoreboard.bp)
|
||||||
|
app.register_blueprint(settings.bp)
|
||||||
|
|
||||||
|
app.jinja_env.globals["icon_svg"] = lambda name: load_icon_svg(name, app.static_folder)
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def ensure_archives():
|
||||||
|
archive_months_missing_up_to_previous()
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_globals():
|
||||||
|
return {
|
||||||
|
"app_name": app.config["APP_NAME"],
|
||||||
|
"nav_items": [
|
||||||
|
("tasks.my_tasks", "Meine Aufgaben", "house"),
|
||||||
|
("tasks.all_tasks", "Alle", "list"),
|
||||||
|
("tasks.create", "Neu", "plus"),
|
||||||
|
("tasks.calendar_view", "Kalender", "calendar"),
|
||||||
|
("scoreboard.index", "Highscore", "trophy"),
|
||||||
|
("settings.index", "Optionen", "gear"),
|
||||||
|
],
|
||||||
|
"icon_svg": lambda name: load_icon_svg(name, app.static_folder),
|
||||||
|
"now_local": local_now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.template_filter("date_de")
|
||||||
|
def date_de(value):
|
||||||
|
return value.strftime("%d.%m.%Y") if value else "—"
|
||||||
|
|
||||||
|
@app.template_filter("datetime_de")
|
||||||
|
def datetime_de(value):
|
||||||
|
return value.strftime("%d.%m.%Y, %H:%M") if value else "—"
|
||||||
|
|
||||||
|
@app.template_filter("month_name")
|
||||||
|
def month_name(value):
|
||||||
|
return MONTH_NAMES[value]
|
||||||
|
|
||||||
|
return app
|
||||||
71
app/cli.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from .extensions import db
|
||||||
|
from .models import BadgeDefinition
|
||||||
|
from .services.monthly import archive_months_missing_up_to_previous
|
||||||
|
from .services.notifications import send_due_notifications, send_monthly_winner_notifications
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_BADGES = [
|
||||||
|
{
|
||||||
|
"key": "early_bird",
|
||||||
|
"name": "Frühstarter",
|
||||||
|
"description": "Erledige 3 Aufgaben vor ihrem Fälligkeitsdatum.",
|
||||||
|
"icon_name": "bell",
|
||||||
|
"trigger_type": "early_finisher_count",
|
||||||
|
"threshold": 3,
|
||||||
|
"bonus_points": 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "on_time_streak",
|
||||||
|
"name": "Sauberer Lauf",
|
||||||
|
"description": "Erledige Aufgaben an 3 Tagen in Folge.",
|
||||||
|
"icon_name": "check",
|
||||||
|
"trigger_type": "streak_days",
|
||||||
|
"threshold": 3,
|
||||||
|
"bonus_points": 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "task_sprinter",
|
||||||
|
"name": "Putz-Sprinter",
|
||||||
|
"description": "Schließe 8 Aufgaben in einem Monat ab.",
|
||||||
|
"icon_name": "trophy",
|
||||||
|
"trigger_type": "monthly_task_count",
|
||||||
|
"threshold": 8,
|
||||||
|
"bonus_points": 20,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def seed_badges() -> None:
|
||||||
|
for payload in DEFAULT_BADGES:
|
||||||
|
badge = BadgeDefinition.query.filter_by(key=payload["key"]).first()
|
||||||
|
if not badge:
|
||||||
|
db.session.add(BadgeDefinition(**payload))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def register_cli(app) -> None:
|
||||||
|
@app.cli.command("init-db")
|
||||||
|
def init_db_command():
|
||||||
|
db.create_all()
|
||||||
|
seed_badges()
|
||||||
|
click.echo("Datenbank und Standard-Badges sind bereit.")
|
||||||
|
|
||||||
|
@app.cli.command("archive-months")
|
||||||
|
def archive_months_command():
|
||||||
|
archive_months_missing_up_to_previous()
|
||||||
|
click.echo("Monatsarchiv wurde geprüft.")
|
||||||
|
|
||||||
|
@app.cli.command("notify-due")
|
||||||
|
def notify_due_command():
|
||||||
|
result = send_due_notifications()
|
||||||
|
click.echo(f"Due-Push: sent={result.sent} skipped={result.skipped} failed={result.failed}")
|
||||||
|
|
||||||
|
@app.cli.command("notify-monthly-winner")
|
||||||
|
def notify_monthly_winner_command():
|
||||||
|
result = send_monthly_winner_notifications()
|
||||||
|
click.echo(f"Winner-Push: sent={result.sent} skipped={result.skipped} failed={result.failed}")
|
||||||
|
|
||||||
12
app/extensions.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from flask_login import LoginManager
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_wtf import CSRFProtect
|
||||||
|
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
|
login_manager = LoginManager()
|
||||||
|
login_manager.login_view = "auth.login"
|
||||||
|
login_manager.login_message = "Bitte melde dich an, um Putzliga zu nutzen."
|
||||||
|
login_manager.login_message_category = "info"
|
||||||
|
csrf = CSRFProtect()
|
||||||
|
|
||||||
99
app/forms.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from flask_wtf.file import FileAllowed, FileField
|
||||||
|
from wtforms import (
|
||||||
|
BooleanField,
|
||||||
|
DateField,
|
||||||
|
EmailField,
|
||||||
|
IntegerField,
|
||||||
|
PasswordField,
|
||||||
|
SelectField,
|
||||||
|
StringField,
|
||||||
|
SubmitField,
|
||||||
|
TextAreaField,
|
||||||
|
)
|
||||||
|
from wtforms.validators import DataRequired, Email, EqualTo, Length, NumberRange, Optional, ValidationError
|
||||||
|
|
||||||
|
from .models import User
|
||||||
|
|
||||||
|
|
||||||
|
class LoginForm(FlaskForm):
|
||||||
|
email = EmailField("E-Mail", validators=[DataRequired(), Email(), Length(max=255)])
|
||||||
|
password = PasswordField("Passwort", validators=[DataRequired(), Length(min=6, max=128)])
|
||||||
|
remember_me = BooleanField("Angemeldet bleiben")
|
||||||
|
submit = SubmitField("Einloggen")
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterForm(FlaskForm):
|
||||||
|
name = StringField("Name", validators=[DataRequired(), Length(min=2, max=120)])
|
||||||
|
email = EmailField("E-Mail", validators=[DataRequired(), Email(), Length(max=255)])
|
||||||
|
password = PasswordField("Passwort", validators=[DataRequired(), Length(min=6, max=128)])
|
||||||
|
password_confirm = PasswordField(
|
||||||
|
"Passwort wiederholen",
|
||||||
|
validators=[DataRequired(), EqualTo("password", message="Die Passwörter stimmen nicht überein.")],
|
||||||
|
)
|
||||||
|
submit = SubmitField("Konto erstellen")
|
||||||
|
|
||||||
|
def validate_email(self, field) -> None:
|
||||||
|
if User.query.filter_by(email=field.data.lower().strip()).first():
|
||||||
|
raise ValidationError("Diese E-Mail-Adresse ist bereits vergeben.")
|
||||||
|
|
||||||
|
|
||||||
|
class TaskForm(FlaskForm):
|
||||||
|
title = StringField("Titel", validators=[DataRequired(), Length(min=2, max=160)])
|
||||||
|
description = TextAreaField("Beschreibung", validators=[Optional(), Length(max=2000)])
|
||||||
|
default_points = IntegerField("Punkte", validators=[DataRequired(), NumberRange(min=1, max=500)], default=10)
|
||||||
|
assigned_user_id = SelectField("Zugewiesen an", coerce=int, validators=[DataRequired()])
|
||||||
|
due_date = DateField("Fälligkeitsdatum", format="%Y-%m-%d", validators=[DataRequired()])
|
||||||
|
recurrence_interval_value = IntegerField(
|
||||||
|
"Intervallwert",
|
||||||
|
validators=[Optional(), NumberRange(min=1, max=365)],
|
||||||
|
default=1,
|
||||||
|
)
|
||||||
|
recurrence_interval_unit = SelectField(
|
||||||
|
"Wiederholung",
|
||||||
|
choices=[
|
||||||
|
("none", "Einmalig"),
|
||||||
|
("days", "Alle X Tage"),
|
||||||
|
("weeks", "Alle X Wochen"),
|
||||||
|
("months", "Alle X Monate"),
|
||||||
|
],
|
||||||
|
validators=[DataRequired()],
|
||||||
|
)
|
||||||
|
active = BooleanField("Vorlage aktiv", default=True)
|
||||||
|
submit = SubmitField("Speichern")
|
||||||
|
|
||||||
|
def validate(self, extra_validators=None):
|
||||||
|
valid = super().validate(extra_validators=extra_validators)
|
||||||
|
if not valid:
|
||||||
|
return False
|
||||||
|
if self.recurrence_interval_unit.data != "none" and not self.recurrence_interval_value.data:
|
||||||
|
self.recurrence_interval_value.errors.append("Bitte gib einen Intervallwert an.")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsProfileForm(FlaskForm):
|
||||||
|
name = StringField("Name", validators=[DataRequired(), Length(min=2, max=120)])
|
||||||
|
email = EmailField("E-Mail", validators=[DataRequired(), Email(), Length(max=255)])
|
||||||
|
password = PasswordField("Neues Passwort", validators=[Optional(), Length(min=6, max=128)])
|
||||||
|
avatar = FileField(
|
||||||
|
"Avatar",
|
||||||
|
validators=[Optional(), FileAllowed(["png", "jpg", "jpeg", "gif", "webp", "svg"], "Bitte ein Bild hochladen.")],
|
||||||
|
)
|
||||||
|
notification_task_due_enabled = BooleanField("Push bei bald fälligen Aufgaben")
|
||||||
|
notification_monthly_winner_enabled = BooleanField("Push zum Monatssieger")
|
||||||
|
submit = SubmitField("Einstellungen speichern")
|
||||||
|
|
||||||
|
def __init__(self, original_email: str | None = None, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.original_email = original_email
|
||||||
|
|
||||||
|
def validate_email(self, field) -> None:
|
||||||
|
value = field.data.lower().strip()
|
||||||
|
if value == (self.original_email or "").lower().strip():
|
||||||
|
return
|
||||||
|
if User.query.filter_by(email=value).first():
|
||||||
|
raise ValidationError("Diese E-Mail-Adresse wird bereits verwendet.")
|
||||||
|
|
||||||
167
app/models.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, date, datetime, timedelta
|
||||||
|
|
||||||
|
from flask_login import UserMixin
|
||||||
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
|
from .extensions import db, login_manager
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
return datetime.now(UTC).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin:
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False, default=utcnow)
|
||||||
|
updated_at = db.Column(db.DateTime, nullable=False, default=utcnow, onupdate=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class User(UserMixin, TimestampMixin, db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(120), nullable=False)
|
||||||
|
email = db.Column(db.String(255), nullable=False, unique=True, index=True)
|
||||||
|
password_hash = db.Column(db.String(255), nullable=False)
|
||||||
|
avatar_path = db.Column(db.String(255), nullable=True)
|
||||||
|
notification_task_due_enabled = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
|
notification_monthly_winner_enabled = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
assigned_task_templates = db.relationship(
|
||||||
|
"TaskTemplate",
|
||||||
|
foreign_keys="TaskTemplate.default_assigned_user_id",
|
||||||
|
backref="default_assigned_user",
|
||||||
|
lazy=True,
|
||||||
|
)
|
||||||
|
assigned_tasks = db.relationship(
|
||||||
|
"TaskInstance",
|
||||||
|
foreign_keys="TaskInstance.assigned_user_id",
|
||||||
|
backref="assigned_user",
|
||||||
|
lazy=True,
|
||||||
|
)
|
||||||
|
completed_tasks = db.relationship(
|
||||||
|
"TaskInstance",
|
||||||
|
foreign_keys="TaskInstance.completed_by_user_id",
|
||||||
|
backref="completed_by_user",
|
||||||
|
lazy=True,
|
||||||
|
)
|
||||||
|
subscriptions = db.relationship("PushSubscription", backref="user", lazy=True, cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
def set_password(self, password: str) -> None:
|
||||||
|
self.password_hash = generate_password_hash(password)
|
||||||
|
|
||||||
|
def check_password(self, password: str) -> bool:
|
||||||
|
return check_password_hash(self.password_hash, password)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_avatar(self) -> str:
|
||||||
|
return self.avatar_path or "images/avatars/default.svg"
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<User {self.email}>"
|
||||||
|
|
||||||
|
|
||||||
|
@login_manager.user_loader
|
||||||
|
def load_user(user_id: str) -> User | None:
|
||||||
|
return db.session.get(User, int(user_id))
|
||||||
|
|
||||||
|
|
||||||
|
class TaskTemplate(TimestampMixin, db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
title = db.Column(db.String(160), nullable=False)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
default_points = db.Column(db.Integer, nullable=False, default=10)
|
||||||
|
default_assigned_user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
|
||||||
|
recurrence_interval_value = db.Column(db.Integer, nullable=True)
|
||||||
|
recurrence_interval_unit = db.Column(db.String(20), nullable=False, default="none")
|
||||||
|
active = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
instances = db.relationship("TaskInstance", backref="task_template", lazy=True, cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def recurrence_label(self) -> str:
|
||||||
|
if self.recurrence_interval_unit == "none" or not self.recurrence_interval_value:
|
||||||
|
return "Einmalig"
|
||||||
|
return f"Alle {self.recurrence_interval_value} {self.recurrence_interval_unit}"
|
||||||
|
|
||||||
|
|
||||||
|
class TaskInstance(TimestampMixin, db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
task_template_id = db.Column(db.Integer, db.ForeignKey("task_template.id"), nullable=False, index=True)
|
||||||
|
title = db.Column(db.String(160), nullable=False)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
assigned_user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True, index=True)
|
||||||
|
due_date = db.Column(db.Date, nullable=False, index=True)
|
||||||
|
status = db.Column(db.String(20), nullable=False, default="open", index=True)
|
||||||
|
completed_at = db.Column(db.DateTime, nullable=True, index=True)
|
||||||
|
completed_by_user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True, index=True)
|
||||||
|
points_awarded = db.Column(db.Integer, nullable=False, default=10)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_completed(self) -> bool:
|
||||||
|
return self.completed_at is not None
|
||||||
|
|
||||||
|
def compute_status(self, reference_date: date | None = None) -> str:
|
||||||
|
reference_date = reference_date or date.today()
|
||||||
|
if self.completed_at:
|
||||||
|
return "completed"
|
||||||
|
if self.due_date < reference_date:
|
||||||
|
return "overdue"
|
||||||
|
if self.due_date <= reference_date + timedelta(days=2):
|
||||||
|
return "soon"
|
||||||
|
return "open"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_label(self) -> str:
|
||||||
|
labels = {
|
||||||
|
"open": "Offen",
|
||||||
|
"soon": "Bald fällig",
|
||||||
|
"overdue": "Überfällig",
|
||||||
|
"completed": "Erledigt",
|
||||||
|
}
|
||||||
|
return labels.get(self.status, "Offen")
|
||||||
|
|
||||||
|
|
||||||
|
class MonthlyScoreSnapshot(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
year = db.Column(db.Integer, nullable=False, index=True)
|
||||||
|
month = db.Column(db.Integer, nullable=False, index=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True)
|
||||||
|
total_points = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
completed_tasks_count = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
rank = db.Column(db.Integer, nullable=False, default=1)
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False, default=utcnow)
|
||||||
|
|
||||||
|
user = db.relationship("User", backref="monthly_snapshots")
|
||||||
|
|
||||||
|
__table_args__ = (db.UniqueConstraint("year", "month", "user_id", name="uq_snapshot_month_user"),)
|
||||||
|
|
||||||
|
|
||||||
|
class PushSubscription(TimestampMixin, db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True)
|
||||||
|
endpoint = db.Column(db.Text, nullable=False, unique=True)
|
||||||
|
p256dh = db.Column(db.Text, nullable=False)
|
||||||
|
auth = db.Column(db.Text, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationLog(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True)
|
||||||
|
type = db.Column(db.String(80), nullable=False, index=True)
|
||||||
|
payload = db.Column(db.Text, nullable=False)
|
||||||
|
sent_at = db.Column(db.DateTime, nullable=False, default=utcnow, index=True)
|
||||||
|
|
||||||
|
user = db.relationship("User", backref="notification_logs")
|
||||||
|
|
||||||
|
|
||||||
|
class BadgeDefinition(TimestampMixin, db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
key = db.Column(db.String(80), nullable=False, unique=True, index=True)
|
||||||
|
name = db.Column(db.String(120), nullable=False)
|
||||||
|
description = db.Column(db.String(255), nullable=False)
|
||||||
|
icon_name = db.Column(db.String(80), nullable=False, default="sparkles")
|
||||||
|
trigger_type = db.Column(db.String(80), nullable=False)
|
||||||
|
threshold = db.Column(db.Integer, nullable=False, default=1)
|
||||||
|
bonus_points = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
active = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
2
app/routes/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
from . import auth, main, scoreboard, settings, tasks
|
||||||
|
|
||||||
53
app/routes/auth.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Blueprint, flash, redirect, render_template, url_for
|
||||||
|
from flask_login import current_user, login_required, login_user, logout_user
|
||||||
|
|
||||||
|
from ..extensions import db
|
||||||
|
from ..forms import LoginForm, RegisterForm
|
||||||
|
from ..models import User
|
||||||
|
|
||||||
|
|
||||||
|
bp = Blueprint("auth", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/login", methods=["GET", "POST"])
|
||||||
|
def login():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for("tasks.my_tasks"))
|
||||||
|
|
||||||
|
form = LoginForm()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
user = User.query.filter_by(email=form.email.data.lower().strip()).first()
|
||||||
|
if user and user.check_password(form.password.data):
|
||||||
|
login_user(user, remember=form.remember_me.data)
|
||||||
|
flash(f"Willkommen zurück, {user.name}.", "success")
|
||||||
|
return redirect(url_for("tasks.my_tasks"))
|
||||||
|
flash("Die Kombination aus E-Mail und Passwort passt leider nicht.", "error")
|
||||||
|
return render_template("auth/login.html", form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/register", methods=["GET", "POST"])
|
||||||
|
def register():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for("tasks.my_tasks"))
|
||||||
|
|
||||||
|
form = RegisterForm()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
user = User(name=form.name.data.strip(), email=form.email.data.lower().strip())
|
||||||
|
user.set_password(form.password.data)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
login_user(user)
|
||||||
|
flash("Dein Konto ist bereit. Willkommen in der Putzliga.", "success")
|
||||||
|
return redirect(url_for("tasks.my_tasks"))
|
||||||
|
return render_template("auth/register.html", form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/logout")
|
||||||
|
@login_required
|
||||||
|
def logout():
|
||||||
|
logout_user()
|
||||||
|
flash("Du bist jetzt abgemeldet.", "info")
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
45
app/routes/main.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Blueprint, current_app, redirect, send_from_directory, url_for
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
|
|
||||||
|
bp = Blueprint("main", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/")
|
||||||
|
def index():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for("tasks.my_tasks"))
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/healthz")
|
||||||
|
def healthz():
|
||||||
|
return {"status": "ok"}, 200
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/manifest.json")
|
||||||
|
def manifest():
|
||||||
|
return send_from_directory(current_app.static_folder, "manifest.json", mimetype="application/manifest+json")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/service-worker.js")
|
||||||
|
def service_worker():
|
||||||
|
response = send_from_directory(current_app.static_folder, "service-worker.js", mimetype="application/javascript")
|
||||||
|
response.headers["Service-Worker-Allowed"] = "/"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/uploads/<path:filename>")
|
||||||
|
def uploads(filename: str):
|
||||||
|
return send_from_directory(current_app.config["UPLOAD_FOLDER"], filename)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=64)
|
||||||
|
def load_icon_svg(name: str, static_folder: str) -> str:
|
||||||
|
path = Path(static_folder) / "icons" / f"{name}.svg"
|
||||||
|
return path.read_text(encoding="utf-8") if path.exists() else ""
|
||||||
43
app/routes/scoreboard.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, request
|
||||||
|
from flask_login import login_required
|
||||||
|
|
||||||
|
from ..services.dates import local_now, month_label
|
||||||
|
from ..services.monthly import archive_months_missing_up_to_previous, compute_monthly_scores, get_archived_months, get_snapshot_rows
|
||||||
|
|
||||||
|
|
||||||
|
bp = Blueprint("scoreboard", __name__, url_prefix="/scoreboard")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("")
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
archive_months_missing_up_to_previous()
|
||||||
|
now = local_now()
|
||||||
|
current_rows = compute_monthly_scores(now.year, now.month)
|
||||||
|
archive_options = get_archived_months(limit=18)
|
||||||
|
|
||||||
|
selected = request.args.get("archive")
|
||||||
|
selected_archive = selected
|
||||||
|
selected_year = selected_month = None
|
||||||
|
archived_rows = []
|
||||||
|
if selected:
|
||||||
|
year_str, month_str = selected.split("-")
|
||||||
|
selected_year, selected_month = int(year_str), int(month_str)
|
||||||
|
archived_rows = get_snapshot_rows(selected_year, selected_month)
|
||||||
|
elif archive_options:
|
||||||
|
selected_year, selected_month = archive_options[0]
|
||||||
|
selected_archive = f"{selected_year}-{selected_month:02d}"
|
||||||
|
archived_rows = get_snapshot_rows(selected_year, selected_month)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"scoreboard/index.html",
|
||||||
|
current_rows=current_rows,
|
||||||
|
current_label=month_label(now.year, now.month),
|
||||||
|
archive_options=archive_options,
|
||||||
|
selected_archive=selected_archive,
|
||||||
|
archived_rows=archived_rows,
|
||||||
|
archive_label=month_label(selected_year, selected_month) if selected_year and selected_month else None,
|
||||||
|
max_points=max([row["total_points"] for row in current_rows], default=1),
|
||||||
|
)
|
||||||
106
app/routes/settings.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from flask import Blueprint, current_app, flash, jsonify, redirect, render_template, request, url_for
|
||||||
|
from flask_login import current_user, login_required
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
from ..extensions import csrf, db
|
||||||
|
from ..forms import SettingsProfileForm
|
||||||
|
from ..models import BadgeDefinition, PushSubscription
|
||||||
|
from ..services.notifications import push_enabled
|
||||||
|
|
||||||
|
|
||||||
|
bp = Blueprint("settings", __name__, url_prefix="/settings")
|
||||||
|
|
||||||
|
|
||||||
|
def _save_avatar(file_storage) -> str:
|
||||||
|
filename = secure_filename(file_storage.filename or "")
|
||||||
|
ext = Path(filename).suffix.lower() or ".png"
|
||||||
|
relative_path = Path("avatars") / f"{uuid4().hex}{ext}"
|
||||||
|
absolute_path = Path(current_app.config["UPLOAD_FOLDER"]) / relative_path
|
||||||
|
absolute_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_storage.save(absolute_path)
|
||||||
|
return relative_path.as_posix()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
form = SettingsProfileForm(original_email=current_user.email, obj=current_user)
|
||||||
|
if form.validate_on_submit():
|
||||||
|
current_user.name = form.name.data.strip()
|
||||||
|
current_user.email = form.email.data.lower().strip()
|
||||||
|
current_user.notification_task_due_enabled = form.notification_task_due_enabled.data
|
||||||
|
current_user.notification_monthly_winner_enabled = form.notification_monthly_winner_enabled.data
|
||||||
|
if form.password.data:
|
||||||
|
current_user.set_password(form.password.data)
|
||||||
|
if form.avatar.data:
|
||||||
|
current_user.avatar_path = _save_avatar(form.avatar.data)
|
||||||
|
db.session.commit()
|
||||||
|
flash("Deine Einstellungen wurden gespeichert.", "success")
|
||||||
|
return redirect(url_for("settings.index"))
|
||||||
|
|
||||||
|
badges = BadgeDefinition.query.order_by(BadgeDefinition.name.asc()).all()
|
||||||
|
subscriptions = PushSubscription.query.filter_by(user_id=current_user.id).all()
|
||||||
|
return render_template(
|
||||||
|
"settings/index.html",
|
||||||
|
form=form,
|
||||||
|
badges=badges,
|
||||||
|
push_ready=push_enabled(),
|
||||||
|
vapid_public_key=current_app.config["VAPID_PUBLIC_KEY"],
|
||||||
|
has_subscription=bool(subscriptions),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/badges/<int:badge_id>", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def update_badge(badge_id: int):
|
||||||
|
badge = BadgeDefinition.query.get_or_404(badge_id)
|
||||||
|
badge.threshold = max(1, request.form.get("threshold", type=int, default=badge.threshold))
|
||||||
|
badge.bonus_points = max(0, request.form.get("bonus_points", type=int, default=badge.bonus_points))
|
||||||
|
badge.active = request.form.get("active") == "on"
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Badge „{badge.name}“ wurde aktualisiert.", "success")
|
||||||
|
return redirect(url_for("settings.index"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/push/subscribe", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@csrf.exempt
|
||||||
|
def push_subscribe():
|
||||||
|
if not push_enabled():
|
||||||
|
return jsonify({"ok": False, "message": "VAPID ist nicht konfiguriert."}), 400
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
endpoint = data.get("endpoint")
|
||||||
|
keys = data.get("keys", {})
|
||||||
|
if not endpoint or not keys.get("p256dh") or not keys.get("auth"):
|
||||||
|
return jsonify({"ok": False, "message": "Subscription unvollständig."}), 400
|
||||||
|
|
||||||
|
subscription = PushSubscription.query.filter_by(endpoint=endpoint).first()
|
||||||
|
if not subscription:
|
||||||
|
subscription = PushSubscription(user_id=current_user.id, endpoint=endpoint, p256dh=keys["p256dh"], auth=keys["auth"])
|
||||||
|
db.session.add(subscription)
|
||||||
|
else:
|
||||||
|
subscription.user_id = current_user.id
|
||||||
|
subscription.p256dh = keys["p256dh"]
|
||||||
|
subscription.auth = keys["auth"]
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/push/unsubscribe", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@csrf.exempt
|
||||||
|
def push_unsubscribe():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
endpoint = data.get("endpoint")
|
||||||
|
if endpoint:
|
||||||
|
subscription = PushSubscription.query.filter_by(endpoint=endpoint, user_id=current_user.id).first()
|
||||||
|
if subscription:
|
||||||
|
db.session.delete(subscription)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"ok": True})
|
||||||
174
app/routes/tasks.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import calendar
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||||
|
from flask_login import current_user, login_required
|
||||||
|
|
||||||
|
from ..forms import TaskForm
|
||||||
|
from ..models import TaskInstance, User
|
||||||
|
from ..services.dates import month_label, today_local
|
||||||
|
from ..services.tasks import complete_task, create_task_template_and_instance, refresh_task_statuses, update_template_and_instance
|
||||||
|
|
||||||
|
|
||||||
|
bp = Blueprint("tasks", __name__, url_prefix="")
|
||||||
|
|
||||||
|
|
||||||
|
def _user_choices() -> list[tuple[int, str]]:
|
||||||
|
return [(user.id, user.name) for user in User.query.order_by(User.name.asc()).all()]
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/my-tasks")
|
||||||
|
@login_required
|
||||||
|
def my_tasks():
|
||||||
|
tasks = (
|
||||||
|
TaskInstance.query.filter_by(assigned_user_id=current_user.id)
|
||||||
|
.order_by(TaskInstance.completed_at.is_(None).desc(), TaskInstance.due_date.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
refresh_task_statuses(tasks)
|
||||||
|
|
||||||
|
sections = {"open": [], "soon": [], "overdue": [], "completed": []}
|
||||||
|
for task in tasks:
|
||||||
|
sections[task.status].append(task)
|
||||||
|
|
||||||
|
completed_count = len(sections["completed"])
|
||||||
|
active_count = len(sections["open"]) + len(sections["soon"]) + len(sections["overdue"])
|
||||||
|
completion_ratio = 0 if completed_count + active_count == 0 else round(completed_count / (completed_count + active_count) * 100)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"tasks/my_tasks.html",
|
||||||
|
sections=sections,
|
||||||
|
completion_ratio=completion_ratio,
|
||||||
|
today=today_local(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/tasks")
|
||||||
|
@login_required
|
||||||
|
def all_tasks():
|
||||||
|
query = TaskInstance.query
|
||||||
|
status = request.args.get("status", "all")
|
||||||
|
mine = request.args.get("mine")
|
||||||
|
user_filter = request.args.get("user_id", type=int)
|
||||||
|
sort = request.args.get("sort", "due")
|
||||||
|
|
||||||
|
if mine == "1":
|
||||||
|
query = query.filter(TaskInstance.assigned_user_id == current_user.id)
|
||||||
|
elif user_filter:
|
||||||
|
query = query.filter(TaskInstance.assigned_user_id == user_filter)
|
||||||
|
|
||||||
|
if sort == "points":
|
||||||
|
query = query.order_by(TaskInstance.points_awarded.desc(), TaskInstance.due_date.asc())
|
||||||
|
elif sort == "user":
|
||||||
|
query = query.order_by(TaskInstance.assigned_user_id.asc(), TaskInstance.due_date.asc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(TaskInstance.completed_at.is_(None).desc(), TaskInstance.due_date.asc())
|
||||||
|
|
||||||
|
tasks = query.all()
|
||||||
|
refresh_task_statuses(tasks)
|
||||||
|
|
||||||
|
if status != "all":
|
||||||
|
status_map = {"completed": "completed", "overdue": "overdue", "open": "open", "soon": "soon"}
|
||||||
|
selected = status_map.get(status)
|
||||||
|
if selected:
|
||||||
|
tasks = [task for task in tasks if task.status == selected]
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"tasks/all_tasks.html",
|
||||||
|
tasks=tasks,
|
||||||
|
users=User.query.order_by(User.name.asc()).all(),
|
||||||
|
filters={"status": status, "mine": mine, "user_id": user_filter, "sort": sort},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/tasks/new", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def create():
|
||||||
|
form = TaskForm()
|
||||||
|
form.assigned_user_id.choices = _user_choices()
|
||||||
|
if request.method == "GET" and not form.due_date.data:
|
||||||
|
form.due_date.data = today_local()
|
||||||
|
|
||||||
|
if form.validate_on_submit():
|
||||||
|
task = create_task_template_and_instance(form)
|
||||||
|
flash(f"Aufgabe „{task.title}“ wurde angelegt.", "success")
|
||||||
|
return redirect(url_for("tasks.my_tasks"))
|
||||||
|
return render_template("tasks/task_form.html", form=form, mode="create", task=None)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/tasks/<int:task_id>/edit", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def edit(task_id: int):
|
||||||
|
task = TaskInstance.query.get_or_404(task_id)
|
||||||
|
form = TaskForm(obj=task.task_template)
|
||||||
|
form.assigned_user_id.choices = _user_choices()
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
form.title.data = task.title
|
||||||
|
form.description.data = task.description
|
||||||
|
form.default_points.data = task.points_awarded
|
||||||
|
form.assigned_user_id.data = task.assigned_user_id or _user_choices()[0][0]
|
||||||
|
form.due_date.data = task.due_date
|
||||||
|
form.recurrence_interval_value.data = task.task_template.recurrence_interval_value or 1
|
||||||
|
form.recurrence_interval_unit.data = task.task_template.recurrence_interval_unit
|
||||||
|
form.active.data = task.task_template.active
|
||||||
|
|
||||||
|
if form.validate_on_submit():
|
||||||
|
update_template_and_instance(task, form)
|
||||||
|
flash("Aufgabe und Vorlage wurden aktualisiert.", "success")
|
||||||
|
return redirect(url_for("tasks.all_tasks"))
|
||||||
|
|
||||||
|
return render_template("tasks/task_form.html", form=form, mode="edit", task=task)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/tasks/<int:task_id>/complete", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def complete(task_id: int):
|
||||||
|
task = TaskInstance.query.get_or_404(task_id)
|
||||||
|
choice = request.form.get("completed_for", "me")
|
||||||
|
if task.is_completed:
|
||||||
|
flash("Diese Aufgabe ist bereits erledigt.", "info")
|
||||||
|
return redirect(request.referrer or url_for("tasks.my_tasks"))
|
||||||
|
|
||||||
|
completed_by_id = current_user.id
|
||||||
|
if task.assigned_user_id and task.assigned_user_id != current_user.id and choice == "assigned":
|
||||||
|
completed_by_id = task.assigned_user_id
|
||||||
|
|
||||||
|
complete_task(task, completed_by_id)
|
||||||
|
flash("Punkte verbucht. Gute Arbeit.", "success")
|
||||||
|
return redirect(request.referrer or url_for("tasks.my_tasks"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/calendar")
|
||||||
|
@login_required
|
||||||
|
def calendar_view():
|
||||||
|
today = today_local()
|
||||||
|
year = request.args.get("year", type=int) or today.year
|
||||||
|
month = request.args.get("month", type=int) or today.month
|
||||||
|
view = request.args.get("view", "calendar")
|
||||||
|
|
||||||
|
tasks = TaskInstance.query.filter(
|
||||||
|
TaskInstance.due_date >= date(year, month, 1),
|
||||||
|
TaskInstance.due_date <= date(year, month, calendar.monthrange(year, month)[1]),
|
||||||
|
).order_by(TaskInstance.due_date.asc()).all()
|
||||||
|
refresh_task_statuses(tasks)
|
||||||
|
|
||||||
|
tasks_by_day: dict[int, list[TaskInstance]] = defaultdict(list)
|
||||||
|
for task in tasks:
|
||||||
|
tasks_by_day[task.due_date.day].append(task)
|
||||||
|
|
||||||
|
month_calendar = calendar.Calendar(firstweekday=0).monthdayscalendar(year, month)
|
||||||
|
return render_template(
|
||||||
|
"tasks/calendar.html",
|
||||||
|
current_year=year,
|
||||||
|
current_month=month,
|
||||||
|
current_label=month_label(year, month),
|
||||||
|
month_calendar=month_calendar,
|
||||||
|
tasks_by_day=tasks_by_day,
|
||||||
|
view=view,
|
||||||
|
tasks=tasks,
|
||||||
|
)
|
||||||
|
|
||||||
53
app/services/badges.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from ..models import BadgeDefinition, TaskInstance
|
||||||
|
|
||||||
|
|
||||||
|
def _max_day_streak(days: set[date]) -> int:
|
||||||
|
if not days:
|
||||||
|
return 0
|
||||||
|
streak = 1
|
||||||
|
best = 1
|
||||||
|
ordered = sorted(days)
|
||||||
|
for previous, current in zip(ordered, ordered[1:]):
|
||||||
|
if current == previous + timedelta(days=1):
|
||||||
|
streak += 1
|
||||||
|
else:
|
||||||
|
streak = 1
|
||||||
|
best = max(best, streak)
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def compute_badge_awards(definitions: list[BadgeDefinition], completed_tasks: list[TaskInstance]) -> list[dict]:
|
||||||
|
by_type: dict[str, int] = defaultdict(int)
|
||||||
|
completion_days: set[date] = set()
|
||||||
|
|
||||||
|
for task in completed_tasks:
|
||||||
|
if not task.completed_at:
|
||||||
|
continue
|
||||||
|
completion_day = task.completed_at.date()
|
||||||
|
completion_days.add(completion_day)
|
||||||
|
by_type["monthly_task_count"] += 1
|
||||||
|
if task.due_date and completion_day < task.due_date:
|
||||||
|
by_type["early_finisher_count"] += 1
|
||||||
|
if task.due_date and completion_day <= task.due_date:
|
||||||
|
by_type["on_time_count"] += 1
|
||||||
|
|
||||||
|
by_type["streak_days"] = _max_day_streak(completion_days)
|
||||||
|
|
||||||
|
awards = []
|
||||||
|
for definition in definitions:
|
||||||
|
metric_value = by_type.get(definition.trigger_type, 0)
|
||||||
|
if definition.active and metric_value >= definition.threshold:
|
||||||
|
awards.append(
|
||||||
|
{
|
||||||
|
"definition": definition,
|
||||||
|
"metric_value": metric_value,
|
||||||
|
"bonus_points": definition.bonus_points,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return awards
|
||||||
|
|
||||||
68
app/services/dates.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import calendar
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
|
||||||
|
MONTH_NAMES = [
|
||||||
|
"",
|
||||||
|
"Januar",
|
||||||
|
"Februar",
|
||||||
|
"März",
|
||||||
|
"April",
|
||||||
|
"Mai",
|
||||||
|
"Juni",
|
||||||
|
"Juli",
|
||||||
|
"August",
|
||||||
|
"September",
|
||||||
|
"Oktober",
|
||||||
|
"November",
|
||||||
|
"Dezember",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_timezone() -> ZoneInfo:
|
||||||
|
return ZoneInfo(current_app.config["APP_TIMEZONE"])
|
||||||
|
|
||||||
|
|
||||||
|
def local_now() -> datetime:
|
||||||
|
return datetime.now(UTC).astimezone(get_timezone())
|
||||||
|
|
||||||
|
|
||||||
|
def today_local() -> date:
|
||||||
|
return local_now().date()
|
||||||
|
|
||||||
|
|
||||||
|
def previous_month(year: int, month: int) -> tuple[int, int]:
|
||||||
|
if month == 1:
|
||||||
|
return year - 1, 12
|
||||||
|
return year, month - 1
|
||||||
|
|
||||||
|
|
||||||
|
def next_month(year: int, month: int) -> tuple[int, int]:
|
||||||
|
if month == 12:
|
||||||
|
return year + 1, 1
|
||||||
|
return year, month + 1
|
||||||
|
|
||||||
|
|
||||||
|
def month_label(year: int, month: int) -> str:
|
||||||
|
return f"{MONTH_NAMES[month]} {year}"
|
||||||
|
|
||||||
|
|
||||||
|
def add_months(base_date: date, months: int) -> date:
|
||||||
|
month_index = base_date.month - 1 + months
|
||||||
|
year = base_date.year + month_index // 12
|
||||||
|
month = month_index % 12 + 1
|
||||||
|
day = min(base_date.day, calendar.monthrange(year, month)[1])
|
||||||
|
return date(year, month, day)
|
||||||
|
|
||||||
|
|
||||||
|
def month_bounds(year: int, month: int) -> tuple[datetime, datetime]:
|
||||||
|
start = datetime(year, month, 1)
|
||||||
|
next_year, next_month_value = next_month(year, month)
|
||||||
|
end = datetime(next_year, next_month_value, 1)
|
||||||
|
return start, end
|
||||||
|
|
||||||
123
app/services/monthly.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import extract, select
|
||||||
|
|
||||||
|
from ..extensions import db
|
||||||
|
from ..models import BadgeDefinition, MonthlyScoreSnapshot, TaskInstance, User
|
||||||
|
from .badges import compute_badge_awards
|
||||||
|
from .dates import local_now, month_bounds, next_month, previous_month
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ranking(rows: list[dict]) -> list[dict]:
|
||||||
|
rows.sort(key=lambda row: (-row["total_points"], -row["completed_tasks_count"], row["user"].name.lower()))
|
||||||
|
for index, row in enumerate(rows, start=1):
|
||||||
|
row["rank"] = index
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def compute_monthly_scores(year: int, month: int) -> list[dict]:
|
||||||
|
start, end = month_bounds(year, month)
|
||||||
|
users = User.query.order_by(User.name.asc()).all()
|
||||||
|
badges = BadgeDefinition.query.filter_by(active=True).all()
|
||||||
|
completed_tasks = TaskInstance.query.filter(
|
||||||
|
TaskInstance.completed_at.isnot(None),
|
||||||
|
TaskInstance.completed_at >= start,
|
||||||
|
TaskInstance.completed_at < end,
|
||||||
|
).all()
|
||||||
|
|
||||||
|
tasks_by_user: dict[int, list[TaskInstance]] = defaultdict(list)
|
||||||
|
for task in completed_tasks:
|
||||||
|
if task.completed_by_user_id:
|
||||||
|
tasks_by_user[task.completed_by_user_id].append(task)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for user in users:
|
||||||
|
personal_tasks = tasks_by_user.get(user.id, [])
|
||||||
|
base_points = sum(task.points_awarded for task in personal_tasks)
|
||||||
|
awards = compute_badge_awards(badges, personal_tasks)
|
||||||
|
bonus_points = sum(award["bonus_points"] for award in awards)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"user": user,
|
||||||
|
"base_points": base_points,
|
||||||
|
"bonus_points": bonus_points,
|
||||||
|
"total_points": base_points + bonus_points,
|
||||||
|
"completed_tasks_count": len(personal_tasks),
|
||||||
|
"badges": awards,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return _build_ranking(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_monthly_snapshots(reference: datetime | None = None) -> None:
|
||||||
|
now = reference or local_now().replace(tzinfo=None)
|
||||||
|
target_year, target_month = previous_month(now.year, now.month)
|
||||||
|
if MonthlyScoreSnapshot.query.filter_by(year=target_year, month=target_month).count():
|
||||||
|
return
|
||||||
|
|
||||||
|
snapshot_rows = compute_monthly_scores(target_year, target_month)
|
||||||
|
for row in snapshot_rows:
|
||||||
|
db.session.add(
|
||||||
|
MonthlyScoreSnapshot(
|
||||||
|
year=target_year,
|
||||||
|
month=target_month,
|
||||||
|
user_id=row["user"].id,
|
||||||
|
total_points=row["total_points"],
|
||||||
|
completed_tasks_count=row["completed_tasks_count"],
|
||||||
|
rank=row["rank"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def archive_months_missing_up_to_previous() -> None:
|
||||||
|
now = local_now()
|
||||||
|
previous_year, previous_month_value = previous_month(now.year, now.month)
|
||||||
|
latest = (
|
||||||
|
MonthlyScoreSnapshot.query.order_by(MonthlyScoreSnapshot.year.desc(), MonthlyScoreSnapshot.month.desc()).first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if latest:
|
||||||
|
year, month = next_month(latest.year, latest.month)
|
||||||
|
else:
|
||||||
|
year, month = previous_year, previous_month_value
|
||||||
|
|
||||||
|
while (year, month) <= (previous_year, previous_month_value):
|
||||||
|
if not MonthlyScoreSnapshot.query.filter_by(year=year, month=month).count():
|
||||||
|
rows = compute_monthly_scores(year, month)
|
||||||
|
for row in rows:
|
||||||
|
db.session.add(
|
||||||
|
MonthlyScoreSnapshot(
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
user_id=row["user"].id,
|
||||||
|
total_points=row["total_points"],
|
||||||
|
completed_tasks_count=row["completed_tasks_count"],
|
||||||
|
rank=row["rank"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
year, month = next_month(year, month)
|
||||||
|
|
||||||
|
|
||||||
|
def get_archived_months(limit: int = 12) -> list[tuple[int, int]]:
|
||||||
|
rows = (
|
||||||
|
db.session.query(MonthlyScoreSnapshot.year, MonthlyScoreSnapshot.month)
|
||||||
|
.group_by(MonthlyScoreSnapshot.year, MonthlyScoreSnapshot.month)
|
||||||
|
.order_by(MonthlyScoreSnapshot.year.desc(), MonthlyScoreSnapshot.month.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [(row.year, row.month) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_snapshot_rows(year: int, month: int) -> list[MonthlyScoreSnapshot]:
|
||||||
|
return (
|
||||||
|
MonthlyScoreSnapshot.query.filter_by(year=year, month=month)
|
||||||
|
.order_by(MonthlyScoreSnapshot.rank.asc(), MonthlyScoreSnapshot.total_points.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
172
app/services/notifications.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import timedelta
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
from pywebpush import WebPushException, webpush
|
||||||
|
|
||||||
|
from ..extensions import db
|
||||||
|
from ..models import NotificationLog, PushSubscription, TaskInstance, User
|
||||||
|
from .monthly import archive_months_missing_up_to_previous, get_snapshot_rows
|
||||||
|
from .dates import local_now, previous_month
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NotificationResult:
|
||||||
|
sent: int = 0
|
||||||
|
skipped: int = 0
|
||||||
|
failed: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def push_enabled() -> bool:
|
||||||
|
return bool(current_app.config["VAPID_PUBLIC_KEY"] and current_app.config["VAPID_PRIVATE_KEY"])
|
||||||
|
|
||||||
|
|
||||||
|
def _absolute_url(path: str) -> str:
|
||||||
|
base = current_app.config["APP_BASE_URL"].rstrip("/") + "/"
|
||||||
|
return urljoin(base, path.lstrip("/"))
|
||||||
|
|
||||||
|
|
||||||
|
def _notification_exists(user_id: int, notification_type: str, payload: dict) -> bool:
|
||||||
|
payload_value = json.dumps(payload, sort_keys=True)
|
||||||
|
return (
|
||||||
|
NotificationLog.query.filter_by(user_id=user_id, type=notification_type, payload=payload_value).first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _log_notification(user_id: int, notification_type: str, payload: dict) -> None:
|
||||||
|
db.session.add(
|
||||||
|
NotificationLog(user_id=user_id, type=notification_type, payload=json.dumps(payload, sort_keys=True))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_subscription(subscription: PushSubscription, payload: dict) -> bool:
|
||||||
|
try:
|
||||||
|
webpush(
|
||||||
|
subscription_info={
|
||||||
|
"endpoint": subscription.endpoint,
|
||||||
|
"keys": {"p256dh": subscription.p256dh, "auth": subscription.auth},
|
||||||
|
},
|
||||||
|
data=json.dumps(payload),
|
||||||
|
vapid_private_key=current_app.config["VAPID_PRIVATE_KEY"],
|
||||||
|
vapid_claims={"sub": current_app.config["VAPID_CLAIMS_SUBJECT"]},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except WebPushException:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def send_due_notifications() -> NotificationResult:
|
||||||
|
result = NotificationResult()
|
||||||
|
if not push_enabled():
|
||||||
|
result.skipped += 1
|
||||||
|
return result
|
||||||
|
|
||||||
|
today = local_now().date()
|
||||||
|
relevant_tasks = TaskInstance.query.filter(
|
||||||
|
TaskInstance.completed_at.is_(None),
|
||||||
|
TaskInstance.assigned_user_id.isnot(None),
|
||||||
|
TaskInstance.due_date <= today + timedelta(days=1),
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for task in relevant_tasks:
|
||||||
|
user = task.assigned_user
|
||||||
|
if not user or not user.notification_task_due_enabled:
|
||||||
|
result.skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
payload_marker = {"task_instance_id": task.id, "due_date": task.due_date.isoformat()}
|
||||||
|
if _notification_exists(user.id, "task_due", payload_marker):
|
||||||
|
result.skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
subscriptions = PushSubscription.query.filter_by(user_id=user.id).all()
|
||||||
|
if not subscriptions:
|
||||||
|
result.skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
body = "Heute ist ein guter Tag für Punkte." if task.due_date <= today else "Morgen wird's fällig."
|
||||||
|
payload = {
|
||||||
|
"title": f"Putzliga erinnert: {task.title}",
|
||||||
|
"body": body,
|
||||||
|
"icon": _absolute_url("/static/images/pwa-icon-192.png"),
|
||||||
|
"badge": _absolute_url("/static/images/pwa-badge.png"),
|
||||||
|
"url": _absolute_url("/my-tasks"),
|
||||||
|
"tag": f"task-{task.id}",
|
||||||
|
}
|
||||||
|
|
||||||
|
sent_any = False
|
||||||
|
for subscription in subscriptions:
|
||||||
|
if _send_subscription(subscription, payload):
|
||||||
|
sent_any = True
|
||||||
|
result.sent += 1
|
||||||
|
else:
|
||||||
|
result.failed += 1
|
||||||
|
|
||||||
|
if sent_any:
|
||||||
|
_log_notification(user.id, "task_due", payload_marker)
|
||||||
|
db.session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def send_monthly_winner_notifications() -> NotificationResult:
|
||||||
|
result = NotificationResult()
|
||||||
|
if not push_enabled():
|
||||||
|
result.skipped += 1
|
||||||
|
return result
|
||||||
|
|
||||||
|
now = local_now()
|
||||||
|
if not (now.day == 1 and now.hour >= 9):
|
||||||
|
result.skipped += 1
|
||||||
|
return result
|
||||||
|
|
||||||
|
archive_months_missing_up_to_previous()
|
||||||
|
target_year, target_month = previous_month(now.year, now.month)
|
||||||
|
rows = get_snapshot_rows(target_year, target_month)
|
||||||
|
if not rows:
|
||||||
|
result.skipped += 1
|
||||||
|
return result
|
||||||
|
|
||||||
|
winners = [row.user.name for row in rows if row.rank == 1]
|
||||||
|
winner_text = ", ".join(winners)
|
||||||
|
users = User.query.order_by(User.name.asc()).all()
|
||||||
|
marker = {"year": target_year, "month": target_month}
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
if not user.notification_monthly_winner_enabled:
|
||||||
|
result.skipped += 1
|
||||||
|
continue
|
||||||
|
if _notification_exists(user.id, "monthly_winner", marker):
|
||||||
|
result.skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
subscriptions = PushSubscription.query.filter_by(user_id=user.id).all()
|
||||||
|
if not subscriptions:
|
||||||
|
result.skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"title": "Der Haushalts-Champion des letzten Monats steht fest",
|
||||||
|
"body": f"{winner_text} führt den letzten Monat an. Schau ins Scoreboard.",
|
||||||
|
"icon": _absolute_url("/static/images/pwa-icon-192.png"),
|
||||||
|
"badge": _absolute_url("/static/images/pwa-badge.png"),
|
||||||
|
"url": _absolute_url(f"/scoreboard?archive={target_year}-{target_month:02d}"),
|
||||||
|
"tag": f"winner-{target_year}-{target_month}",
|
||||||
|
}
|
||||||
|
sent_any = False
|
||||||
|
for subscription in subscriptions:
|
||||||
|
if _send_subscription(subscription, payload):
|
||||||
|
sent_any = True
|
||||||
|
result.sent += 1
|
||||||
|
else:
|
||||||
|
result.failed += 1
|
||||||
|
|
||||||
|
if sent_any:
|
||||||
|
_log_notification(user.id, "monthly_winner", marker)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return result
|
||||||
126
app/services/tasks.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from ..extensions import db
|
||||||
|
from ..models import TaskInstance, TaskTemplate
|
||||||
|
from .dates import add_months, today_local
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_task_status(task: TaskInstance, reference_date: date | None = None) -> bool:
|
||||||
|
status = task.compute_status(reference_date or today_local())
|
||||||
|
if task.status != status:
|
||||||
|
task.status = status
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_task_statuses(tasks: list[TaskInstance]) -> None:
|
||||||
|
dirty = any(refresh_task_status(task) for task in tasks)
|
||||||
|
if dirty:
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def create_task_template_and_instance(form) -> TaskInstance:
|
||||||
|
template = TaskTemplate(
|
||||||
|
title=form.title.data.strip(),
|
||||||
|
description=(form.description.data or "").strip(),
|
||||||
|
default_points=form.default_points.data,
|
||||||
|
default_assigned_user_id=form.assigned_user_id.data,
|
||||||
|
recurrence_interval_value=form.recurrence_interval_value.data if form.recurrence_interval_unit.data != "none" else None,
|
||||||
|
recurrence_interval_unit=form.recurrence_interval_unit.data,
|
||||||
|
active=form.active.data,
|
||||||
|
)
|
||||||
|
db.session.add(template)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
task = TaskInstance(
|
||||||
|
task_template_id=template.id,
|
||||||
|
title=template.title,
|
||||||
|
description=template.description,
|
||||||
|
assigned_user_id=template.default_assigned_user_id,
|
||||||
|
due_date=form.due_date.data,
|
||||||
|
points_awarded=template.default_points,
|
||||||
|
status="open",
|
||||||
|
)
|
||||||
|
refresh_task_status(task, form.due_date.data)
|
||||||
|
db.session.add(task)
|
||||||
|
db.session.commit()
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
def update_template_and_instance(task: TaskInstance, form) -> TaskInstance:
|
||||||
|
template = task.task_template
|
||||||
|
template.title = form.title.data.strip()
|
||||||
|
template.description = (form.description.data or "").strip()
|
||||||
|
template.default_points = form.default_points.data
|
||||||
|
template.default_assigned_user_id = form.assigned_user_id.data
|
||||||
|
template.recurrence_interval_unit = form.recurrence_interval_unit.data
|
||||||
|
template.recurrence_interval_value = (
|
||||||
|
form.recurrence_interval_value.data if form.recurrence_interval_unit.data != "none" else None
|
||||||
|
)
|
||||||
|
template.active = form.active.data
|
||||||
|
|
||||||
|
task.title = template.title
|
||||||
|
task.description = template.description
|
||||||
|
task.assigned_user_id = template.default_assigned_user_id
|
||||||
|
task.points_awarded = template.default_points
|
||||||
|
task.due_date = form.due_date.data
|
||||||
|
refresh_task_status(task, form.due_date.data)
|
||||||
|
db.session.commit()
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
def _next_due_date(task: TaskInstance) -> date | None:
|
||||||
|
template = task.task_template
|
||||||
|
value = template.recurrence_interval_value
|
||||||
|
if template.recurrence_interval_unit == "none" or not value:
|
||||||
|
return None
|
||||||
|
if template.recurrence_interval_unit == "days":
|
||||||
|
return task.due_date + timedelta(days=value)
|
||||||
|
if template.recurrence_interval_unit == "weeks":
|
||||||
|
return task.due_date + timedelta(weeks=value)
|
||||||
|
if template.recurrence_interval_unit == "months":
|
||||||
|
return add_months(task.due_date, value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_next_recurring_task(task: TaskInstance) -> TaskInstance | None:
|
||||||
|
next_due = _next_due_date(task)
|
||||||
|
if not next_due or not task.task_template.active:
|
||||||
|
return None
|
||||||
|
|
||||||
|
existing = db.session.scalar(
|
||||||
|
select(TaskInstance).where(
|
||||||
|
TaskInstance.task_template_id == task.task_template_id,
|
||||||
|
TaskInstance.due_date == next_due,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
next_task = TaskInstance(
|
||||||
|
task_template_id=task.task_template_id,
|
||||||
|
title=task.task_template.title,
|
||||||
|
description=task.task_template.description,
|
||||||
|
assigned_user_id=task.task_template.default_assigned_user_id,
|
||||||
|
due_date=next_due,
|
||||||
|
points_awarded=task.task_template.default_points,
|
||||||
|
status="open",
|
||||||
|
)
|
||||||
|
refresh_task_status(next_task, today_local())
|
||||||
|
db.session.add(next_task)
|
||||||
|
return next_task
|
||||||
|
|
||||||
|
|
||||||
|
def complete_task(task: TaskInstance, completed_by_user_id: int) -> TaskInstance:
|
||||||
|
if not task.completed_at:
|
||||||
|
task.completed_at = datetime.utcnow()
|
||||||
|
task.completed_by_user_id = completed_by_user_id
|
||||||
|
task.status = "completed"
|
||||||
|
ensure_next_recurring_task(task)
|
||||||
|
db.session.commit()
|
||||||
|
return task
|
||||||
|
|
||||||
916
app/static/css/style.css
Normal file
@@ -0,0 +1,916 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: "InterLocal";
|
||||||
|
src: url("../fonts/Inter_24pt-Regular.ttf") format("truetype");
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "InterLocal";
|
||||||
|
src: url("../fonts/Inter_24pt-Medium.ttf") format("truetype");
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "InterLocal";
|
||||||
|
src: url("../fonts/Inter_24pt-SemiBold.ttf") format("truetype");
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "InterLocal";
|
||||||
|
src: url("../fonts/Inter_24pt-Bold.ttf") format("truetype");
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "SpaceGroteskLocal";
|
||||||
|
src: url("../fonts/SpaceGrotesk-Regular.ttf") format("truetype");
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "SpaceGroteskLocal";
|
||||||
|
src: url("../fonts/SpaceGrotesk-SemiBold.ttf") format("truetype");
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "SpaceGroteskLocal";
|
||||||
|
src: url("../fonts/SpaceGrotesk-Bold.ttf") format("truetype");
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #eef3ff;
|
||||||
|
--bg-accent: #d8e6ff;
|
||||||
|
--surface: rgba(255, 255, 255, 0.85);
|
||||||
|
--surface-strong: #ffffff;
|
||||||
|
--surface-soft: rgba(244, 248, 255, 0.95);
|
||||||
|
--text: #223049;
|
||||||
|
--muted: #64748b;
|
||||||
|
--border: rgba(132, 152, 190, 0.24);
|
||||||
|
--primary: #2563eb;
|
||||||
|
--primary-strong: #1745c1;
|
||||||
|
--secondary: #edf4ff;
|
||||||
|
--success: #059669;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--danger: #e11d48;
|
||||||
|
--shadow: 0 24px 60px rgba(52, 79, 131, 0.16);
|
||||||
|
--radius-lg: 28px;
|
||||||
|
--radius-md: 22px;
|
||||||
|
--radius-sm: 16px;
|
||||||
|
--font-body: "InterLocal", system-ui, sans-serif;
|
||||||
|
--font-heading: "SpaceGroteskLocal", "InterLocal", sans-serif;
|
||||||
|
--safe-bottom: max(24px, env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(181, 210, 255, 0.85), transparent 32%),
|
||||||
|
radial-gradient(circle at top right, rgba(255, 221, 196, 0.48), transparent 32%),
|
||||||
|
linear-gradient(180deg, #f8fbff 0%, #eef3ff 42%, #edf2ff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24px 18px calc(100px + var(--safe-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar h1 {
|
||||||
|
font-size: clamp(1.9rem, 4vw, 2.9rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-user {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel,
|
||||||
|
.hero-card,
|
||||||
|
.task-card,
|
||||||
|
.score-row,
|
||||||
|
.sidebar-card,
|
||||||
|
.calendar-day,
|
||||||
|
.archive-row,
|
||||||
|
.badge-setting-card,
|
||||||
|
.list-row {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel,
|
||||||
|
.hero-card,
|
||||||
|
.score-row,
|
||||||
|
.list-row {
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-grid,
|
||||||
|
.two-column {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card {
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(245, 249, 255, 0.86)),
|
||||||
|
linear-gradient(135deg, rgba(37, 99, 235, 0.06), rgba(5, 150, 105, 0.03));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card h2 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: clamp(1.8rem, 4vw, 2.7rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card p {
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card--brand {
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stats {
|
||||||
|
margin-top: 22px;
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stats div {
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
border: 1px solid rgba(150, 173, 214, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stats strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand__logo {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash--success {
|
||||||
|
border-color: rgba(5, 150, 105, 0.2);
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash--error {
|
||||||
|
border-color: rgba(225, 29, 72, 0.2);
|
||||||
|
color: #9f1239;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash--info {
|
||||||
|
color: var(--primary-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-card {
|
||||||
|
margin-top: 22px;
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(255, 255, 255, 0.74);
|
||||||
|
border: 1px solid rgba(132, 152, 190, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-card__top,
|
||||||
|
.section-heading,
|
||||||
|
.task-card__top,
|
||||||
|
.score-row__head,
|
||||||
|
.score-row__meta,
|
||||||
|
.task-card__footer,
|
||||||
|
.sidebar-card__row,
|
||||||
|
.toolbar-actions,
|
||||||
|
.archive-row,
|
||||||
|
.list-row,
|
||||||
|
.push-box__state,
|
||||||
|
.form-actions,
|
||||||
|
.field-inline {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
margin-top: 12px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(162, 182, 218, 0.24);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress span,
|
||||||
|
.score-bar span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, #2563eb, #34d399);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack,
|
||||||
|
.task-grid,
|
||||||
|
.scoreboard,
|
||||||
|
.archive-list,
|
||||||
|
.badge-settings {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading h2,
|
||||||
|
.panel h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading__count,
|
||||||
|
.point-pill,
|
||||||
|
.reward-chip,
|
||||||
|
.rank-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading__count,
|
||||||
|
.point-pill {
|
||||||
|
background: var(--secondary);
|
||||||
|
color: var(--primary-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 28px;
|
||||||
|
border: 1px dashed rgba(132, 152, 190, 0.44);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
background: rgba(255, 255, 255, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card h3 {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card--compact {
|
||||||
|
opacity: 0.94;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-row,
|
||||||
|
.badge-cloud {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.83rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge--open {
|
||||||
|
background: #e8f0ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge--soon {
|
||||||
|
background: #fff3d6;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge--overdue {
|
||||||
|
background: #ffe3ea;
|
||||||
|
color: #be123c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge--completed {
|
||||||
|
background: #ddfbf1;
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-meta dt {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-meta dd {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-assignee {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.8);
|
||||||
|
box-shadow: 0 10px 24px rgba(78, 100, 141, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar--lg {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.done-hint,
|
||||||
|
.muted,
|
||||||
|
.inline-note,
|
||||||
|
.sidebar-card p,
|
||||||
|
.score-row__meta,
|
||||||
|
.archive-row__right small,
|
||||||
|
.calendar-task small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button,
|
||||||
|
.button--ghost,
|
||||||
|
.button--secondary,
|
||||||
|
.icon-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, var(--primary), #4f8cff);
|
||||||
|
box-shadow: 0 14px 30px rgba(37, 99, 235, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover,
|
||||||
|
.button--secondary:hover,
|
||||||
|
.button--ghost:hover,
|
||||||
|
.icon-button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--secondary {
|
||||||
|
background: var(--secondary);
|
||||||
|
color: var(--primary-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--ghost {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(132, 152, 190, 0.3);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--wide {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
width: 48px;
|
||||||
|
padding: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.74);
|
||||||
|
border: 1px solid rgba(132, 152, 190, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid--two {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field--full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label,
|
||||||
|
.checkbox span {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field select,
|
||||||
|
.field textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid rgba(132, 152, 190, 0.3);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.86);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field--compact input,
|
||||||
|
.field--compact select {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox input {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox--compact {
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar,
|
||||||
|
.inline-form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel--toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.74);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented a {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented .is-active {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid__weekdays {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day {
|
||||||
|
min-height: 132px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day strong {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day--empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day__tasks {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-task {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-task--open {
|
||||||
|
border-left: 4px solid #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-task--soon {
|
||||||
|
border-left: 4px solid #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-task--overdue {
|
||||||
|
border-left: 4px solid #e11d48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-task--completed {
|
||||||
|
border-left: 4px solid #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-row--leader {
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(246, 255, 249, 0.92)),
|
||||||
|
linear-gradient(135deg, rgba(52, 211, 153, 0.08), rgba(37, 99, 235, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-row__person,
|
||||||
|
.archive-row__left {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-row__points strong {
|
||||||
|
display: block;
|
||||||
|
font-size: clamp(1.8rem, 4vw, 2.8rem);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-bar {
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(162, 182, 218, 0.22);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-chip,
|
||||||
|
.rank-badge {
|
||||||
|
background: rgba(37, 99, 235, 0.1);
|
||||||
|
color: var(--primary-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-setting-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.push-box {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.push-box__state {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.66);
|
||||||
|
}
|
||||||
|
|
||||||
|
.push-box__state.is-disabled {
|
||||||
|
border: 1px dashed rgba(225, 29, 72, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.push-box__state.is-ready {
|
||||||
|
border: 1px solid rgba(5, 150, 105, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-layout {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel {
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav {
|
||||||
|
position: fixed;
|
||||||
|
left: 14px;
|
||||||
|
right: 14px;
|
||||||
|
bottom: calc(10px + env(safe-area-inset-bottom));
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.88);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
box-shadow: 0 24px 44px rgba(58, 82, 128, 0.2);
|
||||||
|
border: 1px solid rgba(132, 152, 190, 0.22);
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav__item,
|
||||||
|
.nav-link {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
border-radius: 16px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.73rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav__item.is-active,
|
||||||
|
.nav-link.is-active {
|
||||||
|
color: var(--primary-strong);
|
||||||
|
background: rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon,
|
||||||
|
.nav-icon svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.complete-dialog {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.complete-dialog::backdrop {
|
||||||
|
background: rgba(18, 31, 56, 0.36);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.complete-dialog__surface {
|
||||||
|
width: min(460px, calc(100vw - 24px));
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 28px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-link {
|
||||||
|
color: var(--primary-strong);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 760px) {
|
||||||
|
.page-shell {
|
||||||
|
padding: 28px 28px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-grid,
|
||||||
|
.two-column,
|
||||||
|
.auth-layout {
|
||||||
|
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.95fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid--two,
|
||||||
|
.badge-settings {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid {
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid__weekdays {
|
||||||
|
display: grid;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day--empty {
|
||||||
|
display: block;
|
||||||
|
opacity: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1100px) {
|
||||||
|
.app-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 290px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
height: 100vh;
|
||||||
|
padding: 28px 20px;
|
||||||
|
border-right: 1px solid rgba(132, 152, 190, 0.2);
|
||||||
|
background: rgba(248, 251, 255, 0.72);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
justify-items: start;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.94rem;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-shell {
|
||||||
|
padding: 36px 40px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-user {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px 8px 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
border: 1px solid rgba(132, 152, 190, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-grid,
|
||||||
|
.scoreboard {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid--two {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
app/static/fonts/Inter_24pt-Bold.ttf
Normal file
BIN
app/static/fonts/Inter_24pt-Medium.ttf
Normal file
BIN
app/static/fonts/Inter_24pt-Regular.ttf
Normal file
BIN
app/static/fonts/Inter_24pt-SemiBold.ttf
Normal file
BIN
app/static/fonts/SpaceGrotesk-Bold.ttf
Normal file
BIN
app/static/fonts/SpaceGrotesk-Regular.ttf
Normal file
BIN
app/static/fonts/SpaceGrotesk-SemiBold.ttf
Normal file
1
app/static/icons/bell.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M240.4 40.2C243.1 17.4 257.8 0 288 0C318.2 0 332.9 17.4 335.6 40.2C376.9 48 409.9 66.3 434 93.1C466.3 129.1 480 177.5 480 227.9L480 265.2C546.5 299 576 362.5 576 431.9L576 479.9L384 479.9C384 503.9 377.5 528.2 360.8 546.8C343.7 565.9 318.6 575.9 288 575.9C257.4 575.9 232.4 565.9 215.2 546.8C198.5 528.3 192 504 192 480L0 480L0 432C0 362.6 29.5 299.2 96 265.3L96 228C96 177.5 109.7 129.2 142 93.2C166.1 66.4 199.1 48 240.4 40.3zM213.4 121.6C198.6 146.4 192 183 192 228L192 296.8L177.1 302.9C121.4 325.7 96 373.2 96 432L480 432C480 373.2 454.6 325.7 398.9 302.9L384 296.8L384 228C384 183 377.4 146.4 362.5 121.6C348.9 98.9 327 84 288 84C249 84 227.1 98.9 213.5 121.6zM271.9 518.6C275.8 525.2 280.4 528 287.9 528C295.4 528 300 525.2 303.9 518.6C308.6 510.7 311.9 497.6 311.9 480L263.9 480C263.9 497.6 267.2 510.7 271.9 518.6z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
1
app/static/icons/calendar.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M192 0L192 48L384 48L384 0L432 0L432 48L576 48L576 432C576 485 533 528 480 528L96 528C43 528 0 485 0 432L0 48L144 48L144 0L192 0zM96 96L96 144L480 144L480 96L96 96zM96 192L96 432C96 458.5 117.5 480 144 480L432 480C458.5 480 480 458.5 480 432L480 192L96 192zM288 312C262.8 312 252 295.9 252 276C252 256.1 262.8 240 288 240C313.2 240 324 256.1 324 276C324 295.9 313.2 312 288 312zM432 276C432 295.9 421.2 312 396 312C370.8 312 360 295.9 360 276C360 256.1 370.8 240 396 240C421.2 240 432 256.1 432 276zM180 312C154.8 312 144 295.9 144 276C144 256.1 154.8 240 180 240C205.2 240 216 256.1 216 276C216 295.9 205.2 312 180 312zM324 396C324 415.9 313.2 432 288 432C262.8 432 252 415.9 252 396C252 376.1 262.8 360 288 360C313.2 360 324 376.1 324 396zM396 432C370.8 432 360 415.9 360 396C360 376.1 370.8 360 396 360C421.2 360 432 376.1 432 396C432 415.9 421.2 432 396 432zM216 396C216 415.9 205.2 432 180 432C154.8 432 144 415.9 144 396C144 376.1 154.8 360 180 360C205.2 360 216 376.1 216 396z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
1
app/static/icons/chart-bar.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M408 528L408 24L552 24L552 456C552 509 509 552 456 552L408 552L408 528zM504 408L504 72L456 72L456 456C482.5 456 504 434.5 504 408zM216 528L216 144L360 144L360 552L216 552L216 528zM312 456L312 192L264 192L264 456L312 456zM168 528L168 552L120 552C67 552 24 509 24 456L24 264L168 264L168 528zM120 456L120 312L72 312L72 408C72 434.5 93.5 456 120 456z"/></svg>
|
||||||
|
After Width: | Height: | Size: 614 B |
1
app/static/icons/check.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M215.5 352.1L488 102.3L521.9 136.3L233.9 448.3L215.6 468.1L198 447.6L54 279.6L87.6 245.5L215.5 352.1z"/></svg>
|
||||||
|
After Width: | Height: | Size: 369 B |
1
app/static/icons/gear.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M248.4 0L229.7 0L226 14.7C215.5 44.8 201.4 54.7 189.5 57.6C175.1 61.1 154.7 56.9 128.9 42.4L118.9 36.4C117.2 38.1 94.1 61.2 49.6 105.7L36.3 119L42.3 128.9C56.8 154.7 61 175.1 57.5 189.5C54.6 201.4 44.7 215.5 14.6 226L0 229.7L0 346.3L14.7 350C44.8 360.5 54.7 374.6 57.6 386.5C61.1 400.9 56.9 421.3 42.4 447.1L36.4 457C38.1 458.7 61.2 481.8 105.7 526.3L119 539.6L128.9 533.6C154.6 519.1 175.1 514.9 189.5 518.4C201.4 521.3 215.5 531.2 226 561.3L229.7 576L346.4 576L350.1 561.3C360.6 531.2 374.7 521.3 386.6 518.4C401 514.9 421.4 519.1 447.1 533.6L457.1 539.6L539.6 457.1L533.6 447.2C519.1 421.5 514.9 401 518.4 386.6C521.3 374.7 531.2 360.6 561.3 350.1L576 346.4L576 229.7L561.3 226C531.2 215.5 521.3 201.4 518.4 189.5C514.9 175.1 519.1 154.7 533.6 128.9L539.6 119C537.9 117.3 514.8 94.2 470.3 49.7L457 36.4L447 42.4C421.3 56.9 400.8 61.1 386.4 57.6C374.5 54.7 360.4 44.8 349.9 14.7L346.3 0L248.4 0zM251.3 91.4C257.1 79.9 262.1 65.4 266.9 48L309.1 48C313.9 65.3 318.9 79.9 324.7 91.4C332.5 106.8 343.9 121.1 362.8 125.7C379.8 129.8 396.7 124.3 411.2 117.7L458.5 165L458 166.2C451.4 181.1 445.9 198.5 450.8 215.7C456 234.3 470.8 245.1 485.9 252.3C497.2 257.7 511.4 262.3 528.1 266.9L528.1 309C510.7 313.8 496.1 318.8 484.7 324.5C469.3 332.2 454.9 343.6 450.3 362.5C446.1 379.6 451.8 396.6 458.4 411.1L412.3 457.2C411 456.5 409.7 455.9 408.5 455.2C394.6 448.2 376.9 440.7 359.2 445.3C339.5 450.4 329.5 467.4 323.4 482.2C318.5 494 313.9 509.4 309.1 527.9L267.2 527.9C262.4 509.4 257.9 494 252.9 482.2C246.7 467.4 236.8 450.5 217.1 445.3C199.4 440.7 181.7 448.2 167.8 455.2C166.6 455.8 165.3 456.5 164 457.2L118.9 412.1C119.6 410.8 120.3 409.4 121 408.1C128 394.2 135.6 376.5 131 358.8C125.9 339 108.8 329.1 94 323C82.2 318.1 66.7 313.6 48.2 308.7L48.2 266.8C66.7 262 82.2 257.5 94 252.5C108.7 246.4 125.8 236.5 131 216.7C135.6 199 128.1 181.3 121 167.4C120.3 166.1 119.7 164.8 118.9 163.4L165 117.3C179.5 123.9 196.4 129.4 213.4 125.3C232.2 120.7 243.7 106.4 251.5 91zM304 249.4C308.7 257.3 312 270.4 312 288C312 305.6 308.7 318.7 304 326.6C300.1 333.2 295.5 336 288 336C280.5 336 275.9 333.2 272 326.6C267.3 318.7 264 305.6 264 288C264 270.4 267.3 257.3 272 249.4C275.9 242.8 280.5 240 288 240C295.5 240 300.1 242.8 304 249.4zM288 192C257.4 192 232.4 202 215.2 221.1C198.5 239.7 192 264.1 192 288C192 311.9 198.5 336.3 215.2 354.9C232.4 374 257.4 384 288 384C318.6 384 343.6 374 360.8 354.9C377.5 336.3 384 312 384 288C384 264 377.5 239.7 360.8 221.1C343.6 202 318.6 192 288 192z"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
1
app/static/icons/house.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M301.8 16.3L288 6.7L274.2 16.3L34.2 184.3L24 191.5L24 456C24 509 67 552 120 552L456 552C509 552 552 509 552 456L552 48L456 48L456 124.3L301.8 16.3zM72 297.9L288 81.9L504 297.9L504 456C504 482.5 482.5 504 456 504L408 504L408 360C408 320 393.2 289.2 369.3 268.6C346 248.6 316.1 240 288 240C259.9 240 229.9 248.6 206.7 268.6C182.8 289.2 168 320 168 360L168 504L120 504C93.5 504 72 482.5 72 456L72 297.9zM240 504L240 360C240 329.9 247.2 312.1 255.5 302.2C263.4 292.8 274.2 288 288 288C301.8 288 312.6 292.8 320.5 302.2C328.8 312.1 336 330 336 360L336 504L240 504z"/></svg>
|
||||||
|
After Width: | Height: | Size: 827 B |
1
app/static/icons/list.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M552 120L552 145.9L526.2 143.9L214.2 119.9L214.2 72L526.2 48L552 46L552 119.9zM96 144C62.4 144 48 122.5 48 96C48 69.5 62.4 48 96 48C129.6 48 144 69.5 144 96C144 122.5 129.6 144 96 144zM96 336C62.4 336 48 314.5 48 288C48 261.5 62.4 240 96 240C129.6 240 144 261.5 144 288C144 314.5 129.6 336 96 336zM144 480C144 506.5 129.6 528 96 528C62.4 528 48 506.5 48 480C48 453.5 62.4 432 96 432C129.6 432 144 453.5 144 480zM552 337.9L526.2 335.9L214.2 311.9L214.2 264L526.2 240L552 238L552 337.8zM552 504L552 529.9L526.2 527.9L214.2 503.9L214.2 456L526.2 432L552 430L552 503.9z"/></svg>
|
||||||
|
After Width: | Height: | Size: 833 B |
1
app/static/icons/plus.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M506.1 264.1L327.2 248.8L311.9 69.9L264.1 70.2L250.9 250.8L70.3 264L70 311.9L248.9 327.2L264.2 506.1L312 505.8L325.2 325.2L505.8 312L506.1 264.1z"/></svg>
|
||||||
|
After Width: | Height: | Size: 413 B |
1
app/static/icons/trophy.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M96 0L480 0C533 0 576 43 576 96L576 108L576 108C574.7 165.7 541.6 218 489.9 243.9L465.4 256.1C458.5 281 449 304.1 436.6 324.3C409.1 369.2 367.7 399.7 312 406.5L312 431.9L324.2 431.9C362.6 431.9 392.9 440.8 415 459.2C437.2 477.7 447.8 502.9 452.4 528.9C454.3 539.9 457.1 555.6 460.7 575.9L115.5 575.9C119.1 555.6 121.9 539.9 123.8 528.9C128.4 502.9 139 477.7 161.2 459.2C183.3 440.8 213.6 431.9 252 431.9L264.2 431.9L264.2 406.5C208.6 399.7 167.1 369.1 139.6 324.3C127.2 304 117.7 281 110.8 256.1L86.2 243.9C34.4 218 1.3 165.7 0 108L0 108L0 96C0 43 43 0 96 0zM384 72L384 48L192 48L192 138.9C192 205 200.9 262.6 218.5 302.6C235.9 342.3 259.3 360.1 288 360.1C316.7 360.1 340.1 342.3 357.5 302.6C375.1 262.6 384 205.1 384 138.9L384 72zM528 104.5C528 86.5 513.4 72 495.5 72L480 72L480 137.6C480 157.6 479 177.2 476.8 196.3C508.4 176.8 528 142.1 528 104.5zM80.5 72C62.5 72 48 86.6 48 104.5C48 142.1 67.6 176.8 99.2 196.3C97 177.2 96 157.6 96 137.6L96 72L80.5 72zM257.9 480C233.5 480 220.1 486.6 212 494.7C204.7 502 199.3 512.8 196.1 528L380 528C376.8 512.8 371.4 502 364.1 494.7C356 486.6 342.6 480 318.2 480L257.9 480z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
app/static/icons/user.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M288 72C308.9 72 325.9 78.9 338 92.5C350.3 106.3 360 129.9 360 168C360 206.1 350.3 229.7 338 243.5C326 257 309 264 288.1 264L288 264C267.1 264 250.1 257.1 238 243.5C225.7 229.7 216 206.1 216 168C216 129.9 225.7 106.3 238 92.5C250.1 78.9 267.1 72 288 72zM187.1 277.4C187.7 277.9 188.2 278.4 188.8 278.9C163.7 287.1 140.6 298.8 120.6 314.2C75.4 349.1 48 401.6 48 469.4L48 552L528 552L528 469.4C528 401.5 500.6 349.1 455.4 314.2C435.4 298.8 412.3 287.1 387.2 278.9C387.8 278.4 388.3 277.9 388.9 277.4C416.7 252.5 432 215.4 432 168C432 120.6 416.6 83.6 388.9 58.6C361.6 34.1 325.2 24 288 24C250.8 24 214.4 34.1 187.1 58.6C159.4 83.6 144 120.6 144 168C144 215.4 159.4 252.4 187.1 277.4zM185 346.4C210.1 324.5 246 312.1 287.7 312.1L288.3 312.1C330 312.2 365.9 324.5 391 346.4C415.7 368 432 400.5 432 445.5L432 504.1L144 504.1L144 445.5C144 400.5 160.2 368 185 346.4z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
1
app/static/icons/users.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M200.5 134.2C208.8 144.1 216 162 216 192C216 222 208.8 239.9 200.5 249.8C192.6 259.2 181.8 264 168 264C154.2 264 143.4 259.2 135.5 249.8C127.2 239.9 120 222 120 192C120 162 127.2 144.1 135.5 134.2C143.4 124.8 154.2 120 168 120C181.8 120 192.6 124.8 200.5 134.2zM253.4 281.7C275.9 260.7 288 230.2 288 192C288 153 275.3 122 251.9 100.9C228.9 80.3 198.6 72 168 72C137.4 72 107.1 80.3 84.1 100.9C60.7 122 48 153 48 192C48 230.1 60.1 260.7 82.6 281.7C71.2 287.1 60.5 293.8 50.8 301.9C18.6 328.8 0 369 0 420.5L0 504L576 504L576 426.5C576 382.2 558.1 346.6 528.4 322.6C519.8 315.7 510.5 309.8 500.4 305.1C518.4 286.2 528 260 528 228.1C528 193.3 516.7 165.3 495.4 146.2C474.6 127.5 447.2 120.1 420 120.1C392.8 120.1 365.4 127.5 344.6 146.2C323.4 165.3 312 193.3 312 228.1C312 260 321.5 286.3 339.6 305.1C329.6 309.9 320.2 315.7 311.6 322.6C310.3 323.7 309 324.8 307.7 325.9C301.2 317 293.7 309 285.2 302C275.5 293.9 264.8 287.2 253.4 281.8zM504 456L336 456L336 426.4C336 394.5 346.2 372.4 360.5 358.3C374.9 344.1 395.5 336 420 336C444.5 336 465.1 344.1 479.5 358.3C493.8 372.4 504 394.5 504 426.4L504 455.9zM264 456L72 456L72 402.5C72 371.4 83 349.5 99.1 335.2C115.6 320.5 139.5 312 168 312C196.5 312 220.4 320.6 236.9 335.2C253 349.5 264 371.4 264 402.5L264 456zM443.6 178.9C450 186.9 456 201.9 456 228C456 254.1 450 269.1 443.6 277.1C437.8 284.5 430 288 420 288C410 288 402.2 284.5 396.4 277.1C390 269.1 384 254.1 384 228C384 201.9 390 186.9 396.4 178.9C402.2 171.5 410 168 420 168C430 168 437.8 171.5 443.6 178.9z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app/static/images/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
12
app/static/images/avatars/anna.svg
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#ffcf9c"/>
|
||||||
|
<stop offset="100%" stop-color="#ff8ba7"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="128" height="128" rx="44" fill="url(#g)"/>
|
||||||
|
<circle cx="64" cy="50" r="24" fill="#fff" fill-opacity="0.92"/>
|
||||||
|
<path d="M26 106c7-20 21-31 38-31s31 11 38 31" fill="#fff" fill-opacity="0.92"/>
|
||||||
|
<text x="64" y="117" text-anchor="middle" font-family="Inter, sans-serif" font-size="28" fill="#b4236c">A</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 594 B |
12
app/static/images/avatars/ben.svg
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#93c5fd"/>
|
||||||
|
<stop offset="100%" stop-color="#34d399"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="128" height="128" rx="44" fill="url(#g)"/>
|
||||||
|
<circle cx="64" cy="50" r="24" fill="#fff" fill-opacity="0.92"/>
|
||||||
|
<path d="M26 106c7-20 21-31 38-31s31 11 38 31" fill="#fff" fill-opacity="0.92"/>
|
||||||
|
<text x="64" y="117" text-anchor="middle" font-family="Inter, sans-serif" font-size="28" fill="#0c6e6f">B</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 594 B |
11
app/static/images/avatars/default.svg
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#dbeafe"/>
|
||||||
|
<stop offset="100%" stop-color="#bfdbfe"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="128" height="128" rx="44" fill="url(#g)"/>
|
||||||
|
<circle cx="64" cy="46" r="22" fill="#fff" fill-opacity="0.94"/>
|
||||||
|
<path d="M30 104c6-18 19-28 34-28s28 10 34 28" fill="#fff" fill-opacity="0.94"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 479 B |
11
app/static/images/favicon.svg
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#2563eb"/>
|
||||||
|
<stop offset="100%" stop-color="#34d399"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="64" height="64" rx="18" fill="url(#g)"/>
|
||||||
|
<path d="M18 32.5 32 21l14 11.5V46H18z" fill="#fff"/>
|
||||||
|
<path d="m42 16 2.5 5 5.5.8-4 3.8.9 5.4-4.9-2.8-4.9 2.8.9-5.4-4-3.8 5.5-.8z" fill="#f59e0b"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 477 B |
20
app/static/images/logo.svg
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">Putzliga Logo</title>
|
||||||
|
<desc id="desc">Ein rundes Signet mit Haus, Schild und Stern für Putzliga.</desc>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#2563eb"/>
|
||||||
|
<stop offset="100%" stop-color="#34d399"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="card" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.96"/>
|
||||||
|
<stop offset="100%" stop-color="#eef6ff" stop-opacity="0.92"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg)"/>
|
||||||
|
<circle cx="128" cy="128" r="82" fill="url(#card)"/>
|
||||||
|
<path d="M82 127.5 128 88l46 39.5v43.5a8 8 0 0 1-8 8H90a8 8 0 0 1-8-8z" fill="#2563eb"/>
|
||||||
|
<path d="M108 177v-34a20 20 0 0 1 40 0v34" fill="#9fd0ff"/>
|
||||||
|
<path d="M128 58l59 50.5" stroke="#ffffff" stroke-width="13" stroke-linecap="round"/>
|
||||||
|
<path d="m164 74 7 15 16 2-12 11 3 16-14-8-14 8 3-16-12-11 16-2z" fill="#f59e0b"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
BIN
app/static/images/pwa-badge.png
Normal file
|
After Width: | Height: | Size: 364 B |
BIN
app/static/images/pwa-icon-192.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/static/images/pwa-icon-512.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
104
app/static/js/app.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
(function () {
|
||||||
|
const dialog = document.getElementById("completeDialog");
|
||||||
|
const dialogForm = document.getElementById("completeDialogForm");
|
||||||
|
const dialogChoice = document.getElementById("completeDialogChoice");
|
||||||
|
const dialogText = document.getElementById("completeDialogText");
|
||||||
|
const closeButton = document.getElementById("completeDialogClose");
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-complete-action]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
if (!dialog || !dialogForm || !dialogChoice || !dialogText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dialogForm.action = button.dataset.completeAction;
|
||||||
|
dialogText.textContent = `Die Aufgabe "${button.dataset.completeTitle}" war ${button.dataset.completeAssigned} zugewiesen. Wer hat sie erledigt?`;
|
||||||
|
dialog.showModal();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-complete-choice]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
dialogChoice.value = button.dataset.completeChoice || "me";
|
||||||
|
dialog.close();
|
||||||
|
dialogForm.submit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (closeButton && dialog) {
|
||||||
|
closeButton.addEventListener("click", () => dialog.close());
|
||||||
|
}
|
||||||
|
|
||||||
|
const pushButton = document.getElementById("pushToggle");
|
||||||
|
const pushHint = document.getElementById("pushHint");
|
||||||
|
const vapidKey = document.body.dataset.pushKey;
|
||||||
|
const isIos = /iphone|ipad|ipod/i.test(window.navigator.userAgent);
|
||||||
|
const isStandalone =
|
||||||
|
window.matchMedia("(display-mode: standalone)").matches ||
|
||||||
|
window.navigator.standalone === true;
|
||||||
|
|
||||||
|
function urlBase64ToUint8Array(base64String) {
|
||||||
|
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const raw = atob(base64);
|
||||||
|
return Uint8Array.from([...raw].map((char) => char.charCodeAt(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJSON(url, payload) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePush() {
|
||||||
|
if (!("serviceWorker" in navigator) || !("PushManager" in window) || !pushButton) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration = await navigator.serviceWorker.register("/service-worker.js");
|
||||||
|
const existing = await registration.pushManager.getSubscription();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await postJSON("/settings/push/unsubscribe", { endpoint: existing.endpoint });
|
||||||
|
await existing.unsubscribe();
|
||||||
|
pushButton.dataset.subscribed = "0";
|
||||||
|
pushButton.textContent = "Push aktivieren";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission !== "granted") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(vapidKey),
|
||||||
|
});
|
||||||
|
await postJSON("/settings/push/subscribe", subscription.toJSON());
|
||||||
|
pushButton.dataset.subscribed = "1";
|
||||||
|
pushButton.textContent = "Push deaktivieren";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
navigator.serviceWorker.register("/service-worker.js").catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pushButton && (!("serviceWorker" in navigator) || !("PushManager" in window))) {
|
||||||
|
pushButton.disabled = true;
|
||||||
|
if (pushHint) {
|
||||||
|
pushHint.textContent = "Dieser Browser unterstützt Web-Push hier aktuell nicht.";
|
||||||
|
}
|
||||||
|
} else if (pushButton && isIos && !isStandalone) {
|
||||||
|
pushButton.disabled = true;
|
||||||
|
if (pushHint) {
|
||||||
|
pushHint.textContent = "Auf iPhone/iPad funktioniert Web-Push erst nach „Zum Home-Bildschirm“ und Öffnen als Web-App.";
|
||||||
|
}
|
||||||
|
} else if (pushButton && vapidKey) {
|
||||||
|
pushButton.addEventListener("click", () => {
|
||||||
|
togglePush().catch((error) => console.error("Push toggle failed", error));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
25
app/static/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "Putzliga",
|
||||||
|
"short_name": "Putzliga",
|
||||||
|
"description": "Spielerische Haushalts-App mit Aufgaben, Punkten und Monats-Highscore.",
|
||||||
|
"id": "/",
|
||||||
|
"start_url": "/my-tasks",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#eef3ff",
|
||||||
|
"theme_color": "#f5f7ff",
|
||||||
|
"lang": "de",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/images/pwa-icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/images/pwa-icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
71
app/static/service-worker.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
const CACHE_NAME = "putzliga-shell-v1";
|
||||||
|
const ASSETS = [
|
||||||
|
"/my-tasks",
|
||||||
|
"/static/css/style.css",
|
||||||
|
"/static/js/app.js",
|
||||||
|
"/static/images/logo.svg",
|
||||||
|
"/static/images/pwa-icon-192.png",
|
||||||
|
"/static/images/pwa-icon-512.png"
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS)));
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
if (event.request.method !== "GET") return;
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request).then((cached) => {
|
||||||
|
if (cached) return cached;
|
||||||
|
return fetch(event.request)
|
||||||
|
.then((response) => {
|
||||||
|
const clone = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match("/my-tasks"));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("push", (event) => {
|
||||||
|
const payload = event.data ? event.data.json() : {};
|
||||||
|
const title = payload.title || "Putzliga";
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(title, {
|
||||||
|
body: payload.body || "Es gibt Neuigkeiten in der Putzliga.",
|
||||||
|
icon: payload.icon || "/static/images/pwa-icon-192.png",
|
||||||
|
badge: payload.badge || "/static/images/pwa-badge.png",
|
||||||
|
tag: payload.tag || "putzliga",
|
||||||
|
data: { url: payload.url || "/my-tasks" }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("notificationclick", (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const targetUrl = event.notification.data?.url || "/my-tasks";
|
||||||
|
event.waitUntil(
|
||||||
|
clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => {
|
||||||
|
for (const client of clientList) {
|
||||||
|
if ("focus" in client) {
|
||||||
|
client.navigate(targetUrl);
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (clients.openWindow) {
|
||||||
|
return clients.openWindow(targetUrl);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
48
app/templates/auth/login.html
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Login · Putzliga{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="auth-layout">
|
||||||
|
<div class="hero-card hero-card--brand">
|
||||||
|
<p class="eyebrow">Leichtgewichtige Haushalts-App</p>
|
||||||
|
<h2>Putzliga bringt Punkte, Rhythmus und ein bisschen Liga-Stimmung in den Alltag.</h2>
|
||||||
|
<p>Mehrere Nutzer, wiederkehrende Aufgaben, Monats-Highscore, Archiv, Kalender und PWA-Pushs in einer bewusst schlanken Flask-App.</p>
|
||||||
|
<div class="hero-stats">
|
||||||
|
<div>
|
||||||
|
<strong>Mobile first</strong>
|
||||||
|
<span>Bottom Navigation wie in einer iPhone-App</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Fair verteilt</strong>
|
||||||
|
<span>Punkte landen bei der Person, die wirklich erledigt hat</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Erweiterbar</strong>
|
||||||
|
<span>SQLite, Flask, Jinja und saubere Services statt Overengineering</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel auth-panel">
|
||||||
|
<p class="eyebrow">Einloggen</p>
|
||||||
|
<h2>Willkommen zurück</h2>
|
||||||
|
<form method="post" class="form-grid">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<div class="field">
|
||||||
|
{{ form.email.label }}
|
||||||
|
{{ form.email(placeholder="anna@putzliga.local") }}
|
||||||
|
{% for error in form.email.errors %}<small class="error">{{ error }}</small>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
{{ form.password.label }}
|
||||||
|
{{ form.password(placeholder="Passwort") }}
|
||||||
|
{% for error in form.password.errors %}<small class="error">{{ error }}</small>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<label class="checkbox">{{ form.remember_me() }} <span>Angemeldet bleiben</span></label>
|
||||||
|
{{ form.submit(class_="button button--wide") }}
|
||||||
|
</form>
|
||||||
|
<p class="inline-note">Demo-Logins nach dem Seeden: `anna@putzliga.local` / `putzliga123` und `ben@putzliga.local` / `putzliga123`.</p>
|
||||||
|
<p class="inline-note">Noch kein Konto? <a href="{{ url_for('auth.register') }}">Neu registrieren</a></p>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
42
app/templates/auth/register.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Registrieren · Putzliga{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="auth-layout">
|
||||||
|
<div class="hero-card hero-card--brand">
|
||||||
|
<p class="eyebrow">Gemeinsam sauberer</p>
|
||||||
|
<h2>Erstelle dein Konto und steig direkt in die Liga ein.</h2>
|
||||||
|
<p>Nach dem Login landest du sofort bei „Meine Aufgaben“ und kannst Aufgaben anlegen, verteilen und Punkte sammeln.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel auth-panel">
|
||||||
|
<p class="eyebrow">Registrieren</p>
|
||||||
|
<h2>Neues Konto</h2>
|
||||||
|
<form method="post" class="form-grid">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<div class="field">
|
||||||
|
{{ form.name.label }}
|
||||||
|
{{ form.name(placeholder="Dein Name") }}
|
||||||
|
{% for error in form.name.errors %}<small class="error">{{ error }}</small>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
{{ form.email.label }}
|
||||||
|
{{ form.email(placeholder="mail@example.com") }}
|
||||||
|
{% for error in form.email.errors %}<small class="error">{{ error }}</small>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
{{ form.password.label }}
|
||||||
|
{{ form.password(placeholder="Mindestens 6 Zeichen") }}
|
||||||
|
{% for error in form.password.errors %}<small class="error">{{ error }}</small>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
{{ form.password_confirm.label }}
|
||||||
|
{{ form.password_confirm(placeholder="Passwort wiederholen") }}
|
||||||
|
{% for error in form.password_confirm.errors %}<small class="error">{{ error }}</small>{% endfor %}
|
||||||
|
</div>
|
||||||
|
{{ form.submit(class_="button button--wide") }}
|
||||||
|
</form>
|
||||||
|
<p class="inline-note">Schon dabei? <a href="{{ url_for('auth.login') }}">Zum Login</a></p>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
121
app/templates/base.html
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#f5f7ff">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="{{ app_name }}">
|
||||||
|
<meta name="description" content="Putzliga macht Haushaltsaufgaben leichter, motivierender und fair verteilt.">
|
||||||
|
<title>{% block title %}{{ app_name }}{% endblock %}</title>
|
||||||
|
<link rel="manifest" href="{{ url_for('main.manifest') }}">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='images/favicon.svg') }}">
|
||||||
|
<link rel="apple-touch-icon" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body data-push-key="{{ config['VAPID_PUBLIC_KEY'] if current_user.is_authenticated else '' }}">
|
||||||
|
{% from "partials/macros.html" import nav_icon %}
|
||||||
|
<div class="app-shell {% if not current_user.is_authenticated %}app-shell--auth{% endif %}">
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<aside class="sidebar">
|
||||||
|
<a class="brand" href="{{ url_for('tasks.my_tasks') }}">
|
||||||
|
<img src="{{ url_for('static', filename='images/logo.svg') }}" alt="Putzliga Logo" class="brand__logo">
|
||||||
|
<div>
|
||||||
|
<strong>Putzliga</strong>
|
||||||
|
<span>Haushalt mit Punktestand</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<nav class="sidebar-nav" aria-label="Hauptnavigation">
|
||||||
|
{% for endpoint, label, icon in nav_items %}
|
||||||
|
<a href="{{ url_for(endpoint) }}" class="nav-link {% if request.endpoint == endpoint %}is-active{% endif %}">
|
||||||
|
{{ nav_icon(icon) }}
|
||||||
|
<span>{{ label }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section class="sidebar-card">
|
||||||
|
<div class="sidebar-card__row">
|
||||||
|
<img class="avatar avatar--lg" src="{% if current_user.avatar_path and current_user.avatar_path.startswith('avatars/') %}{{ url_for('main.uploads', filename=current_user.avatar_path) }}{% else %}{{ url_for('static', filename=current_user.display_avatar) }}{% endif %}" alt="Avatar von {{ current_user.name }}">
|
||||||
|
<div>
|
||||||
|
<strong>{{ current_user.name }}</strong>
|
||||||
|
<p>{{ current_user.email }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a class="text-link" href="{{ url_for('auth.logout') }}">Abmelden</a>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="page-shell">
|
||||||
|
<header class="topbar">
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Spielerisch sauber bleiben</p>
|
||||||
|
<h1>{% block page_title %}{{ app_name }}{% endblock %}</h1>
|
||||||
|
</div>
|
||||||
|
<a class="topbar-user" href="{{ url_for('settings.index') }}">
|
||||||
|
<span>{{ current_user.name }}</span>
|
||||||
|
<img class="avatar" src="{% if current_user.avatar_path and current_user.avatar_path.startswith('avatars/') %}{{ url_for('main.uploads', filename=current_user.avatar_path) }}{% else %}{{ url_for('static', filename=current_user.display_avatar) }}{% endif %}" alt="">
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="brand brand--public" href="{{ url_for('auth.login') }}">
|
||||||
|
<img src="{{ url_for('static', filename='images/logo.svg') }}" alt="Putzliga Logo" class="brand__logo">
|
||||||
|
<div>
|
||||||
|
<strong>Putzliga</strong>
|
||||||
|
<span>Haushaltsaufgaben mit Liga-Gefühl</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="flash-stack">
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="flash flash--{{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<nav class="bottom-nav" aria-label="Mobile Navigation">
|
||||||
|
{% for endpoint, label, icon in nav_items %}
|
||||||
|
<a href="{{ url_for(endpoint) }}" class="bottom-nav__item {% if request.endpoint == endpoint %}is-active{% endif %}">
|
||||||
|
{{ nav_icon(icon) }}
|
||||||
|
<span>{{ label }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<dialog class="complete-dialog" id="completeDialog">
|
||||||
|
<form method="dialog" class="complete-dialog__surface">
|
||||||
|
<p class="eyebrow">Punkte fair verbuchen</p>
|
||||||
|
<h2>Wer hat diese Aufgabe erledigt?</h2>
|
||||||
|
<p id="completeDialogText">Bitte wähle aus, wem die Punkte gutgeschrieben werden sollen.</p>
|
||||||
|
<div class="choice-grid">
|
||||||
|
<button type="button" class="button button--secondary" data-complete-choice="assigned">Zugewiesene Person</button>
|
||||||
|
<button type="button" class="button" data-complete-choice="me">Ich</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="button button--ghost" id="completeDialogClose">Abbrechen</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<form method="post" class="sr-only" id="completeDialogForm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="completed_for" value="me" id="completeDialogChoice">
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/app.js') }}" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
92
app/templates/partials/macros.html
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
{% macro nav_icon(name) -%}
|
||||||
|
<span class="nav-icon">{{ icon_svg(name)|safe }}</span>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro status_badge(task) -%}
|
||||||
|
<span class="status-badge status-badge--{{ task.status }}">{{ task.status_label }}</span>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro avatar(user, size='') -%}
|
||||||
|
{% if user %}
|
||||||
|
{% if user.avatar_path and user.avatar_path.startswith('avatars/') %}
|
||||||
|
<img class="avatar {{ size }}" src="{{ url_for('main.uploads', filename=user.avatar_path) }}" alt="Avatar von {{ user.name }}">
|
||||||
|
{% else %}
|
||||||
|
<img class="avatar {{ size }}" src="{{ url_for('static', filename=user.display_avatar) }}" alt="Avatar von {{ user.name }}">
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro task_card(task, current_user, compact=false) -%}
|
||||||
|
<article class="task-card {% if compact %}task-card--compact{% endif %}">
|
||||||
|
<div class="task-card__top">
|
||||||
|
<div>
|
||||||
|
<div class="chip-row">
|
||||||
|
{{ status_badge(task) }}
|
||||||
|
<span class="point-pill">{{ task.points_awarded }} Punkte</span>
|
||||||
|
</div>
|
||||||
|
<h3>{{ task.title }}</h3>
|
||||||
|
</div>
|
||||||
|
<a class="icon-button" href="{{ url_for('tasks.edit', task_id=task.id) }}" aria-label="Aufgabe bearbeiten">
|
||||||
|
{{ nav_icon('gear') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if task.description %}
|
||||||
|
<p class="muted">{{ task.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<dl class="task-meta">
|
||||||
|
<div>
|
||||||
|
<dt>Fällig</dt>
|
||||||
|
<dd>{{ task.due_date|date_de }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Zuständig</dt>
|
||||||
|
<dd>{{ task.assigned_user.name if task.assigned_user else 'Unverteilt' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Rhythmus</dt>
|
||||||
|
<dd>{{ task.task_template.recurrence_label }}</dd>
|
||||||
|
</div>
|
||||||
|
{% if task.completed_at %}
|
||||||
|
<div>
|
||||||
|
<dt>Erledigt von</dt>
|
||||||
|
<dd>{{ task.completed_by_user.name if task.completed_by_user else '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="task-card__footer">
|
||||||
|
<div class="task-assignee">
|
||||||
|
{{ avatar(task.assigned_user) }}
|
||||||
|
<span>{{ task.assigned_user.name if task.assigned_user else 'Ohne Person' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not task.completed_at %}
|
||||||
|
{% if task.assigned_user_id and task.assigned_user_id != current_user.id %}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button"
|
||||||
|
data-complete-action="{{ url_for('tasks.complete', task_id=task.id) }}"
|
||||||
|
data-complete-title="{{ task.title }}"
|
||||||
|
data-complete-assigned="{{ task.assigned_user.name if task.assigned_user else 'Zugewiesene Person' }}"
|
||||||
|
>
|
||||||
|
{{ nav_icon('check') }}
|
||||||
|
<span>Erledigen</span>
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="{{ url_for('tasks.complete', task_id=task.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="completed_for" value="me">
|
||||||
|
<button type="submit" class="button">
|
||||||
|
{{ nav_icon('check') }}
|
||||||
|
<span>Erledigen</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="done-hint">Am {{ task.completed_at|datetime_de }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{%- endmacro %}
|
||||||
84
app/templates/scoreboard/index.html
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "partials/macros.html" import avatar %}
|
||||||
|
{% block title %}Highscoreboard · Putzliga{% endblock %}
|
||||||
|
{% block page_title %}Highscoreboard{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="hero-grid">
|
||||||
|
<article class="hero-card">
|
||||||
|
<p class="eyebrow">Aktueller Monat</p>
|
||||||
|
<h2>{{ current_label }}</h2>
|
||||||
|
<p>Aufgabenpunkte und Badge-Boni zählen nur im aktuellen Monat. Zum Monatswechsel landet alles im Archiv und die Liga startet wieder bei null.</p>
|
||||||
|
</article>
|
||||||
|
<article class="panel highlight-panel">
|
||||||
|
<p class="eyebrow">Archiv</p>
|
||||||
|
<form method="get" class="inline-form">
|
||||||
|
<select name="archive">
|
||||||
|
{% for year, month in archive_options %}
|
||||||
|
{% set archive_value = year ~ '-' ~ '%02d'|format(month) %}
|
||||||
|
<option value="{{ archive_value }}" {% if selected_archive == archive_value %}selected{% endif %}>{{ month|month_name }} {{ year }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="button button--secondary">Anzeigen</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="scoreboard">
|
||||||
|
{% for row in current_rows %}
|
||||||
|
<article class="score-row {% if row.rank == 1 %}score-row--leader{% endif %}">
|
||||||
|
<div class="score-row__head">
|
||||||
|
<div class="score-row__person">
|
||||||
|
<span class="rank-badge">#{{ row.rank }}</span>
|
||||||
|
{{ avatar(row.user) }}
|
||||||
|
<div>
|
||||||
|
<strong>{{ row.user.name }}</strong>
|
||||||
|
<p>{{ row.completed_tasks_count }} erledigte Aufgaben</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="score-row__points">
|
||||||
|
<strong>{{ row.total_points }}</strong>
|
||||||
|
<span>Punkte</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="score-bar">
|
||||||
|
<span style="width: {{ 0 if max_points == 0 else (row.total_points / max_points * 100) }}%"></span>
|
||||||
|
</div>
|
||||||
|
<div class="score-row__meta">
|
||||||
|
<span>Basis: {{ row.base_points }}</span>
|
||||||
|
<span>Badges: +{{ row.bonus_points }}</span>
|
||||||
|
</div>
|
||||||
|
{% if row.badges %}
|
||||||
|
<div class="badge-cloud">
|
||||||
|
{% for badge in row.badges %}
|
||||||
|
<span class="reward-chip">{{ badge.definition.name }} +{{ badge.bonus_points }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">Noch keine Punkte in diesem Monat.</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<p class="eyebrow">Monatsarchiv</p>
|
||||||
|
<h2>{{ archive_label or 'Noch kein Archiv' }}</h2>
|
||||||
|
<div class="archive-list">
|
||||||
|
{% for row in archived_rows %}
|
||||||
|
<article class="archive-row">
|
||||||
|
<div class="archive-row__left">
|
||||||
|
<span class="rank-badge">#{{ row.rank }}</span>
|
||||||
|
{{ avatar(row.user) }}
|
||||||
|
<strong>{{ row.user.name }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="archive-row__right">
|
||||||
|
<span>{{ row.total_points }} Punkte</span>
|
||||||
|
<small>{{ row.completed_tasks_count }} Aufgaben</small>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">Sobald ein Monat archiviert wurde, taucht er hier auf.</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
100
app/templates/settings/index.html
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "partials/macros.html" import avatar, nav_icon %}
|
||||||
|
{% block title %}Optionen · Putzliga{% endblock %}
|
||||||
|
{% block page_title %}Optionen{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="two-column">
|
||||||
|
<article class="panel">
|
||||||
|
<p class="eyebrow">Profil & Benachrichtigungen</p>
|
||||||
|
<h2>Persönliche Einstellungen</h2>
|
||||||
|
<form method="post" enctype="multipart/form-data" class="form-grid">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<div class="field">
|
||||||
|
{{ form.name.label }}
|
||||||
|
{{ form.name() }}
|
||||||
|
{% for error in form.name.errors %}<small class="error">{{ error }}</small>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
{{ form.email.label }}
|
||||||
|
{{ form.email() }}
|
||||||
|
{% for error in form.email.errors %}<small class="error">{{ error }}</small>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
{{ form.password.label }}
|
||||||
|
{{ form.password(placeholder="Leer lassen, wenn nichts geändert werden soll") }}
|
||||||
|
{% for error in form.password.errors %}<small class="error">{{ error }}</small>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
{{ form.avatar.label }}
|
||||||
|
{{ form.avatar() }}
|
||||||
|
{% for error in form.avatar.errors %}<small class="error">{{ error }}</small>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<label class="checkbox">
|
||||||
|
{{ form.notification_task_due_enabled() }}
|
||||||
|
<span>Push für heute oder morgen fällige Aufgaben</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox">
|
||||||
|
{{ form.notification_monthly_winner_enabled() }}
|
||||||
|
<span>Push zum Monatssieger am 1. um 09:00 Uhr</span>
|
||||||
|
</label>
|
||||||
|
{{ form.submit(class_='button') }}
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<p class="eyebrow">Push & App-Install</p>
|
||||||
|
<h2>Web-Push vorbereiten</h2>
|
||||||
|
<p class="muted">Putzliga nutzt echten Web-Push mit Service Worker und gespeicherten Subscriptions. Auf iPhone funktioniert das nur, wenn die App zum Home-Bildschirm hinzugefügt wurde.</p>
|
||||||
|
<div class="push-box">
|
||||||
|
<div class="push-box__state {% if push_ready %}is-ready{% else %}is-disabled{% endif %}">
|
||||||
|
{{ nav_icon('bell') }}
|
||||||
|
<div>
|
||||||
|
<strong>{% if push_ready %}VAPID konfiguriert{% else %}VAPID fehlt{% endif %}</strong>
|
||||||
|
<p>{% if push_ready %}Push kann im Browser aktiviert werden.{% else %}Bitte zuerst Public/Private Key in der Umgebung setzen.{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button button--wide"
|
||||||
|
id="pushToggle"
|
||||||
|
{% if not push_ready %}disabled{% endif %}
|
||||||
|
data-subscribed="{{ '1' if has_subscription else '0' }}"
|
||||||
|
>
|
||||||
|
{% if has_subscription %}Push deaktivieren{% else %}Push aktivieren{% endif %}
|
||||||
|
</button>
|
||||||
|
<p class="inline-note" id="pushHint">
|
||||||
|
Auf iPhone/iPad bitte zuerst in Safari zum Home-Bildschirm hinzufügen und als Web-App öffnen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<p class="eyebrow">Gamification</p>
|
||||||
|
<h2>Badge-Regeln pflegen</h2>
|
||||||
|
<div class="badge-settings">
|
||||||
|
{% for badge in badges %}
|
||||||
|
<form method="post" action="{{ url_for('settings.update_badge', badge_id=badge.id) }}" class="badge-setting-card">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div>
|
||||||
|
<strong>{{ badge.name }}</strong>
|
||||||
|
<p class="muted">{{ badge.description }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="field field--compact">
|
||||||
|
<label>Schwelle</label>
|
||||||
|
<input type="number" name="threshold" min="1" value="{{ badge.threshold }}">
|
||||||
|
</div>
|
||||||
|
<div class="field field--compact">
|
||||||
|
<label>Bonus</label>
|
||||||
|
<input type="number" name="bonus_points" min="0" value="{{ badge.bonus_points }}">
|
||||||
|
</div>
|
||||||
|
<label class="checkbox checkbox--compact">
|
||||||
|
<input type="checkbox" name="active" {% if badge.active %}checked{% endif %}>
|
||||||
|
<span>Aktiv</span>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="button button--secondary">Badge speichern</button>
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
51
app/templates/tasks/all_tasks.html
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "partials/macros.html" import task_card %}
|
||||||
|
{% block title %}Alle Aufgaben · Putzliga{% endblock %}
|
||||||
|
{% block page_title %}Alle Aufgaben{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="panel">
|
||||||
|
<form method="get" class="filter-bar">
|
||||||
|
<div class="field field--compact">
|
||||||
|
<label for="status">Status</label>
|
||||||
|
<select name="status" id="status">
|
||||||
|
<option value="all" {% if filters.status == 'all' %}selected{% endif %}>Alle</option>
|
||||||
|
<option value="open" {% if filters.status == 'open' %}selected{% endif %}>Offen</option>
|
||||||
|
<option value="soon" {% if filters.status == 'soon' %}selected{% endif %}>Bald fällig</option>
|
||||||
|
<option value="overdue" {% if filters.status == 'overdue' %}selected{% endif %}>Überfällig</option>
|
||||||
|
<option value="completed" {% if filters.status == 'completed' %}selected{% endif %}>Erledigt</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field field--compact">
|
||||||
|
<label for="user_id">Nutzer</label>
|
||||||
|
<select name="user_id" id="user_id">
|
||||||
|
<option value="">Alle</option>
|
||||||
|
{% for user in users %}
|
||||||
|
<option value="{{ user.id }}" {% if filters.user_id == user.id %}selected{% endif %}>{{ user.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field field--compact">
|
||||||
|
<label for="sort">Sortierung</label>
|
||||||
|
<select name="sort" id="sort">
|
||||||
|
<option value="due" {% if filters.sort == 'due' %}selected{% endif %}>Fälligkeit</option>
|
||||||
|
<option value="points" {% if filters.sort == 'points' %}selected{% endif %}>Punkte</option>
|
||||||
|
<option value="user" {% if filters.sort == 'user' %}selected{% endif %}>Nutzer</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label class="checkbox checkbox--compact">
|
||||||
|
<input type="checkbox" name="mine" value="1" {% if filters.mine == '1' %}checked{% endif %}>
|
||||||
|
<span>Nur meine</span>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="button">Filter anwenden</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="task-grid">
|
||||||
|
{% for task in tasks %}
|
||||||
|
{{ task_card(task, current_user) }}
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">Für diese Filter gibt es gerade keine Aufgaben.</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
66
app/templates/tasks/calendar.html
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "partials/macros.html" import status_badge %}
|
||||||
|
{% block title %}Kalender · Putzliga{% endblock %}
|
||||||
|
{% block page_title %}Kalender & Liste{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="panel panel--toolbar">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Monatsansicht</p>
|
||||||
|
<h2>{{ current_label }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<a class="button button--secondary" href="{{ url_for('tasks.calendar_view', year=current_year if current_month > 1 else current_year - 1, month=current_month - 1 if current_month > 1 else 12, view=view) }}">Zurück</a>
|
||||||
|
<a class="button button--secondary" href="{{ url_for('tasks.calendar_view', year=current_year if current_month < 12 else current_year + 1, month=current_month + 1 if current_month < 12 else 1, view=view) }}">Weiter</a>
|
||||||
|
<div class="segmented">
|
||||||
|
<a href="{{ url_for('tasks.calendar_view', year=current_year, month=current_month, view='calendar') }}" class="{% if view == 'calendar' %}is-active{% endif %}">Kalender</a>
|
||||||
|
<a href="{{ url_for('tasks.calendar_view', year=current_year, month=current_month, view='list') }}" class="{% if view == 'list' %}is-active{% endif %}">Liste</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if view == 'calendar' %}
|
||||||
|
<section class="calendar-grid">
|
||||||
|
<div class="calendar-grid__weekdays">
|
||||||
|
{% for label in ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] %}
|
||||||
|
<span>{{ label }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% for week in month_calendar %}
|
||||||
|
{% for day in week %}
|
||||||
|
<article class="calendar-day {% if day == 0 %}calendar-day--empty{% endif %}">
|
||||||
|
{% if day != 0 %}
|
||||||
|
<strong>{{ day }}</strong>
|
||||||
|
<div class="calendar-day__tasks">
|
||||||
|
{% for task in tasks_by_day.get(day, []) %}
|
||||||
|
<a href="{{ url_for('tasks.edit', task_id=task.id) }}" class="calendar-task calendar-task--{{ task.status }}">
|
||||||
|
<span>{{ task.title }}</span>
|
||||||
|
<small>{{ task.points_awarded }} P</small>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% else %}
|
||||||
|
<section class="stack">
|
||||||
|
{% for task in tasks %}
|
||||||
|
<article class="panel list-row">
|
||||||
|
<div>
|
||||||
|
<strong>{{ task.title }}</strong>
|
||||||
|
<p class="muted">{{ task.description or 'Ohne Zusatzbeschreibung' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="list-row__meta">
|
||||||
|
{{ status_badge(task) }}
|
||||||
|
<span>{{ task.due_date|date_de }}</span>
|
||||||
|
<a href="{{ url_for('tasks.edit', task_id=task.id) }}" class="text-link">Bearbeiten</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">In diesem Monat sind noch keine Aufgaben hinterlegt.</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
88
app/templates/tasks/my_tasks.html
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "partials/macros.html" import task_card, nav_icon %}
|
||||||
|
{% block title %}Meine Aufgaben · Putzliga{% endblock %}
|
||||||
|
{% block page_title %}Meine Aufgaben{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="hero-grid">
|
||||||
|
<article class="hero-card">
|
||||||
|
<p class="eyebrow">Heute im Fokus</p>
|
||||||
|
<h2>{{ current_user.name }}, deine Liga für den Haushalt läuft.</h2>
|
||||||
|
<p>Erledige offene Aufgaben, sammle Punkte und halte deinen Monatslauf stabil.</p>
|
||||||
|
<div class="progress-card">
|
||||||
|
<div class="progress-card__top">
|
||||||
|
<span>Erledigungsquote</span>
|
||||||
|
<strong>{{ completion_ratio }}%</strong>
|
||||||
|
</div>
|
||||||
|
<div class="progress"><span style="width: {{ completion_ratio }}%"></span></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="panel highlight-panel">
|
||||||
|
<p class="eyebrow">Schnellzugriff</p>
|
||||||
|
<a class="button button--wide" href="{{ url_for('tasks.create') }}">
|
||||||
|
{{ nav_icon('plus') }}
|
||||||
|
<span>Neue Aufgabe anlegen</span>
|
||||||
|
</a>
|
||||||
|
<a class="button button--secondary button--wide" href="{{ url_for('scoreboard.index') }}">
|
||||||
|
{{ nav_icon('trophy') }}
|
||||||
|
<span>Zum aktuellen Highscore</span>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stack">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2>Überfällig</h2>
|
||||||
|
<span class="section-heading__count">{{ sections.overdue|length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="task-grid">
|
||||||
|
{% for task in sections.overdue %}
|
||||||
|
{{ task_card(task, current_user) }}
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">Nichts überfällig. Genau so darf es bleiben.</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stack">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2>Bald fällig</h2>
|
||||||
|
<span class="section-heading__count">{{ sections.soon|length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="task-grid">
|
||||||
|
{% for task in sections.soon %}
|
||||||
|
{{ task_card(task, current_user) }}
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">Gerade nichts, was in den nächsten Tagen drängt.</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stack">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2>Offen</h2>
|
||||||
|
<span class="section-heading__count">{{ sections.open|length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="task-grid">
|
||||||
|
{% for task in sections.open %}
|
||||||
|
{{ task_card(task, current_user) }}
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">Alles leer. Zeit für eine kleine Siegerrunde.</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stack">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2>Erledigt</h2>
|
||||||
|
<span class="section-heading__count">{{ sections.completed|length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="task-grid">
|
||||||
|
{% for task in sections.completed %}
|
||||||
|
{{ task_card(task, current_user, compact=true) }}
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">Noch keine erledigten Aufgaben in deiner Liste.</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
64
app/templates/tasks/task_form.html
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{% if mode == 'edit' %}Aufgabe bearbeiten{% else %}Aufgabe erstellen{% endif %} · Putzliga{% endblock %}
|
||||||
|
{% block page_title %}{% if mode == 'edit' %}Aufgabe bearbeiten{% else %}Aufgabe erstellen{% endif %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="panel form-panel">
|
||||||
|
<p class="eyebrow">{% if mode == 'edit' %}Bestehende Aufgabe anpassen{% else %}Neue Aufgabe und Vorlage{% endif %}</p>
|
||||||
|
<h2>{% if mode == 'edit' %}Änderungen für {{ task.title }}{% else %}Neue Aufgabe anlegen{% endif %}</h2>
|
||||||
|
<form method="post" class="form-grid form-grid--two">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
|
<div class="field field--full">
|
||||||
|
{{ form.title.label }}
|
||||||
|
{{ form.title(placeholder="Zum Beispiel: Küche wischen") }}
|
||||||
|
{% for error in form.title.errors %}<small class="error">{{ error }}</small>{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field field--full">
|
||||||
|
{{ form.description.label }}
|
||||||
|
{{ form.description(rows="4", placeholder="Optional: kurze Hinweise zur Aufgabe") }}
|
||||||
|
{% for error in form.description.errors %}<small class="error">{{ error }}</small>{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
{{ form.default_points.label }}
|
||||||
|
{{ form.default_points() }}
|
||||||
|
{% for error in form.default_points.errors %}<small class="error">{{ error }}</small>{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
{{ form.assigned_user_id.label }}
|
||||||
|
{{ form.assigned_user_id() }}
|
||||||
|
{% for error in form.assigned_user_id.errors %}<small class="error">{{ error }}</small>{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
{{ form.due_date.label }}
|
||||||
|
{{ form.due_date() }}
|
||||||
|
{% for error in form.due_date.errors %}<small class="error">{{ error }}</small>{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
{{ form.recurrence_interval_unit.label }}
|
||||||
|
{{ form.recurrence_interval_unit() }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
{{ form.recurrence_interval_value.label }}
|
||||||
|
{{ form.recurrence_interval_value() }}
|
||||||
|
{% for error in form.recurrence_interval_value.errors %}<small class="error">{{ error }}</small>{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="checkbox">
|
||||||
|
{{ form.active() }}
|
||||||
|
<span>Vorlage bleibt aktiv und erzeugt bei Wiederholung weitere Aufgaben</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="form-actions field--full">
|
||||||
|
{{ form.submit(class_='button') }}
|
||||||
|
<a class="button button--ghost" href="{{ url_for('tasks.all_tasks') }}">Abbrechen</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
32
config.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
APP_NAME = "Putzliga"
|
||||||
|
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-change-me")
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
WTF_CSRF_TIME_LIMIT = None
|
||||||
|
|
||||||
|
DATA_DIR = Path(os.getenv("DATA_DIR", BASE_DIR / "data")).resolve()
|
||||||
|
SQLITE_PATH = Path(os.getenv("DATABASE_PATH", DATA_DIR / "putzliga.db")).resolve()
|
||||||
|
SQLALCHEMY_DATABASE_URI = f"sqlite:///{SQLITE_PATH}"
|
||||||
|
|
||||||
|
UPLOAD_FOLDER = Path(os.getenv("UPLOAD_FOLDER", DATA_DIR / "uploads")).resolve()
|
||||||
|
MAX_CONTENT_LENGTH = 3 * 1024 * 1024
|
||||||
|
|
||||||
|
APP_BASE_URL = os.getenv("APP_BASE_URL") or os.getenv("CLOUDRON_APP_ORIGIN", "http://localhost:8000")
|
||||||
|
APP_TIMEZONE = os.getenv("APP_TIMEZONE", "Europe/Berlin")
|
||||||
|
PORT = int(os.getenv("PORT", "8000"))
|
||||||
|
|
||||||
|
VAPID_PUBLIC_KEY = os.getenv("VAPID_PUBLIC_KEY", "")
|
||||||
|
VAPID_PRIVATE_KEY = os.getenv("VAPID_PRIVATE_KEY", "").replace("\\n", "\n")
|
||||||
|
VAPID_CLAIMS_SUBJECT = os.getenv("VAPID_CLAIMS_SUBJECT", "mailto:admin@example.com")
|
||||||
|
|
||||||
|
SESSION_COOKIE_SAMESITE = "Lax"
|
||||||
|
REMEMBER_COOKIE_SAMESITE = "Lax"
|
||||||
8
requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Flask==3.1.0
|
||||||
|
Flask-Login==0.6.3
|
||||||
|
Flask-SQLAlchemy==3.1.1
|
||||||
|
Flask-WTF==1.2.2
|
||||||
|
email-validator==2.2.0
|
||||||
|
gunicorn==23.0.0
|
||||||
|
pywebpush==2.0.3
|
||||||
|
python-dotenv==1.0.1
|
||||||
103
scripts/generate_assets.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import struct
|
||||||
|
import zlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1] / "app" / "static" / "images"
|
||||||
|
|
||||||
|
|
||||||
|
def png_chunk(tag: bytes, data: bytes) -> bytes:
|
||||||
|
return struct.pack("!I", len(data)) + tag + data + struct.pack("!I", zlib.crc32(tag + data) & 0xFFFFFFFF)
|
||||||
|
|
||||||
|
|
||||||
|
def save_png(path: Path, width: int, height: int, rgba_rows: list[bytes]) -> None:
|
||||||
|
raw = b"".join(b"\x00" + row for row in rgba_rows)
|
||||||
|
header = struct.pack("!IIBBBBB", width, height, 8, 6, 0, 0, 0)
|
||||||
|
content = b"".join(
|
||||||
|
[
|
||||||
|
b"\x89PNG\r\n\x1a\n",
|
||||||
|
png_chunk(b"IHDR", header),
|
||||||
|
png_chunk(b"IDAT", zlib.compress(raw, 9)),
|
||||||
|
png_chunk(b"IEND", b""),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
path.write_bytes(content)
|
||||||
|
|
||||||
|
|
||||||
|
def lerp(a: int, b: int, t: float) -> int:
|
||||||
|
return int(a + (b - a) * t)
|
||||||
|
|
||||||
|
|
||||||
|
def rounded_square_icon(size: int) -> list[bytes]:
|
||||||
|
rows = []
|
||||||
|
radius = size * 0.22
|
||||||
|
for y in range(size):
|
||||||
|
row = bytearray()
|
||||||
|
for x in range(size):
|
||||||
|
dx = min(x, size - 1 - x)
|
||||||
|
dy = min(y, size - 1 - y)
|
||||||
|
in_corner = (dx < radius and dy < radius)
|
||||||
|
if in_corner:
|
||||||
|
cx = radius - dx
|
||||||
|
cy = radius - dy
|
||||||
|
inside = cx * cx + cy * cy <= radius * radius
|
||||||
|
else:
|
||||||
|
inside = True
|
||||||
|
|
||||||
|
if not inside:
|
||||||
|
row.extend((0, 0, 0, 0))
|
||||||
|
continue
|
||||||
|
|
||||||
|
t = (x + y) / (2 * size)
|
||||||
|
r = lerp(37, 52, t)
|
||||||
|
g = lerp(99, 211, t)
|
||||||
|
b = lerp(235, 153, t)
|
||||||
|
|
||||||
|
if (x - size * 0.5) ** 2 + (y - size * 0.48) ** 2 < (size * 0.33) ** 2:
|
||||||
|
r, g, b = 250, 252, 255
|
||||||
|
|
||||||
|
roof = abs(x - size * 0.5) + (y - size * 0.40)
|
||||||
|
if roof < size * 0.19 and y < size * 0.55:
|
||||||
|
r, g, b = 37, 99, 235
|
||||||
|
|
||||||
|
if size * 0.33 < x < size * 0.67 and size * 0.52 < y < size * 0.80:
|
||||||
|
r, g, b = 159, 208, 255
|
||||||
|
|
||||||
|
star_x = x - size * 0.68
|
||||||
|
star_y = y - size * 0.28
|
||||||
|
if abs(star_x) + abs(star_y) < size * 0.06 or (abs(star_x) < size * 0.018 or abs(star_y) < size * 0.018):
|
||||||
|
r, g, b = 245, 158, 11
|
||||||
|
|
||||||
|
row.extend((r, g, b, 255))
|
||||||
|
rows.append(bytes(row))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def badge_icon(size: int) -> list[bytes]:
|
||||||
|
rows = []
|
||||||
|
radius = size * 0.48
|
||||||
|
cx = cy = size / 2
|
||||||
|
for y in range(size):
|
||||||
|
row = bytearray()
|
||||||
|
for x in range(size):
|
||||||
|
if (x - cx) ** 2 + (y - cy) ** 2 > radius ** 2:
|
||||||
|
row.extend((0, 0, 0, 0))
|
||||||
|
continue
|
||||||
|
row.extend((37, 99, 235, 255))
|
||||||
|
rows.append(bytes(row))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
|
save_png(ROOT / "pwa-icon-192.png", 192, 192, rounded_square_icon(192))
|
||||||
|
save_png(Path(__file__).resolve().parents[1] / "icon.png", 256, 256, rounded_square_icon(256))
|
||||||
|
save_png(ROOT / "pwa-icon-512.png", 512, 512, rounded_square_icon(512))
|
||||||
|
save_png(ROOT / "apple-touch-icon.png", 180, 180, rounded_square_icon(180))
|
||||||
|
save_png(ROOT / "pwa-badge.png", 96, 96, badge_icon(96))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
15
scripts/generate_vapid_keys.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import base64
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||||
|
from py_vapid import Vapid01
|
||||||
|
|
||||||
|
|
||||||
|
private_key = Vapid01()
|
||||||
|
private_key.generate_keys()
|
||||||
|
|
||||||
|
public_key = private_key.public_key.public_bytes(Encoding.X962, PublicFormat.UncompressedPoint)
|
||||||
|
public_key_b64 = base64.urlsafe_b64encode(public_key).rstrip(b"=").decode()
|
||||||
|
private_key_env = private_key.private_pem().decode().strip().replace("\n", "\\n")
|
||||||
|
|
||||||
|
print("VAPID_PUBLIC_KEY=" + public_key_b64)
|
||||||
|
print("VAPID_PRIVATE_KEY=" + private_key_env)
|
||||||
182
seed.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
from app.cli import seed_badges
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models import TaskInstance, TaskTemplate, User
|
||||||
|
from app.services.monthly import archive_months_missing_up_to_previous
|
||||||
|
|
||||||
|
|
||||||
|
def seed_database() -> None:
|
||||||
|
app = create_app()
|
||||||
|
with app.app_context():
|
||||||
|
db.drop_all()
|
||||||
|
db.create_all()
|
||||||
|
seed_badges()
|
||||||
|
|
||||||
|
anna = User(
|
||||||
|
name="Anna",
|
||||||
|
email="anna@putzliga.local",
|
||||||
|
avatar_path="images/avatars/anna.svg",
|
||||||
|
notification_task_due_enabled=True,
|
||||||
|
notification_monthly_winner_enabled=True,
|
||||||
|
)
|
||||||
|
anna.set_password("putzliga123")
|
||||||
|
ben = User(
|
||||||
|
name="Ben",
|
||||||
|
email="ben@putzliga.local",
|
||||||
|
avatar_path="images/avatars/ben.svg",
|
||||||
|
notification_task_due_enabled=True,
|
||||||
|
notification_monthly_winner_enabled=False,
|
||||||
|
)
|
||||||
|
ben.set_password("putzliga123")
|
||||||
|
|
||||||
|
db.session.add_all([anna, ben])
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
templates = [
|
||||||
|
TaskTemplate(
|
||||||
|
title="Küche wischen",
|
||||||
|
description="Arbeitsfläche, Herd und Tisch sauber machen.",
|
||||||
|
default_points=12,
|
||||||
|
default_assigned_user_id=anna.id,
|
||||||
|
recurrence_interval_value=3,
|
||||||
|
recurrence_interval_unit="days",
|
||||||
|
active=True,
|
||||||
|
),
|
||||||
|
TaskTemplate(
|
||||||
|
title="Bad putzen",
|
||||||
|
description="Waschbecken, Spiegel und Dusche reinigen.",
|
||||||
|
default_points=20,
|
||||||
|
default_assigned_user_id=ben.id,
|
||||||
|
recurrence_interval_value=1,
|
||||||
|
recurrence_interval_unit="weeks",
|
||||||
|
active=True,
|
||||||
|
),
|
||||||
|
TaskTemplate(
|
||||||
|
title="Müll runterbringen",
|
||||||
|
description="Restmüll, Papier und Bio entsorgen.",
|
||||||
|
default_points=8,
|
||||||
|
default_assigned_user_id=anna.id,
|
||||||
|
recurrence_interval_value=1,
|
||||||
|
recurrence_interval_unit="weeks",
|
||||||
|
active=True,
|
||||||
|
),
|
||||||
|
TaskTemplate(
|
||||||
|
title="Fensterbank entstauben",
|
||||||
|
description="Wohnzimmer und Flur mitnehmen.",
|
||||||
|
default_points=6,
|
||||||
|
default_assigned_user_id=ben.id,
|
||||||
|
recurrence_interval_unit="none",
|
||||||
|
active=True,
|
||||||
|
),
|
||||||
|
TaskTemplate(
|
||||||
|
title="Bettwäsche wechseln",
|
||||||
|
description="Neue Bettwäsche aufziehen.",
|
||||||
|
default_points=15,
|
||||||
|
default_assigned_user_id=anna.id,
|
||||||
|
recurrence_interval_value=1,
|
||||||
|
recurrence_interval_unit="months",
|
||||||
|
active=True,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
db.session.add_all(templates)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
current_month_anchor = now.replace(day=5, hour=10, minute=0, second=0, microsecond=0)
|
||||||
|
previous_month_anchor = (current_month_anchor.replace(day=1) - timedelta(days=3)).replace(day=10)
|
||||||
|
|
||||||
|
instances = [
|
||||||
|
TaskInstance(
|
||||||
|
task_template_id=templates[0].id,
|
||||||
|
title=templates[0].title,
|
||||||
|
description=templates[0].description,
|
||||||
|
assigned_user_id=anna.id,
|
||||||
|
due_date=(now + timedelta(days=1)).date(),
|
||||||
|
status="soon",
|
||||||
|
points_awarded=12,
|
||||||
|
),
|
||||||
|
TaskInstance(
|
||||||
|
task_template_id=templates[1].id,
|
||||||
|
title=templates[1].title,
|
||||||
|
description=templates[1].description,
|
||||||
|
assigned_user_id=ben.id,
|
||||||
|
due_date=(now - timedelta(days=1)).date(),
|
||||||
|
status="overdue",
|
||||||
|
points_awarded=20,
|
||||||
|
),
|
||||||
|
TaskInstance(
|
||||||
|
task_template_id=templates[2].id,
|
||||||
|
title=templates[2].title,
|
||||||
|
description=templates[2].description,
|
||||||
|
assigned_user_id=anna.id,
|
||||||
|
due_date=(now + timedelta(days=4)).date(),
|
||||||
|
status="open",
|
||||||
|
points_awarded=8,
|
||||||
|
),
|
||||||
|
TaskInstance(
|
||||||
|
task_template_id=templates[3].id,
|
||||||
|
title=templates[3].title,
|
||||||
|
description=templates[3].description,
|
||||||
|
assigned_user_id=ben.id,
|
||||||
|
due_date=(now + timedelta(days=2)).date(),
|
||||||
|
status="soon",
|
||||||
|
points_awarded=6,
|
||||||
|
),
|
||||||
|
TaskInstance(
|
||||||
|
task_template_id=templates[4].id,
|
||||||
|
title=templates[4].title,
|
||||||
|
description=templates[4].description,
|
||||||
|
assigned_user_id=anna.id,
|
||||||
|
due_date=(now - timedelta(days=9)).date(),
|
||||||
|
status="completed",
|
||||||
|
completed_at=current_month_anchor - timedelta(days=2),
|
||||||
|
completed_by_user_id=anna.id,
|
||||||
|
points_awarded=15,
|
||||||
|
),
|
||||||
|
TaskInstance(
|
||||||
|
task_template_id=templates[1].id,
|
||||||
|
title=templates[1].title,
|
||||||
|
description=templates[1].description,
|
||||||
|
assigned_user_id=ben.id,
|
||||||
|
due_date=(previous_month_anchor - timedelta(days=1)).date(),
|
||||||
|
status="completed",
|
||||||
|
completed_at=previous_month_anchor,
|
||||||
|
completed_by_user_id=ben.id,
|
||||||
|
points_awarded=20,
|
||||||
|
),
|
||||||
|
TaskInstance(
|
||||||
|
task_template_id=templates[0].id,
|
||||||
|
title=templates[0].title,
|
||||||
|
description=templates[0].description,
|
||||||
|
assigned_user_id=anna.id,
|
||||||
|
due_date=(previous_month_anchor - timedelta(days=2)).date(),
|
||||||
|
status="completed",
|
||||||
|
completed_at=previous_month_anchor - timedelta(days=1),
|
||||||
|
completed_by_user_id=anna.id,
|
||||||
|
points_awarded=12,
|
||||||
|
),
|
||||||
|
TaskInstance(
|
||||||
|
task_template_id=templates[2].id,
|
||||||
|
title=templates[2].title,
|
||||||
|
description=templates[2].description,
|
||||||
|
assigned_user_id=anna.id,
|
||||||
|
due_date=(previous_month_anchor + timedelta(days=2)).date(),
|
||||||
|
status="completed",
|
||||||
|
completed_at=previous_month_anchor + timedelta(days=2),
|
||||||
|
completed_by_user_id=anna.id,
|
||||||
|
points_awarded=8,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
db.session.add_all(instances)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
archive_months_missing_up_to_previous()
|
||||||
|
print("Seed-Daten geschrieben.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
seed_database()
|
||||||
21
start.sh
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
export PYTHONUNBUFFERED=1
|
||||||
|
APP_ROOT="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||||
|
|
||||||
|
export DATA_DIR="${DATA_DIR:-$APP_ROOT/data}"
|
||||||
|
export DATABASE_PATH="${DATABASE_PATH:-$DATA_DIR/putzliga.db}"
|
||||||
|
export UPLOAD_FOLDER="${UPLOAD_FOLDER:-$DATA_DIR/uploads}"
|
||||||
|
export PORT="${PORT:-8000}"
|
||||||
|
|
||||||
|
mkdir -p "$DATA_DIR" "$UPLOAD_FOLDER"
|
||||||
|
|
||||||
|
flask --app app.py init-db
|
||||||
|
|
||||||
|
exec gunicorn \
|
||||||
|
--bind "0.0.0.0:${PORT}" \
|
||||||
|
--workers "${GUNICORN_WORKERS:-2}" \
|
||||||
|
--threads "${GUNICORN_THREADS:-2}" \
|
||||||
|
--timeout "${GUNICORN_TIMEOUT:-120}" \
|
||||||
|
"wsgi:app"
|
||||||