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 - Mehrere Nutzer mit Login, Admin-Nutzermanagement und Profil-/Avatar-Einstellungen
- Trennung zwischen `TaskTemplate` und `TaskInstance` - Trennung zwischen `TaskTemplate` und `TaskInstance`
- Aufgaben anlegen, bearbeiten, zuweisen und erledigen - 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 - 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 - Saubere Erledigungslogik für fremd zugewiesene Aufgaben mit Auswahl, wer wirklich erledigt hat
- Statuslogik für offen, bald fällig, überfällig und erledigt - 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. 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 ### 5. Entwicklungsserver starten
```bash ```bash

View File

@@ -6,8 +6,10 @@ from config import Config
from .cli import register_cli, seed_badges from .cli import register_cli, seed_badges
from .extensions import csrf, db, login_manager from .extensions import csrf, db, login_manager
from .forms import QuickTaskForm
from .routes import auth, main, scoreboard, settings, tasks from .routes import auth, main, scoreboard, settings, tasks
from .routes.main import load_icon_svg 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.badges import sync_existing_badges
from .services.bootstrap import ensure_schema_and_admins from .services.bootstrap import ensure_schema_and_admins
from .services.dates import MONTH_NAMES, local_now from .services.dates import MONTH_NAMES, local_now
@@ -47,6 +49,12 @@ def create_app(config_class: type[Config] = Config) -> Flask:
@app.context_processor @app.context_processor
def inject_globals(): 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 { return {
"app_name": app.config["APP_NAME"], "app_name": app.config["APP_NAME"],
"nav_items": [ "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), "icon_svg": lambda name: load_icon_svg(name, app.static_folder),
"now_local": local_now(), "now_local": local_now(),
"quick_task_form": quick_task_form,
"quick_task_config": quick_task_config,
} }
@app.template_filter("date_de") @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 wtforms.validators import DataRequired, EqualTo, Length, NumberRange, Optional, Regexp, ValidationError
from .models import User 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.") 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() value = field.data.lower().strip()
if User.query.filter_by(email=value).first(): if User.query.filter_by(email=value).first():
raise ValidationError("Diese E-Mail-Adresse wird bereits verwendet.") 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) context = db.Column(db.Text, nullable=True)
__table_args__ = (db.UniqueConstraint("user_id", "badge_definition_id", name="uq_user_badge_once"),) __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 werkzeug.utils import secure_filename
from ..extensions import csrf, db 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 ..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.badges import earned_badges_for_user
from ..services.notifications import push_enabled from ..services.notifications import push_enabled
@@ -46,6 +47,7 @@ def _save_avatar(file_storage) -> str:
def index(): def index():
form = SettingsProfileForm(original_email=current_user.email, obj=current_user) form = SettingsProfileForm(original_email=current_user.email, obj=current_user)
admin_form = AdminUserForm(prefix="admin") admin_form = AdminUserForm(prefix="admin")
quick_task_config_form = QuickTaskConfigForm(prefix="quickconfig")
if form.validate_on_submit(): if form.validate_on_submit():
current_user.name = form.name.data.strip() current_user.name = form.name.data.strip()
current_user.email = form.email.data.lower().strip() current_user.email = form.email.data.lower().strip()
@@ -64,6 +66,8 @@ def index():
"settings/index.html", "settings/index.html",
form=form, form=form,
admin_form=admin_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(), users=User.query.order_by(User.is_admin.desc(), User.name.asc()).all(),
earned_badges=earned_badges_for_user(current_user.id), earned_badges=earned_badges_for_user(current_user.id),
push_ready=push_enabled(), push_ready=push_enabled(),
@@ -127,6 +131,28 @@ def create_user():
return redirect(url_for("settings.index")) 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"]) @bp.route("/users/<int:user_id>/toggle-admin", methods=["POST"])
@login_required @login_required
def toggle_admin(user_id: int): 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 import Blueprint, flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required from flask_login import current_user, login_required
from ..forms import TaskForm from ..forms import QuickTaskForm, TaskForm
from ..models import TaskInstance, User from ..models import TaskInstance, User
from ..services.app_settings import get_quick_task_config
from ..services.dates import month_label, today_local 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="") 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) 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"]) @bp.route("/tasks/<int:task_id>/edit", methods=["GET", "POST"])
@login_required @login_required
def edit(task_id: int): def edit(task_id: int):
@@ -171,4 +199,3 @@ def calendar_view():
view=view, view=view,
tasks=tasks, 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 ..extensions import db
from ..models import User from ..models import User
from .app_settings import ensure_app_settings
def ensure_schema_and_admins() -> None: 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.execute(text("ALTER TABLE user ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT 0"))
db.session.commit() db.session.commit()
ensure_app_settings()
admin_exists = User.query.filter_by(is_admin=True).first() admin_exists = User.query.filter_by(is_admin=True).first()
if admin_exists: if admin_exists:
return return

