tasks: shared task board with sticker rewards, difficulty levels, and calendar

Complete household task management system behind task_users auth group:
- Task CRUD with recurring schedules, assignees, tags, and optional difficulty
- Blobcat SVG sticker rewards on completion, rarity weighted by difficulty
- Sticker collection page with calendar view and progress tracking
- Redesigned cards with left accent urgency strip, assignee PFP, round check button
- Weekday-based due date labels for tasks within 7 days
- Tasks link added to homepage LinksGrid
This commit is contained in:
2026-04-02 07:32:53 +02:00
parent c76c6e8cbe
commit 81c70df78e
69 changed files with 11158 additions and 1 deletions
+18
View File
@@ -56,6 +56,24 @@ async function authorization({ event, resolve }: Parameters<Handle>[0]) {
}
}
// Protect tasks routes and API endpoints
if (event.url.pathname.startsWith('/tasks') || event.url.pathname.startsWith('/api/tasks')) {
if (!session) {
if (event.url.pathname.startsWith('/api/tasks')) {
error(401, {
message: 'Anmeldung erforderlich.'
});
}
const callbackUrl = encodeURIComponent(event.url.pathname + event.url.search);
redirect(303, `/login?callbackUrl=${callbackUrl}`);
}
else if (!session.user?.groups?.includes('task_users')) {
error(403, {
message: 'Zugriff verweigert. Du hast keine Berechtigung für diesen Bereich. Falls du glaubst, dass dies ein Fehler ist, wende dich bitte an Alexander.'
});
}
}
// Protect fitness routes and API endpoints
if (event.url.pathname.startsWith('/fitness') || event.url.pathname.startsWith('/api/fitness')) {
if (!session) {
@@ -0,0 +1,254 @@
<script>
import { ChevronLeft, ChevronRight } from 'lucide-svelte';
import { getStickerById } from '$lib/utils/stickers';
import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
eachDayOfInterval, isSameMonth, isSameDay, isToday, format, addMonths, subMonths
} from 'date-fns';
import { de } from 'date-fns/locale';
let { completions = [], currentUser = '' } = $props();
let viewDate = $state(new Date());
let filteredCompletions = $derived(
completions
.filter((/** @type {any} */ c) => c.stickerId)
.filter((/** @type {any} */ c) => !currentUser || c.completedBy === currentUser)
);
// Build a map: "YYYY-MM-DD" -> sticker ids[]
let stickersByDate = $derived.by(() => {
/** @type {Map<string, any[]>} */
const map = new Map();
for (const c of filteredCompletions) {
const key = format(new Date(c.completedAt), 'yyyy-MM-dd');
if (!map.has(key)) map.set(key, []);
map.get(key)?.push(c);
}
return map;
});
let calendarDays = $derived.by(() => {
const monthStart = startOfMonth(viewDate);
const monthEnd = endOfMonth(viewDate);
const calStart = startOfWeek(monthStart, { locale: de });
const calEnd = endOfWeek(monthEnd, { locale: de });
return eachDayOfInterval({ start: calStart, end: calEnd });
});
let monthLabel = $derived(format(viewDate, 'MMMM yyyy', { locale: de }));
const weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
function prevMonth() { viewDate = subMonths(viewDate, 1); }
function nextMonth() { viewDate = addMonths(viewDate, 1); }
</script>
<div class="cal-container">
<div class="cal-header">
<button class="cal-nav" onclick={prevMonth}><ChevronLeft size={18} /></button>
<span class="cal-month">{monthLabel}</span>
<button class="cal-nav" onclick={nextMonth}><ChevronRight size={18} /></button>
</div>
<div class="cal-grid">
{#each weekdays as day}
<div class="cal-weekday">{day}</div>
{/each}
{#each calendarDays as day}
{@const key = format(day, 'yyyy-MM-dd')}
{@const dayStickers = stickersByDate.get(key) || []}
{@const inMonth = isSameMonth(day, viewDate)}
<div
class="cal-day"
class:outside={!inMonth}
class:today={isToday(day)}
class:has-stickers={dayStickers.length > 0}
>
<span class="cal-day-num">{format(day, 'd')}</span>
{#if dayStickers.length > 0}
<div class="cal-stickers">
{#each dayStickers.slice(0, 6) as completion}
{@const sticker = getStickerById(completion.stickerId)}
{#if sticker}
<img
class="cal-sticker-img"
src="/stickers/{sticker.image}"
alt={sticker.name}
title="{sticker.name} {completion.taskTitle}"
/>
{/if}
{/each}
{#if dayStickers.length > 6}
<span class="cal-more">+{dayStickers.length - 6}</span>
{/if}
</div>
{/if}
</div>
{/each}
</div>
</div>
<style>
.cal-container {
background: var(--color-bg-primary, white);
border: 1px solid var(--color-border, #e8e4dd);
border-radius: 14px;
padding: 1rem;
margin-bottom: 2rem;
}
.cal-header {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-bottom: 0.75rem;
}
.cal-month {
font-size: 1rem;
font-weight: 700;
text-transform: capitalize;
min-width: 160px;
text-align: center;
}
.cal-nav {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: var(--color-text-secondary, #888);
border-radius: 8px;
cursor: pointer;
transition: all 120ms;
}
.cal-nav:hover {
background: var(--color-bg-secondary, #f0ede6);
color: var(--color-text-primary, #333);
}
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
}
.cal-weekday {
text-align: center;
font-size: 0.68rem;
font-weight: 700;
color: var(--color-text-secondary, #999);
padding: 0.3rem 0;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.cal-day {
position: relative;
min-height: 80px;
padding: 0.3rem;
border-radius: 8px;
border: 1px solid transparent;
transition: background 120ms;
overflow: hidden;
}
.cal-day.outside {
opacity: 0.25;
}
.cal-day.today {
background: rgba(94, 129, 172, 0.08);
border-color: rgba(94, 129, 172, 0.2);
}
.cal-day.today .cal-day-num {
background: var(--nord10);
color: white;
border-radius: 50%;
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.cal-day.has-stickers {
background: rgba(163, 190, 140, 0.06);
}
.cal-day-num {
font-size: 0.7rem;
font-weight: 600;
color: var(--color-text-secondary, #888);
line-height: 1;
display: block;
margin-bottom: 0.2rem;
}
.cal-stickers {
display: flex;
flex-wrap: wrap;
gap: 3px;
align-items: center;
}
.cal-sticker-img {
width: 28px;
height: 28px;
object-fit: contain;
transition: transform 150ms;
cursor: default;
}
.cal-sticker-img:hover {
transform: scale(2);
z-index: 10;
position: relative;
filter: drop-shadow(0 2px 6px rgba(0,0,0,0.2));
}
.cal-more {
font-size: 0.6rem;
font-weight: 700;
color: var(--color-text-secondary, #aaa);
display: flex;
align-items: center;
padding-left: 2px;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .cal-container {
background: var(--nord1);
border-color: var(--nord2);
}
:global(:root:not([data-theme="light"])) .cal-nav:hover {
background: var(--nord2);
}
:global(:root:not([data-theme="light"])) .cal-day.today {
background: rgba(94, 129, 172, 0.12);
}
:global(:root:not([data-theme="light"])) .cal-day.has-stickers {
background: rgba(163, 190, 140, 0.08);
}
}
:global(:root[data-theme="dark"]) .cal-container {
background: var(--nord1);
border-color: var(--nord2);
}
:global(:root[data-theme="dark"]) .cal-nav:hover {
background: var(--nord2);
}
:global(:root[data-theme="dark"]) .cal-day.today {
background: rgba(94, 129, 172, 0.12);
}
:global(:root[data-theme="dark"]) .cal-day.has-stickers {
background: rgba(163, 190, 140, 0.08);
}
@media (max-width: 500px) {
.cal-day { min-height: 56px; padding: 0.2rem; }
.cal-sticker-img { width: 22px; height: 22px; }
.cal-stickers { gap: 2px; }
.cal-month { font-size: 0.9rem; min-width: 130px; }
}
</style>
@@ -0,0 +1,121 @@
<script>
import { scale, fade } from 'svelte/transition';
import { elasticOut } from 'svelte/easing';
import { getRarityColor } from '$lib/utils/stickers';
let { sticker, onclose } = $props();
const rarityLabels = /** @type {Record<string, string>} */ ({
common: 'Gewöhnlich',
uncommon: 'Ungewöhnlich',
rare: 'Selten',
legendary: 'Legendär'
});
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="popup-backdrop" transition:fade={{ duration: 200 }} onclick={onclose} onkeydown={e => e.key === 'Escape' && onclose?.()}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="popup-card" transition:scale={{ start: 0.5, duration: 500, easing: elasticOut }} onclick={e => e.stopPropagation()}>
<div class="sticker-display" style="--rarity-color: {getRarityColor(sticker.rarity)}">
<img class="sticker-img" src="/stickers/{sticker.image}" alt={sticker.name} />
</div>
<div class="popup-text">
<h3>Sticker erhalten!</h3>
<p class="sticker-name">{sticker.name}</p>
<p class="sticker-desc">{sticker.description}</p>
<span class="rarity-badge" style="color: {getRarityColor(sticker.rarity)}">
{rarityLabels[sticker.rarity] || sticker.rarity}
</span>
</div>
<button class="btn-close" onclick={onclose}>Toll!</button>
</div>
</div>
<style>
.popup-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.popup-card {
background: var(--color-bg-primary, white);
border-radius: 20px;
padding: 2rem;
text-align: center;
max-width: 320px;
width: 90%;
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
}
.sticker-display {
width: 100px;
height: 100px;
margin: 0 auto 1rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: radial-gradient(circle, var(--rarity-color, var(--nord13)) 0%, transparent 70%);
opacity: 0.95;
}
.sticker-img {
width: 80px;
height: 80px;
object-fit: contain;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.15));
}
.popup-text h3 {
margin: 0 0 0.3rem;
font-size: 1.2rem;
font-weight: 700;
color: var(--color-text-primary, #333);
}
.sticker-name {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--nord10);
}
.sticker-desc {
margin: 0.25rem 0 0.5rem;
font-size: 0.82rem;
color: var(--color-text-secondary, #888);
}
.rarity-badge {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.btn-close {
margin-top: 1.25rem;
padding: 0.6rem 2rem;
border: none;
background: var(--nord10);
color: white;
border-radius: 100px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background 150ms;
}
.btn-close:hover { background: var(--nord9); }
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .popup-card {
background: var(--nord1);
}
}
:global(:root[data-theme="dark"]) .popup-card {
background: var(--nord1);
}
</style>
+698
View File
@@ -0,0 +1,698 @@
<script>
import { X, Sparkles, Wind, Bath, UtensilsCrossed, CookingPot, WashingMachine,
Flower2, Droplets, Leaf, ShoppingCart, Trash2, Shirt, Brush } from 'lucide-svelte';
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
const USERS = ['anna', 'alexander'];
let {
task = null,
onclosed,
onsaved
} = $props();
/** @type {{tag: string, icon: any}[]} */
const AVAILABLE_TAGS = [
{ tag: 'putzen', icon: Sparkles },
{ tag: 'saugen', icon: Wind },
{ tag: 'wischen', icon: Brush },
{ tag: 'bad', icon: Bath },
{ tag: 'küche', icon: UtensilsCrossed },
{ tag: 'kochen', icon: CookingPot },
{ tag: 'abwasch', icon: Droplets },
{ tag: 'wäsche', icon: WashingMachine },
{ tag: 'bügeln', icon: Shirt },
{ tag: 'pflanzen', icon: Flower2 },
{ tag: 'gießen', icon: Droplets },
{ tag: 'düngen', icon: Leaf },
{ tag: 'garten', icon: Leaf },
{ tag: 'einkaufen', icon: ShoppingCart },
{ tag: 'müll', icon: Trash2 },
];
let title = $state(task?.title || '');
let description = $state(task?.description || '');
/** @type {string[]} */
let selectedAssignees = $state(task?.assignees ? [...task.assignees] : []);
/** @type {string[]} */
let selectedTags = $state(task?.tags ? [...task.tags] : []);
let difficulty = $state(task?.difficulty || '');
let isRecurring = $state(task?.isRecurring || false);
let frequencyType = $state(task?.frequency?.type || 'weekly');
let customDays = $state(task?.frequency?.customDays || 7);
let nextDueDate = $state(
task?.nextDueDate
? new Date(task.nextDueDate).toISOString().split('T')[0]
: new Date().toISOString().split('T')[0]
);
let saving = $state(false);
/** @type {string | null} */
let error = $state(null);
// Desktop tag input
let tagInput = $state('');
let tagDropdownOpen = $state(false);
let unselectedTags = $derived(AVAILABLE_TAGS.filter(t => !selectedTags.includes(t.tag)));
let filteredDropdownTags = $derived(
tagInput.trim() === ''
? unselectedTags
: unselectedTags.filter(t => t.tag.includes(tagInput.toLowerCase()))
);
/** @param {string} tag */
function toggleTag(tag) {
if (selectedTags.includes(tag)) {
selectedTags = selectedTags.filter(t => t !== tag);
} else {
selectedTags = [...selectedTags, tag];
}
}
function handleTagInputFocus() {
tagDropdownOpen = true;
}
/** @param {FocusEvent} _event */
function handleTagInputBlur(_event) {
setTimeout(() => {
tagDropdownOpen = false;
tagInput = '';
}, 200);
}
/** @param {KeyboardEvent} event */
function handleTagKeyDown(event) {
if (event.key === 'Enter') {
event.preventDefault();
const value = tagInput.trim().toLowerCase();
const matched = AVAILABLE_TAGS.find(t => t.tag === value) || filteredDropdownTags[0];
if (matched && !selectedTags.includes(matched.tag)) {
toggleTag(matched.tag);
tagInput = '';
}
} else if (event.key === 'Escape') {
tagDropdownOpen = false;
tagInput = '';
}
}
/** @param {string} tag */
function selectDropdownTag(tag) {
toggleTag(tag);
tagInput = '';
tagDropdownOpen = false;
}
/** @param {string} tag */
function getTagIcon(tag) {
return AVAILABLE_TAGS.find(t => t.tag === tag)?.icon;
}
async function handleSubmit() {
if (!title.trim()) { error = 'Titel ist erforderlich'; return; }
saving = true;
error = null;
const payload = {
title: title.trim(),
description: description.trim() || undefined,
assignees: selectedAssignees,
tags: selectedTags,
difficulty: difficulty || undefined,
isRecurring,
frequency: isRecurring ? {
type: frequencyType,
customDays: frequencyType === 'custom' ? customDays : undefined
} : undefined,
nextDueDate
};
const url = task?._id ? `/api/tasks/${task._id}` : '/api/tasks';
const method = task?._id ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
onsaved?.(new CustomEvent('saved'));
} else {
const data = await res.json().catch(() => ({}));
error = data.message || 'Fehler beim Speichern';
}
saving = false;
}
</script>
<form class="task-form" onsubmit={e => { e.preventDefault(); handleSubmit(); }}>
<div class="form-header">
<h2>{task?._id ? 'Aufgabe bearbeiten' : 'Neue Aufgabe'}</h2>
<button type="button" class="btn-close" onclick={onclosed}><X size={18} /></button>
</div>
{#if error}
<p class="error">{error}</p>
{/if}
<div class="field">
<label for="title">Titel *</label>
<input id="title" type="text" bind:value={title} placeholder="z.B. Staubsaugen" required />
</div>
<div class="field">
<label for="description">Beschreibung</label>
<textarea id="description" bind:value={description} placeholder="Optionale Details..." rows="2"></textarea>
</div>
<div class="field">
<label>Zugewiesen an</label>
<div class="assignee-buttons">
{#each USERS as user}
<button
type="button"
class="assignee-btn"
class:selected={selectedAssignees.includes(user)}
onclick={() => {
if (selectedAssignees.includes(user)) {
selectedAssignees = selectedAssignees.filter(a => a !== user);
} else {
selectedAssignees = [...selectedAssignees, user];
}
}}
>
<ProfilePicture username={user} size={26} />
<span class="assignee-name">{user}</span>
</button>
{/each}
</div>
</div>
<!-- Tags: desktop = input + dropdown, mobile = pill buttons -->
<div class="field">
<label>Tags</label>
<span class="hint">Bestimmen Sticker-Belohnungen</span>
<!-- Desktop: input with dropdown -->
<div class="tag-input-desktop">
<div class="tag-input-wrapper">
<input
type="text"
bind:value={tagInput}
onfocus={handleTagInputFocus}
onblur={handleTagInputBlur}
onkeydown={handleTagKeyDown}
placeholder="Tag eingeben oder auswählen..."
autocomplete="off"
/>
{#if tagDropdownOpen && filteredDropdownTags.length > 0}
<div class="tag-dropdown">
{#each filteredDropdownTags as { tag, icon }}
<button type="button" class="tag-dropdown-item" onclick={() => selectDropdownTag(tag)}>
<svelte:component this={icon} size={14} />
{tag}
</button>
{/each}
</div>
{/if}
</div>
</div>
<!-- Mobile: pill buttons -->
<div class="tag-pills-mobile">
{#each AVAILABLE_TAGS as { tag, icon }}
<button
type="button"
class="tag-pill"
class:selected={selectedTags.includes(tag)}
onclick={() => toggleTag(tag)}
>
<svelte:component this={icon} size={14} />
{tag}
</button>
{/each}
</div>
<!-- Selected tags (shown on desktop below input) -->
{#if selectedTags.length > 0}
<div class="selected-tags">
{#each selectedTags as tag}
{@const Icon = getTagIcon(tag)}
<button type="button" class="tag-chip selected" onclick={() => toggleTag(tag)}>
{#if Icon}
<svelte:component this={Icon} size={13} />
{/if}
{tag}
<span class="remove-x">&times;</span>
</button>
{/each}
</div>
{/if}
</div>
<div class="field">
<label>Schwierigkeit</label>
<span class="hint">Schwerere Aufgaben geben seltenere Sticker</span>
<div class="difficulty-buttons">
<button
type="button"
class="diff-btn"
class:selected={difficulty === '' || difficulty === undefined}
onclick={() => difficulty = ''}
>
Keine
</button>
<button
type="button"
class="diff-btn low"
class:selected={difficulty === 'low'}
onclick={() => difficulty = 'low'}
>
Leicht
</button>
<button
type="button"
class="diff-btn medium"
class:selected={difficulty === 'medium'}
onclick={() => difficulty = 'medium'}
>
Mittel
</button>
<button
type="button"
class="diff-btn high"
class:selected={difficulty === 'high'}
onclick={() => difficulty = 'high'}
>
Schwer
</button>
</div>
</div>
<div class="field">
<label for="dueDate">Fällig am</label>
<input id="dueDate" type="date" bind:value={nextDueDate} required />
</div>
<div class="field-row">
<label class="checkbox-label">
<input type="checkbox" bind:checked={isRecurring} />
Wiederkehrend
</label>
</div>
{#if isRecurring}
<div class="field">
<label for="frequency">Häufigkeit</label>
<select id="frequency" bind:value={frequencyType}>
<option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option>
<option value="biweekly">Alle 2 Wochen</option>
<option value="monthly">Monatlich</option>
<option value="custom">Benutzerdefiniert</option>
</select>
</div>
{#if frequencyType === 'custom'}
<div class="field">
<label for="customDays">Alle X Tage</label>
<input id="customDays" type="number" bind:value={customDays} min="1" max="365" />
</div>
{/if}
{/if}
<div class="form-actions">
<button type="button" class="btn-cancel" onclick={onclosed}>Abbrechen</button>
<button type="submit" class="btn-save" disabled={saving}>
{saving ? 'Speichern...' : (task?._id ? 'Aktualisieren' : 'Erstellen')}
</button>
</div>
</form>
<style>
.task-form {
background: var(--color-bg-primary, white);
border: 1px solid var(--color-border, #e8e4dd);
border-radius: 12px;
padding: 1.25rem;
}
.form-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.form-header h2 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.btn-close {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: none;
background: transparent;
color: var(--color-text-secondary, #999);
border-radius: 6px;
cursor: pointer;
}
.btn-close:hover { background: var(--color-bg-secondary, #f0ede6); }
.error {
color: var(--nord11);
font-size: 0.82rem;
margin: 0 0 0.75rem;
padding: 0.4rem 0.6rem;
background: rgba(191, 97, 106, 0.08);
border-radius: 6px;
}
.field {
margin-bottom: 0.75rem;
}
.field label {
display: block;
font-size: 0.78rem;
font-weight: 600;
margin-bottom: 0.25rem;
color: var(--color-text-secondary, #666);
}
.field input[type="text"],
.field input[type="date"],
.field input[type="number"],
.field textarea,
.field select {
width: 100%;
padding: 0.45rem 0.6rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 8px;
font-size: 0.85rem;
background: var(--color-bg-primary, white);
color: inherit;
box-sizing: border-box;
}
.field textarea { resize: vertical; }
.hint {
font-size: 0.7rem;
color: var(--color-text-secondary, #aaa);
display: block;
margin-bottom: 0.3rem;
}
/* ── Assignee buttons ── */
.assignee-buttons {
display: flex;
gap: 0.5rem;
}
.assignee-btn {
all: unset;
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.75rem 0.35rem 0.35rem;
border-radius: 100px;
cursor: pointer;
border: 1.5px solid var(--color-border, #ddd);
background: transparent;
transition: all 120ms;
text-transform: capitalize;
}
.assignee-btn:hover {
border-color: var(--nord10);
background: rgba(94, 129, 172, 0.06);
}
.assignee-btn.selected {
border-color: var(--nord10);
background: rgba(94, 129, 172, 0.12);
}
.assignee-name {
font-size: 0.82rem;
font-weight: 500;
color: var(--color-text-primary, #333);
}
/* ── Tag input (desktop) ── */
.tag-input-desktop {
position: relative;
}
.tag-input-wrapper {
position: relative;
}
.tag-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.25rem;
background: var(--color-bg-primary, white);
border: 1px solid var(--color-border, #ddd);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
max-height: 200px;
overflow-y: auto;
z-index: 100;
padding: 0.35rem;
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.tag-dropdown-item {
all: unset;
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.6rem;
font-size: 0.8rem;
border-radius: 100px;
cursor: pointer;
color: var(--color-text-secondary, #666);
background: var(--color-bg-secondary, #f0ede6);
transition: all 100ms;
}
.tag-dropdown-item:hover {
background: var(--nord10);
color: white;
transform: scale(1.03);
}
/* ── Tag pills (mobile) ── */
.tag-pills-mobile {
display: none;
flex-wrap: wrap;
gap: 0.35rem;
}
.tag-pill {
all: unset;
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.65rem;
font-size: 0.78rem;
border-radius: 100px;
cursor: pointer;
user-select: none;
transition: all 120ms;
background: var(--color-bg-secondary, #f0ede6);
color: var(--color-text-secondary, #777);
border: 1.5px solid transparent;
}
.tag-pill:hover {
background: rgba(94, 129, 172, 0.12);
color: var(--nord10);
}
.tag-pill:active {
transform: scale(0.95);
}
.tag-pill.selected {
background: var(--nord10);
color: white;
border-color: var(--nord10);
}
.tag-pill.selected:hover {
background: var(--nord9);
border-color: var(--nord9);
}
/* ── Selected tags (shown below input on desktop) ── */
.selected-tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.35rem;
}
.tag-chip {
all: unset;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.55rem;
font-size: 0.75rem;
border-radius: 100px;
cursor: pointer;
transition: all 100ms;
user-select: none;
}
.tag-chip.selected {
background: var(--nord10);
color: white;
}
.tag-chip.selected:hover {
background: var(--nord9);
transform: scale(1.03);
}
.remove-x {
font-size: 0.9rem;
font-weight: 700;
margin-left: 0.1rem;
line-height: 1;
}
/* Responsive: show pills on mobile, input on desktop */
@media (max-width: 700px) {
.tag-input-desktop { display: none; }
.tag-pills-mobile { display: flex; }
.selected-tags { display: none; }
}
@media (min-width: 701px) {
.tag-pills-mobile { display: none; }
}
/* ── Difficulty buttons ── */
.difficulty-buttons {
display: flex;
gap: 0.4rem;
}
.diff-btn {
all: unset;
flex: 1;
text-align: center;
padding: 0.4rem 0.5rem;
font-size: 0.8rem;
font-weight: 500;
border-radius: 8px;
cursor: pointer;
border: 1.5px solid var(--color-border, #ddd);
transition: all 120ms;
color: var(--color-text-secondary, #777);
}
.diff-btn:hover {
border-color: var(--nord10);
background: rgba(94, 129, 172, 0.06);
}
.diff-btn.selected {
border-color: var(--nord10);
background: rgba(94, 129, 172, 0.1);
color: var(--nord10);
font-weight: 600;
}
.diff-btn.low.selected {
border-color: var(--nord14);
background: rgba(163, 190, 140, 0.12);
color: var(--nord14);
}
.diff-btn.medium.selected {
border-color: var(--nord13);
background: rgba(235, 203, 139, 0.12);
color: #b8a038;
}
.diff-btn.high.selected {
border-color: var(--nord12);
background: rgba(208, 135, 112, 0.12);
color: var(--nord12);
}
.field-row {
margin-bottom: 0.75rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
cursor: pointer;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
.btn-cancel {
padding: 0.45rem 1rem;
border: 1px solid var(--color-border, #ddd);
background: transparent;
border-radius: 8px;
font-size: 0.82rem;
cursor: pointer;
color: var(--color-text-secondary, #888);
}
.btn-save {
padding: 0.45rem 1rem;
border: none;
background: var(--nord10);
color: white;
border-radius: 8px;
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
transition: background 150ms;
}
.btn-save:hover { background: var(--nord9); }
.btn-save:disabled { opacity: 0.6; cursor: not-allowed; }
/* Dark mode */
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .task-form {
background: var(--nord1);
border-color: var(--nord2);
}
:global(:root:not([data-theme="light"])) .field input,
:global(:root:not([data-theme="light"])) .field textarea,
:global(:root:not([data-theme="light"])) .field select {
background: var(--nord0);
border-color: var(--nord2);
}
:global(:root:not([data-theme="light"])) .tag-dropdown {
background: var(--nord0);
border-color: var(--nord2);
}
:global(:root:not([data-theme="light"])) .tag-dropdown-item {
background: var(--nord2);
color: var(--nord4);
}
:global(:root:not([data-theme="light"])) .tag-pill {
background: var(--nord2);
color: var(--nord4);
}
}
:global(:root[data-theme="dark"]) .task-form {
background: var(--nord1);
border-color: var(--nord2);
}
:global(:root[data-theme="dark"]) .field input,
:global(:root[data-theme="dark"]) .field textarea,
:global(:root[data-theme="dark"]) .field select {
background: var(--nord0);
border-color: var(--nord2);
}
:global(:root[data-theme="dark"]) .tag-dropdown {
background: var(--nord0);
border-color: var(--nord2);
}
:global(:root[data-theme="dark"]) .tag-dropdown-item {
background: var(--nord2);
color: var(--nord4);
}
:global(:root[data-theme="dark"]) .tag-pill {
background: var(--nord2);
color: var(--nord4);
}
</style>
+157
View File
@@ -0,0 +1,157 @@
export interface Sticker {
id: string;
name: string;
image: string; // path under /stickers/
description: string;
rarity: 'common' | 'uncommon' | 'rare' | 'legendary';
category: string;
}
// Blobcat sticker catalog — images from Tirifto's Blobcats (Free Art Licence) & tastytea (CC0)
export const STICKERS: Sticker[] = [
// Cleaning / household
{ id: 'clean-cat', name: 'Putzkatze', image: 'blobcat_clean.svg', description: 'Alles blitzeblank!', rarity: 'common', category: 'cleaning' },
{ id: 'sparkle-proud', name: 'Glitzerkatze', image: 'blobcat_sparkling_proud.svg', description: 'Stolz auf die Sauberkeit', rarity: 'uncommon', category: 'cleaning' },
{ id: 'hammer-cat', name: 'Handwerkerkatze', image: 'blobcat_hammer.svg', description: 'Hier wird angepackt!', rarity: 'uncommon', category: 'cleaning' },
{ id: 'in-box', name: 'Kartonkatze', image: 'blobcat_in_box.svg', description: 'Alles eingeräumt', rarity: 'rare', category: 'cleaning' },
// Plants / garden
{ id: 'rose-red', name: 'Rosenkatze', image: 'blobcat_rose_red.svg', description: 'Eine rote Rose für dich', rarity: 'common', category: 'plants' },
{ id: 'rose-pink', name: 'Blümchenkatze', image: 'blobcat_rose_pink.svg', description: 'Rosarote Blütenträume', rarity: 'common', category: 'plants' },
{ id: 'strawberry', name: 'Erdbeerkatze', image: 'blobcat_strawberry.svg', description: 'Erntezeit!', rarity: 'uncommon', category: 'plants' },
{ id: 'earth-cat', name: 'Erdkatze', image: 'blobcat_earth.svg', description: 'Die ganze Welt ist ein Garten', rarity: 'rare', category: 'plants' },
// Kitchen / cooking
{ id: 'cutlery', name: 'Besteckkatze', image: 'blobcat_cutlery.svg', description: 'Messer und Gabel bereit', rarity: 'common', category: 'kitchen' },
{ id: 'cupcake', name: 'Muffinkatze', image: 'blobcat_cupcake.svg', description: 'Süße Belohnung!', rarity: 'common', category: 'kitchen' },
{ id: 'eating-cupcake', name: 'Naschkatze', image: 'blobcat_eating_cupcake.svg', description: 'Nom nom nom', rarity: 'uncommon', category: 'kitchen' },
{ id: 'pot-cat', name: 'Topfkatze', image: 'blobcat_pot.svg', description: 'Was köchelt da?', rarity: 'uncommon', category: 'kitchen' },
{ id: 'hungry', name: 'Hungrige Katze', image: 'blobcat_hungry_cutlery.svg', description: 'Wann gibt\'s Essen?', rarity: 'rare', category: 'kitchen' },
// Errands
{ id: 'profit', name: 'Geschäftskatze', image: 'blobcat_profit.svg', description: 'Guter Deal!', rarity: 'common', category: 'errands' },
{ id: 'idea-cat', name: 'Ideenkatze', image: 'blobcat_idea.svg', description: 'Heureka!', rarity: 'uncommon', category: 'errands' },
// General / universal — positive emotions
{ id: 'happy', name: 'Zufriedene Katze', image: 'blobcat_content.svg', description: 'Alles gut!', rarity: 'common', category: 'general' },
{ id: 'heart', name: 'Herzkatze', image: 'blobcat_heart.svg', description: 'Mit Liebe gemacht', rarity: 'common', category: 'general' },
{ id: 'heart-eyes', name: 'Verliebte Katze', image: 'blobcat_heart_eyes.svg', description: 'So schön!', rarity: 'common', category: 'general' },
{ id: 'hooray', name: 'Jubelkatze', image: 'blobcat_hooray.svg', description: 'Hurra!', rarity: 'common', category: 'general' },
{ id: 'waving', name: 'Winkende Katze', image: 'blobcat_waving.svg', description: 'Hallo!', rarity: 'common', category: 'general' },
{ id: 'laughing', name: 'Lachkatze', image: 'blobcat_laughing.svg', description: 'Hahaha!', rarity: 'common', category: 'general' },
{ id: 'thumbs-up', name: 'Daumen-hoch-Katze', image: 'blobcat_thumbs_up.svg', description: 'Super gemacht!', rarity: 'uncommon', category: 'general' },
{ id: 'sparkle-eyes', name: 'Funkelkatze', image: 'blobcat_sparkle_eyes.svg', description: 'Augen wie Sterne', rarity: 'uncommon', category: 'general' },
{ id: 'finger-guns', name: 'Fingerpistolenkatze', image: 'blobcat_finger_guns.svg', description: 'Pew pew!', rarity: 'uncommon', category: 'general' },
{ id: 'proud', name: 'Stolze Katze', image: 'blobcat_proud.svg', description: 'Richtig gut!', rarity: 'uncommon', category: 'general' },
// Achievement / reward
{ id: 'crown', name: 'Königskatze', image: 'blobcat_crown.svg', description: 'Majestätisch!', rarity: 'rare', category: 'achievement' },
{ id: 'cool', name: 'Coole Katze', image: 'blobcat_cool.svg', description: 'Eiskalt erledigt', rarity: 'rare', category: 'achievement' },
{ id: 'hero', name: 'Heldenkatze', image: 'blobcat_hero.svg', description: 'Held des Tages!', rarity: 'rare', category: 'achievement' },
{ id: '10-of-10', name: 'Perfekte Katze', image: 'blobcat_sign_10_of_10.svg', description: '10 von 10!', rarity: 'rare', category: 'achievement' },
{ id: 'birthday', name: 'Geburtstagskatze', image: 'blobcat_birthday.svg', description: 'Party!', rarity: 'rare', category: 'achievement' },
// Cozy / relaxed
{ id: 'sleeping', name: 'Schlafkatze', image: 'blobcat_sleeping.svg', description: 'Zzzzz...', rarity: 'uncommon', category: 'cozy' },
{ id: 'tea', name: 'Teekatze', image: 'blobcat_drinking_tea.svg', description: 'Erstmal Tee', rarity: 'uncommon', category: 'cozy' },
{ id: 'cocoa', name: 'Kakaokatze', image: 'blobcat_drinking_cocoa.svg', description: 'Mmh, Kakao!', rarity: 'uncommon', category: 'cozy' },
{ id: 'book', name: 'Lesekatze', image: 'blobcat_book.svg', description: 'Ein gutes Buch', rarity: 'rare', category: 'cozy' },
{ id: 'blanket', name: 'Deckenkatze', image: 'blobcat_blanket.svg', description: 'Eingekuschelt', rarity: 'rare', category: 'cozy' },
{ id: 'teapot', name: 'Teekannenkatze', image: 'blobcat_teapot.svg', description: 'Tee ist fertig!', rarity: 'rare', category: 'cozy' },
// Rare / special
{ id: 'rainbow', name: 'Regenbogenkatze', image: 'blobcat_rainbow.svg', description: 'Alle Farben!', rarity: 'rare', category: 'special' },
{ id: 'angel', name: 'Engelskatze', image: 'blobcat_angel.svg', description: 'Himmlisch!', rarity: 'rare', category: 'special' },
{ id: 'scientist', name: 'Wissenschaftskatze', image: 'blobcat_scientist.svg', description: 'Für die Wissenschaft!', rarity: 'rare', category: 'special' },
// Quirky / cute (general pool — drops for any task)
{ id: 'adorable', name: 'Süße Katze', image: 'blobcat_adorable.svg', description: 'Unwiderstehlich süß!', rarity: 'uncommon', category: 'general' },
{ id: 'adoring', name: 'Bewundernde Katze', image: 'blobcat_adoring.svg', description: 'So toll!', rarity: 'common', category: 'general' },
{ id: 'joyful-surprise', name: 'Überraschte Katze', image: 'blobcat_joyful_surprise.svg', description: 'Oh! Was ist das?', rarity: 'uncommon', category: 'general' },
{ id: 'purring', name: 'Schnurrkatze', image: 'blobcat_purring.svg', description: 'Prrrrr...', rarity: 'common', category: 'general' },
{ id: 'x3', name: 'x3-Katze', image: 'blobcat_x3.svg', description: 'x3 UwU', rarity: 'rare', category: 'general' },
{ id: 'heart-tastytea', name: 'Liebevolle Katze', image: 'blobcat_heart_tastytea.svg', description: 'Ganz viel Liebe!', rarity: 'uncommon', category: 'general' },
{ id: 'blobcat-classic', name: 'Klassikatze', image: 'blobcat.svg', description: 'Die Originalkatze', rarity: 'rare', category: 'general' },
// Legendary
{ id: 'astronaut', name: 'Astronautenkatze', image: 'blobcat_astronaut.svg', description: 'Ab ins All!', rarity: 'legendary', category: 'special' },
{ id: 'space', name: 'Weltraumkatze', image: 'blobcat_space.svg', description: 'Zwischen den Sternen', rarity: 'legendary', category: 'special' },
{ id: 'robot', name: 'Roboterkatze', image: 'blobcat_robot.svg', description: 'Beep boop!', rarity: 'legendary', category: 'special' },
{ id: 'ghost', name: 'Geisterkatze', image: 'blobcat_ghost.svg', description: 'Buuuuh!', rarity: 'legendary', category: 'special' },
];
// Tag to sticker category mapping
const TAG_CATEGORY_MAP: Record<string, string[]> = {
'putzen': ['cleaning', 'general'],
'saugen': ['cleaning', 'general'],
'wischen': ['cleaning', 'general'],
'bad': ['cleaning', 'general'],
'küche': ['kitchen', 'cleaning', 'general'],
'kochen': ['kitchen', 'general'],
'abwasch': ['kitchen', 'cleaning', 'general'],
'wäsche': ['cleaning', 'general'],
'bügeln': ['cleaning', 'general'],
'pflanzen': ['plants', 'general'],
'gießen': ['plants', 'general'],
'düngen': ['plants', 'general'],
'garten': ['plants', 'general'],
'einkaufen': ['errands', 'general'],
'müll': ['cleaning', 'general'],
};
// Rarity weights per difficulty (higher = more likely)
// low: mostly common, rare/legendary very unlikely
// medium (default): balanced distribution
// high: rare and legendary much more likely
const DIFFICULTY_RARITY_WEIGHTS: Record<string, Record<string, number>> = {
low: { common: 65, uncommon: 25, rare: 8, legendary: 2 },
medium: { common: 50, uncommon: 30, rare: 15, legendary: 5 },
high: { common: 25, uncommon: 30, rare: 30, legendary: 15 },
};
export function getStickerForTags(tags: string[], difficulty?: string): Sticker {
const weights = DIFFICULTY_RARITY_WEIGHTS[difficulty || 'medium'] || DIFFICULTY_RARITY_WEIGHTS.medium;
// Determine relevant categories from tags
const categories = new Set<string>();
for (const tag of tags) {
const mapped = TAG_CATEGORY_MAP[tag.toLowerCase()];
if (mapped) {
mapped.forEach(c => categories.add(c));
}
}
// Always include general + achievement/cozy/special so all stickers can drop
categories.add('general');
categories.add('achievement');
categories.add('cozy');
categories.add('special');
// Filter stickers by matching categories
const pool = STICKERS.filter(s => categories.has(s.category));
// Weighted random selection by rarity
const totalWeight = pool.reduce((sum, s) => sum + weights[s.rarity], 0);
let random = Math.random() * totalWeight;
for (const sticker of pool) {
random -= weights[sticker.rarity];
if (random <= 0) return sticker;
}
// Fallback
return pool[Math.floor(Math.random() * pool.length)];
}
export function getStickerById(id: string): Sticker | undefined {
return STICKERS.find(s => s.id === id);
}
export function getRarityColor(rarity: string): string {
switch (rarity) {
case 'common': return 'var(--nord14)';
case 'uncommon': return 'var(--nord9)';
case 'rare': return 'var(--nord15)';
case 'legendary': return 'var(--nord13)';
default: return 'var(--nord4)';
}
}
+92
View File
@@ -0,0 +1,92 @@
import mongoose from 'mongoose';
export interface ITask {
_id?: string;
title: string;
description?: string;
assignees: string[];
tags: string[];
difficulty?: 'low' | 'medium' | 'high';
isRecurring: boolean;
frequency?: {
type: 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'custom';
customDays?: number;
};
nextDueDate: Date;
lastCompletedAt?: Date;
lastCompletedBy?: string;
createdBy: string;
active: boolean;
createdAt?: Date;
updatedAt?: Date;
}
const TaskSchema = new mongoose.Schema(
{
title: {
type: String,
required: true,
trim: true
},
description: {
type: String,
trim: true
},
assignees: [{
type: String,
trim: true
}],
tags: [{
type: String,
trim: true,
lowercase: true
}],
difficulty: {
type: String,
enum: ['low', 'medium', 'high']
},
isRecurring: {
type: Boolean,
required: true,
default: false
},
frequency: {
type: {
type: String,
enum: ['daily', 'weekly', 'biweekly', 'monthly', 'custom']
},
customDays: {
type: Number,
min: 1
}
},
nextDueDate: {
type: Date,
required: true
},
lastCompletedAt: {
type: Date
},
lastCompletedBy: {
type: String,
trim: true
},
createdBy: {
type: String,
required: true,
trim: true
},
active: {
type: Boolean,
default: true
}
},
{
timestamps: true
}
);
TaskSchema.index({ active: 1, nextDueDate: 1 });
TaskSchema.index({ tags: 1 });
export const Task = mongoose.model<ITask>('Task', TaskSchema);
+51
View File
@@ -0,0 +1,51 @@
import mongoose from 'mongoose';
export interface ITaskCompletion {
_id?: string;
taskId: mongoose.Types.ObjectId;
taskTitle: string;
completedBy: string;
completedAt: Date;
stickerId?: string;
tags: string[];
}
const TaskCompletionSchema = new mongoose.Schema(
{
taskId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Task',
required: true
},
taskTitle: {
type: String,
required: true,
trim: true
},
completedBy: {
type: String,
required: true,
trim: true
},
completedAt: {
type: Date,
required: true,
default: Date.now
},
stickerId: {
type: String,
trim: true
},
tags: [{
type: String,
trim: true,
lowercase: true
}]
}
);
TaskCompletionSchema.index({ completedBy: 1 });
TaskCompletionSchema.index({ taskId: 1 });
TaskCompletionSchema.index({ completedAt: -1 });
export const TaskCompletion = mongoose.model<ITaskCompletion>('TaskCompletion', TaskCompletionSchema);
+7 -1
View File
@@ -56,7 +56,8 @@
transmission: 'Transmission',
documents: isEnglish ? 'Documents' : 'Dokumente',
audiobooksPodcasts: isEnglish ? 'Audiobooks & Podcasts' : 'Hörbücher & Podcasts',
fitness: 'Fitness'
fitness: 'Fitness',
tasks: isEnglish ? 'Tasks' : 'Aufgaben'
});
</script>
<style>
@@ -223,6 +224,11 @@ section h2{
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M256 80C149.9 80 62.4 159.4 49.6 262c9.4-3.8 19.6-6 30.4-6c26.5 0 48 21.5 48 48l0 128c0 26.5-21.5 48-48 48c-44.2 0-80-35.8-80-80l0-16 0-48 0-48C0 146.6 114.6 32 256 32s256 114.6 256 256l0 48 0 48 0 16c0 44.2-35.8 80-80 80c-26.5 0-48-21.5-48-48l0-128c0-26.5 21.5-48 48-48c10.8 0 21 2.1 30.4 6C449.6 159.4 362.1 80 256 80z"/></svg>
<h3>{labels.audiobooksPodcasts}</h3>
</a>
<a href="/tasks">
<svg class="lock-icon"><use href="#lock-icon"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M152.1 38.2c9.9 8.9 10.7 24 1.8 33.9l-72 80c-4.4 4.9-10.6 7.8-17.2 7.9s-12.9-2.4-17.6-7L7 113c-9.3-9.4-9.3-24.6 0-34C16.3 69.5 31.5 69.5 40.7 79l21.9 22.3 53.5-59.4c8.9-9.9 24-10.7 33.9-1.8zm0 160c9.9 8.9 10.7 24 1.8 33.9l-72 80c-4.4 4.9-10.6 7.8-17.2 7.9s-12.9-2.4-17.6-7L7 273c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l22 22.3 53.5-59.4c8.9-9.9 24-10.7 33.9-1.8zM224 96c0-17.7 14.3-32 32-32H480c17.7 0 32 14.3 32 32s-14.3 32-32 32H256c-17.7 0-32-14.3-32-32zm0 160c0-17.7 14.3-32 32-32H480c17.7 0 32 14.3 32 32s-14.3 32-32 32H256c-17.7 0-32-14.3-32-32zM160 416c0-17.7 14.3-32 32-32H480c17.7 0 32 14.3 32 32s-14.3 32-32 32H192c-17.7 0-32-14.3-32-32zM48 368a48 48 0 1 1 0 96 48 48 0 1 1 0-96z"/></svg>
<h3>{labels.tasks}</h3>
</a>
</LinksGrid>
</section>
+46
View File
@@ -0,0 +1,46 @@
import type { RequestHandler } from '@sveltejs/kit';
import { Task } from '$models/Task';
import { dbConnect } from '$utils/db';
import { error, json } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
await dbConnect();
const tasks = await Task.find({ active: true })
.sort({ nextDueDate: 1 })
.lean();
return json({ tasks });
};
export const POST: RequestHandler = async ({ request, locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
const data = await request.json();
const { title, description, assignees, tags, difficulty, isRecurring, frequency, nextDueDate } = data;
if (!title?.trim()) throw error(400, 'Title is required');
if (!nextDueDate) throw error(400, 'Due date is required');
if (isRecurring && !frequency?.type) throw error(400, 'Frequency is required for recurring tasks');
await dbConnect();
const task = await Task.create({
title: title.trim(),
description: description?.trim(),
assignees: assignees || [],
tags: tags || [],
difficulty: difficulty || undefined,
isRecurring: !!isRecurring,
frequency: isRecurring ? frequency : undefined,
nextDueDate: new Date(nextDueDate),
createdBy: auth.user.nickname,
active: true
});
return json({ task }, { status: 201 });
};
+48
View File
@@ -0,0 +1,48 @@
import type { RequestHandler } from '@sveltejs/kit';
import { Task } from '$models/Task';
import { dbConnect } from '$utils/db';
import { error, json } from '@sveltejs/kit';
export const PUT: RequestHandler = async ({ params, request, locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
const data = await request.json();
const { title, description, assignees, tags, difficulty, isRecurring, frequency, nextDueDate, active } = data;
await dbConnect();
const task = await Task.findById(params.id);
if (!task) throw error(404, 'Task not found');
if (title !== undefined) task.title = title.trim();
if (description !== undefined) task.description = description?.trim();
if (assignees !== undefined) task.assignees = assignees;
if (tags !== undefined) task.tags = tags;
if (difficulty !== undefined) task.difficulty = difficulty || undefined;
if (isRecurring !== undefined) {
task.isRecurring = isRecurring;
task.frequency = isRecurring ? frequency : undefined;
}
if (nextDueDate !== undefined) task.nextDueDate = new Date(nextDueDate);
if (active !== undefined) task.active = active;
await task.save();
return json({ task });
};
export const DELETE: RequestHandler = async ({ params, locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
await dbConnect();
const task = await Task.findById(params.id);
if (!task) throw error(404, 'Task not found');
// Soft delete
task.active = false;
await task.save();
return json({ success: true });
};
@@ -0,0 +1,65 @@
import type { RequestHandler } from '@sveltejs/kit';
import { Task } from '$models/Task';
import { TaskCompletion } from '$models/TaskCompletion';
import { dbConnect } from '$utils/db';
import { error, json } from '@sveltejs/kit';
import { getStickerForTags } from '$lib/utils/stickers';
import { addDays } from 'date-fns';
function getNextDueDate(completedAt: Date, frequencyType: string, customDays?: number): Date {
switch (frequencyType) {
case 'daily': return addDays(completedAt, 1);
case 'weekly': return addDays(completedAt, 7);
case 'biweekly': return addDays(completedAt, 14);
case 'monthly': {
const next = new Date(completedAt);
next.setMonth(next.getMonth() + 1);
return next;
}
case 'custom': return addDays(completedAt, customDays || 7);
default: return addDays(completedAt, 7);
}
}
export const POST: RequestHandler = async ({ params, locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
await dbConnect();
const task = await Task.findById(params.id);
if (!task) throw error(404, 'Task not found');
if (!task.active) throw error(400, 'Task is archived');
const now = new Date();
const nickname = auth.user.nickname;
// Award a sticker based on task tags and difficulty
const sticker = getStickerForTags(task.tags, task.difficulty || 'medium');
// Record the completion
const completion = await TaskCompletion.create({
taskId: task._id,
taskTitle: task.title,
completedBy: nickname,
completedAt: now,
stickerId: sticker.id,
tags: task.tags
});
// Update task
task.lastCompletedAt = now;
task.lastCompletedBy = nickname;
if (task.isRecurring && task.frequency) {
// Reset from NOW (completion time), not from the original due date
task.nextDueDate = getNextDueDate(now, task.frequency.type, task.frequency.customDays);
} else {
// One-off task: deactivate
task.active = false;
}
await task.save();
return json({ completion, sticker, task });
};
+32
View File
@@ -0,0 +1,32 @@
import type { RequestHandler } from '@sveltejs/kit';
import { TaskCompletion } from '$models/TaskCompletion';
import { dbConnect } from '$utils/db';
import { error, json } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
await dbConnect();
// Completions per user
const userStats = await TaskCompletion.aggregate([
{ $group: { _id: '$completedBy', count: { $sum: 1 } } },
{ $sort: { count: -1 } }
]);
// Stickers per user
const userStickers = await TaskCompletion.aggregate([
{ $match: { stickerId: { $exists: true, $ne: null } } },
{ $group: { _id: { user: '$completedBy', sticker: '$stickerId' }, count: { $sum: 1 } } },
{ $sort: { '_id.user': 1, count: -1 } }
]);
// Recent completions (enough for ~3 months of calendar)
const recentCompletions = await TaskCompletion.find()
.sort({ completedAt: -1 })
.limit(500)
.lean();
return json({ userStats, userStickers, recentCompletions });
};
+7
View File
@@ -0,0 +1,7 @@
import type { LayoutServerLoad } from "./$types"
export const load: LayoutServerLoad = async ({ locals }) => {
return {
session: locals.session ?? await locals.auth()
}
};
+33
View File
@@ -0,0 +1,33 @@
<script>
import { page } from '$app/stores';
import Header from '$lib/components/Header.svelte';
import UserHeader from '$lib/components/UserHeader.svelte';
import { ClipboardList, Trophy } from 'lucide-svelte';
let { data, children } = $props();
let user = $derived(data.session?.user);
/** @param {string} path */
function isActive(path) {
const currentPath = $page.url.pathname;
if (path === '/tasks') {
return currentPath === '/tasks' || currentPath === '/tasks/';
}
return currentPath.startsWith(path);
}
</script>
<Header>
{#snippet links()}
<ul class="site_header">
<li style="--active-fill: var(--nord10)"><a href="/tasks" class:active={isActive('/tasks')}><ClipboardList size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Aufgaben</span></a></li>
<li style="--active-fill: var(--nord13)"><a href="/tasks/rewards" class:active={isActive('/tasks/rewards')}><Trophy size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Sticker</span></a></li>
</ul>
{/snippet}
{#snippet right_side()}
<UserHeader {user}></UserHeader>
{/snippet}
{@render children()}
</Header>
+18
View File
@@ -0,0 +1,18 @@
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals, fetch }) => {
const session = await locals.auth();
if (!session) throw redirect(302, '/login');
const [tasksRes, statsRes] = await Promise.all([
fetch('/api/tasks'),
fetch('/api/tasks/stats')
]);
return {
session,
tasks: (await tasksRes.json()).tasks,
stats: await statsRes.json()
};
};
+690
View File
@@ -0,0 +1,690 @@
<script>
import { invalidateAll } from '$app/navigation';
import { formatDistanceToNow, isPast, isToday, differenceInDays, format } from 'date-fns';
import { de } from 'date-fns/locale';
import { Plus, Check, Pencil, Trash2, Tag, Users, RotateCcw, Calendar,
Sparkles, Wind, Bath, UtensilsCrossed, CookingPot, WashingMachine,
Flower2, Droplets, Leaf, ShoppingCart, Shirt, Brush } from 'lucide-svelte';
import { fly, scale } from 'svelte/transition';
import { flip } from 'svelte/animate';
import TaskForm from '$lib/components/tasks/TaskForm.svelte';
import StickerPopup from '$lib/components/tasks/StickerPopup.svelte';
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
let { data } = $props();
let tasks = $state(data.tasks || []);
let stats = $state(data.stats || { userStats: [], userStickers: [], recentCompletions: [] });
let currentUser = $derived(data.session?.user?.nickname || '');
let myStat = $derived(stats.userStats.find((/** @type {any} */ s) => s._id === currentUser));
let showForm = $state(false);
/** @type {any} */
let editingTask = $state(null);
/** @type {any} */
let awardedSticker = $state(null);
let filterTag = $state('');
let filterAssignee = $state('');
// Collect all unique tags from tasks
let allTags = $derived([...new Set(tasks.flatMap((/** @type {any} */ t) => t.tags))].sort());
let allAssignees = $derived([...new Set(tasks.flatMap((/** @type {any} */ t) => t.assignees))].sort());
let filteredTasks = $derived(
tasks.filter((/** @type {any} */ t) => {
if (filterTag && !t.tags.includes(filterTag)) return false;
if (filterAssignee && !t.assignees.includes(filterAssignee)) return false;
return true;
})
);
// Sort by urgency: overdue first, then by days until due
let sortedTasks = $derived(
[...filteredTasks].sort((/** @type {any} */ a, /** @type {any} */ b) => {
return new Date(a.nextDueDate).getTime() - new Date(b.nextDueDate).getTime();
})
);
/** @param {any} task */
function getUrgencyClass(task) {
const due = new Date(task.nextDueDate);
const days = differenceInDays(due, new Date());
if (days < 0) return 'overdue';
if (days === 0) return 'due-today';
if (days <= 2) return 'due-soon';
return 'upcoming';
}
/** German weekday names */
const WEEKDAYS_DE = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
/** @param {any} task */
function getUrgencyLabel(task) {
const due = new Date(task.nextDueDate);
if (isPast(due) && !isToday(due)) {
return `Überfällig (${formatDistanceToNow(due, { locale: de, addSuffix: true })})`;
}
if (isToday(due)) return 'Heute fällig';
const days = differenceInDays(due, new Date());
if (days >= 1 && days <= 6) {
return `Fällig am ${WEEKDAYS_DE[due.getDay()]}`;
}
return `Fällig ${formatDistanceToNow(due, { locale: de, addSuffix: true })}`;
}
/** @param {string} type */
function getFrequencyLabel(type) {
const labels = /** @type {Record<string, string>} */ ({
daily: 'Täglich',
weekly: 'Wöchentlich',
biweekly: 'Alle 2 Wochen',
monthly: 'Monatlich',
custom: 'Benutzerdefiniert'
});
return labels[type] || type;
}
/** @param {any} task */
async function completeTask(task) {
const res = await fetch(`/api/tasks/${task._id}/complete`, { method: 'POST' });
if (!res.ok) return;
const result = await res.json();
// Show sticker popup
awardedSticker = result.sticker;
// Refresh data
await refreshTasks();
}
/** @param {any} task */
async function deleteTask(task) {
if (!confirm(`"${task.title}" wirklich löschen?`)) return;
const res = await fetch(`/api/tasks/${task._id}`, { method: 'DELETE' });
if (res.ok) await refreshTasks();
}
async function refreshTasks() {
const [tasksRes, statsRes] = await Promise.all([
fetch('/api/tasks'),
fetch('/api/tasks/stats')
]);
if (tasksRes.ok) tasks = (await tasksRes.json()).tasks;
if (statsRes.ok) stats = await statsRes.json();
}
async function handleTaskSaved() {
showForm = false;
editingTask = null;
await refreshTasks();
}
/** @param {any} task */
function startEdit(task) {
editingTask = task;
showForm = true;
}
/** @type {Record<string, any>} */
const TAG_ICONS = {
putzen: Sparkles, saugen: Wind, wischen: Brush, bad: Bath,
küche: UtensilsCrossed, kochen: CookingPot, abwasch: Droplets,
wäsche: WashingMachine, bügeln: Shirt,
pflanzen: Flower2, gießen: Droplets, düngen: Leaf, garten: Leaf,
einkaufen: ShoppingCart, müll: Trash2,
};
/** @param {string} nickname */
function getCompletionCount(nickname) {
const stat = stats.userStats.find((/** @type {any} */ s) => s._id === nickname);
return stat?.count || 0;
}
</script>
<div class="tasks-page">
<header class="page-header">
<div class="header-top">
<h1>Aufgaben</h1>
<button class="btn-add" onclick={() => { editingTask = null; showForm = true; }}>
<Plus size={18} /> Neue Aufgabe
</button>
</div>
{#if myStat}
<div class="scoreboard">
<div class="score-card">
<ProfilePicture username={currentUser} size={36} />
<div class="score-info">
<span class="score-count">{myStat.count} <span class="score-label">erledigt</span></span>
</div>
</div>
</div>
{/if}
<div class="filters">
{#if allTags.length > 0}
<div class="filter-group">
<Tag size={14} />
<select bind:value={filterTag}>
<option value="">Alle Tags</option>
{#each allTags as tag}
<option value={tag}>{tag}</option>
{/each}
</select>
</div>
{/if}
{#if allAssignees.length > 0}
<div class="filter-group">
<Users size={14} />
<select bind:value={filterAssignee}>
<option value="">Alle Personen</option>
{#each allAssignees as assignee}
<option value={assignee}>{assignee}</option>
{/each}
</select>
</div>
{/if}
</div>
</header>
{#if showForm}
<div class="form-overlay" transition:fly={{ y: -20, duration: 200 }}>
<TaskForm
task={editingTask}
onclosed={() => { showForm = false; editingTask = null; }}
onsaved={handleTaskSaved}
/>
</div>
{/if}
<div class="task-list">
{#each sortedTasks as task (task._id)}
<div
class="task-card {getUrgencyClass(task)}"
animate:flip={{ duration: 300 }}
transition:fly={{ y: 20, duration: 200 }}
>
<div class="card-accent"></div>
<div class="card-content">
<div class="card-top-row">
<div class="card-title-area">
<h3>{task.title}</h3>
{#if task.description}
<p class="task-description">{task.description}</p>
{/if}
</div>
{#if task.assignees?.length > 0}
<div class="card-assignee">
<ProfilePicture username={task.assignees[0]} size={36} />
{#if task.assignees.length > 1}
<div class="assignee-extra">
<ProfilePicture username={task.assignees[1]} size={22} />
</div>
{/if}
</div>
{/if}
</div>
<div class="card-due">
<Calendar size={14} />
<span>{getUrgencyLabel(task)}</span>
</div>
{#if task.isRecurring && task.frequency}
<span class="meta-badge recurring">
<RotateCcw size={13} />
{getFrequencyLabel(task.frequency.type)}
{#if task.frequency.type === 'custom' && task.frequency.customDays}
({task.frequency.customDays} Tage)
{/if}
</span>
{/if}
{#if task.tags?.length > 0}
<div class="task-tags">
{#each task.tags as tag}
<span class="tag">
{#if TAG_ICONS[tag]}
<svelte:component this={TAG_ICONS[tag]} size={14} />
{/if}
{tag}
</span>
{/each}
</div>
{/if}
<div class="card-bottom-row">
<div class="card-bottom-left">
{#if task.lastCompletedBy}
<div class="last-completed">
<ProfilePicture username={task.lastCompletedBy} size={16} />
<span>Zuletzt gemacht {formatDistanceToNow(new Date(task.lastCompletedAt), { locale: de, addSuffix: true })}</span>
</div>
{/if}
<div class="task-actions">
<button class="btn-icon" title="Bearbeiten" onclick={() => startEdit(task)}>
<Pencil size={14} />
</button>
<button class="btn-icon btn-icon-danger" title="Löschen" onclick={() => deleteTask(task)}>
<Trash2 size={14} />
</button>
</div>
</div>
<button class="btn-complete" onclick={() => completeTask(task)} title="Als erledigt markieren">
<Check size={22} strokeWidth={2.5} />
</button>
</div>
</div>
</div>
{/each}
{#if sortedTasks.length === 0}
<div class="empty-state">
<p>Keine Aufgaben gefunden.</p>
<button class="btn-add" onclick={() => { editingTask = null; showForm = true; }}>
<Plus size={18} /> Erste Aufgabe erstellen
</button>
</div>
{/if}
</div>
</div>
{#if awardedSticker}
<StickerPopup sticker={awardedSticker} onclose={() => awardedSticker = null} />
{/if}
<style>
.tasks-page {
max-width: 1200px;
margin: 0 auto;
padding: 1.5rem 1rem;
}
.page-header {
margin-bottom: 1.5rem;
}
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
h1 {
font-size: 1.6rem;
font-weight: 700;
margin: 0;
}
.btn-add {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
background: var(--nord10);
color: white;
border: none;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background 150ms;
}
.btn-add:hover { background: var(--nord9); }
/* Scoreboard */
.scoreboard {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.score-card {
display: flex;
align-items: center;
gap: 0.6rem;
background: var(--color-bg-secondary, #f0ede6);
border-radius: 12px;
padding: 0.6rem 1.2rem;
}
.score-info {
display: flex;
flex-direction: column;
}
.score-count {
font-size: 1.3rem;
font-weight: 800;
color: var(--nord10);
line-height: 1.2;
}
.score-label {
font-size: 0.65rem;
font-weight: 500;
color: var(--color-text-secondary, #999);
}
/* Filters */
.filters {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.filter-group {
display: flex;
align-items: center;
gap: 0.35rem;
color: var(--color-text-secondary, #888);
}
.filter-group select {
padding: 0.3rem 0.6rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 6px;
background: var(--color-bg-primary, white);
color: inherit;
font-size: 0.8rem;
}
/* Form overlay */
.form-overlay {
margin-bottom: 1.5rem;
max-width: 560px;
}
/* Task grid */
.task-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
/* Card structure: left accent strip + content */
.task-card {
display: flex;
background: var(--color-bg-secondary, #f0ede6);
border-radius: 12px;
overflow: hidden;
transition: box-shadow 250ms, transform 250ms;
position: relative;
}
.task-card:hover {
box-shadow: 0 6px 24px rgba(0,0,0,0.08);
transform: translateY(-2px);
}
/* Left accent strip — colored by urgency */
.card-accent {
width: 4px;
flex-shrink: 0;
background: var(--nord14);
transition: width 200ms;
}
.task-card.overdue .card-accent { background: var(--nord11); width: 5px; }
.task-card.due-today .card-accent { background: var(--nord12); }
.task-card.due-soon .card-accent { background: var(--nord13); }
.task-card.upcoming .card-accent { background: var(--nord14); }
/* Subtle urgency background tints */
.task-card.overdue { background: color-mix(in srgb, var(--nord11) 6%, var(--color-bg-secondary, #f0ede6)); }
.task-card.due-today { background: color-mix(in srgb, var(--nord12) 5%, var(--color-bg-secondary, #f0ede6)); }
.card-content {
flex: 1;
padding: 1rem 1rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
}
/* Top row: title + assignee pfp */
.card-top-row {
display: flex;
gap: 0.75rem;
align-items: flex-start;
}
.card-title-area {
flex: 1;
min-width: 0;
}
.card-title-area h3 {
margin: 0;
font-size: 1.05rem;
font-weight: 700;
line-height: 1.3;
}
.task-description {
margin: 0.2rem 0 0;
font-size: 0.82rem;
color: var(--color-text-secondary, #777);
line-height: 1.4;
}
/* Assignee PFP top-right */
.card-assignee {
position: relative;
flex-shrink: 0;
}
.assignee-extra {
position: absolute;
bottom: -4px;
right: -6px;
border: 2px solid var(--color-bg-secondary, #f0ede6);
border-radius: 50%;
}
/* Due date line */
.card-due {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-secondary, #888);
}
.task-card.overdue .card-due { color: var(--nord11); }
.task-card.due-today .card-due { color: var(--nord12); }
.task-card.due-soon .card-due { color: var(--nord13); }
.meta-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.5rem;
border-radius: 100px;
color: var(--color-text-secondary, #888);
background: var(--color-bg-secondary, #f0ede6);
font-size: 0.78rem;
width: fit-content;
}
/* Tags — significantly larger */
.task-tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.85rem;
padding: 0.2rem 0.6rem;
border-radius: 100px;
background: rgba(94, 129, 172, 0.1);
color: var(--nord10);
font-weight: 500;
}
/* Bottom row: last-completed + actions left, check button right */
.card-bottom-row {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-top: auto;
padding-top: 0.5rem;
}
.card-bottom-left {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.last-completed {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.72rem;
color: var(--color-text-secondary, #aaa);
}
.last-completed span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-actions {
display: flex;
gap: 0.15rem;
opacity: 0;
transition: opacity 150ms;
}
.task-card:hover .task-actions { opacity: 1; }
.btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--color-text-secondary, #999);
border-radius: 6px;
cursor: pointer;
transition: all 150ms;
}
.btn-icon:hover {
background: var(--color-bg-secondary, #f0ede6);
color: var(--color-text-primary, #333);
}
.btn-icon-danger:hover {
background: rgba(191, 97, 106, 0.1);
color: var(--nord11);
}
/* Round check button — neutral default, green on hover */
.btn-complete {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 50%;
border: 2px solid var(--color-border, #ddd);
background: transparent;
color: var(--color-text-secondary, #bbb);
cursor: pointer;
transition: all 200ms;
flex-shrink: 0;
}
.btn-complete:hover {
border-color: var(--nord14);
background: var(--nord14);
color: white;
box-shadow: 0 2px 10px rgba(163, 190, 140, 0.35);
transform: scale(1.08);
}
.btn-complete:active {
transform: scale(0.95);
background: #8fad7a;
border-color: #8fad7a;
color: white;
}
.empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 3rem 1rem;
color: var(--color-text-secondary, #999);
}
.empty-state p { margin-bottom: 1rem; }
.empty-state .btn-add { margin: 0 auto; }
/* Dark mode */
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .task-card {
background: var(--nord2);
}
:global(:root:not([data-theme="light"])) .task-card.overdue {
background: color-mix(in srgb, var(--nord11) 8%, var(--nord2));
}
:global(:root:not([data-theme="light"])) .task-card.due-today {
background: color-mix(in srgb, var(--nord12) 6%, var(--nord2));
}
:global(:root:not([data-theme="light"])) .score-card {
background: var(--nord1);
}
:global(:root:not([data-theme="light"])) .btn-icon:hover {
background: var(--nord2);
}
:global(:root:not([data-theme="light"])) .meta-badge {
background: var(--nord2);
}
:global(:root:not([data-theme="light"])) .filter-group select {
background: var(--nord1);
border-color: var(--nord2);
}
:global(:root:not([data-theme="light"])) .btn-complete {
border-color: var(--nord3);
color: var(--nord4);
}
:global(:root:not([data-theme="light"])) .assignee-extra {
border-color: var(--nord2);
}
:global(:root:not([data-theme="light"])) .tag {
background: rgba(94, 129, 172, 0.15);
}
}
:global(:root[data-theme="dark"]) .task-card {
background: var(--nord2);
}
:global(:root[data-theme="dark"]) .task-card.overdue {
background: color-mix(in srgb, var(--nord11) 8%, var(--nord2));
}
:global(:root[data-theme="dark"]) .task-card.due-today {
background: color-mix(in srgb, var(--nord12) 6%, var(--nord2));
}
:global(:root[data-theme="dark"]) .score-card {
background: var(--nord1);
}
:global(:root[data-theme="dark"]) .meta-badge {
background: var(--nord2);
}
:global(:root[data-theme="dark"]) .filter-group select {
background: var(--nord1);
border-color: var(--nord2);
}
:global(:root[data-theme="dark"]) .btn-complete {
border-color: var(--nord3);
color: var(--nord4);
}
:global(:root[data-theme="dark"]) .assignee-extra {
border-color: var(--nord1);
}
:global(:root[data-theme="dark"]) .tag {
background: rgba(94, 129, 172, 0.15);
}
@media (max-width: 600px) {
.tasks-page { padding: 1rem 0.75rem; }
h1 { font-size: 1.3rem; }
.task-list { grid-template-columns: 1fr; }
.task-actions { opacity: 1; }
.scoreboard { gap: 0.5rem; }
.score-card { padding: 0.5rem 1rem; min-width: 80px; }
.score-count { font-size: 1.4rem; }
.btn-complete { width: 40px; height: 40px; }
}
</style>
+14
View File
@@ -0,0 +1,14 @@
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals, fetch }) => {
const session = await locals.auth();
if (!session) throw redirect(302, '/login');
const statsRes = await fetch('/api/tasks/stats');
return {
session,
stats: await statsRes.json()
};
};
+378
View File
@@ -0,0 +1,378 @@
<script>
import { STICKERS, getStickerById, getRarityColor } from '$lib/utils/stickers';
import { formatDistanceToNow } from 'date-fns';
import { de } from 'date-fns/locale';
import { scale } from 'svelte/transition';
import { flip } from 'svelte/animate';
import StickerCalendar from '$lib/components/tasks/StickerCalendar.svelte';
let { data } = $props();
let stats = $state(data.stats || { userStats: [], userStickers: [], recentCompletions: [] });
let currentUser = $derived(data.session?.user?.nickname || '');
const rarityLabels = /** @type {Record<string, string>} */ ({
common: 'Gewöhnlich',
uncommon: 'Ungewöhnlich',
rare: 'Selten',
legendary: 'Legendär'
});
const rarityOrder = /** @type {Record<string, number>} */ ({
legendary: 0,
rare: 1,
uncommon: 2,
common: 3
});
let debugShowAll = $state(false);
// Build current user's sticker collection
let displayedStickers = $derived.by(() => {
if (debugShowAll) {
/** @type {Map<string, number>} */
const all = new Map();
for (let i = 0; i < STICKERS.length; i++) all.set(STICKERS[i].id, (i % 5) + 1);
return all;
}
/** @type {Map<string, number>} */
const collection = new Map();
for (const entry of stats.userStickers) {
if (entry._id.user === currentUser) {
collection.set(entry._id.sticker, entry.count);
}
}
return collection;
});
// Sort stickers for display: owned first (by rarity), then unowned
let sortedStickers = $derived.by(() => {
return [...STICKERS].sort((a, b) => {
const aOwned = displayedStickers.has(a.id);
const bOwned = displayedStickers.has(b.id);
if (aOwned && !bOwned) return -1;
if (!aOwned && bOwned) return 1;
const rarityDiff = (rarityOrder[a.rarity] ?? 3) - (rarityOrder[b.rarity] ?? 3);
if (rarityDiff !== 0) return rarityDiff;
return a.name.localeCompare(b.name, 'de');
});
});
let collectedCount = $derived(displayedStickers.size);
let totalCount = STICKERS.length;
// Recent completions with stickers
let recentWithStickers = $derived(
stats.recentCompletions
.filter((/** @type {any} */ c) => c.stickerId)
.filter((/** @type {any} */ c) => !currentUser || c.completedBy === currentUser)
.slice(0, 20)
);
</script>
<div class="rewards-page">
<header class="page-header">
<h1>Sticker-Sammlung</h1>
<p class="subtitle">{collectedCount} / {totalCount} gesammelt</p>
<div class="progress-bar">
<div class="progress-fill" style="width: {(collectedCount / totalCount) * 100}%"></div>
</div>
<button class="debug-btn" onclick={() => debugShowAll = !debugShowAll}>
{debugShowAll ? '🐛 Debug an' : '🐛 Debug'}
</button>
</header>
<StickerCalendar completions={stats.recentCompletions} {currentUser} />
<h2 class="section-title">Alle Sticker</h2>
<div class="sticker-grid">
{#each sortedStickers as sticker (sticker.id)}
{@const count = displayedStickers.get(sticker.id) || 0}
{@const owned = count > 0}
<div
class="sticker-card"
class:owned
class:locked={!owned}
animate:flip={{ duration: 300 }}
style="--rarity-color: {getRarityColor(sticker.rarity)}"
>
<div class="sticker-visual">
{#if owned}
<img class="sticker-img" src="/stickers/{sticker.image}" alt={sticker.name} />
{:else}
<span class="sticker-unknown">?</span>
{/if}
{#if count > 1}
<span class="sticker-count">x{count}</span>
{/if}
</div>
<div class="sticker-info">
<span class="sticker-name">{owned ? sticker.name : '???'}</span>
<span class="sticker-rarity" style="color: {getRarityColor(sticker.rarity)}">
{rarityLabels[sticker.rarity]}
</span>
{#if owned}
<span class="sticker-desc">{sticker.description}</span>
{/if}
</div>
</div>
{/each}
</div>
{#if recentWithStickers.length > 0}
<section class="recent-section">
<h2>Letzte Sticker</h2>
<div class="recent-list">
{#each recentWithStickers as completion}
{@const sticker = getStickerById(completion.stickerId)}
{#if sticker}
<div class="recent-item">
<img class="recent-img" src="/stickers/{sticker.image}" alt={sticker.name} />
<div class="recent-info">
<span class="recent-task">{completion.taskTitle}</span>
<span class="recent-meta">
{completion.completedBy} &middot; {formatDistanceToNow(new Date(completion.completedAt), { locale: de, addSuffix: true })}
</span>
</div>
</div>
{/if}
{/each}
</div>
</section>
{/if}
</div>
<style>
.rewards-page {
max-width: 900px;
margin: 0 auto;
padding: 1.5rem 1rem;
}
.page-header {
text-align: center;
margin-bottom: 2rem;
}
h1 {
font-size: 1.6rem;
font-weight: 700;
margin: 0;
}
.subtitle {
margin: 0.25rem 0 0.75rem;
color: var(--color-text-secondary, #888);
font-size: 0.9rem;
}
.progress-bar {
width: 100%;
max-width: 400px;
height: 8px;
background: var(--color-bg-secondary, #e8e4dd);
border-radius: 100px;
margin: 0 auto 1rem;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--nord14), var(--nord13));
border-radius: 100px;
transition: width 500ms ease;
}
.section-title {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.75rem;
}
/* Sticker grid */
.sticker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem;
}
.sticker-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem 0.5rem;
border-radius: 14px;
border: 1px solid var(--color-border, #e8e4dd);
background: var(--color-bg-primary, white);
transition: transform 150ms, box-shadow 150ms;
}
.sticker-card.owned {
border-color: var(--rarity-color);
border-width: 1.5px;
}
.sticker-card.owned:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
}
.sticker-card.locked {
opacity: 0.4;
filter: grayscale(0.8);
}
.sticker-visual {
position: relative;
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.4rem;
}
.owned .sticker-visual {
background: radial-gradient(circle, color-mix(in srgb, var(--rarity-color) 20%, transparent) 0%, transparent 70%);
border-radius: 50%;
}
.sticker-img {
width: 52px;
height: 52px;
object-fit: contain;
}
.sticker-unknown {
font-size: 1.6rem;
font-weight: 700;
color: var(--color-text-secondary, #ccc);
opacity: 0.4;
}
.sticker-count {
position: absolute;
bottom: -2px;
right: -2px;
background: var(--nord10);
color: white;
font-size: 0.65rem;
font-weight: 700;
padding: 0.1rem 0.35rem;
border-radius: 100px;
line-height: 1.2;
}
.sticker-info {
text-align: center;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.sticker-name {
font-size: 0.78rem;
font-weight: 600;
}
.sticker-rarity {
font-size: 0.62rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.sticker-desc {
font-size: 0.68rem;
color: var(--color-text-secondary, #999);
}
/* Recent section */
.recent-section {
margin-top: 2.5rem;
}
.recent-section h2 {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.75rem;
}
.recent-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.recent-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.75rem;
background: var(--color-bg-primary, white);
border: 1px solid var(--color-border, #e8e4dd);
border-radius: 10px;
}
.recent-img {
width: 36px;
height: 36px;
object-fit: contain;
}
.recent-info {
display: flex;
flex-direction: column;
}
.recent-task {
font-size: 0.82rem;
font-weight: 500;
}
.recent-meta {
font-size: 0.7rem;
color: var(--color-text-secondary, #aaa);
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .sticker-card {
background: var(--nord1);
border-color: var(--nord2);
}
:global(:root:not([data-theme="light"])) .sticker-card.owned {
border-color: var(--rarity-color);
}
:global(:root:not([data-theme="light"])) .recent-item {
background: var(--nord1);
border-color: var(--nord2);
}
:global(:root:not([data-theme="light"])) .progress-bar {
background: var(--nord2);
}
}
:global(:root[data-theme="dark"]) .sticker-card {
background: var(--nord1);
border-color: var(--nord2);
}
:global(:root[data-theme="dark"]) .sticker-card.owned {
border-color: var(--rarity-color);
}
:global(:root[data-theme="dark"]) .recent-item {
background: var(--nord1);
border-color: var(--nord2);
}
:global(:root[data-theme="dark"]) .progress-bar {
background: var(--nord2);
}
.debug-btn {
position: fixed;
bottom: 1rem;
right: 1rem;
padding: 0.35rem 0.7rem;
font-size: 0.72rem;
background: var(--nord1);
color: var(--nord4);
border: 1px solid var(--nord2);
border-radius: 6px;
cursor: pointer;
opacity: 0.5;
z-index: 50;
}
.debug-btn:hover { opacity: 1; }
@media (max-width: 600px) {
.sticker-grid {
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 0.5rem;
}
.sticker-card { padding: 0.7rem 0.3rem; }
h1 { font-size: 1.3rem; }
}
</style>