tasks: complete tasks on behalf of another user via long-press
All checks were successful
CI / update (push) Successful in 2m22s

Long-press the check button to open a popover with user selection.
Normal click still completes for yourself.
This commit is contained in:
2026-04-02 08:13:47 +02:00
parent 4f17ad56fa
commit 7ebebe5d11
2 changed files with 139 additions and 13 deletions

View File

@@ -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

View File

@@ -25,6 +25,12 @@
let filterTag = $state('');
let filterAssignee = $state('');
const USERS = ['anna', 'alexander'];
/** @type {string | null} */
let completeForTaskId = $state(null);
/** @type {ReturnType<typeof setTimeout> | 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 @@
</button>
</div>
</div>
<button class="btn-complete" onclick={() => completeTask(task)} title="Als erledigt markieren">
<Check size={22} strokeWidth={2.5} />
</button>
<div class="complete-wrapper">
{#if completeForTaskId === task._id}
<div class="complete-for-popover" transition:scale={{ duration: 150, start: 0.9 }}>
<span class="popover-label">Erledigt für:</span>
{#each USERS as user}
<button class="popover-user" onclick={() => completeTask(task, user)}>
<ProfilePicture username={user} size={28} />
<span>{user}</span>
</button>
{/each}
<button class="popover-close" onclick={() => completeForTaskId = null}>&times;</button>
</div>
{/if}
<button
class="btn-complete"
onclick={() => { cancelLongPress(); if (!completeForTaskId) completeTask(task); }}
onpointerdown={() => startLongPress(task)}
onpointerup={cancelLongPress}
onpointerleave={cancelLongPress}
title="Klick = selbst erledigt, gedrückt halten = für andere"
>
<Check size={22} strokeWidth={2.5} />
</button>
</div>
</div>
</div>
</div>
@@ -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);
}