feat: add floating quick task flow
This commit is contained in:
@@ -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")
|
||||
|
||||
19
app/forms.py
19
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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
57
app/services/app_settings.py
Normal file
57
app/services/app_settings.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user