feat(frontend): implement task CRUD functionality

- Create TaskItem component with checkbox, meta info, tags
- Create TaskList component with loading/empty states
- Update home page with task list and filter toggle (pending/completed)
- Add new task form with description, project, priority, due date, tags
- Add task detail placeholder page
- Implement task toggle (complete/uncomplete)
- Add filter bar to switch between pending and completed tasks
- Support for optimistic UI updates and offline queueing
- Visual indicators for priority, due dates, overdue tasks
- Mobile-optimized list items with proper touch targets
This commit is contained in:
2026-01-06 16:16:44 +01:00
parent 6b146c16a8
commit e8c6dd3930
5 changed files with 621 additions and 2 deletions
+176 -2
View File
@@ -1,2 +1,176 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.js';
import { tasksStore, pendingTasks, completedTasks } from '$lib/stores/tasks.js';
import TaskList from '$lib/components/TaskList.svelte';
import Button from '$lib/components/ui/Button.svelte';
let loading = true;
let showCompleted = false;
$: displayTasks = showCompleted ? $completedTasks : $pendingTasks;
onMount(async () => {
// Redirect to login if not authenticated
if (!$authStore.isAuthenticated) {
goto('/auth/login');
return;
}
// Load tasks
try {
await tasksStore.load({ status: showCompleted ? 'C' : 'P' });
} catch (error) {
console.error('Failed to load tasks:', error);
} finally {
loading = false;
}
});
/**
* Toggle task completion
* @param {string} uuid
*/
async function handleToggle(uuid) {
try {
await tasksStore.complete(uuid);
// Reload tasks
await tasksStore.load({ status: showCompleted ? 'C' : 'P' });
} catch (error) {
console.error('Failed to toggle task:', error);
}
}
/**
* Navigate to task detail
* @param {string} uuid
*/
function handleTaskClick(uuid) {
goto(`/tasks/${uuid}`);
}
/**
* Toggle between pending and completed view
*/
async function toggleView() {
showCompleted = !showCompleted;
loading = true;
try {
await tasksStore.load({ status: showCompleted ? 'C' : 'P' });
} catch (error) {
console.error('Failed to load tasks:', error);
} finally {
loading = false;
}
}
</script>
<div class="page">
<div class="container">
<div class="header">
<h1>Tasks</h1>
<a href="/tasks/new" class="new-btn">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
New
</a>
</div>
<div class="filter-bar">
<button
class="filter-btn"
class:active={!showCompleted}
on:click={() => !showCompleted || toggleView()}
>
Pending ({$pendingTasks.length})
</button>
<button
class="filter-btn"
class:active={showCompleted}
on:click={() => showCompleted || toggleView()}
>
Completed ({$completedTasks.length})
</button>
</div>
<TaskList
tasks={displayTasks}
{loading}
onToggle={handleToggle}
onTaskClick={handleTaskClick}
emptyMessage={showCompleted ? 'No completed tasks' : 'No pending tasks. Add one to get started!'}
/>
</div>
</div>
<style>
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-lg);
}
.header h1 {
margin-bottom: 0;
}
.icon {
width: 1rem;
height: 1rem;
}
.new-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem;
background-color: var(--color-primary);
color: white;
border-radius: var(--border-radius);
font-size: var(--font-size-sm);
font-weight: 500;
text-decoration: none;
transition: background-color 0.2s;
}
.new-btn:hover {
background-color: var(--color-primary-dark);
text-decoration: none;
}
.filter-bar {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
background-color: var(--bg-primary);
padding: var(--spacing-sm);
border-radius: var(--border-radius);
box-shadow: var(--shadow-sm);
}
.filter-btn {
flex: 1;
padding: 0.5rem 1rem;
background-color: transparent;
border: none;
border-radius: var(--border-radius);
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
.filter-btn.active {
background-color: var(--color-primary);
color: white;
}
.filter-btn:hover:not(.active) {
background-color: var(--bg-tertiary);
}
</style>
@@ -0,0 +1,26 @@
<script>
import { page } from '$app/stores';
import { goto } from '$app/navigation';
$: uuid = $page.params.uuid;
</script>
<div class="page">
<div class="container">
<h1>Task Detail</h1>
<p class="text-secondary">UUID: {uuid}</p>
<p class="text-secondary mb-lg">Task detail view coming in next iteration...</p>
<a href="/" class="btn-link">← Back to tasks</a>
</div>
</div>
<style>
.btn-link {
display: inline-block;
padding: 0.75rem 1.5rem;
background-color: var(--color-primary);
color: white;
border-radius: var(--border-radius);
text-decoration: none;
}
</style>
+172
View File
@@ -0,0 +1,172 @@
<script>
import { goto } from '$app/navigation';
import { tasksStore } from '$lib/stores/tasks.js';
import { generateUUID } from '$lib/utils/uuid.js';
import { toUnix } from '$lib/utils/dates.js';
import Button from '$lib/components/ui/Button.svelte';
import Input from '$lib/components/ui/Input.svelte';
import Select from '$lib/components/ui/Select.svelte';
let description = '';
let project = '';
let priority = '1';
let dueDate = '';
let tags = '';
let saving = false;
let error = '';
const priorityOptions = [
{ value: '0', label: 'Low' },
{ value: '1', label: 'Default' },
{ value: '2', label: 'Medium' },
{ value: '3', label: 'High' }
];
async function handleSubmit() {
if (!description.trim()) {
error = 'Description is required';
return;
}
saving = true;
error = '';
try {
/** @type {Partial<import('$lib/api/types.js').Task>} */
const task = {
uuid: generateUUID(),
description: description.trim(),
status: /** @type {'P'} */ ('P'),
priority: /** @type {0|1|2|3} */ (parseInt(priority)),
created: toUnix(new Date()),
modified: toUnix(new Date()),
tags: tags.trim() ? tags.split(',').map(t => t.trim()).filter(t => t) : [],
project: project.trim() || null,
due: dueDate ? toUnix(new Date(dueDate)) : null
};
await tasksStore.add(task);
goto('/');
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to create task';
} finally {
saving = false;
}
}
</script>
<div class="page">
<div class="container">
<div class="header">
<a href="/" class="back-btn">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Back
</a>
<h1>New Task</h1>
</div>
<form on:submit|preventDefault={handleSubmit} class="task-form">
{#if error}
<div class="error-banner">{error}</div>
{/if}
<Input
label="Description"
placeholder="What needs to be done?"
bind:value={description}
required
id="description"
/>
<Input
label="Project"
placeholder="Optional"
bind:value={project}
id="project"
/>
<Select
label="Priority"
options={priorityOptions}
bind:value={priority}
id="priority"
/>
<Input
label="Due Date"
type="date"
bind:value={dueDate}
id="due"
/>
<Input
label="Tags"
placeholder="Comma separated (e.g., work, urgent)"
bind:value={tags}
id="tags"
/>
<div class="actions">
<Button type="button" variant="secondary" on:click={() => goto('/')}>
Cancel
</Button>
<Button type="submit" loading={saving}>
Create Task
</Button>
</div>
</form>
</div>
</div>
<style>
.header {
display: flex;
align-items: center;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.back-btn {
display: flex;
align-items: center;
gap: 0.25rem;
color: var(--color-primary);
text-decoration: none;
font-size: var(--font-size-sm);
}
.icon {
width: 1.25rem;
height: 1.25rem;
}
.task-form {
background-color: var(--bg-primary);
border-radius: var(--border-radius);
padding: var(--spacing-lg);
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.error-banner {
background-color: #fee2e2;
color: var(--color-danger);
padding: var(--spacing-md);
border-radius: var(--border-radius);
font-size: var(--font-size-sm);
}
.actions {
display: flex;
gap: var(--spacing-md);
margin-top: var(--spacing-md);
}
.actions > :global(button) {
flex: 1;
}
</style>