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

@@ -322,3 +322,146 @@ Shadows that are close to tokens but intentionally differ in blur radius or opac
| `0 20px 60px rgba(0,0,0,0.15)` | `--shadow-xl` | Single layer |
| `0 20px 60px rgba(0,0,0,0.2)` | `--shadow-xl` | Single layer, higher opacity |
| `0 1px 3px rgba(0,0,0,0.15)` | `--shadow-sm` | Toggle thumb, much higher opacity |
---
## Adding a New App (Frontend Guide)
Step-by-step for adding a new app page to the platform.
### 1. Create the route
Create `src/routes/(app)/yourapp/+page.svelte`. Every app is a single self-contained file.
### 2. Page structure template
```svelte
<script lang="ts">
import { onMount } from 'svelte';
// -- Types --
interface Item {
id: string;
name: string;
}
// -- State (Svelte 5 runes) --
let loading = $state(true);
let items = $state<Item[]>([]);
let activeTab = $state<'all' | 'recent'>('all');
let searchQuery = $state('');
// -- Derived --
const filtered = $derived(
items.filter(i => i.name.toLowerCase().includes(searchQuery.toLowerCase()))
);
// -- API helper (scoped to this app's gateway prefix) --
async function api(path: string, opts: RequestInit = {}) {
const res = await fetch(`/api/yourapp${path}`, { credentials: 'include', ...opts });
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
}
// -- Data mapper (normalize backend shape to UI interface) --
function mapItem(raw: any): Item {
return { id: raw.id || raw.Id, name: raw.name || raw.title || '' };
}
// -- Load --
async function loadItems() {
try {
const data = await api('/items');
items = (data.items || data).map(mapItem);
} catch (e) { console.error('Failed to load items', e); }
}
onMount(async () => {
await loadItems();
loading = false;
});
</script>
<div class="page">
<div class="page-header">
<h1 class="page-title">Your App</h1>
</div>
{#if loading}
<div class="module"><div class="skeleton" style="height: 200px"></div></div>
{:else}
<div class="tab-bar">
<button class="tab" class:active={activeTab === 'all'} onclick={() => activeTab = 'all'}>All</button>
<button class="tab" class:active={activeTab === 'recent'} onclick={() => activeTab = 'recent'}>Recent</button>
</div>
<div class="module">
<div class="module-header">
<span class="module-title">Items</span>
<button class="module-action" onclick={...}>View all &rarr;</button>
</div>
{#each filtered as item}
<div class="data-row">
<span style="color: var(--text-1)">{item.name}</span>
</div>
{:else}
<p style="color: var(--text-3); padding: var(--card-pad)">No items found</p>
{/each}
</div>
{/if}
</div>
```
### 3. Register in navigation
**`(app)/+layout.server.ts`** -- add ID to allApps:
```ts
const allApps = ['trips', 'fitness', 'inventory', 'budget', 'reader', 'media', 'yourapp'];
```
**`Navbar.svelte`** -- add link:
```svelte
{#if showApp('yourapp')}
<a href="/yourapp" class="navbar-link" class:active={isActive('/yourapp')}>Your App</a>
{/if}
```
**`MobileTabBar.svelte`** -- add to primary tabs or the "More" sheet.
### 4. Key conventions
| Rule | Detail |
|------|--------|
| All state uses `$state()` | Never plain `let` for reactive values |
| Computed values use `$derived()` | For filtered lists, counts, conditions |
| API calls go through `/api/yourapp/*` | Gateway proxies to backend |
| `credentials: 'include'` on every fetch | Cookie-based auth |
| Map raw API data through `mapX()` | Normalize backend shapes to clean interfaces |
| No shared stores or context | Each page is self-contained |
| No separate `+page.ts` load function | Data loads in `onMount` |
| Types defined inline in script | No shared type files |
### 5. UI component classes to use
| Class | When |
|-------|------|
| `.page` / `.page-header` / `.page-title` | Page-level layout |
| `.module` / `.module.primary` / `.module.flush` | Card containers |
| `.module-header` / `.module-title` / `.module-action` | Card headers |
| `.data-row` | List items with hover + zebra |
| `.badge` + `.error/.success/.warning/.accent/.muted` | Status indicators |
| `.tab-bar` + `.tab` | Pill-style tabs |
| `.btn-primary` / `.btn-secondary` / `.btn-icon` | Buttons |
| `.input` | Text inputs |
| `.skeleton` | Loading placeholders |
| `.section-label` | Uppercase group header |
### 6. Styling rules
- All colors from tokens: `var(--text-1)`, `var(--accent)`, `var(--card)`, etc.
- All spacing from tokens: `var(--sp-3)`, `var(--card-pad)`, `var(--row-gap)`, etc.
- All radii from tokens: `var(--radius)`, `var(--radius-md)`, etc.
- All shadows from tokens: `var(--shadow-md)`, `var(--shadow-lg)`, etc.
- All font sizes from tokens: `var(--text-sm)`, `var(--text-base)`, etc.
- Never use raw `#hex` colors, raw `px` spacing, or raw shadows unless listed in the Intentional Raw Values section above
- Dark mode is automatic via CSS custom properties — no manual `prefers-color-scheme` needed

View File

@@ -16,7 +16,7 @@ COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
ENV NODE_ENV=production
HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD wget -qO- http://localhost:3000/ || exit 1
HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD wget -qO- http://127.0.0.1:3000/ || exit 1
USER node
CMD ["node", "build"]

View File

@@ -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; }
}

View 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 &rarr;</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>

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>

View File

@@ -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} />

View File

@@ -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}

View File

@@ -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'],
};

View File

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

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>