Files
platform/frontend-v2/src/lib/components/dashboard/TasksPanel.svelte
Yusuf Suleman 6023ebf9d0 feat: tasks app, security hardening, mobile fixes, iOS app shell
- Custom SQLite task manager replacing TickTick wrapper
- 73 tasks migrated from TickTick across 15 projects
- RRULE recurrence engine with lazy materialization
- Dashboard tasks widget (desktop sidebar + mobile card)
- Tasks page with project tabs, add/edit/complete/delete
- Security: locked ports to localhost, removed old containers
- Gitea Actions runner configured and all 3 CI jobs passing
- Fixed mobile overflow on dashboard cards
- iOS Capacitor app shell (Second Brain)
- Frontend/backend guide docs for adding new services
- TickTick Google Calendar sync re-authorized

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:35:57 -05:00

391 lines
9.3 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 Task {
id: string;
title: string;
projectId: string;
_projectName: string;
dueDate?: string;
startDate?: string;
isAllDay?: boolean;
priority: number;
status: number;
}
let loading = $state(true);
let error = $state(false);
let todayTasks = $state<Task[]>([]);
let overdueTasks = $state<Task[]>([]);
let showAdd = $state(false);
let newTaskTitle = $state('');
let saving = $state(false);
let projects = $state<{ id: string; name: string }[]>([]);
let selectedProject = $state('');
let completing = $state<Set<string>>(new Set());
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 formatTime(task: Task): string {
const d = task.startDate || task.dueDate;
if (!d || task.isAllDay) return '';
try {
const date = new Date(d);
const h = date.getHours();
const m = date.getMinutes();
if (h === 0 && m === 0) return '';
const ampm = h >= 12 ? 'PM' : 'AM';
const hour = h % 12 || 12;
return m > 0 ? `${hour}:${String(m).padStart(2, '0')} ${ampm}` : `${hour} ${ampm}`;
} catch { return ''; }
}
function priorityClass(p: number): string {
if (p >= 5) return 'priority-high';
if (p >= 3) return 'priority-med';
return '';
}
async function loadTasks() {
try {
const data = await api('/today');
todayTasks = data.today || [];
overdueTasks = data.overdue || [];
loading = false;
} catch {
error = true;
loading = false;
}
}
async function loadProjects() {
try {
const data = await api('/projects');
projects = (data.projects || []).map((p: any) => ({ id: p.id, name: p.name }));
if (projects.length > 0 && !selectedProject) {
selectedProject = projects[0].id;
}
} catch { /* silent */ }
}
async function addTask() {
if (!newTaskTitle.trim() || !selectedProject) return;
saving = true;
try {
const now = new Date();
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}T00:00:00+0000`;
await api('/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: newTaskTitle.trim(),
projectId: selectedProject,
dueDate: todayStr,
isAllDay: true,
})
});
newTaskTitle = '';
showAdd = false;
await loadTasks();
} 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 })
});
// Animate out then reload
setTimeout(() => loadTasks(), 300);
} catch (e) {
console.error('Failed to complete task', e);
completing = new Set([...completing].filter(id => id !== task.id));
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') addTask();
if (e.key === 'Escape') { showAdd = false; newTaskTitle = ''; }
}
onMount(() => {
loadTasks();
loadProjects();
});
</script>
<div class="tasks-panel">
<div class="tasks-header">
<span class="tasks-title">Tasks</span>
<button class="tasks-add-btn" onclick={() => { showAdd = !showAdd; if (!showAdd) newTaskTitle = ''; }}>
{showAdd ? '×' : '+'}
</button>
</div>
{#if showAdd}
<div class="tasks-add-form">
<input
class="input tasks-input"
placeholder="Add a task..."
bind:value={newTaskTitle}
onkeydown={handleKeydown}
disabled={saving}
/>
<select class="input tasks-select" bind:value={selectedProject}>
{#each projects as proj}
<option value={proj.id}>{proj.name}</option>
{/each}
</select>
<button class="btn-primary tasks-submit" onclick={addTask} disabled={saving || !newTaskTitle.trim()}>
{saving ? '...' : 'Add'}
</button>
</div>
{/if}
{#if loading}
<div class="tasks-loading">
<div class="skeleton" style="height: 32px; margin-bottom: 8px"></div>
<div class="skeleton" style="height: 32px; margin-bottom: 8px"></div>
<div class="skeleton" style="height: 32px"></div>
</div>
{:else if error}
<div class="tasks-empty">Could not load tasks</div>
{:else}
{#if overdueTasks.length > 0}
<div class="tasks-section-label overdue-label">Overdue · {overdueTasks.length}</div>
{#each overdueTasks as task (task.id)}
<div class="task-row" class:completing={completing.has(task.id)}>
<button class="task-check" onclick={() => completeTask(task)} title="Complete">
<span class="task-check-circle"></span>
</button>
<div class="task-content">
<span class="task-title {priorityClass(task.priority)}">{task.title}</span>
<span class="task-meta">{task._projectName}</span>
</div>
</div>
{/each}
{/if}
{#if todayTasks.length > 0}
<div class="tasks-section-label">Today · {todayTasks.length}</div>
{#each todayTasks as task (task.id)}
<div class="task-row" class:completing={completing.has(task.id)}>
<button class="task-check" onclick={() => completeTask(task)} title="Complete">
<span class="task-check-circle"></span>
</button>
<div class="task-content">
<span class="task-title {priorityClass(task.priority)}">{task.title}</span>
<span class="task-meta">
{#if formatTime(task)}{formatTime(task)} · {/if}{task._projectName}
</span>
</div>
</div>
{/each}
{/if}
{#if todayTasks.length === 0 && overdueTasks.length === 0}
<div class="tasks-empty">All clear for today</div>
{/if}
{/if}
<a href="/tasks" class="tasks-footer">View all tasks &rarr;</a>
</div>
<style>
.tasks-panel {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
padding: var(--card-pad);
width: 280px;
max-height: calc(100vh - 120px);
overflow-y: auto;
position: sticky;
top: 80px;
}
.tasks-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--sp-3);
}
.tasks-title {
font-size: var(--text-sm);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-3);
}
.tasks-add-btn {
width: 28px;
height: 28px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--card-secondary);
color: var(--text-2);
font-size: var(--text-lg);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
transition: background 0.15s, color 0.15s;
}
.tasks-add-btn:hover {
background: var(--accent-dim);
color: var(--accent);
}
.tasks-add-form {
display: flex;
flex-direction: column;
gap: var(--sp-2);
margin-bottom: var(--sp-3);
padding-bottom: var(--sp-3);
border-bottom: 1px solid var(--border);
}
.tasks-input {
font-size: var(--text-sm) !important;
padding: var(--sp-2) var(--sp-3) !important;
}
.tasks-select {
font-size: var(--text-sm) !important;
padding: var(--sp-1.5) var(--sp-3) !important;
background: var(--card-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-2);
}
.tasks-submit {
font-size: var(--text-sm) !important;
padding: var(--sp-1.5) var(--sp-3) !important;
}
.tasks-section-label {
font-size: var(--text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-3);
padding: var(--sp-2) 0 var(--sp-1);
}
.overdue-label {
color: var(--error);
}
.task-row {
display: flex;
align-items: flex-start;
gap: var(--sp-2);
padding: var(--sp-1.5) 0;
transition: opacity 0.3s;
}
.task-row.completing {
opacity: 0.3;
text-decoration: line-through;
}
.task-check {
flex-shrink: 0;
width: 20px;
height: 20px;
padding: 0;
margin-top: 1px;
background: none;
border: none;
cursor: pointer;
}
.task-check-circle {
display: block;
width: 18px;
height: 18px;
border-radius: var(--radius-full);
border: 2px solid var(--border-strong);
transition: background 0.15s, border-color 0.15s;
}
.task-check:hover .task-check-circle {
border-color: var(--accent);
background: var(--accent-dim);
}
.task-content {
flex: 1;
min-width: 0;
}
.task-title {
display: block;
font-size: var(--text-sm);
color: var(--text-1);
line-height: var(--leading-snug);
word-break: break-word;
}
.task-title.priority-high {
color: var(--error);
}
.task-title.priority-med {
color: var(--warning);
}
.task-meta {
font-size: var(--text-xs);
color: var(--text-4);
margin-top: 1px;
}
.tasks-empty {
padding: var(--sp-6) 0;
text-align: center;
color: var(--text-3);
font-size: var(--text-sm);
}
.tasks-loading {
padding: var(--sp-3) 0;
}
.tasks-footer {
display: block;
text-align: center;
padding-top: var(--sp-3);
margin-top: var(--sp-3);
border-top: 1px solid var(--border);
font-size: var(--text-sm);
color: var(--accent);
text-decoration: none;
}
.tasks-footer:hover {
text-decoration: underline;
}
/* Mobile: hide the sidebar panel, show inline card instead */
@media (max-width: 1100px) {
.tasks-panel {
display: none;
}
}
</style>