3 Commits

12 changed files with 341 additions and 88 deletions
+2
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"),
@@ -98,6 +99,7 @@ def create_app(config_class: type[Config] = Config) -> Flask:
"mobile_nav_items": [ "mobile_nav_items": [
("tasks.my_tasks", "Meine Aufgaben", "house"), ("tasks.my_tasks", "Meine Aufgaben", "house"),
("tasks.all_tasks", "Alle Aufgaben", "list"), ("tasks.all_tasks", "Alle Aufgaben", "list"),
("tasks.archive_view", "Archiv", "check-double"),
("tasks.calendar_view", "Kalender", "calendar"), ("tasks.calendar_view", "Kalender", "calendar"),
("scoreboard.index", "Highscore", "trophy"), ("scoreboard.index", "Highscore", "trophy"),
("settings.index", "Optionen", "gear"), ("settings.index", "Optionen", "gear"),
+111 -29
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():
+79 -14
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,46 @@ p {
gap: 14px; gap: 14px;
} }
.quick-actions {
display: grid;
gap: 12px;
}
.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 +1394,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;
@@ -1469,8 +1534,8 @@ p {
right: 8px; right: 8px;
bottom: calc(10px + env(safe-area-inset-bottom)); bottom: calc(10px + env(safe-area-inset-bottom));
display: grid; display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 4px; gap: 3px;
padding: 8px 8px; padding: 8px 8px;
border-radius: 22px; border-radius: 22px;
background: var(--nav-bg); background: var(--nav-bg);
@@ -1484,13 +1549,13 @@ p {
.nav-link { .nav-link {
display: grid; display: grid;
justify-items: center; justify-items: center;
gap: 5px; gap: 4px;
min-width: 0; min-width: 0;
padding: 10px 4px 9px; padding: 10px 3px 9px;
color: var(--muted); color: var(--muted);
border-radius: 16px; border-radius: 16px;
text-align: center; text-align: center;
font-size: 0.66rem; font-size: 0.61rem;
font-weight: 700; font-weight: 700;
line-height: 1.05; line-height: 1.05;
} }
@@ -1498,7 +1563,7 @@ p {
.bottom-nav__item span { .bottom-nav__item span {
display: block; display: block;
width: 100%; width: 100%;
min-height: 2.1em; min-height: 2.3em;
white-space: normal; white-space: normal;
word-break: keep-all; word-break: keep-all;
overflow-wrap: break-word; overflow-wrap: break-word;
@@ -1512,8 +1577,8 @@ p {
.nav-icon, .nav-icon,
.nav-icon svg { .nav-icon svg {
width: 20px; width: 18px;
height: 20px; height: 18px;
display: inline-block; display: inline-block;
} }
@@ -1565,7 +1630,7 @@ p {
.fab-quick-task { .fab-quick-task {
position: fixed; position: fixed;
right: max(16px, env(safe-area-inset-right)); right: max(16px, env(safe-area-inset-right));
bottom: calc(72px + var(--safe-bottom)); bottom: calc(86px + var(--safe-bottom));
width: 62px; width: 62px;
height: 62px; height: 62px;
border: 2px solid rgba(255, 255, 255, 0.55); border: 2px solid rgba(255, 255, 255, 0.55);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 587 KiB

+2 -1
View File
@@ -1,8 +1,9 @@
const CACHE_NAME = "putzliga-shell-v3"; const CACHE_NAME = "putzliga-shell-v4";
const ASSETS = [ const ASSETS = [
"/static/css/style.css", "/static/css/style.css",
"/static/js/app.js", "/static/js/app.js",
"/static/images/favicon.png", "/static/images/favicon.png",
"/static/images/apple-touch-icon.png",
"/static/images/logo-mark.png", "/static/images/logo-mark.png",
"/static/images/pwa-icon-192.png", "/static/images/pwa-icon-192.png",
"/static/images/pwa-icon-512.png" "/static/images/pwa-icon-512.png"
+2 -10
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>
+65 -8
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,11 +42,69 @@
</form> </form>
</section> </section>
<section class="task-grid"> {% if filters.status == 'all' %}
{% for task in tasks %} <section class="stack">
{{ task_card(task, current_user) }} <div class="section-heading">
{% else %} <h2>Überfällig</h2>
<div class="empty-state">Für diese Filter gibt es gerade keine Aufgaben.</div> <span class="section-heading__count">{{ sections.overdue|length }}</span>
{% endfor %} </div>
</section> <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">
{% for task in tasks %}
{{ task_card(task, current_user) }}
{% else %}
<div class="empty-state">Für diese Filter gibt es gerade keine Aufgaben.</div>
{% endfor %}
</section>
{% endif %}
{% endblock %} {% endblock %}
+48
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 %}
+32 -26
View File
@@ -18,14 +18,20 @@
</article> </article>
<article class="panel highlight-panel"> <article class="panel highlight-panel">
<p class="eyebrow">Schnellzugriff</p> <p class="eyebrow">Schnellzugriff</p>
<a class="button button--wide" href="{{ url_for('tasks.create') }}"> <div class="quick-actions">
{{ nav_icon('plus') }} <a class="button button--wide" href="{{ url_for('tasks.create') }}">
<span>Neue Aufgabe anlegen</span> {{ nav_icon('plus') }}
</a> <span>Neue Aufgabe anlegen</span>
<a class="button button--secondary button--wide" href="{{ url_for('scoreboard.index') }}"> </a>
{{ nav_icon('trophy') }} <a class="button button--ghost button--wide" href="{{ url_for('tasks.archive_view') }}">
<span>Zum aktuellen Highscore</span> {{ nav_icon('check-double') }}
</a> <span>Archiv</span>
</a>
<a class="button button--secondary button--wide" href="{{ url_for('scoreboard.index') }}">
{{ nav_icon('trophy') }}
<span>Zum aktuellen Highscore</span>
</a>
</div>
</article> </article>
</section> </section>
@@ -45,14 +51,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 +90,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 %}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 151 KiB