tasks: complete tasks on behalf of another user via long-press
All checks were successful
CI / update (push) Successful in 2m22s
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:
@@ -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
|
||||
|
||||
@@ -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}>×</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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user