feat: add floating quick task flow

This commit is contained in:
2026-04-13 10:23:06 +02:00
parent c36abe82a8
commit 1a889e0ee1
13 changed files with 330 additions and 5 deletions

View File

@@ -7,6 +7,7 @@ Putzliga ist eine moderne, leichte Haushaltsaufgaben-Web-App mit spielerischem C
- Mehrere Nutzer mit Login, Admin-Nutzermanagement und Profil-/Avatar-Einstellungen
- Trennung zwischen `TaskTemplate` und `TaskInstance`
- Aufgaben anlegen, bearbeiten, zuweisen und erledigen
- globale Schnellaufgabe per Plus-Button mit Titel + Aufwand und automatisch passender Punktezahl
- 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
@@ -128,6 +129,24 @@ Admins können Nutzer zusätzlich direkt in der App unter `Optionen -> Profil &
Badges werden dauerhaft pro Nutzer gespeichert und automatisch freigeschaltet. Die Badge-Regeln werden für Admins auf einer eigenen Seite unter `Optionen -> Badges` gepflegt.
## Schnellaufgabe
Über den global sichtbaren Plus-Button rechts unten kannst du auf jeder eingeloggten Seite eine Schnellaufgabe anlegen.
- nur `Titel` und `Aufwand`
- die Aufgabe wird automatisch dem gerade eingeloggten Nutzer zugewiesen
- Fälligkeit ist direkt `heute`
- die Punkte hängen vom Aufwand ab
Die Aufwand-Stufen sind:
- Schnell
- Normal
- Dauert etwas
- Aufwendig
Admins können die Punkte je Aufwand unter `Optionen -> Profil & Team` anpassen.
### 5. Entwicklungsserver starten
```bash

View File

@@ -6,8 +6,10 @@ from config import Config
from .cli import register_cli, seed_badges
from .extensions import csrf, db, login_manager
from .forms import QuickTaskForm
from .routes import auth, main, scoreboard, settings, tasks
from .routes.main import load_icon_svg
from .services.app_settings import get_quick_task_config
from .services.badges import sync_existing_badges
from .services.bootstrap import ensure_schema_and_admins
from .services.dates import MONTH_NAMES, local_now
@@ -47,6 +49,12 @@ def create_app(config_class: type[Config] = Config) -> Flask:
@app.context_processor
def inject_globals():
quick_task_form = QuickTaskForm(prefix="quick")
quick_task_config = get_quick_task_config()
quick_task_form.effort.choices = [
(key, f"{values['label']} · {values['points']} Punkte")
for key, values in quick_task_config.items()
]
return {
"app_name": app.config["APP_NAME"],
"nav_items": [
@@ -59,6 +67,8 @@ def create_app(config_class: type[Config] = Config) -> Flask:
],
"icon_svg": lambda name: load_icon_svg(name, app.static_folder),
"now_local": local_now(),
"quick_task_form": quick_task_form,
"quick_task_config": quick_task_config,
}
@app.template_filter("date_de")

View File

