387 lines
15 KiB
Svelte
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>
|