tasks: shared task board with sticker rewards, difficulty levels, and calendar
CI / update (push) Has been cancelled

Complete household task management system behind task_users auth group:
- Task CRUD with recurring schedules, assignees, tags, and optional difficulty
- Blobcat SVG sticker rewards on completion, rarity weighted by difficulty
- Sticker collection page with calendar view and progress tracking
- Redesigned cards with left accent urgency strip, assignee PFP, round check button
- Weekday-based due date labels for tasks within 7 days
- Tasks link added to homepage LinksGrid
This commit is contained in:
2026-04-02 07:32:53 +02:00
parent 3cafe8955a
commit 9027dd9881
69 changed files with 11158 additions and 1 deletions
+46
View File
@@ -0,0 +1,46 @@
import type { RequestHandler } from '@sveltejs/kit';
import { Task } from '$models/Task';
import { dbConnect } from '$utils/db';
import { error, json } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
await dbConnect();
const tasks = await Task.find({ active: true })
.sort({ nextDueDate: 1 })
.lean();
return json({ tasks });
};
export const POST: RequestHandler = async ({ request, locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
const data = await request.json();
const { title, description, assignees, tags, difficulty, isRecurring, frequency, nextDueDate } = data;
if (!title?.trim()) throw error(400, 'Title is required');
if (!nextDueDate) throw error(400, 'Due date is required');
if (isRecurring && !frequency?.type) throw error(400, 'Frequency is required for recurring tasks');
await dbConnect();
const task = await Task.create({
title: title.trim(),
description: description?.trim(),
assignees: assignees || [],
tags: tags || [],
difficulty: difficulty || undefined,
isRecurring: !!isRecurring,
frequency: isRecurring ? frequency : undefined,
nextDueDate: new Date(nextDueDate),
createdBy: auth.user.nickname,
active: true
});
return json({ task }, { status: 201 });
};
+48
View File
@@ -0,0 +1,48 @@
import type { RequestHandler } from '@sveltejs/kit';
import { Task } from '$models/Task';
import { dbConnect } from '$utils/db';
import { error, json } from '@sveltejs/kit';
export const PUT: RequestHandler = async ({ params, request, locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
const data = await request.json();
const { title, description, assignees, tags, difficulty, isRecurring, frequency, nextDueDate, active } = data;
await dbConnect();
const task = await Task.findById(params.id);
if (!task) throw error(404, 'Task not found');
if (title !== undefined) task.title = title.trim();
if (description !== undefined) task.description = description?.trim();
if (assignees !== undefined) task.assignees = assignees;
if (tags !== undefined) task.tags = tags;
if (difficulty !== undefined) task.difficulty = difficulty || undefined;
if (isRecurring !== undefined) {
task.isRecurring = isRecurring;
task.frequency = isRecurring ? frequency : undefined;
}
if (nextDueDate !== undefined) task.nextDueDate = new Date(nextDueDate);
if (active !== undefined) task.active = active;
await task.save();
return json({ task });
};
export const DELETE: RequestHandler = async ({ params, locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
await dbConnect();
const task = await Task.findById(params.id);
if (!task) throw error(404, 'Task not found');
// Soft delete
task.active = false;
await task.save();
return json({ success: true });
};
@@ -0,0 +1,65 @@
import type { RequestHandler } from '@sveltejs/kit';
import { Task } from '$models/Task';
import { TaskCompletion } from '$models/TaskCompletion';
import { dbConnect } from '$utils/db';
import { error, json } from '@sveltejs/kit';
import { getStickerForTags } from '$lib/utils/stickers';
import { addDays } from 'date-fns';
function getNextDueDate(completedAt: Date, frequencyType: string, customDays?: number): Date {
switch (frequencyType) {
case 'daily': return addDays(completedAt, 1);
case 'weekly': return addDays(completedAt, 7);
case 'biweekly': return addDays(completedAt, 14);
case 'monthly': {
const next = new Date(completedAt);
next.setMonth(next.getMonth() + 1);
return next;
}
case 'custom': return addDays(completedAt, customDays || 7);
default: return addDays(completedAt, 7);
}
}
export const POST: RequestHandler = async ({ params, locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
await dbConnect();
const task = await Task.findById(params.id);
if (!task) throw error(404, 'Task not found');
if (!task.active) throw error(400, 'Task is archived');
const now = new Date();
const nickname = auth.user.nickname;
// Award a sticker based on task tags and difficulty
const sticker = getStickerForTags(task.tags, task.difficulty || 'medium');
// Record the completion
const completion = await TaskCompletion.create({
taskId: task._id,
taskTitle: task.title,
completedBy: nickname,
completedAt: now,
stickerId: sticker.id,
tags: task.tags
});
// Update task
task.lastCompletedAt = now;
task.lastCompletedBy = nickname;
if (task.isRecurring && task.frequency) {
// Reset from NOW (completion time), not from the original due date
task.nextDueDate = getNextDueDate(now, task.frequency.type, task.frequency.customDays);
} else {
// One-off task: deactivate
task.active = false;
}
await task.save();
return json({ completion, sticker, task });
};
+32
View File
@@ -0,0 +1,32 @@
import type { RequestHandler } from '@sveltejs/kit';
import { TaskCompletion } from '$models/TaskCompletion';
import { dbConnect } from '$utils/db';
import { error, json } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
await dbConnect();
// Completions per user
const userStats = await TaskCompletion.aggregate([
{ $group: { _id: '$completedBy', count: { $sum: 1 } } },
{ $sort: { count: -1 } }
]);
// Stickers per user
const userStickers = await TaskCompletion.aggregate([
{ $match: { stickerId: { $exists: true, $ne: null } } },
{ $group: { _id: { user: '$completedBy', sticker: '$stickerId' }, count: { $sum: 1 } } },
{ $sort: { '_id.user': 1, count: -1 } }
]);
// Recent completions (enough for ~3 months of calendar)
const recentCompletions = await TaskCompletion.find()
.sort({ completedAt: -1 })
.limit(500)
.lean();
return json({ userStats, userStickers, recentCompletions });
};