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:
@@ -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>
|
||||
@@ -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">×</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>
|
||||
@@ -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)';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { LayoutServerLoad } from "./$types"
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
session: locals.session ?? await locals.auth()
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
};
|
||||
};
|
||||
@@ -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} · {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>
|
||||
Reference in New Issue
Block a user