View File

@@ -5,7 +5,8 @@ from datetime import date, datetime, timedelta
from sqlalchemy import select from sqlalchemy import select
from ..extensions import db 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 .badges import evaluate_task_badges
from .dates import add_months, today_local 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: if task.completed_by_user:
evaluate_task_badges(task.completed_by_user) evaluate_task_badges(task.completed_by_user)
return task 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; gap: 18px;
} }
.complete-dialog__surface--task {
width: min(520px, calc(100vw - 24px));
}
.choice-grid { .choice-grid {
display: grid; display: grid;
gap: 12px; gap: 12px;
} }
.dialog-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.text-link { .text-link {
color: var(--primary-strong); color: var(--primary-strong);
font-weight: 700; 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) { @media (min-width: 760px) {
.page-shell { .page-shell {
padding: 28px 28px 32px; padding: 28px 28px 32px;
@@ -1017,6 +1051,11 @@ p {
display: none; display: none;
} }
.fab-quick-task {
right: 28px;
bottom: 28px;
}
.task-grid, .task-grid,
.scoreboard { .scoreboard {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));

View File

@@ -4,6 +4,9 @@
const dialogChoice = document.getElementById("completeDialogChoice"); const dialogChoice = document.getElementById("completeDialogChoice");
const dialogText = document.getElementById("completeDialogText"); const dialogText = document.getElementById("completeDialogText");
const closeButton = document.getElementById("completeDialogClose"); 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) => { document.querySelectorAll("[data-complete-action]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
@@ -28,6 +31,14 @@
closeButton.addEventListener("click", () => dialog.close()); 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 pushButton = document.getElementById("pushToggle");
const pushHint = document.getElementById("pushHint"); const pushHint = document.getElementById("pushHint");
const vapidKey = document.body.dataset.pushKey; const vapidKey = document.body.dataset.pushKey;

View File

@@ -97,6 +97,10 @@
{% endfor %} {% endfor %}
</nav> </nav>
<button type="button" class="fab-quick-task" id="quickTaskOpen" aria-label="Schnellaufgabe anlegen">
{{ nav_icon('plus') }}
</button>
<dialog class="complete-dialog" id="completeDialog"> <dialog class="complete-dialog" id="completeDialog">
<form method="dialog" class="complete-dialog__surface"> <form method="dialog" class="complete-dialog__surface">
<p class="eyebrow">Punkte fair verbuchen</p> <p class="eyebrow">Punkte fair verbuchen</p>
@@ -110,6 +114,27 @@
</form> </form>
</dialog> </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"> <form method="post" class="sr-only" id="completeDialogForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="completed_for" value="me" id="completeDialogChoice"> <input type="hidden" name="completed_for" value="me" id="completeDialogChoice">

View File

@@ -163,5 +163,57 @@
{% endfor %} {% endfor %}
</div> </div>
</section> </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 %} {% endif %}
{% endblock %} {% endblock %}