feat: add task archive and simplify task cards

This commit is contained in:
2026-04-16 12:00:07 +02:00
parent 8cab2d1929
commit 03d3a50169
7 changed files with 314 additions and 70 deletions

View File

@@ -90,6 +90,7 @@ def create_app(config_class: type[Config] = Config) -> Flask:
"nav_items": [ "nav_items": [
("tasks.my_tasks", "Meine Aufgaben", "house"), ("tasks.my_tasks", "Meine Aufgaben", "house"),
("tasks.all_tasks", "Alle", "list"), ("tasks.all_tasks", "Alle", "list"),
("tasks.archive_view", "Archiv", "check-double"),
("tasks.create", "Neu", "plus"), ("tasks.create", "Neu", "plus"),
("tasks.calendar_view", "Kalender", "calendar"), ("tasks.calendar_view", "Kalender", "calendar"),
("scoreboard.index", "Highscore", "trophy"), ("scoreboard.index", "Highscore", "trophy"),

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import calendar import calendar
from collections import defaultdict from collections import defaultdict
from datetime import date from datetime import date, timedelta
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
@@ -37,15 +37,72 @@ def _my_tasks_soon_priority(task: TaskInstance) -> int:
order = { order = {
"due_tomorrow": 0, "due_tomorrow": 0,
"due_day_after_tomorrow": 1, "due_day_after_tomorrow": 1,
"due_today": 2, "open": 2,
} }
return order.get(task.status, 99) return order.get(task.status, 99)
def _task_sort_key(task: TaskInstance, sort: str) -> tuple:
if sort == "points":
return (-task.points_awarded, task.due_date, task.title.lower())
if sort == "user":
return (task.assignee_label.lower(), task.due_date, task.title.lower())
return (task.due_date, task.title.lower())
def _group_active_tasks(tasks: list[TaskInstance], *, sort: str = "due") -> dict[str, list[TaskInstance]]:
sections = {
"overdue": [],
"today": [],
"soon": [],
"open": [],
}
for task in tasks:
if task.is_completed:
continue
if task.status == "overdue":
sections["overdue"].append(task)
elif task.status == "due_today":
sections["today"].append(task)
elif task.status in {"due_tomorrow", "due_day_after_tomorrow"}:
sections["soon"].append(task)
else:
sections["open"].append(task)
sections["overdue"].sort(key=lambda task: _task_sort_key(task, sort))
sections["today"].sort(key=lambda task: _task_sort_key(task, sort))
sections["soon"].sort(
key=lambda task: (_my_tasks_soon_priority(task),) + _task_sort_key(task, sort)
)
sections["open"].sort(key=lambda task: _task_sort_key(task, sort))
return sections
def _archive_day_priority(day: date, today: date) -> tuple[int, int]:
if day == today:
return (0, 0)
if day == today - timedelta(days=1):
return (1, 0)
if day == today - timedelta(days=2):
return (2, 0)
return (3, -day.toordinal())
def _archive_day_label(day: date, today: date) -> str:
if day == today:
return "Heute"
if day == today - timedelta(days=1):
return "Gestern"
if day == today - timedelta(days=2):
return "Vorgestern"
return day.strftime("%d.%m.%Y")
@bp.route("/my-tasks") @bp.route("/my-tasks")
@login_required @login_required
def my_tasks(): def my_tasks():
tasks = ( all_tasks = (
TaskInstance.query.filter( TaskInstance.query.filter(
or_( or_(
TaskInstance.assigned_user_id == current_user.id, TaskInstance.assigned_user_id == current_user.id,
@@ -55,38 +112,21 @@ def my_tasks():
.order_by(TaskInstance.completed_at.is_(None).desc(), TaskInstance.due_date.asc()) .order_by(TaskInstance.completed_at.is_(None).desc(), TaskInstance.due_date.asc())
.all() .all()
) )
refresh_task_statuses(tasks) refresh_task_statuses(all_tasks)
sections = _group_active_tasks(all_tasks)
sections = { completed_count = len([task for task in all_tasks if task.is_completed])
"open": [],
"due_today": [],
"due_tomorrow": [],
"due_day_after_tomorrow": [],
"overdue": [],
"completed": [],
}
for task in tasks:
sections[task.status].append(task)
soon_tasks = sorted(
sections["due_tomorrow"] + sections["due_day_after_tomorrow"] + sections["due_today"],
key=lambda task: (_my_tasks_soon_priority(task), task.due_date, task.title.lower()),
)
completed_count = len(sections["completed"])
active_count = ( active_count = (
len(sections["open"]) len(sections["overdue"])
+ len(sections["due_today"]) + len(sections["today"])
+ len(sections["due_tomorrow"]) + len(sections["soon"])
+ len(sections["due_day_after_tomorrow"]) + len(sections["open"])
+ len(sections["overdue"])
) )
completion_ratio = 0 if completed_count + active_count == 0 else round(completed_count / (completed_count + active_count) * 100) completion_ratio = 0 if completed_count + active_count == 0 else round(completed_count / (completed_count + active_count) * 100)
return render_template( return render_template(
"tasks/my_tasks.html", "tasks/my_tasks.html",
sections=sections, sections=sections,
soon_tasks=soon_tasks,
completion_ratio=completion_ratio, completion_ratio=completion_ratio,
today=today_local(), today=today_local(),
) )
@@ -128,10 +168,9 @@ def all_tasks():
if status != "all": if status != "all":
if status == "soon": if status == "soon":
tasks = [task for task in tasks if task.status in {"due_today", "due_tomorrow", "due_day_after_tomorrow"}] tasks = [task for task in tasks if task.status in {"due_tomorrow", "due_day_after_tomorrow"}]
else: else:
status_map = { status_map = {
"completed": "completed",
"overdue": "overdue", "overdue": "overdue",
"open": "open", "open": "open",
"today": "due_today", "today": "due_today",
@@ -141,15 +180,58 @@ def all_tasks():
selected = status_map.get(status) selected = status_map.get(status)
if selected: if selected:
tasks = [task for task in tasks if task.status == selected] tasks = [task for task in tasks if task.status == selected]
tasks = [task for task in tasks if not task.is_completed]
else:
tasks = [task for task in tasks if not task.is_completed]
sections = _group_active_tasks(tasks, sort=sort)
return render_template( return render_template(
"tasks/all_tasks.html", "tasks/all_tasks.html",
tasks=tasks, tasks=tasks,
sections=sections,
users=User.query.order_by(User.name.asc()).all(), users=User.query.order_by(User.name.asc()).all(),
filters={"status": status, "mine": mine, "user_id": user_filter, "sort": sort}, filters={"status": status, "mine": mine, "user_id": user_filter, "sort": sort},
) )
@bp.route("/archive")
@login_required
def archive_view():
selected_user_id = request.args.get("user_id", type=int) or current_user.id
archive_users = User.query.order_by(User.name.asc()).all()
selected_user = next((user for user in archive_users if user.id == selected_user_id), current_user)
ordered_users = sorted(archive_users, key=lambda user: (user.id != current_user.id, user.name.lower()))
completed_tasks = (
TaskInstance.query.filter_by(completed_by_user_id=selected_user.id)
.filter(TaskInstance.completed_at.isnot(None))
.order_by(TaskInstance.completed_at.desc(), TaskInstance.updated_at.desc())
.all()
)
today = today_local()
grouped: dict[date, list[TaskInstance]] = defaultdict(list)
for task in completed_tasks:
grouped[task.completed_at.date()].append(task)
archive_sections = [
{
"label": _archive_day_label(day, today),
"day": day,
"tasks": grouped[day],
}
for day in sorted(grouped.keys(), key=lambda value: _archive_day_priority(value, today))
]
return render_template(
"tasks/archive.html",
archive_users=ordered_users,
selected_user=selected_user,
archive_sections=archive_sections,
)
@bp.route("/tasks/new", methods=["GET", "POST"]) @bp.route("/tasks/new", methods=["GET", "POST"])
@login_required @login_required
def create(): def create():

View File

@@ -474,8 +474,8 @@ p {
.task-card { .task-card {
display: grid; display: grid;
gap: 16px; gap: 14px;
padding: 20px; padding: 18px;
} }
.task-card__top { .task-card__top {
@@ -505,7 +505,7 @@ p {
} }
.task-card h3 { .task-card h3 {
font-size: 1.35rem; font-size: 1.24rem;
line-height: 1.08; line-height: 1.08;
overflow-wrap: break-word; overflow-wrap: break-word;
hyphens: auto; hyphens: auto;
@@ -577,8 +577,8 @@ p {
.task-meta { .task-meta {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 14px; gap: 10px 14px;
} }
.task-meta dt { .task-meta dt {
@@ -594,11 +594,16 @@ p {
font-weight: 600; font-weight: 600;
} }
.task-card--compact .task-meta {
grid-template-columns: 1fr;
}
.task-assignee { .task-assignee {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
color: var(--muted); color: var(--muted);
min-width: 0;
} }
.task-assignee__avatars { .task-assignee__avatars {
@@ -611,6 +616,10 @@ p {
border: 2px solid var(--surface-strong); border: 2px solid var(--surface-strong);
} }
.task-assignee span:last-child {
overflow-wrap: break-word;
}
.avatar { .avatar {
width: 34px; width: 34px;
height: 34px; height: 34px;
@@ -757,6 +766,41 @@ p {
gap: 14px; gap: 14px;
} }
.archive-user-tabs {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 4px;
margin-bottom: 8px;
}
.archive-user-tab {
flex: 0 0 auto;
min-width: 108px;
display: grid;
justify-items: center;
gap: 8px;
padding: 14px 16px;
border-radius: 20px;
border: 1px solid var(--border);
background: var(--surface);
box-shadow: var(--shadow);
color: var(--muted);
text-align: center;
}
.archive-user-tab span {
font-size: 0.92rem;
font-weight: 700;
line-height: 1.15;
}
.archive-user-tab.is-active {
color: var(--text);
border-color: rgba(37, 99, 235, 0.22);
background: linear-gradient(135deg, rgba(37, 99, 235, 0.14), rgba(52, 211, 153, 0.1));
}
.panel--toolbar { .panel--toolbar {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -1345,6 +1389,22 @@ p {
border-radius: 28px; border-radius: 28px;
} }
.archive-user-tabs {
gap: 10px;
margin-bottom: 4px;
}
.archive-user-tab {
min-width: 92px;
padding: 12px 12px;
gap: 6px;
border-radius: 18px;
}
.archive-user-tab span {
font-size: 0.82rem;
}
.task-card { .task-card {
gap: 14px; gap: 14px;
padding: 18px; padding: 18px;

View File

@@ -66,18 +66,10 @@
<dt>Fällig</dt> <dt>Fällig</dt>
<dd>{{ task.due_date|date_de }}</dd> <dd>{{ task.due_date|date_de }}</dd>
</div> </div>
<div>
<dt>Zuständig</dt>
<dd>{{ task.assignee_label }}</dd>
</div>
<div>
<dt>Rhythmus</dt>
<dd>{{ task.task_template.recurrence_label }}</dd>
</div>
{% if task.completed_at %} {% if task.completed_at %}
<div> <div>
<dt>Erledigt von</dt> <dt>Erledigt</dt>
<dd>{{ task.completed_by_user.name if task.completed_by_user else '—' }}</dd> <dd>{{ task.completed_at|datetime_de }}</dd>
</div> </div>
{% endif %} {% endif %}
</dl> </dl>

View File

@@ -15,7 +15,6 @@
<option value="tomorrow" {% if filters.status == 'tomorrow' %}selected{% endif %}>Morgen fällig</option> <option value="tomorrow" {% if filters.status == 'tomorrow' %}selected{% endif %}>Morgen fällig</option>
<option value="day_after_tomorrow" {% if filters.status == 'day_after_tomorrow' %}selected{% endif %}>Übermorgen fällig</option> <option value="day_after_tomorrow" {% if filters.status == 'day_after_tomorrow' %}selected{% endif %}>Übermorgen fällig</option>
<option value="overdue" {% if filters.status == 'overdue' %}selected{% endif %}>Überfällig</option> <option value="overdue" {% if filters.status == 'overdue' %}selected{% endif %}>Überfällig</option>
<option value="completed" {% if filters.status == 'completed' %}selected{% endif %}>Erledigt</option>
</select> </select>
</div> </div>
<div class="field field--compact"> <div class="field field--compact">
@@ -43,6 +42,63 @@
</form> </form>
</section> </section>
{% if filters.status == 'all' %}
<section class="stack">
<div class="section-heading">
<h2>Überfällig</h2>
<span class="section-heading__count">{{ sections.overdue|length }}</span>
</div>
<div class="task-grid">
{% for task in sections.overdue %}
{{ task_card(task, current_user) }}
{% else %}
<div class="empty-state">Für diese Auswahl ist nichts überfällig.</div>
{% endfor %}
</div>
</section>
<section class="stack">
<div class="section-heading">
<h2>Heute</h2>
<span class="section-heading__count">{{ sections.today|length }}</span>
</div>
<div class="task-grid">
{% for task in sections.today %}
{{ task_card(task, current_user) }}
{% else %}
<div class="empty-state">Heute ist hier gerade nichts offen.</div>
{% endfor %}
</div>
</section>
<section class="stack">
<div class="section-heading">
<h2>Bald fällig</h2>
<span class="section-heading__count">{{ sections.soon|length }}</span>
</div>
<div class="task-grid">
{% for task in sections.soon %}
{{ task_card(task, current_user) }}
{% else %}
<div class="empty-state">In den nächsten Tagen ist hier gerade nichts fällig.</div>
{% endfor %}
</div>
</section>
<section class="stack">
<div class="section-heading">
<h2>Offen</h2>
<span class="section-heading__count">{{ sections.open|length }}</span>
</div>
<div class="task-grid">
{% for task in sections.open %}
{{ task_card(task, current_user) }}
{% else %}
<div class="empty-state">Keine weiteren offenen Aufgaben für diese Auswahl.</div>
{% endfor %}
</div>
</section>
{% else %}
<section class="task-grid"> <section class="task-grid">
{% for task in tasks %} {% for task in tasks %}
{{ task_card(task, current_user) }} {{ task_card(task, current_user) }}
@@ -50,4 +106,5 @@
<div class="empty-state">Für diese Filter gibt es gerade keine Aufgaben.</div> <div class="empty-state">Für diese Filter gibt es gerade keine Aufgaben.</div>
{% endfor %} {% endfor %}
</section> </section>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% from "partials/macros.html" import avatar, task_card %}
{% block title %}Archiv · Putzliga{% endblock %}
{% block page_title %}Archiv{% endblock %}
{% block content %}
<section class="hero-grid">
<article class="hero-card">
<p class="eyebrow">Erledigte Aufgaben</p>
<h2>Was schon geschafft wurde</h2>
<p>Hier landen alle erledigten Aufgaben. Du kannst pro Person sehen, was heute, gestern, vorgestern und davor erledigt wurde.</p>
</article>
<article class="panel highlight-panel">
<p class="eyebrow">Ansicht</p>
<h2>{{ selected_user.name }}</h2>
<p class="muted">Wechsle über die Tabs direkt zwischen den erledigten Aufgaben der einzelnen Nutzer.</p>
</article>
</section>
<section class="archive-user-tabs" aria-label="Archiv nach Nutzer">
{% for user in archive_users %}
<a
href="{{ url_for('tasks.archive_view', user_id=user.id) }}"
class="archive-user-tab {% if selected_user.id == user.id %}is-active{% endif %}"
>
{{ avatar(user) }}
<span>{{ user.name }}</span>
</a>
{% endfor %}
</section>
{% for section in archive_sections %}
<section class="stack">
<div class="section-heading">
<h2>{{ section.label }}</h2>
<span class="section-heading__count">{{ section.tasks|length }}</span>
</div>
<div class="task-grid">
{% for task in section.tasks %}
{{ task_card(task, current_user, compact=true) }}
{% endfor %}
</div>
</section>
{% else %}
<section class="panel">
<div class="empty-state">Für {{ selected_user.name }} gibt es im Archiv noch keine erledigten Aufgaben.</div>
</section>
{% endfor %}
{% endblock %}

View File

@@ -22,6 +22,10 @@
{{ nav_icon('plus') }} {{ nav_icon('plus') }}
<span>Neue Aufgabe anlegen</span> <span>Neue Aufgabe anlegen</span>
</a> </a>
<a class="button button--ghost button--wide" href="{{ url_for('tasks.archive_view') }}">
{{ nav_icon('check-double') }}
<span>Archiv</span>
</a>
<a class="button button--secondary button--wide" href="{{ url_for('scoreboard.index') }}"> <a class="button button--secondary button--wide" href="{{ url_for('scoreboard.index') }}">
{{ nav_icon('trophy') }} {{ nav_icon('trophy') }}
<span>Zum aktuellen Highscore</span> <span>Zum aktuellen Highscore</span>
@@ -45,14 +49,28 @@
<section class="stack"> <section class="stack">
<div class="section-heading"> <div class="section-heading">
<h2>Bald fällig</h2> <h2>Heute</h2>
<span class="section-heading__count">{{ soon_tasks|length }}</span> <span class="section-heading__count">{{ sections.today|length }}</span>
</div> </div>
<div class="task-grid"> <div class="task-grid">
{% for task in soon_tasks %} {% for task in sections.today %}
{{ task_card(task, current_user) }} {{ task_card(task, current_user) }}
{% else %} {% else %}
<div class="empty-state">Gerade ist nichts bald fällig. Sehr stark.</div> <div class="empty-state">Heute ist gerade nichts mehr offen. Sehr stark.</div>
{% endfor %}
</div>
</section>
<section class="stack">
<div class="section-heading">
<h2>Bald fällig</h2>
<span class="section-heading__count">{{ sections.soon|length }}</span>
</div>
<div class="task-grid">
{% for task in sections.soon %}
{{ task_card(task, current_user) }}
{% else %}
<div class="empty-state">Gerade ist nichts in den nächsten Tagen fällig.</div>
{% endfor %} {% endfor %}
</div> </div>
</section> </section>
@@ -70,18 +88,4 @@
{% endfor %} {% endfor %}
</div> </div>
</section> </section>
<section class="stack">
<div class="section-heading">
<h2>Erledigt</h2>
<span class="section-heading__count">{{ sections.completed|length }}</span>
</div>
<div class="task-grid">
{% for task in sections.completed %}
{{ task_card(task, current_user, compact=true) }}
{% else %}
<div class="empty-state">Noch keine erledigten Aufgaben in deiner Liste.</div>
{% endfor %}
</div>
</section>
{% endblock %} {% endblock %}