diff --git a/src/routes/api/tasks/[id]/complete/+server.ts b/src/routes/api/tasks/[id]/complete/+server.ts index 098fc49..9d90967 100644 --- a/src/routes/api/tasks/[id]/complete/+server.ts +++ b/src/routes/api/tasks/[id]/complete/+server.ts @@ -21,7 +21,7 @@ function getNextDueDate(completedAt: Date, frequencyType: string, customDays?: n } } -export const POST: RequestHandler = async ({ params, locals }) => { +export const POST: RequestHandler = async ({ params, request, locals }) => { const auth = await locals.auth(); if (!auth?.user?.nickname) throw error(401, 'Not logged in'); @@ -31,8 +31,11 @@ export const POST: RequestHandler = async ({ params, locals }) => { if (!task) throw error(404, 'Task not found'); if (!task.active) throw error(400, 'Task is archived'); + // Allow completing on behalf of another user + const body = await request.json().catch(() => ({})); + const completedFor = body.completedFor || auth.user.nickname; + 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'); @@ -41,7 +44,7 @@ export const POST: RequestHandler = async ({ params, locals }) => { const completion = await TaskCompletion.create({ taskId: task._id, taskTitle: task.title, - completedBy: nickname, + completedBy: completedFor, completedAt: now, stickerId: sticker.id, tags: task.tags @@ -49,7 +52,7 @@ export const POST: RequestHandler = async ({ params, locals }) => { // Update task task.lastCompletedAt = now; - task.lastCompletedBy = nickname; + task.lastCompletedBy = completedFor; if (task.isRecurring && task.frequency) { // Reset from NOW (completion time), not from the original due date diff --git a/src/routes/tasks/+page.svelte b/src/routes/tasks/+page.svelte index ca114e2..18e22a4 100644 --- a/src/routes/tasks/+page.svelte +++ b/src/routes/tasks/+page.svelte @@ -25,6 +25,12 @@ let filterTag = $state(''); let filterAssignee = $state(''); + const USERS = ['anna', 'alexander']; + /** @type {string | null} */ + let completeForTaskId = $state(null); + /** @type {ReturnType | null} */ + let longPressTimer = $state(null); + // 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()); @@ -83,19 +89,39 @@ return labels[type] || type; } - /** @param {any} task */ - async function completeTask(task) { - const res = await fetch(`/api/tasks/${task._id}/complete`, { method: 'POST' }); + /** + * @param {any} task + * @param {string} [forUser] + */ + async function completeTask(task, forUser) { + const res = await fetch(`/api/tasks/${task._id}/complete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(forUser ? { completedFor: forUser } : {}) + }); if (!res.ok) return; const result = await res.json(); - // Show sticker popup awardedSticker = result.sticker; - - // Refresh data + completeForTaskId = null; await refreshTasks(); } + /** @param {any} task */ + function startLongPress(task) { + longPressTimer = setTimeout(() => { + completeForTaskId = task._id; + longPressTimer = null; + }, 500); + } + + function cancelLongPress() { + if (longPressTimer) { + clearTimeout(longPressTimer); + longPressTimer = null; + } + } + /** @param {any} task */ async function deleteTask(task) { if (!confirm(`"${task.title}" wirklich löschen?`)) return; @@ -269,9 +295,30 @@ - +
+ {#if completeForTaskId === task._id} +
+ Erledigt für: + {#each USERS as user} + + {/each} + +
+ {/if} + +
@@ -574,6 +621,68 @@ color: var(--nord11); } + /* Complete button wrapper with popover */ + .complete-wrapper { + position: relative; + flex-shrink: 0; + } + + .complete-for-popover { + position: absolute; + bottom: calc(100% + 0.5rem); + right: 0; + background: var(--color-bg-primary, white); + border: 1px solid var(--color-border, #ddd); + border-radius: 10px; + padding: 0.5rem; + box-shadow: 0 4px 20px rgba(0,0,0,0.12); + display: flex; + flex-direction: column; + gap: 0.3rem; + z-index: 20; + min-width: 140px; + } + .popover-label { + font-size: 0.7rem; + font-weight: 600; + color: var(--color-text-secondary, #888); + padding: 0 0.25rem 0.15rem; + } + .popover-user { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.5rem; + border: none; + background: transparent; + border-radius: 8px; + cursor: pointer; + font-size: 0.82rem; + font-weight: 500; + text-transform: capitalize; + color: var(--color-text-primary, #333); + transition: background 120ms; + } + .popover-user:hover { + background: var(--color-bg-secondary, #f0ede6); + } + .popover-close { + position: absolute; + top: 0.25rem; + right: 0.35rem; + border: none; + background: transparent; + color: var(--color-text-secondary, #aaa); + font-size: 1.1rem; + line-height: 1; + cursor: pointer; + padding: 0.1rem 0.25rem; + border-radius: 4px; + } + .popover-close:hover { + color: var(--color-text-primary, #333); + } + /* Round check button — neutral default, green on hover */ .btn-complete { display: flex; @@ -640,6 +749,13 @@ border-color: var(--nord3); color: var(--nord4); } + :global(:root:not([data-theme="light"])) .complete-for-popover { + background: var(--nord1); + border-color: var(--nord3); + } + :global(:root:not([data-theme="light"])) .popover-user:hover { + background: var(--nord2); + } :global(:root:not([data-theme="light"])) .assignee-extra { border-color: var(--nord2); } @@ -670,6 +786,13 @@ border-color: var(--nord3); color: var(--nord4); } + :global(:root[data-theme="dark"]) .complete-for-popover { + background: var(--nord1); + border-color: var(--nord3); + } + :global(:root[data-theme="dark"]) .popover-user:hover { + background: var(--nord2); + } :global(:root[data-theme="dark"]) .assignee-extra { border-color: var(--nord1); }