Files
Calendink/Provider/frontend/src/lib/TaskManager.svelte
T

387 lines
15 KiB
Svelte

<script>
import { getTasks, addTask, updateTask, deleteTask } from './api.js';
import { formatRelativeDate, isOverdue } from './utils.js';
import UserManager from './UserManager.svelte';
let selectedUserId = $state(null);
let tasks = $state([]);
let error = $state('');
// Period and day-of-week labels
const PERIODS = ['Morning', 'Afternoon', 'Evening'];
const PERIOD_ICONS = ['🌅', '☀️', '🌙'];
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
// Shared bitmask helpers (used for both period and recurrence)
function toggleBit(current, idx) {
return current ^ (1 << idx);
}
function hasBit(mask, idx) {
return (mask & (1 << idx)) !== 0;
}
function formatPeriod(mask) {
if (mask === 0x07) return 'All Day';
const names = [];
for (let i = 0; i < 3; i++) {
if (mask & (1 << i)) names.push(PERIODS[i]);
}
return names.length > 0 ? names.join(', ') : 'None';
}
function periodIcons(mask) {
const icons = [];
for (let i = 0; i < 3; i++) {
if (mask & (1 << i)) icons.push(PERIOD_ICONS[i]);
}
return icons.join('');
}
function formatRecurrence(mask) {
if (mask === 0x7F) return 'Every day';
if (mask === 0x1F) return 'Weekdays';
if (mask === 0x60) return 'Weekends';
const names = [];
for (let i = 0; i < 7; i++) {
if (mask & (1 << i)) names.push(DAYS[i]);
}
return names.join(', ');
}
// Add task form state
let newTitle = $state('');
let newPeriod = $state(0x01);
let newRecurrence = $state(0);
let newDueDay = $state('');
let showAddForm = $state(false);
// Edit state
let editingTaskId = $state(null);
let editTitle = $state('');
let editPeriod = $state(0);
let editRecurrence = $state(0);
let editDueDay = $state('');
// Confirm delete
let confirmDeleteId = $state(null);
async function fetchTasks() {
if (!selectedUserId) {
tasks = [];
return;
}
try {
tasks = await getTasks(selectedUserId);
// Sort: recurrent first (by day), then one-off by due_date
tasks.sort((a, b) => {
if (a.recurrence && !b.recurrence) return -1;
if (!a.recurrence && b.recurrence) return 1;
if (a.recurrence && b.recurrence) return a.recurrence - b.recurrence;
return a.due_date - b.due_date;
});
error = '';
} catch (e) {
error = e.message;
}
}
// Refetch when selected user changes
$effect(() => {
if (selectedUserId) {
fetchTasks();
} else {
tasks = [];
}
});
async function handleAddTask(e) {
e.preventDefault();
if (!newTitle.trim()) return;
if (newRecurrence === 0 && !newDueDay) return;
const dueTimestamp = newRecurrence > 0
? 0
: Math.floor(new Date(`${newDueDay}T00:00`).getTime() / 1000);
try {
await addTask(selectedUserId, newTitle.trim(), dueTimestamp, newPeriod, newRecurrence);
newTitle = '';
newPeriod = 0x01;
newRecurrence = 0;
newDueDay = '';
showAddForm = false;
await fetchTasks();
} catch (e) {
error = e.message;
}
}
async function handleToggleComplete(task) {
try {
await updateTask(task.id, { completed: !task.completed });
await fetchTasks();
} catch (e) {
error = e.message;
}
}
function startEditing(task) {
editingTaskId = task.id;
editTitle = task.title;
editPeriod = task.period;
editRecurrence = task.recurrence;
if (task.recurrence === 0 && task.due_date) {
const d = new Date(task.due_date * 1000);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const dayNum = String(d.getDate()).padStart(2, '0');
editDueDay = `${year}-${month}-${dayNum}`;
} else {
editDueDay = '';
}
}
async function saveEdit(e) {
e.preventDefault();
if (!editTitle.trim()) return;
if (editRecurrence === 0 && !editDueDay) return;
const dueTimestamp = editRecurrence > 0
? 0
: Math.floor(new Date(`${editDueDay}T00:00`).getTime() / 1000);
try {
await updateTask(editingTaskId, {
title: editTitle.trim(),
due_date: dueTimestamp,
period: editPeriod,
recurrence: editRecurrence
});
editingTaskId = null;
await fetchTasks();
} catch (e) {
error = e.message;
}
}
async function handleDelete(id) {
try {
await deleteTask(id);
confirmDeleteId = null;
await fetchTasks();
} catch (e) {
error = e.message;
}
}
function setTodayForNewTask() {
const d = new Date();
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const dayNum = String(d.getDate()).padStart(2, '0');
newDueDay = `${year}-${month}-${dayNum}`;
}
function taskIsOverdue(task) {
if (task.recurrence > 0) return false;
return isOverdue(task.due_date);
}
</script>
<div class="w-full">
<UserManager bind:selectedUserId onUsersChanged={fetchTasks} />
{#if selectedUserId}
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-semibold uppercase tracking-[0.05em] text-text-primary">Tasks</h2>
{#if !showAddForm}
<button
class="px-3.5 py-1.5 rounded-lg border border-accent bg-accent/10 text-accent cursor-pointer text-xs font-semibold transition-all hover:bg-accent/20"
onclick={() => { showAddForm = true; setTodayForNewTask(); }}
>+ Add Task</button>
{/if}
</div>
{#if showAddForm}
<form class="flex flex-col gap-2 p-3 rounded-[10px] border border-border bg-bg-card mb-3" onsubmit={handleAddTask}>
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
bind:value={newTitle}
placeholder="Task title..."
class="px-3 py-2 rounded-lg border border-border bg-bg-primary text-text-primary text-[13px] outline-none w-full box-border focus:border-accent"
autofocus
/>
<div class="flex gap-2">
<div class="flex flex-col gap-1 flex-1">
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Period</span>
<div class="flex gap-1">
{#each PERIODS as p, i}
<button
type="button"
class="flex-1 px-2 py-1.5 rounded-md border cursor-pointer text-[11px] transition-all whitespace-nowrap
{hasBit(newPeriod, i)
? 'border-accent bg-accent/15 text-accent font-semibold'
: 'border-border bg-bg-primary text-text-secondary hover:bg-bg-card-hover'}"
onclick={() => newPeriod = toggleBit(newPeriod, i)}
>{PERIOD_ICONS[i]} {p}</button>
{/each}
</div>
</div>
</div>
<div class="flex gap-2">
<div class="flex flex-col gap-1 flex-1">
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Recurrence</span>
<div class="flex gap-[3px] flex-wrap">
{#each DAYS as day, i}
<button
type="button"
class="px-2 py-[5px] rounded-md border cursor-pointer text-[11px] transition-all min-w-8 text-center
{hasBit(newRecurrence, i)
? 'border-accent bg-accent/15 text-accent font-semibold'
: 'border-border bg-bg-primary text-text-secondary hover:bg-bg-card-hover'}"
onclick={() => newRecurrence = toggleBit(newRecurrence, i)}
>{day}</button>
{/each}
</div>
</div>
</div>
{#if newRecurrence === 0}
<div class="flex gap-2">
<label class="flex flex-col gap-1 flex-1">
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Date</span>
<input type="date" bind:value={newDueDay}
class="date-input px-3 py-2 rounded-lg border border-border bg-bg-primary text-text-primary text-[13px] outline-none [color-scheme:dark] flex-1 cursor-pointer relative focus:border-accent" />
</label>
</div>
{/if}
<div class="flex gap-1.5">
<button type="submit"
class="px-4 py-1.5 rounded-lg border-none bg-accent text-white cursor-pointer text-xs font-semibold transition-[filter] hover:brightness-110 disabled:opacity-40 disabled:cursor-not-allowed"
disabled={!newTitle.trim() || (newRecurrence === 0 && !newDueDay)}>Add</button>
<button type="button"
class="px-4 py-1.5 rounded-lg border border-border bg-transparent text-text-secondary cursor-pointer text-xs transition-all hover:bg-bg-card-hover"
onclick={() => { showAddForm = false; newTitle = ''; newDueDay = ''; newRecurrence = 0; newPeriod = 0x01; }}>Cancel</button>
</div>
</form>
{/if}
<div class="flex flex-col gap-1">
{#each tasks as task}
<div class="flex items-center justify-between flex-wrap px-3.5 py-2.5 rounded-[10px] border bg-bg-card transition-all hover:bg-bg-card-hover
{task.completed ? 'opacity-50' : ''}
{taskIsOverdue(task) && !task.completed ? 'border-danger/40' : 'border-border'}">
{#if editingTaskId === task.id}
<form class="w-full flex flex-col gap-2 p-0" onsubmit={saveEdit}>
<input type="text" bind:value={editTitle}
class="px-3 py-2 rounded-lg border border-border bg-bg-primary text-text-primary text-[13px] outline-none w-full box-border focus:border-accent" />
<div class="flex gap-2">
<div class="flex flex-col gap-1 flex-1">
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Period</span>
<div class="flex gap-1">
{#each PERIODS as p, i}
<button type="button"
class="flex-1 px-2 py-1.5 rounded-md border cursor-pointer text-[11px] transition-all whitespace-nowrap
{hasBit(editPeriod, i)
? 'border-accent bg-accent/15 text-accent font-semibold'
: 'border-border bg-bg-primary text-text-secondary hover:bg-bg-card-hover'}"
onclick={() => editPeriod = toggleBit(editPeriod, i)}
>{PERIOD_ICONS[i]} {p}</button>
{/each}
</div>
</div>
</div>
<div class="flex gap-2">
<div class="flex flex-col gap-1 flex-1">
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Recurrence</span>
<div class="flex gap-[3px] flex-wrap">
{#each DAYS as day, i}
<button type="button"
class="px-2 py-[5px] rounded-md border cursor-pointer text-[11px] transition-all min-w-8 text-center
{hasBit(editRecurrence, i)
? 'border-accent bg-accent/15 text-accent font-semibold'
: 'border-border bg-bg-primary text-text-secondary hover:bg-bg-card-hover'}"
onclick={() => editRecurrence = toggleBit(editRecurrence, i)}
>{day}</button>
{/each}
</div>
</div>
</div>
{#if editRecurrence === 0}
<div class="flex gap-2">
<label class="flex flex-col gap-1 flex-1">
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Date</span>
<input type="date" bind:value={editDueDay}
class="date-input px-3 py-2 rounded-lg border border-border bg-bg-primary text-text-primary text-[13px] outline-none [color-scheme:dark] flex-1 cursor-pointer relative focus:border-accent" />
</label>
</div>
{/if}
<div class="flex gap-1.5">
<button type="submit"
class="px-2.5 py-1 rounded-lg border-none bg-accent text-white cursor-pointer text-[11px] font-semibold transition-[filter] hover:brightness-110">Save</button>
<button type="button"
class="px-2.5 py-1 rounded-lg border border-border bg-transparent text-text-secondary cursor-pointer text-[11px] transition-all hover:bg-bg-card-hover"
onclick={() => editingTaskId = null}>Cancel</button>
</div>
</form>
{:else}
<div class="flex items-center gap-2.5 flex-1 min-w-0">
<input
type="checkbox"
checked={task.completed}
onchange={() => handleToggleComplete(task)}
class="w-4 h-4 cursor-pointer flex-shrink-0"
style="accent-color: var(--color-accent)"
/>
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-[13px] text-text-primary whitespace-nowrap overflow-hidden text-ellipsis
{task.completed ? 'line-through text-text-secondary' : ''}">{task.title}</span>
<div class="flex gap-2 items-center flex-wrap">
<span class="text-[10px] text-text-secondary font-medium">{periodIcons(task.period)} {formatPeriod(task.period)}</span>
{#if task.recurrence > 0}
<span class="text-[10px] text-accent font-medium">🔁 {formatRecurrence(task.recurrence)}</span>
{:else}
<span class="text-[10px] font-medium {taskIsOverdue(task) && !task.completed ? 'text-danger' : 'text-text-secondary'}">
{formatRelativeDate(task.due_date)}
</span>
{/if}
</div>
</div>
</div>
<div class="flex gap-1 items-center flex-shrink-0">
<button class="bg-transparent border-none cursor-pointer text-xs p-1 rounded opacity-60 transition-[background] hover:opacity-100 hover:bg-bg-card-hover"
onclick={() => startEditing(task)} title="Edit">✏️</button>
{#if confirmDeleteId === task.id}
<button class="bg-transparent border-none cursor-pointer text-xs p-1 rounded text-danger opacity-60 hover:opacity-100"
onclick={() => handleDelete(task.id)} title="Confirm"></button>
<button class="bg-transparent border-none cursor-pointer text-xs p-1 rounded opacity-60 hover:opacity-100 hover:bg-bg-card-hover"
onclick={() => confirmDeleteId = null} title="Cancel"></button>
{:else}
<button class="bg-transparent border-none cursor-pointer text-xs p-1 rounded opacity-60 transition-[background] hover:opacity-100 hover:bg-bg-card-hover"
onclick={() => confirmDeleteId = task.id} title="Delete">🗑️</button>
{/if}
</div>
{/if}
</div>
{:else}
<div class="text-center p-8 text-text-secondary text-[13px]">No tasks yet. Add one above!</div>
{/each}
</div>
{:else}
<div class="text-center p-8 text-text-secondary text-[13px]">Select or add a user to see their tasks.</div>
{/if}
{#if error}
<div class="mt-2 px-2.5 py-1.5 rounded-md bg-danger/10 border border-danger/20 text-danger text-[11px]">{error}</div>
{/if}
</div>