tasks: complete tasks on behalf of another user via long-press
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();
|
const auth = await locals.auth();
|
||||||
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
|
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) throw error(404, 'Task not found');
|
||||||
if (!task.active) throw error(400, 'Task is archived');
|
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 now = new Date();
|
||||||
const nickname = auth.user.nickname;
|
|
||||||
|
|
||||||
// Award a sticker based on task tags and difficulty
|
// Award a sticker based on task tags and difficulty
|
||||||
const sticker = getStickerForTags(task.tags, task.difficulty || 'medium');
|
const sticker = getStickerForTags(task.tags, task.difficulty || 'medium');
|
||||||
@@ -41,7 +44,7 @@ export const POST: RequestHandler = async ({ params, locals }) => {
|
|||||||
const completion = await TaskCompletion.create({
|
const completion = await TaskCompletion.create({
|
||||||
taskId: task._id,
|
taskId: task._id,
|
||||||
taskTitle: task.title,
|
taskTitle: task.title,
|
||||||
completedBy: nickname,
|
completedBy: completedFor,
|
||||||
completedAt: now,
|
completedAt: now,
|
||||||
stickerId: sticker.id,
|
stickerId: sticker.id,
|
||||||
tags: task.tags
|
tags: task.tags
|
||||||
@@ -49,7 +52,7 @@ export const POST: RequestHandler = async ({ params, locals }) => {
|
|||||||
|
|
||||||
// Update task
|
// Update task
|
||||||
task.lastCompletedAt = now;
|
task.lastCompletedAt = now;
|
||||||
task.lastCompletedBy = nickname;
|
task.lastCompletedBy = completedFor;
|
||||||
|
|
||||||
if (task.isRecurring && task.frequency) {
|
if (task.isRecurring && task.frequency) {
|
||||||
// Reset from NOW (completion time), not from the original due date
|
// Reset from NOW (completion time), not from the original due date
|
||||||
|
|||||||
@@ -25,6 +25,12 @@
|
|||||||
let filterTag = $state('');
|
let filterTag = $state('');
|
||||||
let filterAssignee = $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
|
// Collect all unique tags from tasks
|
||||||
let allTags = $derived([...new Set(tasks.flatMap((/** @type {any} */ t) => t.tags))].sort());
|
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 allAssignees = $derived([...new Set(tasks.flatMap((/** @type {any} */ t) => t.assignees))].sort());
|
||||||
@@ -83,19 +89,39 @@
|
|||||||
return labels[type] || type;
|
return labels[type] || type;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {any} task */
|
/**
|
||||||
async function completeTask(task) {
|
* @param {any} task
|
||||||
const res = await fetch(`/api/tasks/${task._id}/complete`, { method: 'POST' });
|
* @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;
|
if (!res.ok) return;
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
|
|
||||||
// Show sticker popup
|
|
||||||
awardedSticker = result.sticker;
|
awardedSticker = result.sticker;
|
||||||
|
completeForTaskId = null;
|
||||||
// Refresh data
|
|
||||||
await refreshTasks();
|
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 */
|
/** @param {any} task */
|
||||||
async function deleteTask(task) {
|
async function deleteTask(task) {
|
||||||
if (!confirm(`"${task.title}" wirklich löschen?`)) return;
|
if (!confirm(`"${task.title}" wirklich löschen?`)) return;
|
||||||
@@ -269,9 +295,30 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-complete" onclick={() => completeTask(task)} title="Als erledigt markieren">
|
<div class="complete-wrapper">
|
||||||
<Check size={22} strokeWidth={2.5} />
|
{#if completeForTaskId === task._id}
|
||||||
</button>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -574,6 +621,68 @@
|
|||||||
color: var(--nord11);
|
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 */
|
/* Round check button — neutral default, green on hover */
|
||||||
.btn-complete {
|
.btn-complete {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -640,6 +749,13 @@
|
|||||||
border-color: var(--nord3);
|
border-color: var(--nord3);
|
||||||
color: var(--nord4);
|
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 {
|
:global(:root:not([data-theme="light"])) .assignee-extra {
|
||||||
border-color: var(--nord2);
|
border-color: var(--nord2);
|
||||||
}
|
}
|
||||||
@@ -670,6 +786,13 @@
|
|||||||
border-color: var(--nord3);
|
border-color: var(--nord3);
|
||||||
color: var(--nord4);
|
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 {
|
:global(:root[data-theme="dark"]) .assignee-extra {
|
||||||
border-color: var(--nord1);
|
border-color: var(--nord1);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user