@@ -15,6 +15,7 @@ from wtforms import (
from wtforms.validators import DataRequired, EqualTo, Length, NumberRange, Optional, Regexp, ValidationError
from .models import User
from .services.app_settings import QUICK_TASK_EFFORTS
EMAIL_LIKE = Regexp(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", message="Bitte gib eine gültige E-Mail-Adresse ein.")
@@ -111,3 +112,21 @@ class AdminUserForm(FlaskForm):
value = field.data.lower().strip()
if User.query.filter_by(email=value).first():
raise ValidationError("Diese E-Mail-Adresse wird bereits verwendet.")
class QuickTaskForm(FlaskForm):
title = StringField("Titel", validators=[DataRequired(), Length(min=2, max=160)])
effort = SelectField(
"Aufwand",
choices=[(key, label) for key, label, _ in QUICK_TASK_EFFORTS],
validators=[DataRequired()],
)
submit = SubmitField("Schnellaufgabe speichern")
class QuickTaskConfigForm(FlaskForm):
fast_points = IntegerField("Schnell", validators=[DataRequired(), NumberRange(min=1, max=500)])
normal_points = IntegerField("Normal", validators=[DataRequired(), NumberRange(min=1, max=500)])
medium_points = IntegerField("Dauert etwas", validators=[DataRequired(), NumberRange(min=1, max=500)])
heavy_points = IntegerField("Aufwendig", validators=[DataRequired(), NumberRange(min=1, max=500)])
submit = SubmitField("Punkte speichern")

View File

@@ -184,3 +184,9 @@ class UserBadge(db.Model):
context = db.Column(db.Text, nullable=True)
__table_args__ = (db.UniqueConstraint("user_id", "badge_definition_id", name="uq_user_badge_once"),)
class AppSetting(TimestampMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
key = db.Column(db.String(120), nullable=False, unique=True, index=True)
value = db.Column(db.String(255), nullable=False)

View File

@@ -8,8 +8,9 @@ from flask_login import current_user, login_required
from werkzeug.utils import secure_filename
from ..extensions import csrf, db
from ..forms import AdminUserForm, SettingsProfileForm
from ..forms import AdminUserForm, QuickTaskConfigForm, SettingsProfileForm
from ..models import BadgeDefinition, MonthlyScoreSnapshot, NotificationLog, PushSubscription, TaskInstance, TaskTemplate, User
from ..services.app_settings import get_quick_task_config, set_setting_int
from ..services.badges import earned_badges_for_user
from ..services.notifications import push_enabled
@@ -46,6 +47,7 @@ def _save_avatar(file_storage) -> str:
def index():
form = SettingsProfileForm(original_email=current_user.email, obj=current_user)
admin_form = AdminUserForm(prefix="admin")
quick_task_config_form = QuickTaskConfigForm(prefix="quickconfig")
if form.validate_on_submit():
current_user.name = form.name.data.strip()
current_user.email = form.email.data.lower().strip()
@@ -64,6 +66,8 @@ def index():
"settings/index.html",
form=form,
admin_form=admin_form,
quick_task_config_form=quick_task_config_form,
quick_task_config=get_quick_task_config(),
users=User.query.order_by(User.is_admin.desc(), User.name.asc()).all(),
earned_badges=earned_badges_for_user(current_user.id),
push_ready=push_enabled(),
@@ -127,6 +131,28 @@ def create_user():
return redirect(url_for("settings.index"))
@bp.route("/quick-task-config", methods=["POST"])
@login_required
def update_quick_task_config():
if not _require_admin():
return redirect(url_for("settings.index"))
form = QuickTaskConfigForm(prefix="quickconfig")
if not form.validate_on_submit():
for field_errors in form.errors.values():
for error in field_errors:
flash(error, "error")
return redirect(url_for("settings.index"))
set_setting_int("quick_task_points_fast", form.fast_points.data)
set_setting_int("quick_task_points_normal", form.normal_points.data)
set_setting_int("quick_task_points_medium", form.medium_points.data)
set_setting_int("quick_task_points_heavy", form.heavy_points.data)
db.session.commit()
flash("Schnellaufgaben-Punkte wurden aktualisiert.", "success")
return redirect(url_for("settings.index"))
@bp.route("/users/<int:user_id>/toggle-admin", methods=["POST"])
@login_required
def toggle_admin(user_id: int):

View File

@@ -7,10 +7,17 @@ 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 ..forms import QuickTaskForm, TaskForm
from ..models import TaskInstance, User
from ..services.app_settings import get_quick_task_config
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
from ..services.tasks import (
complete_task,
create_quick_task,
create_task_template_and_instance,
refresh_task_statuses,
update_template_and_instance,
)
bp = Blueprint("tasks", __name__, url_prefix="")
@@ -99,6 +106,27 @@ def create():
return render_template("tasks/task_form.html", form=form, mode="create", task=None)
@bp.route("/tasks/quick", methods=["POST"])
@login_required
def quick_create():
form = QuickTaskForm(prefix="quick")
config = get_quick_task_config()
form.effort.choices = [
(key, f"{values['label']} · {values['points']} Punkte")
for key, values in config.items()
]
if not form.validate_on_submit():
for field_errors in form.errors.values():
for error in field_errors:
flash(error, "error")
return redirect(request.referrer or url_for("tasks.my_tasks"))
task = create_quick_task(form.title.data, form.effort.data, current_user)
flash(f"Schnellaufgabe „{task.title}“ wurde für dich angelegt.", "success")
return redirect(request.referrer or url_for("tasks.my_tasks"))
@bp.route("/tasks/<int:task_id>/edit", methods=["GET", "POST"])
@login_required
def edit(task_id: int):
@@ -171,4 +199,3 @@ def calendar_view():
view=view,
tasks=tasks,
)

View File

@@ -0,0 +1,57 @@
from __future__ import annotations
from ..extensions import db
from ..models import AppSetting
QUICK_TASK_DEFAULTS = {
"quick_task_points_fast": 4,
"quick_task_points_normal": 8,
"quick_task_points_medium": 12,
"quick_task_points_heavy": 18,
}
QUICK_TASK_EFFORTS = [
("fast", "Schnell", "quick_task_points_fast"),
("normal", "Normal", "quick_task_points_normal"),
("medium", "Dauert etwas", "quick_task_points_medium"),
("heavy", "Aufwendig", "quick_task_points_heavy"),
]
def ensure_app_settings() -> None:
for key, value in QUICK_TASK_DEFAULTS.items():
setting = AppSetting.query.filter_by(key=key).first()
if not setting:
db.session.add(AppSetting(key=key, value=str(value)))
db.session.commit()
def get_setting_int(key: str, default: int) -> int:
setting = AppSetting.query.filter_by(key=key).first()
if not setting:
return default
try:
return int(setting.value)
except (TypeError, ValueError):
return default
def set_setting_int(key: str, value: int) -> None:
setting = AppSetting.query.filter_by(key=key).first()
if not setting:
setting = AppSetting(key=key, value=str(value))
db.session.add(setting)
else:
setting.value = str(value)
def get_quick_task_config() -> dict[str, dict]:
config: dict[str, dict] = {}
for effort_key, label, setting_key in QUICK_TASK_EFFORTS:
config[effort_key] = {
"label": label,
"setting_key": setting_key,
"points": get_setting_int(setting_key, QUICK_TASK_DEFAULTS[setting_key]),
}
return config

View File

@@ -6,6 +6,7 @@ from sqlalchemy import inspect, text
from ..extensions import db
from ..models import User
from .app_settings import ensure_app_settings
def ensure_schema_and_admins() -> None:
@@ -16,6 +17,8 @@ def ensure_schema_and_admins() -> None:
db.session.execute(text("ALTER TABLE user ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT 0"))
db.session.commit()
ensure_app_settings()
admin_exists = User.query.filter_by(is_admin=True).first()
if admin_exists:
return

View File

@@ -5,7 +5,8 @@ from datetime import date, datetime, timedelta
from sqlalchemy import select
from ..extensions import db
from ..models import TaskInstance, TaskTemplate
from ..models import TaskInstance, TaskTemplate, User
from .app_settings import get_quick_task_config
from .badges import evaluate_task_badges
from .dates import add_months, today_local
@@ -126,3 +127,33 @@ def complete_task(task: TaskInstance, completed_by_user_id: int) -> TaskInstance
if task.completed_by_user:
evaluate_task_badges(task.completed_by_user)
return task
def create_quick_task(title: str, effort: str, creator: User) -> TaskInstance:
config = get_quick_task_config()
effort_config = config[effort]
template = TaskTemplate(
title=title.strip(),
description="Schnellaufgabe",
default_points=effort_config["points"],
default_assigned_user_id=creator.id,
recurrence_interval_value=None,
recurrence_interval_unit="none",
active=False,
)
db.session.add(template)
db.session.flush()
task = TaskInstance(
task_template_id=template.id,
title=template.title,
description="Schnellaufgabe",
assigned_user_id=creator.id,
due_date=today_local(),
points_awarded=template.default_points,
status="open",
)
refresh_task_status(task, today_local())
db.session.add(task)
db.session.commit()
return task

View File

@@ -913,16 +913,50 @@ p {
gap: 18px;
}
.complete-dialog__surface--task {
width: min(520px, calc(100vw - 24px));
}
.choice-grid {
display: grid;
gap: 12px;
}
.dialog-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.text-link {
color: var(--primary-strong);
font-weight: 700;
}
.fab-quick-task {
position: fixed;
right: 18px;
bottom: calc(96px + env(safe-area-inset-bottom));
width: 62px;
height: 62px;
border: 0;
border-radius: 999px;
background: linear-gradient(135deg, #2563eb, #34d399);
color: #fff;
box-shadow: 0 24px 40px rgba(37, 99, 235, 0.28);
display: inline-flex;
align-items: center;
justify-content: center;
z-index: 45;
cursor: pointer;
}
.fab-quick-task .nav-icon,
.fab-quick-task .nav-icon svg {
width: 24px;
height: 24px;
}
@media (min-width: 760px) {
.page-shell {
padding: 28px 28px 32px;
@@ -1017,6 +1051,11 @@ p {
display: none;
}
.fab-quick-task {
right: 28px;
bottom: 28px;
}
.task-grid,
.scoreboard {
grid-template-columns: repeat(2, minmax(0, 1fr));

View File

@@ -4,6 +4,9 @@
const dialogChoice = document.getElementById("completeDialogChoice");
const dialogText = document.getElementById("completeDialogText");
const closeButton = document.getElementById("completeDialogClose");
const quickTaskDialog = document.getElementById("quickTaskDialog");
const quickTaskOpen = document.getElementById("quickTaskOpen");
const quickTaskClose = document.getElementById("quickTaskClose");
document.querySelectorAll("[data-complete-action]").forEach((button) => {
button.addEventListener("click", () => {
@@ -28,6 +31,14 @@
closeButton.addEventListener("click", () => dialog.close());
}
if (quickTaskOpen && quickTaskDialog) {
quickTaskOpen.addEventListener("click", () => quickTaskDialog.showModal());
}
if (quickTaskClose && quickTaskDialog) {
quickTaskClose.addEventListener("click", () => quickTaskDialog.close());
}
const pushButton = document.getElementById("pushToggle");
const pushHint = document.getElementById("pushHint");
const vapidKey = document.body.dataset.pushKey;

View File

@@ -97,6 +97,10 @@
{% endfor %}
</nav>
<button type="button" class="fab-quick-task" id="quickTaskOpen" aria-label="Schnellaufgabe anlegen">
{{ nav_icon('plus') }}
</button>
<dialog class="complete-dialog" id="completeDialog">
<form method="dialog" class="complete-dialog__surface">
<p class="eyebrow">Punkte fair verbuchen</p>
@@ -110,6 +114,27 @@
</form>
</dialog>
<dialog class="complete-dialog" id="quickTaskDialog">
<form method="post" action="{{ url_for('tasks.quick_create') }}" class="complete-dialog__surface complete-dialog__surface--task">
{{ quick_task_form.hidden_tag() }}
<p class="eyebrow">Schnellaufgabe</p>
<h2>Direkt etwas für dich anlegen</h2>
<p class="muted">Titel und Aufwand reichen. Die Aufgabe wird automatisch dir zugewiesen und auf heute gesetzt.</p>
<div class="field">
{{ quick_task_form.title.label }}
{{ quick_task_form.title(placeholder="Zum Beispiel: Küche kurz aufräumen") }}
</div>
<div class="field">
{{ quick_task_form.effort.label }}
{{ quick_task_form.effort() }}
</div>
<div class="dialog-actions">
{{ quick_task_form.submit(class_='button') }}
<button type="button" class="button button--ghost" id="quickTaskClose">Abbrechen</button>
</div>
</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">

View File

@@ -163,5 +163,57 @@
{% endfor %}
</div>
</section>
<section class="panel">
<p class="eyebrow">Admin</p>
<h2>Schnellaufgabe-Punkte</h2>
<p class="muted">Diese Werte erscheinen direkt im Schnellaufgaben-Dialog hinter den Aufwand-Stufen.</p>
<form method="post" action="{{ url_for('settings.update_quick_task_config') }}" class="badge-settings">
{{ quick_task_config_form.hidden_tag() }}
<div class="badge-setting-card">
<div>
<strong>Schnell</strong>
<p class="muted">Kleine Sache für zwischendurch.</p>
</div>
<div class="field">
{{ quick_task_config_form.fast_points.label }}
{{ quick_task_config_form.fast_points(value=quick_task_config['fast']['points']) }}
</div>
</div>
<div class="badge-setting-card">
<div>
<strong>Normal</strong>
<p class="muted">Typische Alltagssache.</p>
</div>
<div class="field">
{{ quick_task_config_form.normal_points.label }}
{{ quick_task_config_form.normal_points(value=quick_task_config['normal']['points']) }}
</div>
</div>
<div class="badge-setting-card">
<div>
<strong>Dauert etwas</strong>
<p class="muted">Braucht etwas mehr Zeit oder Konzentration.</p>
</div>
<div class="field">
{{ quick_task_config_form.medium_points.label }}
{{ quick_task_config_form.medium_points(value=quick_task_config['medium']['points']) }}
</div>
</div>
<div class="badge-setting-card">
<div>
<strong>Aufwendig</strong>
<p class="muted">Spürbarer Aufwand mit mehr Punkten.</p>
</div>
<div class="field">
{{ quick_task_config_form.heavy_points.label }}
{{ quick_task_config_form.heavy_points(value=quick_task_config['heavy']['points']) }}
</div>
</div>
<div class="field field--full">
{{ quick_task_config_form.submit(class_='button button--secondary') }}
</div>
</form>
</section>
{% endif %}
{% endblock %}