Compare commits

..

3 Commits

27 changed files with 2298 additions and 176 deletions

View File

@@ -0,0 +1,12 @@
---
trigger: model_decision
description: When working on a TDD (technical design document)
---
TDD Should be added to the /tdd folder.
When writing a TDD, please write it so that it can be read by human or agent ai.
Please start by writing that it was authored by an ai agent and write your actual agent name.
Pleaes add the date.
The TDD should starts with the What, then the Why and finally the how.
Everytime you write a document you can read other tdd in the folder for inspiration.
When implementation is finished, the user can add to edit the TDD to add more informations (implementation details that are important, benchmarks, any change of plan during development)

View File

@@ -1,6 +1,8 @@
<script>
import { getSystemInfo, reboot, getOTAStatus } from "./lib/api.js";
import { getSystemInfo, reboot, getOTAStatus, getUpcomingTasks } from "./lib/api.js";
import OTAUpdate from "./lib/OTAUpdate.svelte";
import Sidebar from "./lib/Sidebar.svelte";
import TaskManager from "./lib/TaskManager.svelte";
/** @type {'loading' | 'ok' | 'error' | 'rebooting'} */
let status = $state("loading");
@@ -8,6 +10,9 @@
let showRebootConfirm = $state(false);
let isRecovering = $state(false);
/** @type {'dashboard' | 'tasks'} */
let currentView = $state("dashboard");
let systemInfo = $state({
chip: "—",
freeHeap: 0,
@@ -22,6 +27,8 @@
running_firmware_label: "—"
});
let upcomingData = $state({ users: [] });
/** Format uptime seconds into human-readable string */
function formatUptime(seconds) {
const d = Math.floor(seconds / 86400);
@@ -43,11 +50,37 @@
return `${bytes} B`;
}
function formatRelativeDate(timestamp) {
const now = Date.now() / 1000;
const diff = timestamp - now;
const absDiff = Math.abs(diff);
if (absDiff < 3600) {
const mins = Math.round(absDiff / 60);
return diff < 0 ? `${mins}m ago` : `in ${mins}m`;
}
if (absDiff < 86400) {
const hours = Math.round(absDiff / 3600);
return diff < 0 ? `${hours}h ago` : `in ${hours}h`;
}
const days = Math.round(absDiff / 86400);
return diff < 0 ? `${days}d ago` : `in ${days}d`;
}
function isOverdue(timestamp) {
return timestamp < Date.now() / 1000;
}
async function fetchAll() {
try {
const [sys, ota] = await Promise.all([getSystemInfo(), getOTAStatus()]);
const [sys, ota, upcoming] = await Promise.all([
getSystemInfo(),
getOTAStatus(),
getUpcomingTasks().catch(() => ({ users: [] }))
]);
systemInfo = sys;
otaStatus = ota;
upcomingData = upcoming;
status = "ok";
errorMsg = "";
} catch (e) {
@@ -104,174 +137,240 @@
]);
</script>
<main class="min-h-screen bg-bg-primary p-4 md:p-8 flex flex-col items-center">
<div class="w-full max-w-6xl space-y-8">
<!-- Header -->
<div class="text-center">
<h1 class="text-2xl font-bold text-accent">Calendink Provider 🚀🚀👑</h1>
<p class="text-text-secondary text-sm">ESP32-S3 System Dashboard v{__APP_VERSION__}</p>
<div class="app-layout">
<Sidebar {currentView} onNavigate={(view) => currentView = view} />
<!-- Status Badge -->
<div class="flex justify-center mt-4">
{#if status === "loading"}
<div class="inline-flex items-center gap-2 bg-accent/10 border border-accent/20 rounded-full px-4 py-1.5 text-xs text-accent">
<span class="w-2 h-2 rounded-full bg-accent animate-pulse"></span>
<span>Connecting...</span>
</div>
{:else if status === "ok"}
<div class="inline-flex items-center gap-2 bg-success/10 border border-success/20 rounded-full px-4 py-1.5 text-xs text-success">
<span class="w-2 h-2 rounded-full bg-success animate-pulse"></span>
<span>Connected</span>
</div>
{:else if status === "rebooting"}
<div class="inline-flex items-center gap-2 bg-amber-500/10 border border-amber-500/20 rounded-full px-4 py-1.5 text-xs text-amber-400">
<span class="w-2 h-2 rounded-full bg-amber-400 animate-pulse"></span>
<span>Rebooting...</span>
</div>
{:else}
<div class="inline-flex items-center gap-2 bg-danger/10 border border-danger/20 rounded-full px-4 py-1.5 text-xs text-danger">
<span class="w-2 h-2 rounded-full bg-danger"></span>
<span>Offline — {errorMsg}</span>
</div>
{/if}
</div>
</div>
<main class="main-content">
<div class="w-full max-w-6xl mx-auto space-y-8">
<!-- Header -->
<div class="text-center">
<h1 class="text-2xl font-bold text-accent">Calendink Provider 🚀👑🥸</h1>
<p class="text-text-secondary text-sm">ESP32-S3 System Dashboard v{__APP_VERSION__}</p>
<!-- 2-Column Grid Layout -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 items-start">
<!-- Left Column: System Info & Partition Table -->
<div class="space-y-8">
<!-- System Info Card -->
<div class="bg-bg-card border border-border rounded-xl overflow-hidden shadow-xl">
<div class="px-5 py-3 border-b border-border">
<h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider">
System Info
</h2>
</div>
<div class="divide-y divide-border">
{#each infoItems as item}
<div class="flex items-center justify-between px-5 py-3 hover:bg-bg-card-hover transition-colors">
<div class="flex items-center gap-3">
<span class="text-base">{item.icon}</span>
<span class="text-sm text-text-secondary">{item.label}</span>
</div>
<span class="text-sm font-mono text-text-primary">
{#if status === "loading"}
<span class="inline-block w-16 h-4 bg-border rounded animate-pulse"></span>
{:else}
{item.value}
{/if}
</span>
</div>
{/each}
</div>
<!-- Status Badge -->
<div class="flex justify-center mt-4">
{#if status === "loading"}
<div class="inline-flex items-center gap-2 bg-accent/10 border border-accent/20 rounded-full px-4 py-1.5 text-xs text-accent">
<span class="w-2 h-2 rounded-full bg-accent animate-pulse"></span>
<span>Connecting...</span>
</div>
{:else if status === "ok"}
<div class="inline-flex items-center gap-2 bg-success/10 border border-success/20 rounded-full px-4 py-1.5 text-xs text-success">
<span class="w-2 h-2 rounded-full bg-success animate-pulse"></span>
<span>Connected</span>
</div>
{:else if status === "rebooting"}
<div class="inline-flex items-center gap-2 bg-amber-500/10 border border-amber-500/20 rounded-full px-4 py-1.5 text-xs text-amber-400">
<span class="w-2 h-2 rounded-full bg-amber-400 animate-pulse"></span>
<span>Rebooting...</span>
</div>
{:else}
<div class="inline-flex items-center gap-2 bg-danger/10 border border-danger/20 rounded-full px-4 py-1.5 text-xs text-danger">
<span class="w-2 h-2 rounded-full bg-danger"></span>
<span>Offline — {errorMsg}</span>
</div>
{/if}
</div>
</div>
<!-- Partition Table Card -->
<div class="bg-bg-card border border-border rounded-xl overflow-hidden shadow-xl">
<div class="px-5 py-3 border-b border-border flex items-center justify-between">
<h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider">
Partition Table
</h2>
<span class="text-[10px] text-text-secondary font-mono">Flash: 16MB</span>
</div>
<div class="divide-y divide-border">
{#if status === "loading"}
<div class="p-5 text-center text-xs text-text-secondary animate-pulse">Loading memory layout...</div>
{:else}
{#each otaStatus.partitions as part}
<div class="px-5 py-2.5 flex items-center justify-between hover:bg-bg-card-hover transition-colors">
<div class="flex flex-col">
<div class="flex items-center gap-2">
<span class="text-[11px] font-mono font-bold {part.label === otaStatus.active_partition || part.label === otaStatus.running_firmware_label ? 'text-accent' : 'text-text-primary'}">
{part.label}
</span>
{#if part.label === otaStatus.active_partition || part.label === otaStatus.running_firmware_label}
<span class="text-[8px] bg-accent/20 text-accent px-1 rounded uppercase tracking-tighter">Active</span>
{/if}
{#if currentView === 'dashboard'}
<!-- Dashboard View -->
<!-- Upcoming Tasks Section (top priority) -->
{#if upcomingData.users.length > 0}
<div class="bg-bg-card border border-border rounded-xl overflow-hidden shadow-xl">
<div class="px-5 py-3 border-b border-border">
<h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider">
📋 Upcoming Tasks
</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-0 divide-y md:divide-y-0 md:divide-x divide-border">
{#each upcomingData.users as user}
<div class="p-4">
<h3 class="text-xs font-bold text-accent mb-3 uppercase tracking-wider">{user.name}</h3>
{#if user.tasks.length === 0}
<p class="text-[11px] text-text-secondary italic">No pending tasks</p>
{:else}
<div class="space-y-2">
{#each user.tasks as task}
<div class="flex items-start gap-2">
<span class="text-[10px] mt-0.5 {isOverdue(task.due_date) ? 'text-danger' : 'text-text-secondary'} font-mono whitespace-nowrap">
{formatRelativeDate(task.due_date)}
</span>
<span class="text-xs text-text-primary leading-tight">{task.title}</span>
</div>
{/each}
</div>
<span class="text-[9px] text-text-secondary uppercase">
Type {part.type} / Sub {part.subtype}
</span>
</div>
<div class="text-right flex flex-col items-end">
<div class="text-[11px] font-mono text-text-primary">{formatBytes(part.size)}</div>
<div class="flex items-center gap-1.5 mt-0.5">
{#if part.app_version}
<span class="text-[9px] text-accent font-bold">v{part.app_version}</span>
{/if}
{#if part.free !== undefined}
<span class="text-[9px] {part.free > 1024 ? 'text-success' : 'text-text-secondary'} font-bold">
{formatBytes(part.free)} free
</span>
{/if}
</div>
</div>
{/if}
</div>
{/each}
{/if}
</div>
</div>
</div>
<!-- Right Column: Device Control & OTA Updates -->
<div class="space-y-8">
<!-- Device Control Card -->
<div class="bg-bg-card border border-border rounded-xl p-5 shadow-xl">
<div class="flex items-center justify-between">
<div>
<h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider">
Device Control
</h2>
<p class="text-xs text-text-secondary mt-1">
Restart the ESP32 microcontroller
</p>
</div>
<button
onclick={() => (showRebootConfirm = true)}
disabled={status === "rebooting" || status === "loading"}
class="px-4 py-2 text-sm font-medium rounded-lg transition-colors
bg-danger/10 text-danger border border-danger/20
hover:bg-danger/20 hover:border-danger/30
disabled:opacity-40 disabled:cursor-not-allowed"
>
Reboot
</button>
</div>
{/if}
<!-- 2-Column Grid Layout -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 items-start">
<!-- Left Column: System Info & Partition Table -->
<div class="space-y-8">
<!-- System Info Card -->
<div class="bg-bg-card border border-border rounded-xl overflow-hidden shadow-xl">
<div class="px-5 py-3 border-b border-border">
<h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider">
System Info
</h2>
</div>
<div class="divide-y divide-border">
{#each infoItems as item}
<div class="flex items-center justify-between px-5 py-3 hover:bg-bg-card-hover transition-colors">
<div class="flex items-center gap-3">
<span class="text-base">{item.icon}</span>
<span class="text-sm text-text-secondary">{item.label}</span>
</div>
<span class="text-sm font-mono text-text-primary">
{#if status === "loading"}
<span class="inline-block w-16 h-4 bg-border rounded animate-pulse"></span>
{:else}
{item.value}
{/if}
</span>
</div>
{/each}
</div>
</div>
<!-- Partition Table Card -->
<div class="bg-bg-card border border-border rounded-xl overflow-hidden shadow-xl">
<div class="px-5 py-3 border-b border-border flex items-center justify-between">
<h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider">
Partition Table
</h2>
<span class="text-[10px] text-text-secondary font-mono">Flash: 16MB</span>
</div>
<div class="divide-y divide-border">
{#if status === "loading"}
<div class="p-5 text-center text-xs text-text-secondary animate-pulse">Loading memory layout...</div>
{:else}
{#each otaStatus.partitions as part}
<div class="px-5 py-2.5 flex items-center justify-between hover:bg-bg-card-hover transition-colors">
<div class="flex flex-col">
<div class="flex items-center gap-2">
<span class="text-[11px] font-mono font-bold {part.label === otaStatus.active_partition || part.label === otaStatus.running_firmware_label ? 'text-accent' : 'text-text-primary'}">
{part.label}
</span>
{#if part.label === otaStatus.active_partition || part.label === otaStatus.running_firmware_label}
<span class="text-[8px] bg-accent/20 text-accent px-1 rounded uppercase tracking-tighter">Active</span>
{/if}
</div>
<span class="text-[9px] text-text-secondary uppercase">
Type {part.type} / Sub {part.subtype}
</span>
</div>
<div class="text-right flex flex-col items-end">
<div class="text-[11px] font-mono text-text-primary">{formatBytes(part.size)}</div>
<div class="flex items-center gap-1.5 mt-0.5">
{#if part.app_version}
<span class="text-[9px] text-accent font-bold">v{part.app_version}</span>
{/if}
{#if part.free !== undefined}
<span class="text-[9px] {part.free > 1024 ? 'text-success' : 'text-text-secondary'} font-bold">
{formatBytes(part.free)} free
</span>
{/if}
</div>
</div>
</div>
{/each}
{/if}
</div>
</div>
</div>
<!-- Right Column: Device Control & OTA Updates -->
<div class="space-y-8">
<!-- Device Control Card -->
<div class="bg-bg-card border border-border rounded-xl p-5 shadow-xl">
<div class="flex items-center justify-between">
<div>
<h2 class="text-sm font-semibold text-text-primary uppercase tracking-wider">
Device Control
</h2>
<p class="text-xs text-text-secondary mt-1">
Restart the ESP32 microcontroller
</p>
</div>
<button
onclick={() => (showRebootConfirm = true)}
disabled={status === "rebooting" || status === "loading"}
class="px-4 py-2 text-sm font-medium rounded-lg transition-colors
bg-danger/10 text-danger border border-danger/20
hover:bg-danger/20 hover:border-danger/30
disabled:opacity-40 disabled:cursor-not-allowed"
>
Reboot
</button>
</div>
</div>
<!-- Updates & Maintenance Card -->
<OTAUpdate onReboot={() => { status = "rebooting"; isRecovering = true; }} />
</div>
</div>
<!-- Updates & Maintenance Card -->
<OTAUpdate onReboot={() => { status = "rebooting"; isRecovering = true; }} />
</div>
{:else if currentView === 'tasks'}
<!-- Task Manager View -->
<div class="bg-bg-card border border-border rounded-xl p-6 shadow-xl">
<TaskManager />
</div>
{/if}
<!-- Reboot Confirmation Modal -->
{#if showRebootConfirm}
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
<div class="bg-bg-card border border-border rounded-xl p-6 max-w-sm w-full space-y-4 shadow-2xl">
<h3 class="text-lg font-semibold text-text-primary">Confirm Reboot</h3>
<p class="text-sm text-text-secondary">
Are you sure you want to reboot the ESP32? The device will be temporarily unavailable.
</p>
<div class="flex gap-3 justify-end">
<button
onclick={() => (showRebootConfirm = false)}
class="px-4 py-2 text-sm rounded-lg bg-border/30 text-text-secondary hover:bg-border/50 transition-colors"
>
Cancel
</button>
<button
onclick={handleReboot}
class="px-4 py-2 text-sm font-medium rounded-lg bg-danger text-white hover:bg-danger-hover transition-colors shadow-lg shadow-danger/20"
>
Reboot Now
</button>
</div>
</div>
</div>
{/if}
</div>
</main>
</div>
<!-- Reboot Confirmation Modal -->
{#if showRebootConfirm}
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
<div class="bg-bg-card border border-border rounded-xl p-6 max-w-sm w-full space-y-4 shadow-2xl">
<h3 class="text-lg font-semibold text-text-primary">Confirm Reboot</h3>
<p class="text-sm text-text-secondary">
Are you sure you want to reboot the ESP32? The device will be temporarily unavailable.
</p>
<div class="flex gap-3 justify-end">
<button
onclick={() => (showRebootConfirm = false)}
class="px-4 py-2 text-sm rounded-lg bg-border/30 text-text-secondary hover:bg-border/50 transition-colors"
>
Cancel
</button>
<button
onclick={handleReboot}
class="px-4 py-2 text-sm font-medium rounded-lg bg-danger text-white hover:bg-danger-hover transition-colors shadow-lg shadow-danger/20"
>
Reboot Now
</button>
</div>
</div>
</div>
{/if}
<style>
.app-layout {
display: flex;
min-height: 100vh;
background: var(--color-bg-primary);
}
</div>
</main>
.main-content {
flex: 1;
padding: 16px 32px;
overflow-y: auto;
}
@media (max-width: 768px) {
.main-content {
padding: 16px;
}
}
</style>

View File

@@ -0,0 +1,135 @@
<script>
let { currentView = 'dashboard', onNavigate = () => {} } = $props();
let collapsed = $state(typeof window !== 'undefined' && window.innerWidth <= 768);
const navItems = [
{ id: 'dashboard', label: 'Dashboard', icon: '🏠' },
{ id: 'tasks', label: 'Tasks', icon: '📋' },
];
</script>
<aside class="sidebar {collapsed ? 'collapsed' : ''}">
<div class="sidebar-header">
{#if !collapsed}
<span class="sidebar-title">Menu</span>
{/if}
<button
class="collapse-btn"
onclick={() => collapsed = !collapsed}
title={collapsed ? 'Expand' : 'Collapse'}
>
{collapsed ? '▶' : '◀'}
</button>
</div>
<nav class="sidebar-nav">
{#each navItems as item}
<button
class="nav-item {currentView === item.id ? 'active' : ''}"
onclick={() => onNavigate(item.id)}
title={item.label}
>
<span class="nav-icon">{item.icon}</span>
{#if !collapsed}
<span class="nav-label">{item.label}</span>
{/if}
</button>
{/each}
</nav>
</aside>
<style>
.sidebar {
width: 200px;
min-height: 100vh;
background: var(--color-bg-card);
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
transition: width 0.25s ease;
flex-shrink: 0;
}
.sidebar.collapsed {
width: 56px;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 12px;
border-bottom: 1px solid var(--color-border);
min-height: 56px;
}
.sidebar-title {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
}
.collapse-btn {
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
padding: 4px;
font-size: 10px;
border-radius: 4px;
transition: color 0.15s, background 0.15s;
flex-shrink: 0;
}
.collapse-btn:hover {
color: var(--color-text-primary);
background: var(--color-bg-card-hover);
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.15s;
white-space: nowrap;
text-align: left;
}
.nav-item:hover {
background: var(--color-bg-card-hover);
color: var(--color-text-primary);
}
.nav-item.active {
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
color: var(--color-accent);
}
.nav-icon {
font-size: 16px;
flex-shrink: 0;
}
.nav-label {
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,541 @@
<script>
import { getTasks, addTask, updateTask, deleteTask } from './api.js';
import UserManager from './UserManager.svelte';
let selectedUserId = $state(null);
let tasks = $state([]);
let error = $state('');
// Add task form state
let newTitle = $state('');
let newDueDay = $state('');
let newDueTime = $state('');
let showAddForm = $state(false);
// Edit state
let editingTaskId = $state(null);
let editTitle = $state('');
let editDueDay = $state('');
let editDueTime = $state('');
// Confirm delete
let confirmDeleteId = $state(null);
async function fetchTasks() {
if (!selectedUserId) {
tasks = [];
return;
}
try {
tasks = await getTasks(selectedUserId);
// Sort by due_date ascending
tasks.sort((a, b) => a.due_date - b.due_date);
error = '';
} catch (e) {
error = e.message;
}
}
// Refetch when selected user changes
$effect(() => {
if (selectedUserId) {
fetchTasks();
} else {
tasks = [];
}
});
async function handleAddTask(e) {
e.preventDefault();
if (!newTitle.trim() || !newDueDay || !newDueTime) return;
const dueTimestamp = Math.floor(new Date(`${newDueDay}T${newDueTime}`).getTime() / 1000);
try {
await addTask(selectedUserId, newTitle.trim(), dueTimestamp);
newTitle = '';
newDueDay = '';
newDueTime = '';
showAddForm = false;
await fetchTasks();
} catch (e) {
error = e.message;
}
}
async function handleToggleComplete(task) {
try {
await updateTask(task.id, { completed: !task.completed });
await fetchTasks();
} catch (e) {
error = e.message;
}
}
function startEditing(task) {
editingTaskId = task.id;
editTitle = task.title;
const parts = formatDateForInput(task.due_date);
editDueDay = parts.day;
editDueTime = parts.time;
}
async function saveEdit(e) {
e.preventDefault();
if (!editTitle.trim()) return;
const dueTimestamp = Math.floor(new Date(`${editDueDay}T${editDueTime}`).getTime() / 1000);
try {
await updateTask(editingTaskId, {
title: editTitle.trim(),
due_date: dueTimestamp
});
editingTaskId = null;
await fetchTasks();
} catch (e) {
error = e.message;
}
}
async function handleDelete(id) {
try {
await deleteTask(id);
confirmDeleteId = null;
await fetchTasks();
} catch (e) {
error = e.message;
}
}
function formatDateForInput(timestamp) {
const d = new Date(timestamp * 1000);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const dayNum = String(d.getDate()).padStart(2, '0');
const hours = String(d.getHours()).padStart(2, '0');
const mins = String(d.getMinutes()).padStart(2, '0');
return { day: `${year}-${month}-${dayNum}`, time: `${hours}:${mins}` };
}
function setNowForNewTask() {
const parts = formatDateForInput(Date.now() / 1000);
newDueDay = parts.day;
newDueTime = parts.time;
}
function formatRelativeDate(timestamp) {
const now = Date.now() / 1000;
const diff = timestamp - now;
const absDiff = Math.abs(diff);
if (absDiff < 3600) {
const mins = Math.round(absDiff / 60);
return diff < 0 ? `${mins}m ago` : `in ${mins}m`;
}
if (absDiff < 86400) {
const hours = Math.round(absDiff / 3600);
return diff < 0 ? `${hours}h ago` : `in ${hours}h`;
}
const days = Math.round(absDiff / 86400);
return diff < 0 ? `${days}d ago` : `in ${days}d`;
}
function isOverdue(timestamp) {
return timestamp < Date.now() / 1000;
}
</script>
<div class="task-manager">
<UserManager bind:selectedUserId onUsersChanged={fetchTasks} />
{#if selectedUserId}
<div class="task-header">
<h2 class="task-title">Tasks</h2>
{#if !showAddForm}
<button class="add-task-btn" onclick={() => { showAddForm = true; setNowForNewTask(); }}>+ Add Task</button>
{/if}
</div>
{#if showAddForm}
<form class="add-task-form" onsubmit={handleAddTask}>
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
bind:value={newTitle}
placeholder="Task title..."
class="task-input"
autofocus
/>
<div class="date-time-row">
<label class="date-time-field">
<span class="field-label">Date</span>
<input type="date" bind:value={newDueDay} class="task-date-input" />
</label>
<label class="date-time-field">
<span class="field-label">Time</span>
<input type="time" bind:value={newDueTime} class="task-time-input" />
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" disabled={!newTitle.trim() || !newDueDay || !newDueTime}>Add</button>
<button type="button" class="btn-secondary" onclick={() => { showAddForm = false; newTitle = ''; newDueDay = ''; newDueTime = ''; }}>Cancel</button>
</div>
</form>
{/if}
<div class="task-list">
{#each tasks as task}
<div class="task-item {task.completed ? 'completed' : ''} {isOverdue(task.due_date) && !task.completed ? 'overdue' : ''}">
{#if editingTaskId === task.id}
<form class="edit-form" onsubmit={saveEdit}>
<input type="text" bind:value={editTitle} class="task-input" />
<div class="date-time-row">
<label class="date-time-field">
<span class="field-label">Date</span>
<input type="date" bind:value={editDueDay} class="task-date-input" />
</label>
<label class="date-time-field">
<span class="field-label">Time</span>
<input type="time" bind:value={editDueTime} class="task-time-input" />
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary btn-sm">Save</button>
<button type="button" class="btn-secondary btn-sm" onclick={() => editingTaskId = null}>Cancel</button>
</div>
</form>
{:else}
<div class="task-left">
<input
type="checkbox"
checked={task.completed}
onchange={() => handleToggleComplete(task)}
class="task-checkbox"
/>
<div class="task-info">
<span class="task-text">{task.title}</span>
<span class="task-due {isOverdue(task.due_date) && !task.completed ? 'text-overdue' : ''}">
{formatRelativeDate(task.due_date)}
</span>
</div>
</div>
<div class="task-actions">
<button class="action-btn" onclick={() => startEditing(task)} title="Edit">✏️</button>
{#if confirmDeleteId === task.id}
<button class="action-btn text-danger" onclick={() => handleDelete(task.id)} title="Confirm"></button>
<button class="action-btn" onclick={() => confirmDeleteId = null} title="Cancel"></button>
{:else}
<button class="action-btn" onclick={() => confirmDeleteId = task.id} title="Delete">🗑️</button>
{/if}
</div>
{/if}
</div>
{:else}
<div class="empty-state">No tasks yet. Add one above!</div>
{/each}
</div>
{:else}
<div class="empty-state">Select or add a user to see their tasks.</div>
{/if}
{#if error}
<div class="task-error">{error}</div>
{/if}
</div>
<style>
.task-manager {
width: 100%;
}
.task-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.task-title {
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-primary);
}
.add-task-btn {
padding: 6px 14px;
border-radius: 8px;
border: 1px solid var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
color: var(--color-accent);
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: all 0.15s;
}
.add-task-btn:hover {
background: color-mix(in srgb, var(--color-accent) 20%, transparent);
}
.add-task-form, .edit-form {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border-radius: 10px;
border: 1px solid var(--color-border);
background: var(--color-bg-card);
margin-bottom: 12px;
}
.task-input {
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--color-border);
background: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: 13px;
outline: none;
width: 100%;
box-sizing: border-box;
}
.task-input:focus {
border-color: var(--color-accent);
}
.date-time-row {
display: flex;
gap: 8px;
}
.date-time-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.date-time-field:first-child {
flex: 1;
}
.field-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
}
.task-date-input {
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--color-border);
background: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: 13px;
outline: none;
color-scheme: dark;
flex: 1;
cursor: pointer;
position: relative;
}
.task-time-input {
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--color-border);
background: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: 13px;
outline: none;
color-scheme: dark;
width: 110px;
cursor: pointer;
position: relative;
}
/* Make the native picker icon cover the entire input (Chrome/Edge only) */
.task-date-input::-webkit-calendar-picker-indicator,
.task-time-input::-webkit-calendar-picker-indicator {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: auto;
height: auto;
opacity: 0;
cursor: pointer;
}
.task-date-input:focus, .task-time-input:focus {
border-color: var(--color-accent);
}
.form-actions {
display: flex;
gap: 6px;
}
.btn-primary {
padding: 6px 16px;
border-radius: 8px;
border: none;
background: var(--color-accent);
color: white;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: filter 0.15s;
}
.btn-primary:hover {
filter: brightness(1.1);
}
.btn-primary:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-secondary {
padding: 6px 16px;
border-radius: 8px;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 12px;
transition: all 0.15s;
}
.btn-secondary:hover {
background: var(--color-bg-card-hover);
}
.btn-sm {
padding: 4px 10px;
font-size: 11px;
}
.task-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.task-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-radius: 10px;
border: 1px solid var(--color-border);
background: var(--color-bg-card);
transition: all 0.15s;
}
.task-item:hover {
background: var(--color-bg-card-hover);
}
.task-item.completed {
opacity: 0.5;
}
.task-item.overdue {
border-color: color-mix(in srgb, var(--color-danger) 40%, transparent);
}
.task-left {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
}
.task-checkbox {
width: 16px;
height: 16px;
accent-color: var(--color-accent);
cursor: pointer;
flex-shrink: 0;
}
.task-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.task-text {
font-size: 13px;
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.completed .task-text {
text-decoration: line-through;
color: var(--color-text-secondary);
}
.task-due {
font-size: 10px;
color: var(--color-text-secondary);
font-weight: 500;
}
.text-overdue {
color: var(--color-danger) !important;
}
.text-danger {
color: var(--color-danger);
}
.task-actions {
display: flex;
gap: 4px;
align-items: center;
flex-shrink: 0;
}
.action-btn {
background: none;
border: none;
cursor: pointer;
font-size: 12px;
padding: 4px;
border-radius: 4px;
transition: background 0.15s;
opacity: 0.6;
}
.action-btn:hover {
opacity: 1;
background: var(--color-bg-card-hover);
}
.empty-state {
text-align: center;
padding: 32px;
color: var(--color-text-secondary);
font-size: 13px;
}
.task-error {
margin-top: 8px;
padding: 6px 10px;
border-radius: 6px;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-danger) 20%, transparent);
color: var(--color-danger);
font-size: 11px;
}
</style>

View File

@@ -0,0 +1,274 @@
<script>
import { getUsers, addUser, removeUser } from './api.js';
let { selectedUserId = $bindable(null), onUsersChanged = () => {} } = $props();
let users = $state([]);
let newUserName = $state('');
let showAddForm = $state(false);
let error = $state('');
let confirmDeleteId = $state(null);
async function fetchUsers() {
try {
users = await getUsers();
if (users.length > 0 && !selectedUserId) {
selectedUserId = users[0].id;
}
// If selected user was deleted, select first available
if (selectedUserId && !users.find(u => u.id === selectedUserId)) {
selectedUserId = users.length > 0 ? users[0].id : null;
}
error = '';
} catch (e) {
error = e.message;
}
}
async function handleAddUser() {
if (!newUserName.trim()) return;
try {
const user = await addUser(newUserName.trim());
newUserName = '';
showAddForm = false;
await fetchUsers();
selectedUserId = user.id;
onUsersChanged();
} catch (e) {
error = e.message;
}
}
async function handleRemoveUser(id) {
try {
await removeUser(id);
confirmDeleteId = null;
await fetchUsers();
onUsersChanged();
} catch (e) {
error = e.message;
}
}
$effect(() => {
fetchUsers();
});
</script>
<div class="user-manager">
<div class="user-bar">
<div class="user-chips">
{#each users as user}
<!-- svelte-ignore node_invalid_placement_ssr -->
<div
class="user-chip {selectedUserId === user.id ? 'selected' : ''}"
onclick={() => selectedUserId = user.id}
role="button"
tabindex="0"
onkeydown={(e) => { if (e.key === 'Enter') selectedUserId = user.id; }}
>
<span class="user-name">{user.name}</span>
{#if confirmDeleteId === user.id}
<span class="confirm-delete">
<button class="confirm-yes" onclick={(e) => { e.stopPropagation(); handleRemoveUser(user.id); }} title="Confirm delete"></button>
<button class="confirm-no" onclick={(e) => { e.stopPropagation(); confirmDeleteId = null; }} title="Cancel"></button>
</span>
{:else}
<button
class="remove-btn"
onclick={(e) => { e.stopPropagation(); confirmDeleteId = user.id; }}
title="Remove user"
></button>
{/if}
</div>
{/each}
{#if showAddForm}
<form class="add-user-form" onsubmit={(e) => { e.preventDefault(); handleAddUser(); }}>
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
bind:value={newUserName}
placeholder="Name..."
class="add-user-input"
autofocus
/>
<button type="submit" class="add-user-submit" disabled={!newUserName.trim()}>+</button>
<button type="button" class="add-user-cancel" onclick={() => { showAddForm = false; newUserName = ''; }}>✕</button>
</form>
{:else}
<button class="add-user-btn" onclick={() => showAddForm = true}>
+ User
</button>
{/if}
</div>
</div>
{#if error}
<div class="user-error">{error}</div>
{/if}
</div>
<style>
.user-manager {
margin-bottom: 16px;
}
.user-bar {
display: flex;
align-items: center;
gap: 8px;
}
.user-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.user-chip {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 20px;
border: 1px solid var(--color-border);
background: var(--color-bg-card);
color: var(--color-text-secondary);
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all 0.15s;
}
.user-chip:hover {
border-color: var(--color-accent);
color: var(--color-text-primary);
}
.user-chip.selected {
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
border-color: var(--color-accent);
color: var(--color-accent);
}
.user-name {
white-space: nowrap;
}
.remove-btn {
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 10px;
padding: 0 2px;
opacity: 0.5;
transition: opacity 0.15s, color 0.15s;
line-height: 1;
}
.remove-btn:hover {
opacity: 1;
color: var(--color-danger);
}
.confirm-delete {
display: flex;
gap: 4px;
}
.confirm-yes, .confirm-no {
background: none;
border: none;
cursor: pointer;
font-size: 11px;
padding: 0 2px;
line-height: 1;
}
.confirm-yes {
color: var(--color-danger);
}
.confirm-no {
color: var(--color-text-secondary);
}
.add-user-btn {
padding: 6px 12px;
border-radius: 20px;
border: 1px dashed var(--color-border);
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all 0.15s;
}
.add-user-btn:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
.add-user-form {
display: flex;
align-items: center;
gap: 4px;
}
.add-user-input {
padding: 5px 10px;
border-radius: 16px;
border: 1px solid var(--color-border);
background: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: 12px;
width: 100px;
outline: none;
}
.add-user-input:focus {
border-color: var(--color-accent);
}
.add-user-submit {
background: var(--color-accent);
color: white;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.add-user-submit:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.add-user-cancel {
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 12px;
}
.user-error {
margin-top: 8px;
padding: 6px 10px;
border-radius: 6px;
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-danger) 20%, transparent);
color: var(--color-danger);
font-size: 11px;
}
</style>

View File

@@ -116,3 +116,127 @@ export async function uploadOTABundle(file) {
return res.json();
}
// ─── User Management ─────────────────────────────────────────────────────────
/**
* Fetch all users.
* @returns {Promise<Array<{id: number, name: string}>>}
*/
export async function getUsers() {
const res = await fetch(`${API_BASE}/api/users`);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return res.json();
}
/**
* Create a new user.
* @param {string} name
* @returns {Promise<{id: number, name: string}>}
*/
export async function addUser(name) {
const res = await fetch(`${API_BASE}/api/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(`Failed (${res.status}): ${errorText || res.statusText}`);
}
return res.json();
}
/**
* Delete a user and all their tasks.
* @param {number} id
* @returns {Promise<{status: string}>}
*/
export async function removeUser(id) {
const res = await fetch(`${API_BASE}/api/users?id=${id}`, {
method: 'DELETE'
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(`Failed (${res.status}): ${errorText || res.statusText}`);
}
return res.json();
}
// ─── Task Management ─────────────────────────────────────────────────────────
/**
* Fetch tasks for a specific user.
* @param {number} userId
* @returns {Promise<Array<{id: number, user_id: number, title: string, due_date: number, completed: boolean}>>}
*/
export async function getTasks(userId) {
const res = await fetch(`${API_BASE}/api/tasks?user_id=${userId}`);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return res.json();
}
/**
* Fetch top 3 upcoming tasks per user (for Dashboard).
* @returns {Promise<{users: Array<{id: number, name: string, tasks: Array}>}>}
*/
export async function getUpcomingTasks() {
const res = await fetch(`${API_BASE}/api/tasks/upcoming`);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return res.json();
}
/**
* Create a new task.
* @param {number} userId
* @param {string} title
* @param {number} dueDate Unix timestamp in seconds
* @returns {Promise<{id: number, user_id: number, title: string, due_date: number, completed: boolean}>}
*/
export async function addTask(userId, title, dueDate) {
const res = await fetch(`${API_BASE}/api/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId, title, due_date: dueDate })
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(`Failed (${res.status}): ${errorText || res.statusText}`);
}
return res.json();
}
/**
* Update a task (partial update — only include fields you want to change).
* @param {number} id
* @param {Object} fields - { title?: string, due_date?: number, completed?: boolean }
* @returns {Promise<{status: string}>}
*/
export async function updateTask(id, fields) {
const res = await fetch(`${API_BASE}/api/tasks/update`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, ...fields })
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(`Failed (${res.status}): ${errorText || res.statusText}`);
}
return res.json();
}
/**
* Delete a task.
* @param {number} id
* @returns {Promise<{status: string}>}
*/
export async function deleteTask(id) {
const res = await fetch(`${API_BASE}/api/tasks?id=${id}`, {
method: 'DELETE'
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(`Failed (${res.status}): ${errorText || res.statusText}`);
}
return res.json();
}

View File

@@ -1,5 +1,5 @@
{
"major": 0,
"minor": 1,
"revision": 14
"revision": 4
}

View File

@@ -2,7 +2,7 @@ idf_component_register(SRCS "main.cpp"
# Needed as we use minimal build
PRIV_REQUIRES esp_http_server esp_eth
esp_wifi nvs_flash esp_netif vfs
json app_update esp_timer
json app_update esp_timer esp_psram
INCLUDE_DIRS ".")
if(CONFIG_CALENDINK_DEPLOY_WEB_PAGES)

View File

@@ -0,0 +1,76 @@
// POST /api/tasks — Create a new task
// Body: {"user_id":1, "title":"...", "due_date":1741369200}
#include "cJSON.h"
#include "esp_http_server.h"
#include "todo.hpp"
#include "types.hpp"
internal esp_err_t api_tasks_post_handler(httpd_req_t *req)
{
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
char buf[256];
int received = httpd_req_recv(req, buf, sizeof(buf) - 1);
if (received <= 0)
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Empty body");
return ESP_FAIL;
}
buf[received] = '\0';
cJSON *body = cJSON_Parse(buf);
if (!body)
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON");
return ESP_FAIL;
}
cJSON *user_id_item = cJSON_GetObjectItem(body, "user_id");
cJSON *title_item = cJSON_GetObjectItem(body, "title");
cJSON *due_date_item = cJSON_GetObjectItem(body, "due_date");
if (!cJSON_IsNumber(user_id_item) || !cJSON_IsString(title_item) ||
!cJSON_IsNumber(due_date_item))
{
cJSON_Delete(body);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST,
"Missing user_id, title, or due_date");
return ESP_FAIL;
}
task_t *task =
add_task((uint8)user_id_item->valueint, title_item->valuestring,
(int64)due_date_item->valuedouble);
cJSON_Delete(body);
if (!task)
{
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"Task limit reached or invalid user");
return ESP_FAIL;
}
cJSON *resp = cJSON_CreateObject();
cJSON_AddNumberToObject(resp, "id", task->id);
cJSON_AddNumberToObject(resp, "user_id", task->user_id);
cJSON_AddStringToObject(resp, "title", task->title);
cJSON_AddNumberToObject(resp, "due_date", (double)task->due_date);
cJSON_AddBoolToObject(resp, "completed", task->completed);
const char *json = cJSON_PrintUnformatted(resp);
httpd_resp_sendstr(req, json);
free((void *)json);
cJSON_Delete(resp);
return ESP_OK;
}
internal const httpd_uri_t api_tasks_post_uri = {.uri = "/api/tasks",
.method = HTTP_POST,
.handler =
api_tasks_post_handler,
.user_ctx = NULL};

View File

@@ -0,0 +1,62 @@
// GET /api/tasks?user_id=N — List tasks for a specific user
#include "cJSON.h"
#include "esp_http_server.h"
#include "todo.hpp"
#include "types.hpp"
internal esp_err_t api_tasks_get_handler(httpd_req_t *req)
{
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
char query[64] = {};
if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK)
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST,
"Missing query param 'user_id'");
return ESP_FAIL;
}
char user_id_str[8] = {};
if (httpd_query_key_value(query, "user_id", user_id_str,
sizeof(user_id_str)) != ESP_OK)
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST,
"Missing query param 'user_id'");
return ESP_FAIL;
}
uint8 user_id = (uint8)atoi(user_id_str);
cJSON *arr = cJSON_CreateArray();
for (int i = 0; i < MAX_TASKS; i++)
{
if (g_Tasks[i].active && g_Tasks[i].user_id == user_id)
{
cJSON *obj = cJSON_CreateObject();
cJSON_AddNumberToObject(obj, "id", g_Tasks[i].id);
cJSON_AddNumberToObject(obj, "user_id", g_Tasks[i].user_id);
cJSON_AddStringToObject(obj, "title", g_Tasks[i].title);
cJSON_AddNumberToObject(obj, "due_date", (double)g_Tasks[i].due_date);
cJSON_AddBoolToObject(obj, "completed", g_Tasks[i].completed);
cJSON_AddItemToArray(arr, obj);
}
}
const char *json = cJSON_PrintUnformatted(arr);
httpd_resp_sendstr(req, json);
free((void *)json);
cJSON_Delete(arr);
return ESP_OK;
}
internal const httpd_uri_t api_tasks_get_uri = {.uri = "/api/tasks",
.method = HTTP_GET,
.handler =
api_tasks_get_handler,
.user_ctx = NULL};

View File

@@ -0,0 +1,42 @@
// DELETE /api/tasks?id=N — Delete a task
#include "esp_http_server.h"
#include "types.hpp"
internal esp_err_t api_tasks_delete_handler(httpd_req_t *req)
{
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
char query[32] = {};
if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK)
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing query param 'id'");
return ESP_FAIL;
}
char id_str[8] = {};
if (httpd_query_key_value(query, "id", id_str, sizeof(id_str)) != ESP_OK)
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing query param 'id'");
return ESP_FAIL;
}
uint16 id = (uint16)atoi(id_str);
if (!remove_task(id))
{
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Task not found");
return ESP_FAIL;
}
httpd_resp_sendstr(req, "{\"status\":\"ok\"}");
return ESP_OK;
}
internal const httpd_uri_t api_tasks_delete_uri = {.uri = "/api/tasks",
.method = HTTP_DELETE,
.handler =
api_tasks_delete_handler,
.user_ctx = NULL};

View File

@@ -0,0 +1,111 @@
// Task data store: CRUD helpers, sorting, and seed data
#include "esp_timer.h"
#include "api/tasks/store.hpp"
#include "api/users/store.hpp"
// Find a task by ID, returns nullptr if not found
internal task_t *find_task(uint16 id)
{
for (int i = 0; i < MAX_TASKS; i++)
{
if (g_Tasks[i].active && g_Tasks[i].id == id)
{
return &g_Tasks[i];
}
}
return nullptr;
}
// Add a task, returns pointer to new task or nullptr if full
internal task_t *add_task(uint8 user_id, const char *title, int64 due_date)
{
// Verify user exists
if (find_user(user_id) == nullptr)
{
return nullptr;
}
for (int i = 0; i < MAX_TASKS; i++)
{
if (!g_Tasks[i].active)
{
g_Tasks[i].id = g_NextTaskId++;
g_Tasks[i].user_id = user_id;
strlcpy(g_Tasks[i].title, title, sizeof(g_Tasks[i].title));
g_Tasks[i].due_date = due_date;
g_Tasks[i].completed = false;
g_Tasks[i].active = true;
return &g_Tasks[i];
}
}
return nullptr;
}
// Remove a task by ID, returns true if found and removed
internal bool remove_task(uint16 id)
{
for (int i = 0; i < MAX_TASKS; i++)
{
if (g_Tasks[i].active && g_Tasks[i].id == id)
{
g_Tasks[i].active = false;
g_Tasks[i].id = 0;
return true;
}
}
return false;
}
// Remove all tasks belonging to a user
internal void remove_tasks_for_user(uint8 user_id)
{
for (int i = 0; i < MAX_TASKS; i++)
{
if (g_Tasks[i].active && g_Tasks[i].user_id == user_id)
{
g_Tasks[i].active = false;
g_Tasks[i].id = 0;
}
}
}
// Simple insertion sort for small arrays — sort task pointers by due_date
// ascending
internal void sort_tasks_by_due_date(task_t **arr, int count)
{
for (int i = 1; i < count; i++)
{
task_t *key = arr[i];
int j = i - 1;
while (j >= 0 && arr[j]->due_date > key->due_date)
{
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
}
// Populate dummy tasks on boot for development iteration.
// Uses relative offsets from current time so due dates always make sense.
internal void seed_tasks()
{
int64 now = (int64)(esp_timer_get_time() / 1000000);
// Alice's tasks (user_id = 1)
add_task(1, "Buy groceries", now + 86400); // +1 day
add_task(1, "Review PR #42", now + 3600); // +1 hour
add_task(1, "Book dentist appointment", now + 172800); // +2 days
add_task(1, "Update resume", now + 604800); // +7 days
// Bob's tasks (user_id = 2)
add_task(2, "Fix login bug", now + 7200); // +2 hours
add_task(2, "Deploy staging", now + 43200); // +12 hours
add_task(2, "Write unit tests", now + 259200); // +3 days
// Charlie's tasks (user_id = 3)
add_task(3, "Water plants", now + 1800); // +30 min
add_task(3, "Call plumber", now + 86400); // +1 day
}

View File

@@ -0,0 +1,12 @@
#pragma once
#include "todo.hpp"
#include "types.hpp"
// Data store operations for tasks
internal task_t *find_task(uint16 id);
internal task_t *add_task(uint8 user_id, const char *title, int64 due_date);
internal bool remove_task(uint16 id);
internal void remove_tasks_for_user(uint8 user_id);
internal void sort_tasks_by_due_date(task_t **arr, int count);
internal void seed_tasks();

View File

@@ -0,0 +1,8 @@
// clang-format off
#include "api/tasks/store.cpp"
#include "api/tasks/add.cpp"
#include "api/tasks/list.cpp"
#include "api/tasks/remove.cpp"
#include "api/tasks/upcoming.cpp"
#include "api/tasks/update.cpp"
// clang-format on

View File

@@ -0,0 +1,72 @@
// GET /api/tasks/upcoming — Top 3 upcoming tasks per user (for Dashboard)
#include "cJSON.h"
#include "esp_http_server.h"
#include "todo.hpp"
#include "types.hpp"
internal esp_err_t api_tasks_upcoming_handler(httpd_req_t *req)
{
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
cJSON *root = cJSON_CreateObject();
cJSON *users_arr = cJSON_AddArrayToObject(root, "users");
for (int u = 0; u < MAX_USERS; u++)
{
if (!g_Users[u].active)
continue;
// Collect incomplete tasks for this user
task_t *user_tasks[MAX_TASKS];
int count = 0;
for (int t = 0; t < MAX_TASKS; t++)
{
if (g_Tasks[t].active && g_Tasks[t].user_id == g_Users[u].id &&
!g_Tasks[t].completed)
{
user_tasks[count++] = &g_Tasks[t];
}
}
// Sort by due_date ascending
sort_tasks_by_due_date(user_tasks, count);
// Build user object with top 3
cJSON *user_obj = cJSON_CreateObject();
cJSON_AddNumberToObject(user_obj, "id", g_Users[u].id);
cJSON_AddStringToObject(user_obj, "name", g_Users[u].name);
cJSON *tasks_arr = cJSON_AddArrayToObject(user_obj, "tasks");
int limit = count < 3 ? count : 3;
for (int i = 0; i < limit; i++)
{
cJSON *t_obj = cJSON_CreateObject();
cJSON_AddNumberToObject(t_obj, "id", user_tasks[i]->id);
cJSON_AddStringToObject(t_obj, "title", user_tasks[i]->title);
cJSON_AddNumberToObject(t_obj, "due_date",
(double)user_tasks[i]->due_date);
cJSON_AddBoolToObject(t_obj, "completed", user_tasks[i]->completed);
cJSON_AddItemToArray(tasks_arr, t_obj);
}
cJSON_AddItemToArray(users_arr, user_obj);
}
const char *json = cJSON_PrintUnformatted(root);
httpd_resp_sendstr(req, json);
free((void *)json);
cJSON_Delete(root);
return ESP_OK;
}
internal const httpd_uri_t api_tasks_upcoming_uri = {
.uri = "/api/tasks/upcoming",
.method = HTTP_GET,
.handler = api_tasks_upcoming_handler,
.user_ctx = NULL};

View File

@@ -0,0 +1,77 @@
// POST /api/tasks/update — Modify a task
// Body: {"id":1, "title":"...", "due_date":..., "completed":true}
// All fields except "id" are optional
#include "cJSON.h"
#include "esp_http_server.h"
#include "todo.hpp"
#include "types.hpp"
internal esp_err_t api_tasks_update_handler(httpd_req_t *req)
{
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
char buf[256];
int received = httpd_req_recv(req, buf, sizeof(buf) - 1);
if (received <= 0)
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Empty body");
return ESP_FAIL;
}
buf[received] = '\0';
cJSON *body = cJSON_Parse(buf);
if (!body)
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON");
return ESP_FAIL;
}
cJSON *id_item = cJSON_GetObjectItem(body, "id");
if (!cJSON_IsNumber(id_item))
{
cJSON_Delete(body);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing 'id'");
return ESP_FAIL;
}
task_t *task = find_task((uint16)id_item->valueint);
if (!task)
{
cJSON_Delete(body);
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Task not found");
return ESP_FAIL;
}
// Update fields if present
cJSON *title_item = cJSON_GetObjectItem(body, "title");
if (cJSON_IsString(title_item))
{
strlcpy(task->title, title_item->valuestring, sizeof(task->title));
}
cJSON *due_date_item = cJSON_GetObjectItem(body, "due_date");
if (cJSON_IsNumber(due_date_item))
{
task->due_date = (int64)due_date_item->valuedouble;
}
cJSON *completed_item = cJSON_GetObjectItem(body, "completed");
if (cJSON_IsBool(completed_item))
{
task->completed = cJSON_IsTrue(completed_item);
}
cJSON_Delete(body);
httpd_resp_sendstr(req, "{\"status\":\"ok\"}");
return ESP_OK;
}
internal const httpd_uri_t api_tasks_update_uri = {.uri = "/api/tasks/update",
.method = HTTP_POST,
.handler =
api_tasks_update_handler,
.user_ctx = NULL};

View File

@@ -0,0 +1,67 @@
// POST /api/users — Create a new user
// Body: {"name": "Alice"}
#include "cJSON.h"
#include "esp_http_server.h"
#include "types.hpp"
#include "user.hpp"
internal esp_err_t api_users_post_handler(httpd_req_t *req)
{
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
char buf[128];
int received = httpd_req_recv(req, buf, sizeof(buf) - 1);
if (received <= 0)
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Empty body");
return ESP_FAIL;
}
buf[received] = '\0';
cJSON *body = cJSON_Parse(buf);
if (!body)
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON");
return ESP_FAIL;
}
cJSON *name_item = cJSON_GetObjectItem(body, "name");
if (!cJSON_IsString(name_item) || strlen(name_item->valuestring) == 0)
{
cJSON_Delete(body);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing 'name'");
return ESP_FAIL;
}
user_t *user = add_user(name_item->valuestring);
cJSON_Delete(body);
if (!user)
{
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"User limit reached");
return ESP_FAIL;
}
cJSON *resp = cJSON_CreateObject();
cJSON_AddNumberToObject(resp, "id", user->id);
cJSON_AddStringToObject(resp, "name", user->name);
const char *json = cJSON_PrintUnformatted(resp);
httpd_resp_sendstr(req, json);
free((void *)json);
cJSON_Delete(resp);
return ESP_OK;
}
internal const httpd_uri_t api_users_post_uri = {.uri = "/api/users",
.method = HTTP_POST,
.handler =
api_users_post_handler,
.user_ctx = NULL};

View File

@@ -0,0 +1,41 @@
// GET /api/users — List all active users
#include "cJSON.h"
#include "esp_http_server.h"
#include "types.hpp"
#include "user.hpp"
internal esp_err_t api_users_get_handler(httpd_req_t *req)
{
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
cJSON *arr = cJSON_CreateArray();
for (int i = 0; i < MAX_USERS; i++)
{
if (g_Users[i].active)
{
cJSON *obj = cJSON_CreateObject();
cJSON_AddNumberToObject(obj, "id", g_Users[i].id);
cJSON_AddStringToObject(obj, "name", g_Users[i].name);
cJSON_AddItemToArray(arr, obj);
}
}
const char *json = cJSON_PrintUnformatted(arr);
httpd_resp_sendstr(req, json);
free((void *)json);
cJSON_Delete(arr);
return ESP_OK;
}
internal const httpd_uri_t api_users_get_uri = {.uri = "/api/users",
.method = HTTP_GET,
.handler =
api_users_get_handler,
.user_ctx = NULL};

View File

@@ -0,0 +1,47 @@
// DELETE /api/users?id=N — Delete a user and cascade-delete their tasks
#include "esp_http_server.h"
#include "api/tasks/store.hpp"
#include "api/users/store.hpp"
#include "types.hpp"
internal esp_err_t api_users_delete_handler(httpd_req_t *req)
{
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
char query[32] = {};
if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK)
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing query param 'id'");
return ESP_FAIL;
}
char id_str[8] = {};
if (httpd_query_key_value(query, "id", id_str, sizeof(id_str)) != ESP_OK)
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing query param 'id'");
return ESP_FAIL;
}
uint8 id = (uint8)atoi(id_str);
// Cascade: remove all tasks belonging to this user
remove_tasks_for_user(id);
if (!remove_user(id))
{
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "User not found");
return ESP_FAIL;
}
httpd_resp_sendstr(req, "{\"status\":\"ok\"}");
return ESP_OK;
}
internal const httpd_uri_t api_users_delete_uri = {.uri = "/api/users",
.method = HTTP_DELETE,
.handler =
api_users_delete_handler,
.user_ctx = NULL};

View File

@@ -0,0 +1,56 @@
// User data store: CRUD helpers and seed data
#include "api/users/store.hpp"
// Find a user by ID, returns nullptr if not found
internal user_t *find_user(uint8 id)
{
for (int i = 0; i < MAX_USERS; i++)
{
if (g_Users[i].active && g_Users[i].id == id)
{
return &g_Users[i];
}
}
return nullptr;
}
// Add a user, returns pointer to new user or nullptr if full
internal user_t *add_user(const char *name)
{
for (int i = 0; i < MAX_USERS; i++)
{
if (!g_Users[i].active)
{
g_Users[i].id = g_NextUserId++;
strlcpy(g_Users[i].name, name, sizeof(g_Users[i].name));
g_Users[i].active = true;
return &g_Users[i];
}
}
return nullptr;
}
// Remove a user by ID, returns true if found and removed
internal bool remove_user(uint8 id)
{
for (int i = 0; i < MAX_USERS; i++)
{
if (g_Users[i].active && g_Users[i].id == id)
{
g_Users[i].active = false;
g_Users[i].id = 0;
g_Users[i].name[0] = '\0';
return true;
}
}
return false;
}
// Populate dummy users on boot for development iteration
internal void seed_users()
{
add_user("Alice");
add_user("Bob");
add_user("Charlie");
}

View File

@@ -0,0 +1,11 @@
#pragma once
#include "types.hpp"
#include "user.hpp"
// Data store operations for users
internal user_t *find_user(uint8 id);
internal user_t *add_user(const char *name);
internal bool remove_user(uint8 id);
internal void seed_users();

View File

@@ -0,0 +1,6 @@
// clang-format off
#include "api/users/store.cpp"
#include "api/users/list.cpp"
#include "api/users/add.cpp"
#include "api/users/remove.cpp"
// clang-format on

View File

@@ -19,6 +19,8 @@
#include "api/ota/status.cpp"
#include "api/system/info.cpp"
#include "api/system/reboot.cpp"
#include "api/tasks/unity.cpp"
#include "api/users/unity.cpp"
internal const char *TAG = "HTTP_SERVER";
@@ -170,7 +172,8 @@ internal esp_err_t static_file_handler(httpd_req_t *req)
internal esp_err_t cors_options_handler(httpd_req_t *req)
{
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
httpd_resp_set_hdr(req, "Access-Control-Allow-Methods", "GET, POST, OPTIONS");
httpd_resp_set_hdr(req, "Access-Control-Allow-Methods",
"GET, POST, DELETE, OPTIONS");
httpd_resp_set_hdr(req, "Access-Control-Allow-Headers", "Content-Type");
httpd_resp_set_status(req, "204 No Content");
httpd_resp_send(req, NULL, 0);
@@ -216,7 +219,7 @@ internal httpd_handle_t start_webserver(void)
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.uri_match_fn = httpd_uri_match_wildcard;
config.max_uri_handlers = 10; // We have info, reboot, options, and static
config.max_uri_handlers = 20;
httpd_handle_t server = NULL;
ESP_LOGI(TAG, "Starting HTTP Server on port: '%d'", config.server_port);
@@ -238,6 +241,20 @@ internal httpd_handle_t start_webserver(void)
httpd_register_uri_handler(server, &api_ota_firmware_uri);
httpd_register_uri_handler(server, &api_ota_bundle_uri);
// Register todo list API routes
httpd_register_uri_handler(server, &api_users_get_uri);
httpd_register_uri_handler(server, &api_users_post_uri);
httpd_register_uri_handler(server, &api_users_delete_uri);
httpd_register_uri_handler(server, &api_tasks_upcoming_uri);
httpd_register_uri_handler(server, &api_tasks_get_uri);
httpd_register_uri_handler(server, &api_tasks_post_uri);
httpd_register_uri_handler(server, &api_tasks_update_uri);
httpd_register_uri_handler(server, &api_tasks_delete_uri);
// Populate dummy data for development
seed_users();
seed_tasks();
#ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES
// Register static file handler last as a catch-all wildcard if deployed
httpd_uri_t static_get_uri = {.uri = "/*",

View File

@@ -4,6 +4,7 @@
// SDK
#include "esp_log.h"
#include "esp_ota_ops.h"
#include "esp_psram.h"
#include "esp_system.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
@@ -28,6 +29,8 @@ extern "C" void app_main()
{
printf("Hello, Calendink OTA! [V1.1]\n");
printf("PSRAM size: %d bytes\n", esp_psram_get_size());
httpd_handle_t web_server = NULL;
esp_err_t err = nvs_flash_init();
@@ -41,25 +44,23 @@ extern "C" void app_main()
nvs_handle_t my_handle;
if (nvs_open("storage", NVS_READWRITE, &my_handle) == ESP_OK)
{
// If we are running from the factory partition, force the www partition to
// 0 This ensures that after a USB flash (which only writes to www_0), we
// aren't stuck looking at an old www_1.
const esp_partition_t *running = esp_ota_get_running_partition();
if (running && running->subtype == ESP_PARTITION_SUBTYPE_APP_FACTORY)
// Read active www partition from NVS
err = nvs_get_u8(my_handle, "www_part", &g_Active_WWW_Partition);
if (err == ESP_ERR_NVS_NOT_FOUND)
{
printf(
"Running from factory: resetting www_part to 0 for consistency.\n");
// First boot (no NVS key yet): default to www_0
// This ensures that after a fresh USB flash (which only writes www_0),
// we start from the correct partition.
printf("No www_part in NVS, defaulting to 0.\n");
g_Active_WWW_Partition = 0;
nvs_set_u8(my_handle, "www_part", 0);
nvs_commit(my_handle);
}
else
else if (err != ESP_OK)
{
err = nvs_get_u8(my_handle, "www_part", &g_Active_WWW_Partition);
if (err != ESP_OK && err != ESP_ERR_NVS_NOT_FOUND)
{
printf("Error reading www_part from NVS: %s\n", esp_err_to_name(err));
}
printf("Error reading www_part from NVS: %s\n", esp_err_to_name(err));
g_Active_WWW_Partition = 0;
}
if (g_Active_WWW_Partition > 1)

20
Provider/main/todo.hpp Normal file
View File

@@ -0,0 +1,20 @@
#pragma once
#include <cstring>
#include "types.hpp"
#include "user.hpp"
struct task_t
{
uint16 id; // Auto-assigned (165535, 0 = empty slot)
uint8 user_id; // Owner (matches user_t.id)
char title[64]; // Task description
int64 due_date; // Unix timestamp (seconds)
bool completed; // Done flag
bool active; // Slot in use
};
constexpr int MAX_TASKS = 32;
internal task_t g_Tasks[MAX_TASKS] = {};
internal uint16 g_NextTaskId = 1;

16
Provider/main/user.hpp Normal file
View File

@@ -0,0 +1,16 @@
#pragma once
#include <cstring>
#include "types.hpp"
struct user_t
{
uint8 id; // Auto-assigned (1255, 0 = empty slot)
char name[32]; // Display name
bool active; // Slot in use
};
constexpr int MAX_USERS = 8;
internal user_t g_Users[MAX_USERS] = {};
internal uint8 g_NextUserId = 1;

195
Provider/tdd/todo_list.md Normal file
View File

@@ -0,0 +1,195 @@
# Todo List System for ESP32-S3 Provider
**Authored by Antigravity (Claude Opus)**
**Date:** 2026-03-07
---
## 1. Goal
Add a user-managed todo list system to the Calendink Provider. The system must:
- Allow creating, modifying, and deleting **users** via a REST API.
- Allow creating, modifying, and deleting **tasks** per user, each with a due date.
- Display the **top 3 upcoming tasks** per user on the Dashboard home page.
- Introduce a **collapsible sidebar** to navigate between Dashboard and a detailed Task Manager view.
- Store everything **in RAM only** — all data is lost on reboot. Persistent storage (SQLite on SD card) is planned as a future phase.
- Pre-populate **seed data** on boot so the UI is immediately usable during development without manual setup after every reboot.
## 2. Chosen Approach
### 2.1. Backend: Static Arrays in BSS
Users and tasks are stored as **fixed-size static arrays** at file scope (BSS segment). This means:
- No `malloc` / `free` — zero fragmentation risk on a constrained device.
- Deterministic memory footprint: the arrays consume a fixed amount of RAM regardless of usage.
- Simple slot-based allocation: each entry has an `active` flag; adding an item finds the first inactive slot, deleting clears the flag.
Limits: `MAX_USERS = 8`, `MAX_TASKS = 32` (total across all users). At ~80 bytes per task and ~40 bytes per user, this uses <3KB of RAM — negligible against the ~247KB free heap the system typically has. These limits will become irrelevant when we migrate to SQLite.
### 2.2. Frontend: Sidebar + View Routing
The existing single-page dashboard is extended with a collapsible left sidebar. The `App.svelte` component gains a view state (`'dashboard' | 'tasks'`) and conditionally renders either the existing Dashboard content or a new `TodoManager` component. This avoids introducing a full client-side router for what is currently a two-view app.
### 2.3. API Style: RESTful with DELETE
We use standard REST conventions: `GET` for reads, `POST` for creates/updates, `DELETE` for removals. This requires updating the existing CORS handler to allow the `DELETE` method. We chose REST purity over the simpler "POST everything" approach because the API surface is small enough that the CORS overhead is negligible, and it sets the right pattern for future endpoint growth.
## 3. Design Decisions & Trade-offs
### 3.1. Why Static Arrays Over Heap Allocation?
| | Static Arrays (BSS) | Dynamic (malloc/vector) |
|---|---|---|
| **Fragmentation** | ✅ Impossible | ⚠️ Risk on long-running device |
| **Memory footprint** | Fixed, predictable | Grows/shrinks at runtime |
| **Complexity** | Trivial — array index + active flag | Requires careful lifetime management |
| **Scalability** | Hard limit (32 tasks) | Flexible |
| **Migration path** | Replace array access with SQLite queries | Same |
For a temporary in-memory store that will be replaced by SQLite, static arrays are the simpler and safer choice. The hard limits are acceptable given the short-lived nature of this storage layer.
### 3.2. Why a Dedicated `/api/tasks/upcoming` Endpoint?
The Dashboard needs to show the top 3 tasks per user, sorted by due date. Two options were considered:
1. **Client-side**: Fetch all tasks for all users, sort and slice on the frontend.
2. **Server-side**: Dedicated endpoint that returns pre-sorted, pre-sliced data.
We chose option 2 because:
- It reduces the number of API calls (1 instead of N per user).
- Sorting and filtering on the ESP32 is trivial for 32 items.
- The response payload is smaller — only the data the Dashboard actually needs.
### 3.3. Seed Data Strategy
Since all data is lost on reboot, manually re-creating users and tasks after every firmware flash would slow down development. Both `user.hpp` and `todo.hpp` expose `seed_users()` / `seed_tasks()` functions called at server startup. The seed tasks use **relative time offsets** from the current system time (e.g., "now + 1 day", "now + 3 days"), so due dates always appear realistic regardless of when the device boots.
### 3.4. Frontend Navigation: Sidebar vs. Tabs vs. Router
| | Sidebar | Tabs | Client-side Router |
|---|---|---|---|
| **Scalability** | ✅ Easily add more views | Limited horizontal space | Best for many routes |
| **Discoverability** | Always visible (or icon-only when collapsed) | Always visible | Requires URL navigation |
| **Complexity** | Low — simple boolean state | Low | Higher — routing library needed |
| **Mobile** | Collapsible — works well | Good | Good |
A sidebar is the best fit: it scales to future views (Calendar, Settings, etc.), gives a clear navigation model, and the collapsible behavior keeps it unobtrusive on smaller screens.
### 3.5. `max_uri_handlers` Increase
The ESP-IDF HTTP server has a compile-time limit on registered URI handlers (default: 8, currently set to 10). Adding 7 new endpoints (3 user + 4 task) brings the total to ~17. We increase the limit to 20 to accommodate the new routes with room for growth. Each handler slot costs minimal memory (~40 bytes).
## 4. Architecture
### 4.1. Data Models
```
user.hpp todo.hpp
┌─────────────────────┐ ┌──────────────────────────┐
│ user_t │ │ task_t │
│ id: uint8 │◄─────── │ user_id: uint8 │
│ name: char[32] │ │ id: uint16 │
│ active: bool │ │ title: char[64] │
└─────────────────────┘ │ due_date: int64 (epoch)│
g_Users[MAX_USERS=8] │ completed: bool │
│ active: bool │
└──────────────────────────┘
g_Tasks[MAX_TASKS=32]
```
### 4.2. API Endpoints
| Method | URI | Purpose |
|--------|-----|---------|
| `GET` | `/api/users` | List all active users |
| `POST` | `/api/users` | Create a user |
| `DELETE` | `/api/users?id=N` | Delete a user + cascade-delete their tasks |
| `GET` | `/api/tasks?user_id=N` | List tasks for a user |
| `GET` | `/api/tasks/upcoming` | Top 3 upcoming tasks per user (Dashboard) |
| `POST` | `/api/tasks` | Create a task |
| `POST` | `/api/tasks/update` | Modify a task (title, due date, completion) |
| `DELETE` | `/api/tasks?id=N` | Delete a task |
### 4.3. Backend File Structure
```
Provider/main/
├── user.hpp # user_t struct, g_Users[] (data only)
├── todo.hpp # task_t struct, g_Tasks[] (data only)
├── api/
│ ├── users/
│ │ ├── store.hpp # Forward declarations for user operations
│ │ ├── store.cpp # find_user, add_user, remove_user, seed_users
│ │ ├── list.cpp # GET /api/users
│ │ ├── add.cpp # POST /api/users
│ │ ├── remove.cpp # DELETE /api/users
│ │ └── unity.cpp # Includes all users/*.cpp
│ ├── tasks/
│ │ ├── store.hpp # Forward declarations for task operations
│ │ ├── store.cpp # find_task, add_task, remove_task, sort, seed_tasks
│ │ ├── list.cpp # GET /api/tasks?user_id=N
│ │ ├── upcoming.cpp # GET /api/tasks/upcoming
│ │ ├── add.cpp # POST /api/tasks
│ │ ├── update.cpp # POST /api/tasks/update
│ │ ├── remove.cpp # DELETE /api/tasks
│ │ └── unity.cpp # Includes all tasks/*.cpp
│ ├── system/
│ │ ├── info.cpp
│ │ └── reboot.cpp
│ └── ota/
│ └── ...
├── http_server.cpp # includes tasks/unity.cpp + users/unity.cpp
└── main.cpp # Entry point (unchanged)
```
### 4.4. Frontend Component Structure
```
frontend/src/
├── App.svelte # Layout with Sidebar + view routing
├── lib/
│ ├── Sidebar.svelte # Collapsible left nav
│ ├── UserManager.svelte # User selection & management
│ ├── TaskManager.svelte # Full task management UI (embeds UserManager)
│ ├── OTAUpdate.svelte # Existing OTA component
│ └── api.js # +user/task API functions
└── app.css # +sidebar theme tokens
```
## 5. Changelog
### 2026-03-07 — API Refactoring & UI Improvements
**Backend Refactoring:**
- Split monolithic `manage.cpp` and `utils.cpp` into single-responsibility files per endpoint (e.g., `list.cpp`, `add.cpp`, `remove.cpp`)
- Renamed `seed.cpp``store.cpp` in both `api/users/` and `api/tasks/` (better name for the data-store layer)
- Introduced `store.hpp` forward-declaration headers to resolve cross-domain dependencies (tasks needs `find_user`, users/remove needs `remove_tasks_for_user`)
- Added `unity.cpp` per domain to simplify `http_server.cpp` includes
- `.hpp` files contain structs and constants only; all functions live in `.cpp` files
**Frontend OTA Bug Fix:**
- Fixed `main.cpp` factory-partition check that unconditionally reset `www_part` to 0 on every boot, preventing OTA from ever switching to `www_1`
- Now only defaults to `www_0` on first boot (NVS key not found); OTA-written values are respected
**Date/Time Picker UX:**
- Fixed timezone drift bug: `formatDateForInput` used `toISOString()` (UTC) but `datetime-local` inputs use local time, causing ±N hours shift on each edit
- Split `datetime-local` into separate `date` + `time` inputs with labels
- Default date/time set to "now" when opening the add task form
- CSS trick to make Chrome/Edge open the picker on click anywhere in the input (`::-webkit-calendar-picker-indicator` stretched to fill)
**Dashboard Layout:**
- Moved "Upcoming Tasks" section above the system info grid (content-first, debug tools lower)
**Sidebar:**
- Auto-collapses on mobile (viewport ≤ 768px) via `window.innerWidth` check at init
**Build Warnings Fixed:**
- `<button>` inside `<button>` nesting in UserManager → changed user chips to `<div role="button">`
- `autofocus` a11y warnings suppressed with `<!-- svelte-ignore -->` comments
## 6. Summary
We implement a **temporary in-memory todo list** using **static BSS arrays** on the ESP32 backend, exposed via a **RESTful API** with 8 new endpoints. The frontend gains a **collapsible sidebar** for navigation between the existing Dashboard and a new **Task Manager** view. **Seed data** is populated on every boot for fast development iteration. The architecture is designed to make the eventual migration to **SQLite on SD card** straightforward — the API contracts and frontend components remain unchanged; only the storage layer swaps out.
---
*Created by Antigravity (Claude Opus) - Last Updated: 2026-03-07*