Files
platform/frontend-v2/src/routes/(app)/tasks/+page.svelte
2026-03-30 21:22:55 -05:00

752 lines
22 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from 'svelte';
interface Project {
id: string;
name: string;
isInbox?: boolean;
isShared?: boolean;
}
interface Task {
id: string;
title: string;
content?: string;
projectId: string;
_projectName: string;
_projectId: string;
dueDate?: string;
startDate?: string;
isAllDay?: boolean;
priority: number;
status: number;
repeatFlag?: string;
}
type TabType = 'today' | 'all' | 'completed' | 'project';
let loading = $state(true);
let activeTab = $state<TabType>('today');
let tasks = $state<Task[]>([]);
let todayTasks = $state<Task[]>([]);
let overdueTasks = $state<Task[]>([]);
let projects = $state<Project[]>([]);
let selectedProjectId = $state('');
let completing = $state<Set<string>>(new Set());
let deleting = $state<Set<string>>(new Set());
// Add task form
let showAdd = $state(false);
let newTitle = $state('');
let newProjectId = $state('');
let newDueDate = $state('');
let newDueTime = $state('');
let newAllDay = $state(true);
let newPriority = $state(0);
let newRepeat = $state('');
let saving = $state(false);
// Edit
let editingTask = $state<Task | null>(null);
let editTitle = $state('');
let editDueDate = $state('');
let editDueTime = $state('');
let editAllDay = $state(true);
let editPriority = $state(0);
let editRepeat = $state('');
let editProjectId = $state('');
// New project
let showNewProject = $state(false);
let newProjectName = $state('');
async function api(path: string, opts: RequestInit = {}) {
const res = await fetch(`/api/tasks${path}`, { credentials: 'include', ...opts });
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
}
function formatDate(task: Task): string {
const d = task.startDate || task.dueDate;
if (!d) return 'No date';
try {
const date = new Date(d);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const taskDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const diff = Math.round((taskDate.getTime() - today.getTime()) / 86400000);
let dateStr = '';
if (diff === 0) dateStr = 'Today';
else if (diff === 1) dateStr = 'Tomorrow';
else if (diff === -1) dateStr = 'Yesterday';
else if (diff < -1) dateStr = `${Math.abs(diff)}d ago`;
else dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
if (!task.isAllDay) {
const h = date.getHours();
const m = date.getMinutes();
if (h !== 0 || m !== 0) {
const ampm = h >= 12 ? 'PM' : 'AM';
const hour = h % 12 || 12;
const timeStr = m > 0 ? `${hour}:${String(m).padStart(2, '0')} ${ampm}` : `${hour} ${ampm}`;
return `${dateStr} · ${timeStr}`;
}
}
return dateStr;
} catch { return ''; }
}
function isOverdue(task: Task): boolean {
const d = task.startDate || task.dueDate;
if (!d) return false;
const taskDate = new Date(d);
const now = new Date();
return taskDate < new Date(now.getFullYear(), now.getMonth(), now.getDate()) && task.status === 0;
}
function priorityLabel(p: number): string {
if (p >= 5) return 'high';
if (p >= 3) return 'medium';
if (p >= 1) return 'low';
return '';
}
function repeatLabel(flag: string): string {
if (!flag) return '';
if (flag.includes('DAILY')) return 'Daily';
if (flag.includes('WEEKLY')) {
const m = flag.match(/BYDAY=([A-Z,]+)/);
if (m) return `Weekly (${m[1]})`;
return 'Weekly';
}
if (flag.includes('MONTHLY')) return 'Monthly';
if (flag.includes('YEARLY')) return 'Yearly';
return 'Repeating';
}
async function loadProjects() {
try {
const data = await api('/projects');
projects = data.projects || [];
if (projects.length > 0 && !newProjectId) {
newProjectId = projects[0].id;
}
} catch { /* silent */ }
}
async function loadToday() {
try {
const data = await api('/today');
todayTasks = data.today || [];
overdueTasks = data.overdue || [];
} catch { /* silent */ }
}
async function loadAll() {
try {
const data = await api('/tasks');
tasks = (data.tasks || []).filter((t: Task) => t.status === 0);
} catch { /* silent */ }
}
async function loadCompleted() {
try {
const data = await api('/tasks/completed');
tasks = data.tasks || [];
} catch { /* silent */ }
}
async function loadProject(projectId: string) {
try {
const data = await api(`/tasks?project_id=${projectId}`);
tasks = (data.tasks || []).filter((t: Task) => t.status === 0);
} catch { /* silent */ }
}
async function loadData() {
loading = true;
if (activeTab === 'today') await loadToday();
else if (activeTab === 'all') await loadAll();
else if (activeTab === 'completed') await loadCompleted();
else if (activeTab === 'project' && selectedProjectId) await loadProject(selectedProjectId);
loading = false;
}
async function addTask() {
if (!newTitle.trim() || !newProjectId) return;
saving = true;
try {
const payload: any = {
title: newTitle.trim(),
projectId: newProjectId,
isAllDay: newAllDay,
priority: newPriority,
};
if (newDueDate) {
if (newAllDay) {
payload.dueDate = `${newDueDate}T00:00:00+0000`;
} else {
payload.startDate = `${newDueDate}T${newDueTime || '09:00'}:00+0000`;
payload.dueDate = `${newDueDate}T${newDueTime || '09:00'}:00+0000`;
}
}
if (newRepeat) payload.repeatFlag = newRepeat;
await api('/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
newTitle = '';
newDueDate = '';
newDueTime = '';
newPriority = 0;
newRepeat = '';
showAdd = false;
await loadData();
} catch (e) { console.error('Failed to add task', e); }
saving = false;
}
async function completeTask(task: Task) {
completing = new Set([...completing, task.id]);
try {
await api(`/tasks/${task.id}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId: task.projectId || task._projectId })
});
setTimeout(loadData, 400);
} catch {
completing = new Set([...completing].filter(id => id !== task.id));
}
}
async function deleteTask(task: Task) {
if (!confirm(`Delete "${task.title}"?`)) return;
deleting = new Set([...deleting, task.id]);
try {
await api(`/tasks/${task.id}?projectId=${task.projectId || task._projectId}`, {
method: 'DELETE',
});
setTimeout(loadData, 300);
} catch {
deleting = new Set([...deleting].filter(id => id !== task.id));
}
}
function startEdit(task: Task) {
editingTask = task;
editTitle = task.title;
editAllDay = task.isAllDay ?? true;
editPriority = task.priority || 0;
editRepeat = task.repeatFlag || '';
editProjectId = task.projectId || task._projectId;
const d = task.startDate || task.dueDate || '';
if (d) {
try {
const date = new Date(d);
editDueDate = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
editDueTime = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
} catch { editDueDate = ''; editDueTime = ''; }
} else {
editDueDate = '';
editDueTime = '';
}
}
async function saveEdit() {
if (!editingTask || !editTitle.trim()) return;
saving = true;
try {
const payload: any = {
title: editTitle.trim(),
projectId: editProjectId,
isAllDay: editAllDay,
priority: editPriority,
repeatFlag: editRepeat || null,
};
if (editDueDate) {
if (editAllDay) {
payload.dueDate = `${editDueDate}T00:00:00+0000`;
payload.startDate = `${editDueDate}T00:00:00+0000`;
} else {
payload.startDate = `${editDueDate}T${editDueTime || '09:00'}:00+0000`;
payload.dueDate = `${editDueDate}T${editDueTime || '09:00'}:00+0000`;
}
} else {
payload.dueDate = null;
payload.startDate = null;
}
await api(`/tasks/${editingTask.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
editingTask = null;
await loadData();
} catch (e) { console.error('Failed to update task', e); }
saving = false;
}
async function createProject() {
if (!newProjectName.trim()) return;
try {
await api('/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newProjectName.trim() })
});
newProjectName = '';
showNewProject = false;
await loadProjects();
} catch (e) { console.error('Failed to create project', e); }
}
function selectProject(projectId: string) {
selectedProjectId = projectId;
activeTab = 'project';
loadData();
}
onMount(async () => {
await loadProjects();
await loadData();
});
</script>
<div class="page">
<div class="page-header">
<h1 class="page-title">Tasks</h1>
<div class="header-actions">
<button class="btn-secondary" onclick={() => { showNewProject = !showNewProject; }}>
{showNewProject ? 'Cancel' : '+ List'}
</button>
<button class="btn-primary" onclick={() => { showAdd = !showAdd; }}>
{showAdd ? 'Cancel' : '+ Add task'}
</button>
</div>
</div>
{#if showNewProject}
<div class="module add-form">
<div class="add-row">
<input class="input" style="flex:1" placeholder="New list name" bind:value={newProjectName}
onkeydown={(e) => e.key === 'Enter' && createProject()} />
<button class="btn-primary" onclick={createProject} disabled={!newProjectName.trim()}>Create</button>
</div>
</div>
{/if}
{#if showAdd}
<div class="module add-form">
<input class="input" placeholder="What needs to be done?" bind:value={newTitle}
onkeydown={(e) => e.key === 'Enter' && addTask()} />
<div class="add-row">
<select class="input add-select" bind:value={newProjectId}>
{#each projects as proj}
<option value={proj.id}>{proj.name}</option>
{/each}
</select>
<input class="input add-date" type="date" bind:value={newDueDate} />
{#if !newAllDay}
<input class="input add-time" type="time" bind:value={newDueTime} />
{/if}
</div>
<div class="add-row">
<label class="add-toggle">
<input type="checkbox" bind:checked={newAllDay} /> All day
</label>
<select class="input add-priority" bind:value={newPriority}>
<option value={0}>No priority</option>
<option value={1}>Low</option>
<option value={3}>Medium</option>
<option value={5}>High</option>
</select>
<select class="input add-repeat" bind:value={newRepeat}>
<option value="">No repeat</option>
<option value="FREQ=DAILY">Daily</option>
<option value="FREQ=WEEKLY">Weekly</option>
<option value="FREQ=WEEKLY;BYDAY=MO,WE,FR">Mon/Wed/Fri</option>
<option value="FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR">Weekdays</option>
<option value="FREQ=MONTHLY">Monthly</option>
<option value="FREQ=YEARLY">Yearly</option>
</select>
</div>
<button class="btn-primary full" onclick={addTask} disabled={saving || !newTitle.trim()}>
{saving ? 'Adding...' : 'Add task'}
</button>
</div>
{/if}
<div class="tab-bar tasks-tabs">
<button class="tab" class:active={activeTab === 'today'} onclick={() => { activeTab = 'today'; loadData(); }}>Today</button>
<button class="tab" class:active={activeTab === 'all'} onclick={() => { activeTab = 'all'; loadData(); }}>All</button>
{#each projects as proj}
<button class="tab" class:active={activeTab === 'project' && selectedProjectId === proj.id} onclick={() => selectProject(proj.id)}>
{proj.name}
</button>
{/each}
<button class="tab" class:active={activeTab === 'completed'} onclick={() => { activeTab = 'completed'; loadData(); }}>Done</button>
</div>
{#if loading}
<div class="module"><div class="skeleton" style="height: 200px"></div></div>
{:else}
{#if activeTab === 'today'}
{#if overdueTasks.length > 0}
<div class="section-label overdue-label">Overdue · {overdueTasks.length}</div>
<div class="module flush">
{#each overdueTasks as task (task.id)}
<div class="data-row task-item" class:fading={completing.has(task.id) || deleting.has(task.id)}>
<button class="task-check" onclick={() => completeTask(task)}>
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
<rect x="1.5" y="1.5" width="19" height="19" rx="4" stroke="var(--error)" stroke-width="2"/>
</svg>
</button>
<div class="task-body" onclick={() => startEdit(task)}>
<div class="task-name">{task.title}</div>
<div class="task-sub">
{formatDate(task)} · {task._projectName}
{#if task.repeatFlag}<span class="badge muted">{repeatLabel(task.repeatFlag)}</span>{/if}
</div>
</div>
<button class="btn-icon task-delete" onclick={() => deleteTask(task)} title="Delete">×</button>
</div>
{/each}
</div>
{/if}
{#if todayTasks.length > 0}
<div class="section-label">Today · {todayTasks.length}</div>
<div class="module flush">
{#each todayTasks as task (task.id)}
<div class="data-row task-item" class:fading={completing.has(task.id) || deleting.has(task.id)}>
<button class="task-check" onclick={() => completeTask(task)}>
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
<rect x="1.5" y="1.5" width="19" height="19" rx="4" stroke="var(--border-strong)" stroke-width="2"/>
</svg>
</button>
<div class="task-body" onclick={() => startEdit(task)}>
<div class="task-name">{task.title}</div>
<div class="task-sub">
{formatDate(task)} · {task._projectName}
{#if task.repeatFlag}<span class="badge muted">{repeatLabel(task.repeatFlag)}</span>{/if}
</div>
</div>
<button class="btn-icon task-delete" onclick={() => deleteTask(task)} title="Delete">×</button>
</div>
{/each}
</div>
{/if}
{#if todayTasks.length === 0 && overdueTasks.length === 0}
<div class="module">
<div class="empty-state">All clear for today</div>
</div>
{/if}
{:else if activeTab === 'completed'}
{#if tasks.length > 0}
<div class="module flush">
{#each tasks as task (task.id)}
<div class="data-row task-item completed-item">
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" style="flex-shrink:0">
<rect x="1.5" y="1.5" width="19" height="19" rx="4" stroke="var(--success)" stroke-width="2" fill="var(--success-dim)"/>
<polyline points="7,11 10,14 15,8" stroke="var(--success)" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<div class="task-body">
<div class="task-name completed-name">{task.title}</div>
<div class="task-sub">{task._projectName}{#if task.completedAt} · {new Date(task.completedAt).toLocaleDateString()}{/if}</div>
</div>
</div>
{/each}
</div>
{:else}
<div class="module"><div class="empty-state">No completed tasks</div></div>
{/if}
{:else}
{#if tasks.length > 0}
<div class="module flush">
{#each tasks as task (task.id)}
<div class="data-row task-item" class:fading={completing.has(task.id) || deleting.has(task.id)}>
<button class="task-check" onclick={() => completeTask(task)}>
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
<rect x="1.5" y="1.5" width="19" height="19" rx="4" stroke="{isOverdue(task) ? 'var(--error)' : 'var(--border-strong)'}" stroke-width="2"/>
</svg>
</button>
<div class="task-body" onclick={() => startEdit(task)}>
<div class="task-name" class:overdue-text={isOverdue(task)}>{task.title}</div>
<div class="task-sub">
{formatDate(task)}
{#if activeTab === 'all'} · {task._projectName}{/if}
{#if task.repeatFlag}<span class="badge muted">{repeatLabel(task.repeatFlag)}</span>{/if}
{#if priorityLabel(task.priority)}
<span class="badge {priorityLabel(task.priority) === 'high' ? 'error' : priorityLabel(task.priority) === 'medium' ? 'warning' : 'muted'}">{priorityLabel(task.priority)}</span>
{/if}
</div>
</div>
<button class="btn-icon task-delete" onclick={() => deleteTask(task)} title="Delete">×</button>
</div>
{/each}
</div>
{:else}
<div class="module">
<div class="empty-state">No tasks</div>
</div>
{/if}
{/if}
{/if}
</div>
<!-- Edit modal -->
{#if editingTask}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={(e) => { if (e.target === e.currentTarget) editingTask = null; }} onkeydown={() => {}}>
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">Edit task</h3>
<button class="btn-icon" onclick={() => editingTask = null}>×</button>
</div>
<div class="modal-body">
<input class="input" bind:value={editTitle} placeholder="Task title" />
<select class="input" bind:value={editProjectId}>
{#each projects as proj}
<option value={proj.id}>{proj.name}</option>
{/each}
</select>
<input class="input" type="date" bind:value={editDueDate} />
<label class="add-toggle">
<input type="checkbox" bind:checked={editAllDay} /> All day
</label>
{#if !editAllDay}
<input class="input" type="time" bind:value={editDueTime} />
{/if}
<select class="input" bind:value={editPriority}>
<option value={0}>No priority</option>
<option value={1}>Low</option>
<option value={3}>Medium</option>
<option value={5}>High</option>
</select>
<select class="input" bind:value={editRepeat}>
<option value="">No repeat</option>
<option value="FREQ=DAILY">Daily</option>
<option value="FREQ=WEEKLY">Weekly</option>
<option value="FREQ=WEEKLY;BYDAY=MO,WE,FR">Mon/Wed/Fri</option>
<option value="FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR">Weekdays</option>
<option value="FREQ=MONTHLY">Monthly</option>
<option value="FREQ=YEARLY">Yearly</option>
</select>
</div>
<div class="modal-actions">
<button class="btn-secondary" onclick={() => editingTask = null}>Cancel</button>
<button class="btn-primary" onclick={saveEdit} disabled={saving}>{saving ? 'Saving...' : 'Save'}</button>
</div>
</div>
</div>
{/if}
<style>
/* ═══ Tasks page — Zinc/Emerald design ═══ */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.header-actions {
display: flex;
gap: 8px;
}
/* ── Tab bar ── */
.tasks-tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding-bottom: 8px;
margin-bottom: 12px;
gap: 6px;
}
.tasks-tabs::-webkit-scrollbar { display: none; }
.tasks-tabs > :global(.tab) {
white-space: nowrap;
flex-shrink: 0;
padding: 4px 12px;
font-size: 13px;
font-weight: 500;
color: var(--text-4);
border: 1px solid var(--border);
border-radius: 6px;
transition: all 0.2s cubic-bezier(0.16,1,0.3,1);
}
.tasks-tabs > :global(.tab:hover) {
color: var(--text-2);
background: rgba(0,0,0,0.03);
}
.tasks-tabs > :global(.tab.active) {
background: var(--accent);
color: white;
border-color: var(--accent);
font-weight: 600;
}
/* ── Add form ── */
.add-form {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 24px;
}
.add-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.add-select { flex: 1; min-width: 120px; }
.add-date { width: 160px; }
.add-time { width: 120px; }
.add-priority { width: 130px; }
.add-repeat { width: 150px; }
.add-toggle {
font-size: 13px;
color: var(--text-3);
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
/* ── Task rows ── */
.task-item {
display: flex;
align-items: flex-start;
gap: 12px;
transition: opacity 0.3s;
}
.task-item.fading { opacity: 0.15; }
.task-check {
flex-shrink: 0;
padding: 0;
margin-top: 2px;
background: none;
border: none;
cursor: pointer;
opacity: 0.5;
transition: opacity 0.15s;
}
.task-item:hover .task-check { opacity: 1; }
.task-check:hover svg rect {
stroke: var(--accent);
fill: var(--accent-dim);
}
.task-body {
flex: 1;
min-width: 0;
cursor: pointer;
}
.task-name {
font-size: 14px;
font-weight: 500;
color: var(--text-1);
line-height: 1.35;
}
.task-name.overdue-text { color: var(--error); }
.task-name.completed-name {
text-decoration: line-through;
color: var(--text-4);
font-weight: 400;
}
.completed-item { opacity: 0.6; }
.task-sub {
font-size: 12px;
color: var(--text-4);
margin-top: 2px;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.task-delete {
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
color: var(--text-4);
font-size: 16px;
}
.task-item:hover .task-delete { opacity: 1; }
.overdue-label { color: var(--error) !important; }
.empty-state {
padding: 48px 16px;
text-align: center;
color: var(--text-4);
font-size: 13px;
}
/* ── Modal ── */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: var(--card);
border-radius: 16px;
box-shadow: var(--shadow-xl);
width: 90%;
max-width: 420px;
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 22px 24px 0;
}
.modal-title {
font-size: 17px;
font-weight: 700;
color: var(--text-1);
letter-spacing: -0.02em;
}
.modal-body {
padding: 16px 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.modal-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
padding: 0 24px 22px;
}
</style>