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,390 @@
<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>