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:
@@ -12,6 +12,8 @@
|
||||
═══════════════════════════════════════════════ */
|
||||
|
||||
@layer base {
|
||||
html, body { overflow-x: hidden; }
|
||||
body { padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); }
|
||||
:root {
|
||||
/* ── Fonts ── */
|
||||
--font: 'Inter', -apple-system, system-ui, sans-serif;
|
||||
@@ -457,5 +459,6 @@
|
||||
}
|
||||
.page-greeting { font-size: var(--text-xl); }
|
||||
.page { padding: var(--sp-5) 0 var(--sp-20); }
|
||||
.app-surface { padding: 0 var(--sp-5); }
|
||||
.app-surface { padding: 0 var(--sp-5); max-width: 100vw; box-sizing: border-box; }
|
||||
.module, .module.primary, .module.flush { box-sizing: border-box; max-width: 100%; overflow: hidden; }
|
||||
}
|
||||
|
||||
237
frontend-v2/src/lib/components/dashboard/TasksModule.svelte
Normal file
237
frontend-v2/src/lib/components/dashboard/TasksModule.svelte
Normal file
@@ -0,0 +1,237 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
projectId: string;
|
||||
_projectName: string;
|
||||
_projectId: string;
|
||||
dueDate?: string;
|
||||
startDate?: string;
|
||||
isAllDay?: boolean;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
let loading = $state(true);
|
||||
let todayTasks = $state<Task[]>([]);
|
||||
let overdueTasks = $state<Task[]>([]);
|
||||
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 formatOverdueDate(task: Task): string {
|
||||
const d = task.dueDate || task.startDate;
|
||||
if (!d) return '';
|
||||
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);
|
||||
if (diff === -1) return 'Yesterday';
|
||||
if (diff < -1) return `${Math.abs(diff)}d ago`;
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
function sortByTime(tasks: Task[]): Task[] {
|
||||
return [...tasks].sort((a, b) => {
|
||||
const aDate = a.startDate || a.dueDate || '9999';
|
||||
const bDate = b.startDate || b.dueDate || '9999';
|
||||
return aDate.localeCompare(bDate);
|
||||
});
|
||||
}
|
||||
|
||||
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(loadTasks, 300);
|
||||
} catch {
|
||||
completing = new Set([...completing].filter(id => id !== task.id));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
try {
|
||||
const data = await api('/today');
|
||||
overdueTasks = sortByTime(data.overdue || []).slice(0, 5);
|
||||
todayTasks = sortByTime(data.today || []).slice(0, 5);
|
||||
loading = false;
|
||||
} catch { loading = false; }
|
||||
}
|
||||
|
||||
onMount(loadTasks);
|
||||
</script>
|
||||
|
||||
<div class="module tasks-mobile-module">
|
||||
<div class="module-header">
|
||||
<div class="module-title">Tasks</div>
|
||||
<a href="/tasks" class="module-action">View all →</a>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="skeleton" style="height: 80px"></div>
|
||||
{:else if overdueTasks.length === 0 && todayTasks.length === 0}
|
||||
<div class="tm-empty">All clear for today</div>
|
||||
{:else}
|
||||
{#if overdueTasks.length > 0}
|
||||
<div class="tm-section overdue">Overdue · {overdueTasks.length}</div>
|
||||
{#each overdueTasks as task (task.id)}
|
||||
<div class="tm-row" class:completing={completing.has(task.id)}>
|
||||
<button class="tm-check" onclick={() => completeTask(task)}>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<rect x="1.5" y="1.5" width="17" height="17" rx="4" stroke="var(--error)" stroke-width="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="tm-body">
|
||||
<span class="tm-title">{task.title}</span>
|
||||
<span class="tm-time">{formatOverdueDate(task)}{#if formatTime(task)} · {formatTime(task)}{/if}</span>
|
||||
</div>
|
||||
<span class="tm-project">{task._projectName}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if todayTasks.length > 0}
|
||||
<div class="tm-section">Today · {todayTasks.length}</div>
|
||||
{#each todayTasks as task (task.id)}
|
||||
<div class="tm-row" class:completing={completing.has(task.id)}>
|
||||
<button class="tm-check" onclick={() => completeTask(task)}>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<rect x="1.5" y="1.5" width="17" height="17" rx="4" stroke="var(--border-strong)" stroke-width="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="tm-body">
|
||||
<span class="tm-title">{task.title}</span>
|
||||
{#if formatTime(task)}
|
||||
<span class="tm-time">{formatTime(task)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="tm-project">{task._projectName}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tasks-mobile-module {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.tasks-mobile-module {
|
||||
display: block;
|
||||
margin-bottom: var(--module-gap);
|
||||
}
|
||||
}
|
||||
|
||||
.tm-section {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-3);
|
||||
padding: var(--sp-3) 0 var(--sp-1);
|
||||
}
|
||||
|
||||
.tm-section.overdue {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.tm-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.tm-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tm-row.completing {
|
||||
opacity: 0.2;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.tm-check {
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tm-check:hover svg rect {
|
||||
stroke: var(--accent);
|
||||
fill: var(--accent-dim);
|
||||
}
|
||||
|
||||
.tm-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tm-title {
|
||||
display: block;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 500;
|
||||
color: var(--text-1);
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
|
||||
.tm-time {
|
||||
display: block;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-3);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.tm-project {
|
||||
flex-shrink: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-3);
|
||||
text-align: right;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tm-empty {
|
||||
padding: var(--sp-6) 0;
|
||||
text-align: center;
|
||||
color: var(--text-3);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
</style>
|
||||
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>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { LayoutDashboard, DollarSign, Package, Activity, MoreVertical, MapPin, BookOpen, Library, Settings } from '@lucide/svelte';
|
||||
import { LayoutDashboard, DollarSign, Package, Activity, MoreVertical, MapPin, BookOpen, Library, Settings, CheckSquare } from '@lucide/svelte';
|
||||
|
||||
interface Props {
|
||||
visibleApps?: string[];
|
||||
@@ -60,6 +60,12 @@
|
||||
<div class="more-sheet-overlay open" onclick={(e) => { if (e.target === e.currentTarget) closeMore(); }} onkeydown={() => {}}>
|
||||
<div class="more-sheet">
|
||||
<div class="more-sheet-handle"></div>
|
||||
{#if showApp('tasks')}
|
||||
<a href="/tasks" class="more-sheet-item" onclick={closeMore}>
|
||||
<CheckSquare size={20} />
|
||||
Tasks
|
||||
</a>
|
||||
{/if}
|
||||
{#if showApp('trips')}
|
||||
<a href="/trips" class="more-sheet-item" onclick={closeMore}>
|
||||
<MapPin size={20} />
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
<div class="navbar-links">
|
||||
<a href="/" class="navbar-link" class:active={page.url.pathname === '/'}>Dashboard</a>
|
||||
|
||||
{#if showApp('tasks')}
|
||||
<a href="/tasks" class="navbar-link" class:active={isActive('/tasks')}>Tasks</a>
|
||||
{/if}
|
||||
|
||||
{#if showApp('trips')}
|
||||
<a href="/trips" class="navbar-link" class:active={isActive('/trips')}>Trips</a>
|
||||
{/if}
|
||||
|
||||
@@ -22,7 +22,7 @@ export const load: LayoutServerLoad = async ({ cookies, url }) => {
|
||||
// Hides nav items but does NOT block direct URL access.
|
||||
// This is intentional: all shared services are accessible to all authenticated users.
|
||||
// Hiding reduces clutter for users who don't need certain apps day-to-day.
|
||||
const allApps = ['trips', 'fitness', 'inventory', 'budget', 'reader', 'media'];
|
||||
const allApps = ['tasks', 'trips', 'fitness', 'inventory', 'budget', 'reader', 'media'];
|
||||
const hiddenByUser: Record<string, string[]> = {
|
||||
'madiha': ['inventory', 'reader'],
|
||||
};
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
import DashboardActionCard from '$lib/components/dashboard/DashboardActionCard.svelte';
|
||||
import BudgetModule from '$lib/components/dashboard/BudgetModule.svelte';
|
||||
import FitnessModule from '$lib/components/dashboard/FitnessModule.svelte';
|
||||
import IssuesModule from '$lib/components/dashboard/IssuesModule.svelte';
|
||||
import TasksPanel from '$lib/components/dashboard/TasksPanel.svelte';
|
||||
import TasksModule from '$lib/components/dashboard/TasksModule.svelte';
|
||||
|
||||
const userName = $derived((page as any).data?.user?.display_name || 'there');
|
||||
import IssuesModule from '$lib/components/dashboard/IssuesModule.svelte';
|
||||
|
||||
let inventoryIssueCount = $state(0);
|
||||
let inventoryReviewCount = $state(0);
|
||||
@@ -58,8 +60,12 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<div class="app-surface">
|
||||
<div class="page dash-page">
|
||||
<div class="dash-layout">
|
||||
<aside class="tasks-sidebar">
|
||||
<TasksPanel />
|
||||
</aside>
|
||||
<div class="app-surface">
|
||||
<div class="page-header">
|
||||
<div class="page-title">Dashboard</div>
|
||||
<div class="page-greeting">Good to see you, <strong>{userName}</strong></div>
|
||||
@@ -90,6 +96,8 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TasksModule />
|
||||
|
||||
<div class="modules-grid">
|
||||
<BudgetModule />
|
||||
<div class="right-stack">
|
||||
@@ -98,9 +106,51 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dash-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: var(--module-gap);
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--sp-6);
|
||||
}
|
||||
|
||||
.tasks-sidebar {
|
||||
position: sticky;
|
||||
top: 80px;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.dash-layout > :global(.app-surface) {
|
||||
padding: 0;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.dash-layout {
|
||||
display: block;
|
||||
padding: 0;
|
||||
}
|
||||
.tasks-sidebar {
|
||||
display: none;
|
||||
}
|
||||
.dash-layout > :global(.app-surface) {
|
||||
padding: 0 var(--sp-6);
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dash-layout > :global(.app-surface) {
|
||||
padding: 0 var(--sp-5);
|
||||
}
|
||||
}
|
||||
|
||||
.action-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -126,5 +176,9 @@
|
||||
.modules-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.modules-grid > :global(*) {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
730
frontend-v2/src/routes/(app)/tasks/+page.svelte
Normal file
730
frontend-v2/src/routes/(app)/tasks/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user