diff --git a/README.md b/README.md index 345191f..c903b29 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/__init__.py b/app/__init__.py index 207dc03..1844a5c 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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") diff --git a/app/forms.py b/app/forms.py index cf927c9..75f434f 100644 --- a/app/forms.py +++ b/app/forms.py @@ -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") diff --git a/app/models.py b/app/models.py index 245f581..fe705ef 100644 --- a/app/models.py +++ b/app/models.py @@ -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) diff --git a/app/routes/settings.py b/app/routes/settings.py index 2e8e2c1..f4b98dd 100644 --- a/app/routes/settings.py +++ b/app/routes/settings.py @@ -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//toggle-admin", methods=["POST"]) @login_required def toggle_admin(user_id: int): diff --git a/app/routes/tasks.py b/app/routes/tasks.py index 93be0b2..f276b17 100644 --- a/app/routes/tasks.py +++ b/app/routes/tasks.py @@ -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//edit", methods=["GET", "POST"]) @login_required def edit(task_id: int): @@ -171,4 +199,3 @@ def calendar_view(): view=view, tasks=tasks, ) - diff --git a/app/services/app_settings.py b/app/services/app_settings.py new file mode 100644 index 0000000..72e5a75 --- /dev/null +++ b/app/services/app_settings.py @@ -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 diff --git a/app/services/bootstrap.py b/app/services/bootstrap.py index 6506467..28028b6 100644 --- a/app/services/bootstrap.py +++ b/app/services/bootstrap.py @@ -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 diff --git a/app/services/tasks.py b/app/services/tasks.py index 855029c..4f8248e 100644 --- a/app/services/tasks.py +++ b/app/services/tasks.py @@ -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 diff --git a/app/static/css/style.css b/app/static/css/style.css index 874a306..cc41377 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -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)); diff --git a/app/static/js/app.js b/app/static/js/app.js index 49da34a..fbe1338 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -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; diff --git a/app/templates/base.html b/app/templates/base.html index 5fd9f9f..56aec7a 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -97,6 +97,10 @@ {% endfor %} + +

Punkte fair verbuchen

@@ -110,6 +114,27 @@
+ +
+ {{ quick_task_form.hidden_tag() }} +

Schnellaufgabe

+

Direkt etwas für dich anlegen

+

Titel und Aufwand reichen. Die Aufgabe wird automatisch dir zugewiesen und auf heute gesetzt.

+
+ {{ quick_task_form.title.label }} + {{ quick_task_form.title(placeholder="Zum Beispiel: Küche kurz aufräumen") }} +
+
+ {{ quick_task_form.effort.label }} + {{ quick_task_form.effort() }} +
+
+ {{ quick_task_form.submit(class_='button') }} + +
+
+
+
diff --git a/app/templates/settings/index.html b/app/templates/settings/index.html index e55ba0d..28d562a 100644 --- a/app/templates/settings/index.html +++ b/app/templates/settings/index.html @@ -163,5 +163,57 @@ {% endfor %} + +
+

Admin

+

Schnellaufgabe-Punkte

+

Diese Werte erscheinen direkt im Schnellaufgaben-Dialog hinter den Aufwand-Stufen.

+ + {{ quick_task_config_form.hidden_tag() }} +
+
+ Schnell +

Kleine Sache für zwischendurch.

+
+
+ {{ quick_task_config_form.fast_points.label }} + {{ quick_task_config_form.fast_points(value=quick_task_config['fast']['points']) }} +
+
+
+
+ Normal +

Typische Alltagssache.

+
+
+ {{ quick_task_config_form.normal_points.label }} + {{ quick_task_config_form.normal_points(value=quick_task_config['normal']['points']) }} +
+
+
+
+ Dauert etwas +

Braucht etwas mehr Zeit oder Konzentration.

+
+
+ {{ quick_task_config_form.medium_points.label }} + {{ quick_task_config_form.medium_points(value=quick_task_config['medium']['points']) }} +
+
+
+
+ Aufwendig +

Spürbarer Aufwand mit mehr Punkten.

+
+
+ {{ quick_task_config_form.heavy_points.label }} + {{ quick_task_config_form.heavy_points(value=quick_task_config['heavy']['points']) }} +
+
+
+ {{ quick_task_config_form.submit(class_='button button--secondary') }} +
+ +
{% endif %} {% endblock %}