feat: polish mobile ui and admin quick task settings

This commit is contained in:
2026-04-13 10:52:14 +02:00
parent 42d4f8ec8e
commit 7b53f66406
10 changed files with 559 additions and 74 deletions

View File

@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from flask import Flask from flask import Flask
from config import Config from config import Config
@@ -52,9 +54,16 @@ def create_app(config_class: type[Config] = Config) -> Flask:
quick_task_form = QuickTaskForm(prefix="quick") quick_task_form = QuickTaskForm(prefix="quick")
quick_task_config = get_quick_task_config() quick_task_config = get_quick_task_config()
quick_task_form.effort.choices = [ quick_task_form.effort.choices = [
(key, f"{values['label']} · {values['points']} Punkte") (key, values["label"])
for key, values in quick_task_config.items() for key, values in quick_task_config.items()
] ]
def asset_version(filename: str) -> int:
path = Path(app.static_folder) / filename
try:
return int(path.stat().st_mtime)
except FileNotFoundError:
return 1
return { return {
"app_name": app.config["APP_NAME"], "app_name": app.config["APP_NAME"],
"nav_items": [ "nav_items": [
@@ -65,7 +74,15 @@ def create_app(config_class: type[Config] = Config) -> Flask:
("scoreboard.index", "Highscore", "trophy"), ("scoreboard.index", "Highscore", "trophy"),
("settings.index", "Optionen", "gear"), ("settings.index", "Optionen", "gear"),
], ],
"mobile_nav_items": [
("tasks.my_tasks", "Meine Aufgaben", "house"),
("tasks.all_tasks", "Alle Aufgaben", "list"),
("tasks.calendar_view", "Kalender", "calendar"),
("scoreboard.index", "Highscore", "trophy"),
("settings.index", "Optionen", "gear"),
],
"icon_svg": lambda name: load_icon_svg(name, app.static_folder), "icon_svg": lambda name: load_icon_svg(name, app.static_folder),
"asset_version": asset_version,
"now_local": local_now(), "now_local": local_now(),
"quick_task_form": quick_task_form, "quick_task_form": quick_task_form,
"quick_task_config": quick_task_config, "quick_task_config": quick_task_config,

View File

@@ -118,15 +118,19 @@ class QuickTaskForm(FlaskForm):
title = StringField("Titel", validators=[DataRequired(), Length(min=2, max=160)]) title = StringField("Titel", validators=[DataRequired(), Length(min=2, max=160)])
effort = SelectField( effort = SelectField(
"Aufwand", "Aufwand",
choices=[(key, label) for key, label, _ in QUICK_TASK_EFFORTS], choices=[(key, key) for key, _, _ in QUICK_TASK_EFFORTS],
validators=[DataRequired()], validators=[DataRequired()],
) )
submit = SubmitField("Schnellaufgabe speichern") submit = SubmitField("Schnellaufgabe speichern")
class QuickTaskConfigForm(FlaskForm): class QuickTaskConfigForm(FlaskForm):
fast_label = StringField("Bezeichnung", validators=[DataRequired(), Length(min=2, max=60)])
fast_points = IntegerField("Schnell", validators=[DataRequired(), NumberRange(min=1, max=500)]) fast_points = IntegerField("Schnell", validators=[DataRequired(), NumberRange(min=1, max=500)])
normal_label = StringField("Bezeichnung", validators=[DataRequired(), Length(min=2, max=60)])
normal_points = IntegerField("Normal", validators=[DataRequired(), NumberRange(min=1, max=500)]) normal_points = IntegerField("Normal", validators=[DataRequired(), NumberRange(min=1, max=500)])
medium_label = StringField("Bezeichnung", validators=[DataRequired(), Length(min=2, max=60)])
medium_points = IntegerField("Dauert etwas", validators=[DataRequired(), NumberRange(min=1, max=500)]) medium_points = IntegerField("Dauert etwas", validators=[DataRequired(), NumberRange(min=1, max=500)])
heavy_label = StringField("Bezeichnung", validators=[DataRequired(), Length(min=2, max=60)])
heavy_points = IntegerField("Aufwendig", validators=[DataRequired(), NumberRange(min=1, max=500)]) heavy_points = IntegerField("Aufwendig", validators=[DataRequired(), NumberRange(min=1, max=500)])
submit = SubmitField("Punkte speichern") submit = SubmitField("Aufwand speichern")

View File

@@ -89,7 +89,14 @@ class TaskTemplate(TimestampMixin, db.Model):
def recurrence_label(self) -> str: def recurrence_label(self) -> str:
if self.recurrence_interval_unit == "none" or not self.recurrence_interval_value: if self.recurrence_interval_unit == "none" or not self.recurrence_interval_value:
return "Einmalig" return "Einmalig"
return f"Alle {self.recurrence_interval_value} {self.recurrence_interval_unit}" units = {
"days": ("Tag", "Tage"),
"weeks": ("Woche", "Wochen"),
"months": ("Monat", "Monate"),
}
singular, plural = units.get(self.recurrence_interval_unit, (self.recurrence_interval_unit, self.recurrence_interval_unit))
unit_label = singular if self.recurrence_interval_value == 1 else plural
return f"Alle {self.recurrence_interval_value} {unit_label}"
class TaskInstance(TimestampMixin, db.Model): class TaskInstance(TimestampMixin, db.Model):

View File

@@ -10,7 +10,7 @@ from werkzeug.utils import secure_filename
from ..extensions import csrf, db from ..extensions import csrf, db
from ..forms import AdminUserForm, QuickTaskConfigForm, 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.app_settings import get_quick_task_config, set_setting_int, set_setting_str
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
@@ -48,6 +48,16 @@ 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") quick_task_config_form = QuickTaskConfigForm(prefix="quickconfig")
quick_task_config = get_quick_task_config()
if request.method == "GET":
quick_task_config_form.fast_label.data = quick_task_config["fast"]["label"]
quick_task_config_form.fast_points.data = quick_task_config["fast"]["points"]
quick_task_config_form.normal_label.data = quick_task_config["normal"]["label"]
quick_task_config_form.normal_points.data = quick_task_config["normal"]["points"]
quick_task_config_form.medium_label.data = quick_task_config["medium"]["label"]
quick_task_config_form.medium_points.data = quick_task_config["medium"]["points"]
quick_task_config_form.heavy_label.data = quick_task_config["heavy"]["label"]
quick_task_config_form.heavy_points.data = quick_task_config["heavy"]["points"]
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()
@@ -67,7 +77,7 @@ def index():
form=form, form=form,
admin_form=admin_form, admin_form=admin_form,
quick_task_config_form=quick_task_config_form, quick_task_config_form=quick_task_config_form,
quick_task_config=get_quick_task_config(), quick_task_config=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(),
@@ -144,12 +154,16 @@ def update_quick_task_config():
flash(error, "error") flash(error, "error")
return redirect(url_for("settings.index")) return redirect(url_for("settings.index"))
set_setting_str("quick_task_label_fast", form.fast_label.data)
set_setting_int("quick_task_points_fast", form.fast_points.data) set_setting_int("quick_task_points_fast", form.fast_points.data)
set_setting_str("quick_task_label_normal", form.normal_label.data)
set_setting_int("quick_task_points_normal", form.normal_points.data) set_setting_int("quick_task_points_normal", form.normal_points.data)
set_setting_str("quick_task_label_medium", form.medium_label.data)
set_setting_int("quick_task_points_medium", form.medium_points.data) set_setting_int("quick_task_points_medium", form.medium_points.data)
set_setting_str("quick_task_label_heavy", form.heavy_label.data)
set_setting_int("quick_task_points_heavy", form.heavy_points.data) set_setting_int("quick_task_points_heavy", form.heavy_points.data)
db.session.commit() db.session.commit()
flash("Schnellaufgaben-Punkte wurden aktualisiert.", "success") flash("Schnellaufgaben-Aufwand und Punkte wurden aktualisiert.", "success")
return redirect(url_for("settings.index")) return redirect(url_for("settings.index"))

View File

@@ -193,6 +193,11 @@ def calendar_view():
for task in tasks: for task in tasks:
tasks_by_day[task.due_date.day].append(task) tasks_by_day[task.due_date.day].append(task)
mobile_day_groups = [
{"day": day, "tasks": grouped_tasks}
for day, grouped_tasks in sorted(tasks_by_day.items(), key=lambda item: item[0])
]
month_calendar = calendar.Calendar(firstweekday=0).monthdayscalendar(year, month) month_calendar = calendar.Calendar(firstweekday=0).monthdayscalendar(year, month)
return render_template( return render_template(
"tasks/calendar.html", "tasks/calendar.html",
@@ -201,6 +206,7 @@ def calendar_view():
current_label=month_label(year, month), current_label=month_label(year, month),
month_calendar=month_calendar, month_calendar=month_calendar,
tasks_by_day=tasks_by_day, tasks_by_day=tasks_by_day,
mobile_day_groups=mobile_day_groups,
view=view, view=view,
tasks=tasks, tasks=tasks,
) )

View File

@@ -5,6 +5,10 @@ from ..models import AppSetting
QUICK_TASK_DEFAULTS = { QUICK_TASK_DEFAULTS = {
"quick_task_label_fast": "Schnell",
"quick_task_label_normal": "Normal",
"quick_task_label_medium": "Dauert etwas",
"quick_task_label_heavy": "Aufwendig",
"quick_task_points_fast": 4, "quick_task_points_fast": 4,
"quick_task_points_normal": 8, "quick_task_points_normal": 8,
"quick_task_points_medium": 12, "quick_task_points_medium": 12,
@@ -12,10 +16,10 @@ QUICK_TASK_DEFAULTS = {
} }
QUICK_TASK_EFFORTS = [ QUICK_TASK_EFFORTS = [
("fast", "Schnell", "quick_task_points_fast"), ("fast", "quick_task_label_fast", "quick_task_points_fast"),
("normal", "Normal", "quick_task_points_normal"), ("normal", "quick_task_label_normal", "quick_task_points_normal"),
("medium", "Dauert etwas", "quick_task_points_medium"), ("medium", "quick_task_label_medium", "quick_task_points_medium"),
("heavy", "Aufwendig", "quick_task_points_heavy"), ("heavy", "quick_task_label_heavy", "quick_task_points_heavy"),
] ]
@@ -46,12 +50,30 @@ def set_setting_int(key: str, value: int) -> None:
setting.value = str(value) setting.value = str(value)
def get_setting_str(key: str, default: str) -> str:
setting = AppSetting.query.filter_by(key=key).first()
if not setting or not setting.value.strip():
return default
return setting.value.strip()
def set_setting_str(key: str, value: str) -> None:
setting = AppSetting.query.filter_by(key=key).first()
normalized = value.strip()
if not setting:
setting = AppSetting(key=key, value=normalized)
db.session.add(setting)
else:
setting.value = normalized
def get_quick_task_config() -> dict[str, dict]: def get_quick_task_config() -> dict[str, dict]:
config: dict[str, dict] = {} config: dict[str, dict] = {}
for effort_key, label, setting_key in QUICK_TASK_EFFORTS: for effort_key, label_key, points_key in QUICK_TASK_EFFORTS:
config[effort_key] = { config[effort_key] = {
"label": label, "label_key": label_key,
"setting_key": setting_key, "label": get_setting_str(label_key, str(QUICK_TASK_DEFAULTS[label_key])),
"points": get_setting_int(setting_key, QUICK_TASK_DEFAULTS[setting_key]), "points_key": points_key,
"points": get_setting_int(points_key, int(QUICK_TASK_DEFAULTS[points_key])),
} }
return config return config

View File

@@ -48,6 +48,7 @@
} }
:root { :root {
color-scheme: light;
--bg: #eef3ff; --bg: #eef3ff;
--bg-accent: #d8e6ff; --bg-accent: #d8e6ff;
--surface: rgba(255, 255, 255, 0.85); --surface: rgba(255, 255, 255, 0.85);
@@ -69,6 +70,14 @@
--font-body: "InterLocal", system-ui, sans-serif; --font-body: "InterLocal", system-ui, sans-serif;
--font-heading: "SpaceGroteskLocal", "InterLocal", sans-serif; --font-heading: "SpaceGroteskLocal", "InterLocal", sans-serif;
--safe-bottom: max(24px, env(safe-area-inset-bottom)); --safe-bottom: max(24px, env(safe-area-inset-bottom));
--body-radial-a: rgba(181, 210, 255, 0.85);
--body-radial-b: rgba(255, 221, 196, 0.48);
--body-linear-start: #f8fbff;
--body-linear-mid: #eef3ff;
--body-linear-end: #edf2ff;
--input-bg: rgba(255, 255, 255, 0.86);
--nav-bg: rgba(255, 255, 255, 0.88);
--nav-active-bg: rgba(37, 99, 235, 0.1);
} }
* { * {
@@ -77,17 +86,19 @@
html { html {
min-height: 100%; min-height: 100%;
overflow-x: hidden;
} }
body { body {
margin: 0; margin: 0;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden;
font-family: var(--font-body); font-family: var(--font-body);
color: var(--text); color: var(--text);
background: background:
radial-gradient(circle at top left, rgba(181, 210, 255, 0.85), transparent 32%), radial-gradient(circle at top left, var(--body-radial-a), transparent 32%),
radial-gradient(circle at top right, rgba(255, 221, 196, 0.48), transparent 32%), radial-gradient(circle at top right, var(--body-radial-b), transparent 32%),
linear-gradient(180deg, #f8fbff 0%, #eef3ff 42%, #edf2ff 100%); linear-gradient(180deg, var(--body-linear-start) 0%, var(--body-linear-mid) 42%, var(--body-linear-end) 100%);
} }
a { a {
@@ -167,6 +178,7 @@ p {
.content { .content {
display: grid; display: grid;
gap: 24px; gap: 24px;
min-width: 0;
} }
.panel, .panel,
@@ -266,7 +278,7 @@ p {
padding: 14px 16px; padding: 14px 16px;
border-radius: 16px; border-radius: 16px;
border: 1px solid var(--border); border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.94); background: var(--surface-strong);
} }
.flash--success { .flash--success {
@@ -287,7 +299,7 @@ p {
margin-top: 22px; margin-top: 22px;
padding: 18px; padding: 18px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.74); background: var(--surface-soft);
border: 1px solid rgba(132, 152, 190, 0.2); border: 1px solid rgba(132, 152, 190, 0.2);
} }
@@ -366,7 +378,7 @@ p {
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: var(--muted); color: var(--muted);
text-align: center; text-align: center;
background: rgba(255, 255, 255, 0.58); background: var(--surface-soft);
} }
.task-card { .task-card {
@@ -396,7 +408,7 @@ p {
gap: 10px; gap: 10px;
padding: 10px 14px; padding: 10px 14px;
border-radius: 999px; border-radius: 999px;
background: rgba(37, 99, 235, 0.08); background: var(--nav-active-bg);
color: var(--primary-strong); color: var(--primary-strong);
font-weight: 700; font-weight: 700;
} }
@@ -408,7 +420,7 @@ p {
width: 28px; width: 28px;
height: 28px; height: 28px;
border-radius: 999px; border-radius: 999px;
background: rgba(255, 255, 255, 0.72); background: var(--surface-soft);
} }
.status-badge { .status-badge {
@@ -537,7 +549,7 @@ p {
.icon-button { .icon-button {
width: 48px; width: 48px;
padding: 0; padding: 0;
background: rgba(255, 255, 255, 0.74); background: var(--surface-soft);
border: 1px solid rgba(132, 152, 190, 0.24); border: 1px solid rgba(132, 152, 190, 0.24);
} }
@@ -571,7 +583,7 @@ p {
padding: 14px 16px; padding: 14px 16px;
border: 1px solid rgba(132, 152, 190, 0.3); border: 1px solid rgba(132, 152, 190, 0.3);
border-radius: 16px; border-radius: 16px;
background: rgba(255, 255, 255, 0.86); background: var(--input-bg);
color: var(--text); color: var(--text);
} }
@@ -612,28 +624,176 @@ p {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 18px; gap: 18px;
flex-wrap: wrap;
}
.panel--toolbar > * {
min-width: 0;
} }
.segmented { .segmented {
display: inline-flex; display: inline-flex;
padding: 4px; padding: 4px;
border-radius: 18px; border-radius: 18px;
background: rgba(255, 255, 255, 0.74); background: var(--surface-soft);
min-width: 0;
} }
.segmented a { .segmented a {
padding: 10px 16px; padding: 10px 16px;
border-radius: 14px; border-radius: 14px;
color: var(--muted); color: var(--muted);
min-width: 0;
} }
.segmented .is-active { .segmented .is-active {
background: #fff; background: var(--surface-strong);
color: var(--text); color: var(--text);
} }
.calendar-grid { .calendar-toolbar-mobile {
display: grid; display: grid;
gap: 14px;
max-width: 100%;
overflow: hidden;
}
.calendar-toolbar-mobile__header,
.calendar-toolbar-mobile__switch {
width: 100%;
}
.calendar-toolbar-mobile__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
min-width: 0;
}
.calendar-toolbar-mobile__header > div:first-child {
min-width: 0;
}
.calendar-toolbar-mobile__arrows {
display: inline-flex;
align-items: center;
gap: 10px;
flex: 0 0 auto;
}
.calendar-nav-button {
width: 44px;
min-width: 44px;
height: 44px;
border-radius: 14px;
}
.calendar-nav-button span {
font-size: 1.7rem;
line-height: 1;
}
.calendar-toolbar-mobile__switch a {
width: 100%;
min-width: 0;
}
.calendar-toolbar-mobile__switch {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.calendar-toolbar-desktop {
display: none;
}
.calendar-mobile-strip {
padding: 14px 16px;
max-width: 100%;
overflow: hidden;
}
.calendar-mobile-strip__scroller {
display: flex;
gap: 10px;
overflow-x: auto;
padding-bottom: 4px;
scroll-snap-type: x proximity;
max-width: 100%;
}
.calendar-mobile-pill {
flex: 0 0 auto;
min-width: 84px;
padding: 12px 14px;
border-radius: 18px;
border: 1px solid var(--border);
background: var(--surface-strong);
display: grid;
gap: 3px;
scroll-snap-align: start;
box-shadow: 0 10px 20px rgba(52, 79, 131, 0.08);
}
.calendar-mobile-pill strong {
font-size: 1rem;
line-height: 1;
}
.calendar-mobile-pill small {
color: var(--muted);
font-size: 0.74rem;
white-space: nowrap;
}
.calendar-mobile-pill.is-active {
border-color: rgba(37, 99, 235, 0.16);
background: linear-gradient(135deg, rgba(37, 99, 235, 0.14), rgba(52, 211, 153, 0.1));
}
.calendar-mobile-list {
display: grid;
gap: 14px;
max-width: 100%;
}
.calendar-mobile-day {
padding: 18px;
max-width: 100%;
overflow: hidden;
}
.calendar-mobile-day__header {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 10px;
margin-bottom: 14px;
}
.calendar-mobile-day__header strong {
font-size: 1.12rem;
}
.calendar-mobile-day__header span {
color: var(--muted);
font-size: 0.82rem;
white-space: nowrap;
}
.calendar-mobile-day__tasks {
display: grid;
gap: 10px;
min-width: 0;
}
.calendar-mobile-day .calendar-task {
background: var(--surface-strong);
}
.calendar-grid {
display: none;
gap: 12px; gap: 12px;
} }
@@ -666,9 +826,10 @@ p {
display: grid; display: grid;
gap: 4px; gap: 4px;
min-width: 0; min-width: 0;
max-width: 100%;
padding: 9px 10px; padding: 9px 10px;
border-radius: 14px; border-radius: 14px;
background: rgba(255, 255, 255, 0.72); background: var(--surface-soft);
overflow: hidden; overflow: hidden;
} }
@@ -762,7 +923,7 @@ p {
} }
.badge-card--earned { .badge-card--earned {
background: rgba(37, 99, 235, 0.03); background: var(--nav-active-bg);
} }
.badge-card__icon { .badge-card__icon {
@@ -793,7 +954,7 @@ p {
gap: 10px; gap: 10px;
padding: 6px; padding: 6px;
border-radius: 22px; border-radius: 22px;
background: rgba(255, 255, 255, 0.7); background: var(--surface-soft);
border: 1px solid rgba(132, 152, 190, 0.18); border: 1px solid rgba(132, 152, 190, 0.18);
box-shadow: 0 12px 30px rgba(58, 82, 128, 0.1); box-shadow: 0 12px 30px rgba(58, 82, 128, 0.1);
} }
@@ -809,7 +970,7 @@ p {
} }
.settings-tab.is-active { .settings-tab.is-active {
background: #fff; background: var(--surface-strong);
color: var(--primary-strong); color: var(--primary-strong);
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.08); box-shadow: 0 10px 20px rgba(37, 99, 235, 0.08);
} }
@@ -830,7 +991,7 @@ p {
gap: 14px; gap: 14px;
padding: 18px; padding: 18px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.76); background: var(--surface-soft);
border: 1px solid rgba(132, 152, 190, 0.22); border: 1px solid rgba(132, 152, 190, 0.22);
} }
@@ -850,7 +1011,7 @@ p {
align-items: flex-start; align-items: flex-start;
padding: 16px; padding: 16px;
border-radius: 18px; border-radius: 18px;
background: rgba(255, 255, 255, 0.66); background: var(--surface-soft);
} }
.push-box__state.is-disabled { .push-box__state.is-disabled {
@@ -876,16 +1037,15 @@ p {
.bottom-nav { .bottom-nav {
position: fixed; position: fixed;
left: 50%; left: 8px;
transform: translateX(-50%); right: 8px;
width: min(calc(100vw - 52px), 560px);
bottom: calc(10px + env(safe-area-inset-bottom)); bottom: calc(10px + env(safe-area-inset-bottom));
display: grid; display: grid;
grid-template-columns: repeat(6, 1fr); grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px; gap: 4px;
padding: 8px; padding: 8px 8px;
border-radius: 24px; border-radius: 22px;
background: rgba(255, 255, 255, 0.88); background: var(--nav-bg);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
box-shadow: 0 24px 44px rgba(58, 82, 128, 0.2); box-shadow: 0 24px 44px rgba(58, 82, 128, 0.2);
border: 1px solid rgba(132, 152, 190, 0.22); border: 1px solid rgba(132, 152, 190, 0.22);
@@ -897,19 +1057,29 @@ p {
display: grid; display: grid;
justify-items: center; justify-items: center;
gap: 5px; gap: 5px;
padding: 10px 4px; min-width: 0;
padding: 10px 4px 9px;
color: var(--muted); color: var(--muted);
border-radius: 16px; border-radius: 16px;
text-align: center; text-align: center;
font-size: 0.69rem; font-size: 0.66rem;
font-weight: 700; font-weight: 700;
line-height: 1.1; line-height: 1.05;
}
.bottom-nav__item span {
display: block;
width: 100%;
min-height: 2.1em;
white-space: normal;
word-break: keep-all;
overflow-wrap: break-word;
} }
.bottom-nav__item.is-active, .bottom-nav__item.is-active,
.nav-link.is-active { .nav-link.is-active {
color: var(--primary-strong); color: var(--primary-strong);
background: rgba(37, 99, 235, 0.1); background: var(--nav-active-bg);
} }
.nav-icon, .nav-icon,
@@ -934,7 +1104,7 @@ p {
width: min(460px, calc(100vw - 24px)); width: min(460px, calc(100vw - 24px));
padding: 24px; padding: 24px;
border-radius: 28px; border-radius: 28px;
background: #fff; background: var(--surface-strong);
box-shadow: var(--shadow); box-shadow: var(--shadow);
display: grid; display: grid;
gap: 18px; gap: 18px;
@@ -962,15 +1132,15 @@ p {
.fab-quick-task { .fab-quick-task {
position: fixed; position: fixed;
right: 18px; right: max(16px, env(safe-area-inset-right));
bottom: calc(96px + env(safe-area-inset-bottom)); bottom: calc(72px + var(--safe-bottom));
width: 62px; width: 62px;
height: 62px; height: 62px;
border: 0; border: 2px solid rgba(255, 255, 255, 0.55);
border-radius: 999px; border-radius: 999px;
background: linear-gradient(135deg, #2563eb, #34d399); background: linear-gradient(135deg, #2563eb, #34d399);
color: #fff; color: #fff;
box-shadow: 0 24px 40px rgba(37, 99, 235, 0.28); box-shadow: 0 24px 40px rgba(37, 99, 235, 0.34);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -984,7 +1154,128 @@ p {
height: 24px; height: 24px;
} }
@media (max-width: 759px) {
.calendar-toolbar-mobile__header {
align-items: flex-start;
}
.calendar-toolbar-mobile__header h2 {
font-size: 1.5rem;
}
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--bg: #0b1220;
--bg-accent: #15233d;
--surface: rgba(17, 24, 39, 0.82);
--surface-strong: rgba(15, 23, 38, 0.96);
--surface-soft: rgba(20, 30, 49, 0.92);
--text: #e5eefc;
--muted: #9eb0cc;
--border: rgba(121, 146, 191, 0.2);
--primary: #5ea8ff;
--primary-strong: #d7e7ff;
--secondary: rgba(37, 99, 235, 0.16);
--shadow: 0 28px 70px rgba(0, 0, 0, 0.34);
--body-radial-a: rgba(37, 99, 235, 0.28);
--body-radial-b: rgba(20, 184, 166, 0.14);
--body-linear-start: #0b1220;
--body-linear-mid: #0f172a;
--body-linear-end: #131f35;
--input-bg: rgba(16, 24, 38, 0.92);
--nav-bg: rgba(15, 23, 38, 0.9);
--nav-active-bg: rgba(59, 130, 246, 0.18);
}
.hero-card {
background:
linear-gradient(135deg, rgba(16, 24, 38, 0.98), rgba(20, 30, 49, 0.92)),
linear-gradient(135deg, rgba(37, 99, 235, 0.1), rgba(20, 184, 166, 0.06));
}
.hero-stats div {
background: var(--surface-soft);
}
.icon-button {
border-color: rgba(121, 146, 191, 0.24);
}
.flash--success {
color: #7df1c7;
}
.flash--error {
color: #ff94b0;
}
.status-badge--open {
background: rgba(37, 99, 235, 0.16);
color: #8db7ff;
}
.status-badge--soon {
background: rgba(245, 158, 11, 0.18);
color: #ffd38a;
}
.status-badge--overdue {
background: rgba(225, 29, 72, 0.18);
color: #ff9cb2;
}
.status-badge--completed {
background: rgba(5, 150, 105, 0.18);
color: #8df0cb;
}
.button--ghost {
border-color: rgba(121, 146, 191, 0.24);
}
.score-row--leader {
background:
linear-gradient(135deg, rgba(18, 29, 49, 0.98), rgba(24, 39, 62, 0.94)),
linear-gradient(135deg, rgba(52, 211, 153, 0.12), rgba(94, 168, 255, 0.08));
border-color: rgba(94, 168, 255, 0.2);
}
.score-row--leader .rank-badge,
.score-row--leader .earned-badge {
background: rgba(94, 168, 255, 0.16);
color: var(--primary-strong);
}
.score-row--leader .earned-badge__icon {
background: rgba(10, 18, 32, 0.56);
}
.score-row__points span,
.archive-row__right span {
color: var(--muted);
}
.sidebar {
background: rgba(10, 16, 28, 0.76);
border-right-color: rgba(121, 146, 191, 0.16);
}
.topbar-user {
border-color: rgba(121, 146, 191, 0.2);
}
}
@media (min-width: 760px) { @media (min-width: 760px) {
.calendar-toolbar-mobile {
display: none;
}
.calendar-toolbar-desktop {
display: flex;
}
.page-shell { .page-shell {
padding: 28px 28px 32px; padding: 28px 28px 32px;
} }
@@ -1005,8 +1296,15 @@ p {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.calendar-mobile-strip,
.calendar-mobile-list {
display: none;
}
.calendar-grid { .calendar-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr)); grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 10px;
} }
.calendar-grid__weekdays { .calendar-grid__weekdays {
@@ -1026,6 +1324,34 @@ p {
background: transparent; background: transparent;
border: 0; border: 0;
} }
.calendar-day {
display: flex;
flex-direction: column;
min-height: 152px;
padding: 12px 10px;
}
.calendar-day__tasks {
gap: 6px;
max-height: 100%;
overflow: auto;
padding-right: 2px;
}
.calendar-task {
padding: 8px 9px;
border-radius: 12px;
}
.calendar-task__title {
font-size: 0.82rem;
line-height: 1.18;
}
.calendar-task__person {
font-size: 0.7rem;
}
} }
@media (min-width: 1100px) { @media (min-width: 1100px) {
@@ -1043,7 +1369,7 @@ p {
height: 100vh; height: 100vh;
padding: 28px 20px; padding: 28px 20px;
border-right: 1px solid rgba(132, 152, 190, 0.2); border-right: 1px solid rgba(132, 152, 190, 0.2);
background: rgba(248, 251, 255, 0.72); background: var(--surface-soft);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
} }
@@ -1070,7 +1396,7 @@ p {
gap: 10px; gap: 10px;
padding: 8px 10px 8px 18px; padding: 8px 10px 8px 18px;
border-radius: 999px; border-radius: 999px;
background: rgba(255, 255, 255, 0.85); background: var(--nav-bg);
border: 1px solid rgba(132, 152, 190, 0.2); border: 1px solid rgba(132, 152, 190, 0.2);
} }

View File

@@ -3,7 +3,9 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#f5f7ff"> <meta name="theme-color" media="(prefers-color-scheme: light)" content="#f5f7ff">
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#101826">
<meta name="color-scheme" content="light dark">
<meta name="apple-mobile-web-app-capable" content="yes"> <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-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="{{ app_name }}"> <meta name="apple-mobile-web-app-title" content="{{ app_name }}">
@@ -12,7 +14,7 @@
<link rel="manifest" href="{{ url_for('main.manifest') }}"> <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="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="apple-touch-icon" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css', v=asset_version('css/style.css')) }}">
</head> </head>
<body data-push-key="{{ config['VAPID_PUBLIC_KEY'] if current_user.is_authenticated else '' }}"> <body data-push-key="{{ config['VAPID_PUBLIC_KEY'] if current_user.is_authenticated else '' }}">
{% from "partials/macros.html" import nav_icon %} {% from "partials/macros.html" import nav_icon %}
@@ -89,7 +91,7 @@
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<nav class="bottom-nav" aria-label="Mobile Navigation"> <nav class="bottom-nav" aria-label="Mobile Navigation">
{% for endpoint, label, icon in nav_items %} {% for endpoint, label, icon in mobile_nav_items %}
<a href="{{ url_for(endpoint) }}" class="bottom-nav__item {% if request.endpoint == endpoint %}is-active{% endif %}"> <a href="{{ url_for(endpoint) }}" class="bottom-nav__item {% if request.endpoint == endpoint %}is-active{% endif %}">
{{ nav_icon(icon) }} {{ nav_icon(icon) }}
<span>{{ label }}</span> <span>{{ label }}</span>
@@ -142,6 +144,6 @@
</form> </form>
{% endif %} {% endif %}
<script src="{{ url_for('static', filename='js/app.js') }}" defer></script> <script src="{{ url_for('static', filename='js/app.js', v=asset_version('js/app.js')) }}" defer></script>
</body> </body>
</html> </html>

View File

@@ -166,48 +166,64 @@
<section class="panel"> <section class="panel">
<p class="eyebrow">Admin</p> <p class="eyebrow">Admin</p>
<h2>Schnellaufgabe-Punkte</h2> <h2>Schnellaufgabe-Aufwand</h2>
<p class="muted">Diese Werte erscheinen direkt im Schnellaufgaben-Dialog hinter den Aufwand-Stufen.</p> <p class="muted">Hier definierst du die sichtbaren Aufwand-Stufen und die dazugehörigen Punkte. Im Schnellaufgaben-Dialog wird nur die Bezeichnung angezeigt.</p>
<form method="post" action="{{ url_for('settings.update_quick_task_config') }}" class="badge-settings"> <form method="post" action="{{ url_for('settings.update_quick_task_config') }}" class="badge-settings">
{{ quick_task_config_form.hidden_tag() }} {{ quick_task_config_form.hidden_tag() }}
<div class="badge-setting-card"> <div class="badge-setting-card">
<div> <div>
<strong>Schnell</strong> <strong>Slot 1</strong>
<p class="muted">Kleine Sache für zwischendurch.</p> <p class="muted">Kleine Sache für zwischendurch.</p>
</div> </div>
<div class="field"> <div class="field">
{{ quick_task_config_form.fast_points.label }} {{ quick_task_config_form.fast_label.label }}
{{ quick_task_config_form.fast_points(value=quick_task_config['fast']['points']) }} {{ quick_task_config_form.fast_label() }}
</div>
<div class="field">
<label for="{{ quick_task_config_form.fast_points.id }}">Punkte</label>
{{ quick_task_config_form.fast_points() }}
</div> </div>
</div> </div>
<div class="badge-setting-card"> <div class="badge-setting-card">
<div> <div>
<strong>Normal</strong> <strong>Slot 2</strong>
<p class="muted">Typische Alltagssache.</p> <p class="muted">Typische Alltagssache.</p>
</div> </div>
<div class="field"> <div class="field">
{{ quick_task_config_form.normal_points.label }} {{ quick_task_config_form.normal_label.label }}
{{ quick_task_config_form.normal_points(value=quick_task_config['normal']['points']) }} {{ quick_task_config_form.normal_label() }}
</div>
<div class="field">
<label for="{{ quick_task_config_form.normal_points.id }}">Punkte</label>
{{ quick_task_config_form.normal_points() }}
</div> </div>
</div> </div>
<div class="badge-setting-card"> <div class="badge-setting-card">
<div> <div>
<strong>Dauert etwas</strong> <strong>Slot 3</strong>
<p class="muted">Braucht etwas mehr Zeit oder Konzentration.</p> <p class="muted">Braucht etwas mehr Zeit oder Konzentration.</p>
</div> </div>
<div class="field"> <div class="field">
{{ quick_task_config_form.medium_points.label }} {{ quick_task_config_form.medium_label.label }}
{{ quick_task_config_form.medium_points(value=quick_task_config['medium']['points']) }} {{ quick_task_config_form.medium_label() }}
</div>
<div class="field">
<label for="{{ quick_task_config_form.medium_points.id }}">Punkte</label>
{{ quick_task_config_form.medium_points() }}
</div> </div>
</div> </div>
<div class="badge-setting-card"> <div class="badge-setting-card">
<div> <div>
<strong>Aufwendig</strong> <strong>Slot 4</strong>
<p class="muted">Spürbarer Aufwand mit mehr Punkten.</p> <p class="muted">Spürbarer Aufwand mit mehr Punkten.</p>
</div> </div>
<div class="field"> <div class="field">
{{ quick_task_config_form.heavy_points.label }} {{ quick_task_config_form.heavy_label.label }}
{{ quick_task_config_form.heavy_points(value=quick_task_config['heavy']['points']) }} {{ quick_task_config_form.heavy_label() }}
</div>
<div class="field">
<label for="{{ quick_task_config_form.heavy_points.id }}">Punkte</label>
{{ quick_task_config_form.heavy_points() }}
</div> </div>
</div> </div>
<div class="field field--full"> <div class="field field--full">

View File

@@ -3,7 +3,36 @@
{% block title %}Kalender · Putzliga{% endblock %} {% block title %}Kalender · Putzliga{% endblock %}
{% block page_title %}Kalender & Liste{% endblock %} {% block page_title %}Kalender & Liste{% endblock %}
{% block content %} {% block content %}
<section class="panel panel--toolbar"> <section class="panel calendar-toolbar-mobile">
<div class="calendar-toolbar-mobile__header">
<div>
<p class="eyebrow">Monatsansicht</p>
<h2>{{ current_label }}</h2>
</div>
<div class="calendar-toolbar-mobile__arrows">
<a
class="icon-button calendar-nav-button"
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) }}"
aria-label="Vorheriger Monat"
>
<span aria-hidden="true"></span>
</a>
<a
class="icon-button calendar-nav-button"
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) }}"
aria-label="Nächster Monat"
>
<span aria-hidden="true"></span>
</a>
</div>
</div>
<div class="segmented calendar-toolbar-mobile__switch">
<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>
</section>
<section class="panel panel--toolbar calendar-toolbar-desktop">
<div> <div>
<p class="eyebrow">Monatsansicht</p> <p class="eyebrow">Monatsansicht</p>
<h2>{{ current_label }}</h2> <h2>{{ current_label }}</h2>
@@ -19,6 +48,48 @@
</section> </section>
{% if view == 'calendar' %} {% if view == 'calendar' %}
{% if mobile_day_groups %}
<section class="panel calendar-mobile-strip">
<div class="calendar-mobile-strip__scroller">
{% for group in mobile_day_groups %}
<a href="#day-{{ group.day }}" class="calendar-mobile-pill {% if loop.first %}is-active{% endif %}">
<strong>{{ group.day }}.</strong>
<small>{{ group.tasks|length }} Aufgabe{% if group.tasks|length != 1 %}n{% endif %}</small>
</a>
{% endfor %}
</div>
</section>
{% endif %}
<section class="calendar-mobile-list">
{% for group in mobile_day_groups %}
<article class="panel calendar-mobile-day" id="day-{{ group.day }}">
<div class="calendar-mobile-day__header">
<strong>{{ group.day }}. {{ current_month|month_name }}</strong>
<span>{{ group.tasks|length }} Aufgabe{% if group.tasks|length != 1 %}n{% endif %}</span>
</div>
<div class="calendar-mobile-day__tasks">
{% for task in group.tasks %}
<a href="{{ url_for('tasks.edit', task_id=task.id) }}" class="calendar-task calendar-task--{{ task.status }}">
<strong class="calendar-task__title">{{ task.title }}</strong>
<small class="calendar-task__person">
{% if task.completed_by_user %}
{{ task.completed_by_user.name }}
{% elif task.assigned_user %}
{{ task.assigned_user.name }}
{% else %}
Ohne Zuweisung
{% endif %}
</small>
</a>
{% endfor %}
</div>
</article>
{% else %}
<div class="empty-state">In diesem Monat sind noch keine Aufgaben hinterlegt.</div>
{% endfor %}
</section>
<section class="calendar-grid"> <section class="calendar-grid">
<div class="calendar-grid__weekdays"> <div class="calendar-grid__weekdays">
{% for label in ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] %} {% for label in ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] %}
@@ -36,9 +107,9 @@
<strong class="calendar-task__title">{{ task.title }}</strong> <strong class="calendar-task__title">{{ task.title }}</strong>
<small class="calendar-task__person"> <small class="calendar-task__person">
{% if task.completed_by_user %} {% if task.completed_by_user %}
Erledigt: {{ task.completed_by_user.name }} {{ task.completed_by_user.name }}
{% elif task.assigned_user %} {% elif task.assigned_user %}
Zuständig: {{ task.assigned_user.name }} {{ task.assigned_user.name }}
{% else %} {% else %}
Ohne Zuweisung Ohne Zuweisung
{% endif %} {% endif %}