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:
390
frontend-v2/src/lib/components/dashboard/TasksPanel.svelte
Normal file
390
frontend-v2/src/lib/components/dashboard/TasksPanel.svelte
Normal 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 →</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>
|
||||
Reference in New Issue
Block a user