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>
This commit is contained in:
Yusuf Suleman
2026-03-30 15:35:57 -05:00
parent 877021ff20
commit 6023ebf9d0
49 changed files with 5207 additions and 23 deletions

View File

@@ -0,0 +1,730 @@
<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>
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.header-actions {
display: flex;
gap: var(--sp-2);
}
.tasks-tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding-bottom: var(--sp-2);
margin-bottom: var(--sp-3);
gap: var(--sp-2);
}
.tasks-tabs::-webkit-scrollbar { display: none; }
.tasks-tabs > :global(.tab) {
white-space: nowrap;
flex-shrink: 0;
padding: var(--sp-1.5) var(--sp-3);
font-size: var(--text-sm);
border: 1px solid var(--border);
}
.tasks-tabs > :global(.tab.active) {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.add-form {
display: flex;
flex-direction: column;
gap: var(--sp-3);
margin-bottom: var(--section-gap);
}
.add-row {
display: flex;
gap: var(--sp-2);
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: var(--text-sm);
color: var(--text-2);
display: flex;
align-items: center;
gap: var(--sp-1);
cursor: pointer;
}
.task-item {
display: flex;
align-items: flex-start;
gap: var(--sp-3);
transition: opacity 0.3s;
}
.task-item.fading { opacity: 0.2; }
.task-check {
flex-shrink: 0;
padding: 0;
margin-top: 2px;
background: none;
border: none;
cursor: pointer;
}
.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: var(--text-base);
color: var(--text-1);
line-height: var(--leading-snug);
}
.task-name.overdue-text { color: var(--error); }
.task-name.completed-name {
text-decoration: line-through;
color: var(--text-3);
}
.completed-item { opacity: 0.7; }
.task-sub {
font-size: var(--text-sm);
color: var(--text-3);
margin-top: 2px;
display: flex;
align-items: center;
gap: var(--sp-2);
flex-wrap: wrap;
}
.task-delete {
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
color: var(--text-4);
font-size: var(--text-lg);
}
.task-item:hover .task-delete { opacity: 1; }
.overdue-label { color: var(--error) !important; }
.empty-state {
padding: var(--sp-8);
text-align: center;
color: var(--text-3);
font-size: var(--text-base);
}
.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: var(--radius);
box-shadow: var(--shadow-xl);
width: 90%;
max-width: 420px;
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--card-pad) var(--card-pad) 0;
}
.modal-title {
font-size: var(--text-lg);
font-weight: 600;
color: var(--text-1);
}
.modal-body {
padding: var(--sp-4) var(--card-pad);
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.modal-actions {
display: flex;
gap: var(--sp-2);
justify-content: flex-end;
padding: 0 var(--card-pad) var(--card-pad);
}
</style>